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
7 changes: 4 additions & 3 deletions workspaces/arborist/lib/arborist/rebuild.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const { promiseRetry } = require('@gar/promise-retry')
const { log, time } = require('proc-log')
const { resolve, delimiter } = require('node:path')
const { isScriptAllowed } = require('../script-allowed.js')
const { hasGypfileOptOut } = require('../gypfile.js')

const boolEnv = b => b ? '1' : ''
const sortNodes = (a, b) => (a.depth - b.depth) || localeCompare(a.path, b.path)
Expand Down Expand Up @@ -277,10 +278,10 @@ module.exports = cls => class Builder extends cls {
// Rebuild node-gyp dependencies lacking an install or preinstall script
// note that 'scripts' might be missing entirely, and the package may
// set gypfile:false to avoid this automatic detection.
const isGyp = gypfile !== false &&
!install &&
const isGyp = !install &&
!preinstall &&
await isNodeGypPackage(node.path)
await isNodeGypPackage(node.path) &&
!await hasGypfileOptOut(node.path, gypfile)

if (bin || preinstall || install || postinstall || prepare || isGyp) {
if (bin) {
Expand Down
19 changes: 19 additions & 0 deletions workspaces/arborist/lib/gypfile.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
const PackageJson = require('@npmcli/package-json')

// `gypfile: false` opts a package out of the synthetic `node-gyp rebuild`
// install script npm adds when it finds a `binding.gyp`.
//
// The flag is only on the tree node when the node came from a packument or
// from disk. Lockfile-derived nodes (`npm ci`, a repeat `npm install`) carry
// no `gypfile` field at all, so the opt-out is invisible there and has to be
// read back off the installed package.json.
const hasGypfileOptOut = async (path, gypfile) => {
if (gypfile !== undefined) {
return gypfile === false
}

const { content } = await PackageJson.load(path).catch(() => ({ content: {} }))
return content.gypfile === false
}

module.exports = { hasGypfileOptOut }
5 changes: 3 additions & 2 deletions workspaces/arborist/lib/install-scripts.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
const { isNodeGypPackage } = require('@npmcli/node-gyp')
const PackageJson = require('@npmcli/package-json')
const { hasGypfileOptOut } = require('./gypfile.js')

// Returns the install-relevant lifecycle scripts that would run for a
// given arborist Node, or `{}` if there are none.
Expand Down Expand Up @@ -65,8 +66,8 @@ const getInstallScripts = async (node) => {
const hasExplicitGypGate = !!(collected.preinstall || collected.install)
if (
!hasExplicitGypGate &&
pkg.gypfile !== false &&
await isNodeGypPackage(node.path).catch(() => false)
await isNodeGypPackage(node.path).catch(() => false) &&
!await hasGypfileOptOut(node.path, pkg.gypfile)
) {
collected.install = 'node-gyp rebuild'
}
Expand Down
46 changes: 46 additions & 0 deletions workspaces/arborist/test/arborist/rebuild.js
Original file line number Diff line number Diff line change
Expand Up @@ -603,6 +603,52 @@ t.test('do not rebuild node-gyp dependencies with gypfile:false', async t => {
await arb.rebuild()
})

// ref: https://github.com/npm/cli/issues/9837
t.test('do not rebuild node-gyp dependencies with gypfile:false from a lockfile', async t => {
const Arborist = t.mock('../../lib/arborist/index.js', {
'@npmcli/run-script': async () => {
throw new Error('should not run any scripts')
},
})
const path = t.testdir({
node_modules: {
dep: {
'package.json': JSON.stringify({
name: 'dep',
version: '1.0.0',
gypfile: false,
}),
'binding.gyp': '',
},
},
'package-lock.json': JSON.stringify({
name: 'project',
lockfileVersion: 3,
requires: true,
packages: {
'': {
name: 'project',
dependencies: {
dep: '1',
},
},
'node_modules/dep': {
version: '1.0.0',
},
},
}),
'package.json': JSON.stringify({
name: 'project',
dependencies: {
dep: '1',
},
}),
})
const arb = new Arborist({ path, dangerouslyAllowAllScripts: true })
const tree = await arb.loadVirtual()
await arb.rebuild({ nodes: [...tree.inventory.values()] })
})

// ref: https://github.com/npm/cli/issues/2905
t.test('do not run lifecycle scripts of linked deps twice', async t => {
const testdir = t.testdir({
Expand Down
23 changes: 23 additions & 0 deletions workspaces/arborist/test/install-scripts.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,29 @@ t.test('synthetic node-gyp suppressed when gypfile: false', async t => {
)
})

t.test('synthetic node-gyp suppressed by gypfile: false on disk', async t => {
const getInstallScripts = mockGetInstallScripts(t, () => true)
const path = t.testdir({
'package.json': JSON.stringify({
name: 'dep',
version: '1.0.0',
gypfile: false,
}),
})
t.strictSame(await getInstallScripts(node({ path })), {})
})

t.test('synthetic node-gyp still detected when disk has no gypfile', async t => {
const getInstallScripts = mockGetInstallScripts(t, () => true)
const path = t.testdir({
'package.json': JSON.stringify({ name: 'dep', version: '1.0.0' }),
})
t.strictSame(
await getInstallScripts(node({ path })),
{ install: 'node-gyp rebuild' }
)
})

t.test('synthetic node-gyp suppressed when explicit install is present', async t => {
const getInstallScripts = mockGetInstallScripts(t, () => true)
t.strictSame(
Expand Down