fix: changes local-resolver to support absolute paths (#9761)

* feat: changes local-resolver to support absolute paths

Previously absolute paths were being turned to relative paths, but if the file:
specifier is 'file:/path/to/file', and the users are using a shared network
storage, this relative path requires that the users all use the same
local folder structure. Instead, using an absolute path as the specifier
allows them to have the source code anywhere, and the absolute path will
be resolved consistently.

Enabled via the `preserveAbsolutePaths` option.

* chore: changeset

* feat: add preserve absolute paths option

* docs: add changesets

* fix: also update the 'dependencyPath', add test for that case

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
This commit is contained in:
Colin T.A. Gray
2025-07-23 17:54:49 -04:00
committed by GitHub
parent fb9de7ac3a
commit 5dedadac76
10 changed files with 78 additions and 22 deletions

View File

@@ -0,0 +1,5 @@
---
"@pnpm/local-resolver": minor
---
Added `preserveAbsolutePaths` option to `resolveFromLocal`. When using `file:/path/to/package`, the absolute path will be preserved instead of being turned into a relative path.

View File

@@ -225,6 +225,7 @@ export interface Config extends OptionsFromRootManifest {
initType: 'commonjs' | 'module'
dangerouslyAllowAllBuilds: boolean
ci: boolean
preserveAbsolutePaths?: boolean
}
export interface ConfigWithDeprecatedSettings extends Config {

View File

@@ -81,6 +81,7 @@ export const types = Object.assign({
'prefer-offline': Boolean,
'prefer-symlinked-executables': Boolean,
'prefer-workspace-packages': Boolean,
'preserve-absolute-paths': Boolean,
production: [null, true],
'public-hoist-pattern': Array,
'publish-branch': String,

View File

@@ -30,6 +30,7 @@ export type ClientOptions = {
gitShallowHosts?: string[]
resolveSymlinksInInjectedDirs?: boolean
includeOnlyPackageFiles?: boolean
preserveAbsolutePaths?: boolean
} & ResolverFactoryOptions & AgentOptions
export interface Client {

View File

@@ -47,6 +47,9 @@ export function createResolver (
): { resolve: DefaultResolver, clearCache: () => void } {
const { resolveFromNpm, resolveFromJsr, clearCache } = createNpmResolver(fetchFromRegistry, getAuthHeader, pnpmOpts)
const resolveFromGit = createGitResolver(pnpmOpts)
const _resolveFromLocal = resolveFromLocal.bind(null, {
preserveAbsolutePaths: pnpmOpts.preserveAbsolutePaths,
})
const _resolveNodeRuntime = resolveNodeRuntime.bind(null, { fetchFromRegistry, offline: pnpmOpts.offline, rawConfig: pnpmOpts.rawConfig })
return {
resolve: async (wantedDependency, opts) => {
@@ -55,7 +58,7 @@ export function createResolver (
(wantedDependency.bareSpecifier && (
await resolveFromTarball(fetchFromRegistry, wantedDependency as { bareSpecifier: string }) ??
await resolveFromGit(wantedDependency as { bareSpecifier: string }) ??
await resolveFromLocal(wantedDependency as { bareSpecifier: string }, opts)
await _resolveFromLocal(wantedDependency as { bareSpecifier: string }, opts)
)) ??
await _resolveNodeRuntime(wantedDependency)
if (!resolution) {

View File

@@ -21,13 +21,17 @@ export interface LocalResolveResult extends ResolveResult {
* Resolves a package hosted on the local filesystem
*/
export async function resolveFromLocal (
ctx: {
preserveAbsolutePaths?: boolean
},
wantedDependency: WantedLocalDependency,
opts: {
lockfileDir?: string
projectDir: string
}
): Promise<LocalResolveResult | null> {
const spec = parseBareSpecifier(wantedDependency, opts.projectDir, opts.lockfileDir ?? opts.projectDir)
const preserveAbsolutePaths = ctx.preserveAbsolutePaths ?? false
const spec = parseBareSpecifier(wantedDependency, opts.projectDir, opts.lockfileDir ?? opts.projectDir, { preserveAbsolutePaths })
if (spec == null) return null
if (spec.type === 'file') {
return {

View File

@@ -37,10 +37,11 @@ class PathIsUnsupportedProtocolError extends PnpmError {
export function parseBareSpecifier (
wd: WantedLocalDependency,
projectDir: string,
lockfileDir: string
lockfileDir: string,
opts: { preserveAbsolutePaths: boolean }
): LocalPackageSpec | null {
if (wd.bareSpecifier.startsWith('link:') || wd.bareSpecifier.startsWith('workspace:')) {
return fromLocal(wd, projectDir, lockfileDir, 'directory')
return fromLocal(wd, projectDir, lockfileDir, 'directory', opts)
}
if (wd.bareSpecifier.endsWith('.tgz') ||
wd.bareSpecifier.endsWith('.tar.gz') ||
@@ -50,7 +51,7 @@ export function parseBareSpecifier (
isFilespec.test(wd.bareSpecifier)
) {
const type = isFilename.test(wd.bareSpecifier) ? 'file' : 'directory'
return fromLocal(wd, projectDir, lockfileDir, type)
return fromLocal(wd, projectDir, lockfileDir, type, opts)
}
if (wd.bareSpecifier.startsWith('path:')) {
throw new PathIsUnsupportedProtocolError(wd.bareSpecifier, 'path:')
@@ -62,7 +63,8 @@ function fromLocal (
{ bareSpecifier, injected }: WantedLocalDependency,
projectDir: string,
lockfileDir: string,
type: 'file' | 'directory'
type: 'file' | 'directory',
opts: { preserveAbsolutePaths: boolean }
): LocalPackageSpec {
const spec = bareSpecifier.replace(/\\/g, '/')
.replace(/^(?:file|link|workspace):\/*([A-Z]:)/i, '$1') // drive name paths on windows
@@ -91,14 +93,24 @@ function fromLocal (
}
}
function normalizeRelativeOrAbsolute (relativeTo: string, fromPath: string) {
let specPath
if (opts.preserveAbsolutePaths && isAbsolute(spec)) {
specPath = path.resolve(fromPath)
} else {
specPath = path.relative(relativeTo, fromPath)
}
return normalize(specPath)
}
injected = protocol === 'file:'
const dependencyPath = injected
? normalize(path.relative(lockfileDir, fetchSpec))
? normalizeRelativeOrAbsolute(lockfileDir, fetchSpec)
: normalize(path.resolve(fetchSpec))
const id = (
!injected && (type === 'directory' || projectDir === lockfileDir)
? `${protocol}${normalize(path.relative(projectDir, fetchSpec))}`
: `${protocol}${normalize(path.relative(lockfileDir, fetchSpec))}`
? `${protocol}${normalizeRelativeOrAbsolute(projectDir, fetchSpec)}`
: `${protocol}${normalizeRelativeOrAbsolute(lockfileDir, fetchSpec)}`
) as PkgResolutionId
return {

View File

@@ -8,7 +8,7 @@ import { logger } from '@pnpm/logger'
const TEST_DIR = path.dirname(require.resolve('@pnpm/tgz-fixtures/tgz/pnpm-local-resolver-0.1.1.tgz'))
test('resolve directory', async () => {
const resolveResult = await resolveFromLocal({ bareSpecifier: '..' }, { projectDir: __dirname })
const resolveResult = await resolveFromLocal({}, { bareSpecifier: '..' }, { projectDir: __dirname })
expect(resolveResult!.id).toEqual('link:..')
expect(resolveResult!.normalizedBareSpecifier).toEqual('link:..')
expect(resolveResult!['manifest']!.name).toEqual('@pnpm/local-resolver')
@@ -19,7 +19,7 @@ test('resolve directory', async () => {
test('resolve directory specified using absolute path', async () => {
const linkedDir = path.join(__dirname, '..')
const normalizedLinkedDir = normalize(linkedDir)
const resolveResult = await resolveFromLocal({ bareSpecifier: `link:${linkedDir}` }, { projectDir: __dirname })
const resolveResult = await resolveFromLocal({}, { bareSpecifier: `link:${linkedDir}` }, { projectDir: __dirname })
expect(resolveResult!.id).toEqual('link:..')
expect(resolveResult!.normalizedBareSpecifier).toEqual(`link:${normalizedLinkedDir}`)
expect(resolveResult!['manifest']!.name).toEqual('@pnpm/local-resolver')
@@ -27,8 +27,34 @@ test('resolve directory specified using absolute path', async () => {
expect((resolveResult!.resolution as DirectoryResolution).type).toEqual('directory')
})
test('resolve directory specified using absolute path with preserveAbsolutePaths', async () => {
const linkedDir = path.join(__dirname, '..')
const normalizedLinkedDir = normalize(linkedDir)
const resolveResult = await resolveFromLocal({ preserveAbsolutePaths: true }, { bareSpecifier: `link:${linkedDir}` }, { projectDir: __dirname })
expect(resolveResult!.id).toEqual(`link:${normalizedLinkedDir}`)
expect(resolveResult!.normalizedBareSpecifier).toEqual(`link:${normalizedLinkedDir}`)
expect(resolveResult!['manifest']!.name).toEqual('@pnpm/local-resolver')
expect((resolveResult!.resolution as DirectoryResolution).directory).toEqual(normalizedLinkedDir)
expect((resolveResult!.resolution as DirectoryResolution).type).toEqual('directory')
})
test('resolve directory specified using absolute path with preserveAbsolutePaths and file: scheme', async () => {
const linkedDir = path.join(__dirname, '..')
const normalizedLinkedDir = normalize(linkedDir)
const resolveResult = await resolveFromLocal(
{ preserveAbsolutePaths: true },
{ bareSpecifier: `file:${linkedDir}` },
{ projectDir: __dirname }
)
expect(resolveResult!.id).toEqual(`file:${normalizedLinkedDir}`)
expect(resolveResult!.normalizedBareSpecifier).toEqual(`file:${normalizedLinkedDir}`)
expect(resolveResult!['manifest']!.name).toEqual('@pnpm/local-resolver')
expect((resolveResult!.resolution as DirectoryResolution).directory).toEqual(normalizedLinkedDir)
expect((resolveResult!.resolution as DirectoryResolution).type).toEqual('directory')
})
test('resolve injected directory', async () => {
const resolveResult = await resolveFromLocal({ injected: true, bareSpecifier: '..' }, { projectDir: __dirname })
const resolveResult = await resolveFromLocal({}, { injected: true, bareSpecifier: '..' }, { projectDir: __dirname })
expect(resolveResult!.id).toEqual('file:..')
expect(resolveResult!.normalizedBareSpecifier).toEqual('file:..')
expect(resolveResult!['manifest']!.name).toEqual('@pnpm/local-resolver')
@@ -37,7 +63,7 @@ test('resolve injected directory', async () => {
})
test('resolve workspace directory', async () => {
const resolveResult = await resolveFromLocal({ bareSpecifier: 'workspace:..' }, { projectDir: __dirname })
const resolveResult = await resolveFromLocal({}, { bareSpecifier: 'workspace:..' }, { projectDir: __dirname })
expect(resolveResult!.id).toEqual('link:..')
expect(resolveResult!.normalizedBareSpecifier).toEqual('link:..')
expect(resolveResult!['manifest']!.name).toEqual('@pnpm/local-resolver')
@@ -46,7 +72,7 @@ test('resolve workspace directory', async () => {
})
test('resolve directory specified using the file: protocol', async () => {
const resolveResult = await resolveFromLocal({ bareSpecifier: 'file:..' }, { projectDir: __dirname })
const resolveResult = await resolveFromLocal({}, { bareSpecifier: 'file:..' }, { projectDir: __dirname })
expect(resolveResult!.id).toEqual('file:..')
expect(resolveResult!.normalizedBareSpecifier).toEqual('file:..')
expect(resolveResult!['manifest']!.name).toEqual('@pnpm/local-resolver')
@@ -55,7 +81,7 @@ test('resolve directory specified using the file: protocol', async () => {
})
test('resolve directory specified using the link: protocol', async () => {
const resolveResult = await resolveFromLocal({ bareSpecifier: 'link:..' }, { projectDir: __dirname })
const resolveResult = await resolveFromLocal({}, { bareSpecifier: 'link:..' }, { projectDir: __dirname })
expect(resolveResult!.id).toEqual('link:..')
expect(resolveResult!.normalizedBareSpecifier).toEqual('link:..')
expect(resolveResult!['manifest']!.name).toEqual('@pnpm/local-resolver')
@@ -65,7 +91,7 @@ test('resolve directory specified using the link: protocol', async () => {
test('resolve file', async () => {
const wantedDependency = { bareSpecifier: './pnpm-local-resolver-0.1.1.tgz' }
const resolveResult = await resolveFromLocal(wantedDependency, { projectDir: TEST_DIR })
const resolveResult = await resolveFromLocal({}, wantedDependency, { projectDir: TEST_DIR })
expect(resolveResult).toEqual({
id: 'file:pnpm-local-resolver-0.1.1.tgz',
@@ -80,7 +106,7 @@ test('resolve file', async () => {
test("resolve file when lockfile directory differs from the package's dir", async () => {
const wantedDependency = { bareSpecifier: './pnpm-local-resolver-0.1.1.tgz' }
const resolveResult = await resolveFromLocal(wantedDependency, {
const resolveResult = await resolveFromLocal({}, wantedDependency, {
lockfileDir: path.join(TEST_DIR, '..'),
projectDir: TEST_DIR,
})
@@ -98,7 +124,7 @@ test("resolve file when lockfile directory differs from the package's dir", asyn
test('resolve tarball specified with file: protocol', async () => {
const wantedDependency = { bareSpecifier: 'file:./pnpm-local-resolver-0.1.1.tgz' }
const resolveResult = await resolveFromLocal(wantedDependency, { projectDir: TEST_DIR })
const resolveResult = await resolveFromLocal({}, wantedDependency, { projectDir: TEST_DIR })
expect(resolveResult).toEqual({
id: 'file:pnpm-local-resolver-0.1.1.tgz',
@@ -114,7 +140,7 @@ test('resolve tarball specified with file: protocol', async () => {
test('fail when resolving tarball specified with the link: protocol', async () => {
try {
const wantedDependency = { bareSpecifier: 'link:./pnpm-local-resolver-0.1.1.tgz' }
await resolveFromLocal(wantedDependency, { projectDir: TEST_DIR })
await resolveFromLocal({}, wantedDependency, { projectDir: TEST_DIR })
fail()
} catch (err: any) { // eslint-disable-line
expect(err).toBeDefined()
@@ -126,14 +152,14 @@ test('fail when resolving from not existing directory an injected dependency', a
const wantedDependency = { bareSpecifier: 'file:./dir-does-not-exist' }
const projectDir = __dirname
await expect(
resolveFromLocal(wantedDependency, { projectDir })
resolveFromLocal({}, wantedDependency, { projectDir })
).rejects.toThrow(`Could not install from "${path.join(projectDir, 'dir-does-not-exist')}" as it does not exist.`)
})
test('do not fail when resolving from not existing directory', async () => {
jest.spyOn(logger, 'warn')
const wantedDependency = { bareSpecifier: 'link:./dir-does-not-exist' }
const resolveResult = await resolveFromLocal(wantedDependency, { projectDir: __dirname })
const resolveResult = await resolveFromLocal({}, wantedDependency, { projectDir: __dirname })
expect(resolveResult?.manifest).toStrictEqual({
name: 'dir-does-not-exist',
version: '0.0.0',
@@ -147,7 +173,7 @@ test('do not fail when resolving from not existing directory', async () => {
test('throw error when the path: protocol is used', async () => {
try {
await resolveFromLocal({ bareSpecifier: 'path:..' }, { projectDir: __dirname })
await resolveFromLocal({}, { bareSpecifier: 'path:..' }, { projectDir: __dirname })
fail()
} catch (err: any) { // eslint-disable-line
expect(err).toBeDefined()

View File

@@ -74,6 +74,7 @@ export interface ResolverFactoryOptions {
timeout?: number
registries: Registries
saveWorkspaceProtocol?: boolean | 'rolling'
preserveAbsolutePaths?: boolean
}
export interface NpmResolveResult extends ResolveResult {

View File

@@ -34,6 +34,7 @@ export type CreateNewStoreControllerOptions = CreateResolverOptions & Pick<Confi
| 'offline'
| 'packageImportMethod'
| 'preferOffline'
| 'preserveAbsolutePaths'
| 'registries'
| 'registrySupportsTimeField'
| 'resolutionMode'
@@ -92,6 +93,7 @@ export async function createNewStoreController (
resolveSymlinksInInjectedDirs: opts.resolveSymlinksInInjectedDirs,
includeOnlyPackageFiles: !opts.deployAllFiles,
saveWorkspaceProtocol: opts.saveWorkspaceProtocol,
preserveAbsolutePaths: opts.preserveAbsolutePaths,
})
await fs.mkdir(opts.storeDir, { recursive: true })
return {