ls-engines
Version:
Determine if your dependency graph's stated "engines" criteria is met.
423 lines (380 loc) • 12.7 kB
JavaScript
import { styleText } from 'util';
import pargs from 'pargs';
const {
entries,
groupBy,
} = Object;
import validEngines from '#validEngines';
const {
errors,
help,
tokens,
values: {
current,
dev,
mode,
peer,
production,
save,
},
} = await pargs(import.meta.filename, {
description: 'Determine if your dependency graph’s stated "engines" criteria is met.',
options: {
current: {
default: true,
description: 'check that the current node version matches your dependency graph’s requirements',
type: 'boolean',
},
dev: {
default: false,
description: 'whether to include dev deps or not',
type: 'boolean',
},
mode: {
choices: ['auto', 'actual', 'virtual', 'ideal'],
default: 'auto',
description: '"actual" reads from `node_modules`; "virtual" reads from a lockfile; "ideal" reads from `package.json`',
type: 'enum',
},
peer: {
default: true,
description: 'whether to include peer deps or not',
type: 'boolean',
},
production: {
default: true,
description: 'whether to include production deps or not',
type: 'boolean',
},
save: {
default: false,
description: 'update `package.json`’s "engines" field to match that of your dependency graph',
type: 'boolean',
},
},
tokens: true,
});
// Check if options were explicitly passed (not defaults)
const currentExplicitlyPassed = tokens.some((t) => t.kind === 'option' && (t.name === 'current' || t.name === 'no-current'));
const devExplicitlyPassed = tokens.some((t) => t.kind === 'option' && (t.name === 'dev' || t.name === 'no-dev'));
// Custom validation
if (![dev, production, peer].some(Boolean)) {
errors[errors.length] = 'At least one of `--dev`, `--production`, or `--peer` must be enabled.';
}
if (dev && devExplicitlyPassed && current && currentExplicitlyPassed) {
errors[errors.length] = '`--current` is not available when checking dev deps.';
}
await help();
// If we get here, no errors and no --help
const selectedEngines = validEngines;
const effectiveCurrent = dev ? false : current;
import path from 'path';
import Range from 'semver/classes/range.js';
import satisfies from 'semver/functions/satisfies.js';
import major from 'semver/functions/major.js';
import minor from 'semver/functions/minor.js';
import fastArrayIntersect from 'fast_array_intersect';
const intersect = fastArrayIntersect.default || fastArrayIntersect;
import jsonFile from 'json-file-plus';
import EXITS from './exit-codes.js';
import checkCurrent from './checkCurrent.js';
import checkEngines from './checkEngines.js';
import getGraphEntries from './getGraphEntries.js';
import getGraphValids from './getGraphValids.js';
import getLatestEngineMajors from './getLatestEngineMajors.js';
import processFulfilledResults from './processFulfilledResults.js';
import table from './table.js';
import validVersionsForEngines from './validVersionsForEngines.js';
import getAlVersions from './getAllVersions.js';
const {
fromEntries,
values,
} = Object;
const pPackage = jsonFile(path.join(process.cwd(), 'package.json'));
/** @type {(ver: string) => string} */
function caret(ver) {
return `^${ver.replace(/^v/g, '')}`;
}
const pAllVersions = getAlVersions(selectedEngines);
/** @typedef {typeof selectedEngines[number]} ValidEngine */
/** @typedef {{ name: ValidEngine; version?: string | null }} Runtime */
/** @typedef {{ runtime?: Runtime | Runtime[] }} DevEngines */
/** @typedef {Record<ValidEngine, string | null>} Engines */
/** @typedef {{ private?: boolean; devEngines?: DevEngines; engines?: Engines }} PackageData */
/** @import { FulfilledCheckResult, JSONFileLike } from './processFulfilledResults.js' */
const pRootRanges = Promise.all([pPackage, pAllVersions]).then(async ([
{ data },
allVersions,
]) => {
const {
private: isPrivate,
devEngines, engines:
prodEngines,
} = /** @type {PackageData} */ (data);
const devEnginesRuntime = devEngines?.runtime;
// For private packages, devEngines takes precedence over engines (if present)
const useDevEngines = isPrivate && devEnginesRuntime;
const engineEntries = validEngines.map((engine) => {
if (useDevEngines) {
// devEngines.runtime is an object with name and version (or an array of such)
const runtimes = /** @type {Runtime[]} */ ([]).concat(devEnginesRuntime);
const nodeRuntime = runtimes.find((r) => r.name === 'node');
const version = nodeRuntime?.version || null;
return [engine, version?.replace(/[=](?<digits>\d)/, '= $<digits>') || null];
}
return [
engine,
(prodEngines?.[engine] || null)?.replace(/[=](?<digits>\d)/, '= $<digits>'),
];
});
const engines = fromEntries(engineEntries);
const rangeEntries = engineEntries.map(([engine, v]) => [engine, new Range(v || '*')]);
const ranges = fromEntries(rangeEntries);
const valids = await validVersionsForEngines(engines, allVersions);
return { engines, ranges, useDevEngines, valids };
});
/** @type {(v: string) => string} */
function dropPatch(v) {
const num = v.replace(/^v/, '');
return `^${major(num)}.${minor(num)}`;
}
/** @type {(prev: string[], v: string) => string[]} */
function versionReducer(prev, v) {
if (prev.length === 0) {
return [v];
}
return satisfies(v, caret(prev[prev.length - 1])) ? prev : prev.concat(v);
}
const pGraphRanges = Promise.all([
getGraphEntries({
dev,
mode,
peer,
production,
selectedEngines,
}),
pAllVersions,
]).then(async ([graphEntries, allVersions]) => {
const { valids: graphValids, allowed } = await getGraphValids(graphEntries, allVersions);
const graphRanges = entries(graphValids).map(([engine, versions]) => {
const validMajorRanges = graphEntries.length > 0 && versions.length > 0 ? versions.reduceRight(versionReducer, []).map(dropPatch).reverse() : ['*'];
const lastMajor = validMajorRanges[validMajorRanges.length - 1];
const greaterThanLowest = lastMajor === '*' ? lastMajor : `>= ${lastMajor.replace(/^\^/, '')}`;
const validRange = versions.every((v) => satisfies(v, greaterThanLowest))
? new Range(greaterThanLowest)
: new Range(validMajorRanges.join(' || '));
if (!versions.every((v) => validRange.test(v))) {
throw new RangeError(`please report this: ${engine}: ${versions.join(',')} / ${validRange}`);
}
const displayRange = validRange.raw && validRange.raw.replace(/(?:\.0)+(?<spacing> |$)/g, '$<spacing>').split(' ').join(' ');
return [engine, { displayRange, validRange }];
});
const engineEntries = graphRanges.map(([engine, x]) => [engine, /** @type {{ displayRange: string }} */ (x).displayRange]);
const engines = fromEntries(engineEntries);
const validEntries = await Promise.all(validEngines.map(async (engine) => {
const validForEngine = await validVersionsForEngines(engines, allVersions);
return [engine, validForEngine[engine]];
}));
const ranges = fromEntries(graphRanges);
const valids = fromEntries(validEntries);
return {
allowed,
engines,
ranges,
valids,
};
});
const pLatestEngineMajors = Promise.all([
pRootRanges,
pGraphRanges,
pAllVersions,
]).then(([
{ ranges: rootRanges },
{ ranges: graphRanges },
allVersions,
]) => getLatestEngineMajors(selectedEngines, allVersions, rootRanges, graphRanges));
/** @type {(array: string[], limit: number) => string} */
function wrapCommaSeparated(array, limit) {
const str = array.join(', ');
if (str.length <= limit) {
return str;
}
return array.reduce((lines, version) => {
const lastLine = lines.pop();
const possibleLine = lastLine ? `${lastLine}, ${version}` : version;
if (possibleLine.length <= limit) {
return lines.concat(possibleLine);
}
return lines.concat(lastLine || [], version);
}, /** @type {string[]} */ ([])).map((x) => x.split(',').map((y) => styleText('blue', y)).join(',')).join(',\n');
}
/** @type {(engines: Engines) => Record<string, string>} */
function normalizeEngines(engines) {
const engineEntries = entries(engines)
.flatMap(([engine, version]) => (
engine === 'node' || version !== '*'
? [[engine, version || '*']]
: []
))
.toSorted(([a], [b]) => a.localeCompare(b));
return fromEntries(engineEntries);
}
const majorsHeading = 'Currently available latest release of each valid major version:';
/** @type {(engine: unknown) => engine is ValidEngine} */
function isValidEngine(engine) {
return selectedEngines.includes(/** @type {ValidEngine} */ (engine));
}
const pSummary = Promise.all([
pRootRanges,
pGraphRanges,
pLatestEngineMajors,
]).then(([
{ engines: rootEngines, useDevEngines },
{ engines: graphEngines, valids: graphValids },
latestEngineMajors,
]) => {
const enginesField = useDevEngines ? 'devEngines' : 'engines';
/** @type {DevEngines} */
const displayRootEngines = useDevEngines
? { runtime: { name: ('node'), version: rootEngines.node } }
: normalizeEngines(rootEngines);
return {
output: /** @type {string[]} */ ([]).concat(
table([
[
'engine',
majorsHeading,
].map((x) => styleText(['bold', 'gray'], x)),
...entries(latestEngineMajors)
.flatMap(([
engine,
{ root, graph },
]) => (
isValidEngine(engine)
? [[
styleText('blue', engine),
wrapCommaSeparated(
graph.length > 0
? intersect([root, graph])
: root,
majorsHeading.length,
),
]]
: []
)),
]),
table([
/** @type {string[]} */ ([]).concat(
`package ${enginesField}:`,
'dependency graph engines:',
).map((x) => styleText(['bold', 'gray'], x)),
[
`"${enginesField}": ${JSON.stringify(displayRootEngines, null, 2)}`,
values(graphValids).some((x) => x.length > 0) && values(graphEngines).length > 0
? `"engines": ${JSON.stringify(normalizeEngines(graphEngines), null, 2)}`
: 'N/A',
].map((x) => styleText('blue', x)),
]),
),
};
});
Promise.all([
pGraphRanges,
pPackage,
pRootRanges,
pAllVersions,
]).then(async ([
{
allowed: graphAllowed,
valids: graphValids,
ranges: graphDisplayRanges,
},
pkg,
{
engines: rootEngines,
valids: rootValids,
useDevEngines,
},
allVersions,
]) => {
// Validate devEngines is subset of engines for non-private packages
const pkgData = /** @type {PackageData} */ (pkg.data);
const devEnginesRuntime = pkgData.devEngines?.runtime;
if (!pkgData.private && devEnginesRuntime) {
const runtimes = /** @type {Runtime[]} */ ([]).concat(devEnginesRuntime);
const nodeRuntime = runtimes.find((r) => r.name === 'node');
const devVersion = nodeRuntime?.version || '*';
const devValids = await validVersionsForEngines({ node: devVersion }, allVersions);
const rootValidsSet = new Set(rootValids.node);
if (!devValids.node.every((v) => rootValidsSet.has(v))) {
throw {
code: EXITS.DEV_ENGINES,
output: [
styleText(['bold', 'red'], '\nYour "devEngines" field is not a subset of your "engines" field!'),
`\n"engines.node" allows: ${styleText('blue', rootEngines.node || '*')}`,
`"devEngines.runtime" requires: ${styleText('blue', devVersion)}`,
'\nEither widen your "engines" field or narrow your "devEngines" field.',
],
};
}
}
const pEngines = checkEngines(
selectedEngines,
rootEngines,
rootValids,
graphValids,
graphAllowed,
graphDisplayRanges,
save,
/** @type {boolean} */ (useDevEngines),
);
const pCurrent = effectiveCurrent
? checkCurrent(selectedEngines, rootValids, graphValids)
: { output: [] };
// print out successes first
const { fulfilled = [], rejected = [] } = groupBy(
await Promise.allSettled([pSummary, pEngines, pCurrent]),
(x) => x.status,
);
await processFulfilledResults(
/** @type {FulfilledCheckResult[]} */
(fulfilled),
save,
/** @type {JSONFileLike} */
(pkg),
EXITS,
console.log,
);
// print out failures last
await rejected.reduce(async (prev, error) => {
await prev;
const rejection = /** @type {PromiseRejectedResult} */ (error);
if (!rejection.reason) {
throw error;
}
const { reason } = rejection;
const { code, output, save: doSave } = reason;
if (!output) {
throw reason;
}
if (save && doSave) {
doSave(pkg.data);
try {
await pkg.save();
} catch {
process.exitCode = /** @type {number} */ (process.exitCode) | EXITS.SAVE;
}
} else {
process.exitCode = /** @type {number} */ (process.exitCode) | code;
}
output.forEach((/** @type {unknown} */ line) => {
console.error(line);
});
}, Promise.resolve());
}).catch((e) => {
[].concat(e.output || (e && e.stack) || e).forEach((/** @type {unknown} */ line) => {
console.error(line);
});
process.exitCode = /** @type {number} */ (process.exitCode) | (typeof e.code === 'number' ? e.code : EXITS.ERROR);
});