diff --git a/docs/lib/content/using-npm/scripts.md b/docs/lib/content/using-npm/scripts.md index 1613d803ee3e9..b18613a6a5db0 100644 --- a/docs/lib/content/using-npm/scripts.md +++ b/docs/lib/content/using-npm/scripts.md @@ -39,6 +39,12 @@ These scripts happen in addition to the `pre`, `post`, and * `prepare`, `prepublish`, `prepublishOnly`, `prepack`, `postpack`, `dependencies` +**preunpack** +* Runs BEFORE dependencies are installed, ONLY on local `npm install` (without arguments) and `npm ci`. +* Provides a hook for setup tasks that must happen before any packages are fetched, such as authenticating to private registries or validating environment prerequisites. +* Does NOT run when installing specific packages (e.g., `npm install express`), during global installs, or when `--ignore-scripts` is set. +* If the script exits with a non-zero code, the installation is aborted. + **prepare** (since `npm@4.0.0`) * Runs BEFORE the package is packed, i.e. during `npm publish` and `npm pack` @@ -111,6 +117,7 @@ It is run AFTER the changes have been applied and the `package.json` and `packag #### [`npm ci`](/commands/npm-ci) +* `preunpack` * `preinstall` * `install` * `postinstall` @@ -119,7 +126,8 @@ It is run AFTER the changes have been applied and the `package.json` and `packag * `prepare` * `postprepare` -These all run after the actual installation of modules into +`preunpack` runs before the actual installation of modules. +All other scripts run after the actual installation of modules into `node_modules`, in order, with no internal actions happening in between #### [`npm diff`](/commands/npm-diff) @@ -130,6 +138,7 @@ These all run after the actual installation of modules into These also run when you run `npm install -g ` +* `preunpack` * `preinstall` * `install` * `postinstall` diff --git a/lib/commands/ci.js b/lib/commands/ci.js index 7480e645a4e0d..f36c5387af517 100644 --- a/lib/commands/ci.js +++ b/lib/commands/ci.js @@ -98,9 +98,26 @@ class CI extends ArboristWorkspaceCmd { }) } + // Run 'preunpack' lifecycle script before installation. + // This provides a hook to run setup tasks (e.g., private registry auth, + // environment validation) before dependencies are fetched and installed. + // See: https://github.com/npm/cli/issues/2660 + // See: https://github.com/npm/rfcs/pull/403 + const ignoreScripts = this.npm.config.get('ignore-scripts') + if (!ignoreScripts) { + const scriptShell = this.npm.config.get('script-shell') || undefined + await runScript({ + path: where, + args: [], + scriptShell, + stdio: 'inherit', + banner: !this.npm.silent, + event: 'preunpack', + }) + } + await arb.reify(opts) - const ignoreScripts = this.npm.config.get('ignore-scripts') // run the same set of scripts that `npm install` runs. if (!ignoreScripts) { const scripts = [ diff --git a/lib/commands/install.js b/lib/commands/install.js index 707848a43d48f..99eb31595f918 100644 --- a/lib/commands/install.js +++ b/lib/commands/install.js @@ -139,6 +139,22 @@ class Install extends ArboristWorkspaceCmd { throw this.usageError() } + // Run 'preunpack' lifecycle script before any installation. + // This provides a hook to run setup tasks (e.g., private registry auth, + // environment validation) before dependencies are fetched and installed. + // See: https://github.com/npm/cli/issues/2660 + // See: https://github.com/npm/rfcs/pull/403 + if (!args.length && !isGlobalInstall && !ignoreScripts) { + await runScript({ + path: where, + args: [], + scriptShell, + stdio: 'inherit', + banner: !this.npm.silent, + event: 'preunpack', + }) + } + const Arborist = require('@npmcli/arborist') const opts = { ...this.npm.flatOptions, diff --git a/lib/commands/run.js b/lib/commands/run.js index d89cb4d93bb7f..1fb2452b0da22 100644 --- a/lib/commands/run.js +++ b/lib/commands/run.js @@ -174,6 +174,7 @@ class RunScript extends BaseCommand { 'prepare', 'prepublishOnly', 'prepack', 'postpack', 'dependencies', + 'preunpack', 'preinstall', 'install', 'postinstall', 'prepublish', 'publish', 'postpublish', 'prerestart', 'restart', 'postrestart', diff --git a/test/lib/commands/ci.js b/test/lib/commands/ci.js index 8dc0f1d3cc149..11a7ce42a0906 100644 --- a/test/lib/commands/ci.js +++ b/test/lib/commands/ci.js @@ -172,6 +172,7 @@ t.test('lifecycle scripts', async t => { registry.nock.post('/-/npm/v1/security/advisories/bulk').reply(200, {}) await npm.exec('ci', []) t.same(scripts, [ + 'preunpack', 'preinstall', 'install', 'postinstall', @@ -308,3 +309,83 @@ t.test('should use --workspace flag', async t => { assert.packageMissing('node_modules/abbrev@1.1.0') assert.packageInstalled('node_modules/lodash@1.1.1') }) + +t.test('preunpack lifecycle script tests for ci', async t => { + await t.test('preunpack runs before arborist reify', async t => { + const events = [] + let reifyCalled = false + + const { npm } = await loadMockNpm(t, { + prefixDir: { + 'package.json': JSON.stringify({ + ...packageJson, + scripts: { + preunpack: 'echo preunpack', + }, + }), + 'package-lock.json': JSON.stringify(packageLock), + }, + mocks: { + '{LIB}/utils/reify-finish.js': async () => {}, + '@npmcli/run-script': (opts) => { + if (opts.event === 'preunpack') { + events.push('preunpack') + t.notOk(reifyCalled, 'preunpack should run before arborist.reify is called') + } + }, + '@npmcli/arborist': function () { + this.virtualTree = { inventory: new Map() } + this.actualTree = { inventory: new Map() } + this.idealTree = { inventory: new Map() } + + this.loadActual = async () => this.actualTree + this.loadVirtual = async () => this.virtualTree + this.buildIdealTree = async () => this.idealTree + this.reify = async () => { + reifyCalled = true + events.push('reify') + t.ok(events.includes('preunpack'), 'preunpack should have run before reify') + return {} + } + }, + }, + }) + + await npm.exec('ci', []) + t.same(events, ['preunpack', 'reify'], 'preunpack runs before reify in ci') + t.ok(reifyCalled, 'arborist reify should have been called') + }) + + await t.test('preunpack does not run with --ignore-scripts', async t => { + const scripts = [] + const { npm, registry } = await loadMockNpm(t, { + config: { + 'ignore-scripts': true, + }, + prefixDir: { + 'package.json': JSON.stringify({ + ...packageJson, + scripts: { + preunpack: 'echo preunpack', + preinstall: 'echo preinstall', + }, + }), + 'package-lock.json': JSON.stringify(packageLock), + abbrev, + }, + mocks: { + '@npmcli/run-script': (opts) => { + scripts.push(opts.event) + }, + }, + }) + const manifest = registry.manifest({ name: 'abbrev' }) + await registry.tarball({ + manifest: manifest.versions['1.0.0'], + tarball: path.join(npm.prefix, 'abbrev'), + }) + registry.nock.post('/-/npm/v1/security/advisories/bulk').reply(200, {}) + await npm.exec('ci', []) + t.same(scripts, [], 'no scripts should run with --ignore-scripts') + }) +}) diff --git a/test/lib/commands/install.js b/test/lib/commands/install.js index d886ed787166c..5af68dd583260 100644 --- a/test/lib/commands/install.js +++ b/test/lib/commands/install.js @@ -60,6 +60,7 @@ t.test('exec commands', async t => { await t.test('without args runs lifecycle scripts', async t => { const lifecycleScripts = [ + 'preunpack', 'preinstall', 'install', 'postinstall', @@ -770,3 +771,115 @@ t.test('devEngines', async t => { t.ok(!output.includes('EBADDEVENGINES')) }) }) + +t.test('preunpack lifecycle script', async t => { + await t.test('preunpack runs before arborist reify', async t => { + const events = [] + let reifyCalled = false + + const { npm } = await loadMockNpm(t, { + config: { audit: false }, + prefixDir: { + 'package.json': JSON.stringify({ + ...packageJson, + scripts: { + preunpack: 'echo preunpack', + }, + }), + }, + mocks: { + '{LIB}/utils/reify-finish.js': async () => {}, + '@npmcli/run-script': (opts) => { + if (opts.event === 'preunpack') { + events.push('preunpack') + t.notOk(reifyCalled, 'preunpack should run before arborist.reify is called') + } + }, + '@npmcli/arborist': function () { + this.loadActual = async () => ({}) + this.loadVirtual = async () => ({}) + this.buildIdealTree = async () => ({}) + this.reify = async () => { + reifyCalled = true + events.push('reify') + t.ok(events.includes('preunpack'), 'preunpack should have run before reify') + return {} + } + }, + }, + }) + + await npm.exec('install') + t.same(events, ['preunpack', 'reify'], 'preunpack runs before reify') + t.ok(reifyCalled, 'arborist reify should have been called') + }) + + await t.test('preunpack does not run when installing specific packages', async t => { + const scripts = [] + + const { npm, registry } = await loadMockNpm(t, { + config: { audit: false }, + prefixDir: { + 'package.json': JSON.stringify({ + ...packageJson, + scripts: { + preunpack: 'echo preunpack', + preinstall: 'echo preinstall', + }, + }), + abbrev, + }, + mocks: { + '@npmcli/run-script': (opts) => { + scripts.push(opts.event) + }, + }, + }) + + const manifest = registry.manifest({ name: 'abbrev' }) + await registry.package({ manifest }) + await registry.tarball({ + manifest: manifest.versions['1.0.0'], + tarball: path.join(npm.prefix, 'abbrev'), + }) + + await npm.exec('install', ['abbrev']) + t.same(scripts, [], 'no project lifecycle scripts should run when installing specific packages') + }) + + await t.test('preunpack does not run with --ignore-scripts', async t => { + const scripts = [] + + const { npm, registry } = await loadMockNpm(t, { + config: { + 'ignore-scripts': true, + audit: false, + }, + prefixDir: { + 'package.json': JSON.stringify({ + ...packageJson, + scripts: { + preunpack: 'echo preunpack', + preinstall: 'echo preinstall', + }, + }), + abbrev, + }, + mocks: { + '@npmcli/run-script': (opts) => { + scripts.push(opts.event) + }, + }, + }) + + const manifest = registry.manifest({ name: 'abbrev' }) + await registry.package({ manifest }) + await registry.tarball({ + manifest: manifest.versions['1.0.0'], + tarball: path.join(npm.prefix, 'abbrev'), + }) + + await npm.exec('install') + t.same(scripts, [], 'no lifecycle scripts should run with --ignore-scripts') + }) +})