src/comptroller.js
const path = require('./path');
const fs = require('./fs');
const glob = require('./glob');
const logger = require('./logger');
const Package = require('./package');
const Patch = require('./patch');
/**
* This is the main Comptroller class. It serves as the entry point into all of
* Comptroller's higher level functionality.
*/
module.exports = class Comptroller extends Package
{
/**
* Creates a new Comptroller instance. It accepts all of the arguments in
* {@link Package#constructor} as well as...
* @param {string} [opts.config.packages = packages] - The directory name
* where packages can be found.
*/
constructor (config)
{
super(config);
/** @type {string} */
this._packages = config.packages || 'packages';
/** @type {Package[]} */
this._children = this.readChildren();
}
/**
* The packages directory.
* @type {string}
*/
get packages () {return this._packages}
/**
* The child packages.
* @type {Package[]}
*/
get children () {return this._children}
/**
* Scans the {@link Comptroller#packages} directory, finds package.json files
* and generates {@link Package}s. Note that all package.json files must be
* in a direct subdirectory of the packages directory.
* @return {Package[]} - The child packages.
*/
readChildren ()
{
const packageJsons = glob.sync(path.resolve(this.root, this.packages, '*', 'package.json'), {
ignore: this.ignore,
nodir: true,
});
return packageJsons.map((pkgjson) => new Package({root: path.dirname(pkgjson), config: this}));
}
/**
* Loops through all packages in {@link Comptroller#children} and writes it's
* package.json.
*/
async writePackages ()
{
for (let child of this.children) {
try {await child.writePackageJson()}
catch (err) {logger.error(err); return}
}
}
/**
* A convenience method that locates a package in {@link Comptroller#children}
* by it's name in {@link Package#packageJson}.
* @param {string} name - The name of the child package.
* @return {Package | boolean} - The found package, or `false` if not found.
*/
getChildByName (name)
{
for (let child of this.children) {
if (child.packageJson.name == name) return child;
}
return false;
}
/**
* Takes an array of raw patches (returned by
* {@link Package#generateDependencyPatches} or
* {@link Package#generateInheritPatches}) and updates them with the
* information in the {@link Comptroller#packageJson}
* @param {Patch[]} patches - The patches to update.
* @return {Patch[]} - The updated patches.
*/
updatePatches (patches)
{
const newPatches = [];
for (let patch of patches) {
switch (patch.type) {
case Patch.ADD:
case Patch.UPDATE:
const child = this.getChildByName(patch.name);
const source = child ? 'local' : 'remote';
let value = child ?
child.packageJson.version :
this.dependencies[patch.name];
if (!value && patch.dev) {
value = this.devDependencies[patch.name];
}
newPatches.push(new Patch(patch.type, {
...patch,
source,
value,
}));
break;
case Patch.REMOVE:
newPatches.push(new Patch(patch.type, {
...patch,
disabled: !this.prune,
}));
break;
case Patch.INHERIT:
newPatches.push(new Patch(patch.type, {
...patch,
value: this.packageJson[patch.name],
}));
break;
default:
break;
}
}
return newPatches;
}
/**
* Logs patch operations.
* @param {Package} child - The child the patch is being applied to.
* @param {Patch} patch - The patch being applied.
*/
logPatch (child, patch)
{
const childName = child.packageJson.name;
const disabled = patch.disabled ? 'DISABLED: ' : '';
const dev = patch.dev ? ' dev ' : ' ';
if ((patch.type == Patch.ADD || patch.type == Patch.UPDATE) && !patch.value) {
if (patch.name in child.devDependencies) {
logger.warn(`WARNING: '${patch.name}' required by ${childName} in non-dev source (${patch.files}) was found in package.json devDependencies.`);
}
else {
logger.warn(`WARNING: '${patch.name}' required by ${childName} (${patch.files}) not found in package.json or local packages.`);
}
return;
}
switch (patch.type) {
case Patch.ADD:
logger.log(`${disabled}Adding ${patch.source}${dev}package '${patch.name}@${patch.value}' to package '${childName}'`);
break;
case Patch.UPDATE:
const depField = patch.dev ? 'devDependencies' : 'dependencies';
const oldVersion = child[depField][patch.name];
if (oldVersion !== patch.value) {
logger.log(`${disabled}Updating ${patch.source}${dev}package '${patch.name}' from ${oldVersion} to ${patch.value} in package '${childName}'`);
}
break;
case Patch.REMOVE:
logger.log(`${disabled}Removing${dev}package '${patch.name}' from '${childName}'`);
break;
case Patch.INHERIT:
const oldValue = JSON.stringify(child.packageJson[patch.name]);
const newValue = JSON.stringify(patch.value);
if (oldValue !== newValue) {
if (oldValue) {
logger.log(`${disabled}Updating field ${patch.name} from ${oldValue} to ${newValue} in package '${childName}'`);
}
else {
logger.log(`${disabled}Adding field ${patch.name} as ${newValue} to package '${childName}'`);
}
}
}
}
/**
* Analyzes the dependencies and inherits of each package and applies the
* respective patches to each package.
*/
async updatePackages ()
{
for (let child of this.children) {
let deps = await child.analyzeSourceDependencies();
let patches = [
...child.generateDependencyPatches(deps),
...child.generateInheritPatches(),
];
patches = this.updatePatches(patches);
for (let patch of patches) {
this.logPatch(child, patch);
child.applyPatch(patch);
}
}
}
/**
* Analyzes the dependencies of the root package and applies the respective
* patches.
*/
async updateSelf ()
{
const deps = await this.analyzeSourceDependencies();
let patches = this.generateDependencyPatches(deps)
for (let child of this.children) {
const childDeps = await child.analyzeSourceDependencies();
const childPatches = child.generateDependencyPatches(childDeps);
patches.push(...childPatches);
}
patches = this.updatePatches(patches);
// shake out patches
const shaken = {};
for (let patch of patches) {
if (patch.source == 'local') continue;
const name = patch.name;
if (!shaken[name]) {
shaken[name] = patch;
continue;
}
shaken[name] = Comptroller.mergePatches(shaken[name], patch);
}
for (let name in shaken) {
const patch = shaken[name];
this.logPatch(this, patch);
this.applyPatch(patch);
}
}
/**
* Merges patches generated by child packages in order to apply to the parent
* package. This resolves conflicts that occur when applying patches
* generated by multiple sources.
* @param {Patch} a - A patch
* @param {Patch} b - Another patch
* @return {Patch} - The merged patch
*/
static mergePatches (a, b)
{
const priority = [Patch.UPDATE, Patch.ADD, Patch.REMOVE];
const name = a.name;
const value = a.value || b.value;
const source = a.source || b.source;
const dev = a.dev && b.dev;
const files = [...a.files, ...b.files];
const type = priority[
Math.min(priority.indexOf(a.type), priority.indexOf(b.type))
];
const disabled = (a.disabled || b.disabled) && type == Patch.REMOVE;
return new Patch(type, {
name, value, source, dev, disabled, files,
});
}
/**
* Links the packages to node_modules in a way that enables them to be
* resolved by other packages by name.
*/
async linkPackages ()
{
const node_modules = path.resolve(this.root, 'node_modules');
await fs.ensureDirPlease(node_modules);
for (let child of this.children) {
const name = child.packageJson.name;
await fs.ensureSymlinkPlease(child.root, path.resolve(node_modules, name));
}
}
}