Registries do not all lay out tarball URLs the way the npm registry does. JFrog Artifactory repeats the scope in a scoped package's tarball filename (`@acme/widget/-/@acme/widget-1.0.0.tgz`) where npm strips it. pnpm cannot rebuild such a URL, so it writes it out for every scoped package instead of omitting it, and the lockfile carries a host-specific URL per dependency. pnpm had no place to record such a fact, because it had no place to describe a registry at all — only three ways to name one. `registries` mapped a scope to a URL, `namedRegistries` mapped a bare-specifier prefix to a URL, and neither could carry anything else. `registries` now declares a registry once, keyed by its URL, with every fact about it in the entry: a `serverType`, the `scopes` routed to it, and the `prefix` it answers to. `serverType` has three states: undeclared strict; only the exact canonical URL is reconstructible npm also serves the percent-encoded scoped path artifactory repeats the scope in the tarball filename registry.npmjs.org resolves to `npm` as a built-in, so its behavior is unchanged and the old hostname check becomes that one default rather than a special case in the predicate. `npm` cannot be the default: asserting npmjs-compatibility is a claim only the operator can make. The URL is the key because every fact in an entry is a fact about that server. Keying the layout by scope would bind it to whoever the scope currently points at, so two developers whose scope resolves differently would write lockfiles that disagree about which URLs may be omitted. `scopes` and `prefix` are routes to the registry and are inverted at config-read time into the two lookups the rest of pnpm already queries, leaving the resolver, installer, and lockfile layers untouched and the precedence chain (builtin < .npmrc < yaml < `_auth` < CLI) unchanged. The layout is declared, never inferred. Sniffing the registry URL cannot work: a virtual repository serves both layouts at once, depending on whether each package was synced from upstream or published locally, so no registry-level signal — route, response header, or probe — decides it. Declaring it also keeps a wrong guess a fixable misconfiguration instead of a silent breakage. `serverType` feeds a single URL builder that both sides of the lockfile use: the writer omits a tarball URL only when the builder reproduces it, and the reader rebuilds it with the same call. They therefore agree by construction, so pnpm never has to assume a registry serves some second URL as well. The setting lives in pnpm-workspace.yaml rather than .npmrc because the lockfile depends on it: one developer omitting URLs that another reconstructs differently would break a frozen install. A `serverType` in the global config.yaml is ignored for the same reason, while the routes declared alongside it are kept. Credentials are rejected there — the file is committed — and still belong in .npmrc. The registry URL is the map key, so the request-destination env gate applies to keys as well as values. Credentials and unknown fields are refused after parsing, since a parse error renders the offending source line verbatim. A map whose values are all strings is the older `<scope>: <url>` shape and is still read as one. Mixing the two shapes in one map is refused, and so is a URL-keyed entry written as a string. `namedRegistries` is deprecated in favor of `prefix` and is read only for prefixes `registries` does not declare; a prefix stays singular because it is the registry's identity in a lockfile dep path. An entry that routes nothing to itself and matches no configured registry is reported as a warning rather than silently ignored; it is a warning and not an error because a shared config dependency can legitimately describe registries a given project does not use. Config dependencies and pnpr-server-mode resolution pass no server type; in both, the writer and the reader share that default, so they stay consistent. Closes pnpm/get-npm-tarball-url#16. Supersedes pnpm/pnpm#13920.
195 lines
6.9 KiB
TypeScript
195 lines
6.9 KiB
TypeScript
import { afterEach, beforeEach, expect, test } from '@jest/globals'
|
|
import { createPackageVersionPolicy } from '@pnpm/config.version-policy'
|
|
import { type LogBase, streamParser } from '@pnpm/logger'
|
|
import { createFetchFromRegistry } from '@pnpm/network.fetch'
|
|
import { createNpmResolver } from '@pnpm/resolving.npm-resolver'
|
|
import type { RegistriesByScope } from '@pnpm/types'
|
|
import { temporaryDirectory } from 'tempy'
|
|
|
|
import { getMockAgent, setupMockAgent, teardownMockAgent } from './utils/index.js'
|
|
|
|
const registriesByScope: RegistriesByScope = {
|
|
default: 'https://registry.npmjs.org/',
|
|
}
|
|
const registryOrigin = registriesByScope.default.slice(0, -1)
|
|
|
|
const fetch = createFetchFromRegistry({})
|
|
const getAuthHeader = () => undefined
|
|
const createResolveFromNpm = createNpmResolver.bind(null, fetch, getAuthHeader)
|
|
|
|
const fooMeta = {
|
|
name: 'foo',
|
|
'dist-tags': { latest: '2.1.4' },
|
|
versions: {
|
|
'2.1.3': {
|
|
name: 'foo',
|
|
version: '2.1.3',
|
|
dist: {
|
|
integrity: 'sha512-9Qa5b+9n69IEuxk4FiNcavXqkixb9lD03BLtdTeu2bbORnLZQrw+pR/exiSg7SoODeu08yxS47mdZa9ddodNwQ==',
|
|
tarball: 'https://registry.npmjs.org/foo/-/foo-2.1.3.tgz',
|
|
},
|
|
},
|
|
'2.1.4': {
|
|
name: 'foo',
|
|
version: '2.1.4',
|
|
dist: {
|
|
integrity: 'sha512-9Qa5b+9n69IEuxk4FiNcavXqkixb9lD03BLtdTeu2bbORnLZQrw+pR/exiSg7SoODeu08yxS47mdZa9ddodNwQ==',
|
|
tarball: 'https://registry.npmjs.org/foo/-/foo-2.1.4.tgz',
|
|
},
|
|
},
|
|
},
|
|
time: {
|
|
'2.1.3': '2026-01-01T00:00:00.000Z',
|
|
'2.1.4': '2026-07-14T12:00:00.000Z',
|
|
},
|
|
}
|
|
|
|
const collectedWarnings: string[] = []
|
|
|
|
beforeEach(async () => {
|
|
collectedWarnings.length = 0
|
|
streamParser.on('data', collectWarnings as (msg: LogBase) => void)
|
|
await setupMockAgent()
|
|
})
|
|
|
|
afterEach(async () => {
|
|
streamParser.removeListener('data', collectWarnings as (msg: LogBase) => void)
|
|
await teardownMockAgent()
|
|
})
|
|
|
|
function collectWarnings (msg: LogBase & { message?: string }): void {
|
|
if (msg.level === 'warn' && typeof msg.message === 'string') {
|
|
collectedWarnings.push(msg.message)
|
|
}
|
|
}
|
|
|
|
// https://github.com/pnpm/pnpm/issues/13071
|
|
test('does not warn about a held-back update when minimumReleaseAge is the reason the newer version was not picked', async () => {
|
|
getMockAgent().get(registryOrigin)
|
|
.intercept({ path: '/foo', method: 'GET' })
|
|
.reply(200, fooMeta)
|
|
|
|
const { resolveFromNpm } = createResolveFromNpm({
|
|
storeDir: temporaryDirectory(),
|
|
cacheDir: temporaryDirectory(),
|
|
fullMetadata: true,
|
|
registriesByScope,
|
|
})
|
|
const resolveResult = await resolveFromNpm({ alias: 'foo', bareSpecifier: '^2.1.3' }, {
|
|
updateRequested: true,
|
|
publishedBy: new Date('2026-07-01T00:00:00.000Z'),
|
|
preferredVersions: {
|
|
foo: { '^2.1.3': 'range' },
|
|
},
|
|
})
|
|
|
|
// 2.1.4 is inside the minimumReleaseAge window, so 2.1.3 is picked.
|
|
expect(resolveResult!.id).toBe('foo@2.1.3')
|
|
// The pick is fully explained by the maturity cutoff, so no warning about
|
|
// manifests holding the update back should be printed.
|
|
expect(collectedWarnings.filter(warning => warning.includes('was updated to'))).toStrictEqual([])
|
|
})
|
|
|
|
test('still warns about a held-back update when a manifest pin is the reason the newer version was not picked', async () => {
|
|
getMockAgent().get(registryOrigin)
|
|
.intercept({ path: '/foo', method: 'GET' })
|
|
.reply(200, fooMeta)
|
|
|
|
const { resolveFromNpm } = createResolveFromNpm({
|
|
storeDir: temporaryDirectory(),
|
|
cacheDir: temporaryDirectory(),
|
|
fullMetadata: true,
|
|
registriesByScope,
|
|
})
|
|
const resolveResult = await resolveFromNpm({ alias: 'foo', bareSpecifier: '^2.1.3' }, {
|
|
updateRequested: true,
|
|
preferredVersions: {
|
|
foo: { '2.1.3': { selectorType: 'version', weight: 1000 } },
|
|
},
|
|
})
|
|
|
|
expect(resolveResult!.id).toBe('foo@2.1.3')
|
|
const heldBackWarnings = collectedWarnings.filter(warning => warning.includes('was updated to'))
|
|
expect(heldBackWarnings).toHaveLength(1)
|
|
expect(heldBackWarnings[0]).toContain('was updated to 2.1.3, not 2.1.4')
|
|
})
|
|
|
|
test('still warns about a held-back update when the newer version is mature under the publishedBy cutoff', async () => {
|
|
getMockAgent().get(registryOrigin)
|
|
.intercept({ path: '/foo', method: 'GET' })
|
|
.reply(200, fooMeta)
|
|
|
|
const { resolveFromNpm } = createResolveFromNpm({
|
|
storeDir: temporaryDirectory(),
|
|
cacheDir: temporaryDirectory(),
|
|
fullMetadata: true,
|
|
registriesByScope,
|
|
})
|
|
const resolveResult = await resolveFromNpm({ alias: 'foo', bareSpecifier: '^2.1.3' }, {
|
|
updateRequested: true,
|
|
publishedBy: new Date('2026-08-01T00:00:00.000Z'),
|
|
preferredVersions: {
|
|
foo: { '2.1.3': { selectorType: 'version', weight: 1000 } },
|
|
},
|
|
})
|
|
|
|
expect(resolveResult!.id).toBe('foo@2.1.3')
|
|
const heldBackWarnings = collectedWarnings.filter(warning => warning.includes('was updated to'))
|
|
expect(heldBackWarnings).toHaveLength(1)
|
|
expect(heldBackWarnings[0]).toContain('was updated to 2.1.3, not 2.1.4')
|
|
})
|
|
|
|
test('keeps the unfiltered baseline for a package excluded from the age gate via publishedByExclude', async () => {
|
|
getMockAgent().get(registryOrigin)
|
|
.intercept({ path: '/foo', method: 'GET' })
|
|
.reply(200, fooMeta)
|
|
|
|
const { resolveFromNpm } = createResolveFromNpm({
|
|
storeDir: temporaryDirectory(),
|
|
cacheDir: temporaryDirectory(),
|
|
fullMetadata: true,
|
|
registriesByScope,
|
|
})
|
|
const resolveResult = await resolveFromNpm({ alias: 'foo', bareSpecifier: '^2.1.3' }, {
|
|
updateRequested: true,
|
|
publishedBy: new Date('2026-07-01T00:00:00.000Z'),
|
|
publishedByExclude: () => true,
|
|
preferredVersions: {
|
|
foo: { '2.1.3': { selectorType: 'version', weight: 1000 } },
|
|
},
|
|
})
|
|
|
|
// The pin holds the pick at 2.1.3, and the exclusion keeps 2.1.4 in the
|
|
// baseline despite the publishedBy cutoff, so the warning is printed.
|
|
expect(resolveResult!.id).toBe('foo@2.1.3')
|
|
const heldBackWarnings = collectedWarnings.filter(warning => warning.includes('was updated to'))
|
|
expect(heldBackWarnings).toHaveLength(1)
|
|
expect(heldBackWarnings[0]).toContain('was updated to 2.1.3, not 2.1.4')
|
|
})
|
|
|
|
test('keeps a version the age gate trusts by exact version in the baseline', async () => {
|
|
getMockAgent().get(registryOrigin)
|
|
.intercept({ path: '/foo', method: 'GET' })
|
|
.reply(200, fooMeta)
|
|
|
|
const { resolveFromNpm } = createResolveFromNpm({
|
|
storeDir: temporaryDirectory(),
|
|
cacheDir: temporaryDirectory(),
|
|
fullMetadata: true,
|
|
registriesByScope,
|
|
})
|
|
const resolveResult = await resolveFromNpm({ alias: 'foo', bareSpecifier: '^2.1.3' }, {
|
|
updateRequested: true,
|
|
publishedBy: new Date('2026-07-01T00:00:00.000Z'),
|
|
publishedByExclude: createPackageVersionPolicy(['foo@2.1.4']),
|
|
preferredVersions: {
|
|
foo: { '2.1.3': { selectorType: 'version', weight: 1000 } },
|
|
},
|
|
})
|
|
|
|
expect(resolveResult!.id).toBe('foo@2.1.3')
|
|
const heldBackWarnings = collectedWarnings.filter(warning => warning.includes('was updated to'))
|
|
expect(heldBackWarnings).toHaveLength(1)
|
|
expect(heldBackWarnings[0]).toContain('was updated to 2.1.3, not 2.1.4')
|
|
})
|