src/package.js
const path = require('./path');
const glob = require('./glob');
const minimatch = require('minimatch');
const fs = require('./fs');
const builtins = require('builtin-modules');
const detective = require('@zdychacek/detective');
const sortPackageJson = require('sort-package-json');
const beautify = require('json-beautify');
const Patch = require('./patch');
/**
* This is the package class. It serves as encapsulation for individual
* packages, as a means of querying for package info, and as an outlet for
* changes to a package.
*/
module.exports = class Package
{
/**
* Creates a new package instance. It can accept it's configuration either
* through the package.json file or through the constructor arguments.
* @param {object} opts - The package options.
* @param {string} opts.root - The root directory of the package.
* @param {object} [opts.packageJson = fs.readJson('root package.json')] - The
* package's package.json. If not specified, it's read from the root
* directory.
* @param {object} [opts.config = opts.packageJson.comptroller || {}] - The
* Comptroller configurations options.
* @param {glob} [opts.config.source = '⚹⚹/⚹.js'] - A glob that selects
* the packages source files.
* @param {glob} [opts.config.dev = '⚹⚹/test/⚹⚹/⚹.js'] - A glob that selects
* the packages that should also include devDependencies
* @param {glob[]} [opts.config.ignore = ['⚹⚹/node_modules/⚹⚹']] - An array
* of globs to pass into {@link glob}'s `ignore` option when searching for
* source files.
* @param {Array<string|object>} [opts.config.exclude = builtins] - An array of package
* names to ignore in all operations. Defaults to {@link builtin-modules}.
* @param {string[]} [opts.config.inherits = []] - An array of field names to
* inherit from the parent package.json. This is useful for values that
* remain the same across all packages.
* @param {object} [opts.config.detective = {}] - The options to pass through
* to <a href="https://npmjs.com/package/@zdychacek/detective">detective</a>.
* @param {boolean} [opts.config.prune = false] - Whether or not unused
* dependencies should be pruned from the package.json.
* @param {boolean|number} [opts.config.pretty = false] - The "maximum fixed
* character width" parameter to pass to json-beautify.
*/
constructor ({
root,
packageJson = fs.readJsonSync(path.resolve(root, 'package.json')),
config = {},
_config = packageJson.comptroller || {},
source = _config.source || config.source || '**/*.js',
dev = _config.dev || config.dev || '**/test/*.js',
ignore = _config.ignore || config.ignore || ['**/node_modules/**'],
exclude = _config.exclude || config.exclude || builtins,
inherits = _config.inherits || config.inherits || [],
detective = _config.detective || config.detective || {},
prune = _config.prune || config.prune || false,
pretty = _config.pretty || config.pretty || false,
})
{
/** @type {string} */
this._root = root;
/** @type {object} */
this._packageJson = packageJson;
/** @type {glob} */
this._source = source;
/** @type {glob} */
this._dev = dev;
/** @type {glob[]} */
this._ignore = ignore;
/** @type {string[]} */
this._exclude = Package.generateExcludes(exclude);
/** @type {string[]} */
this._inherits = inherits;
/** @type {object} */
this._detective = detective;
/** @type {boolean} */
this._prune = prune;
/** @type {boolean|number} */
this._pretty = pretty;
}
/**
* The package's root directory.
* @type {string}
*/
get root () {return this._root}
/**
* An object representing the package's package.json.
* @type {object}
*/
get packageJson () {return this._packageJson}
/**
* An object representing the package's dependencies.
* @type {object}
*/
get dependencies () {return this.packageJson.dependencies || (this.packageJson.dependencies = {})}
/**
* An object representing the package's devDependencies.
* @type {object}
*/
get devDependencies () {return this.packageJson.devDependencies || (this.packageJson.devDependencies = {})}
/**
* A glob that matches the package's source files
* @type {glob}
*/
get source () {return this._source}
/**
* A glob that matches the package's dev files
* @type {minimatch}
*/
get dev () {return this._dev}
/**
* An array of globs that match files not to be included with the package's
* source files.
* @type {glob[]}
*/
get ignore () {return this._ignore}
/**
* An array of package names to ignore in all operations.
* @type {string[]}
*/
get exclude () {return this._exclude}
/**
* An array of field names the package should inherit from it's parent
* package.json.
* @type {string[]}
*/
get inherits () {return this._inherits}
/**
* The options to pass through to <a href="https://npmjs.com/package/@zdychacek/detective">detective</a>.
* @type {object}
*/
get detective () {return this._detective}
/**
* Whether or not unused dependencies should be pruned from the package.json.
* @type {boolean}
*/
get prune () {return this._prune}
/**
* The "maximum fixed character width" parameter to pass to json-beautify.
* @type {boolean|number}
*/
get pretty () {return this._pretty}
/**
* Writes {@link Package#packageJson} to it's respective package.json file.
*/
async writePackageJson ()
{
const packageJson = sortPackageJson(this.packageJson);
const json = beautify(packageJson, null, 2, this.pretty);
await fs.writeFilePlease(path.resolve(this.root, 'package.json'), json);
}
/**
* Takes a dependency name and resolves it to the actual dependency name,
* stripping any subdirectories from the dependency name (retaining @org
* style dependencies). It also excludes dependencies with relative paths (./
* or ../ style) and any dependencies listed in {@link Package#exclude}
* @param {string} dependency - The dependency to resolve
* @return {string | boolean} - The resolved dependency name, or false if the
* the dependency is excluded.
*/
resolveDependency (dependency)
{
if (dependency.charAt(0) == '.') return false;
if (this.exclude.indexOf(dependency) >= 0) return false;
const split = dependency.split('/');
return split[0].charAt(0) == '@' ? split[0] + '/' + split[1] : split[0];
}
/**
* Analyzes the package's source files and returns all of the invoked
* dependencies mapped to the files invoking them.
* @return {Map<string, object>} - A map with the dependency names as keys
* and the dependency metadata as values.
*/
async analyzeSourceDependencies ()
{
const files = await glob.please(path.resolve(this.root, this.source), {
ignore: this.ignore,
nodir: true,
});
const deps = {};
await Promise.all(files.map(async (file) => {
const src = await fs.readFilePlease(file);
const dependencies = detective(src, this.detective);
const relFile = path.relative(this.root, file);
for (let dep of dependencies) {
if (dep = this.resolveDependency(dep)) {
deps[dep] = deps[dep] || {files: []};
deps[dep].files.push(relFile);
}
}
}));
return deps;
}
/**
* Compares a dependency object (as returned from {@link Package#analyzeSourceDependencies})
* with the dependencies listed in {@link Package@packageJson} and returns an
* array of patches.
* @param {object} dependencies - The dependencies to generate a patch for.
* @return {Patch[]} - The patches that will make {@link Package#packageJson} match the inputted dependencies
*/
generateDependencyPatches (dependencies)
{
const patches = [];
const usedDeps = {};
// const usedDev = {};
for (let dep in dependencies)
{
let {files} = dependencies[dep];
let dev = true;
for (let file of files) {
if (!minimatch(file, this.dev)) {
dev = false;
break;
}
}
usedDeps[dep] = true;
if (dev && !(dep in this.devDependencies) && !(dep in this.dependencies)) {
patches.push(new Patch(Patch.ADD, {name: dep, dev, files}));
}
else if (!dev && !(dep in this.dependencies)) {
patches.push(new Patch(Patch.ADD, {name: dep, files}));
}
else {
if (dep in this.devDependencies) {
patches.push(new Patch(Patch.UPDATE, {name: dep, dev: true, files}));
}
else if (dep in this.dependencies) {
patches.push(new Patch(Patch.UPDATE, {name: dep, files}));
}
}
}
for (let dep in this.dependencies) {
if (!usedDeps[dep] && this.exclude.indexOf(dep) < 0) {
patches.push(new Patch(Patch.REMOVE, {name: dep}))
}
}
return patches;
}
/**
* Generates the patches that will satisfy {@link Package#inherits}
* @return {Patch[]} - The patches that will update {@link Package#packageJson} with the inherited fields.
*/
generateInheritPatches ()
{
return this.inherits.map((name) => new Patch(Patch.INHERIT, {name}));
}
/**
* Applies a given patch to {@link Package#packageJson}.
* @param {Patch} patch - The patch to apply,
*/
applyPatch (patch)
{
if (patch.disabled) return;
let depField = patch.dev ? 'devDependencies' : 'dependencies';
switch (patch.type) {
case Patch.ADD:
case Patch.UPDATE:
if (typeof patch.value !== 'undefined') {
this[depField][patch.name] = patch.value;
}
break;
case Patch.REMOVE:
delete this[depField][patch.name];
break;
case Patch.INHERIT:
if (typeof patch.value !== 'undefined') {
this.packageJson[patch.name] = patch.value;
}
default:
break;
}
}
/**
* Generates an exclude array
* @param {Array<string|object>} excludes - The excludes array to use as a base.
*/
static generateExcludes (excludes)
{
const result = [];
for (let exclude of excludes) {
if (typeof exclude === 'string') {
result.push(exclude);
continue;
}
if (exclude && typeof exclude === 'object') {
if (exclude.group === 'builtins') {
result.push(...builtins);
}
}
}
return result;
}
}