Files
pnpm/resolving/npm-resolver/src/parseBareSpecifier.ts
Igor Savin b61e268d57 feat: add support for github prefix and named registries (#11324)
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>
2026-04-29 12:38:56 +02:00

165 lines
5.1 KiB
TypeScript

import { PnpmError } from '@pnpm/error'
import { parseJsrSpecifier } from '@pnpm/resolving.jsr-specifier-parser'
import parseNpmTarballUrl from 'parse-npm-tarball-url'
import semver from 'semver'
import getVersionSelectorType from 'version-selector-type'
export interface RegistryPackageSpec {
type: 'tag' | 'version' | 'range'
name: string
fetchSpec: string
normalizedBareSpecifier?: string
}
export function parseBareSpecifier (
bareSpecifier: string,
alias: string | undefined,
defaultTag: string,
registry: string
): RegistryPackageSpec | null {
let name = alias
if (bareSpecifier.startsWith('npm:')) {
bareSpecifier = bareSpecifier.slice(4)
// `npm:<version_selector>` — fall back to the outer dependency alias as
// the package name, mirroring the named-registry shape (e.g. `gh:^1.0.0`).
// Restricted to semver ranges/versions so unscoped package names like
// `npm:is-positive` keep their npm package-aliasing meaning.
if (alias && semver.validRange(bareSpecifier) != null) {
name = alias
} else {
const index = bareSpecifier.lastIndexOf('@')
if (index < 1) {
name = bareSpecifier
bareSpecifier = defaultTag
} else {
name = bareSpecifier.slice(0, index)
bareSpecifier = bareSpecifier.slice(index + 1)
}
}
}
if (name) {
const selector = getVersionSelectorType(bareSpecifier)
if (selector != null) {
return {
fetchSpec: selector.normalized,
name,
type: selector.type,
}
}
}
if (bareSpecifier.startsWith(registry)) {
const pkg = parseNpmTarballUrl.default(bareSpecifier)
if (pkg != null) {
return {
fetchSpec: pkg.version,
name: pkg.name,
normalizedBareSpecifier: bareSpecifier,
type: 'version',
}
}
}
return null
}
export interface JsrRegistryPackageSpec extends RegistryPackageSpec {
jsrPkgName: string
}
export function parseJsrSpecifierToRegistryPackageSpec (
rawSpecifier: string,
alias: string | undefined,
defaultTag: string
): JsrRegistryPackageSpec | null {
const spec = parseJsrSpecifier(rawSpecifier, alias)
if (!spec?.npmPkgName) return null
const selector = getVersionSelectorType(spec.versionSelector ?? defaultTag)
if (selector == null) return null
return {
fetchSpec: selector.normalized,
name: spec.npmPkgName,
type: selector.type,
jsrPkgName: spec.jsrPkgName,
}
}
export const BUILTIN_NAMED_REGISTRIES: Readonly<Record<string, string>> = Object.freeze({
gh: 'https://npm.pkg.github.com/',
})
export interface NamedRegistryPackageSpec extends RegistryPackageSpec {
registryName: string
}
// Parses a named-registry specifier of the shape `<alias>:<body>` into a
// RegistryPackageSpec. Returns `null` when the specifier does not use one of
// the configured aliases, so the caller can fall through to other resolvers.
// Supported shapes:
// - `<alias>:[@<owner>/]<name>[@<version_selector>]`
// - `<alias>:<version_selector>` paired with a package alias
export function parseNamedRegistrySpecifierToRegistryPackageSpec (
rawSpecifier: string,
knownRegistryNames: ReadonlySet<string>,
packageAlias: string | undefined,
defaultTag: string
): NamedRegistryPackageSpec | null {
const colon = rawSpecifier.indexOf(':')
if (colon <= 0) return null
const registryName = rawSpecifier.substring(0, colon)
if (!knownRegistryNames.has(registryName)) return null
const body = rawSpecifier.substring(colon + 1)
let pkgName: string
let versionSelector: string | undefined
if (semver.validRange(body) != null) {
// `<alias>:<version_selector>` — fall back to the dependency alias as
// the package name. Unresolvable without one.
if (!packageAlias) return null
pkgName = packageAlias
versionSelector = body
} else if (body[0] === '@') {
// `<alias>:@<owner>/<name>[@<version_selector>]` — scoped package.
const index = body.lastIndexOf('@')
if (index === 0) {
pkgName = body
} else {
pkgName = body.substring(0, index)
versionSelector = body.substring(index + '@'.length)
}
if (pkgName.indexOf('/') === -1 || pkgName.endsWith('/')) {
throw new PnpmError(
'INVALID_NAMED_REGISTRY_PACKAGE_NAME',
`The package name '${pkgName}' in named registry '${registryName}:' is invalid`
)
}
} else if (packageAlias?.startsWith('@')) {
// `<alias>:<tag>` paired with a scoped alias — body is a version
// selector (tag/dist-tag). Mirrors GitHub Packages, where the package
// is always scoped and a bare body is a tag.
pkgName = packageAlias
versionSelector = body
} else {
// `<alias>:<name>[@<version_selector>]` — unscoped package in body.
const index = body.lastIndexOf('@')
if (index < 1) {
pkgName = body
} else {
pkgName = body.substring(0, index)
versionSelector = body.substring(index + '@'.length)
}
if (!pkgName) return null
}
const selector = getVersionSelectorType(versionSelector ?? defaultTag)
if (selector == null) return null
return {
fetchSpec: selector.normalized,
name: pkgName,
type: selector.type,
registryName,
}
}