mirror of
https://github.com/pnpm/pnpm.git
synced 2026-05-12 01:54:53 -04:00
This is consistent with #9358, but implements support for the GitHub Packages npm registry and, more broadly, for vlt-style https://docs.vlt.sh/cli/registries for any registry. This PR adds a built-in gh: specifier that resolves against the GitHub Packages npm registry, plus a namedRegistries config key so a project can map its own aliases to arbitrary registries. A project can mix public npm packages and private GitHub Packages (or self-hosted) ones without applying a scope-wide registry override to every @scope/* package. - pnpm add gh:@acme/private writes "@acme/private": "gh:^1.0.0" and resolves from https://npm.pkg.github.com/. - pnpm add gh:@acme/private@^1.0.0 (with or without an alias) is also supported. Aliased form writes "my-alias": "gh:@acme/private@^1.0.0". - Auth comes from the existing per-URL .npmrc mechanism, e.g. //npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}. No new auth surface. - @github is intentionally not defaulted to https://npm.pkg.github.com/ - hardcoding that would hijack installs of the public @github/* packages on npmjs.org (e.g. @github/relative-time-element) for users without a scope-wide override. Use gh: to install from GitHub Packages, or configure @github:registry=... yourself if that's really what you want. - Additional named registries (a self-hosted proxy, GitHub Enterprise Server, etc.) can be configured in pnpm-workspace.yaml: ```yml namedRegistries: gh: https://npm.pkg.github.example.com/ # optional: overrides the built-in `gh` alias for GHES work: https://npm.work.example.com/ ``` - Then work:@corp/lib@^2.0.0 resolves against https://npm.work.example.com/, and the built-in gh alias can be redirected to a GHES host. - Env-var substitution (${VAR}) is supported in namedRegistries values, mirroring the .npmrc convention. - Reserved alias names (npm, jsr, github, workspace, catalog, file, git, http, https, link, patch, and related git host shorthands) cannot be redefined as user-named registries - the resolver throws ERR_PNPM_RESERVED_NAMED_REGISTRY_ALIAS at startup rather than silently shadowing another protocol. Malformed URLs throw ERR_PNPM_INVALID_NAMED_REGISTRY_URL at startup too, instead of failing as a confusing 404 during resolution. - On publish, createExportableManifest strips any named-registry prefix (both the built-in gh: and any user-configured alias) so npm and yarn consumers can still resolve the dependency via their own scope-registry configuration - mirroring the user-facing requirement when installing such a dep without the prefix. The prefix is gh: rather than github: because github: is reserved by npm-package-arg / hosted-git-info as a git host shorthand (e.g. github:owner/repo) - reusing it would be a deviation from the specs used by the npm CLI. gh: is shorter, matches vlt's convention, and cannot collide with any existing npm scheme. Unlike jsr:, gh: (and any other named-registry alias) does not rewrite the package name - gh:@acme/foo resolves @acme/foo from the GitHub Packages registry as-is. This also means npm/yarn consumers see the original name after the prefix is stripped on publish. --------- Co-authored-by: Zoltan Kochan <z@kochan.io>
175 lines
8.6 KiB
TypeScript
175 lines
8.6 KiB
TypeScript
import { describe, expect, test } from '@jest/globals'
|
|
|
|
import {
|
|
type NamedRegistryPackageSpec,
|
|
parseBareSpecifier,
|
|
parseNamedRegistrySpecifierToRegistryPackageSpec,
|
|
} from '../lib/parseBareSpecifier.js'
|
|
|
|
const GH_ALIASES: ReadonlySet<string> = new Set(['gh'])
|
|
const DEFAULT_TAG = 'latest'
|
|
const NPM_REGISTRY = 'https://registry.npmjs.org/'
|
|
|
|
describe('parseBareSpecifier', () => {
|
|
test('npm:<version_selector> falls back to the outer alias as the package name', () => {
|
|
// Mirrors the named-registry shape (`gh:^1.0.0` paired with `@acme/foo`),
|
|
// so `npm:^1.0.0` paired with `is-positive` resolves the outer alias.
|
|
expect(parseBareSpecifier('npm:^1.0.0', 'is-positive', DEFAULT_TAG, NPM_REGISTRY)).toMatchObject({
|
|
name: 'is-positive',
|
|
type: 'range',
|
|
})
|
|
expect(parseBareSpecifier('npm:1.0.0', '@acme/foo', DEFAULT_TAG, NPM_REGISTRY)).toMatchObject({
|
|
name: '@acme/foo',
|
|
type: 'version',
|
|
fetchSpec: '1.0.0',
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('parseNamedRegistrySpecifierToRegistryPackageSpec', () => {
|
|
test('returns null on non-named-registry specifiers', () => {
|
|
expect(parseNamedRegistrySpecifierToRegistryPackageSpec('^1.0.0', GH_ALIASES, undefined, DEFAULT_TAG)).toBeNull()
|
|
expect(parseNamedRegistrySpecifierToRegistryPackageSpec('1.0.0', GH_ALIASES, undefined, DEFAULT_TAG)).toBeNull()
|
|
expect(parseNamedRegistrySpecifierToRegistryPackageSpec('latest', GH_ALIASES, undefined, DEFAULT_TAG)).toBeNull()
|
|
expect(parseNamedRegistrySpecifierToRegistryPackageSpec('npm:foo', GH_ALIASES, undefined, DEFAULT_TAG)).toBeNull()
|
|
expect(parseNamedRegistrySpecifierToRegistryPackageSpec('npm:@foo/bar', GH_ALIASES, undefined, DEFAULT_TAG)).toBeNull()
|
|
expect(parseNamedRegistrySpecifierToRegistryPackageSpec('jsr:@foo/bar', GH_ALIASES, undefined, DEFAULT_TAG)).toBeNull()
|
|
expect(parseNamedRegistrySpecifierToRegistryPackageSpec('catalog:', GH_ALIASES, undefined, DEFAULT_TAG)).toBeNull()
|
|
expect(parseNamedRegistrySpecifierToRegistryPackageSpec('workspace:*', GH_ALIASES, undefined, DEFAULT_TAG)).toBeNull()
|
|
})
|
|
|
|
test('does not intercept github: git shorthand (that scheme belongs to the git resolver)', () => {
|
|
// `hosted-git-info` / `npm-package-arg` own the `github:` scheme as a GitHub git repository shortcut.
|
|
// Even if a caller accidentally passed it in, it is not in the built-in `gh` alias set.
|
|
expect(parseNamedRegistrySpecifierToRegistryPackageSpec('github:owner/repo', GH_ALIASES, undefined, DEFAULT_TAG)).toBeNull()
|
|
expect(parseNamedRegistrySpecifierToRegistryPackageSpec('github:owner/repo#main', GH_ALIASES, undefined, DEFAULT_TAG)).toBeNull()
|
|
expect(parseNamedRegistrySpecifierToRegistryPackageSpec('github:@acme/foo', GH_ALIASES, undefined, DEFAULT_TAG)).toBeNull()
|
|
})
|
|
|
|
test('parses <alias>:<version_selector> when a scoped package alias is given', () => {
|
|
expect(parseNamedRegistrySpecifierToRegistryPackageSpec('gh:^1.0.0', GH_ALIASES, '@acme/foo', DEFAULT_TAG)).toStrictEqual({
|
|
name: '@acme/foo',
|
|
fetchSpec: '>=1.0.0 <2.0.0-0',
|
|
type: 'range',
|
|
registryName: 'gh',
|
|
} as NamedRegistryPackageSpec)
|
|
expect(parseNamedRegistrySpecifierToRegistryPackageSpec('gh:1.0.0', GH_ALIASES, '@acme/foo', DEFAULT_TAG)).toStrictEqual({
|
|
name: '@acme/foo',
|
|
fetchSpec: '1.0.0',
|
|
type: 'version',
|
|
registryName: 'gh',
|
|
} as NamedRegistryPackageSpec)
|
|
expect(parseNamedRegistrySpecifierToRegistryPackageSpec('gh:latest', GH_ALIASES, '@acme/foo', DEFAULT_TAG)).toStrictEqual({
|
|
name: '@acme/foo',
|
|
fetchSpec: 'latest',
|
|
type: 'tag',
|
|
registryName: 'gh',
|
|
} as NamedRegistryPackageSpec)
|
|
})
|
|
|
|
test('parses <alias>:@<owner>/<name> and falls back to the default tag', () => {
|
|
expect(parseNamedRegistrySpecifierToRegistryPackageSpec('gh:@acme/foo', GH_ALIASES, undefined, DEFAULT_TAG)).toStrictEqual({
|
|
name: '@acme/foo',
|
|
fetchSpec: 'latest',
|
|
type: 'tag',
|
|
registryName: 'gh',
|
|
} as NamedRegistryPackageSpec)
|
|
})
|
|
|
|
test('parses <alias>:@<owner>/<name>@<version_selector>', () => {
|
|
expect(parseNamedRegistrySpecifierToRegistryPackageSpec('gh:@acme/foo@^1.0.0', GH_ALIASES, undefined, DEFAULT_TAG)).toStrictEqual({
|
|
name: '@acme/foo',
|
|
fetchSpec: '>=1.0.0 <2.0.0-0',
|
|
type: 'range',
|
|
registryName: 'gh',
|
|
} as NamedRegistryPackageSpec)
|
|
expect(parseNamedRegistrySpecifierToRegistryPackageSpec('gh:@acme/foo@1.0.0', GH_ALIASES, undefined, DEFAULT_TAG)).toStrictEqual({
|
|
name: '@acme/foo',
|
|
fetchSpec: '1.0.0',
|
|
type: 'version',
|
|
registryName: 'gh',
|
|
} as NamedRegistryPackageSpec)
|
|
expect(parseNamedRegistrySpecifierToRegistryPackageSpec('gh:@acme/foo@beta', GH_ALIASES, undefined, DEFAULT_TAG)).toStrictEqual({
|
|
name: '@acme/foo',
|
|
fetchSpec: 'beta',
|
|
type: 'tag',
|
|
registryName: 'gh',
|
|
} as NamedRegistryPackageSpec)
|
|
})
|
|
|
|
test('preserves the original package name (no scope rewrite, unlike jsr)', () => {
|
|
// Named registries publish the package under its original name, unlike the JSR
|
|
// npm compatibility registry which remaps `@scope/name` to `@jsr/scope__name`.
|
|
expect(parseNamedRegistrySpecifierToRegistryPackageSpec('gh:@acme/foo@1.0.0', GH_ALIASES, undefined, DEFAULT_TAG)?.name).toBe('@acme/foo')
|
|
})
|
|
|
|
test('throws when the scope has no package name', () => {
|
|
expect(() => parseNamedRegistrySpecifierToRegistryPackageSpec('gh:@acme@^1.0.0', GH_ALIASES, undefined, DEFAULT_TAG)).toThrow(expect.objectContaining({
|
|
code: 'ERR_PNPM_INVALID_NAMED_REGISTRY_PACKAGE_NAME',
|
|
}))
|
|
expect(() => parseNamedRegistrySpecifierToRegistryPackageSpec('gh:@acme', GH_ALIASES, undefined, DEFAULT_TAG)).toThrow(expect.objectContaining({
|
|
code: 'ERR_PNPM_INVALID_NAMED_REGISTRY_PACKAGE_NAME',
|
|
}))
|
|
expect(() => parseNamedRegistrySpecifierToRegistryPackageSpec('gh:@acme/', GH_ALIASES, undefined, DEFAULT_TAG)).toThrow(expect.objectContaining({
|
|
code: 'ERR_PNPM_INVALID_NAMED_REGISTRY_PACKAGE_NAME',
|
|
}))
|
|
})
|
|
|
|
test('does not claim <alias>:<version_selector> when no package alias is provided', () => {
|
|
// No alias means we cannot know the package name — we must not hijack such specifiers.
|
|
expect(parseNamedRegistrySpecifierToRegistryPackageSpec('gh:^1.0.0', GH_ALIASES, undefined, DEFAULT_TAG)).toBeNull()
|
|
})
|
|
|
|
test('falls back to the dependency alias for <alias>:<version_selector>, scoped or not', () => {
|
|
// Mirrors the `npm:^1.0.0` shape — works with both scoped and unscoped aliases.
|
|
expect(parseNamedRegistrySpecifierToRegistryPackageSpec('gh:^1.0.0', GH_ALIASES, '@acme/foo', DEFAULT_TAG)).toMatchObject({
|
|
name: '@acme/foo',
|
|
registryName: 'gh',
|
|
})
|
|
expect(parseNamedRegistrySpecifierToRegistryPackageSpec('work:^4.0.0', new Set(['work']), 'lodash', DEFAULT_TAG)).toMatchObject({
|
|
name: 'lodash',
|
|
registryName: 'work',
|
|
})
|
|
})
|
|
|
|
test('parses unscoped <alias>:<name>[@<version_selector>]', () => {
|
|
// Arbitrary named registries (vlt-style) accept unscoped names too,
|
|
// not just GitHub Packages-style scopes.
|
|
expect(parseNamedRegistrySpecifierToRegistryPackageSpec('work:lodash@^4.0.0', new Set(['work']), undefined, DEFAULT_TAG)).toMatchObject({
|
|
name: 'lodash',
|
|
fetchSpec: '>=4.0.0 <5.0.0-0',
|
|
type: 'range',
|
|
registryName: 'work',
|
|
})
|
|
expect(parseNamedRegistrySpecifierToRegistryPackageSpec('work:lodash', new Set(['work']), undefined, DEFAULT_TAG)).toMatchObject({
|
|
name: 'lodash',
|
|
fetchSpec: 'latest',
|
|
type: 'tag',
|
|
registryName: 'work',
|
|
})
|
|
})
|
|
|
|
test('matches any alias in the configured set and reports it back to the caller', () => {
|
|
expect(parseNamedRegistrySpecifierToRegistryPackageSpec('work:@acme/foo@^1.0.0', new Set(['gh', 'work']), undefined, DEFAULT_TAG)).toStrictEqual({
|
|
name: '@acme/foo',
|
|
fetchSpec: '>=1.0.0 <2.0.0-0',
|
|
type: 'range',
|
|
registryName: 'work',
|
|
} as NamedRegistryPackageSpec)
|
|
})
|
|
|
|
test('returns null when the alias is not in the configured set', () => {
|
|
// Unrecognized prefixes must fall through so other resolvers (git, npm:, etc.) can try.
|
|
expect(parseNamedRegistrySpecifierToRegistryPackageSpec('work:@acme/foo', GH_ALIASES, undefined, DEFAULT_TAG)).toBeNull()
|
|
})
|
|
|
|
test('includes the user-facing alias in error messages for user-defined aliases', () => {
|
|
// When `work:@acme` fails validation, the user's alias must appear in the error
|
|
// so they can find the offending specifier — not a generic `gh` reference.
|
|
expect(() => parseNamedRegistrySpecifierToRegistryPackageSpec('work:@acme', new Set(['work']), undefined, DEFAULT_TAG)).toThrow(expect.objectContaining({
|
|
code: 'ERR_PNPM_INVALID_NAMED_REGISTRY_PACKAGE_NAME',
|
|
message: expect.stringContaining("'work:'"),
|
|
}))
|
|
})
|
|
})
|