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