diff --git a/README.md b/README.md index 2dd8c7f..ac854f3 100644 --- a/README.md +++ b/README.md @@ -28,3 +28,10 @@ Check if a package's `os`, `cpu` and `libc` match the running system. `environment` overrides the execution environment which comes from `process.platform` `process.arch` and current `libc` environment by default. `environment.os` `environment.cpu` and `environment.libc` are available. Error code: 'EBADPLATFORM' + + +### .checkDevEngines(wanted, current, opts) + +Check if a package's `devEngines` property matches the current system environment. + +Returns an array of `Error` objects, some of which may be warnings, this can be checked with `.isError` and `.isWarn`. Errors correspond to an error for a given "engine" failure, reasons for each engine "dependency" failure can be found within `.errors`. \ No newline at end of file diff --git a/lib/current-env.js b/lib/current-env.js new file mode 100644 index 0000000..f9d44f0 --- /dev/null +++ b/lib/current-env.js @@ -0,0 +1,60 @@ +const process = require('node:process') +const nodeOs = require('node:os') + +function isMusl (file) { + return file.includes('libc.musl-') || file.includes('ld-musl-') +} + +function os () { + return process.platform +} + +function cpu () { + return process.arch +} + +function libc (osName) { + // this is to make it faster on non linux machines + if (osName !== 'linux') { + return undefined + } + let family + const report = process.report.getReport() + if (report.header?.glibcVersionRuntime) { + family = 'glibc' + } else if (Array.isArray(report.sharedObjects) && report.sharedObjects.some(isMusl)) { + family = 'musl' + } + return family +} + +function devEngines (env = {}) { + const osName = env.os || os() + return { + cpu: { + name: env.cpu || cpu(), + }, + libc: { + name: env.libc || libc(osName), + }, + os: { + name: osName, + version: env.osVersion || nodeOs.release(), + }, + packageManager: { + name: 'npm', + version: env.npmVersion, + }, + runtime: { + name: 'node', + version: env.nodeVersion || process.version, + }, + } +} + +module.exports = { + cpu, + libc, + os, + devEngines, +} diff --git a/lib/dev-engines.js b/lib/dev-engines.js new file mode 100644 index 0000000..ac5a182 --- /dev/null +++ b/lib/dev-engines.js @@ -0,0 +1,145 @@ +const satisfies = require('semver/functions/satisfies') +const validRange = require('semver/ranges/valid') + +const recognizedOnFail = [ + 'ignore', + 'warn', + 'error', + 'download', +] + +const recognizedProperties = [ + 'name', + 'version', + 'onFail', +] + +const recognizedEngines = [ + 'packageManager', + 'runtime', + 'cpu', + 'libc', + 'os', +] + +/** checks a devEngine dependency */ +function checkDependency (wanted, current, opts) { + const { engine } = opts + + if ((typeof wanted !== 'object' || wanted === null) || Array.isArray(wanted)) { + throw new Error(`Invalid non-object value for "${engine}"`) + } + + const properties = Object.keys(wanted) + + for (const prop of properties) { + if (!recognizedProperties.includes(prop)) { + throw new Error(`Invalid property "${prop}" for "${engine}"`) + } + } + + if (!properties.includes('name')) { + throw new Error(`Missing "name" property for "${engine}"`) + } + + if (typeof wanted.name !== 'string') { + throw new Error(`Invalid non-string value for "name" within "${engine}"`) + } + + if (typeof current.name !== 'string' || current.name === '') { + throw new Error(`Unable to determine "name" for "${engine}"`) + } + + if (properties.includes('onFail')) { + if (typeof wanted.onFail !== 'string') { + throw new Error(`Invalid non-string value for "onFail" within "${engine}"`) + } + if (!recognizedOnFail.includes(wanted.onFail)) { + throw new Error(`Invalid onFail value "${wanted.onFail}" for "${engine}"`) + } + } + + if (wanted.name !== current.name) { + return new Error( + `Invalid name "${wanted.name}" does not match "${current.name}" for "${engine}"` + ) + } + + if (properties.includes('version')) { + if (typeof wanted.version !== 'string') { + throw new Error(`Invalid non-string value for "version" within "${engine}"`) + } + if (typeof current.version !== 'string' || current.version === '') { + throw new Error(`Unable to determine "version" for "${engine}" "${wanted.name}"`) + } + if (validRange(wanted.version)) { + if (!satisfies(current.version, wanted.version, opts.semver)) { + return new Error( + // eslint-disable-next-line max-len + `Invalid semver version "${wanted.version}" does not match "${current.version}" for "${engine}"` + ) + } + } else if (wanted.version !== current.version) { + return new Error( + `Invalid version "${wanted.version}" does not match "${current.version}" for "${engine}"` + ) + } + } +} + +/** checks devEngines package property and returns array of warnings / errors */ +function checkDevEngines (wanted, current = {}, opts = {}) { + if ((typeof wanted !== 'object' || wanted === null) || Array.isArray(wanted)) { + throw new Error(`Invalid non-object value for devEngines`) + } + + const errors = [] + + for (const engine of Object.keys(wanted)) { + if (!recognizedEngines.includes(engine)) { + throw new Error(`Invalid property "${engine}"`) + } + const dependencyAsAuthored = wanted[engine] + const dependencies = [dependencyAsAuthored].flat() + const currentEngine = current[engine] || {} + + // this accounts for empty array eg { runtime: [] } and ignores it + if (dependencies.length === 0) { + continue + } + + const depErrors = [] + for (const dep of dependencies) { + const result = checkDependency(dep, currentEngine, { ...opts, engine }) + if (result) { + depErrors.push(result) + } + } + + const invalid = depErrors.length === dependencies.length + + if (invalid) { + const lastDependency = dependencies[dependencies.length - 1] + let onFail = lastDependency.onFail || 'error' + if (onFail === 'download') { + onFail = 'error' + } + + const err = Object.assign(new Error(`Invalid engine "${engine}"`), { + errors: depErrors, + engine, + isWarn: onFail === 'warn', + isError: onFail === 'error', + current: currentEngine, + required: dependencyAsAuthored, + }) + + errors.push(err) + } + } + return errors +} + +module.exports = { + checkDevEngines, +} diff --git a/lib/index.js b/lib/index.js index 545472b..7170292 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,4 +1,6 @@ const semver = require('semver') +const currentEnv = require('./current-env') +const { checkDevEngines } = require('./dev-engines') const checkEngine = (target, npmVer, nodeVer, force = false) => { const nodev = force ? null : nodeVer @@ -20,44 +22,29 @@ const checkEngine = (target, npmVer, nodeVer, force = false) => { } } -const isMusl = (file) => file.includes('libc.musl-') || file.includes('ld-musl-') - const checkPlatform = (target, force = false, environment = {}) => { if (force) { return } - const platform = environment.os || process.platform - const arch = environment.cpu || process.arch - const osOk = target.os ? checkList(platform, target.os) : true - const cpuOk = target.cpu ? checkList(arch, target.cpu) : true + const os = environment.os || currentEnv.os() + const cpu = environment.cpu || currentEnv.cpu() + const libc = environment.libc || currentEnv.libc(os) - let libcOk = true - let libcFamily = null - if (target.libc) { - // libc checks only work in linux, any value is a failure if we aren't - if (environment.libc) { - libcOk = checkList(environment.libc, target.libc) - } else if (platform !== 'linux') { - libcOk = false - } else { - const report = process.report.getReport() - if (report.header?.glibcVersionRuntime) { - libcFamily = 'glibc' - } else if (Array.isArray(report.sharedObjects) && report.sharedObjects.some(isMusl)) { - libcFamily = 'musl' - } - libcOk = libcFamily ? checkList(libcFamily, target.libc) : false - } + const osOk = target.os ? checkList(os, target.os) : true + const cpuOk = target.cpu ? checkList(cpu, target.cpu) : true + let libcOk = target.libc ? checkList(libc, target.libc) : true + if (target.libc && !libc) { + libcOk = false } if (!osOk || !cpuOk || !libcOk) { throw Object.assign(new Error('Unsupported platform'), { pkgid: target._id, current: { - os: platform, - cpu: arch, - libc: libcFamily, + os, + cpu, + libc, }, required: { os: target.os, @@ -98,4 +85,6 @@ const checkList = (value, list) => { module.exports = { checkEngine, checkPlatform, + checkDevEngines, + currentEnv, } diff --git a/test/check-dev-engines.js b/test/check-dev-engines.js new file mode 100644 index 0000000..e464383 --- /dev/null +++ b/test/check-dev-engines.js @@ -0,0 +1,419 @@ +const t = require('tap') +const { checkDevEngines, currentEnv } = require('..') + +t.test('noop options', async t => { + t.same(checkDevEngines({ + runtime: [], + }, currentEnv.devEngines()), []) +}) + +t.test('unrecognized property', async t => { + const wanted = { name: `alpha`, version: '1' } + const current = { name: `alpha` } + t.throws( + () => checkDevEngines({ unrecognized: wanted }, { os: current }), + new Error('Invalid property "unrecognized"') + ) +}) + +t.test('empty devEngines', async t => { + t.same(checkDevEngines({ }, { os: { name: `darwin` } }), []) +}) + +t.test('invalid name', async t => { + const wanted = { name: `alpha`, onFail: 'download' } + const current = { name: `beta` } + t.same(checkDevEngines({ os: wanted }, { os: current }), [ + Object.assign(new Error(`Invalid engine "os"`), { + errors: [ + new Error(`Invalid name "alpha" does not match "beta" for "os"`), + ], + engine: 'os', + isWarn: false, + isError: true, + current, + required: wanted, + }), + ]) +}) + +t.test('default options', async t => { + t.same(checkDevEngines({}, currentEnv.devEngines()), []) +}) + +t.test('tests non-object', async t => { + const core = [1, true, false, null, undefined] + for (const nonObject of [...core, [[]], ...core.map(v => [v])]) { + t.test('invalid devEngines', async t => { + t.throws( + () => checkDevEngines(nonObject, { + runtime: { + name: 'nondescript', + version: '14', + }, + }), + new Error(`Invalid non-object value for devEngines`) + ) + }) + + t.test('invalid engine property', async t => { + t.throws( + () => checkDevEngines({ + runtime: nonObject, + }, { + runtime: { + name: 'nondescript', + version: '14', + }, + }), + new Error(`Invalid non-object value for "runtime"`) + ) + }) + } +}) + +t.test('tests non-string ', async t => { + for (const nonString of [1, true, false, null, undefined, {}, []]) { + t.test('invalid name value', async t => { + t.throws( + () => checkDevEngines({ + runtime: { + name: nonString, + version: '14', + }, + }, { + runtime: { + name: 'nondescript', + version: '14', + }, + }), + new Error(`Invalid non-string value for "name" within "runtime"`) + ) + }) + t.test('invalid version value', async t => { + t.throws( + () => checkDevEngines({ + runtime: { + name: 'nondescript', + version: nonString, + }, + }, { + runtime: { + name: 'nondescript', + version: '14', + }, + }), + new Error(`Invalid non-string value for "version" within "runtime"`) + ) + }) + t.test('invalid onFail value', async t => { + t.throws( + () => checkDevEngines({ + runtime: { + name: 'nondescript', + version: '14', + onFail: nonString, + }, + }, { + runtime: { + name: 'nondescript', + version: '14', + }, + }), + new Error(`Invalid non-string value for "onFail" within "runtime"`) + ) + }) + } +}) + +t.test('tests all the right fields', async t => { + for (const env of ['packageManager', 'runtime', 'cpu', 'libc', 'os']) { + t.test(`field - ${env}`, async t => { + t.test('current name does not match, wanted has extra attribute', async t => { + const wanted = { name: `test-${env}-wanted`, extra: `test-${env}-extra` } + const current = { name: `test-${env}-current` } + t.throws( + () => checkDevEngines({ [env]: wanted }, { [env]: current }), + new Error(`Invalid property "extra" for "${env}"`) + ) + }) + t.test('current is not given', async t => { + const wanted = { name: `test-${env}-wanted` } + t.throws( + () => checkDevEngines({ [env]: wanted }), + new Error(`Unable to determine "name" for "${env}"`) + ) + }) + t.test('name only', async t => { + const wanted = { name: 'test-name' } + const current = { name: 'test-name' } + t.same(checkDevEngines({ [env]: wanted }, { [env]: current }), []) + }) + t.test('non-semver version is not the same', async t => { + const wanted = { name: `test-name`, version: 'test-version-wanted' } + const current = { name: `test-name`, version: 'test-version-current' } + t.same(checkDevEngines({ [env]: wanted }, { [env]: current }), [ + Object.assign(new Error(`Invalid engine "${env}"`), { + errors: [ + // eslint-disable-next-line max-len + new Error(`Invalid version "test-version-wanted" does not match "test-version-current" for "${env}"`), + ], + engine: env, + isWarn: false, + isError: true, + current: { name: `test-name`, version: 'test-version-current' }, + required: { name: `test-name`, version: 'test-version-wanted' }, + }), + ]) + }) + t.test('non-semver version is the same', async t => { + const wanted = { name: `test-name`, version: 'test-version' } + const current = { name: `test-name`, version: 'test-version' } + t.same(checkDevEngines({ [env]: wanted }, { [env]: current }), []) + }) + t.test('semver version is not in range', async t => { + const wanted = { name: `test-name`, version: '^1.0.0' } + const current = { name: `test-name`, version: '2.0.0' } + t.same(checkDevEngines({ [env]: wanted }, { [env]: current }), [ + Object.assign(new Error(`Invalid engine "${env}"`), { + errors: [ + // eslint-disable-next-line max-len + new Error(`Invalid semver version "^1.0.0" does not match "2.0.0" for "${env}"`), + ], + engine: env, + isWarn: false, + isError: true, + current: { name: `test-name`, version: '2.0.0' }, + required: { name: `test-name`, version: '^1.0.0' }, + }), + ]) + }) + t.test('semver version is in range', async t => { + const wanted = { name: `test-name`, version: '^1.0.0' } + const current = { name: `test-name`, version: '1.0.0' } + t.same(checkDevEngines({ [env]: wanted }, { [env]: current }), []) + }) + t.test('returns the last failure', async t => { + const wanted = [ + { name: `test-name`, version: 'test-version-one' }, + { name: `test-name`, version: 'test-version-two' }, + ] + const current = { name: `test-name`, version: 'test-version-three' } + t.same(checkDevEngines({ [env]: wanted }, { [env]: current }), [ + Object.assign(new Error(`Invalid engine "${env}"`), { + errors: [ + // eslint-disable-next-line max-len + new Error(`Invalid version "test-version-one" does not match "test-version-three" for "${env}"`), + // eslint-disable-next-line max-len + new Error(`Invalid version "test-version-two" does not match "test-version-three" for "${env}"`), + ], + engine: env, + isWarn: false, + isError: true, + current: { name: `test-name`, version: 'test-version-three' }, + required: [ + { name: `test-name`, version: 'test-version-one' }, + { name: `test-name`, version: 'test-version-two' }, + ], + }), + ]) + }) + t.test('unrecognized onFail', async t => { + const wanted = { name: `test-name`, version: '^1.0.0', onFail: 'unrecognized' } + const current = { name: `test-name`, version: '1.0.0' } + t.throws( + () => checkDevEngines({ [env]: wanted }, { [env]: current }), + new Error(`Invalid onFail value "unrecognized" for "${env}"`) + ) + }) + t.test('missing name', async t => { + const wanted = { version: '^1.0.0' } + const current = { name: `test-name`, version: '1.0.0' } + t.throws( + () => checkDevEngines({ [env]: wanted }, { [env]: current }), + new Error(`Missing "name" property for "${env}"`) + ) + }) + t.test('invalid name', async t => { + const wanted = { name: `alpha` } + const current = { name: `beta` } + t.same(checkDevEngines({ [env]: wanted }, { [env]: current }), [ + Object.assign(new Error(`Invalid engine "${env}"`), { + errors: [ + new Error(`Invalid name "alpha" does not match "beta" for "${env}"`), + ], + engine: env, + isWarn: false, + isError: true, + current, + required: wanted, + }), + ]) + }) + t.test('missing version', async t => { + const wanted = { name: `alpha`, version: '1' } + const current = { name: `alpha` } + t.throws( + () => checkDevEngines({ [env]: wanted }, { [env]: current }), + new Error(`Unable to determine "version" for "${env}" "alpha"`) + ) + }) + }) + } +}) + +t.test('spec 1', async t => { + const example = { + runtime: { + name: 'node', + version: '>= 20.0.0', + onFail: 'error', + }, + packageManager: { + name: 'yarn', + version: '3.2.3', + onFail: 'download', + }, + } + + t.same(checkDevEngines(example, { + os: { name: 'darwin', version: '23.0.0' }, + cpu: { name: 'arm' }, + libc: { name: 'glibc' }, + runtime: { name: 'node', version: '20.0.0' }, + packageManager: { name: 'yarn', version: '3.2.3' }, + }), []) +}) + +t.test('spec 2', async t => { + const example = { + os: { + name: 'darwin', + version: '>= 23.0.0', + }, + cpu: [ + { + name: 'arm', + }, + { + name: 'x86', + }, + ], + libc: { + name: 'glibc', + }, + runtime: [ + { + name: 'bun', + version: '>= 1.0.0', + onFail: 'ignore', + }, + { + name: 'node', + version: '>= 20.0.0', + onFail: 'error', + }, + ], + packageManager: [ + { + name: 'bun', + version: '>= 1.0.0', + onFail: 'ignore', + }, + { + name: 'yarn', + version: '3.2.3', + onFail: 'download', + }, + ], + } + + t.same(checkDevEngines(example, { + os: { name: 'darwin', version: '23.0.0' }, + cpu: { name: 'arm' }, + libc: { name: 'glibc' }, + runtime: { name: 'node', version: '20.0.0' }, + packageManager: { name: 'yarn', version: '3.2.3' }, + }), []) + + t.same(checkDevEngines(example, { + os: { name: 'darwin', version: '10.0.0' }, + cpu: { name: 'arm' }, + libc: { name: 'glibc' }, + runtime: { name: 'node', version: '20.0.0' }, + packageManager: { name: 'yarn', version: '3.2.3' }, + }), [ + Object.assign(new Error(`Invalid engine "os"`), { + errors: [ + // eslint-disable-next-line max-len + new Error(`Invalid semver version ">= 23.0.0" does not match "10.0.0" for "os"`), + ], + engine: 'os', + isWarn: false, + isError: true, + current: { name: 'darwin', version: '10.0.0' }, + required: { + name: 'darwin', + version: '>= 23.0.0', + }, + }), + ]) + + t.same(checkDevEngines(example, { + os: { name: 'darwin', version: '23.0.0' }, + cpu: { name: 'arm' }, + libc: { name: 'glibc' }, + runtime: { name: 'nondescript', version: '20.0.0' }, + packageManager: { name: 'yarn', version: '3.2.3' }, + }), [ + Object.assign(new Error(`Invalid engine "runtime"`), { + errors: [ + // eslint-disable-next-line max-len + new Error(`Invalid name "bun" does not match "nondescript" for "runtime"`), + // eslint-disable-next-line max-len + new Error(`Invalid name "node" does not match "nondescript" for "runtime"`), + ], + engine: 'runtime', + isWarn: false, + isError: true, + current: { name: 'nondescript', version: '20.0.0' }, + required: [ + { + name: 'bun', + version: '>= 1.0.0', + onFail: 'ignore', + }, + { + name: 'node', + version: '>= 20.0.0', + onFail: 'error', + }, + ], + }), + ]) +}) + +t.test('empty array along side error', async t => { + t.same(checkDevEngines({ + cpu: [], + runtime: { + name: 'bun', + onFail: 'error', + }, + }, { + cpu: { name: 'arm' }, + runtime: { name: 'node', version: '20.0.0' }, + }), [Object.assign(new Error(`Invalid engine "runtime"`), { + errors: [ + new Error(`Invalid name "bun" does not match "node" for "runtime"`), + ], + engine: 'runtime', + isWarn: false, + isError: true, + current: { name: 'node', version: '20.0.0' }, + required: { + name: 'bun', + onFail: 'error', + }, + })]) +})