From 5d5f6a86ffdef17f6570b1abb6125063efd27f04 Mon Sep 17 00:00:00 2001 From: Jordan Harband Date: Mon, 26 Feb 2024 18:28:29 -0800 Subject: [PATCH] feat: add `devEngines` field This field is only looked at when in the top-level project, and takes precedence over `engines` when present See https://github.com/openjs-foundation/package-metadata-interoperability-collab-space/issues/15#issuecomment-2140751719 for the schema --- docs/lib/content/commands/npm-query.md | 3 ++- .../content/configuring-npm/package-json.md | 24 +++++++++++++++++ docs/lib/content/using-npm/developers.md | 13 +++++++--- node_modules/npm-install-checks/lib/index.js | 25 +++++++++++++----- .../arborist/lib/arborist/build-ideal-tree.js | 2 +- workspaces/arborist/lib/shrinkwrap.js | 1 + .../test/arborist/build-ideal-tree.js | 26 ++++++++++++++++++- .../package-lock.json | 16 ++++++++++++ .../dev-engine-specification/package.json | 18 +++++++++++++ 9 files changed, 114 insertions(+), 14 deletions(-) create mode 100644 workspaces/arborist/test/fixtures/dev-engine-specification/package-lock.json create mode 100644 workspaces/arborist/test/fixtures/dev-engine-specification/package.json diff --git a/docs/lib/content/commands/npm-query.md b/docs/lib/content/commands/npm-query.md index 490eccffcc4b3..9f1e3024bbbce 100644 --- a/docs/lib/content/commands/npm-query.md +++ b/docs/lib/content/commands/npm-query.md @@ -125,6 +125,7 @@ npm query ":type(git)" | jq 'map(.name)' | xargs -I {} npm why {} "peerDependencies": {}, "peerDependenciesMeta": {}, "engines": {}, + "devEngines": {}, "os": [], "cpu": [], "workspaces": {}, @@ -158,7 +159,7 @@ $ npm query ':root>:outdated(in-range).prod' --no-expect-results ### Package lock only mode -If package-lock-only is enabled, only the information in the package lock (or shrinkwrap) is loaded. This means that information from the package.json files of your dependencies will not be included in the result set (e.g. description, homepage, engines). +If package-lock-only is enabled, only the information in the package lock (or shrinkwrap) is loaded. This means that information from the package.json files of your dependencies will not be included in the result set (e.g. description, homepage, engines, devEngines). ### Configuration diff --git a/docs/lib/content/configuring-npm/package-json.md b/docs/lib/content/configuring-npm/package-json.md index 755071c6f10bd..5c0fe89e8e998 100644 --- a/docs/lib/content/configuring-npm/package-json.md +++ b/docs/lib/content/configuring-npm/package-json.md @@ -1066,6 +1066,30 @@ Unless the user has set the advisory only and will only produce warnings when your package is installed as a dependency. +### devEngines + +This field works similar to the `engines` field. It is only respected at the project root. + +All properties of `devEngines` are optional. Here is a TypeScript interface describing the schema of the object: +```ts +interface DevEngines { + os?: DevEngineDependency | DevEngineDependency[]; + cpu?: DevEngineDependency | DevEngineDependency[]; + runtime?: DevEngineDependency | DevEngineDependency[]; + packageManager?: DevEngineDependency | DevEngineDependency[]; +} + +interface DevEngineDependency { + name: string; + version?: string; + onFail?: 'ignore' | 'warn' | 'error'; +} +``` + +`onFail` defaults to `error`. When an unknown `onFail` value is provided, it will error. + +When present, the `engines` field is ignored. + ### os You can specify which operating systems your diff --git a/docs/lib/content/using-npm/developers.md b/docs/lib/content/using-npm/developers.md index 5fc2e5876e3dd..0949e7b2879dc 100644 --- a/docs/lib/content/using-npm/developers.md +++ b/docs/lib/content/using-npm/developers.md @@ -61,8 +61,9 @@ goes in that file. At the very least, you need: * name: This should be a string that identifies your project. Please do not use the name to specify that it runs on node, or is in JavaScript. You can use the "engines" field to explicitly state the versions of node - (or whatever else) that your program requires, and it's pretty well - assumed that it's JavaScript. + (or whatever else) that your program requires (and "devEngines" when the + requirements for developers differ from the requirements for users), and + it's pretty well assumed that it's JavaScript. It does not necessarily need to match your github repository name. @@ -71,8 +72,12 @@ goes in that file. At the very least, you need: * version: A semver-compatible version. * engines: Specify the versions of node (or whatever else) that your - program runs on. The node API changes a lot, and there may be bugs or - new functionality that you depend on. Be explicit. + program runs on. The node API changes a lot, and there may be bugs or + new functionality that you depend on. Be explicit. + +* devEngines: Specify the versions of node (or whatever else) that your + program needs for development. The node API changes a lot, and there may + be bugs or new functionality that you depend on. Be explicit. * author: Take some credit. diff --git a/node_modules/npm-install-checks/lib/index.js b/node_modules/npm-install-checks/lib/index.js index 545472b61dc60..ab69e6dc0d689 100644 --- a/node_modules/npm-install-checks/lib/index.js +++ b/node_modules/npm-install-checks/lib/index.js @@ -1,18 +1,29 @@ const semver = require('semver') -const checkEngine = (target, npmVer, nodeVer, force = false) => { +const checkEngine = ({ devEngines, engines, _id: pkgid }, npmVer, nodeVer, force = false, isProjectRoot = false) => { const nodev = force ? null : nodeVer - const eng = target.engines const opt = { includePrerelease: true } - if (!eng) { - return + + let npmEngine = engines?.npm ?? '*'; + let nodeEngine = engines?.node ?? '*'; + + if (isProjectRoot && devEngines?.packageManager) { + npmEngine ??= [].concat(devEngines?.packageManager ?? []).find(({ name }) => name === 'npm')?.version; + nodeEngine ??= [].concat(devEngines?.runtime ?? []).find(({ name }) => name === 'node')?.version; } - const nodeFail = nodev && eng.node && !semver.satisfies(nodev, eng.node, opt) - const npmFail = npmVer && eng.npm && !semver.satisfies(npmVer, eng.npm, opt) + if ( + (!npmEngine || npmEngine === '*') + || (!nodeEngine || nodeEngine === '*') + ) { + return; + } + + const nodeFail = nodev && !semver.satisfies(nodev, nodeEngine, opt) + const npmFail = npmVer && !semver.satisfies(npmVer, npmEngine, opt) if (nodeFail || npmFail) { throw Object.assign(new Error('Unsupported engine'), { - pkgid: target._id, + pkgid, current: { node: nodeVer, npm: npmVer }, required: eng, code: 'EBADENGINE', diff --git a/workspaces/arborist/lib/arborist/build-ideal-tree.js b/workspaces/arborist/lib/arborist/build-ideal-tree.js index 06d03bbce7a32..c743bbb5d23a9 100644 --- a/workspaces/arborist/lib/arborist/build-ideal-tree.js +++ b/workspaces/arborist/lib/arborist/build-ideal-tree.js @@ -195,7 +195,7 @@ module.exports = cls => class IdealTreeBuilder extends cls { for (const node of this.idealTree.inventory.values()) { if (!node.optional) { try { - checkEngine(node.package, npmVersion, nodeVersion, this.options.force) + checkEngine(node.package, npmVersion, nodeVersion, this.options.force, node.isProjectRoot) } catch (err) { if (engineStrict) { throw err diff --git a/workspaces/arborist/lib/shrinkwrap.js b/workspaces/arborist/lib/shrinkwrap.js index 5f720ed9bd440..9664a3973f29c 100644 --- a/workspaces/arborist/lib/shrinkwrap.js +++ b/workspaces/arborist/lib/shrinkwrap.js @@ -93,6 +93,7 @@ const pkgMetaKeys = [ 'acceptDependencies', 'funding', 'engines', + 'devEngines', 'os', 'cpu', '_integrity', diff --git a/workspaces/arborist/test/arborist/build-ideal-tree.js b/workspaces/arborist/test/arborist/build-ideal-tree.js index 807287c73cf11..84b6b8d4a567c 100644 --- a/workspaces/arborist/test/arborist/build-ideal-tree.js +++ b/workspaces/arborist/test/arborist/build-ideal-tree.js @@ -22,7 +22,7 @@ t.teardown(stop) const cache = t.testdir() -// track the warnings that are emitted. returns a function that removes +// track the warnings that are emitted. returns a function that removes // the listener and provides the list of what it saw. const warningTracker = () => { const list = [] @@ -126,6 +126,18 @@ t.test('fail on mismatched engine when engineStrict is set', async t => { }), { code: 'EBADENGINE' }) }) +t.test('fail on mismatched devEngine when engineStrict is set', async t => { + const path = resolve(fixtures, 'dev-engine-specification') + + t.rejects(buildIdeal(path, { + ...OPT, + nodeVersion: '12.18.4', + engineStrict: true, + }).then(() => { + throw new Error('failed to fail') + }), { code: 'EBADENGINE' }) +}) + t.test('fail on malformed package.json', t => { const path = resolve(fixtures, 'malformed-json') @@ -157,6 +169,18 @@ t.test('warn on mismatched engine when engineStrict is false', t => { ])) }) +t.test('warn on mismatched devEngine when engineStrict is false', t => { + const path = resolve(fixtures, 'dev-engine-specification') + const check = warningTracker() + return buildIdeal(path, { + ...OPT, + nodeVersion: '12.18.4', + engineStrict: false, + }).then(() => t.match(check(), [ + ['warn', 'EBADENGINE'], + ])) +}) + t.test('fail on mismatched platform', async t => { const path = resolve(fixtures, 'platform-specification') t.rejects(buildIdeal(path, { diff --git a/workspaces/arborist/test/fixtures/dev-engine-specification/package-lock.json b/workspaces/arborist/test/fixtures/dev-engine-specification/package-lock.json new file mode 100644 index 0000000000000..66622ab489d40 --- /dev/null +++ b/workspaces/arborist/test/fixtures/dev-engine-specification/package-lock.json @@ -0,0 +1,16 @@ +{ + "name": "dev-engine-platform-test", + "version": "1.0.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "@bonkydog/test": "^1.4.0", + "lodash": "^4.17.20" + } + } + } +} diff --git a/workspaces/arborist/test/fixtures/dev-engine-specification/package.json b/workspaces/arborist/test/fixtures/dev-engine-specification/package.json new file mode 100644 index 0000000000000..aa2d1497ab2a2 --- /dev/null +++ b/workspaces/arborist/test/fixtures/dev-engine-specification/package.json @@ -0,0 +1,18 @@ +{ + "name": "dev-engine-platform-test", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "", + "license": "ISC", + "dependencies": { + }, + "devEngines": { + "runtime": { + "node": "< 0.1" + } + } +}