Skip to content

Commit c9045d5

Browse files
JamieMageeowlstronaut
authored andcommitted
fix(arborist): read install scripts from disk on lockfile installs instead of a sentinel
(cherry picked from commit a81f2f8)
1 parent 3bd3377 commit c9045d5

2 files changed

Lines changed: 210 additions & 9 deletions

File tree

workspaces/arborist/lib/install-scripts.js

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
const { isNodeGypPackage } = require('@npmcli/node-gyp')
2+
const PackageJson = require('@npmcli/package-json')
23

34
// Returns the install-relevant lifecycle scripts that would run for a
45
// given arborist Node, or `{}` if there are none.
@@ -70,15 +71,40 @@ const getInstallScripts = async (node) => {
7071
collected.install = 'node-gyp rebuild'
7172
}
7273

73-
// Lockfile-only nodes (e.g. `npm ci` before reify) carry
74-
// `hasInstallScript: true` but no enumerated scripts: the lockfile
75-
// records the presence flag but never the script bodies. Without this
76-
// fallback the strict-allow-scripts preflight would miss them entirely
77-
// and let postinstall run. We can't recover the real script body
78-
// without fetching the manifest, so emit a sentinel describing that
79-
// install scripts are present.
74+
// Lockfile-only nodes carry `hasInstallScript: true` but no enumerated
75+
// scripts: the lockfile records the presence flag, not the script bodies,
76+
// so `node.package.scripts` is empty on a lockfile-driven install (`npm ci`,
77+
// a repeat `npm install`). Before giving up, read the installed
78+
// package.json from disk to recover the real script bodies. Builder#addToBuildSet
79+
// does the same disk read to decide what to run, but unlike that path this
80+
// one is read-only: we never mutate `node.package`.
8081
if (Object.keys(collected).length === 0 && node.hasInstallScript === true) {
81-
collected.install = '(install scripts present)'
82+
const { content } = await PackageJson.normalize(node.path)
83+
.catch(() => ({ content: {} }))
84+
/* istanbul ignore next: normalize resolves to an object with a scripts
85+
object, or our catch fallback returns {}; defensive guard only. */
86+
const diskScripts = content?.scripts || {}
87+
88+
if (diskScripts.preinstall) {
89+
collected.preinstall = diskScripts.preinstall
90+
}
91+
if (diskScripts.install) {
92+
collected.install = diskScripts.install
93+
}
94+
if (diskScripts.postinstall) {
95+
collected.postinstall = diskScripts.postinstall
96+
}
97+
if (diskScripts.prepare && hasNonRegistryShape(node)) {
98+
collected.prepare = diskScripts.prepare
99+
}
100+
101+
// Still nothing. The package isn't on disk yet (e.g. `npm ci` before
102+
// reify) or its package.json is unreadable. Emit a sentinel so the
103+
// advisory and the strict-allow-scripts preflight still surface that
104+
// install scripts are present.
105+
if (Object.keys(collected).length === 0) {
106+
collected.install = '(install scripts present)'
107+
}
82108
}
83109

84110
return collected

workspaces/arborist/test/install-scripts.js

Lines changed: 176 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ t.test('lockfile-only node with hasInstallScript=true emits a sentinel', async t
182182

183183
t.test('sentinel is not emitted when scripts are already enumerated', async t => {
184184
// If `hasInstallScript: true` coexists with a real `scripts` map, we
185-
// surface the real names the sentinel must not overwrite them.
185+
// surface the real names, and the sentinel must not overwrite them.
186186
const getInstallScripts = mockGetInstallScripts(t)
187187
const node = {
188188
resolved: 'https://registry.npmjs.org/pkg/-/pkg-1.0.0.tgz',
@@ -206,3 +206,178 @@ t.test('sentinel is not emitted when hasInstallScript is absent', async t => {
206206
}
207207
t.strictSame(await getInstallScripts(node), {})
208208
})
209+
210+
t.test('lockfile-only node hydrates real scripts from package.json on disk', async t => {
211+
// The common lockfile-driven case (`npm ci`, a repeat `npm install`):
212+
// `node.package.scripts` is empty but the package is unpacked on disk.
213+
// We must read the installed package.json and surface the real script
214+
// body instead of the sentinel.
215+
const getInstallScripts = mockGetInstallScripts(t)
216+
const path = t.testdir({
217+
'package.json': JSON.stringify({
218+
name: 'pkg',
219+
version: '1.0.0',
220+
scripts: { postinstall: 'node -e "console.log(1)"' },
221+
}),
222+
})
223+
const lockfileNode = {
224+
resolved: 'https://registry.npmjs.org/pkg/-/pkg-1.0.0.tgz',
225+
path,
226+
isRegistryDependency: true,
227+
hasInstallScript: true,
228+
package: { name: 'pkg', version: '1.0.0' },
229+
}
230+
t.strictSame(
231+
await getInstallScripts(lockfileNode),
232+
{ postinstall: 'node -e "console.log(1)"' }
233+
)
234+
})
235+
236+
t.test('lockfile-only node hydrates an explicit install script from disk', async t => {
237+
// A native package such as fsevents that ships a prebuilt binary (no
238+
// binding.gyp, so synthetic gyp detection finds nothing) but declares an
239+
// explicit `install: node-gyp rebuild`. The disk read must recover it
240+
// rather than emitting the sentinel.
241+
const getInstallScripts = mockGetInstallScripts(t)
242+
const path = t.testdir({
243+
'package.json': JSON.stringify({
244+
name: 'fsevents',
245+
version: '2.3.3',
246+
scripts: { install: 'node-gyp rebuild' },
247+
}),
248+
})
249+
const lockfileNode = {
250+
resolved: 'https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz',
251+
path,
252+
isRegistryDependency: true,
253+
hasInstallScript: true,
254+
package: { name: 'fsevents', version: '2.3.3' },
255+
}
256+
t.strictSame(
257+
await getInstallScripts(lockfileNode),
258+
{ install: 'node-gyp rebuild' }
259+
)
260+
})
261+
262+
t.test('lockfile-only node hydrates a preinstall script from disk', async t => {
263+
// Cover the disk `preinstall` recovery path.
264+
const getInstallScripts = mockGetInstallScripts(t)
265+
const path = t.testdir({
266+
'package.json': JSON.stringify({
267+
name: 'pkg',
268+
version: '1.0.0',
269+
scripts: { preinstall: 'node pre.js' },
270+
}),
271+
})
272+
const lockfileNode = {
273+
resolved: 'https://registry.npmjs.org/pkg/-/pkg-1.0.0.tgz',
274+
path,
275+
isRegistryDependency: true,
276+
hasInstallScript: true,
277+
package: { name: 'pkg', version: '1.0.0' },
278+
}
279+
t.strictSame(
280+
await getInstallScripts(lockfileNode),
281+
{ preinstall: 'node pre.js' }
282+
)
283+
})
284+
285+
t.test('lockfile-only non-registry node hydrates prepare from disk', async t => {
286+
// A git/file dependency installed from a lockfile: `prepare` runs for
287+
// non-registry sources, so the disk read must recover it.
288+
const getInstallScripts = mockGetInstallScripts(t)
289+
const path = t.testdir({
290+
'package.json': JSON.stringify({
291+
name: 'pkg',
292+
version: '1.0.0',
293+
scripts: { prepare: 'node build.js' },
294+
}),
295+
})
296+
const lockfileNode = {
297+
resolved: 'git+ssh://git@github.com/foo/bar.git#abc',
298+
path,
299+
isRegistryDependency: false,
300+
hasInstallScript: true,
301+
package: { name: 'pkg', version: '1.0.0' },
302+
}
303+
t.strictSame(
304+
await getInstallScripts(lockfileNode),
305+
{ prepare: 'node build.js' }
306+
)
307+
})
308+
309+
t.test('hydrated prepare is ignored for registry deps', async t => {
310+
// Hydration reuses the same registry/non-registry boundary as the
311+
// in-memory path: a registry dep whose on-disk package.json only has a
312+
// `prepare` script falls through to the sentinel, since `prepare` never
313+
// runs for registry installs.
314+
const getInstallScripts = mockGetInstallScripts(t)
315+
const path = t.testdir({
316+
'package.json': JSON.stringify({
317+
name: 'pkg',
318+
version: '1.0.0',
319+
scripts: { prepare: 'build' },
320+
}),
321+
})
322+
const lockfileNode = {
323+
resolved: 'https://registry.npmjs.org/pkg/-/pkg-1.0.0.tgz',
324+
path,
325+
isRegistryDependency: true,
326+
hasInstallScript: true,
327+
package: { name: 'pkg', version: '1.0.0' },
328+
}
329+
t.strictSame(
330+
await getInstallScripts(lockfileNode),
331+
{ install: '(install scripts present)' }
332+
)
333+
})
334+
335+
t.test('falls back to sentinel when on-disk package.json has no install scripts', async t => {
336+
// The lockfile flagged install scripts but the on-disk package.json has
337+
// none we recognise (e.g. only lifecycle scripts like `test`). Keep the
338+
// sentinel so the advisory still surfaces that something was flagged.
339+
const getInstallScripts = mockGetInstallScripts(t)
340+
const path = t.testdir({
341+
'package.json': JSON.stringify({
342+
name: 'pkg',
343+
version: '1.0.0',
344+
scripts: { test: 'tap' },
345+
}),
346+
})
347+
const lockfileNode = {
348+
resolved: 'https://registry.npmjs.org/pkg/-/pkg-1.0.0.tgz',
349+
path,
350+
isRegistryDependency: true,
351+
hasInstallScript: true,
352+
package: { name: 'pkg', version: '1.0.0' },
353+
}
354+
t.strictSame(
355+
await getInstallScripts(lockfileNode),
356+
{ install: '(install scripts present)' }
357+
)
358+
})
359+
360+
t.test('disk hydration does not mutate node.package', async t => {
361+
// The enumeration helper is read-only: recovering scripts from disk must
362+
// not write them back onto the node (unlike Builder#addToBuildSet, which
363+
// owns the node and hydrates it deliberately).
364+
const getInstallScripts = mockGetInstallScripts(t)
365+
const path = t.testdir({
366+
'package.json': JSON.stringify({
367+
name: 'pkg',
368+
version: '1.0.0',
369+
scripts: { postinstall: 'echo hi' },
370+
}),
371+
})
372+
const pkg = { name: 'pkg', version: '1.0.0' }
373+
const lockfileNode = {
374+
resolved: 'https://registry.npmjs.org/pkg/-/pkg-1.0.0.tgz',
375+
path,
376+
isRegistryDependency: true,
377+
hasInstallScript: true,
378+
package: pkg,
379+
}
380+
await getInstallScripts(lockfileNode)
381+
t.strictSame(pkg, { name: 'pkg', version: '1.0.0' }, 'node.package untouched')
382+
t.notOk(pkg.scripts, 'no scripts written back onto node.package')
383+
})

0 commit comments

Comments
 (0)