Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion docs/lib/content/using-npm/scripts.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ These scripts happen in addition to the `pre<event>`, `post<event>`, 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`
Expand Down Expand Up @@ -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`
Expand All @@ -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)
Expand All @@ -130,6 +138,7 @@ These all run after the actual installation of modules into

These also run when you run `npm install -g <pkg-name>`

* `preunpack`
* `preinstall`
* `install`
* `postinstall`
Expand Down
19 changes: 18 additions & 1 deletion lib/commands/ci.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
16 changes: 16 additions & 0 deletions lib/commands/install.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions lib/commands/run.js
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ class RunScript extends BaseCommand {
'prepare', 'prepublishOnly',
'prepack', 'postpack',
'dependencies',
'preunpack',
'preinstall', 'install', 'postinstall',
'prepublish', 'publish', 'postpublish',
'prerestart', 'restart', 'postrestart',
Expand Down
81 changes: 81 additions & 0 deletions test/lib/commands/ci.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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')
})
})
113 changes: 113 additions & 0 deletions test/lib/commands/install.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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')
})
})