UNPKG

appcenter-cli

Version:

Command line tool for Visual Studio App Center

533 lines (532 loc) 27.7 kB
"use strict"; var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; Object.defineProperty(exports, "__esModule", { value: true }); exports.getReactNativeVersion = exports.isValidPlatform = exports.isValidOS = exports.getiOSHermesEnabled = exports.getAndroidHermesEnabled = exports.runHermesEmitBinaryCommand = exports.runReactNativeBundleCommand = exports.getReactNativeProjectAppVersion = void 0; const fs = require("fs"); const path = require("path"); const xml2js = require("xml2js"); const interaction_1 = require("../../../util/interaction"); const validation_utils_1 = require("./validation-utils"); const file_utils_1 = require("./file-utils"); const semver_1 = require("semver"); const chalk = require("chalk"); const xcode = require("xcode"); const plist = require("plist"); const g2js = require("gradle-to-js/lib/parser"); const properties = require("properties"); const childProcess = require("child_process"); const fs_helper_1 = require("../../../util/misc/fs-helper"); function getReactNativeProjectAppVersion(versionSearchParams, projectRoot) { return __awaiter(this, void 0, void 0, function* () { projectRoot = projectRoot || process.cwd(); // eslint-disable-next-line security/detect-non-literal-require const projectPackageJson = require(path.join(projectRoot, "package.json")); const projectName = projectPackageJson.name; const fileExists = (file) => { try { return fs.statSync(file).isFile(); } catch (e) { return false; } }; interaction_1.out.text(chalk.cyan(`Detecting ${versionSearchParams.os} app version:\n`)); if (versionSearchParams.os === "ios") { let resolvedPlistFile = versionSearchParams.plistFile; if (resolvedPlistFile) { // If a plist file path is explicitly provided, then we don't // need to attempt to "resolve" it within the well-known locations. if (!fileExists(resolvedPlistFile)) { throw new Error("The specified plist file doesn't exist. Please check that the provided path is correct."); } } else { // Allow the plist prefix to be specified with or without a trailing // separator character, but prescribe the use of a hyphen when omitted, // since this is the most commonly used convetion for plist files. if (versionSearchParams.plistFilePrefix && /.+[^-.]$/.test(versionSearchParams.plistFilePrefix)) { versionSearchParams.plistFilePrefix += "-"; } const iOSDirectory = "ios"; const plistFileName = `${versionSearchParams.plistFilePrefix || ""}Info.plist`; const knownLocations = [path.join(iOSDirectory, projectName, plistFileName), path.join(iOSDirectory, plistFileName)]; resolvedPlistFile = knownLocations.find(fileExists); if (!resolvedPlistFile) { throw new Error(`Unable to find either of the following plist files in order to infer your app's binary version: "${knownLocations.join('", "')}". If your plist has a different name, or is located in a different directory, consider using either the "--plist-file" or "--plist-file-prefix" parameters to help inform the CLI how to find it.`); } } const plistContents = fs.readFileSync(resolvedPlistFile).toString(); let parsedPlist; try { parsedPlist = plist.parse(plistContents); } catch (e) { throw new Error(`Unable to parse "${resolvedPlistFile}". Please ensure it is a well-formed plist file.`); } if (parsedPlist && parsedPlist.CFBundleShortVersionString) { if (validation_utils_1.isValidVersion(parsedPlist.CFBundleShortVersionString)) { interaction_1.out.text(`Using the target binary version value "${parsedPlist.CFBundleShortVersionString}" from "${resolvedPlistFile}".\n`); return Promise.resolve(parsedPlist.CFBundleShortVersionString); } else { if (parsedPlist.CFBundleShortVersionString !== "$(MARKETING_VERSION)") { throw new Error(`The "CFBundleShortVersionString" key in the "${resolvedPlistFile}" file needs to specify a valid semver string, containing both a major and minor version (e.g. 1.3.2, 1.1).`); } const pbxprojFileName = "project.pbxproj"; let resolvedPbxprojFile = versionSearchParams.projectFile; if (resolvedPbxprojFile) { // If a plist file path is explicitly provided, then we don't // need to attempt to "resolve" it within the well-known locations. if (!resolvedPbxprojFile.endsWith(pbxprojFileName)) { // Specify path to pbxproj file if the provided file path is an Xcode project file. resolvedPbxprojFile = path.join(resolvedPbxprojFile, pbxprojFileName); } if (!fileExists(resolvedPbxprojFile)) { throw new Error("The specified pbx project file doesn't exist. Please check that the provided path is correct."); } } else { const iOSDirectory = "ios"; const xcodeprojDirectory = `${projectName}.xcodeproj`; const pbxprojKnownLocations = [ path.join(iOSDirectory, xcodeprojDirectory, pbxprojFileName), path.join(iOSDirectory, pbxprojFileName), ]; resolvedPbxprojFile = pbxprojKnownLocations.find(fileExists); if (!resolvedPbxprojFile) { throw new Error(`Unable to find either of the following pbxproj files in order to infer your app's binary version: "${pbxprojKnownLocations.join('", "')}".`); } } const xcodeProj = xcode.project(resolvedPbxprojFile).parseSync(); const marketingVersion = xcodeProj.getBuildProperty("MARKETING_VERSION", versionSearchParams.buildConfigurationName, versionSearchParams.xcodeTargetName); if (!validation_utils_1.isValidVersion(marketingVersion)) { throw new Error(`The "MARKETING_VERSION" key in the "${resolvedPbxprojFile}" file needs to specify a valid semver string, containing both a major and minor version (e.g. 1.3.2, 1.1).`); } interaction_1.out.text(`Using the target binary version value "${marketingVersion}" from "${resolvedPbxprojFile}".\n`); return Promise.resolve(marketingVersion); } } else { throw new Error(`The "CFBundleShortVersionString" key doesn't exist within the "${resolvedPlistFile}" file.`); } } else if (versionSearchParams.os === "android") { let buildGradlePath = path.join("android", "app"); if (versionSearchParams.gradleFile) { buildGradlePath = versionSearchParams.gradleFile; } if (fs.lstatSync(buildGradlePath).isDirectory()) { buildGradlePath = path.join(buildGradlePath, "build.gradle"); } if (file_utils_1.fileDoesNotExistOrIsDirectory(buildGradlePath)) { throw new Error(`Unable to find gradle file "${buildGradlePath}".`); } return g2js .parseFile(buildGradlePath) .catch(() => { throw new Error(`Unable to parse the "${buildGradlePath}" file. Please ensure it is a well-formed Gradle file.`); }) .then((buildGradle) => { let versionName = null; // First 'if' statement was implemented as workaround for case // when 'build.gradle' file contains several 'android' nodes. // In this case 'buildGradle.android' prop represents array instead of object // due to parsing issue in 'g2js.parseFile' method. if (buildGradle.android instanceof Array) { for (let i = 0; i < buildGradle.android.length; i++) { const gradlePart = buildGradle.android[i]; if (gradlePart.defaultConfig && gradlePart.defaultConfig.versionName) { versionName = gradlePart.defaultConfig.versionName; break; } } } else if (buildGradle.android && buildGradle.android.defaultConfig && buildGradle.android.defaultConfig.versionName) { versionName = buildGradle.android.defaultConfig.versionName; } else { throw new Error(`The "${buildGradlePath}" file doesn't specify a value for the "android.defaultConfig.versionName" property.`); } if (typeof versionName !== "string") { throw new Error(`The "android.defaultConfig.versionName" property value in "${buildGradlePath}" is not a valid string. If this is expected, consider using the --target-binary-version option to specify the value manually.`); } let appVersion = versionName.replace(/"/g, "").trim(); if (validation_utils_1.isValidVersion(appVersion)) { // The versionName property is a valid semver string, // so we can safely use that and move on. interaction_1.out.text(`Using the target binary version value "${appVersion}" from "${buildGradlePath}".\n`); return appVersion; } // The version property isn't a valid semver string // so we assume it is a reference to a property variable. const propertyName = appVersion.replace("project.", ""); const propertiesFileName = "gradle.properties"; const knownLocations = [path.join("android", "app", propertiesFileName), path.join("android", propertiesFileName)]; // Search for gradle properties across all `gradle.properties` files let propertiesFile = null; for (let i = 0; i < knownLocations.length; i++) { propertiesFile = knownLocations[i]; if (fileExists(propertiesFile)) { const propertiesContent = fs.readFileSync(propertiesFile).toString(); try { const parsedProperties = properties.parse(propertiesContent); appVersion = parsedProperties[propertyName]; if (appVersion) { break; } } catch (e) { throw new Error(`Unable to parse "${propertiesFile}". Please ensure it is a well-formed properties file.`); } } } if (!appVersion) { throw new Error(`No property named "${propertyName}" exists in the "${propertiesFile}" file.`); } if (!validation_utils_1.isValidVersion(appVersion)) { throw new Error(`The "${propertyName}" property in the "${propertiesFile}" file needs to specify a valid semver string, containing both a major and minor version (e.g. 1.3.2, 1.1).`); } interaction_1.out.text(`Using the target binary version value "${appVersion}" from the "${propertyName}" key in the "${propertiesFile}" file.\n`); return appVersion.toString(); }); } else { const appxManifestFileName = "Package.appxmanifest"; let appxManifestContents; let appxManifestContainingFolder; try { appxManifestContainingFolder = path.join("windows", projectName); appxManifestContents = fs.readFileSync(path.join(appxManifestContainingFolder, appxManifestFileName)).toString(); } catch (err) { throw new Error(`Unable to find or read "${appxManifestFileName}" in the "${path.join("windows", projectName)}" folder.`); } return new Promise((resolve, reject) => { xml2js.parseString(appxManifestContents, (err, parsedAppxManifest) => { if (err) { reject(new Error(`Unable to parse the "${path.join(appxManifestContainingFolder, appxManifestFileName)}" file, it could be malformed.`)); return; } try { const appVersion = parsedAppxManifest.Package.Identity[0]["$"].Version.match(/^\d+\.\d+\.\d+/)[0]; interaction_1.out.text(`Using the target binary version value "${appVersion}" from the "Identity" key in the "${appxManifestFileName}" file.\n`); return resolve(appVersion); } catch (e) { reject(new Error(`Unable to parse the package version from the "${path.join(appxManifestContainingFolder, appxManifestFileName)}" file.`)); return; } }); }); } }); } exports.getReactNativeProjectAppVersion = getReactNativeProjectAppVersion; function runReactNativeBundleCommand(bundleName, development, entryFile, outputFolder, platform, sourcemapOutput, extraBundlerOptions) { const reactNativeBundleArgs = []; const envNodeArgs = process.env.CODE_PUSH_NODE_ARGS; if (typeof envNodeArgs !== "undefined") { Array.prototype.push.apply(reactNativeBundleArgs, envNodeArgs.trim().split(/\s+/)); } Array.prototype.push.apply(reactNativeBundleArgs, [ getCliPath(), "bundle", "--assets-dest", outputFolder, "--bundle-output", path.join(outputFolder, bundleName), "--dev", development, "--entry-file", entryFile, "--platform", platform, ...extraBundlerOptions, ]); if (sourcemapOutput) { reactNativeBundleArgs.push("--sourcemap-output", sourcemapOutput); } interaction_1.out.text(chalk.cyan('Running "react-native bundle" command:\n')); const reactNativeBundleProcess = childProcess.spawn("node", reactNativeBundleArgs); interaction_1.out.text(`node ${reactNativeBundleArgs.join(" ")}`); return new Promise((resolve, reject) => { reactNativeBundleProcess.stdout.on("data", (data) => { interaction_1.out.text(data.toString().trim()); }); reactNativeBundleProcess.stderr.on("data", (data) => { console.error(data.toString().trim()); }); reactNativeBundleProcess.on("close", (exitCode, signal) => { if (exitCode !== 0) { reject(new Error(`"react-native bundle" command failed (exitCode=${exitCode}, signal=${signal}).`)); } resolve(null); }); }); } exports.runReactNativeBundleCommand = runReactNativeBundleCommand; function runHermesEmitBinaryCommand(bundleName, outputFolder, sourcemapOutput, extraHermesFlags, gradleFile) { return __awaiter(this, void 0, void 0, function* () { const hermesArgs = []; const envNodeArgs = process.env.CODE_PUSH_NODE_ARGS; if (typeof envNodeArgs !== "undefined") { Array.prototype.push.apply(hermesArgs, envNodeArgs.trim().split(/\s+/)); } Array.prototype.push.apply(hermesArgs, [ "-emit-binary", "-out", path.join(outputFolder, bundleName + ".hbc"), path.join(outputFolder, bundleName), ...extraHermesFlags, ]); if (sourcemapOutput) { hermesArgs.push("-output-source-map"); } if (!interaction_1.isDebug()) { hermesArgs.push("-w"); } interaction_1.out.text(chalk.cyan("Converting JS bundle to byte code via Hermes, running command:\n")); const hermesCommand = yield getHermesCommand(gradleFile); const hermesProcess = childProcess.spawn(hermesCommand, hermesArgs); interaction_1.out.text(`${hermesCommand} ${hermesArgs.join(" ")}`); return new Promise((resolve, reject) => { hermesProcess.stdout.on("data", (data) => { interaction_1.out.text(data.toString().trim()); }); hermesProcess.stderr.on("data", (data) => { console.error(data.toString().trim()); }); hermesProcess.on("close", (exitCode, signal) => { if (exitCode !== 0) { reject(new Error(`"hermes" command failed (exitCode=${exitCode}, signal=${signal}).`)); } // Copy HBC bundle to overwrite JS bundle const source = path.join(outputFolder, bundleName + ".hbc"); const destination = path.join(outputFolder, bundleName); fs.copyFile(source, destination, (err) => { if (err) { console.error(err); reject(new Error(`Copying file ${source} to ${destination} failed. "hermes" previously exited with code ${exitCode}.`)); } fs.unlink(source, (err) => { if (err) { console.error(err); reject(err); } resolve(null); }); }); }); }).then(() => { if (!sourcemapOutput) { // skip source map compose if source map is not enabled return; } const composeSourceMapsPath = getComposeSourceMapsPath(); if (!composeSourceMapsPath) { throw new Error("react-native compose-source-maps.js scripts is not found"); } const jsCompilerSourceMapFile = path.join(outputFolder, bundleName + ".hbc" + ".map"); if (!fs.existsSync(jsCompilerSourceMapFile)) { throw new Error(`sourcemap file ${jsCompilerSourceMapFile} is not found`); } return new Promise((resolve, reject) => { const composeSourceMapsArgs = [composeSourceMapsPath, sourcemapOutput, jsCompilerSourceMapFile, "-o", sourcemapOutput]; // https://github.com/facebook/react-native/blob/master/react.gradle#L211 // https://github.com/facebook/react-native/blob/master/scripts/react-native-xcode.sh#L178 // packager.sourcemap.map + hbc.sourcemap.map = sourcemap.map const composeSourceMapsProcess = childProcess.spawn("node", composeSourceMapsArgs); interaction_1.out.text(`${composeSourceMapsPath} ${composeSourceMapsArgs.join(" ")}`); composeSourceMapsProcess.stdout.on("data", (data) => { interaction_1.out.text(data.toString().trim()); }); composeSourceMapsProcess.stderr.on("data", (data) => { console.error(data.toString().trim()); }); composeSourceMapsProcess.on("close", (exitCode, signal) => { if (exitCode !== 0) { reject(new Error(`"compose-source-maps" command failed (exitCode=${exitCode}, signal=${signal}).`)); } // Delete the HBC sourceMap, otherwise it will be included in 'code-push' bundle as well fs.unlink(jsCompilerSourceMapFile, (err) => { if (err) { console.error(err); reject(err); } resolve(null); }); }); }); }); }); } exports.runHermesEmitBinaryCommand = runHermesEmitBinaryCommand; function parseBuildGradleFile(gradleFile) { let buildGradlePath = path.join("android", "app"); if (gradleFile) { buildGradlePath = gradleFile; } if (fs.lstatSync(buildGradlePath).isDirectory()) { buildGradlePath = path.join(buildGradlePath, "build.gradle"); } if (file_utils_1.fileDoesNotExistOrIsDirectory(buildGradlePath)) { throw new Error(`Unable to find gradle file "${buildGradlePath}".`); } return g2js.parseFile(buildGradlePath).catch(() => { throw new Error(`Unable to parse the "${buildGradlePath}" file. Please ensure it is a well-formed Gradle file.`); }); } function getHermesCommandFromGradle(gradleFile) { return __awaiter(this, void 0, void 0, function* () { const buildGradle = yield parseBuildGradleFile(gradleFile); const hermesCommandProperty = Array.from(buildGradle["project.ext.react"] || []).find((prop) => prop.trim().startsWith("hermesCommand:")); if (hermesCommandProperty) { return hermesCommandProperty.replace("hermesCommand:", "").trim().slice(1, -1); } else { return ""; } }); } function getAndroidHermesEnabled(gradleFile) { return parseBuildGradleFile(gradleFile).then((buildGradle) => { return Array.from(buildGradle["project.ext.react"] || []).some((line) => /^enableHermes\s{0,}:\s{0,}true/.test(line)); }); } exports.getAndroidHermesEnabled = getAndroidHermesEnabled; function getiOSHermesEnabled(podFile) { let podPath = path.join("ios", "Podfile"); if (podFile) { podPath = podFile; } if (file_utils_1.fileDoesNotExistOrIsDirectory(podPath)) { throw new Error(`Unable to find Podfile file "${podPath}".`); } try { const podFileContents = fs.readFileSync(podPath).toString(); return /([^#\n]*:?hermes_enabled(\s+|\n+)?(=>|:)(\s+|\n+)?true)/.test(podFileContents); } catch (error) { throw error; } } exports.getiOSHermesEnabled = getiOSHermesEnabled; function getHermesOSBin() { switch (process.platform) { case "win32": return "win64-bin"; case "darwin": return "osx-bin"; case "freebsd": case "linux": case "sunos": default: return "linux64-bin"; } } function getHermesOSExe() { const react63orAbove = semver_1.compare(semver_1.coerce(getReactNativeVersion()).version, "0.63.0") !== -1; const hermesExecutableName = react63orAbove ? "hermesc" : "hermes"; switch (process.platform) { case "win32": return hermesExecutableName + ".exe"; default: return hermesExecutableName; } } function getHermesCommand(gradleFile) { return __awaiter(this, void 0, void 0, function* () { const fileExists = (file) => { try { return fs.statSync(file).isFile(); } catch (e) { return false; } }; // Hermes is bundled with react-native since 0.69 const bundledHermesEngine = path.join(getReactNativePackagePath(), "sdks", "hermesc", getHermesOSBin(), getHermesOSExe()); if (fileExists(bundledHermesEngine)) { return bundledHermesEngine; } const gradleHermesCommand = yield getHermesCommandFromGradle(gradleFile); if (gradleHermesCommand) { return path.join("android", "app", gradleHermesCommand.replace("%OS-BIN%", getHermesOSBin())); } else { // assume if hermes-engine exists it should be used instead of hermesvm const hermesEngine = path.join("node_modules", "hermes-engine", getHermesOSBin(), getHermesOSExe()); if (fileExists(hermesEngine)) { return hermesEngine; } return path.join("node_modules", "hermesvm", getHermesOSBin(), "hermes"); } }); } function getComposeSourceMapsPath() { // detect if compose-source-maps.js script exists const composeSourceMaps = path.join(getReactNativePackagePath(), "scripts", "compose-source-maps.js"); if (fs.existsSync(composeSourceMaps)) { return composeSourceMaps; } return null; } function getCliPath() { if (process.platform === "win32") { const reactNativeVersion = semver_1.coerce(getReactNativeVersion()).version; const isVersion75OrAbove = semver_1.compare(reactNativeVersion, "0.75.0") >= 0; return isVersion75OrAbove ? path.join(getReactNativePackagePath(), "cli.js") : path.join(getReactNativePackagePath(), "local-cli", "cli.js"); } return path.join("node_modules", ".bin", "react-native"); } function getReactNativePackagePath() { const result = childProcess.spawnSync("node", ["--print", "require.resolve('react-native/package.json')"]); const packagePath = path.dirname(result.stdout.toString()); if (result.status === 0 && fs_helper_1.directoryExistsSync(packagePath)) { return packagePath; } return path.join("node_modules", "react-native"); } function isValidOS(os) { switch (os.toLowerCase()) { case "android": case "ios": case "windows": return true; default: return false; } } exports.isValidOS = isValidOS; function isValidPlatform(platform) { return platform.toLowerCase() === "react-native"; } exports.isValidPlatform = isValidPlatform; function getReactNativeVersion() { let packageJsonFilename; let projectPackageJson; try { packageJsonFilename = path.join(process.cwd(), "package.json"); projectPackageJson = JSON.parse(fs.readFileSync(packageJsonFilename, "utf-8")); } 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.`); } const projectName = projectPackageJson.name; if (!projectName) { throw new Error(`The "package.json" file in the CWD does not have the "name" field set.`); } return ((projectPackageJson.dependencies && projectPackageJson.dependencies["react-native"]) || (projectPackageJson.devDependencies && projectPackageJson.devDependencies["react-native"])); } exports.getReactNativeVersion = getReactNativeVersion;