Skip to content

Commit

Permalink
feat: devEngines (#7766)
Browse files Browse the repository at this point in the history
This PR adds a check for `devEngines` in the current projects
`package.json` as defined in the spec here:

openjs-foundation/package-metadata-interoperability-collab-space#15

This PR utilizes a `checkDevEngines` function defined within
`npm-install-checks` open here:
npm/npm-install-checks#116

The goal of this pr is to have a check for specific npm commands
`install`, and `run` consult the `devEngines` property before execution
and check if the current system / environment. For `npm ` the runtime
will always be `node` and the `packageManager` will always be `npm`, if
a project is defined as not those two envs and it's required we'll
throw.

> Note the current `engines` property is checked when you install your
dependencies. Each packages `engines` are checked with your environment.
However, `devEngines` operates on commands for maintainers of a package,
service, project when install and run commands are executed and is meant
to enforce / guide maintainers to all be using the same engine / env and
or versions.
  • Loading branch information
reggi authored Oct 3, 2024
1 parent 95e2cb1 commit 4d57928
Show file tree
Hide file tree
Showing 13 changed files with 765 additions and 3 deletions.
26 changes: 26 additions & 0 deletions docs/lib/content/configuring-npm/package-json.md
Original file line number Diff line number Diff line change
Expand Up @@ -1129,6 +1129,32 @@ Like the `os` option, you can also block architectures:
The host architecture is determined by `process.arch`
### devEngines
The `devEngines` field aids engineers working on a codebase to all be using the same tooling.
You can specify a `devEngines` property in your `package.json` which will run before `install`, `ci`, and `run` commands.
> Note: `engines` and `devEngines` differ in object shape. They also function very differently. `engines` is designed to alert the user when a dependency uses a differening npm or node version that the project it's being used in, whereas `devEngines` is used to alert people interacting with the source code of a project.

The supported keys under the `devEngines` property are `cpu`, `os`, `libc`, `runtime`, and `packageManager`. Each property can be an object or an array of objects. Objects must contain `name`, and optionally can specify `version`, and `onFail`. `onFail` can be `warn`, `error`, or `ignore`, and if left undefined is of the same value as `error`. `npm` will assume that you're running with `node`.
Here's an example of a project that will fail if the environment is not `node` and `npm`. If you set `runtime.name` or `packageManager.name` to any other string, it will fail within the npm CLI.

```json
{
"devEngines": {
"runtime": {
"name": "node",
"onFail": "error"
},
"packageManager": {
"name": "npm",
"onFail": "error"
}
}
}
```

### private

If you set `"private": true` in your package.json, then npm will refuse to
Expand Down
1 change: 1 addition & 0 deletions lib/arborist-cmd.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ class ArboristCmd extends BaseCommand {

static workspaces = true
static ignoreImplicitWorkspace = false
static checkDevEngines = true

constructor (npm) {
super(npm)
Expand Down
61 changes: 60 additions & 1 deletion lib/base-cmd.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
const { log } = require('proc-log')

class BaseCommand {
// these defaults can be overridden by individual commands
static workspaces = false
static ignoreImplicitWorkspace = true
static checkDevEngines = false

// these are all overridden by individual commands
// these should always be overridden by individual commands
static name = null
static description = null
static params = null
Expand Down Expand Up @@ -129,6 +131,63 @@ class BaseCommand {
}
}

// Checks the devEngines entry in the package.json at this.localPrefix
async checkDevEngines () {
const force = this.npm.flatOptions.force

const { devEngines } = await require('@npmcli/package-json')
.normalize(this.npm.config.localPrefix)
.then(p => p.content)
.catch(() => ({}))

if (typeof devEngines === 'undefined') {
return
}

const { checkDevEngines, currentEnv } = require('npm-install-checks')
const current = currentEnv.devEngines({
nodeVersion: this.npm.nodeVersion,
npmVersion: this.npm.version,
})

const failures = checkDevEngines(devEngines, current)
const warnings = failures.filter(f => f.isWarn)
const errors = failures.filter(f => f.isError)

const genMsg = (failure, i = 0) => {
return [...new Set([
// eslint-disable-next-line
i === 0 ? 'The developer of this package has specified the following through devEngines' : '',
`${failure.message}`,
`${failure.errors.map(e => e.message).join('\n')}`,
])].filter(v => v).join('\n')
}

[...warnings, ...(force ? errors : [])].forEach((failure, i) => {
const message = genMsg(failure, i)
log.warn('EBADDEVENGINES', message)
log.warn('EBADDEVENGINES', {
current: failure.current,
required: failure.required,
})
})

if (force) {
return
}

if (errors.length) {
const failure = errors[0]
const message = genMsg(failure)
throw Object.assign(new Error(message), {
engine: failure.engine,
code: 'EBADDEVENGINES',
current: failure.current,
required: failure.required,
})
}
}

async setWorkspaces () {
const { relative } = require('node:path')

Expand Down
1 change: 1 addition & 0 deletions lib/commands/run-script.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ class RunScript extends BaseCommand {
static workspaces = true
static ignoreImplicitWorkspace = false
static isShellout = true
static checkDevEngines = true

static async completion (opts, npm) {
const argv = opts.conf.argv.remain
Expand Down
4 changes: 4 additions & 0 deletions lib/npm.js
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,10 @@ class Npm {
execWorkspaces = true
}

if (command.checkDevEngines && !this.global) {
await command.checkDevEngines()
}

return time.start(`command:${cmd}`, () =>
execWorkspaces ? command.execWorkspaces(args) : command.exec(args))
}
Expand Down
7 changes: 7 additions & 0 deletions lib/utils/error-message.js
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,13 @@ const errorMessage = (er, npm) => {
].join('\n')])
break

case 'EBADDEVENGINES': {
const { current, required } = er
summary.push(['EBADDEVENGINES', er.message])
detail.push(['EBADDEVENGINES', { current, required }])
break
}

case 'EBADPLATFORM': {
const actual = er.current
const expected = { ...er.required }
Expand Down
Loading

0 comments on commit 4d57928

Please sign in to comment.