George Kalpakas 7eb3e3bce6 build: fix build scripts on macOS (#33854)
In #33823, `scripts/package-builds.sh` (which is used by both
`build-packages-dist.sh` and `build-ivy-npm-packages.sh`) was updated to
use `realpath`. It turns out that `realpath` does not exist on macOS, so
the build scripts do not work there.

In order to fix this (and also reduce the likelihood of introducing
similar issues in the future), this commit changes these bash scripts to
Node.js scripts (using [ShellJS](https://github.com/shelljs/shelljs) for
a cross-platform implementation of Unix shell commands where necessary).

PR Close #33854
2019-11-15 16:05:00 -08:00

379 lines
15 KiB
JavaScript

'use strict';
const chalk = require('chalk');
const fs = require('fs-extra');
const lockfile = require('@yarnpkg/lockfile');
const path = require('canonical-path');
const semver = require('semver');
const shelljs = require('shelljs');
const yargs = require('yargs');
const PACKAGE_JSON = 'package.json';
const YARN_LOCK = 'yarn.lock';
const LOCAL_MARKER_PATH = 'node_modules/_local_.json';
const PACKAGE_JSON_REGEX = /^[^/]+\/package\.json$/;
const ANGULAR_ROOT_DIR = path.resolve(__dirname, '../../..');
const ANGULAR_DIST_PACKAGES = path.join(ANGULAR_ROOT_DIR, 'dist/packages-dist');
const ANGULAR_DIST_PACKAGES_BUILD_SCRIPT = path.join(ANGULAR_ROOT_DIR, 'scripts/build-packages-dist.js');
const ANGULAR_DIST_PACKAGES_BUILD_CMD = `"${process.execPath}" "${ANGULAR_DIST_PACKAGES_BUILD_SCRIPT}"`;
/**
* A tool that can install Angular dependencies for a project from NPM or from the
* locally built distributables.
*
* This tool is used to change dependencies of the `aio` application and the example
* applications.
*/
class NgPackagesInstaller {
/**
* Create a new installer for a project in the specified directory.
*
* @param {string} projectDir - the path to the directory containing the project.
* @param {object} options - a hash of options for the install:
* * `debug` (`boolean`) - whether to display debug messages.
* * `force` (`boolean`) - whether to force a local installation even if there is a local marker file.
* * `buildPackages` (`boolean`) - whether to build the local Angular packages before using them.
* (NOTE: Building the packages is currently not supported on Windows, so a message is printed instead.)
* * `ignorePackages` (`string[]`) - a collection of names of packages that should not be copied over.
*/
constructor(projectDir, options = {}) {
this.debug = this._parseBooleanArg(options.debug);
this.force = this._parseBooleanArg(options.force);
this.buildPackages = this._parseBooleanArg(options.buildPackages);
this.ignorePackages = options.ignorePackages || [];
this.projectDir = path.resolve(projectDir);
this.localMarkerPath = path.resolve(this.projectDir, LOCAL_MARKER_PATH);
this._log('Project directory:', this.projectDir);
}
// Public methods
/**
* Check whether the dependencies have been overridden with locally built
* Angular packages. This is done by checking for the `_local_.json` marker file.
* This will emit a warning to the console if the dependencies have been overridden.
*/
checkDependencies() {
if (this._checkLocalMarker()) {
this._printWarning();
}
}
/**
* Install locally built Angular dependencies, overriding the dependencies in the package.json
* This will also write a "marker" file (`_local_.json`), which contains the overridden package.json
* contents and acts as an indicator that dependencies have been overridden.
*/
installLocalDependencies() {
if (this.force || !this._checkLocalMarker()) {
const pathToPackageConfig = path.resolve(this.projectDir, PACKAGE_JSON);
const pathToLockfile = path.resolve(this.projectDir, YARN_LOCK);
const parsedLockfile = this._parseLockfile(pathToLockfile);
const packages = this._getDistPackages();
try {
// Overwrite local Angular packages dependencies to other Angular packages with local files.
Object.keys(packages).forEach(key => {
const pkg = packages[key];
const tmpConfig = JSON.parse(JSON.stringify(pkg.config));
// Prevent accidental publishing of the package, if something goes wrong.
tmpConfig.private = true;
// Overwrite project dependencies/devDependencies to Angular packages with local files.
['dependencies', 'devDependencies'].forEach(prop => {
const deps = tmpConfig[prop] || {};
Object.keys(deps).forEach(key2 => {
const pkg2 = packages[key2];
if (pkg2) {
// point the core Angular packages at the distributable folder
deps[key2] = `file:${pkg2.parentDir}/${key2.replace('@angular/', '')}`;
this._log(`Overriding dependency of local ${key} with local package: ${key2}: ${deps[key2]}`);
}
});
});
fs.writeFileSync(pkg.packageJsonPath, JSON.stringify(tmpConfig, null, 2));
});
const packageConfigFile = fs.readFileSync(pathToPackageConfig, 'utf8');
const packageConfig = JSON.parse(packageConfigFile);
const [dependencies, peers] = this._collectDependencies(packageConfig.dependencies || {}, packages);
const [devDependencies, devPeers] = this._collectDependencies(packageConfig.devDependencies || {}, packages);
this._assignPeerDependencies(peers, dependencies, devDependencies, parsedLockfile);
this._assignPeerDependencies(devPeers, dependencies, devDependencies, parsedLockfile);
const localPackageConfig = Object.assign(Object.create(null), packageConfig, { dependencies, devDependencies });
localPackageConfig.__angular = { local: true };
const localPackageConfigJson = JSON.stringify(localPackageConfig, null, 2);
try {
this._log(`Writing temporary local ${PACKAGE_JSON} to ${pathToPackageConfig}`);
fs.writeFileSync(pathToPackageConfig, localPackageConfigJson);
this._installDeps('--pure-lockfile', '--check-files');
this._setLocalMarker(localPackageConfigJson);
} finally {
this._log(`Restoring original ${PACKAGE_JSON} to ${pathToPackageConfig}`);
fs.writeFileSync(pathToPackageConfig, packageConfigFile);
}
} finally {
// Restore local Angular packages dependencies to other Angular packages.
this._log(`Restoring original ${PACKAGE_JSON} for local Angular packages.`);
Object.keys(packages).forEach(key => {
const pkg = packages[key];
fs.writeFileSync(pkg.packageJsonPath, JSON.stringify(pkg.config, null, 2));
});
}
}
}
/**
* Reinstall the original package.json depdendencies
* Yarn will also delete the local marker file for us.
*/
restoreNpmDependencies() {
this._installDeps('--frozen-lockfile', '--check-files');
}
// Protected helpers
_assignPeerDependencies(peerDependencies, dependencies, devDependencies, parsedLockfile) {
Object.keys(peerDependencies).forEach(key => {
const peerDepRange = peerDependencies[key];
// Ignore peerDependencies whose range is already satisfied by current version in lockfile.
const originalRange = dependencies[key] || devDependencies[key];
const lockfileVersion = originalRange && parsedLockfile[`${key}@${originalRange}`].version;
if (lockfileVersion && semver.satisfies(lockfileVersion, peerDepRange)) return;
// If there is already an equivalent dependency then override it - otherwise assign/override the devDependency
if (dependencies[key]) {
this._log(`Overriding dependency with peerDependency: ${key}: ${peerDepRange}`);
dependencies[key] = peerDepRange;
} else {
this._log(`${devDependencies[key] ? 'Overriding' : 'Assigning'} devDependency with peerDependency: ${key}: ${peerDepRange}`);
devDependencies[key] = peerDepRange;
}
});
}
/**
* Build the local Angular packages.
*
* NOTE:
* Building the packages is currently not supported on Windows, so a message is printed instead, prompting the user to
* do it themselves (e.g. using Windows Subsystem for Linux or a docker container).
*/
_buildDistPackages() {
const canBuild = process.platform !== 'win32';
if (canBuild) {
this._log(`Building the Angular packages with: ${ANGULAR_DIST_PACKAGES_BUILD_SCRIPT}`);
shelljs.exec(ANGULAR_DIST_PACKAGES_BUILD_CMD);
} else {
this._warn([
'Automatically building the local Angular packages is currently not supported on Windows.',
`Please, ensure '${ANGULAR_DIST_PACKAGES}' exists and is up-to-date (e.g. by running ` +
`'${ANGULAR_DIST_PACKAGES_BUILD_SCRIPT}' in Git Bash for Windows, Windows Subsystem for Linux or a Linux ` +
'docker container or VM).',
'',
'Proceeding anyway...',
].join('\n'));
}
}
_collectDependencies(dependencies, packages) {
const peerDependencies = Object.create(null);
const mergedDependencies = Object.assign(Object.create(null), dependencies);
Object.keys(dependencies).forEach(key => {
const sourcePackage = packages[key];
if (sourcePackage) {
// point the core Angular packages at the distributable folder
mergedDependencies[key] = `file:${sourcePackage.parentDir}/${key.replace('@angular/', '')}`;
this._log(`Overriding dependency with local package: ${key}: ${mergedDependencies[key]}`);
// grab peer dependencies
const sourcePackagePeerDeps = sourcePackage.config.peerDependencies || {};
Object.keys(sourcePackagePeerDeps)
// ignore peerDependencies which are already core Angular packages
.filter(key => !packages[key])
.forEach(key => peerDependencies[key] = sourcePackagePeerDeps[key]);
}
});
return [mergedDependencies, peerDependencies];
}
/**
* A hash of Angular package configs.
* (Detected as directories in '/dist/packages-dist/' that contain a top-level 'package.json' file.)
*/
_getDistPackages() {
const packageConfigs = Object.create(null);
const distDir = ANGULAR_DIST_PACKAGES;
this._log(`Angular distributable directory: ${distDir}.`);
if (this.buildPackages) {
this._buildDistPackages();
}
shelljs
.find(distDir)
.map(filePath => filePath.slice(distDir.length + 1))
.filter(filePath => PACKAGE_JSON_REGEX.test(filePath))
.forEach(packagePath => {
const packageName = `@angular/${packagePath.slice(0, -PACKAGE_JSON.length -1)}`;
if (this.ignorePackages.indexOf(packageName) === -1) {
const packageConfig = require(path.resolve(distDir, packagePath));
packageConfigs[packageName] = {
parentDir: distDir,
packageJsonPath: path.resolve(distDir, packagePath),
config: packageConfig
};
} else {
this._log('Ignoring package', packageName);
}
});
this._log('Found the following Angular distributables:', Object.keys(packageConfigs).map(key => `\n - ${key}`));
return packageConfigs;
}
_installDeps(...options) {
const command = 'yarn install ' + options.join(' ');
this._log('Installing dependencies with:', command);
shelljs.exec(command, {cwd: this.projectDir});
}
/**
* Log a message if the `debug` property is set to true.
* @param {...string[]} messages - The messages to be logged.
*/
_log(...messages) {
if (this.debug) {
const header = ` [${NgPackagesInstaller.name}]: `;
const indent = ' '.repeat(header.length);
const message = messages.join(' ');
console.info(`${header}${message.split('\n').join(`\n${indent}`)}`);
}
}
/**
* Extract the value for a boolean cli argument/option. When passing an option multiple times, `yargs` parses it as an
* array of boolean values. In that case, we only care about the last occurrence.
*
* This can be useful, for example, when one has a base command with the option turned on and another command
* (building on top of the first one) turning the option off:
* ```
* "base-command": "my-script --foo --bar",
* "no-bar-command": "yarn base-command --no-bar",
* ```
*/
_parseBooleanArg(value) {
return Array.isArray(value) ? value.pop() : value;
}
/**
* Parse and return a `yarn.lock` file.
*/
_parseLockfile(lockfilePath) {
const lockfileContent = fs.readFileSync(lockfilePath, 'utf8');
const parsed = lockfile.parse(lockfileContent);
if (parsed.type !== 'success') {
throw new Error(`[${NgPackagesInstaller.name}]: Error parsing lockfile '${lockfilePath}' (result type: ${parsed.type}).`);
}
return parsed.object;
}
_printWarning() {
const relativeScriptPath = path.relative('.', __filename.replace(/\.js$/, ''));
const absoluteProjectDir = path.resolve(this.projectDir);
const restoreCmd = `node ${relativeScriptPath} restore ${absoluteProjectDir}`;
// Log a warning.
this._warn([
`The project at "${absoluteProjectDir}" is running against the local Angular build.`,
'',
'To restore the npm packages run:',
'',
` "${restoreCmd}"`,
].join('\n'));
}
/**
* Log a warning message do draw user's attention.
* @param {...string[]} messages - The messages to be logged.
*/
_warn(...messages) {
const lines = messages.join(' ').split('\n');
console.warn(chalk.yellow([
'',
'!'.repeat(110),
'!!!',
'!!! WARNING',
'!!!',
...lines.map(line => `!!! ${line}`),
'!!!',
'!'.repeat(110),
'',
].join('\n')));
}
// Local marker helpers
_checkLocalMarker() {
this._log('Checking for local marker at', this.localMarkerPath);
return fs.existsSync(this.localMarkerPath);
}
_setLocalMarker(contents) {
this._log('Writing local marker file to', this.localMarkerPath);
fs.writeFileSync(this.localMarkerPath, contents);
}
}
function main() {
shelljs.set('-e');
const createInstaller = argv => {
const {projectDir, ...options} = argv;
return new NgPackagesInstaller(projectDir, options);
};
yargs
.usage('$0 <cmd> [args]')
.option('debug', { describe: 'Print additional debug information.', default: false })
.option('force', { describe: 'Force the command to execute even if not needed.', default: false })
.option('build-packages', { describe: 'Build the local Angular packages, before using them.', default: false })
.option('ignore-packages', { describe: 'List of Angular packages that should not be used in local mode.', default: [], array: true })
.command('overwrite <projectDir> [--force] [--debug] [--ignore-packages package1 package2]', 'Install dependencies from the locally built Angular distributables.', () => {}, argv => {
createInstaller(argv).installLocalDependencies();
})
.command('restore <projectDir> [--debug]', 'Install dependencies from the npm registry.', () => {}, argv => {
createInstaller(argv).restoreNpmDependencies();
})
.command('check <projectDir> [--debug]', 'Check that dependencies came from npm. Otherwise display a warning message.', () => {}, argv => {
createInstaller(argv).checkDependencies();
})
.demandCommand(1, 'Please supply a command from the list above.')
.strict()
.wrap(yargs.terminalWidth())
.argv;
}
module.exports = NgPackagesInstaller;
if (require.main === module) {
main();
}