diff --git a/crates/vp_pm_cli/src/config.rs b/crates/vp_pm_cli/src/config.rs index 2ccd406c16..12365402c7 100644 --- a/crates/vp_pm_cli/src/config.rs +++ b/crates/vp_pm_cli/src/config.rs @@ -94,10 +94,14 @@ impl NpmConfig { } pub(crate) fn registry_for_package(&self, package: &str) -> String { - let scoped = package - .strip_prefix('@') - .and_then(|rest| rest.split_once('/')) - .and_then(|(scope, _)| self.values.get(vt_str::format!("@{scope}:registry").as_str())) + let scope = + package.strip_prefix('@').and_then(|rest| rest.split_once('/')).map(|(scope, _)| scope); + self.registry_for_scope(scope) + } + + fn registry_for_scope(&self, scope: Option<&str>) -> String { + let scoped = scope + .and_then(|scope| self.values.get(vt_str::format!("@{scope}:registry").as_str())) .filter(|value| !value.is_empty()); scoped .or_else(|| self.values.get("registry").filter(|value| !value.is_empty())) @@ -135,6 +139,19 @@ impl NpmConfig { } } + // The JS CLI builds its own requests, so it needs the header value instead of a request. + fn authorization_header(&self, url: &str) -> Option { + Some(match self.auth_for_url(url)? { + NpmAuth::Token(token) => vt_str::format!("Bearer {token}").to_string(), + NpmAuth::Encoded(auth) => vt_str::format!("Basic {auth}").to_string(), + NpmAuth::UsernamePassword { username, password } => { + let credentials = [username.as_bytes(), b":", &password].concat(); + vt_str::format!("Basic {}", base64_simd::STANDARD.encode_to_string(credentials)) + .to_string() + } + }) + } + /// A same-origin redirect may reach a more specific authenticated path. /// Such responses must not use the registry-wide latest-version cache. pub(crate) fn has_auth_for_origin(&self, url: &str) -> bool { @@ -351,6 +368,18 @@ fn parse_npmrc_value(value: &str) -> String { parsed.trim_end().to_string() } +/// Resolve the registry for packages in `scope` (without `@`) from the npm config at `cwd`. +#[must_use] +pub fn npm_registry_for_scope(cwd: &AbsolutePath, scope: Option<&str>) -> String { + NpmConfig::load_for_cwd(cwd).registry_for_scope(scope) +} + +/// Resolve the `Authorization` header value npm would send to `url` from the npm config at `cwd`. +#[must_use] +pub fn npm_authorization_for_url(cwd: &AbsolutePath, url: &str) -> Option { + NpmConfig::load_for_cwd(cwd).authorization_header(url) +} + /// Get the configured NPM registry URL. #[must_use] pub fn npm_registry() -> String { @@ -929,6 +958,30 @@ mod tests { ); } + // The JS CLI sends this header itself, so it must match what Rust requests send. + #[test] + fn authorization_header_matches_request_auth() { + for credentials in [ + "//registry.example/:_authToken=TOKEN\n", + "//registry.example/:_auth=dXNlcjpzZWM=\n", + "//registry.example/:username=user\n//registry.example/:_password=c2Vj\n", + ] { + let project = project_with_npmrc(credentials); + let cwd = AbsolutePath::new(project.path()).unwrap(); + with_isolated_npm_config(|| { + let url = "https://registry.example/pkg"; + let request = NpmConfig::load_for_cwd(cwd) + .apply_auth(http_client().get(url), url) + .build() + .unwrap(); + assert_eq!( + npm_authorization_for_url(cwd, url).as_deref(), + Some(request.headers()[reqwest::header::AUTHORIZATION].to_str().unwrap()) + ); + }); + } + } + #[test] fn test_npm_registry_default() { EnvConfig::with_vars([(env_vars::VP_HOME, std::env::temp_dir())], |_| { diff --git a/crates/vp_pm_cli/src/lib.rs b/crates/vp_pm_cli/src/lib.rs index dc99cf0827..12ad5fa286 100644 --- a/crates/vp_pm_cli/src/lib.rs +++ b/crates/vp_pm_cli/src/lib.rs @@ -18,7 +18,7 @@ pub(crate) mod resolution; mod shim; pub use cli::{ManagedGlobalCommand, PackageManagerCommand, PmCommand}; -pub use config::npm_registry; +pub use config::{npm_authorization_for_url, npm_registry, npm_registry_for_scope}; pub use dispatch::{ DispatchResult, dispatch, dispatch_with_metadata, dispatch_with_package_manager, dispatch_with_resolved_package_manager, diff --git a/packages/cli/binding/index.cjs b/packages/cli/binding/index.cjs index 64262d2a95..518d09686d 100644 --- a/packages/cli/binding/index.cjs +++ b/packages/cli/binding/index.cjs @@ -1059,6 +1059,8 @@ module.exports.parseHooksArgs = nativeBinding.parseHooksArgs; module.exports.parseMigrateArgs = nativeBinding.parseMigrateArgs; module.exports.parseStagedArgs = nativeBinding.parseStagedArgs; module.exports.removeConfigKey = nativeBinding.removeConfigKey; +module.exports.resolveNpmAuthorization = nativeBinding.resolveNpmAuthorization; +module.exports.resolveNpmRegistry = nativeBinding.resolveNpmRegistry; module.exports.rewriteEslint = nativeBinding.rewriteEslint; module.exports.rewriteImportsInDirectory = nativeBinding.rewriteImportsInDirectory; module.exports.rewritePrettier = nativeBinding.rewritePrettier; diff --git a/packages/cli/binding/index.d.cts b/packages/cli/binding/index.d.cts index a609a294a1..bd09dc1ae7 100644 --- a/packages/cli/binding/index.d.cts +++ b/packages/cli/binding/index.d.cts @@ -3879,6 +3879,12 @@ export declare function removeConfigKey( configKey: string, ): MergeJsonConfigResult; +/** Resolve the `Authorization` header value npm would send to `url` from the npm config at `cwd`. */ +export declare function resolveNpmAuthorization(cwd: string, url: string): string | null; + +/** Resolve the npm registry for packages in `scope` (without `@`) from the npm config at `cwd`. */ +export declare function resolveNpmRegistry(cwd: string, scope?: string | undefined | null): string; + /** * Rewrite ESLint scripts: rename `eslint` → `vp lint` and strip ESLint-only flags. * diff --git a/packages/cli/binding/src/package_manager.rs b/packages/cli/binding/src/package_manager.rs index 76e7da788b..1ed2e201a5 100644 --- a/packages/cli/binding/src/package_manager.rs +++ b/packages/cli/binding/src/package_manager.rs @@ -196,6 +196,20 @@ pub fn find_monorepo_root(cwd: String) -> Result> { } } +/// Resolve the npm registry for packages in `scope` (without `@`) from the npm config at `cwd`. +#[napi] +pub fn resolve_npm_registry(cwd: String, scope: Option) -> Result { + let cwd = AbsolutePathBuf::new(cwd.into()).ok_or(Error::from_reason("invalid cwd"))?; + Ok(vp_pm_cli::npm_registry_for_scope(&cwd, scope.as_deref())) +} + +/// Resolve the `Authorization` header value npm would send to `url` from the npm config at `cwd`. +#[napi] +pub fn resolve_npm_authorization(cwd: String, url: String) -> Result> { + let cwd = AbsolutePathBuf::new(cwd.into()).ok_or(Error::from_reason("invalid cwd"))?; + Ok(vp_pm_cli::npm_authorization_for_url(&cwd, &url)) +} + #[cfg(test)] mod tests { use super::*; diff --git a/packages/cli/src/utils/__tests__/npm-config.spec.ts b/packages/cli/src/utils/__tests__/npm-config.spec.ts index 18e96b69eb..a0f40ed21f 100644 --- a/packages/cli/src/utils/__tests__/npm-config.spec.ts +++ b/packages/cli/src/utils/__tests__/npm-config.spec.ts @@ -21,14 +21,14 @@ describe('getNpmRegistry / getNpmAuthHeader', () => { delete process.env[key]; } } - originalEnv.HOME = process.env.HOME; - process.env.HOME = homeDir; - vi.spyOn(os, 'homedir').mockReturnValue(homeDir); + // The native config loader caches the home directory, so select the user file explicitly. + vi.stubEnv('npm_config_userconfig', path.join(homeDir, '.npmrc')); vi.spyOn(process, 'cwd').mockReturnValue(projectDir); }); afterEach(() => { vi.restoreAllMocks(); + vi.unstubAllEnvs(); fs.rmSync(homeDir, { recursive: true, force: true }); fs.rmSync(projectDir, { recursive: true, force: true }); for (const [k, v] of Object.entries(originalEnv)) { @@ -60,6 +60,15 @@ describe('getNpmRegistry / getNpmAuthHeader', () => { expect(getNpmRegistry()).toBe('https://user.example.com'); }); + it('ignores `.npmrc` files above the project root', () => { + fs.writeFileSync(path.join(projectDir, '.npmrc'), 'registry=https://parent.example.com/\n'); + const packageDir = path.join(projectDir, 'app'); + fs.mkdirSync(packageDir); + fs.writeFileSync(path.join(packageDir, 'package.json'), '{}'); + vi.spyOn(process, 'cwd').mockReturnValue(packageDir); + expect(getNpmRegistry()).toBe('https://registry.npmjs.org'); + }); + it('resolves `@scope:registry=` overrides ahead of the default', () => { fs.writeFileSync( path.join(projectDir, '.npmrc'), diff --git a/packages/cli/src/utils/npm-config.ts b/packages/cli/src/utils/npm-config.ts index 9c0e15e99b..f56dce1c8c 100644 --- a/packages/cli/src/utils/npm-config.ts +++ b/packages/cli/src/utils/npm-config.ts @@ -1,102 +1,4 @@ -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; - -type NpmConfig = Map; - -function expandNpmrcValue(raw: string): string { - // Strip surrounding quotes and expand `${VAR}` references. Covers only - // the value shapes used by the keys we actually read (registry / - // @scope:registry / :_authToken / :_auth / :username / :_password). - // Intentionally NOT handled: `\$` backslash escapes, `${VAR-default}` - // fallbacks, inline `;` comments after a value, and `key[]=` list - // syntax. Extend when a caller needs any of those. - let value = raw.trim(); - if ( - (value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'")) - ) { - value = value.slice(1, -1); - } - return value.replaceAll(/\$\{([A-Z0-9_]+)\}/gi, (_, name) => process.env[name] ?? ''); -} - -function parseNpmrc(contents: string, into: NpmConfig): void { - for (const rawLine of contents.split(/\r?\n/)) { - const line = rawLine.trim(); - if (!line || line.startsWith('#') || line.startsWith(';')) { - continue; - } - const eq = line.indexOf('='); - if (eq === -1) { - continue; - } - const key = line.slice(0, eq).trim(); - const value = expandNpmrcValue(line.slice(eq + 1)); - if (key) { - into.set(key, value); - } - } -} - -function loadFileInto(filePath: string, config: NpmConfig): void { - try { - parseNpmrc(fs.readFileSync(filePath, 'utf8'), config); - } catch { - // Missing / unreadable .npmrc is fine — nothing to layer in. - } -} - -/** - * Rebuilt on every call so tests that mutate `process.env` mid-run see - * fresh config. Each `vp create` hits this ≤4 times (registry + auth on - * packument + auth on tarball), which is cheap enough vs. the network - * work that a cache isn't worth the test-determinism cost. - */ -function getNpmConfig(): NpmConfig { - const config: NpmConfig = new Map(); - // Layer in order of increasing precedence: user → project → env. - const homeNpmrc = path.resolve(os.homedir(), '.npmrc'); - loadFileInto(homeNpmrc, config); - // Collect project `.npmrc` paths from cwd up to the filesystem root, - // then apply them in reverse (root-side first, cwd last) so the - // innermost file wins. Skip the `$HOME/.npmrc` we already loaded so - // it doesn't re-overwrite project-level settings when cwd is under - // `$HOME`. - const projectRcs: string[] = []; - let dir = path.resolve(process.cwd()); - const seen = new Set(); - while (dir && !seen.has(dir)) { - seen.add(dir); - const candidate = path.resolve(dir, '.npmrc'); - if (candidate !== homeNpmrc && fs.existsSync(candidate)) { - projectRcs.push(candidate); - } - const parent = path.dirname(dir); - if (parent === dir) { - break; - } - dir = parent; - } - for (let i = projectRcs.length - 1; i >= 0; i -= 1) { - loadFileInto(projectRcs[i], config); - } - for (const [envKey, envValue] of Object.entries(process.env)) { - if (envValue === undefined) { - continue; - } - if (envKey.startsWith('npm_config_')) { - config.set(envKey.slice('npm_config_'.length), envValue); - } else if (envKey.startsWith('NPM_CONFIG_')) { - config.set(envKey.slice('NPM_CONFIG_'.length).toLowerCase(), envValue); - } - } - return config; -} - -function normalizeRegistryUrl(url: string): string { - return url.replace(/\/+$/, ''); -} +import { resolveNpmAuthorization, resolveNpmRegistry } from '../../binding/index.js'; /** * Resolve the npm registry base URL for the given scope (or the default @@ -105,57 +7,16 @@ function normalizeRegistryUrl(url: string): string { * vars so private / mirrored registries work for org manifest fetches. */ export function getNpmRegistry(scope?: string): string { - const config = getNpmConfig(); - if (scope) { - const normalizedScope = scope.startsWith('@') ? scope : `@${scope}`; - const scoped = config.get(`${normalizedScope}:registry`); - if (scoped) { - return normalizeRegistryUrl(scoped); - } - } - const registry = config.get('registry') || 'https://registry.npmjs.org'; - return normalizeRegistryUrl(registry); + return resolveNpmRegistry(process.cwd(), scope?.replace(/^@/, '')); } /** - * Build the `Authorization` header value for a registry URL by matching - * the URL against `//host[/path]/:_authToken=...` / `:_auth=...` entries - * in `.npmrc`. Returns `undefined` when no credential is configured. + * Build the `Authorization` header value for a registry URL from the + * matching `//host[/path]/:_authToken=...` / `:_auth=...` entries in + * `.npmrc`. Returns `undefined` when no credential is configured. */ export function getNpmAuthHeader(registryOrUrl: string): string | undefined { - let parsed: URL; - try { - parsed = new URL(registryOrUrl); - } catch { - return undefined; - } - const config = getNpmConfig(); - // npm keys a credential by the protocol-less URL with a trailing slash, - // e.g. `//registry.example.com/foo/:_authToken`. Walk up the path so - // `/foo/bar` also matches a credential set for `/foo` or the host root. - const segments = parsed.pathname.split('/').filter(Boolean); - const candidates: string[] = []; - for (let i = segments.length; i >= 0; i -= 1) { - const subPath = i === 0 ? '/' : `/${segments.slice(0, i).join('/')}/`; - candidates.push(`//${parsed.host}${subPath}`); - } - for (const prefix of candidates) { - const token = config.get(`${prefix}:_authToken`); - if (token) { - return `Bearer ${token}`; - } - const basic = config.get(`${prefix}:_auth`); - if (basic) { - return `Basic ${basic}`; - } - const username = config.get(`${prefix}:username`); - const passwordB64 = config.get(`${prefix}:_password`); - if (username && passwordB64) { - const password = Buffer.from(passwordB64, 'base64').toString('utf8'); - return `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`; - } - } - return undefined; + return resolveNpmAuthorization(process.cwd(), registryOrUrl) ?? undefined; } /**