diff --git a/.changeset/gorgeous-islands-shake.md b/.changeset/gorgeous-islands-shake.md new file mode 100644 index 0000000000..79b3c6939b --- /dev/null +++ b/.changeset/gorgeous-islands-shake.md @@ -0,0 +1,6 @@ +--- +"@pnpm/npm-resolver": patch +"pnpm": patch +--- + +Throw an accurate error message when trying to install a package that has no versions, or all of its versions are unpublished [#5849](https://github.com/pnpm/pnpm/issues/5849). diff --git a/resolving/npm-resolver/src/pickPackage.ts b/resolving/npm-resolver/src/pickPackage.ts index 2990cc34cd..8ec1adfb12 100644 --- a/resolving/npm-resolver/src/pickPackage.ts +++ b/resolving/npm-resolver/src/pickPackage.ts @@ -20,10 +20,17 @@ export interface PackageMeta { name: string 'dist-tags': Record versions: Record - time?: Record + time?: PackageMetaTime cachedAt?: number } +export type PackageMetaTime = Record & { + unpublished?: { + time: string + versions: string[] + } +} + export interface PackageMetaCache { get: (key: string) => PackageMeta | undefined set: (key: string, meta: PackageMeta) => void diff --git a/resolving/npm-resolver/src/pickPackageFromMeta.ts b/resolving/npm-resolver/src/pickPackageFromMeta.ts index 87301d79fd..b7f0fdd27c 100644 --- a/resolving/npm-resolver/src/pickPackageFromMeta.ts +++ b/resolving/npm-resolver/src/pickPackageFromMeta.ts @@ -18,6 +18,14 @@ export function pickPackageFromMeta ( meta: PackageMeta, publishedBy?: Date ): PackageInRegistry | null { + if ((!meta.versions || Object.keys(meta.versions).length === 0) && !publishedBy) { + // Unfortunately, the npm registry doesn't return the time field in the abbreviated metadata. + // So we won't always know if the package was unpublished. + if (meta.time?.unpublished?.versions?.length) { + throw new PnpmError('UNPUBLISHED_PKG', `No versions available for ${spec.name} because it was unpublished`) + } + throw new PnpmError('NO_VERSIONS', `No versions available for ${spec.name}. The package may be unpublished.`) + } try { let version!: string | null switch (spec.type) { diff --git a/resolving/npm-resolver/test/fixtures/malformed.json b/resolving/npm-resolver/test/fixtures/unpublished.json similarity index 100% rename from resolving/npm-resolver/test/fixtures/malformed.json rename to resolving/npm-resolver/test/fixtures/unpublished.json diff --git a/resolving/npm-resolver/test/index.ts b/resolving/npm-resolver/test/index.ts index b6a71cf801..4c8eec7b8d 100644 --- a/resolving/npm-resolver/test/index.ts +++ b/resolving/npm-resolver/test/index.ts @@ -11,6 +11,7 @@ import { fixtures } from '@pnpm/test-fixtures' import loadJsonFile from 'load-json-file' import nock from 'nock' import exists from 'path-exists' +import omit from 'ramda/src/omit' import tempy from 'tempy' const f = fixtures(__dirname) @@ -45,6 +46,15 @@ async function retryLoadJsonFile (filePath: string) { } } +afterEach(() => { + nock.cleanAll() + nock.disableNetConnect() +}) + +beforeEach(() => { + nock.enableNetConnect() +}) + test('resolveFromNpm()', async () => { nock(registry) .get('/is-positive') @@ -1653,17 +1663,46 @@ test('request to metadata is retried if the received JSON is broken', async () = expect(resolveResult?.id).toBe('registry.npmjs.org/is-positive/1.0.0') }) -test('request to a package with malformed metadata', async () => { +test('request to a package with unpublished versions', async () => { nock(registry) .get('/code-snippet') - .reply(200, loadJsonFile.sync(f.find('malformed.json'))) + .reply(200, loadJsonFile.sync(f.find('unpublished.json'))) const cacheDir = tempy.directory() const resolve = createResolveFromNpm({ cacheDir }) await expect(resolve({ alias: 'code-snippet' }, { registry })).rejects .toThrow( - new PnpmError('MALFORMED_METADATA', 'Received malformed metadata for "code-snippet"') + new PnpmError('NO_VERSIONS', 'No versions available for code-snippet because it was unpublished') + ) +}) + +test('request to a package with no versions', async () => { + nock(registry) + .get('/code-snippet') + .reply(200, { name: 'code-snippet' }) + + const cacheDir = tempy.directory() + const resolve = createResolveFromNpm({ cacheDir }) + + await expect(resolve({ alias: 'code-snippet' }, { registry })).rejects + .toThrow( + new PnpmError('NO_VERSIONS', 'No versions available for code-snippet. The package may be unpublished.') + ) +}) + +test('request to a package with no dist-tags', async () => { + const isPositiveMeta = omit(['dist-tags'], loadJsonFile.sync(f.find('is-positive.json'))) + nock(registry) + .get('/is-positive') + .reply(200, isPositiveMeta) + + const cacheDir = tempy.directory() + const resolve = createResolveFromNpm({ cacheDir }) + + await expect(resolve({ alias: 'is-positive' }, { registry })).rejects + .toThrow( + new PnpmError('MALFORMED_METADATA', 'Received malformed metadata for "is-positive"') ) }) diff --git a/resolving/npm-resolver/test/publishedBy.test.ts b/resolving/npm-resolver/test/publishedBy.test.ts index 4ec62db969..018f668db9 100644 --- a/resolving/npm-resolver/test/publishedBy.test.ts +++ b/resolving/npm-resolver/test/publishedBy.test.ts @@ -18,6 +18,15 @@ const fetch = createFetchFromRegistry({}) const getAuthHeader = () => undefined const createResolveFromNpm = createNpmResolver.bind(null, fetch, getAuthHeader) +afterEach(() => { + nock.cleanAll() + nock.disableNetConnect() +}) + +beforeEach(() => { + nock.enableNetConnect() +}) + test('fall back to a newer version if there is no version published by the given date', async () => { nock(registry) .get('/bad-dates')