Skip to content

Commit ed7c524

Browse files
committed
fix(module,upgrade): resolve npmrc credentials as npm does, falling back to npm
1 parent da95c8b commit ed7c524

7 files changed

Lines changed: 154 additions & 38 deletions

File tree

‎packages/nuxt-cli/src/commands/module/add.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -423,8 +423,8 @@ async function resolveModule(moduleName: string, cwd: string, modulesDB: NuxtMod
423423
constmeta: RegistryMeta=awaitdetectNpmRegistry(pkgScope,cwd)
424424
constheaders: HeadersInit={}
425425

426-
if(meta.authToken){
427-
headers.Authorization=`Bearer ${meta.authToken}`
426+
if(meta.authorization){
427+
headers.Authorization=meta.authorization
428428
}
429429

430430
// TODO: spinner

‎packages/nuxt-cli/src/utils/registry.ts‎

Lines changed: 63 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,25 @@
11
importtype{FileHandle}from'node:fs/promises'
22

3+
import{Buffer}from'node:buffer'
34
import*asfsfrom'node:fs'
45
import{homedir}from'node:os'
56
import{join}from'node:path'
67
importprocessfrom'node:process'
78

89
import{parseINI}from'confbox'
910

10-
constPROTOCOL_RE=/^https?:\/\//
1111
constTRAILING_SLASH_RE=/\/$/
12+
constENV_REFERENCE_RE=/\$\{([^}]+)\}/g
13+
14+
/** The registry every public package can be read from, whatever else is configured. */
15+
exportconstPUBLIC_REGISTRY='https://registry.npmjs.org'
1216

1317
exportinterfaceRegistryMeta{
1418
/** Registry URL without a trailing slash, so paths can be appended directly. */
1519
registry: string
1620
authToken: string|null
21+
/** `Authorization` header value for {@link registry}, from a token or basic credentials. */
22+
authorization: string|null
1723
}
1824

1925
exportfunctiongetRegistryFromContent(content: string,scope: string|null): string|null{
@@ -69,27 +75,69 @@ async function getRegistryFromFile(paths: string[], scope: string | null) {
6975
asyncfunctiongetRegistry(scope: string|null,cwd: string): Promise<string>{
7076
constregistry=process.env.COREPACK_NPM_REGISTRY
7177
||awaitgetRegistryFromFile(getNpmrcPaths(cwd),scope)
72-
||'https://registry.npmjs.org'
78+
||PUBLIC_REGISTRY
7379

7480
returnregistry.replace(TRAILING_SLASH_RE,'')
7581
}
7682

77-
asyncfunctiongetAuthToken(registry: RegistryMeta['registry'],cwd: string): Promise<RegistryMeta['authToken']>{
78-
constpaths=getNpmrcPaths(cwd)
79-
constregistryHost=registry.replace(PROTOCOL_RE,'')
80-
constauthTokenKey=`//${registryHost}/:_authToken`
83+
/**
84+
* `npm` credentials apply to a registry URL prefix, not just a host, so a registry
85+
* served from a path (`https://host/npm/`) is configured as
86+
* `//host/npm/:_authToken`. Each prefix is tried from the most specific down to
87+
* the bare host, as `npm` does.
88+
*/
89+
functionauthKeyPrefixes(registry: string): string[]{
90+
leturl: URL
91+
try{
92+
url=newURL(registry)
93+
}
94+
catch{
95+
return[]
96+
}
97+
constsegments=url.pathname.split('/').filter(Boolean)
98+
constprefixes: string[]=[]
99+
for(letdepth=segments.length;depth>=0;depth--){
100+
prefixes.push(`//${url.host}${segments.slice(0,depth).map(segment=>`/${segment}`).join('')}/`)
101+
}
102+
returnprefixes
103+
}
104+
105+
/** `npm` expands `${VAR}` in `.npmrc` values from the environment. */
106+
functionexpand(value: string): string{
107+
returnvalue.trim().replace(ENV_REFERENCE_RE,(match,name: string)=>process.env[name]??match)
108+
}
81109

82-
for(constnpmrcPathofpaths){
110+
functionreadCredentials(config: Record<string,string|undefined>,registry: string): Pick<RegistryMeta,'authToken'|'authorization'>|undefined{
111+
for(constprefixofauthKeyPrefixes(registry)){
112+
consttoken=config[`${prefix}:_authToken`]
113+
if(token){
114+
constauthToken=expand(token)
115+
return{ authToken,authorization: `Bearer ${authToken}`}
116+
}
117+
constauth=config[`${prefix}:_auth`]
118+
if(auth){
119+
return{authToken: null,authorization: `Basic ${expand(auth)}`}
120+
}
121+
constusername=config[`${prefix}:username`]
122+
constpassword=config[`${prefix}:_password`]
123+
if(username&&password){
124+
// `_password` is stored base64-encoded, while the header wants the pair encoded together.
125+
constdecoded=Buffer.from(expand(password),'base64').toString('utf8')
126+
return{authToken: null,authorization: `Basic ${Buffer.from(`${expand(username)}:${decoded}`).toString('base64')}`}
127+
}
128+
}
129+
}
130+
131+
asyncfunctiongetCredentials(registry: RegistryMeta['registry'],cwd: string): Promise<Pick<RegistryMeta,'authToken'|'authorization'>>{
132+
for(constnpmrcPathofgetNpmrcPaths(cwd)){
83133
letfd: FileHandle|undefined
84134
try{
85135
fd=awaitfs.promises.open(npmrcPath,'r')
86136
if(awaitfd.stat().then(r=>r.isFile())){
87-
constnpmrcContent=awaitfd.readFile('utf-8')
88-
constnpmConfig=parseINI<Record<string,string|undefined>>(npmrcContent)
89-
constauthToken=npmConfig[authTokenKey]
90-
91-
if(authToken){
92-
returnauthToken.trim()
137+
constconfig=parseINI<Record<string,string|undefined>>(awaitfd.readFile('utf-8'))
138+
constcredentials=readCredentials(config,registry)
139+
if(credentials){
140+
returncredentials
93141
}
94142
}
95143
}
@@ -101,15 +149,14 @@ async function getAuthToken(registry: RegistryMeta['registry'], cwd: string): Pr
101149
}
102150
}
103151

104-
returnnull
152+
return{authToken: null,authorization: null}
105153
}
106154

107155
exportasyncfunctiondetectNpmRegistry(scope: string|null,cwd=process.cwd()): Promise<RegistryMeta>{
108156
constregistry=awaitgetRegistry(scope,cwd)
109-
constauthToken=awaitgetAuthToken(registry,cwd)
110157

111158
return{
112159
registry,
113-
authToken,
160+
...awaitgetCredentials(registry,cwd),
114161
}
115162
}

‎packages/nuxt-cli/src/utils/update-check.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,9 +83,9 @@ async function resolveLatestVersion(name: string): Promise<string | undefined> {
8383

8484
letlatest: string|undefined
8585
try{
86-
const{ registry,authToken}=awaitdetectNpmRegistry(null)
86+
const{ registry,authorization}=awaitdetectNpmRegistry(null)
8787
latest=(awaitfetchJson<{latest?: string}>(`${registry}/-/package/${name}/dist-tags`,{
88-
headers: authToken ? {Authorization: `Bearer ${authToken}`} : undefined,
88+
headers: authorization ? {Authorization: authorization} : undefined,
8989
timeout: FETCH_TIMEOUT,
9090
retry: 0,
9191
})).latest

‎packages/nuxt-cli/src/utils/versions.ts‎

Lines changed: 28 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { resolveCatalogEntry } from './catalog'
55
import{fetchJson}from'./fetch'
66
import{debug}from'./logger'
77
import{readDependencyPackageJson}from'./package-json'
8-
import{detectNpmRegistry}from'./registry'
8+
import{detectNpmRegistry,PUBLIC_REGISTRY}from'./registry'
99

1010
/** How long to wait on the registry before giving up on a version lookup. */
1111
constFETCH_TIMEOUT=10_000
@@ -38,29 +38,44 @@ export async function getNuxtVersion(cwd: string) {
3838
* range matches nothing or the registry cannot be reached.
3939
*/
4040
exportasyncfunctionresolveRegistryVersion(pkg: string,range: string): Promise<string|undefined>{
41-
letpackument: {'dist-tags'?: Record<string,string>,'versions'?: Record<string,unknown>}
42-
try{
43-
constscope=pkg.startsWith('@') ? pkg.split('/')[0]! : null
44-
const{ registry, authToken }=awaitdetectNpmRegistry(scope)
41+
constscope=pkg.startsWith('@') ? pkg.split('/')[0]! : null
42+
const{ registry, authorization }=awaitdetectNpmRegistry(scope)
43+
44+
constpackument=awaitfetchPackument(pkg,registry,authorization)
45+
// A registry that rejects us (a proxy needing credentials this process does
46+
// not have) still leaves public packages readable from npm itself.
47+
??(registry===PUBLIC_REGISTRY ? undefined : awaitfetchPackument(pkg,PUBLIC_REGISTRY,null))
48+
if(!packument){
49+
debug(`Failed to resolve a version of ${pkg} matching ${range}.`)
50+
returnundefined
51+
}
52+
53+
returnpackument['dist-tags']?.[range]
54+
// the registry lists versions in publication order, so a backported patch can
55+
// appear after a newer major and must not win
56+
??findMaxSatisfying(Object.keys(packument.versions??{}),range)??undefined
57+
}
58+
59+
interfacePackument{
60+
'dist-tags'?: Record<string,string>
61+
'versions'?: Record<string,unknown>
62+
}
4563

46-
packument=awaitfetchJson(`${registry}/${pkg}`,{
64+
asyncfunctionfetchPackument(pkg: string,registry: string,authorization: string|null): Promise<Packument|undefined>{
65+
try{
66+
returnawaitfetchJson<Packument>(`${registry}/${pkg}`,{
4767
headers: {
4868
// The abbreviated packument is a fraction of the size of the full one and
4969
// still carries every version and dist-tag.
5070
Accept: 'application/vnd.npm.install-v1+json',
51-
...authToken ? {Authorization: `Bearer ${authToken}`} : {},
71+
...authorization ? {Authorization: authorization} : {},
5272
},
5373
timeout: FETCH_TIMEOUT,
5474
retry: 0,
5575
})
5676
}
5777
catch(error){
58-
debug(`Failed to resolve a version of ${pkg}matching${range}:`,error)
78+
debug(`Failed to read ${pkg}from${registry}:`,error)
5979
returnundefined
6080
}
61-
62-
returnpackument['dist-tags']?.[range]
63-
// the registry lists versions in publication order, so a backported patch can
64-
// appear after a newer major and must not win
65-
??findMaxSatisfying(Object.keys(packument.versions??{}),range)??undefined
6681
}

‎packages/nuxt-cli/test/unit/utils/registry.spec.ts‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import{Buffer}from'node:buffer'
12
import{mkdtemp,rm,writeFile}from'node:fs/promises'
23
import{tmpdir}from'node:os'
34
import{join}from'node:path'
@@ -79,6 +80,7 @@ describe('detectNpmRegistry', () => {
7980
awaitexpect(detectNpmRegistry(null,directory)).resolves.toEqual({
8081
registry: 'https://registry.example.com',
8182
authToken: 'secret',
83+
authorization: 'Bearer secret',
8284
})
8385
expect(process.env.COREPACK_NPM_REGISTRY).toBeUndefined()
8486
})
@@ -124,6 +126,45 @@ describe('auth token scoping', () => {
124126
awaitexpect(detectNpmRegistry('@scope',directory)).resolves.toEqual({
125127
registry: 'https://scoped.example.com',
126128
authToken: 'scoped-token',
129+
authorization: 'Bearer scoped-token',
130+
})
131+
})
132+
133+
it('should resolve a token registered against the registry path, as npm does',async()=>{
134+
constdirectory=awaitnpmrc(
135+
'registry=https://proxy.example.com/npm/',
136+
'//proxy.example.com/npm/:_authToken=path-token',
137+
)
138+
139+
awaitexpect(detectNpmRegistry(null,directory)).resolves.toMatchObject({authToken: 'path-token'})
140+
})
141+
142+
it('should expand an environment reference in a token',async()=>{
143+
constdirectory=awaitnpmrc(
144+
'registry=https://registry.example.com/',
145+
// eslint-disable-next-line no-template-curly-in-string
146+
'//registry.example.com/:_authToken=${NUXT_TEST_NPM_TOKEN}',
147+
)
148+
process.env.NUXT_TEST_NPM_TOKEN='from-env'
149+
150+
try{
151+
awaitexpect(detectNpmRegistry(null,directory)).resolves.toMatchObject({authToken: 'from-env'})
152+
}
153+
finally{
154+
deleteprocess.env.NUXT_TEST_NPM_TOKEN
155+
}
156+
})
157+
158+
it('should authorise with basic credentials when that is all there is',async()=>{
159+
constdirectory=awaitnpmrc(
160+
'registry=https://registry.example.com/',
161+
'//registry.example.com/:username=alice',
162+
`//registry.example.com/:_password=${Buffer.from('hunter2').toString('base64')}`,
163+
)
164+
165+
awaitexpect(detectNpmRegistry(null,directory)).resolves.toMatchObject({
166+
authToken: null,
167+
authorization: `Basic ${Buffer.from('alice:hunter2').toString('base64')}`,
127168
})
128169
})
129170

‎packages/nuxt-cli/test/unit/utils/update.spec.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ const stdEnv = vi.hoisted(() => ({ isCI: false, isTest: false, provider: 'unknow
77
constrcStore=vi.hoisted(()=>({current: {}asRecord<string,unknown>}))
88
constproject=vi.hoisted(()=>({nuxtVersion: undefinedasstring|undefined}))
99
constfetchMock=vi.hoisted(()=>vi.fn())
10-
constregistry=vi.hoisted(()=>({current: {registry: 'https://registry.npmjs.org',authToken: nullasstring|null}}))
10+
constregistry=vi.hoisted(()=>({current: {registry: 'https://registry.npmjs.org',authToken: nullasstring|null,authorization: nullasstring|null}}))
1111

1212
vi.mock('std-env',async(importOriginal)=>{
1313
constoriginal=awaitimportOriginal<typeofimport('std-env')>()
@@ -68,7 +68,7 @@ describe('update check', () => {
6868
stdEnv.provider='unknown'
6969
rcStore.current={}
7070
project.nuxtVersion='4.0.0'
71-
registry.current={registry: 'https://registry.npmjs.org',authToken: null}
71+
registry.current={registry: 'https://registry.npmjs.org',authToken: null,authorization: null}
7272
fetchMock.mockReset()
7373
process.stdout.isTTY=true
7474
deleteprocess.env.NUXT_IGNORE_UPDATE_CHECK
@@ -156,7 +156,7 @@ describe('update check', () => {
156156
})
157157

158158
it('queries the configured registry with its auth token',async()=>{
159-
registry.current={registry: 'https://npm.example.com',authToken: 'secret'}
159+
registry.current={registry: 'https://npm.example.com',authToken: 'secret',authorization: 'Bearer secret'}
160160
fetchMock.mockResolvedValue({latest: '4.1.0'})
161161
awaitcheckForNuxtUpdate('/project')
162162
expect(fetchMock).toHaveBeenCalledWith(

‎packages/nuxt-cli/test/unit/utils/versions.spec.ts‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@ import { detectNpmRegistry } from '../../../src/utils/registry'
99
import{getNuxtVersion,resolveRegistryVersion}from'../../../src/utils/versions'
1010

1111
vi.mock('../../../src/utils/fetch',()=>({fetchJson: vi.fn()}))
12-
vi.mock('../../../src/utils/registry',()=>({detectNpmRegistry: vi.fn()}))
12+
vi.mock('../../../src/utils/registry',asyncimportOriginal=>({
13+
...awaitimportOriginal<typeofimport('../../../src/utils/registry')>(),
14+
detectNpmRegistry: vi.fn(),
15+
}))
1316

1417
describe('getNuxtVersion',()=>{
1518
lettempDir: string
@@ -52,7 +55,7 @@ describe('getNuxtVersion', () => {
5255

5356
describe('resolveRegistryVersion',()=>{
5457
beforeEach(()=>{
55-
vi.mocked(detectNpmRegistry).mockResolvedValue({registry: 'https://registry.example.com/',authToken: null})
58+
vi.mocked(detectNpmRegistry).mockResolvedValue({registry: 'https://registry.example.com/',authToken: null,authorization: null})
5659
})
5760

5861
it('should prefer a matching dist-tag',async()=>{
@@ -78,4 +81,14 @@ describe('resolveRegistryVersion', () => {
7881

7982
expect(awaitresolveRegistryVersion('nuxt','latest')).toBeUndefined()
8083
})
84+
85+
it('should read a public package from npm when the configured registry rejects us',async()=>{
86+
vi.mocked(fetchJson).mockClear()
87+
vi.mocked(fetchJson)
88+
.mockRejectedValueOnce(Object.assign(newError('Unauthorized'),{status: 401}))
89+
.mockResolvedValueOnce({'dist-tags': {latest: '4.2.0'}})
90+
91+
expect(awaitresolveRegistryVersion('nuxt','latest')).toBe('4.2.0')
92+
expect(vi.mocked(fetchJson).mock.calls[1]![0]).toBe('https://registry.npmjs.org/nuxt')
93+
})
8194
})

0 commit comments

Comments
 (0)