mirror of
https://github.com/pnpm/pnpm.git
synced 2026-05-12 18:49:41 -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>
165 lines
5.1 KiB
TypeScript
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,
|
|
}
|
|
}
|