diff --git a/.npmrc b/.npmrc deleted file mode 100644 index e0f904d9771..00000000000 --- a/.npmrc +++ /dev/null @@ -1,3 +0,0 @@ -manage-package-manager-versions=true -engine-strict=true -update-notifier=false diff --git a/eng/tsp-core/pipelines/jobs/build-packages.yml b/eng/tsp-core/pipelines/jobs/build-packages.yml index a8f6c8ad35a..f1f0f100984 100644 --- a/eng/tsp-core/pipelines/jobs/build-packages.yml +++ b/eng/tsp-core/pipelines/jobs/build-packages.yml @@ -10,6 +10,8 @@ jobs: variables: TYPESPEC_SKIP_WEBSITE_BUILD: true # Disable docusaurus build TYPESPEC_SKIP_VS_BUILD: true # VS extension is built in the dedicated build-vs (Windows) job + TYPESPEC_NPM_REGISTRY: https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/ + NODE_USE_ENV_PROXY: 1 steps: - template: /eng/tsp-core/pipelines/templates/install.yml diff --git a/eng/tsp-core/pipelines/jobs/e2e.yml b/eng/tsp-core/pipelines/jobs/e2e.yml index 414d8a0ab7a..dcbc63f1edb 100644 --- a/eng/tsp-core/pipelines/jobs/e2e.yml +++ b/eng/tsp-core/pipelines/jobs/e2e.yml @@ -9,6 +9,8 @@ jobs: variables: TYPESPEC_VS_CI_BUILD: false # Enable official Visual Studio extension build TYPESPEC_SKIP_WEBSITE_BUILD: true # Disable docusaurus build + TYPESPEC_NPM_REGISTRY: https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/ + NODE_USE_ENV_PROXY: 1 DISPLAY: ":99" # Set DISPLAY for Linux GUI applications pool: diff --git a/eng/tsp-core/pipelines/publish.yml b/eng/tsp-core/pipelines/publish.yml index 359a250c6b0..2afa30d3e28 100644 --- a/eng/tsp-core/pipelines/publish.yml +++ b/eng/tsp-core/pipelines/publish.yml @@ -31,7 +31,7 @@ extends: - template: /eng/tsp-core/pipelines/jobs/build-packages.yml@self - template: /eng/tsp-core/pipelines/jobs/build-vs.yml@self - template: /eng/tsp-core/pipelines/jobs/cli/build-tsp-cli-all.yml@self - # - template: /eng/tsp-core/pipelines/jobs/e2e.yml@self + - template: /eng/tsp-core/pipelines/jobs/e2e.yml@self parameters: azLogin: true diff --git a/packages/compiler/src/init/init.ts b/packages/compiler/src/init/init.ts index ba863da63e8..a58e407f4dc 100644 --- a/packages/compiler/src/init/init.ts +++ b/packages/compiler/src/init/init.ts @@ -10,6 +10,7 @@ import type { Diagnostic } from "../core/types.js"; import { NoTarget } from "../core/types.js"; import { installTypeSpecDependencies } from "../install/install.js"; import { MANIFEST } from "../manifest.js"; +import { loadNpmRegistryConfig } from "../package-manger/npm-registry-config.js"; import type { ValidationResult } from "./init-template-validate.js"; import { validateTemplateDefinitions } from "./init-template-validate.js"; import type { EmitterTemplate, InitTemplate, InitTemplateInput } from "./init-template.js"; @@ -119,6 +120,7 @@ export async function initTypeSpecProjectWorker( directory, parameters, emitters, + npmRegistryConfig: await loadNpmRegistryConfig(), }); await scaffoldNewProject(host, scaffoldingConfig); diff --git a/packages/compiler/src/init/scaffold.ts b/packages/compiler/src/init/scaffold.ts index d3ce68b2123..a9a56441611 100644 --- a/packages/compiler/src/init/scaffold.ts +++ b/packages/compiler/src/init/scaffold.ts @@ -2,7 +2,10 @@ import { stringify } from "yaml"; import type { TypeSpecRawConfig } from "../config/types.js"; import { getDirectoryPath, joinPaths } from "../core/path-utils.js"; import type { SystemHost } from "../core/types.js"; -import { fetchLatestPackageManifest } from "../package-manger/npm-registry.js"; +import { + fetchLatestPackageManifest, + type NpmRegistryConfig, +} from "../package-manger/npm-registry.js"; import type { PackageJson } from "../types/package-json.js"; import { createFileTemplatingContext, @@ -58,6 +61,9 @@ export interface ScaffoldingConfig { * Selected emitters the tempalates. */ emitters: Record; + + /** Configuration used to fetch package metadata from the npm registry. */ + npmRegistryConfig?: NpmRegistryConfig; } export function normalizeLibrary(library: InitTemplateLibrary): InitTemplateLibrarySpec { @@ -115,18 +121,26 @@ async function writePackageJson(host: SystemHost, config: ScaffoldingConfig) { if (!config.template.skipCompilerPackage) { versionResolutions.push( - resolvePackageVersion("@typespec/compiler").then((v) => ["@typespec/compiler", v]), + resolvePackageVersion("@typespec/compiler", config.npmRegistryConfig).then((v) => [ + "@typespec/compiler", + v, + ]), ); } for (const library of config.libraries) { versionResolutions.push( - getPackageVersion(library.name, library).then((v) => [library.name, v]), + getPackageVersion(library.name, library, config.npmRegistryConfig).then((v) => [ + library.name, + v, + ]), ); } for (const key of Object.keys(config.emitters)) { - versionResolutions.push(getPackageVersion(key, config.emitters[key]).then((v) => [key, v])); + versionResolutions.push( + getPackageVersion(key, config.emitters[key], config.npmRegistryConfig).then((v) => [key, v]), + ); } const dependencies: Record = Object.fromEntries( @@ -264,16 +278,20 @@ async function writeFile( async function getPackageVersion( packageName: string, templatePackageConfig: { version?: string }, + npmRegistryConfig?: NpmRegistryConfig, ): Promise { if (templatePackageConfig.version !== undefined) { return templatePackageConfig.version; } - return resolvePackageVersion(packageName); + return resolvePackageVersion(packageName, npmRegistryConfig); } -async function resolvePackageVersion(packageName: string): Promise { +async function resolvePackageVersion( + packageName: string, + npmRegistryConfig?: NpmRegistryConfig, +): Promise { try { - const manifest = await fetchLatestPackageManifest(packageName); + const manifest = await fetchLatestPackageManifest(packageName, npmRegistryConfig); return `^${manifest.version}`; } catch { return "latest"; diff --git a/packages/compiler/src/install/install.ts b/packages/compiler/src/install/install.ts index 9460879ee66..564603578ef 100644 --- a/packages/compiler/src/install/install.ts +++ b/packages/compiler/src/install/install.ts @@ -7,7 +7,12 @@ import { createDiagnosticCollector } from "../core/diagnostics.js"; 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 { loadNpmRegistryConfig } from "../package-manger/npm-registry-config.js"; +import { + fetchPackageManifest, + type NpmManifest, + type NpmRegistryConfig, +} from "../package-manger/npm-registry.js"; import { mkTempDir } from "../utils/fs-utils.js"; import type { SupportedPackageManager } from "./config.js"; import { getPackageManagerConfig, type PackageManagerConfig } from "./config.js"; @@ -91,6 +96,7 @@ async function installPackageManager( spec: Descriptor, installDir: string, manifest: NpmManifest, + npmRegistryConfig: NpmRegistryConfig, ) { await rm(installDir, { recursive: true, force: true }); const tempDir = await mkTempDir(host, pmDir, `tsp-pm-${packageManager}-${manifest.version}`); @@ -99,7 +105,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, + npmRegistryConfig, + ); if (spec.hash) { if (spec.hash.value !== extractResult.hash.value) { throw new InstallDependenciesError( @@ -165,7 +176,8 @@ export async function installTypeSpecDependencies( ); const packageManager = spec.name; const packageManagerConfig = getPackageManagerConfig(packageManager); - const manifest = await fetchPackageManifest(packageManager, spec.range); + const npmRegistryConfig = await loadNpmRegistryConfig(); + const manifest = await fetchPackageManifest(packageManager, spec.range, npmRegistryConfig); tracer.trace( "fetched-manifest", `Resolved manifest for ${packageManager} at version ${manifest.version}`, @@ -182,6 +194,7 @@ export async function installTypeSpecDependencies( spec, installDir, manifest, + npmRegistryConfig, ); 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..0c0c53950c1 100644 --- a/packages/compiler/src/package-manger/npm-package-download.ts +++ b/packages/compiler/src/package-manger/npm-package-download.ts @@ -4,23 +4,36 @@ 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 { loadNpmRegistryConfig } from "./npm-registry-config.js"; +import { + fetchPackageManifest, + getNpmRequestHeaders, + type NpmManifest, + type NpmRegistryConfig, +} from "./npm-registry.js"; export async function downloadPackageVersion( packageName: string, version: string, dest: string, ): Promise { - const manifest = await fetchPackageManifest(packageName, version); - return downloadAndExtractTarball(manifest.dist.tarball, dest); + const config = await loadNpmRegistryConfig(); + const manifest = await fetchPackageManifest(packageName, version, config); + return downloadAndExtractTarball(manifest.dist.tarball, dest, "sha512", config); } export async function downloadAndExtractPackage( manifest: NpmManifest, dest: string, hashAlgorithm: string = "sha512", + config?: NpmRegistryConfig, ): Promise { - return downloadAndExtractTarball(manifest.dist.tarball, dest, hashAlgorithm); + return downloadAndExtractTarball( + manifest.dist.tarball, + dest, + hashAlgorithm, + config ?? (await loadNpmRegistryConfig()), + ); } export interface ExtractedTarballResult { @@ -31,8 +44,9 @@ async function downloadAndExtractTarball( url: string, dest: string, hashAlgorithm: string = "sha512", + config: NpmRegistryConfig = {}, ): Promise { - const res = await fetch(url); + const res = await fetch(url, { headers: getNpmRequestHeaders(url, config) }); 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-config.ts b/packages/compiler/src/package-manger/npm-registry-config.ts new file mode 100644 index 00000000000..f57ff2b3ea7 --- /dev/null +++ b/packages/compiler/src/package-manger/npm-registry-config.ts @@ -0,0 +1,101 @@ +import { readFile } from "fs/promises"; +import { homedir } from "os"; +import { join } from "path"; +import type { NpmRegistryConfig } from "./npm-registry.js"; + +interface NpmrcAuthFields { + auth?: string; + authToken?: string; + password?: string; + username?: string; +} + +export async function loadNpmRegistryConfig(): Promise { + const npmrcPath = process.env["NPM_CONFIG_USERCONFIG"] ?? join(homedir(), ".npmrc"); + let content: string; + try { + content = await readFile(npmrcPath, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return {}; + } + throw error; + } + + const values = parseNpmrc(content); + const registry = values.get("registry"); + const authFields = new Map(); + + for (const [key, value] of values) { + const separatorIndex = key.lastIndexOf(":"); + if (!key.startsWith("//") || separatorIndex === -1) { + continue; + } + + const scope = key.slice(0, separatorIndex); + const field = key.slice(separatorIndex + 1); + const fields = authFields.get(scope) ?? {}; + switch (field) { + case "_auth": + fields.auth = value; + break; + case "_authToken": + fields.authToken = value; + break; + case "_password": + fields.password = value; + break; + case "username": + fields.username = value; + break; + default: + continue; + } + authFields.set(scope, fields); + } + + return { + registry, + authentication: [...authFields].flatMap(([scope, fields]) => { + const authorization = createAuthorizationHeader(fields); + return authorization === undefined ? [] : [{ scope, authorization }]; + }), + }; +} + +function parseNpmrc(content: string): Map { + const values = new Map(); + for (const line of content.split(/\r?\n/)) { + const trimmed = line.trim(); + if (trimmed.length === 0 || trimmed.startsWith("#") || trimmed.startsWith(";")) { + continue; + } + + const separatorIndex = trimmed.indexOf("="); + if (separatorIndex === -1) { + continue; + } + + const key = trimmed.slice(0, separatorIndex).trim(); + const value = trimmed + .slice(separatorIndex + 1) + .trim() + .replace(/\$\{([^}]+)\}/g, (_, name: string) => process.env[name] ?? ""); + values.set(key, value); + } + return values; +} + +function createAuthorizationHeader(fields: NpmrcAuthFields): string | undefined { + if (fields.authToken !== undefined) { + return `Bearer ${fields.authToken}`; + } + if (fields.auth !== undefined) { + return `Basic ${fields.auth}`; + } + if (fields.username !== undefined && fields.password !== undefined) { + const password = Buffer.from(fields.password, "base64").toString("utf8"); + return `Basic ${Buffer.from(`${fields.username}:${password}`).toString("base64")}`; + } + return undefined; +} diff --git a/packages/compiler/src/package-manger/npm-registry.ts b/packages/compiler/src/package-manger/npm-registry.ts index 4672eafe669..25105d09aba 100644 --- a/packages/compiler/src/package-manger/npm-registry.ts +++ b/packages/compiler/src/package-manger/npm-registry.ts @@ -26,7 +26,7 @@ export interface NpmManifest { export interface NpmPackument { readonly name: string; readonly "dist-tags": { latest: string } & Record; - readonly versions: Record; + readonly versions: Record; readonly [key: string]: unknown; } @@ -84,24 +84,69 @@ export interface NpmHuman { const defaultRegistry = `https://registry.npmjs.org`; +export interface NpmRegistryConfig { + readonly registry?: string; + readonly authentication?: readonly NpmRegistryAuthentication[]; +} + +export interface NpmRegistryAuthentication { + readonly scope: string; + readonly authorization: string; +} + /** * Returns the npm registry URL to use for fetching packages. * Uses the `TYPESPEC_NPM_REGISTRY` environment variable if set, * otherwise falls back to the default npm registry. */ -export function getNpmRegistry(): string { - return (process.env["TYPESPEC_NPM_REGISTRY"] ?? defaultRegistry).replace(/\/$/, ""); +export function getNpmRegistry(config: NpmRegistryConfig = {}): string { + return (process.env["TYPESPEC_NPM_REGISTRY"] ?? config.registry ?? defaultRegistry).replace( + /\/$/, + "", + ); } export async function fetchPackageManifest( packageName: string, version: string, + config: NpmRegistryConfig = {}, +): Promise { + const registry = getNpmRegistry(config); + const isAzureDevOpsFeed = /\/_packaging\/[^/]+\/npm\/registry$/i.test(new URL(registry).pathname); + const url = `${registry}/${packageName}${isAzureDevOpsFeed ? "" : `/${version}`}`; + const res = await fetch(url, { headers: getNpmRequestHeaders(url, config) }); + if (!res.ok) { + throw new Error(`Request to ${url} failed with status ${res.status}.`); + } + + if (!isAzureDevOpsFeed) { + return await res.json(); + } + + const packument = (await res.json()) as NpmPackument; + const resolvedVersion = packument["dist-tags"][version] ?? version; + const manifest = packument.versions[resolvedVersion]; + if (manifest === undefined) { + throw new Error(`Package "${packageName}" does not have a version or tag "${version}".`); + } + return manifest; +} + +export function fetchLatestPackageManifest( + packageName: string, + config: NpmRegistryConfig = {}, ): Promise { - const url = `${getNpmRegistry()}/${packageName}/${version}`; - const res = await fetch(url); - return await res.json(); + return fetchPackageManifest(packageName, "latest", config); } -export function fetchLatestPackageManifest(packageName: string): Promise { - return fetchPackageManifest(packageName, "latest"); +export function getNpmRequestHeaders( + url: string, + config: NpmRegistryConfig, +): HeadersInit | undefined { + const requestUrl = new URL(url); + const requestScope = `//${requestUrl.host}${requestUrl.pathname}`; + const authentication = config.authentication + ?.filter(({ scope }) => requestScope.startsWith(scope)) + .sort((left, right) => right.scope.length - left.scope.length)[0]; + return authentication === undefined ? undefined : { Authorization: authentication.authorization }; } diff --git a/packages/compiler/test/cli/init.test.ts b/packages/compiler/test/cli/init.test.ts index 0d67b3d2462..9b022516d03 100644 --- a/packages/compiler/test/cli/init.test.ts +++ b/packages/compiler/test/cli/init.test.ts @@ -10,9 +10,17 @@ import { createTestFileSystem } from "../../src/testing/fs.js"; import type { TestFileSystem } from "../../src/testing/types.js"; import { parseYaml as coreParseYaml } from "../../src/yaml/parser.js"; -const fetchMock = vi.fn().mockResolvedValue({ - json: () => Promise.resolve({ name: "mock-pkg", version: "1.0.0" }), -}); +const npmManifest = { name: "mock-pkg", version: "1.0.0" }; +const npmRegistryResponse = { + ok: true, + json: () => + Promise.resolve({ + ...npmManifest, + "dist-tags": { latest: npmManifest.version }, + versions: { [npmManifest.version]: npmManifest }, + }), +}; +const fetchMock = vi.fn().mockResolvedValue(npmRegistryResponse); beforeEach(() => { vi.stubGlobal("fetch", fetchMock); @@ -20,9 +28,7 @@ beforeEach(() => { afterEach(() => { vi.unstubAllGlobals(); - fetchMock.mockResolvedValue({ - json: () => Promise.resolve({ name: "mock-pkg", version: "1.0.0" }), - }); + fetchMock.mockResolvedValue(npmRegistryResponse); }); const TEST_SCAFFOLDING = { diff --git a/packages/compiler/test/e2e/init-templates.e2e.ts b/packages/compiler/test/e2e/init-templates.e2e.ts index a8243dd2269..916a2124b55 100644 --- a/packages/compiler/test/e2e/init-templates.e2e.ts +++ b/packages/compiler/test/e2e/init-templates.e2e.ts @@ -10,8 +10,15 @@ import { getTypeSpecCoreTemplates } from "../../src/init/core-templates.js"; import { makeScaffoldingConfig, scaffoldNewProject } from "../../src/init/scaffold.js"; import { defaultInternalTemplateSource } from "../../src/init/template-source/index.js"; +const npmManifest = { name: "mock-pkg", version: "1.0.0" }; const fetchMock = vi.fn().mockResolvedValue({ - json: () => Promise.resolve({ name: "mock-pkg", version: "1.0.0" }), + ok: true, + json: () => + Promise.resolve({ + ...npmManifest, + "dist-tags": { latest: npmManifest.version }, + versions: { [npmManifest.version]: npmManifest }, + }), }); const __dirname = dirname(fileURLToPath(import.meta.url)); diff --git a/packages/compiler/test/init/init-template.test.ts b/packages/compiler/test/init/init-template.test.ts index fac8091d745..32ea304fcda 100644 --- a/packages/compiler/test/init/init-template.test.ts +++ b/packages/compiler/test/init/init-template.test.ts @@ -7,8 +7,15 @@ import { makeScaffoldingConfig, scaffoldNewProject } from "../../src/init/scaffo import type { TestHost } from "../../src/testing/index.js"; import { createTestHost, resolveVirtualPath } from "../../src/testing/index.js"; +const npmManifest = { name: "mock-pkg", version: "1.0.0" }; const fetchMock = vi.fn().mockResolvedValue({ - json: () => Promise.resolve({ name: "mock-pkg", version: "1.0.0" }), + ok: true, + json: () => + Promise.resolve({ + ...npmManifest, + "dist-tags": { latest: npmManifest.version }, + versions: { [npmManifest.version]: npmManifest }, + }), }); let testHost: TestHost; diff --git a/packages/compiler/test/package-manager/npm-registry.test.ts b/packages/compiler/test/package-manager/npm-registry.test.ts index 02092ef5b98..ff8a6e5c0d1 100644 --- a/packages/compiler/test/package-manager/npm-registry.test.ts +++ b/packages/compiler/test/package-manager/npm-registry.test.ts @@ -1,30 +1,54 @@ +import { mkdtemp, rm, writeFile } from "fs/promises"; import * as http from "http"; import type { AddressInfo } from "net"; +import { tmpdir } from "os"; +import { join } from "path"; import { afterEach, beforeEach, expect, it } from "vitest"; -import { fetchPackageManifest } from "../../src/package-manger/npm-registry.js"; +import { loadNpmRegistryConfig } from "../../src/package-manger/npm-registry-config.js"; +import { + fetchPackageManifest, + getNpmRequestHeaders, +} from "../../src/package-manger/npm-registry.js"; let server: http.Server; let registryUrl: string; let lastRequestUrl: string | undefined; +let lastAuthorization: string | undefined; +let responseStatus: number; +let tempDirectory: string | undefined; +const originalHome = process.env["HOME"]; +const originalUserProfile = process.env["USERPROFILE"]; beforeEach(async () => { lastRequestUrl = undefined; + lastAuthorization = undefined; + responseStatus = 200; server = http.createServer((req, res) => { lastRequestUrl = req.url ?? ""; - res.writeHead(200, { "Content-Type": "application/json" }); + lastAuthorization = req.headers.authorization; + res.writeHead(responseStatus, { "Content-Type": "application/json" }); + const manifest = { + name: "test-pkg", + version: "1.0.0", + dependencies: {}, + optionalDependencies: {}, + devDependencies: {}, + peerDependencies: {}, + bundleDependencies: false, + dist: { shasum: "abc", tarball: "http://example.com/test.tgz" }, + bin: null, + _shrinkwrap: null, + }; res.end( - JSON.stringify({ - name: "test-pkg", - version: "1.0.0", - dependencies: {}, - optionalDependencies: {}, - devDependencies: {}, - peerDependencies: {}, - bundleDependencies: false, - dist: { shasum: "abc", tarball: "http://example.com/test.tgz" }, - bin: null, - _shrinkwrap: null, - }), + JSON.stringify( + lastRequestUrl.includes("/_packaging/") + ? { + name: "test-pkg", + "dist-tags": { latest: "1.0.0" }, + versions: { "1.0.0": manifest }, + } + : manifest, + ), ); }); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); @@ -34,6 +58,13 @@ beforeEach(async () => { afterEach(async () => { delete process.env["TYPESPEC_NPM_REGISTRY"]; + delete process.env["NPM_CONFIG_USERCONFIG"]; + restoreEnvironmentVariable("HOME", originalHome); + restoreEnvironmentVariable("USERPROFILE", originalUserProfile); + if (tempDirectory !== undefined) { + await rm(tempDirectory, { recursive: true, force: true }); + tempDirectory = undefined; + } await new Promise((resolve) => server.close(() => resolve())); }); @@ -41,6 +72,7 @@ it("uses the registry URL from TYPESPEC_NPM_REGISTRY when set", async () => { process.env["TYPESPEC_NPM_REGISTRY"] = registryUrl; const manifest = await fetchPackageManifest("test-pkg", "latest"); expect(manifest.name).toBe("test-pkg"); + expect(manifest.version).toBe("1.0.0"); expect(lastRequestUrl).toBe("/test-pkg/latest"); }); @@ -48,5 +80,105 @@ it("strips trailing slash from TYPESPEC_NPM_REGISTRY", async () => { process.env["TYPESPEC_NPM_REGISTRY"] = `${registryUrl}/`; const manifest = await fetchPackageManifest("test-pkg", "1.0.0"); expect(manifest.name).toBe("test-pkg"); + expect(manifest.version).toBe("1.0.0"); expect(lastRequestUrl).toBe("/test-pkg/1.0.0"); }); + +it("resolves package versions from the packument for Azure DevOps feeds", async () => { + process.env["TYPESPEC_NPM_REGISTRY"] = `${registryUrl}/_packaging/test/npm/registry`; + + const manifest = await fetchPackageManifest("test-pkg", "latest"); + + expect(manifest.version).toBe("1.0.0"); + expect(lastRequestUrl).toBe("/_packaging/test/npm/registry/test-pkg"); +}); + +it("reports registry errors before reading the manifest", async () => { + process.env["TYPESPEC_NPM_REGISTRY"] = registryUrl; + responseStatus = 401; + + await expect(fetchPackageManifest("test-pkg", "1.0.0")).rejects.toThrow( + `Request to ${registryUrl}/test-pkg/1.0.0 failed with status 401.`, + ); +}); + +it("reports a missing package version", async () => { + process.env["TYPESPEC_NPM_REGISTRY"] = `${registryUrl}/_packaging/test/npm/registry`; + + await expect(fetchPackageManifest("test-pkg", "2.0.0")).rejects.toThrow( + `Package "test-pkg" does not have a version or tag "2.0.0".`, + ); +}); + +it("loads registry and bearer authentication from NPM_CONFIG_USERCONFIG", async () => { + const npmrcPath = await writeNpmrc(` +registry=${registryUrl}/ +//${new URL(registryUrl).host}/:_authToken=test-token +`); + process.env["NPM_CONFIG_USERCONFIG"] = npmrcPath; + + const config = await loadNpmRegistryConfig(); + const manifest = await fetchPackageManifest("test-pkg", "latest", config); + + expect(manifest.name).toBe("test-pkg"); + expect(lastAuthorization).toBe("Bearer test-token"); +}); + +it("loads authentication from the default user npmrc", async () => { + tempDirectory ??= await mkdtemp(join(tmpdir(), "typespec-npmrc-test-")); + process.env["HOME"] = tempDirectory; + process.env["USERPROFILE"] = tempDirectory; + await writeFile( + join(tempDirectory, ".npmrc"), + `registry=${registryUrl}/\n//${new URL(registryUrl).host}/:_authToken=test-token\n`, + ); + + const config = await loadNpmRegistryConfig(); + await fetchPackageManifest("test-pkg", "latest", config); + + expect(lastAuthorization).toBe("Bearer test-token"); +}); + +it("loads Azure DevOps basic authentication from NPM_CONFIG_USERCONFIG", async () => { + const password = Buffer.from("test-password").toString("base64"); + const npmrcPath = await writeNpmrc(` +registry=${registryUrl}/ +//${new URL(registryUrl).host}/:username=test-user +//${new URL(registryUrl).host}/:_password=${password} +`); + process.env["NPM_CONFIG_USERCONFIG"] = npmrcPath; + + const config = await loadNpmRegistryConfig(); + await fetchPackageManifest("test-pkg", "latest", config); + + expect(lastAuthorization).toBe( + `Basic ${Buffer.from("test-user:test-password").toString("base64")}`, + ); +}); + +it("does not send authentication outside its configured registry scope", async () => { + const config = { + authentication: [{ scope: "//registry.example.com/private/", authorization: "Bearer secret" }], + }; + + expect(getNpmRequestHeaders("https://registry.example.com/private/test", config)).toEqual({ + Authorization: "Bearer secret", + }); + expect(getNpmRequestHeaders("https://registry.example.com/public/test", config)).toBeUndefined(); + expect(getNpmRequestHeaders("https://example.com/package.tgz", config)).toBeUndefined(); +}); + +async function writeNpmrc(contents: string): Promise { + tempDirectory ??= await mkdtemp(join(tmpdir(), "typespec-npmrc-test-")); + const npmrcPath = join(tempDirectory, ".npmrc"); + await writeFile(npmrcPath, contents); + return npmrcPath; +} + +function restoreEnvironmentVariable(name: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } +}