@joakimbeng/react-native-version
Version:
Version your React Native or Expo app
462 lines (397 loc) • 11.2 kB
JavaScript
;
const child = require('child_process');
const fs = require('fs');
const path = require('path');
const flattenDeep = require('lodash.flattendeep');
const dottie = require('dottie');
const detectIndent = require('detect-indent');
const {html: beautify} = require('js-beautify');
const plist = require('plist');
const pSettle = require('p-settle');
const resolveFrom = require('resolve-from');
const semver = require('semver');
const stripIndents = require('common-tags/lib/stripIndents');
const unique = require('lodash.uniq');
const {Xcode} = require('pbxproj-dom/xcode');
const {list, log} = require('./util');
/**
* Custom type definition for Promises
* @typedef Promise
* @property {*} result See the implementing function for the resolve type and description
* @property {Error} result Rejection error object
*/
const env = {
target: process.env.RNV && list(process.env.RNV)
};
/**
* Returns default values for some options, namely android/ios file/folder paths
* @private
* @return {Object} Defaults
*/
function getDefaults() {
return {
android: 'android/app/build.gradle',
ios: 'ios',
incrementBuild: true,
neverIncrementBuild: false,
amend: true,
neverAmend: false
};
}
/**
* Returns Info.plist filenames
* @private
* @param {Xcode} xcode Opened Xcode project file
* @return {Array} Plist filenames
*/
function getPlistFilenames(xcode) {
return unique(
flattenDeep(
xcode.document.projects.map(project => {
return project.targets.filter(Boolean).map(target => {
return target.buildConfigurationsList.buildConfigurations.map(config => {
return config.ast.value.get('buildSettings').get('INFOPLIST_FILE').text;
});
});
})
)
);
}
/**
* Determines whether the project is an Expo app or a plain React Native app
* @private
* @return {Boolean} true if the project is an Expo app
*/
function isExpoProject(projPath) {
try {
const module = resolveFrom(projPath, 'expo');
const appInfo = require(`${projPath}/app.json`);
return Boolean(module && appInfo.expo);
} catch (err) {
return false;
}
}
/**
* Versions your app
* @param {Object} program commander/CLI-style options, camelCased
* @param {string} projectPath Path to your React Native project
* @return {Promise<string|Error>} A promise which resolves with the last commit hash
*/
function version(program, projectPath) {
const prog = {...getDefaults(), ...(program || {})};
const projPath = path.resolve(process.cwd(), projectPath || prog.args[0] || '');
const programOpts = {
...prog,
android: path.join(projPath, prog.android),
ios: path.join(projPath, prog.ios)
};
const targets = [].concat(programOpts.target, env.target).filter(Boolean);
const amend = programOpts.amend && !programOpts.neverAmend;
const incrementBuild = programOpts.incrementBuild && !programOpts.neverIncrementBuild;
let appPkg;
try {
resolveFrom(projPath, 'react-native');
appPkg = require(path.join(projPath, 'package.json'));
} catch (err) {
if (err.message === "Cannot find module 'react-native'") {
log({
style: 'red',
text: `Is this the right folder? ${err.message} in ${projPath}`
});
} else {
log({
style: 'red',
text: err.message
});
log({
style: 'red',
text: "Is this the right folder? Looks like there isn't a package.json here"
});
}
log({
style: 'yellow',
text: 'Pass the project path as an argument, see --help for usage'
});
if (program.outputHelp) {
program.outputHelp();
}
process.exitCode = 1;
return;
}
let appJSON;
const appJSONPath = path.join(projPath, 'app.json');
const isExpoApp = isExpoProject(projPath);
if (isExpoApp) {
log({text: 'Expo detected'}, programOpts.quiet);
}
try {
appJSON = require(appJSONPath);
if (isExpoApp) {
appJSON = {
...appJSON,
expo: {...appJSON.expo, version: appPkg.version}
};
}
} catch (err) {}
let android;
let ios;
if (targets.length === 0 || targets.indexOf('android') > -1) {
android = new Promise((resolve, reject) => {
log({text: 'Versioning Android...'}, programOpts.quiet);
let gradleFile;
try {
gradleFile = fs.readFileSync(programOpts.android, 'utf8');
} catch (err) {
if (!isExpoApp) {
reject([
{
style: 'red',
text: 'No gradle file found at ' + programOpts.android
},
{
style: 'yellow',
text: 'Use the "--android" option to specify the path manually'
}
]);
return;
}
}
if (!isExpoApp) {
gradleFile = gradleFile.replace(
/versionName (["'])(.*)["']/,
'versionName $1' + appPkg.version + '$1'
);
}
if (incrementBuild) {
if (isExpoApp) {
const versionCode = dottie.get(appJSON, 'expo.android.versionCode');
appJSON = {
...appJSON,
expo: {
...appJSON.expo,
android: {
...appJSON.expo.android,
versionCode: programOpts.setBuild
? programOpts.setBuild
: versionCode
? versionCode + 1
: 1
}
}
};
} else {
gradleFile = gradleFile.replace(/versionCode (\d+)/, (match, cg1) => {
const newVersionCodeNumber = programOpts.setBuild
? programOpts.setBuild
: parseInt(cg1, 10) + 1;
return 'versionCode ' + newVersionCodeNumber;
});
}
}
if (isExpoApp) {
fs.writeFileSync(appJSONPath, JSON.stringify(appJSON, null, 2));
} else {
fs.writeFileSync(programOpts.android, gradleFile);
}
log({text: 'Android updated'}, programOpts.quiet);
resolve();
});
}
if (targets.length === 0 || targets.indexOf('ios') > -1) {
ios = new Promise(resolve => {
log({text: 'Versioning iOS...'}, programOpts.quiet);
if (isExpoApp) {
if (incrementBuild) {
const buildNumber = dottie.get(appJSON, 'expo.ios.buildNumber');
appJSON = {
...appJSON,
expo: {
...appJSON.expo,
ios: {
...appJSON.expo.ios,
buildNumber: programOpts.setBuild
? programOpts.setBuild.toString()
: buildNumber && !programOpts.resetBuild
? `${parseInt(buildNumber, 10) + 1}`
: '1'
}
}
};
}
fs.writeFileSync(appJSONPath, JSON.stringify(appJSON, null, 2));
} else {
// Find any folder ending in .xcodeproj
const xcodeProjects = fs
.readdirSync(programOpts.ios)
.filter(file => /\.xcodeproj$/i.test(file));
if (xcodeProjects.length === 0) {
throw new Error(`Xcode project not found in "${programOpts.ios}"`);
}
const projectFolder = path.join(programOpts.ios, xcodeProjects[0]);
const xcode = Xcode.open(path.join(projectFolder, 'project.pbxproj'));
const plistFilenames = getPlistFilenames(xcode);
xcode.document.projects.forEach(project => {
project.targets.filter(Boolean).forEach(target => {
if (!incrementBuild) {
return;
}
target.buildConfigurationsList.buildConfigurations.forEach(config => {
if (target.name === appPkg.name) {
let CURRENT_PROJECT_VERSION = 1;
if (!programOpts.resetBuild) {
CURRENT_PROJECT_VERSION =
parseInt(
config.ast.value.get('buildSettings').get('CURRENT_PROJECT_VERSION').text,
10
) + 1;
}
if (programOpts.setBuild) {
CURRENT_PROJECT_VERSION = programOpts.setBuild;
}
config.patch({
buildSettings: {
CURRENT_PROJECT_VERSION
}
});
}
});
});
const plistFiles = plistFilenames.map(filename => {
return fs.readFileSync(path.join(programOpts.ios, filename), 'utf8');
});
const parsedPlistFiles = plistFiles.map(file => {
return plist.parse(file);
});
parsedPlistFiles.forEach((json, index) => {
fs.writeFileSync(
path.join(programOpts.ios, plistFilenames[index]),
plist.build({
...json,
CFBundleShortVersionString: appPkg.version,
...(incrementBuild
? {
CFBundleVersion: `${
programOpts.resetBuild ? 1 : parseInt(json.CFBundleVersion, 10) + 1
}`,
...(programOpts.setBuild
? {
CFBundleVersion: programOpts.setBuild.toString()
}
: {})
}
: {})
})
);
});
plistFilenames.forEach((filename, index) => {
/* eslint-disable camelcase */
const indent = detectIndent(plistFiles[index]);
fs.writeFileSync(
path.join(programOpts.ios, filename),
stripIndents`
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">` +
'\n' +
beautify(
fs
.readFileSync(path.join(programOpts.ios, filename), 'utf8')
.match(/<dict>[\s\S]*<\/dict>/)[0],
{
end_with_newline: true,
...(indent.type === 'tab'
? {indent_with_tabs: true}
: {indent_size: indent.amount})
}
) +
stripIndents`
</plist>` +
'\n'
);
/* eslint-enable camelcase */
});
});
xcode.save();
}
log({text: 'iOS updated'}, programOpts.quiet);
resolve();
});
}
return pSettle([android, ios].filter(Boolean))
.then(result => {
const errs = result
.filter(item => {
return item.isRejected;
})
.map(item => {
return item.reason;
});
if (errs.length !== 0) {
errs
.reduce((a, b) => a.concat(b), [])
.forEach(err => {
if (program.outputHelp) {
log({style: 'red', text: err.toString(), ...err}, programOpts.quiet);
}
});
if (program.outputHelp) {
program.outputHelp();
return;
}
throw errs.map(errGrp => errGrp.map(err => err.text).join(', ')).join('; ');
}
const gitCmdOpts = {
cwd: projPath
};
if (amend) {
let latestTag = child
.execSync('git describe --exact-match HEAD', gitCmdOpts)
.toString()
.trim();
if (!semver.valid(latestTag)) {
latestTag = null;
}
log({text: 'Amending...'}, programOpts.quiet);
switch (process.env.npm_lifecycle_event) {
case 'version':
child.spawnSync(
'git',
['add'].concat(isExpoApp ? appJSONPath : [programOpts.android, programOpts.ios]),
gitCmdOpts
);
break;
case 'postversion':
default:
child.execSync('git commit -a --amend --no-edit', gitCmdOpts);
if (!programOpts.skipTag && latestTag) {
log({text: 'Adjusting Git tag...'}, programOpts.quiet);
child.execSync(`git tag -af ${latestTag} -m ${latestTag}`, gitCmdOpts);
}
}
}
log(
{
style: 'green',
text: 'Done'
},
programOpts.quiet
);
return child.execSync('git log -1 --pretty=%H', gitCmdOpts).toString();
})
.catch(err => {
if (process.env.RNV_ENV === 'ava') {
console.error(err);
}
log({
style: 'red',
text: 'Done, with errors.'
});
process.exitCode = 1;
});
}
module.exports = {
getDefaults,
getPlistFilenames,
isExpoProject,
version
};