microsoft/vscode-react-native
Publicmirrored from https://github.com/microsoft/vscode-react-nativeAvailable
lib/codepush-node-sdk/src/react-native/react-native-utils.ts
360lines · modecode
| 1 | import * as fs from 'fs'; |
| 2 | import * as path from 'path'; |
| 3 | import chalk from 'chalk'; |
| 4 | import * as xml2js from 'xml2js'; |
| 5 | import * as mkdirp from 'mkdirp'; |
| 6 | const plist = require('plist'); |
| 7 | const g2js = require('gradle-to-js/lib/parser'); |
| 8 | const properties = require('properties'); |
| 9 | const childProcess = require('child_process'); |
| 10 | import { isValidVersion } from '../utils/validation-utils'; |
| 11 | import * as fileUtils from '../utils/file-utils'; |
| 12 | |
| 13 | export var spawn = childProcess.spawn; |
| 14 | |
| 15 | export interface VersionSearchParams { |
| 16 | os: string; // ios or android |
| 17 | plistFile: string; |
| 18 | plistFilePrefix: string; |
| 19 | gradleFile: string; |
| 20 | } |
| 21 | |
| 22 | export async function getAndroidAppVersion(projectRoot?: string, gradleFile?: string): Promise<string> { |
| 23 | projectRoot = projectRoot || process.cwd(); |
| 24 | const projectPackageJson: any = require(path.join(projectRoot, 'package.json')); |
| 25 | const projectName: string = projectPackageJson.name; |
| 26 | |
| 27 | console.log(chalk.cyan(`Detecting "Android" app version:\n`)); |
| 28 | |
| 29 | let buildGradlePath: string = path.join(projectRoot, 'android', 'app'); |
| 30 | if (gradleFile) { |
| 31 | buildGradlePath = gradleFile; |
| 32 | } |
| 33 | if (fs.lstatSync(buildGradlePath).isDirectory()) { |
| 34 | buildGradlePath = path.join(buildGradlePath, 'build.gradle'); |
| 35 | } |
| 36 | |
| 37 | if (fileUtils.fileDoesNotExistOrIsDirectory(buildGradlePath)) { |
| 38 | throw new Error(`Unable to find gradle file "${buildGradlePath}".`); |
| 39 | } |
| 40 | |
| 41 | return g2js.parseFile(buildGradlePath) |
| 42 | .catch(() => { |
| 43 | throw new Error(`Unable to parse the "${buildGradlePath}" file. Please ensure it is a well-formed Gradle file.`); |
| 44 | }) |
| 45 | .then((buildGradle: any) => { |
| 46 | let versionName: string = null; |
| 47 | |
| 48 | // First 'if' statement was implemented as workaround for case |
| 49 | // when 'build.gradle' file contains several 'android' nodes. |
| 50 | // In this case 'buildGradle.android' prop represents array instead of object |
| 51 | // due to parsing issue in 'g2js.parseFile' method. |
| 52 | if (buildGradle.android instanceof Array) { |
| 53 | for (let i = 0; i < buildGradle.android.length; i++) { |
| 54 | const gradlePart = buildGradle.android[i]; |
| 55 | if (gradlePart.defaultConfig && gradlePart.defaultConfig.versionName) { |
| 56 | versionName = gradlePart.defaultConfig.versionName; |
| 57 | break; |
| 58 | } |
| 59 | } |
| 60 | } else if (buildGradle.android && buildGradle.android.defaultConfig && buildGradle.android.defaultConfig.versionName) { |
| 61 | versionName = buildGradle.android.defaultConfig.versionName; |
| 62 | } else { |
| 63 | throw new Error(`The "${buildGradlePath}" file doesn't specify a value for the "android.defaultConfig.versionName" property.`); |
| 64 | } |
| 65 | |
| 66 | if (typeof versionName !== 'string') { |
| 67 | 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.`); |
| 68 | } |
| 69 | |
| 70 | let appVersion: string = versionName.replace(/"/g, '').trim(); |
| 71 | |
| 72 | if (isValidVersion(appVersion)) { |
| 73 | // The versionName property is a valid semver string, |
| 74 | // so we can safely use that and move on. |
| 75 | console.log(`Using the target binary version value "${appVersion}" from "${buildGradlePath}".\n`); |
| 76 | return appVersion; |
| 77 | } |
| 78 | |
| 79 | // The version property isn't a valid semver string |
| 80 | // so we assume it is a reference to a property variable. |
| 81 | const propertyName = appVersion.replace('project.', ''); |
| 82 | const propertiesFileName = 'gradle.properties'; |
| 83 | |
| 84 | const knownLocations = [ |
| 85 | path.join(projectRoot, 'android', 'app', propertiesFileName), |
| 86 | path.join(projectRoot, 'android', propertiesFileName) |
| 87 | ]; |
| 88 | |
| 89 | // Search for gradle properties across all `gradle.properties` files |
| 90 | let propertiesFile: string = null; |
| 91 | for (let i = 0; i < knownLocations.length; i++) { |
| 92 | propertiesFile = knownLocations[i]; |
| 93 | if (fileUtils.fileExists(propertiesFile)) { |
| 94 | const propertiesContent: string = fs.readFileSync(propertiesFile).toString(); |
| 95 | try { |
| 96 | const parsedProperties: any = properties.parse(propertiesContent); |
| 97 | appVersion = parsedProperties[propertyName]; |
| 98 | if (appVersion) { |
| 99 | break; |
| 100 | } |
| 101 | } catch (e) { |
| 102 | throw new Error(`Unable to parse "${propertiesFile}". Please ensure it is a well-formed properties file.`); |
| 103 | } |
| 104 | } |
| 105 | } |
| 106 | |
| 107 | if (!appVersion) { |
| 108 | throw new Error(`No property named "${propertyName}" exists in the "${propertiesFile}" file.`); |
| 109 | } |
| 110 | |
| 111 | if (!isValidVersion(appVersion)) { |
| 112 | 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).`); |
| 113 | } |
| 114 | |
| 115 | console.log(`Using the target binary version value "${appVersion}" from the "${propertyName}" key in the "${propertiesFile}" file.\n`); |
| 116 | return appVersion.toString(); |
| 117 | }); |
| 118 | } |
| 119 | |
| 120 | export async function getiOSAppVersion(projectRoot?: string, plistFilePrefix?: string, plistFile?: string): Promise<string> { |
| 121 | projectRoot = projectRoot || process.cwd(); |
| 122 | const projectPackageJson: any = require(path.join(projectRoot, 'package.json')); |
| 123 | const projectName: string = projectPackageJson.name; |
| 124 | |
| 125 | console.log(chalk.cyan(`Detecting "iOS" app version:\n`)); |
| 126 | |
| 127 | let resolvedPlistFile: string = plistFile; |
| 128 | if (resolvedPlistFile) { |
| 129 | // If a plist file path is explicitly provided, then we don't |
| 130 | // need to attempt to "resolve" it within the well-known locations. |
| 131 | if (!fileUtils.fileExists(resolvedPlistFile)) { |
| 132 | throw new Error(`The specified plist file doesn't exist. Please check that the provided path is correct.`); |
| 133 | } |
| 134 | } else { |
| 135 | // Allow the plist prefix to be specified with or without a trailing |
| 136 | // separator character, but prescribe the use of a hyphen when omitted, |
| 137 | // since this is the most commonly used convetion for plist files. |
| 138 | if (plistFilePrefix && /.+[^-.]$/.test(plistFilePrefix)) { |
| 139 | plistFilePrefix += '-'; |
| 140 | } |
| 141 | |
| 142 | const iOSDirectory: string = 'ios'; |
| 143 | const plistFileName = `${plistFilePrefix || ''}Info.plist`; |
| 144 | |
| 145 | const knownLocations = [ |
| 146 | path.join(projectRoot, iOSDirectory, projectName, plistFileName), |
| 147 | path.join(projectRoot, iOSDirectory, plistFileName) |
| 148 | ]; |
| 149 | |
| 150 | resolvedPlistFile = (<any>knownLocations).find(fileUtils.fileExists); |
| 151 | |
| 152 | if (!resolvedPlistFile) { |
| 153 | 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.`); |
| 154 | } |
| 155 | } |
| 156 | |
| 157 | const plistContents = fs.readFileSync(resolvedPlistFile).toString(); |
| 158 | |
| 159 | let parsedPlist: any; |
| 160 | try { |
| 161 | parsedPlist = plist.parse(plistContents); |
| 162 | } catch (e) { |
| 163 | throw new Error(`Unable to parse "${resolvedPlistFile}". Please ensure it is a well-formed plist file.`); |
| 164 | } |
| 165 | |
| 166 | if (parsedPlist && parsedPlist.CFBundleShortVersionString) { |
| 167 | if (isValidVersion(parsedPlist.CFBundleShortVersionString)) { |
| 168 | console.log(`Using the target binary version value "${parsedPlist.CFBundleShortVersionString}" from "${resolvedPlistFile}".\n`); |
| 169 | return Promise.resolve(parsedPlist.CFBundleShortVersionString); |
| 170 | } else { |
| 171 | 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).`); |
| 172 | } |
| 173 | } else { |
| 174 | throw new Error(`The "CFBundleShortVersionString" key doesn't exist within the "${resolvedPlistFile}" file.`); |
| 175 | } |
| 176 | } |
| 177 | |
| 178 | export async function getWindowsAppVersion(projectRoot?: string): Promise<string> { |
| 179 | projectRoot = projectRoot || process.cwd(); |
| 180 | const projectPackageJson: any = require(path.join(projectRoot, 'package.json')); |
| 181 | const projectName: string = projectPackageJson.name; |
| 182 | |
| 183 | console.log(chalk.cyan(`Detecting "Windows" app version:\n`)); |
| 184 | |
| 185 | const appxManifestFileName: string = 'Package.appxmanifest'; |
| 186 | let appxManifestContainingFolder: string; |
| 187 | let appxManifestContents: string; |
| 188 | try { |
| 189 | appxManifestContainingFolder = path.join(projectRoot, 'windows', projectName); |
| 190 | appxManifestContents = fs.readFileSync(path.join(appxManifestContainingFolder, appxManifestFileName)).toString(); |
| 191 | } catch (err) { |
| 192 | throw new Error(`Unable to find or read "${appxManifestFileName}" in the "${path.join('windows', projectName)}" folder.`); |
| 193 | } |
| 194 | return new Promise<string>((resolve, reject) => { |
| 195 | xml2js.parseString(appxManifestContents, (err: Error, parsedAppxManifest: any) => { |
| 196 | if (err) { |
| 197 | reject(new Error(`Unable to parse the "${path.join(appxManifestContainingFolder, appxManifestFileName)}" file, it could be malformed.`)); |
| 198 | return; |
| 199 | } |
| 200 | try { |
| 201 | const appVersion: string = parsedAppxManifest.Package.Identity[0]['$'].Version.match(/^\d+\.\d+\.\d+/)[0]; |
| 202 | console.log(`Using the target binary version value "${appVersion}" from the "Identity" key in the "${appxManifestFileName}" file.\n`); |
| 203 | return resolve(appVersion); |
| 204 | } catch (e) { |
| 205 | reject(new Error(`Unable to parse the package version from the "${path.join(appxManifestContainingFolder, appxManifestFileName)}" file.`)); |
| 206 | return; |
| 207 | } |
| 208 | }); |
| 209 | }); |
| 210 | } |
| 211 | |
| 212 | export function runReactNativeBundleCommand(projectRootPath: string, bundleName: string, development: boolean, entryFile: string, outputFolder: string, platform: string, sourcemapOutput: string): Promise<void> { |
| 213 | const reactNativeBundleArgs: string[] = []; |
| 214 | const envNodeArgs: string = process.env.CODE_PUSH_NODE_ARGS; |
| 215 | |
| 216 | if (typeof envNodeArgs !== 'undefined') { |
| 217 | Array.prototype.push.apply(reactNativeBundleArgs, envNodeArgs.trim().split(/\s+/)); |
| 218 | } |
| 219 | |
| 220 | Array.prototype.push.apply(reactNativeBundleArgs, [ |
| 221 | path.join(projectRootPath, 'node_modules', 'react-native', 'local-cli', 'cli.js'), 'bundle', |
| 222 | '--assets-dest', outputFolder, |
| 223 | '--bundle-output', path.join(outputFolder, bundleName), |
| 224 | '--dev', development, |
| 225 | '--entry-file', entryFile, |
| 226 | '--platform', platform, |
| 227 | ]); |
| 228 | |
| 229 | if (sourcemapOutput) { |
| 230 | reactNativeBundleArgs.push('--sourcemap-output', sourcemapOutput); |
| 231 | } |
| 232 | |
| 233 | console.log(chalk.cyan(`Running "react-native bundle" command:\n`)); |
| 234 | const reactNativeBundleProcess = spawn('node', reactNativeBundleArgs); |
| 235 | console.log(`node ${reactNativeBundleArgs.join(' ')}`); |
| 236 | |
| 237 | return new Promise<void>((resolve, reject) => { |
| 238 | reactNativeBundleProcess.stdout.on('data', (data: Buffer) => { |
| 239 | console.log(data.toString().trim()); |
| 240 | }); |
| 241 | |
| 242 | reactNativeBundleProcess.stderr.on('data', (data: Buffer) => { |
| 243 | console.error(data.toString().trim()); |
| 244 | }); |
| 245 | |
| 246 | reactNativeBundleProcess.on('close', (exitCode: number) => { |
| 247 | if (exitCode) { |
| 248 | reject(new Error(`"react-native bundle" command exited with code ${exitCode}.`)); |
| 249 | } |
| 250 | |
| 251 | resolve(<void>null); |
| 252 | }); |
| 253 | }); |
| 254 | } |
| 255 | |
| 256 | export function isValidOS(os: string): boolean { |
| 257 | switch (os.toLowerCase()) { |
| 258 | case 'android': |
| 259 | case 'ios': |
| 260 | case 'windows': |
| 261 | return true; |
| 262 | default: |
| 263 | return false; |
| 264 | } |
| 265 | } |
| 266 | |
| 267 | export function isValidPlatform(platform: string): boolean { |
| 268 | return platform.toLowerCase() === 'react-native'; |
| 269 | } |
| 270 | |
| 271 | export function isReactNativeProject(): boolean { |
| 272 | try { |
| 273 | const projectPackageJson: any = require(path.join(process.cwd(), 'package.json')); |
| 274 | const projectName: string = projectPackageJson.name; |
| 275 | if (!projectName) { |
| 276 | throw new Error(`The "package.json" file in the CWD does not have the "name" field set.`); |
| 277 | } |
| 278 | |
| 279 | return projectPackageJson.dependencies['react-native'] || (projectPackageJson.devDependencies && projectPackageJson.devDependencies['react-native']); |
| 280 | } catch (error) { |
| 281 | 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.`); |
| 282 | } |
| 283 | } |
| 284 | |
| 285 | export function getDefaultBundleName(os: string): string { |
| 286 | if (!isValidOS(os)) { |
| 287 | throw new Error(`Platform must be either "ios" or "android".`); |
| 288 | } |
| 289 | |
| 290 | return os === 'ios' |
| 291 | ? 'main.jsbundle' |
| 292 | : `index.${os}.bundle`; |
| 293 | } |
| 294 | |
| 295 | export function getDefautEntryFilePath(os: string, projectDir?: string): string { |
| 296 | if (!isValidOS(os)) { |
| 297 | throw new Error(`Platform must be either "ios" or "android".`); |
| 298 | } |
| 299 | |
| 300 | if (!projectDir) { |
| 301 | projectDir = process.cwd(); |
| 302 | } |
| 303 | |
| 304 | let entryFilePath: string = path.join(projectDir, `index.${os}.js`); |
| 305 | if (fileUtils.fileDoesNotExistOrIsDirectory(path.join(projectDir, entryFilePath))) { |
| 306 | entryFilePath = path.join(projectDir, 'index.js'); |
| 307 | } |
| 308 | |
| 309 | if (fileUtils.fileDoesNotExistOrIsDirectory(entryFilePath)) { |
| 310 | throw new Error(`Entry file "index.${os}.js" or "index.js" does not exist.`); |
| 311 | } |
| 312 | |
| 313 | return entryFilePath; |
| 314 | } |
| 315 | |
| 316 | export class BundleConfig { |
| 317 | os: string; |
| 318 | projectRootPath: string; |
| 319 | outputDir?: string; |
| 320 | entryFilePath?: string; |
| 321 | bundleName?: string; |
| 322 | development?: boolean; |
| 323 | sourcemapOutput?: string; |
| 324 | } |
| 325 | |
| 326 | export async function makeUpdateContents(bundleConfig: BundleConfig): Promise<string> { |
| 327 | if (!isValidOS(bundleConfig.os)) { |
| 328 | throw new Error(`Platform must be either "ios" or "android".`); |
| 329 | } |
| 330 | |
| 331 | if (!bundleConfig.projectRootPath) { |
| 332 | bundleConfig.projectRootPath = process.cwd(); |
| 333 | } |
| 334 | |
| 335 | let updateContentsPath: string; |
| 336 | updateContentsPath = bundleConfig.outputDir || await fileUtils.mkTempDir('code-push'); |
| 337 | |
| 338 | // we have to add "CodePush" root folder to make update contents file structure |
| 339 | // to be compatible with React Native client SDK |
| 340 | updateContentsPath = path.join(updateContentsPath, 'CodePush'); |
| 341 | mkdirp.sync(updateContentsPath); |
| 342 | |
| 343 | if (!bundleConfig.bundleName) { |
| 344 | bundleConfig.bundleName = getDefaultBundleName(bundleConfig.os); |
| 345 | } |
| 346 | |
| 347 | if (!bundleConfig.entryFilePath) { |
| 348 | bundleConfig.entryFilePath = getDefautEntryFilePath(bundleConfig.os, bundleConfig.projectRootPath); |
| 349 | } |
| 350 | |
| 351 | if (bundleConfig.outputDir) { |
| 352 | bundleConfig.sourcemapOutput = path.join(updateContentsPath, bundleConfig.bundleName + '.map'); |
| 353 | } |
| 354 | |
| 355 | fileUtils.createEmptyTmpReleaseFolder(updateContentsPath); |
| 356 | fileUtils.removeReactTmpDir(); |
| 357 | await runReactNativeBundleCommand(bundleConfig.projectRootPath, bundleConfig.bundleName, bundleConfig.development, bundleConfig.entryFilePath, updateContentsPath, bundleConfig.os, bundleConfig.sourcemapOutput); |
| 358 | |
| 359 | return updateContentsPath; |
| 360 | } |
| 361 | |