Surfaced by CodeRabbit on #11867. Pre-existing limitation in pacquet, not introduced by that PR — but worth tracking and fixing.
The gap
install_with_fresh_lockfile (the no-lockfile / pacquet install entry) does not run lifecycle scripts. Any package with a preinstall / install / postinstall script that the user authorises via allowBuilds is fetched and linked, but its scripts never fire on a fresh install. They do fire on install --frozen-lockfile because that path routes through InstallFrozenLockfile::run which calls BuildModules.
The intent is explicit in the code today — at the tail of install_with_fresh_lockfile.rs:
// Mirrors upstream `link.ts:167-170`: `importing_done` fires once
// extraction and symlink linking are complete. The fresh-lockfile
// path does not run lifecycle scripts today, so emitting here also
// marks end-of-install for reporters.
That's been the standing TODO; this issue tracks closing it.
What's already in place
#11867 already routes the fresh-lockfile install through CreateVirtualStore::run, whose CreateVirtualStoreOutput carries everything BuildModules needs:
package_manifests — bundled-manifest map (used today by LinkVirtualStoreBins)
side_effects_maps_by_snapshot — currently destructured into _ on the fresh-lockfile site at install_with_fresh_lockfile.rs:728
- The
built_lockfile already provides snapshots, packages, importers in the v9 shape BuildModules expects (install_frozen_lockfile.rs:1092-1113 is the model)
allow_build_policy is already constructed (install_with_fresh_lockfile.rs:629)
store_index_writer is already owned and live until just after the link phase
So the wiring is genuinely just plumbing — most of the inputs are already on the stack at the right point.
Proposed shape
Between the LinkVirtualStoreBins::run call and the lockfile save in install_with_fresh_lockfile.rs, drop in the same BuildModules block install_frozen_lockfile.rs:1092-1115 uses. Sketch:
let ignored_builds = BuildModules {
layout: &layout,
modules_dir: &config.modules_dir,
lockfile_dir,
snapshots: built_lockfile.snapshots.as_ref(),
packages: built_lockfile.packages.as_ref(),
importers: &built_lockfile.importers,
allow_build_policy: &allow_build_policy,
side_effects_maps_by_snapshot: Some(&side_effects_maps_by_snapshot),
engine_name: engine_name.as_deref(),
side_effects_cache: config.side_effects_cache_read(),
side_effects_cache_write: config.side_effects_cache_write(),
store_dir: Some(&config.store_dir),
store_index_writer: Some(&store_index_writer),
patches: patches.as_ref(),
scripts_prepend_node_path,
unsafe_perm: config.unsafe_perm,
child_concurrency: config.child_concurrency,
skipped: &empty_skipped,
pkg_root_by_key: None, // fresh-lockfile is always isolated linker today
gather_ancestor_bin_paths: false, // ditto
}
.run::<Reporter>()
.map_err(InstallWithFreshLockfileError::BuildModules)?;
Reporter::emit(&LogEvent::IgnoredScripts(IgnoredScriptsLog {
level: LogLevel::Debug,
package_names: ignored_builds,
}));
Then stop discarding side_effects_maps_by_snapshot at the CreateVirtualStoreOutput destructure.
Things to think through
-
Ordering vs ImportingDone. Upstream pnpm emits importing_done before the build phase. Pacquet's frozen path emits it implicitly via BuildModules + its trailing reporter events; the fresh path currently emits ImportingDone at the very end as a stand-in for "install fully complete." When BuildModules lands, the emit semantics should match the frozen path's ordering so reporter consumers (@pnpm/cli.default-reporter) see the same event sequence on both paths.
-
patches plumbing. The frozen path resolves patched_dependencies once and threads them through BuildModules. install_with_fresh_lockfile.rs:490 already calls config.resolved_patched_dependencies(); that value is currently threaded only into the resolver, not into a build step (because there is none). It needs to survive to the BuildModules call site — straightforward but it's a thread.
-
scripts_prepend_node_path / unsafe_perm / child_concurrency. All read straight off Config. No fresh-lockfile-specific overrides.
-
engine_name. The frozen path computes this for GVS and the side-effects-cache key, sometimes via a deferred spawn_blocking of detect_node_major(). The fresh-lockfile path has the same need (install_with_fresh_lockfile.rs:652 already detects it under GVS); for non-GVS the build phase still needs it for the side-effects-cache key, so the deferred detection pattern should be ported too.
-
hoisted linker. Today, install_with_fresh_lockfile always runs the isolated linker (the recursive walker had no hoisted branch). When the build phase lands, it can stay isolated-only initially — nodeLinker: hoisted on a fresh install would be a separate, larger port.
-
Test coverage. A few existing tests in pacquet-cli::install exercise allowBuilds: true against the mock registry's @pnpm.e2e/install-script-example family of fixtures (they currently pass because they only assert the package is linked, not that its script ran). New assertions should check the side-effect of the script (a marker file the test packages drop, or the pnpm:lifecycle reporter event count).
Out of scope here
nodeLinker: hoisted for fresh-lockfile.
- Workspace-importer scripts (the top-level project's own
preinstall/install/postinstall).
- The reporter-event-ordering pass — needed but separable.
References
Written by an agent (Claude Code, claude-opus-4-7).
Surfaced by CodeRabbit on #11867. Pre-existing limitation in pacquet, not introduced by that PR — but worth tracking and fixing.
The gap
install_with_fresh_lockfile(the no-lockfile /pacquet installentry) does not run lifecycle scripts. Any package with apreinstall/install/postinstallscript that the user authorises viaallowBuildsis fetched and linked, but its scripts never fire on a fresh install. They do fire oninstall --frozen-lockfilebecause that path routes throughInstallFrozenLockfile::runwhich callsBuildModules.The intent is explicit in the code today — at the tail of
install_with_fresh_lockfile.rs:That's been the standing TODO; this issue tracks closing it.
What's already in place
#11867already routes the fresh-lockfile install throughCreateVirtualStore::run, whoseCreateVirtualStoreOutputcarries everythingBuildModulesneeds:package_manifests— bundled-manifest map (used today byLinkVirtualStoreBins)side_effects_maps_by_snapshot— currently destructured into_on the fresh-lockfile site atinstall_with_fresh_lockfile.rs:728built_lockfilealready providessnapshots,packages,importersin the v9 shapeBuildModulesexpects (install_frozen_lockfile.rs:1092-1113is the model)allow_build_policyis already constructed (install_with_fresh_lockfile.rs:629)store_index_writeris already owned and live until just after the link phaseSo the wiring is genuinely just plumbing — most of the inputs are already on the stack at the right point.
Proposed shape
Between the
LinkVirtualStoreBins::runcall and the lockfile save ininstall_with_fresh_lockfile.rs, drop in the sameBuildModulesblockinstall_frozen_lockfile.rs:1092-1115uses. Sketch:Then stop discarding
side_effects_maps_by_snapshotat theCreateVirtualStoreOutputdestructure.Things to think through
Ordering vs
ImportingDone. Upstream pnpm emitsimporting_donebefore the build phase. Pacquet's frozen path emits it implicitly viaBuildModules+ its trailing reporter events; the fresh path currently emitsImportingDoneat the very end as a stand-in for "install fully complete." WhenBuildModuleslands, the emit semantics should match the frozen path's ordering so reporter consumers (@pnpm/cli.default-reporter) see the same event sequence on both paths.patchesplumbing. The frozen path resolvespatched_dependenciesonce and threads them throughBuildModules.install_with_fresh_lockfile.rs:490already callsconfig.resolved_patched_dependencies(); that value is currently threaded only into the resolver, not into a build step (because there is none). It needs to survive to theBuildModulescall site — straightforward but it's a thread.scripts_prepend_node_path/unsafe_perm/child_concurrency. All read straight offConfig. No fresh-lockfile-specific overrides.engine_name. The frozen path computes this for GVS and the side-effects-cache key, sometimes via a deferredspawn_blockingofdetect_node_major(). The fresh-lockfile path has the same need (install_with_fresh_lockfile.rs:652already detects it under GVS); for non-GVS the build phase still needs it for the side-effects-cache key, so the deferred detection pattern should be ported too.hoistedlinker. Today,install_with_fresh_lockfilealways runs the isolated linker (the recursive walker had no hoisted branch). When the build phase lands, it can stay isolated-only initially —nodeLinker: hoistedon a fresh install would be a separate, larger port.Test coverage. A few existing tests in
pacquet-cli::installexerciseallowBuilds: trueagainst the mock registry's@pnpm.e2e/install-script-examplefamily of fixtures (they currently pass because they only assert the package is linked, not that its script ran). New assertions should check the side-effect of the script (a marker file the test packages drop, or thepnpm:lifecyclereporter event count).Out of scope here
nodeLinker: hoistedfor fresh-lockfile.preinstall/install/postinstall).References
install_with_fresh_lockfile.rs:839on main.install_frozen_lockfile.rs:1079-1124on main.CreateVirtualStoreOutputfield that's currently destructured into_:install_with_fresh_lockfile.rs:728onpacquet-perf5.installing/deps-installer/src/install/link.ts.Written by an agent (Claude Code, claude-opus-4-7).