mirror of
https://github.com/pnpm/pnpm.git
synced 2026-06-30 19:05:23 -04:00
## What
Adds an opt-in `frozenStore` / `--frozen-store` setting (default `false`) that lets `pnpm install --offline --frozen-lockfile` run against a package store that lives on a **read-only filesystem** — a Nix store, a read-only bind mount, an OCI layer.
## Why
A normal install fails against such a store **not** because it writes package content, but because it unconditionally:
1. opens the SQLite `index.db` in WAL mode, which needs to create `-shm`/`-wal` sidecars in the store directory; and
2. writes a project-registry entry under the store.
Both fail with `attempt to write a readonly database` / `EROFS` on a read-only store directory, even when the store is complete and the lockfile is frozen. This blocks any deployment that wants an immutable, content-addressed store.
## How
When `frozenStore` is enabled, pnpm opens `index.db` through the SQLite **`immutable=1`** URI — which tells SQLite the file cannot change underneath it, so it bypasses the WAL/`-shm` sidecar machinery entirely and reads the raw file with zero sidecar creation — and suppresses every store-write path. Pair it with `--offline --frozen-lockfile` against a fully-populated store. It is incompatible with two settings that would write into the store, and each throws a clear config-conflict error before any network or store access: **`--force`** (which bypasses the no-write-on-hit skip → `ERR_PNPM_CONFIG_CONFLICT_FROZEN_STORE_WITH_FORCE`) and a configured **pnpr server** (which would fetch and write packages into the store → `ERR_PNPM_FROZEN_STORE_INCOMPATIBLE_WITH_PNPR`).
> Plain `SQLITE_OPEN_READ_ONLY` is **not** sufficient: opening a WAL-mode db read-only still tries to create the `-shm` sidecar, which fails on a read-only *directory*. `immutable=1` is the load-bearing piece.
> **Node.js requirement:** `node:sqlite` only passes `SQLITE_OPEN_URI` to SQLite (so the `immutable=1` query is honored rather than treated as part of a literal filename) starting in **v22.15.0** (22.x line), **v23.11.0**, and every **v24+**. pnpm's `engines` floor is `>=22.13`, so on a runtime older than that the frozen open is detected up front and fails with a clear `ERR_PNPM_FROZEN_STORE_UNSUPPORTED_NODE` instead of SQLite's cryptic "unable to open database file". (pacquet uses rusqlite with an explicit `SQLITE_OPEN_URI` flag, so it has no such floor.)
> The `immutable=1` URI path is also percent-encoded (`%`→`%25`, `?`→`%3f`, `#`→`%23`, in that order, leaving `/` literal) so a store path containing those characters doesn't truncate the path or inject a spurious query parameter — applied identically in both stacks.
### Build backstop under the global virtual store
Under the global virtual store (default), a package's directory lives **inside** the store (`{storeDir}/links/...`). Applying a patch or running an allowlisted lifecycle script writes into that directory — so on a frozen store it would crash mid-build with a raw `EROFS`. A fully-seeded store never reaches the build step (patched/built packages are imported from the side-effects cache and filtered out by the `isBuilt` gate), so any residual build candidate means the seed is **missing that package's build output**.
`buildModules` now refuses up front with `ERR_PNPM_FROZEN_STORE_NEEDS_BUILD` and actionable guidance ("rebuild the seed with their scripts enabled, or remove them from `onlyBuiltDependencies`") instead of failing cryptically once a script starts. The check is gated on the global virtual store — under the isolated linker, slot directories live in the writable project-local store, so builds there are fine. Non-allowlisted scripts never run, so they are not treated as a blocking write.
Bin-linking has its own read-only-store edge under the global virtual store. On a **warm** checkout (the project's `.bin/<name>` already points at the seed target) `linkBin` returns before touching the store, so it is write-free. But on a **fresh** checkout it (re)creates the bin and calls `fixBin`, whose `chmod` targets the bin's **source file inside the store** (`{storeDir}/links/...`) — which is refused with `EPERM`/`EACCES` on a read-only store, even though a complete seed already ships that bin executable (so the `chmod` is redundant). `@pnpm/bins.linker` now wraps that call in `ensureExecutable`: it swallows the refusal when the target is already executable and rethrows otherwise, so bin-linking is write-free against a frozen store on a cold checkout too, while a genuinely non-executable bin (a broken seed) still surfaces as an error.
The blocking predicate distinguishes the two write kinds: a **patch** is applied regardless of `ignoreScripts`, so a patched package is always blocked; a **lifecycle script** is suppressed under `ignoreScripts`, so an allowlisted build-requiring package is *not* blocked when scripts are off (it would write nothing). This avoids falsely rejecting a valid `--ignore-scripts` frozen install. **Optional dependencies are exempt**: a build or patch failure on an optional dependency is non-fatal at runtime, so a seed missing an optional package's build output skips that build (emitting the `skipped-optional-dependency` log) instead of blocking the install — in both stacks.
### Both stacks (parity rule)
**TypeScript pnpm CLI**
- Config plumbing (`@pnpm/config.reader`): `frozen-store` type, config-file key, default, `Config.frozenStore`.
- The read-only open branch (`@pnpm/store.index`): `immutable=1`, read-only statements, throwing mutators.
- Wiring through `@pnpm/store.controller` and `@pnpm/store.connection-manager` to the sole `StoreIndex` construction site.
- Gating the project-registry write (`@pnpm/installing.context`).
- The `--force` / pnpr-server conflict guards (`@pnpm/installing.deps-installer`) and CLI surface (`@pnpm/installing.commands`).
- **After-install rebuild** (`@pnpm/building.after-install`): the post-install rebuild opens its `StoreIndex` immutably under the flag, so re-reading the store for a rebuild never attempts a writable open against the frozen store.
- **Worker fix:** `@pnpm/worker` opens its *own* writable `StoreIndex` on every `readPkgFromCafs` cache hit, so a pure read crashed on a frozen store. `frozenStore` is threaded through to `getStoreIndex` and keyed into its connection cache.
- **Build backstop** (`@pnpm/building.during-install`): `buildModules` throws `ERR_PNPM_FROZEN_STORE_NEEDS_BUILD` for a GVS slot that would build/patch on a frozen store, honoring `ignoreScripts`; threaded from `@pnpm/installing.deps-installer`.
- **Bin-linking on a read-only store** (`@pnpm/bins.linker`): `linkBin` wraps the `fixBin` chmod in `ensureExecutable`, which tolerates `EPERM`/`EACCES` when the bin's store-resident source is already executable (a complete seed) and rethrows otherwise — so a fresh checkout against a frozen store links bins without crashing on the redundant chmod. Catch-on-failure keeps the writable hot path at zero added syscalls.
**Rust pacquet**
- A dedicated `open_immutable` / `shared_immutable_in` opens via `immutable=1`, selected only under the flag. Plain `open_readonly` keeps the ordinary `SQLITE_OPEN_READ_ONLY` open (WAL locking intact) because normal installs read the index while the same process's `StoreIndexWriter` writes it concurrently — an immutable connection skips all locking and change detection, so a concurrent writer would make those reads undefined.
- `--frozen-store` CLI flag + `frozenStore` workspace-yaml setting.
- The store-index writer is replaced with a drain-and-drop stub (`spawn_disabled`) and `init_store_dir_best_effort` is skipped under the flag.
- **Build backstop:** `build_modules` returns `BuildModulesError::FrozenStoreNeedsBuild` (`ERR_PNPM_FROZEN_STORE_NEEDS_BUILD`) under the same GVS + frozen-store condition, threaded from `config.frozen_store`. The gate keys off `should_run_scripts` (which already folds the allow-build policy), so it is correct without an explicit ignore-scripts branch — pacquet has no configurable ignore-scripts mode yet.
pacquet already separated read-only index access (`shared_readonly_in`, or `shared_immutable_in` under the flag) from writes, so it never had the worker-conflation bug; the flag makes the "no writes attempted" contract explicit and gates the remaining best-effort write attempts.
## Testing
- **TS:** `store.index` frozen-mode-on-`0555`-directory test (reads work, writes throw `ERR_PNPM_FROZEN_STORE_WRITE`) plus a path-with-`?` open test — both gated on the runtime's immutable-URI support, with a complementary test asserting `ERR_PNPM_FROZEN_STORE_UNSUPPORTED_NODE` fires where that support is absent (the CI Node 22.13.0 path); `config.reader` round-trip; `deps-installer` `--force` and pnpr-server conflict guards; `worker`/`package-requester` unit tests. End-to-end on a `chmod -R 0555` store: install succeeds, `node_modules` materializes, no `-shm`/`-wal`/`-journal` sidecars; negative control without the flag fails as expected; incomplete store → clean offline error.
- **pacquet:** `open_immutable_reads_wal_db_on_readonly_directory` unit test plus the `immutable_sqlite_uri` encoding test and a path-with-`?` open test; yaml + CLI fold tests; **integration test** `frozen_store_installs_against_a_read_only_store` — primes a store, `chmod 0555` the tree, runs `install --frozen-lockfile --frozen-store --offline`, asserts success + materialized `node_modules` + zero sidecars. Confirmed load-bearing by reverting the `immutable=1` fix (test then fails).
- **Build backstop (both stacks):** `building/during-install` unit tests — approved-build-not-cached and patched-not-cached refuse; cached, non-allowlisted, and non-GVS cases pass through; **approved-build-under-`ignoreScripts` passes through while patched-under-`ignoreScripts` still refuses** — and the matching pacquet `build_modules` tests (`frozen_store_gvs_patch_not_seeded_refuses` + GVS-off / frozen-off controls). Each confirmed load-bearing by disabling the relevant guard and watching the corresponding test fail.
- **Bin-linking (`bins/linker`):** `ensureExecutable` tests with `fixBin` mocked to reject with `EPERM` — an already-executable bin source resolves (and `fixBin` is asserted called, so it isn't the warm skip-guard passing), a non-executable one rethrows `EPERM`. Confirmed end-to-end by running the built `linkBins` against a real `chflags uchg`-immutable store: the executable-seed case resolves and links the bin, the non-executable-seed control throws `EPERM`.
A changeset is included with `"pnpm": minor` and `"@pnpm/bins.linker": patch` (the read-only-store bin-linking fix).
320 lines
9.1 KiB
TypeScript
320 lines
9.1 KiB
TypeScript
import type { Catalogs } from '@pnpm/catalogs.types'
|
|
import type { Hooks } from '@pnpm/hooks.pnpmfile'
|
|
import type {
|
|
EngineDependency,
|
|
Finder,
|
|
Project,
|
|
ProjectManifest,
|
|
ProjectsGraph,
|
|
Registries,
|
|
RegistryConfig,
|
|
TrustPolicy,
|
|
} from '@pnpm/types'
|
|
|
|
import type { OptionsFromRootManifest } from './getOptionsFromRootManifest.js'
|
|
|
|
export type UniversalOptions = Pick<Config, 'color' | 'dir' | 'authConfig'>
|
|
|
|
|
|
export type VerifyDepsBeforeRun = 'install' | 'warn' | 'error' | 'prompt' | false
|
|
|
|
export interface PackageManagerNetworkConfig {
|
|
ca?: string | string[]
|
|
cert?: string | string[]
|
|
configByUri: Record<string, RegistryConfig>
|
|
httpProxy?: string
|
|
httpsProxy?: string
|
|
key?: string
|
|
localAddress?: string
|
|
noProxy?: string | boolean
|
|
strictSsl?: boolean
|
|
}
|
|
|
|
/**
|
|
* Runtime state, workspace context, and CLI metadata.
|
|
* These fields are NOT user-facing settings — they are computed at startup
|
|
* or populated later by the CLI harness (e.g. workspace filtering, hook loading).
|
|
*/
|
|
export interface ConfigContext {
|
|
// -- Runtime state --
|
|
hooks?: Hooks
|
|
finders?: Record<string, Finder>
|
|
|
|
// -- Workspace context --
|
|
allProjects?: Project[]
|
|
selectedProjectsGraph?: ProjectsGraph
|
|
allProjectsGraph?: ProjectsGraph
|
|
rootProjectManifest?: ProjectManifest
|
|
rootProjectManifestDir: string
|
|
|
|
// -- CLI metadata --
|
|
cliOptions: Record<string, any> // eslint-disable-line
|
|
/** Keys explicitly set from workspace yaml, CLI, or env vars (not defaults). */
|
|
explicitlySetKeys: Set<string>
|
|
packageManager: {
|
|
name: string
|
|
version: string
|
|
}
|
|
wantedPackageManager?: WantedPackageManager
|
|
}
|
|
|
|
/**
|
|
* The package manager requested by the root project's manifest.
|
|
* Extends {@link EngineDependency} with the source of the declaration so that
|
|
* callers can treat the legacy `packageManager` field and
|
|
* `devEngines.packageManager` differently (e.g. only the latter persists
|
|
* resolved pnpm integrity info to `pnpm-lock.yaml`).
|
|
*/
|
|
export interface WantedPackageManager extends EngineDependency {
|
|
fromDevEngines?: boolean
|
|
}
|
|
|
|
/**
|
|
* User-facing settings + auth/network config.
|
|
* Does NOT include runtime state — see {@link ConfigContext} for that.
|
|
*/
|
|
export interface Config extends OptionsFromRootManifest {
|
|
allowNew: boolean
|
|
autoConfirmAllPrompts?: boolean
|
|
autoInstallPeers?: boolean
|
|
bail: boolean
|
|
color: 'always' | 'auto' | 'never'
|
|
useBetaCli: boolean
|
|
excludeLinksFromLockfile: boolean
|
|
extraBinPaths: string[]
|
|
extraEnv: Record<string, string>
|
|
failIfNoMatch: boolean
|
|
filter: string[]
|
|
filterProd: string[]
|
|
authConfig: Record<string, any>, // eslint-disable-line
|
|
dryRun?: boolean // This option might be not supported ever
|
|
global?: boolean
|
|
dir: string
|
|
bin: string
|
|
verifyDepsBeforeRun?: VerifyDepsBeforeRun
|
|
ignoreScripts?: boolean
|
|
ignoreCompatibilityDb?: boolean
|
|
includeWorkspaceRoot?: boolean
|
|
optimisticRepeatInstall?: boolean
|
|
save?: boolean
|
|
saveProd?: boolean
|
|
saveDev?: boolean
|
|
saveOptional?: boolean
|
|
savePeer?: boolean
|
|
saveCatalogName?: string
|
|
saveWorkspaceProtocol?: boolean | 'rolling'
|
|
lockfileIncludeTarballUrl?: boolean
|
|
scriptShell?: string
|
|
stream?: boolean
|
|
pnpmExecPath: string
|
|
pnpmHomeDir: string
|
|
production?: boolean
|
|
fetchRetries?: number
|
|
fetchRetryFactor?: number
|
|
fetchRetryMintimeout?: number
|
|
fetchRetryMaxtimeout?: number
|
|
fetchTimeout?: number
|
|
saveExact?: boolean
|
|
savePrefix?: string
|
|
shellEmulator?: boolean
|
|
scriptsPrependNodePath?: boolean | 'warn-only'
|
|
force?: boolean
|
|
depth?: number
|
|
engineStrict?: boolean
|
|
nodeVersion?: string
|
|
nodeDownloadMirrors?: Record<string, string>
|
|
offline?: boolean
|
|
registry?: string
|
|
optional?: boolean
|
|
unsafePerm?: boolean
|
|
loglevel?: 'silent' | 'error' | 'warn' | 'info' | 'debug'
|
|
frozenLockfile?: boolean
|
|
preferFrozenLockfile?: boolean
|
|
only?: 'prod' | 'production' | 'dev' | 'development'
|
|
preferOffline?: boolean
|
|
sideEffectsCache?: boolean // for backward compatibility
|
|
sideEffectsCacheReadonly?: boolean // for backward compatibility
|
|
sideEffectsCacheRead?: boolean
|
|
sideEffectsCacheWrite?: boolean
|
|
shamefullyHoist?: boolean
|
|
dev?: boolean
|
|
ignoreCurrentSpecifiers?: boolean
|
|
recursive?: boolean
|
|
enablePrePostScripts?: boolean
|
|
useStderr?: boolean
|
|
nodeLinker?: 'hoisted' | 'isolated' | 'pnp'
|
|
preferSymlinkedExecutables?: boolean
|
|
resolutionMode?: 'highest' | 'time-based' | 'lowest-direct'
|
|
registrySupportsTimeField?: boolean
|
|
resolvePeersFromWorkspaceRoot?: boolean
|
|
deployAllFiles?: boolean
|
|
forceLegacyDeploy?: boolean
|
|
reporterHidePrefix?: boolean
|
|
|
|
// proxy
|
|
httpProxy?: string
|
|
httpsProxy?: string
|
|
localAddress?: string
|
|
noProxy?: string | boolean
|
|
|
|
// ssl
|
|
cert?: string | string[]
|
|
key?: string
|
|
ca?: string | string[]
|
|
strictSsl?: boolean
|
|
|
|
userAgent?: string
|
|
tag?: string
|
|
updateNotifier?: boolean
|
|
|
|
// pnpm specific configs
|
|
cacheDir: string
|
|
configDir: string
|
|
stateDir: string
|
|
storeDir?: string
|
|
virtualStoreDir?: string
|
|
virtualStoreOnly?: boolean
|
|
enableGlobalVirtualStore?: boolean
|
|
verifyStoreIntegrity?: boolean
|
|
frozenStore?: boolean
|
|
maxSockets?: number
|
|
networkConcurrency?: number
|
|
fetchingConcurrency?: number
|
|
lockfileOnly?: boolean // like npm's --package-lock-only
|
|
childConcurrency?: number
|
|
ignorePnpmfile?: boolean
|
|
pnpmfile: string[] | string
|
|
tryLoadDefaultPnpmfile?: boolean
|
|
packageImportMethod?: 'auto' | 'hardlink' | 'copy' | 'clone' | 'clone-or-copy'
|
|
hoistPattern?: string[]
|
|
publicHoistPattern?: string[] | string
|
|
hoistWorkspacePackages?: boolean
|
|
hoistingLimits?: 'none' | 'workspaces' | 'dependencies'
|
|
useStoreServer?: boolean
|
|
useRunningStoreServer?: boolean
|
|
workspaceConcurrency: number
|
|
workspaceDir?: string
|
|
workspacePackagePatterns?: string[]
|
|
catalogs?: Catalogs
|
|
catalogMode?: 'strict' | 'prefer' | 'manual'
|
|
cleanupUnusedCatalogs?: boolean
|
|
reporter?: string
|
|
aggregateOutput: boolean
|
|
linkWorkspacePackages: boolean | 'deep'
|
|
injectWorkspacePackages?: boolean
|
|
preferWorkspacePackages: boolean
|
|
reverse: boolean
|
|
sort: boolean
|
|
strictPeerDependencies: boolean
|
|
lockfileDir?: string
|
|
modulesDir?: string
|
|
sharedWorkspaceLockfile?: boolean
|
|
useLockfile: boolean
|
|
useGitBranchLockfile: boolean
|
|
mergeGitBranchLockfiles?: boolean
|
|
mergeGitBranchLockfilesBranchPattern?: string[]
|
|
globalPnpmfile?: string
|
|
npmPath?: string
|
|
gitChecks?: boolean
|
|
publishBranch?: string
|
|
recursiveInstall?: boolean
|
|
symlink: boolean
|
|
enablePnp?: boolean
|
|
enableModulesDir: boolean
|
|
modulesCacheMaxAge: number
|
|
dlxCacheMaxAge: number
|
|
embedReadme?: boolean
|
|
skipManifestObfuscation?: boolean
|
|
gitShallowHosts?: string[]
|
|
legacyDirFiltering?: boolean
|
|
allowBuilds?: Record<string, boolean | string>
|
|
dedupePeerDependents?: boolean
|
|
dedupePeers?: boolean
|
|
patchesDir?: string
|
|
ignoreWorkspaceCycles?: boolean
|
|
disallowWorkspaceCycles?: boolean
|
|
packGzipLevel?: number
|
|
blockExoticSubdeps?: boolean
|
|
|
|
pnprServer?: string
|
|
|
|
registries: Registries
|
|
packageManagerRegistries?: Registries
|
|
packageManagerNetworkConfig?: PackageManagerNetworkConfig
|
|
namedRegistries?: Record<string, string>
|
|
configByUri: Record<string, RegistryConfig>
|
|
ignoreWorkspaceRootCheck: boolean
|
|
workspaceRoot: boolean
|
|
|
|
testPattern?: string[]
|
|
changedFilesIgnorePattern?: string[]
|
|
userConfig: Record<string, string>
|
|
|
|
hoist: boolean
|
|
packageLock: boolean
|
|
pending: boolean
|
|
userconfig: string
|
|
npmrcAuthFile?: string
|
|
workspacePrefix?: string
|
|
dedupeDirectDeps?: boolean
|
|
extendNodePath?: boolean
|
|
gitBranchLockfile?: boolean
|
|
globalBinDir?: string
|
|
globalDir?: string
|
|
globalPkgDir: string
|
|
lockfile?: boolean
|
|
dedupeInjectedDeps?: boolean
|
|
nodeOptions?: string
|
|
pmOnFail?: 'download' | 'error' | 'warn' | 'ignore'
|
|
runtime?: boolean
|
|
runtimeOnFail?: 'download' | 'error' | 'warn' | 'ignore'
|
|
virtualStoreDirMaxLength: number
|
|
peersSuffixMaxLength?: number
|
|
strictStorePkgContentCheck: boolean
|
|
strictDepBuilds: boolean
|
|
syncInjectedDepsAfterScripts?: string[]
|
|
initPackageManager: boolean
|
|
initType: 'commonjs' | 'module'
|
|
dangerouslyAllowAllBuilds: boolean
|
|
ci: boolean
|
|
preserveAbsolutePaths?: boolean
|
|
minimumReleaseAge?: number
|
|
minimumReleaseAgeExclude?: string[]
|
|
minimumReleaseAgeIgnoreMissingTime?: boolean
|
|
minimumReleaseAgeStrict?: boolean
|
|
fetchWarnTimeoutMs?: number
|
|
fetchMinSpeedKiBps?: number
|
|
trustLockfile?: boolean
|
|
trustPolicy?: TrustPolicy
|
|
trustPolicyExclude?: string[]
|
|
trustPolicyIgnoreAfter?: number
|
|
auditLevel?: 'info' | 'low' | 'moderate' | 'high' | 'critical'
|
|
|
|
packageConfigs?: ProjectConfigSet
|
|
}
|
|
|
|
export interface ConfigWithDeprecatedSettings extends Config {
|
|
globalPrefix?: string
|
|
proxy?: string
|
|
}
|
|
|
|
export const PROJECT_CONFIG_FIELDS = [
|
|
'hoist',
|
|
'modulesDir',
|
|
'overrides',
|
|
'saveExact',
|
|
'savePrefix',
|
|
] as const satisfies Array<keyof Config>
|
|
|
|
export type ProjectConfig = Partial<Pick<Config, typeof PROJECT_CONFIG_FIELDS[number] | 'hoistPattern'>>
|
|
|
|
/** Simple map from project names to {@link ProjectConfig} */
|
|
export type ProjectConfigRecord = Record<string, ProjectConfig>
|
|
|
|
/** Map multiple project names to a shared {@link ProjectConfig} */
|
|
export type ProjectConfigMultiMatch = { match: string[] } & ProjectConfig
|
|
|
|
export type ProjectConfigSet =
|
|
| ProjectConfigRecord
|
|
| ProjectConfigMultiMatch[]
|