Skip to content

pacquet: run lifecycle scripts (preinstall / install / postinstall) on the fresh-lockfile install path #11870

@zkochan

Description

@zkochan

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

  1. 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.

  2. 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.

  3. scripts_prepend_node_path / unsafe_perm / child_concurrency. All read straight off Config. No fresh-lockfile-specific overrides.

  4. 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.

  5. 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.

  6. 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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions