From 5dedadac76cc4f2a55d725cf81d676c5cc35d4be Mon Sep 17 00:00:00 2001 From: "Colin T.A. Gray" Date: Wed, 23 Jul 2025 17:54:49 -0400 Subject: [PATCH] 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 --- .changeset/fine-waves-stay.md | 5 ++ config/config/src/Config.ts | 1 + config/config/src/types.ts | 1 + pkg-manager/client/src/index.ts | 1 + resolving/default-resolver/src/index.ts | 5 +- resolving/local-resolver/src/index.ts | 6 ++- .../local-resolver/src/parseBareSpecifier.ts | 26 +++++++--- resolving/local-resolver/test/index.ts | 52 ++++++++++++++----- resolving/npm-resolver/src/index.ts | 1 + .../src/createNewStoreController.ts | 2 + 10 files changed, 78 insertions(+), 22 deletions(-) create mode 100644 .changeset/fine-waves-stay.md diff --git a/.changeset/fine-waves-stay.md b/.changeset/fine-waves-stay.md new file mode 100644 index 0000000000..64d35f1078 --- /dev/null +++ b/.changeset/fine-waves-stay.md @@ -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. diff --git a/config/config/src/Config.ts b/config/config/src/Config.ts index 6642c6ab68..f9c2dd6207 100644 --- a/config/config/src/Config.ts +++ b/config/config/src/Config.ts @@ -225,6 +225,7 @@ export interface Config extends OptionsFromRootManifest { initType: 'commonjs' | 'module' dangerouslyAllowAllBuilds: boolean ci: boolean + preserveAbsolutePaths?: boolean } export interface ConfigWithDeprecatedSettings extends Config { diff --git a/config/config/src/types.ts b/config/config/src/types.ts index 5369faabe6..6c90dd73cc 100644 --- a/config/config/src/types.ts +++ b/config/config/src/types.ts @@ -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, diff --git a/pkg-manager/client/src/index.ts b/pkg-manager/client/src/index.ts index 875f75b453..76afa56d8f 100644 --- a/pkg-manager/client/src/index.ts +++ b/pkg-manager/client/src/index.ts @@ -30,6 +30,7 @@ export type ClientOptions = { gitShallowHosts?: string[] resolveSymlinksInInjectedDirs?: boolean includeOnlyPackageFiles?: boolean + preserveAbsolutePaths?: boolean } & ResolverFactoryOptions & AgentOptions export interface Client { diff --git a/resolving/default-resolver/src/index.ts b/resolving/default-resolver/src/index.ts index f92de230b1..2f16ba82db 100644 --- a/resolving/default-resolver/src/index.ts +++ b/resolving/default-resolver/src/index.ts @@ -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) { diff --git a/resolving/local-resolver/src/index.ts b/resolving/local-resolver/src/index.ts index 952b5c8a1c..036dc17bf9 100644 --- a/resolving/local-resolver/src/index.ts +++ b/resolving/local-resolver/src/index.ts @@ -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 { - 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 { diff --git a/resolving/local-resolver/src/parseBareSpecifier.ts b/resolving/local-resolver/src/parseBareSpecifier.ts index ee92e5d067..148965debc 100644 --- a/resolving/local-resolver/src/parseBareSpecifier.ts +++ b/resolving/local-resolver/src/parseBareSpecifier.ts @@ -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 { diff --git a/resolving/local-resolver/test/index.ts b/resolving/local-resolver/test/index.ts index 115cf7c654..84b4151350 100644 --- a/resolving/local-resolver/test/index.ts +++ b/resolving/local-resolver/test/index.ts @@ -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() diff --git a/resolving/npm-resolver/src/index.ts b/resolving/npm-resolver/src/index.ts index b16c94481f..198e569579 100644 --- a/resolving/npm-resolver/src/index.ts +++ b/resolving/npm-resolver/src/index.ts @@ -74,6 +74,7 @@ export interface ResolverFactoryOptions { timeout?: number registries: Registries saveWorkspaceProtocol?: boolean | 'rolling' + preserveAbsolutePaths?: boolean } export interface NpmResolveResult extends ResolveResult { diff --git a/store/store-connection-manager/src/createNewStoreController.ts b/store/store-connection-manager/src/createNewStoreController.ts index b5481498bf..4e8ef5eed9 100644 --- a/store/store-connection-manager/src/createNewStoreController.ts +++ b/store/store-connection-manager/src/createNewStoreController.ts @@ -34,6 +34,7 @@ export type CreateNewStoreControllerOptions = CreateResolverOptions & Pick