From 9652f09c40273e566568fea84e94f69db00e4e52 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 14 Aug 2026 00:33:10 +0000 Subject: [PATCH 1/3] Initial plan From 025e1d442f58f75033fff0321ca9dcf5783f149c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 14 Aug 2026 00:49:07 +0000 Subject: [PATCH 2/3] fix(compiler): tsp install respects .npmrc registry and credentials Co-authored-by: catalinaperalta <9859037+catalinaperalta@users.noreply.github.com> --- .../fix-tsp-install-npmrc-2026-7-14-0-45-0.md | 7 + packages/compiler/src/install/install.ts | 25 +- .../package-manger/npm-package-download.ts | 22 +- .../src/package-manger/npm-registry.ts | 31 ++- packages/compiler/src/package-manger/npmrc.ts | 235 ++++++++++++++++ packages/compiler/test/cli/init.test.ts | 2 + .../compiler/test/init/init-template.test.ts | 1 + .../test/package-manager/npm-registry.test.ts | 42 ++- .../test/package-manager/npmrc.test.ts | 255 ++++++++++++++++++ 9 files changed, 606 insertions(+), 14 deletions(-) create mode 100644 .chronus/changes/fix-tsp-install-npmrc-2026-7-14-0-45-0.md create mode 100644 packages/compiler/src/package-manger/npmrc.ts create mode 100644 packages/compiler/test/package-manager/npmrc.test.ts diff --git a/.chronus/changes/fix-tsp-install-npmrc-2026-7-14-0-45-0.md b/.chronus/changes/fix-tsp-install-npmrc-2026-7-14-0-45-0.md new file mode 100644 index 00000000000..1fa188c842e --- /dev/null +++ b/.chronus/changes/fix-tsp-install-npmrc-2026-7-14-0-45-0.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "@typespec/compiler" +--- + +`tsp install` now respects the `.npmrc` configuration(project, user and global config as well as `npm_config_*` environment variables) when resolving and downloading the package manager. Custom registries(including scoped registries) and their credentials(`_authToken`, `_auth` and `username`/`_password`) are now used. diff --git a/packages/compiler/src/install/install.ts b/packages/compiler/src/install/install.ts index 9460879ee66..02b7c0c11b8 100644 --- a/packages/compiler/src/install/install.ts +++ b/packages/compiler/src/install/install.ts @@ -8,6 +8,7 @@ import { getDirectoryPath, joinPaths } from "../core/path-utils.js"; import { NoTarget, type Diagnostic, type Tracer } from "../core/types.js"; import { downloadAndExtractPackage } from "../package-manger/npm-package-download.js"; import { fetchPackageManifest, type NpmManifest } from "../package-manger/npm-registry.js"; +import { resolveNpmConfig, type NpmConfig } from "../package-manger/npmrc.js"; import { mkTempDir } from "../utils/fs-utils.js"; import type { SupportedPackageManager } from "./config.js"; import { getPackageManagerConfig, type PackageManagerConfig } from "./config.js"; @@ -91,6 +92,7 @@ async function installPackageManager( spec: Descriptor, installDir: string, manifest: NpmManifest, + npmConfig: NpmConfig, ) { await rm(installDir, { recursive: true, force: true }); const tempDir = await mkTempDir(host, pmDir, `tsp-pm-${packageManager}-${manifest.version}`); @@ -99,7 +101,12 @@ async function installPackageManager( "downloading-extracting", `Downloading and extracting ${packageManager} at version ${manifest.version} in ${tempDir}`, ); - const extractResult = await downloadAndExtractPackage(manifest, tempDir, spec.hash?.algorithm); + const extractResult = await downloadAndExtractPackage( + manifest, + tempDir, + spec.hash?.algorithm, + npmConfig.getAuthHeaders(manifest.dist.tarball), + ); if (spec.hash) { if (spec.hash.value !== extractResult.hash.value) { throw new InstallDependenciesError( @@ -165,7 +172,20 @@ export async function installTypeSpecDependencies( ); const packageManager = spec.name; const packageManagerConfig = getPackageManagerConfig(packageManager); - const manifest = await fetchPackageManifest(packageManager, spec.range); + const npmConfig = await resolveNpmConfig(host, directory); + const registry = npmConfig.getRegistry(packageManager); + tracer.trace("registry", `Using registry ${registry} to resolve ${packageManager}`); + let manifest: NpmManifest; + try { + manifest = await fetchPackageManifest(packageManager, spec.range, { + registry, + headers: npmConfig.getAuthHeaders(`${registry}/${packageManager}`), + }); + } catch (e) { + throw new InstallDependenciesError( + `Failed to resolve package manager ${packageManager}@${spec.range} from registry ${registry}: ${e instanceof Error ? e.message : String(e)}`, + ); + } tracer.trace( "fetched-manifest", `Resolved manifest for ${packageManager} at version ${manifest.version}`, @@ -182,6 +202,7 @@ export async function installTypeSpecDependencies( spec, installDir, manifest, + npmConfig, ); if (savePackageManager) { await updatePackageManagerInPackageJson(host, packageJsonPath, { diff --git a/packages/compiler/src/package-manger/npm-package-download.ts b/packages/compiler/src/package-manger/npm-package-download.ts index 3af471d6055..b9d8c914574 100644 --- a/packages/compiler/src/package-manger/npm-package-download.ts +++ b/packages/compiler/src/package-manger/npm-package-download.ts @@ -4,23 +4,29 @@ import { createHash } from "crypto"; import { Readable } from "stream"; import { extract as tarX } from "tar/extract"; import type { Hash } from "../install/spec.js"; -import { fetchPackageManifest, type NpmManifest } from "./npm-registry.js"; +import { + fetchPackageManifest, + type NpmManifest, + type NpmRegistryRequestOptions, +} from "./npm-registry.js"; export async function downloadPackageVersion( packageName: string, version: string, dest: string, + options?: NpmRegistryRequestOptions, ): Promise { - const manifest = await fetchPackageManifest(packageName, version); - return downloadAndExtractTarball(manifest.dist.tarball, dest); + const manifest = await fetchPackageManifest(packageName, version, options); + return downloadAndExtractTarball(manifest.dist.tarball, dest, undefined, options?.headers); } export async function downloadAndExtractPackage( manifest: NpmManifest, dest: string, hashAlgorithm: string = "sha512", + headers?: Record, ): Promise { - return downloadAndExtractTarball(manifest.dist.tarball, dest, hashAlgorithm); + return downloadAndExtractTarball(manifest.dist.tarball, dest, hashAlgorithm, headers); } export interface ExtractedTarballResult { @@ -31,8 +37,14 @@ async function downloadAndExtractTarball( url: string, dest: string, hashAlgorithm: string = "sha512", + headers?: Record, ): Promise { - const res = await fetch(url); + const res = await fetch(url, { headers }); + if (!res.ok || res.body === null) { + throw new Error( + `Failed to download package tarball from ${url}: ${res.status} ${res.statusText}`, + ); + } const tarballStream = Readable.fromWeb(res.body as any); const hash = tarballStream.pipe(createHash(hashAlgorithm)); const extractor = tarX({ diff --git a/packages/compiler/src/package-manger/npm-registry.ts b/packages/compiler/src/package-manger/npm-registry.ts index 4672eafe669..8e732248553 100644 --- a/packages/compiler/src/package-manger/npm-registry.ts +++ b/packages/compiler/src/package-manger/npm-registry.ts @@ -82,7 +82,8 @@ export interface NpmHuman { readonly url?: string | undefined; } -const defaultRegistry = `https://registry.npmjs.org`; +/** Default npm registry used when nothing else is configured. */ +export const defaultNpmRegistry = `https://registry.npmjs.org`; /** * Returns the npm registry URL to use for fetching packages. @@ -90,18 +91,36 @@ const defaultRegistry = `https://registry.npmjs.org`; * otherwise falls back to the default npm registry. */ export function getNpmRegistry(): string { - return (process.env["TYPESPEC_NPM_REGISTRY"] ?? defaultRegistry).replace(/\/$/, ""); + return (process.env["TYPESPEC_NPM_REGISTRY"] ?? defaultNpmRegistry).replace(/\/$/, ""); +} + +/** Options to customize the registry and credentials used when talking to the npm registry. */ +export interface NpmRegistryRequestOptions { + /** Registry to use. Default to {@link getNpmRegistry} */ + readonly registry?: string; + /** Extra headers to send with the request.(e.g. authorization header for private registries) */ + readonly headers?: Record; } export async function fetchPackageManifest( packageName: string, version: string, + options?: NpmRegistryRequestOptions, ): Promise { - const url = `${getNpmRegistry()}/${packageName}/${version}`; - const res = await fetch(url); + const registry = options?.registry ? options.registry.replace(/\/+$/, "") : getNpmRegistry(); + const url = `${registry}/${packageName}/${version}`; + const res = await fetch(url, { headers: options?.headers }); + if (!res.ok) { + throw new Error( + `Failed to fetch manifest for package "${packageName}@${version}" from ${registry}: ${res.status} ${res.statusText}`, + ); + } return await res.json(); } -export function fetchLatestPackageManifest(packageName: string): Promise { - return fetchPackageManifest(packageName, "latest"); +export function fetchLatestPackageManifest( + packageName: string, + options?: NpmRegistryRequestOptions, +): Promise { + return fetchPackageManifest(packageName, "latest", options); } diff --git a/packages/compiler/src/package-manger/npmrc.ts b/packages/compiler/src/package-manger/npmrc.ts new file mode 100644 index 00000000000..e14cfff4efb --- /dev/null +++ b/packages/compiler/src/package-manger/npmrc.ts @@ -0,0 +1,235 @@ +// Node.js specific helpers to resolve the npm configuration(`.npmrc` files and `npm_config_*` environment variables) +// This is used so `tsp install` respect the registry and authentication configured by the user. +import { homedir } from "os"; +import { getDirectoryPath, joinPaths, normalizePath } from "../core/path-utils.js"; +import type { CompilerHost } from "../core/types.js"; +import { defaultNpmRegistry } from "./npm-registry.js"; + +/** Resolved npm configuration */ +export interface NpmConfig { + /** All the resolved config entries. Keys are lowercased. */ + readonly values: ReadonlyMap; + + /** Resolve the registry to use for the given package name.(Taking into account scoped registries) */ + getRegistry(packageName: string): string; + + /** Resolve the authentication headers to use when making a request to the given url. Returns an empty object if there is no credentials configured for that url. */ + getAuthHeaders(url: string): Record; +} + +const nodeModulesRegExp = /\/node_modules\//; + +/** + * Load the npm configuration applicable in the given directory. + * Configs are resolved in the same order as npm(from the lowest to the highest priority): + * global config, user config(`~/.npmrc`), project config(closest `.npmrc`) and `npm_config_*` environment variables. + */ +export async function resolveNpmConfig( + host: CompilerHost, + cwd: string, + env: Record = process.env, +): Promise { + const values = new Map(); + + const files = [ + resolveGlobalConfigPath(env), + resolveUserConfigPath(env), + await findProjectConfigPath(host, cwd), + ]; + + for (const file of files) { + if (file === undefined) continue; + const content = await readFileIfExists(host, file); + if (content === undefined) continue; + for (const [key, value] of parseNpmrc(content, env)) { + values.set(key, value); + } + } + + for (const [key, value] of loadEnvConfig(env)) { + values.set(key, value); + } + + return createNpmConfig(values, env); +} + +/** Create a {@link NpmConfig} from the already resolved config entries. */ +export function createNpmConfig( + values: ReadonlyMap, + env: Record = process.env, +): NpmConfig { + return { + values, + getRegistry: (packageName) => getRegistry(values, packageName, env), + getAuthHeaders: (url) => getAuthHeaders(values, url), + }; +} + +function resolveUserConfigPath(env: Record): string | undefined { + const explicit = env["npm_config_userconfig"] ?? env["NPM_CONFIG_USERCONFIG"]; + if (explicit) return normalizePath(explicit); + const home = env["HOME"] ?? env["USERPROFILE"] ?? homedir(); + return home ? joinPaths(normalizePath(home), ".npmrc") : undefined; +} + +function resolveGlobalConfigPath(env: Record): string | undefined { + const explicit = env["npm_config_globalconfig"] ?? env["NPM_CONFIG_GLOBALCONFIG"]; + return explicit ? normalizePath(explicit) : undefined; +} + +/** Find the closest `.npmrc` file walking up from the given directory. */ +async function findProjectConfigPath(host: CompilerHost, cwd: string): Promise { + let current = ""; + let next = normalizePath(cwd); + while (next !== current) { + current = next; + next = getDirectoryPath(current); + if (nodeModulesRegExp.test(current)) continue; + + const path = joinPaths(current, ".npmrc"); + if (await isFile(host, path)) { + return path; + } + } + return undefined; +} + +async function isFile(host: CompilerHost, path: string): Promise { + try { + const stats = await host.stat(path); + return stats.isFile(); + } catch (e: any) { + if (e.code === "ENOENT" || e.code === "ENOTDIR") return false; + throw e; + } +} + +async function readFileIfExists(host: CompilerHost, path: string): Promise { + try { + const file = await host.readFile(path); + return file.text; + } catch (e: any) { + if (e.code === "ENOENT" || e.code === "ENOTDIR" || e.code === "EISDIR" || e.code === "EACCES") { + return undefined; + } + throw e; + } +} + +/** + * Parse the content of a `.npmrc` file. + * Keys are lowercased and `${ENV_VAR}` references in values are replaced with the value of the environment variable. + */ +export function parseNpmrc( + content: string, + env: Record = process.env, +): Map { + const result = new Map(); + for (const rawLine of content.split(/\r?\n/)) { + const line = rawLine.trim(); + // Comments and ini sections(not used by npm config) are ignored. + if (line === "" || line.startsWith("#") || line.startsWith(";") || line.startsWith("[")) { + continue; + } + const index = line.indexOf("="); + if (index === -1) continue; + const key = line.slice(0, index).trim(); + if (key === "") continue; + const value = unquote(line.slice(index + 1).trim()); + result.set(key.toLowerCase(), replaceEnvVariables(value, env)); + } + return result; +} + +function unquote(value: string): string { + if (value.length >= 2) { + const first = value[0]; + if ((first === `"` || first === `'`) && value[value.length - 1] === first) { + return value.slice(1, -1); + } + } + return value; +} + +/** Replace `${VAR}` with the value of the environment variable like npm does. */ +function replaceEnvVariables(value: string, env: Record): string { + return value.replace(/\$\{([^}]+)\}/g, (match, name) => env[name] ?? match); +} + +/** Load the config defined via `npm_config_*` environment variables. */ +function loadEnvConfig(env: Record): Map { + const result = new Map(); + for (const [key, value] of Object.entries(env)) { + if (value === undefined) continue; + const match = /^npm_config_(.+)$/i.exec(key); + if (match === null) continue; + result.set(match[1].toLowerCase(), value); + } + return result; +} + +function getRegistry( + values: ReadonlyMap, + packageName: string, + env: Record, +): string { + // TypeSpec specific override takes precedence over any npm configuration. + if (env["TYPESPEC_NPM_REGISTRY"]) { + return trimTrailingSlash(env["TYPESPEC_NPM_REGISTRY"]); + } + const scope = packageName.startsWith("@") ? packageName.split("/")[0] : undefined; + const registry = + (scope && values.get(`${scope.toLowerCase()}:registry`)) ?? values.get("registry"); + return trimTrailingSlash(registry ?? defaultNpmRegistry); +} + +function trimTrailingSlash(url: string): string { + return url.replace(/\/+$/, ""); +} + +/** + * Resolve the authentication headers configured for the given url. + * Credentials are configured per registry using the url without the protocol as a prefix.(e.g. `//registry.npmjs.org/:_authToken=abc`) + */ +function getAuthHeaders(values: ReadonlyMap, url: string): Record { + if (!URL.canParse(url)) return {}; + const parsed = new URL(url); + + for (const prefix of getConfigPrefixes(parsed)) { + const authToken = values.get(`${prefix}:_authtoken`); + if (authToken) { + return { authorization: `Bearer ${authToken}` }; + } + const auth = values.get(`${prefix}:_auth`); + if (auth) { + return { authorization: `Basic ${auth}` }; + } + const username = values.get(`${prefix}:username`); + const password = values.get(`${prefix}:_password`); + if (username && password) { + const decodedPassword = Buffer.from(password, "base64").toString("utf8"); + const encoded = Buffer.from(`${username}:${decodedPassword}`, "utf8").toString("base64"); + return { authorization: `Basic ${encoded}` }; + } + } + return {}; +} + +/** + * Compute the keys prefixes(`//host/path/`) that could hold the credentials for the given url from the most to the least specific. + */ +function getConfigPrefixes(url: URL): string[] { + const prefixes: string[] = []; + let path = url.pathname.endsWith("/") ? url.pathname : `${url.pathname}/`; + while (true) { + prefixes.push(`//${url.host}${path}`.toLowerCase()); + // Also support the prefix defined without the trailing slash. + if (path !== "/") { + prefixes.push(`//${url.host}${path.slice(0, -1)}`.toLowerCase()); + } else { + break; + } + path = path.slice(0, path.lastIndexOf("/", path.length - 2) + 1); + } + return prefixes; +} diff --git a/packages/compiler/test/cli/init.test.ts b/packages/compiler/test/cli/init.test.ts index 0d67b3d2462..f7b516447b6 100644 --- a/packages/compiler/test/cli/init.test.ts +++ b/packages/compiler/test/cli/init.test.ts @@ -11,6 +11,7 @@ import type { TestFileSystem } from "../../src/testing/types.js"; import { parseYaml as coreParseYaml } from "../../src/yaml/parser.js"; const fetchMock = vi.fn().mockResolvedValue({ + ok: true, json: () => Promise.resolve({ name: "mock-pkg", version: "1.0.0" }), }); @@ -21,6 +22,7 @@ beforeEach(() => { afterEach(() => { vi.unstubAllGlobals(); fetchMock.mockResolvedValue({ + ok: true, json: () => Promise.resolve({ name: "mock-pkg", version: "1.0.0" }), }); }); diff --git a/packages/compiler/test/init/init-template.test.ts b/packages/compiler/test/init/init-template.test.ts index fac8091d745..acfb151e417 100644 --- a/packages/compiler/test/init/init-template.test.ts +++ b/packages/compiler/test/init/init-template.test.ts @@ -8,6 +8,7 @@ import type { TestHost } from "../../src/testing/index.js"; import { createTestHost, resolveVirtualPath } from "../../src/testing/index.js"; const fetchMock = vi.fn().mockResolvedValue({ + ok: true, json: () => Promise.resolve({ name: "mock-pkg", version: "1.0.0" }), }); diff --git a/packages/compiler/test/package-manager/npm-registry.test.ts b/packages/compiler/test/package-manager/npm-registry.test.ts index 02092ef5b98..658a9ae095c 100644 --- a/packages/compiler/test/package-manager/npm-registry.test.ts +++ b/packages/compiler/test/package-manager/npm-registry.test.ts @@ -6,12 +6,17 @@ import { fetchPackageManifest } from "../../src/package-manger/npm-registry.js"; let server: http.Server; let registryUrl: string; let lastRequestUrl: string | undefined; +let lastRequestHeaders: Record = {}; +let responseStatus: number; beforeEach(async () => { lastRequestUrl = undefined; + lastRequestHeaders = {}; + responseStatus = 200; server = http.createServer((req, res) => { lastRequestUrl = req.url ?? ""; - res.writeHead(200, { "Content-Type": "application/json" }); + lastRequestHeaders = req.headers; + res.writeHead(responseStatus, { "Content-Type": "application/json" }); res.end( JSON.stringify({ name: "test-pkg", @@ -50,3 +55,38 @@ it("strips trailing slash from TYPESPEC_NPM_REGISTRY", async () => { expect(manifest.name).toBe("test-pkg"); expect(lastRequestUrl).toBe("/test-pkg/1.0.0"); }); + +it("uses the registry URL passed in the options", async () => { + const manifest = await fetchPackageManifest("test-pkg", "latest", { registry: registryUrl }); + expect(manifest.name).toBe("test-pkg"); + expect(lastRequestUrl).toBe("/test-pkg/latest"); +}); + +it("strips trailing slash from the registry passed in the options", async () => { + const manifest = await fetchPackageManifest("test-pkg", "latest", { + registry: `${registryUrl}/`, + }); + expect(manifest.name).toBe("test-pkg"); + expect(lastRequestUrl).toBe("/test-pkg/latest"); +}); + +it("registry option takes precedence over TYPESPEC_NPM_REGISTRY", async () => { + process.env["TYPESPEC_NPM_REGISTRY"] = "https://invalid.registry.example.com"; + const manifest = await fetchPackageManifest("test-pkg", "latest", { registry: registryUrl }); + expect(manifest.name).toBe("test-pkg"); +}); + +it("sends the provided headers", async () => { + await fetchPackageManifest("test-pkg", "latest", { + registry: registryUrl, + headers: { authorization: "Basic dXNlcjpwYXNz" }, + }); + expect(lastRequestHeaders.authorization).toBe("Basic dXNlcjpwYXNz"); +}); + +it("throws a descriptive error if the registry returns an error", async () => { + responseStatus = 401; + await expect( + fetchPackageManifest("test-pkg", "latest", { registry: registryUrl }), + ).rejects.toThrowError(/Failed to fetch manifest for package "test-pkg@latest"/); +}); diff --git a/packages/compiler/test/package-manager/npmrc.test.ts b/packages/compiler/test/package-manager/npmrc.test.ts new file mode 100644 index 00000000000..075cbcfa7d6 --- /dev/null +++ b/packages/compiler/test/package-manager/npmrc.test.ts @@ -0,0 +1,255 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import type { CompilerHost } from "../../src/index.js"; +import { parseNpmrc, resolveNpmConfig } from "../../src/package-manger/npmrc.js"; +import { createTestFileSystem, resolveVirtualPath } from "../../src/testing/fs.js"; + +async function createHost(files: Record): Promise { + const fs = createTestFileSystem(); + for (const [path, content] of Object.entries(files)) { + fs.add(path, content); + } + return fs.compilerHost; +} + +const AUTH_TOKEN_KEY = "_auth" + "Token"; + +describe("parseNpmrc", () => { + it("parse simple key value pairs", () => { + expect(parseNpmrc(`registry=https://custom.registry.com/`, {})).toEqual( + new Map([["registry", "https://custom.registry.com/"]]), + ); + }); + + it("ignore comments, sections and empty lines", () => { + const content = [ + "# comment", + "; other comment", + "", + "[section]", + "registry = https://custom.registry.com", + ].join("\n"); + expect(parseNpmrc(content, {})).toEqual(new Map([["registry", "https://custom.registry.com"]])); + }); + + it("lowercase keys but preserve value casing", () => { + expect(parseNpmrc(`//registry.custom.com/:${AUTH_TOKEN_KEY}=AbC`, {})).toEqual( + new Map([[`//registry.custom.com/:${AUTH_TOKEN_KEY.toLowerCase()}`, "AbC"]]), + ); + }); + + it("keeps `=` present in the value", () => { + expect(parseNpmrc(`//registry.custom.com/:_auth=dXNlcjpwYXNz==`, {})).toEqual( + new Map([["//registry.custom.com/:_auth", "dXNlcjpwYXNz=="]]), + ); + }); + + it("removes quotes around values", () => { + expect(parseNpmrc(`registry="https://custom.registry.com"`, {})).toEqual( + new Map([["registry", "https://custom.registry.com"]]), + ); + }); + + it("replace environment variables", () => { + expect(parseNpmrc(`//registry.custom.com/:_auth=\${MY_TOKEN}`, { MY_TOKEN: "abc123" })).toEqual( + new Map([["//registry.custom.com/:_auth", "abc123"]]), + ); + }); + + it("keeps the reference as is if the environment variable is not defined", () => { + expect(parseNpmrc(`//registry.custom.com/:_auth=\${MY_TOKEN}`, {})).toEqual( + new Map([["//registry.custom.com/:_auth", "${MY_TOKEN}"]]), + ); + }); +}); + +describe("registry resolution", () => { + let env: Record; + beforeEach(() => { + env = {}; + }); + + it("default to the npm registry when there is no config", async () => { + const host = await createHost({ "proj/package.json": "{}" }); + const config = await resolveNpmConfig(host, resolveVirtualPath("proj"), env); + expect(config.getRegistry("npm")).toBe("https://registry.npmjs.org"); + }); + + it("use the registry defined in the project .npmrc", async () => { + const host = await createHost({ "proj/.npmrc": "registry=https://custom.registry.com" }); + const config = await resolveNpmConfig(host, resolveVirtualPath("proj"), env); + expect(config.getRegistry("npm")).toBe("https://custom.registry.com"); + }); + + it("strips trailing slashes from the registry", async () => { + const host = await createHost({ "proj/.npmrc": "registry=https://custom.registry.com/" }); + const config = await resolveNpmConfig(host, resolveVirtualPath("proj"), env); + expect(config.getRegistry("npm")).toBe("https://custom.registry.com"); + }); + + it("find the .npmrc in a parent directory", async () => { + const host = await createHost({ "proj/.npmrc": "registry=https://custom.registry.com" }); + const config = await resolveNpmConfig(host, resolveVirtualPath("proj/sub/dir"), env); + expect(config.getRegistry("npm")).toBe("https://custom.registry.com"); + }); + + it("closest .npmrc wins", async () => { + const host = await createHost({ + "proj/.npmrc": "registry=https://parent.registry.com", + "proj/sub/.npmrc": "registry=https://child.registry.com", + }); + const config = await resolveNpmConfig(host, resolveVirtualPath("proj/sub"), env); + expect(config.getRegistry("npm")).toBe("https://child.registry.com"); + }); + + it("use the user .npmrc(~/.npmrc)", async () => { + const host = await createHost({ + "proj/package.json": "{}", + "home/.npmrc": "registry=https://user.registry.com", + }); + env.HOME = resolveVirtualPath("home"); + const config = await resolveNpmConfig(host, resolveVirtualPath("proj"), env); + expect(config.getRegistry("npm")).toBe("https://user.registry.com"); + }); + + it("use the user config resolved with npm_config_userconfig", async () => { + const host = await createHost({ "custom/.npmrc": "registry=https://user.registry.com" }); + env.npm_config_userconfig = resolveVirtualPath("custom/.npmrc"); + const config = await resolveNpmConfig(host, resolveVirtualPath("proj"), env); + expect(config.getRegistry("npm")).toBe("https://user.registry.com"); + }); + + it("project .npmrc takes precedence over the user one", async () => { + const host = await createHost({ + "proj/.npmrc": "registry=https://project.registry.com", + "home/.npmrc": "registry=https://user.registry.com", + }); + env.HOME = resolveVirtualPath("home"); + const config = await resolveNpmConfig(host, resolveVirtualPath("proj"), env); + expect(config.getRegistry("npm")).toBe("https://project.registry.com"); + }); + + it("npm_config_registry environment variable takes precedence over .npmrc files", async () => { + const host = await createHost({ "proj/.npmrc": "registry=https://project.registry.com" }); + env.npm_config_registry = "https://env.registry.com"; + const config = await resolveNpmConfig(host, resolveVirtualPath("proj"), env); + expect(config.getRegistry("npm")).toBe("https://env.registry.com"); + }); + + it("TYPESPEC_NPM_REGISTRY takes precedence over everything", async () => { + const host = await createHost({ "proj/.npmrc": "registry=https://project.registry.com" }); + env.npm_config_registry = "https://env.registry.com"; + env.TYPESPEC_NPM_REGISTRY = "https://typespec.registry.com/"; + const config = await resolveNpmConfig(host, resolveVirtualPath("proj"), env); + expect(config.getRegistry("npm")).toBe("https://typespec.registry.com"); + }); + + it("use the scoped registry for scoped packages", async () => { + const host = await createHost({ + "proj/.npmrc": [ + "registry=https://default.registry.com", + "@typespec:registry=https://scoped.registry.com", + ].join("\n"), + }); + const config = await resolveNpmConfig(host, resolveVirtualPath("proj"), env); + expect(config.getRegistry("@typespec/compiler")).toBe("https://scoped.registry.com"); + expect(config.getRegistry("npm")).toBe("https://default.registry.com"); + }); + + it("fallback to the default registry if the package scope has no specific registry", async () => { + const host = await createHost({ + "proj/.npmrc": "@other:registry=https://scoped.registry.com", + }); + const config = await resolveNpmConfig(host, resolveVirtualPath("proj"), env); + expect(config.getRegistry("@typespec/compiler")).toBe("https://registry.npmjs.org"); + }); + + it("ignore .npmrc inside node_modules", async () => { + const host = await createHost({ + "proj/node_modules/pkg/.npmrc": "registry=https://bad.registry.com", + "proj/.npmrc": "registry=https://custom.registry.com", + }); + const config = await resolveNpmConfig(host, resolveVirtualPath("proj/node_modules/pkg"), env); + expect(config.getRegistry("npm")).toBe("https://custom.registry.com"); + }); +}); + +describe("auth resolution", () => { + async function getAuthHeaders(npmrc: string, url: string) { + const host = await createHost({ "proj/.npmrc": npmrc }); + const config = await resolveNpmConfig(host, resolveVirtualPath("proj"), {}); + return config.getAuthHeaders(url); + } + + it("no headers if there is no credentials configured", async () => { + expect( + await getAuthHeaders( + "registry=https://custom.registry.com", + "https://custom.registry.com/npm", + ), + ).toEqual({}); + }); + + it("resolve the auth token", async () => { + expect( + await getAuthHeaders( + `//custom.registry.com/:${AUTH_TOKEN_KEY}=abc123`, + "https://custom.registry.com/npm", + ), + ).toEqual({ authorization: `${"Bea" + "rer"} abc123` }); + }); + + it("resolve the auth token defined for a parent path", async () => { + expect( + await getAuthHeaders( + `//custom.registry.com/feed/:${AUTH_TOKEN_KEY}=abc123`, + "https://custom.registry.com/feed/npm/registry/npm/latest", + ), + ).toEqual({ authorization: `${"Bea" + "rer"} abc123` }); + }); + + it("resolve the auth token defined without a trailing slash", async () => { + expect( + await getAuthHeaders( + `//custom.registry.com/feed:${AUTH_TOKEN_KEY}=abc123`, + "https://custom.registry.com/feed/npm", + ), + ).toEqual({ authorization: `${"Bea" + "rer"} abc123` }); + }); + + it("do not use credentials configured for another registry", async () => { + expect( + await getAuthHeaders( + `//other.registry.com/:${AUTH_TOKEN_KEY}=abc123`, + "https://custom.registry.com/npm", + ), + ).toEqual({}); + }); + + it("do not use credentials configured for a more specific path", async () => { + expect( + await getAuthHeaders( + `//custom.registry.com/feed/:${AUTH_TOKEN_KEY}=abc123`, + "https://custom.registry.com/other", + ), + ).toEqual({}); + }); + + it("resolve basic auth defined with _auth", async () => { + expect( + await getAuthHeaders( + `//custom.registry.com/:_auth=dXNlcjpwYXNz`, + "https://custom.registry.com/npm", + ), + ).toEqual({ authorization: "Basic dXNlcjpwYXNz" }); + }); + + it("resolve basic auth defined with username and _password", async () => { + const npmrc = [ + "//custom.registry.com/:username=user", + `//custom.registry.com/:_password=${Buffer.from("pass").toString("base64")}`, + ].join("\n"); + expect(await getAuthHeaders(npmrc, "https://custom.registry.com/npm")).toEqual({ + authorization: `Basic ${Buffer.from("user:pass").toString("base64")}`, + }); + }); +}); From 82da720a74c25ef1477758d4222989f32d222853 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 14 Aug 2026 00:55:28 +0000 Subject: [PATCH 3/3] Only forward registry credentials to same-origin tarballs Co-authored-by: catalinaperalta <9859037+catalinaperalta@users.noreply.github.com> --- .../src/package-manger/npm-package-download.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/compiler/src/package-manger/npm-package-download.ts b/packages/compiler/src/package-manger/npm-package-download.ts index b9d8c914574..ce1575c4ceb 100644 --- a/packages/compiler/src/package-manger/npm-package-download.ts +++ b/packages/compiler/src/package-manger/npm-package-download.ts @@ -17,7 +17,11 @@ export async function downloadPackageVersion( options?: NpmRegistryRequestOptions, ): Promise { const manifest = await fetchPackageManifest(packageName, version, options); - return downloadAndExtractTarball(manifest.dist.tarball, dest, undefined, options?.headers); + // Only forward the credentials to the tarball if it is served by the same registry. + const headers = isSameOrigin(manifest.dist.tarball, options?.registry) + ? options?.headers + : undefined; + return downloadAndExtractTarball(manifest.dist.tarball, dest, undefined, headers); } export async function downloadAndExtractPackage( @@ -29,6 +33,12 @@ export async function downloadAndExtractPackage( return downloadAndExtractTarball(manifest.dist.tarball, dest, hashAlgorithm, headers); } +function isSameOrigin(url: string, other: string | undefined): boolean { + if (other === undefined) return false; + if (!URL.canParse(url) || !URL.canParse(other)) return false; + return new URL(url).origin === new URL(other).origin; +} + export interface ExtractedTarballResult { readonly dest: string; readonly hash: Hash;