Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 57 additions & 4 deletions crates/vp_pm_cli/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()))
Expand Down Expand Up @@ -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<String> {
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 {
Expand Down Expand Up @@ -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<String> {
NpmConfig::load_for_cwd(cwd).authorization_header(url)
}

/// Get the configured NPM registry URL.
#[must_use]
pub fn npm_registry() -> String {
Expand Down Expand Up @@ -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())], |_| {
Expand Down
2 changes: 1 addition & 1 deletion crates/vp_pm_cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/binding/index.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/binding/index.d.cts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
14 changes: 14 additions & 0 deletions packages/cli/binding/src/package_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,20 @@ pub fn find_monorepo_root(cwd: String) -> Result<Option<String>> {
}
}

/// 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<String>) -> Result<String> {
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<Option<String>> {
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::*;
Expand Down
15 changes: 12 additions & 3 deletions packages/cli/src/utils/__tests__/npm-config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down Expand Up @@ -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'),
Expand Down
151 changes: 6 additions & 145 deletions packages/cli/src/utils/npm-config.ts
Original file line number Diff line number Diff line change
@@ -1,102 +1,4 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';

type NpmConfig = Map<string, string>;

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<string>();
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
Expand All @@ -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;
}

/**
Expand Down
Loading