fix: skip local file: protocol dependencies during pnpm fetch (#10514)

This fixes an issue where pnpm fetch would fail in Docker builds when
local directory dependencies (file: protocol) were not available.

The fix adds an ignoreLocalPackages option that is passed from the fetch
command to skip local dependencies during graph building, since pnpm
fetch only downloads packages from the registry and doesn't need local
packages that won't be available in Docker builds.

close #10460
This commit is contained in:
Alessio Attilio
2026-02-06 17:28:39 +01:00
committed by Zoltan Kochan
parent f1cb40c4e1
commit 7adf26b017
7 changed files with 107 additions and 5 deletions

View File

@@ -0,0 +1,9 @@
---
"@pnpm/deps.graph-builder": patch
"@pnpm/headless": patch
"@pnpm/core": patch
"@pnpm/plugin-commands-installation": patch
"pnpm": patch
---
Skip local `file:` protocol dependencies during `pnpm fetch`. This fixes an issue where `pnpm fetch` would fail in Docker builds when local directory dependencies were not available [#10460](https://github.com/pnpm/pnpm/issues/10460).

View File

@@ -70,6 +70,12 @@ export interface LockfileToDepGraphOptions {
include: IncludedDependencies
includeUnchangedDeps?: boolean
ignoreScripts: boolean
/**
* When true, skip fetching local dependencies (file: protocol pointing to directories).
* This is useful for `pnpm fetch` which only downloads packages from the registry
* and doesn't need local packages that won't be available (e.g., in Docker builds).
*/
ignoreLocalPackages?: boolean
lockfileDir: string
nodeVersion: string
pnpmVersion: string
@@ -206,6 +212,14 @@ async function buildGraphFromPackages (
}
const isDirectoryDep = 'directory' in pkgSnapshot.resolution && pkgSnapshot.resolution.directory != null
if (isDirectoryDep && opts.ignoreLocalPackages) {
logger.info({
message: `Skipping local dependency ${pkgName}@${pkgVersion} (file: protocol)`,
prefix: opts.lockfileDir,
})
return
}
const depIsPresent = !isDirectoryDep &&
currentPackages[depPath] &&
equals(currentPackages[depPath].dependencies, pkgSnapshot.dependencies)

View File

@@ -51,6 +51,12 @@ export interface StrictInstallOptions {
ignoreCompatibilityDb: boolean
ignoreDepScripts: boolean
ignorePackageManifest: boolean
/**
* When true, skip fetching local dependencies (file: protocol pointing to directories).
* This is used by `pnpm fetch` which only downloads packages from the registry
* and doesn't need local packages that won't be available (e.g., in Docker builds).
*/
ignoreLocalPackages: boolean
preferFrozenLockfile: boolean
saveWorkspaceProtocol: boolean | 'rolling'
lockfileCheck?: (prev: LockfileObject, next: LockfileObject) => void
@@ -224,6 +230,7 @@ const defaults = (opts: InstallOptions): StrictInstallOptions => {
ownLifecycleHooksStdio: 'inherit',
ignoreCompatibilityDb: false,
ignorePackageManifest: false,
ignoreLocalPackages: false,
packageExtensions: {},
ignoredOptionalDependencies: [] as string[],
packageManager,

View File

@@ -134,6 +134,12 @@ export interface HeadlessOptions {
ignoreDepScripts: boolean
ignoreScripts: boolean
ignorePackageManifest?: boolean
/**
* When true, skip fetching local dependencies (file: protocol pointing to directories).
* This is used by `pnpm fetch` which only downloads packages from the registry
* and doesn't need local packages that won't be available (e.g., in Docker builds).
*/
ignoreLocalPackages?: boolean
include: IncludedDependencies
selectedProjectDirs: string[]
allProjects: Record<string, Project>

View File

@@ -10,6 +10,7 @@ import {
packageIdFromSnapshot,
pkgSnapshotToResolution,
} from '@pnpm/lockfile.utils'
import { logger } from '@pnpm/logger'
import { type IncludedDependencies } from '@pnpm/modules-yaml'
import { packageIsInstallable } from '@pnpm/package-is-installable'
import { type PatchGroupRecord, getPatchInfo } from '@pnpm/patching.config'
@@ -38,6 +39,12 @@ export interface LockfileToHoistedDepGraphOptions {
importerIds: string[]
include: IncludedDependencies
ignoreScripts: boolean
/**
* When true, skip fetching local dependencies (file: protocol pointing to directories).
* This is used by `pnpm fetch` which only downloads packages from the registry
* and doesn't need local packages that won't be available (e.g., in Docker builds).
*/
ignoreLocalPackages?: boolean
currentHoistedLocations?: Record<string, string[]>
lockfileDir: string
modulesDir?: string
@@ -199,6 +206,16 @@ async function fetchDeps (
opts.skipped.add(depPath)
return
}
const isDirectoryDep = 'directory' in pkgSnapshot.resolution && pkgSnapshot.resolution.directory != null
if (isDirectoryDep && opts.ignoreLocalPackages) {
logger.info({
message: `Skipping local dependency ${pkgName}@${pkgVersion} (file: protocol)`,
prefix: opts.lockfileDir,
})
return
}
const dir = path.join(modules, dep.name)
const depLocation = path.relative(opts.lockfileDir, dir)
const resolution = pkgSnapshotToResolution(depPath, pkgSnapshot, opts.registries)
@@ -224,7 +241,7 @@ async function fetchDeps (
ignoreScripts: opts.ignoreScripts,
pkg: pkgResolution,
})
fetchResponse = { filesIndexFile } as any // eslint-disable-line @typescript-eslint/no-explicit-any
fetchResponse = { filesIndexFile } as unknown as ReturnType<FetchPackageToStoreFunction>
} else {
try {
fetchResponse = opts.storeController.fetchPackage({
@@ -234,9 +251,9 @@ async function fetchDeps (
ignoreScripts: opts.ignoreScripts,
pkg: pkgResolution,
supportedArchitectures: opts.supportedArchitectures,
}) as any // eslint-disable-line
}) as unknown as ReturnType<FetchPackageToStoreFunction>
if (fetchResponse instanceof Promise) fetchResponse = await fetchResponse
} catch (err: any) { // eslint-disable-line
} catch (err: unknown) {
if (pkgSnapshot.optional) return
throw err
}
@@ -287,8 +304,8 @@ async function dirHasPackageJsonWithVersion (dir: string, expectedVersion?: stri
try {
const manifest = await safeReadPackageJsonFromDir(dir)
return manifest?.version === expectedVersion
} catch (err: any) { // eslint-disable-line
if (err.code === 'ENOENT') {
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') {
return pathExists(dir)
}
throw err

View File

@@ -64,6 +64,7 @@ export async function handler (opts: FetchCommandOptions): Promise<void> {
...opts,
...getOptionsFromRootManifest(opts.rootProjectManifestDir, opts.rootProjectManifest ?? {}),
ignorePackageManifest: true,
ignoreLocalPackages: true,
include,
modulesCacheMaxAge: 0,
pruneStore: true,

View File

@@ -1,3 +1,4 @@
import fs from 'fs'
import path from 'path'
import { install, fetch } from '@pnpm/plugin-commands-installation'
import { prepare } from '@pnpm/prepare'
@@ -132,3 +133,50 @@ test('fetch only dev dependencies', async () => {
project.storeHas('is-negative')
project.storeHasNot('is-positive')
})
// Regression test for https://github.com/pnpm/pnpm/issues/10460
// pnpm fetch should skip local file: protocol dependencies
// because they won't be available in Docker builds
test('fetch skips file: protocol dependencies that do not exist', async () => {
const project = prepare({
dependencies: {
'is-positive': '1.0.0',
'@local/pkg': 'file:./local-pkg',
},
})
const storeDir = path.resolve('store')
const localPkgDir = path.resolve(project.dir(), 'local-pkg')
// Create the local package for initial install to generate lockfile
fs.mkdirSync(localPkgDir, { recursive: true })
fs.writeFileSync(
path.join(localPkgDir, 'package.json'),
JSON.stringify({ name: '@local/pkg', version: '1.0.0' })
)
// Create a lockfile with the file: dependency
await install.handler({
...DEFAULT_OPTIONS,
cacheDir: path.resolve('cache'),
dir: process.cwd(),
linkWorkspacePackages: true,
storeDir,
})
rimraf(path.resolve(project.dir(), 'node_modules'))
rimraf(path.resolve(project.dir(), './package.json'))
// Remove the local package directory to simulate Docker build scenario
rimraf(localPkgDir)
project.storeHasNot('is-positive')
// This should not throw an error even though the file: dependency doesn't exist
await fetch.handler({
...DEFAULT_OPTIONS,
cacheDir: path.resolve('cache'),
dir: process.cwd(),
storeDir,
})
project.storeHas('is-positive')
})