Skip to content

Commit b61e268

Browse files
kibertoadzkochan
andauthored
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>
1 parent 3f37d17 commit b61e268

19 files changed

Lines changed: 862 additions & 49 deletions

File tree

.changeset/gh-packages-prefix.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
"@pnpm/config.reader": minor
3+
"@pnpm/resolving.npm-resolver": minor
4+
"@pnpm/resolving.default-resolver": minor
5+
"@pnpm/store.connection-manager": minor
6+
"@pnpm/types": minor
7+
"pnpm": minor
8+
---
9+
10+
Added support for installing packages from the [GitHub Packages npm registry](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-npm-registry) via a built-in `gh:` prefix (e.g. `pnpm add gh:@acme/private`), and, more broadly, for arbitrary named registries in the style of [vlt's named-registry aliases](https://docs.vlt.sh/cli/registries). Authentication is picked up from the existing per-URL `.npmrc` entries (e.g. `//npm.pkg.github.com/:_authToken=...`), so no separate auth mechanism is required.
11+
12+
Additional aliases — or an override for the built-in `gh` alias, for GitHub Enterprise Server — can be configured under `namedRegistries` in `pnpm-workspace.yaml`:
13+
14+
```yaml
15+
namedRegistries:
16+
gh: https://npm.pkg.github.example.com/
17+
work: https://npm.work.example.com/
18+
```
19+
20+
With this, `work:@corp/lib@^2.0.0` resolves against `https://npm.work.example.com/`. [#8941](https://github.com/pnpm/pnpm/issues/8941).

config/reader/src/Config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,7 @@ export interface Config extends OptionsFromRootManifest {
224224
agent?: string
225225

226226
registries: Registries
227+
namedRegistries?: Record<string, string>
227228
configByUri: Record<string, RegistryConfig>
228229
ignoreWorkspaceRootCheck: boolean
229230
workspaceRoot: boolean

config/reader/src/getOptionsFromRootManifest.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,13 +52,28 @@ function replaceEnvInSettings (settings: PnpmSettings): PnpmSettings {
5252
if (typeof value === 'string') {
5353
// @ts-expect-error
5454
newSettings[newKey as keyof PnpmSettings] = envReplace(value, process.env)
55+
} else if (newKey === 'registries' || newKey === 'namedRegistries') {
56+
// Registry URL maps in workspace yaml must support `${VAR}` substitution
57+
// in their values so users can reuse the same env-var pattern they use
58+
// in `.npmrc`. Only these keys are treated this way to avoid surprising
59+
// behavior on unrelated object-valued settings.
60+
newSettings[newKey as keyof PnpmSettings] = replaceEnvInStringValues(value) as never
5561
} else {
5662
newSettings[newKey as keyof PnpmSettings] = value
5763
}
5864
}
5965
return newSettings
6066
}
6167

68+
function replaceEnvInStringValues (value: unknown): unknown {
69+
if (value == null || typeof value !== 'object' || Array.isArray(value)) return value
70+
const out: Record<string, unknown> = {}
71+
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
72+
out[k] = typeof v === 'string' ? envReplace(v, process.env) : v
73+
}
74+
return out
75+
}
76+
6277
function createVersionReferencesReplacer (manifest: ProjectManifest): (spec: string) => string {
6378
const allDeps = {
6479
...manifest.devDependencies,

config/reader/test/getOptionsFromRootManifest.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,27 @@ test('getOptionsFromPnpmSettings() replaces env variables in settings', () => {
1717
expect(options.foo).toBe('bar')
1818
})
1919

20+
test('getOptionsFromPnpmSettings() expands env variables inside registries values', () => {
21+
process.env.PNPM_TEST_TOKEN = 'secret'
22+
const options = getOptionsFromPnpmSettings(process.cwd(), {
23+
registries: {
24+
default: 'https://registry.npmjs.org/',
25+
'@scope': 'https://registry.example.com/${PNPM_TEST_TOKEN}/',
26+
},
27+
}) as any // eslint-disable-line
28+
expect(options.registries['@scope']).toBe('https://registry.example.com/secret/')
29+
})
30+
31+
test('getOptionsFromPnpmSettings() expands env variables inside namedRegistries values', () => {
32+
process.env.PNPM_TEST_HOST = 'work.example.com'
33+
const options = getOptionsFromPnpmSettings(process.cwd(), {
34+
namedRegistries: {
35+
work: 'https://${PNPM_TEST_HOST}/npm/',
36+
},
37+
} as any) as any // eslint-disable-line
38+
expect(options.namedRegistries.work).toBe('https://work.example.com/npm/')
39+
})
40+
2041
test('getOptionsFromPnpmSettings() converts allowBuilds', () => {
2142
const options = getOptionsFromPnpmSettings(process.cwd(), {
2243
allowBuilds: {

core/types/src/package.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,7 @@ export interface AuditConfig {
171171
export interface PnpmSettings {
172172
npmrcAuthFile?: string
173173
registries?: Registries
174+
namedRegistries?: Record<string, string>
174175
configDependencies?: ConfigDependencies
175176
allowBuilds?: Record<string, boolean | string>
176177
overrides?: Record<string, string>

cspell.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@
107107
"garply",
108108
"gcttmf",
109109
"getattr",
110+
"ghes",
110111
"ghsa",
111112
"ghsas",
112113
"gitea",

installing/deps-installer/src/install/extendInstallOptions.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ export interface StrictInstallOptions {
111111
userAgent: string
112112
unsafePerm: boolean
113113
registries: Registries
114+
namedRegistries?: Record<string, string>
114115
tag: string
115116
overrides: Record<string, string>
116117
ownLifecycleHooksStdio: 'inherit' | 'pipe'

installing/deps-installer/src/install/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1315,6 +1315,7 @@ const _installInContext: InstallFunction = async (projects, ctx, opts) => {
13151315
preferredVersions,
13161316
preserveWorkspaceProtocol: opts.preserveWorkspaceProtocol,
13171317
registries: ctx.registries,
1318+
namedRegistries: opts.namedRegistries,
13181319
resolutionMode: opts.resolutionMode,
13191320
saveWorkspaceProtocol: opts.saveWorkspaceProtocol,
13201321
storeController: opts.storeController,

installing/deps-resolver/src/replaceVersionInBareSpecifier.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,33 @@
11
import semver from 'semver'
22

3-
export function replaceVersionInBareSpecifier (bareSpecifier: string, version: string): string {
3+
export function replaceVersionInBareSpecifier (
4+
bareSpecifier: string,
5+
version: string,
6+
namedRegistryPrefixes: readonly string[] = []
7+
): string {
48
if (semver.validRange(bareSpecifier)) {
59
return version
610
}
7-
if (!bareSpecifier.startsWith('npm:')) {
11+
let prefix: string | undefined
12+
if (bareSpecifier.startsWith('npm:')) {
13+
prefix = 'npm:'
14+
} else {
15+
for (const candidate of namedRegistryPrefixes) {
16+
if (bareSpecifier.startsWith(candidate)) {
17+
prefix = candidate
18+
break
19+
}
20+
}
21+
}
22+
if (prefix == null) {
823
return bareSpecifier
924
}
25+
// `<prefix>:<version_selector>` paired with a package alias — replace the
26+
// whole body. Covers both `npm:^1.0.0` and named-registry forms like
27+
// `gh:^1.0.0`, where the package name comes from the dependency alias.
28+
if (semver.validRange(bareSpecifier.slice(prefix.length))) {
29+
return `${prefix}${version}`
30+
}
1031
const versionDelimiter = bareSpecifier.lastIndexOf('@')
1132
if (versionDelimiter === -1 || bareSpecifier.indexOf('/') > versionDelimiter) {
1233
return `${bareSpecifier}@${version}`

installing/deps-resolver/src/resolveDependencies.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ export interface ResolutionContext {
178178
nodeVersion?: string
179179
pnpmVersion: string
180180
registries: Registries
181+
namedRegistryPrefixes: readonly string[]
181182
resolutionMode?: 'highest' | 'time-based' | 'lowest-direct'
182183
virtualStoreDir: string
183184
virtualStoreDirMaxLength: number
@@ -1320,7 +1321,7 @@ async function resolveDependency (
13201321
try {
13211322
const calcSpecifier = options.currentDepth === 0
13221323
if (!options.update && currentPkg.version && currentPkg.pkgId?.endsWith(`@${currentPkg.version}`) && !calcSpecifier) {
1323-
wantedDependency.bareSpecifier = replaceVersionInBareSpecifier(wantedDependency.bareSpecifier, currentPkg.version)
1324+
wantedDependency.bareSpecifier = replaceVersionInBareSpecifier(wantedDependency.bareSpecifier, currentPkg.version, ctx.namedRegistryPrefixes)
13241325
}
13251326
pkgResponse = await ctx.storeController.requestPackage(wantedDependency, {
13261327
allowBuild: ctx.allowBuild,
@@ -1813,6 +1814,7 @@ const NON_EXOTIC_RESOLVED_VIA = new Set([
18131814
'github.com/oven-sh/bun',
18141815
'jsr-registry',
18151816
'local-filesystem',
1817+
'named-registry',
18161818
'nodejs.org',
18171819
'npm-registry',
18181820
'workspace',

0 commit comments

Comments
 (0)