Skip to content

Lifecycle script subsystem: gaps vs upstream pnpm #397

@zkochan

Description

@zkochan

Audit of pacquet's dependency-build / lifecycle-script subsystem against pnpm v11 (commit 80037699fb). The recently-landed work (PR #391) covers the policy semantics (pnpm.allowBuilds, default-deny, dangerouslyAllowAllBuilds), the hook ordering (preinstall → install → postinstall), and topological build ordering via buildSequence / graphSequencer. The items below are the remaining divergences, ranked by how badly they break real installs.

The relevant pacquet code lives in:

  • crates/package-manager/src/build_modules.rs
  • crates/package-manager/src/build_sequence.rs
  • crates/executor/src/lifecycle.rs

Critical — common installs will fail

1. Lifecycle env vars almost entirely missing

pacquet sets only PATH, INIT_CWD, PNPM_SCRIPT_SRC_DIR (lifecycle.rs:149-151).

Upstream goes through @pnpm/npm-lifecycle's lifecycle()makeEnv (npm-lifecycle/index.js:74-92, :354-414) and sets at minimum:

  • npm_lifecycle_event (the stage)
  • npm_lifecycle_script (the resolved script)
  • npm_node_execpath / NODE (process.execPath)
  • npm_package_json (path to package.json)
  • npm_execpath
  • npm_config_node_gyp (bundled node-gyp path)
  • npm_package_* for name, version, every key in config, every key in engines, every key in bin
  • npm_config_user_agent (set in exec/lifecycle/src/runLifecycleHook.ts:123)
  • TMPDIR under node_modules/.tmp when !unsafePerm (npm-lifecycle/index.js:96-104)

Impact: native build deps (sharp, node-sass, node-pre-gyp, bcrypt, anything calling node-gyp) read these and fail without them. Common postinstalls also read npm_package_version / npm_package_config_*.

Related: pacquet's Command::new(\"sh\") inherits the parent env unfiltered, so leftover npm_* vars from any wrapping invocation leak through. Upstream makeEnv strips them.

2. PATH does not walk ancestor node_modules/.bin

pacquet only prepends pkg_root/node_modules/.bin plus extra_bin_paths (lifecycle.rs:189-201).

Upstream extendPath (npm-lifecycle/lib/extendPath.js:5-27) walks every ancestor node_modules/.bin segment from wd upward, plus a bundled node-gyp-bin, plus extraBinPaths, plus optionally the dir of process.execPath when scriptsPrependNodePath is set.

Impact: inside the virtual store, a dep's own pkg_root/node_modules/.bin rarely exists. The bins it actually needs (node-gyp, node-pre-gyp, sibling-dep CLIs) live higher up. Without the ancestor walk, most native postinstalls fail to resolve their tools.

3. linkBinsOfDependencies not run before lifecycle scripts

Upstream relinks a dep's own node_modules/.bin from its children right before running its scripts (building/during-install/src/index.ts:168). That's what populates pkg_root/node_modules/.bin in the first place.

pacquet skips this step. Combined with #2 above, anything that shells out to a sibling-dep CLI cannot find the binary.

4. Hardcoded sh -c on Windows

lifecycle.rs:145 hardcodes sh -c.

Upstream picks the shell based on platform and config (npm-lifecycle/index.js:241-252): cmd /d /s /c on Windows, @yarnpkg/shell when shellEmulator is set, or a custom scriptShell.

Impact: scripts cannot run at all on Windows. scriptShell and shellEmulator config are silently ignored on every platform.

Moderate — less common cases or specific configs

5. allowBuilds config source and spec parser

pacquet reads pnpm.allowBuilds and pnpm.dangerouslyAllowAllBuilds from the project's package.json only (build_modules.rs:39-85).

Upstream sources both from the Config object (.npmrc, workspace YAML, env) — see config/reader/src/dependencyBuildOptions.ts.

Upstream also runs allowBuilds keys through expandPackageVersionSpecs (config/version-policy/src/index.ts:15-90), which supports name patterns (e.g. @scope/*) and version unions like foo@1.0.0||2.0.0. pacquet only does literal name@version and bare name lookups.

6. Optional-dep build failures hard-fail

When a build fails for a package marked optional, upstream emits skippedOptionalDependencyLogger and swallows the error (building/during-install/src/index.ts:226-239). pacquet propagates every error.

Impact: installs that should succeed (optional native dep failing to compile on the current platform) hard-fail.

7. Malformed package.json is fatal

pacquet returns Ok(false) on NotFound but treats malformed JSON as a hard error (lifecycle.rs:89-91).

Upstream safeReadPackageJsonFromDir returns null on missing OR malformed package.json (exec/lifecycle/src/index.ts:22-23) and runPostinstallHooks returns false.

8. pnpm:lifecycle NDJSON events not emitted ✅ Addressed in 653bc3d

run_lifecycle_hook now emits pnpm:lifecycle events through the Reporter capability with the three upstream message shapes (Script before spawn, Stdio per output line, Exit after wait). Stdio piping is wired alongside (was item #11). pnpm:ignored-scripts also lands in the same commit, emitted once after BuildModules::run returns.

Originally:

pacquet uses tracing::debug! for lifecycle logs. Upstream emits lifecycleLogger.debug events and pipes stdout/stderr through byline-buffered logs. @pnpm/cli.default-reporter consumes those events as the pnpm:lifecycle channel — pacquet's NDJSON output is therefore parsed as an empty channel by the upstream reporter.

9. patchedDependencies not applied

The build trigger upstream is requiresBuild ?? patch != null (during-install/src/index.ts:74-77). pacquet only checks requires_build (TODO already in the code).

Patch application itself (during-install/src/index.ts:170-178) is also missing.

10. No isBuilt skip / side-effects cache

pacquet rebuilds on every run. Upstream skips when node.isBuilt is set (during-install/src/index.ts) and uploads the post-build state to the side-effects cache (during-install/src/index.ts:198-216). Relevant for warm-install perf.

11. Stdio is Stdio::inherit() ✅ Addressed in 653bc3d

run_lifecycle_hook now spawns each script with Stdio::piped() and pumps each stream on its own thread, emitting one pnpm:lifecycle Stdio event per line. Bundled with the #8 fix.

12. No build concurrency

buildSequence chunks are independent within a chunk and upstream runs each chunk under runGroups(getWorkspaceConcurrency(opts.childConcurrency), ...) (during-install/src/index.ts:124). pacquet runs chunks sequentially and members within a chunk sequentially. Correctness-safe, perf-only.

Minor

13. npx only-allow pnpm skip is install-stage-only

lifecycle.rs:111-113 skips this script string only for the install stage. Upstream skips it for every stage (runLifecycleHook.ts:100). A postinstall: \"npx only-allow pnpm\" in a dep would currently execute under pacquet.

14. No unsafe-perm / uid-gid drop

Upstream drops privileges to opts.user / opts.group when !unsafePerm and not Windows (npm-lifecycle/index.js:204-220). pacquet ignores. Matters for root-run CI.

15. No scriptsPrependNodePath

extendPath prepends dirname(node) when scriptsPrependNodePath is set (extendPath.js:20-23). pacquet does not.

16. No getSubgraphToBuild filter trimming

pacquet's build_sequence (PR #391) follows the upstream walk faithfully, but does not trim the subgraph the way upstream does once it reaches a node whose subtree has no buildable descendant. The current behavior is correct (extra nodes are no-op'd inside the loop), just slightly more work.


Most of the critical items are interlocking: #1, #2, and #3 together explain why a dep's postinstall calling node-gyp cannot find the binary, can't read npm_lifecycle_event, and runs in the wrong working environment. Fixing them as a single chunk likely unblocks the bulk of real-world installs.

Cross-references used during the audit:


Written by an agent (Claude Code, claude-opus-4-7).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions