From 361bc5de94813c88f413f06042d712a0821263c7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Mar 2026 13:18:32 +0000 Subject: [PATCH 01/11] Initial plan From 1c31f2d1a709880e379b02659c299584074c1f36 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Mar 2026 13:28:48 +0000 Subject: [PATCH 02/11] Add e2e test for npm-registry-utils HTTP proxy support Co-authored-by: timotheeguerin <1031227+timotheeguerin@users.noreply.github.com> --- ...egistry-utils-proxy-2026-03-04-13-19-05.md | 7 + .../src/package-manger/npm-registry-utils.ts | 2 +- .../test/e2e/npm-registry-utils.e2e.ts | 164 ++++++++++++++++++ 3 files changed, 172 insertions(+), 1 deletion(-) create mode 100644 .chronus/changes/copilot-add-test-npm-registry-utils-proxy-2026-03-04-13-19-05.md create mode 100644 packages/compiler/test/e2e/npm-registry-utils.e2e.ts diff --git a/.chronus/changes/copilot-add-test-npm-registry-utils-proxy-2026-03-04-13-19-05.md b/.chronus/changes/copilot-add-test-npm-registry-utils-proxy-2026-03-04-13-19-05.md new file mode 100644 index 00000000000..12e549c592f --- /dev/null +++ b/.chronus/changes/copilot-add-test-npm-registry-utils-proxy-2026-03-04-13-19-05.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "@typespec/compiler" +--- + +Add e2e test to verify `fetchPackageManifest` from `npm-registry-utils` works through an HTTP proxy using Node.js 24's `--use-env-proxy` flag. diff --git a/packages/compiler/src/package-manger/npm-registry-utils.ts b/packages/compiler/src/package-manger/npm-registry-utils.ts index 58447c7a483..42c3f0679cb 100644 --- a/packages/compiler/src/package-manger/npm-registry-utils.ts +++ b/packages/compiler/src/package-manger/npm-registry-utils.ts @@ -85,7 +85,7 @@ export interface NpmHuman { readonly url?: string | undefined; } -const registry = `https://registry.npmjs.org`; +const registry = process.env.TYPESPEC_NPM_REGISTRY_URL ?? `https://registry.npmjs.org`; export async function fetchPackageManifest( packageName: string, diff --git a/packages/compiler/test/e2e/npm-registry-utils.e2e.ts b/packages/compiler/test/e2e/npm-registry-utils.e2e.ts new file mode 100644 index 00000000000..7e5d0f21650 --- /dev/null +++ b/packages/compiler/test/e2e/npm-registry-utils.e2e.ts @@ -0,0 +1,164 @@ +import { spawn } from "child_process"; +import { createServer, request as httpRequest } from "http"; +import { mkdtemp, rm, writeFile } from "fs/promises"; +import { connect } from "net"; +import { tmpdir } from "os"; +import { join } from "path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { findTestPackageRoot } from "../../src/testing/test-utils.js"; + +const pkgRoot = await findTestPackageRoot(import.meta.url); +const nodeVersion = parseInt(process.versions.node.split(".")[0], 10); + +interface ExecResult { + exitCode: number; + stdout: string; + stderr: string; +} + +function execAsync(command: string, args: string[], env: NodeJS.ProcessEnv): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { env, stdio: ["ignore", "pipe", "pipe"] }); + child.on("error", reject); + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + child.stdout?.on("data", (d: Buffer) => stdout.push(d)); + child.stderr?.on("data", (d: Buffer) => stderr.push(d)); + child.on("exit", (code) => + resolve({ + exitCode: code ?? -1, + stdout: Buffer.concat(stdout).toString(), + stderr: Buffer.concat(stderr).toString(), + }), + ); + }); +} + +describe.runIf(nodeVersion >= 24)( + "npm-registry-utils: HTTP proxy support (Node >= 24 with --use-env-proxy)", + () => { + let proxyServer: ReturnType; + let mockRegistryServer: ReturnType; + let proxyPort: number; + let mockRegistryPort: number; + let tmpDir: string; + let proxyWasUsed: boolean; + + beforeEach(async () => { + proxyWasUsed = false; + tmpDir = await mkdtemp(join(tmpdir(), "typespec-proxy-test-")); + + // Create mock npm registry that returns a minimal package manifest + mockRegistryServer = createServer((_req, res) => { + const manifest = { + name: "typescript", + version: "5.0.0", + dependencies: {}, + optionalDependencies: {}, + devDependencies: {}, + peerDependencies: {}, + bundleDependencies: false, + dist: { + shasum: "abc123", + tarball: "https://example.com/typescript-5.0.0.tgz", + }, + bin: null, + _shrinkwrap: null, + }; + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(manifest)); + }); + await new Promise((resolve) => + mockRegistryServer.listen(0, "127.0.0.1", resolve as () => void), + ); + mockRegistryPort = (mockRegistryServer.address() as { port: number }).port; + + // Create a simple HTTP proxy that handles both regular HTTP and HTTPS CONNECT requests + proxyServer = createServer(); + + // Handle regular HTTP requests (non-CONNECT) + proxyServer.on("request", (req, res) => { + proxyWasUsed = true; + const targetUrl = new URL(req.url!); + const proxyReq = httpRequest( + { + hostname: targetUrl.hostname, + port: Number(targetUrl.port) || 80, + path: targetUrl.pathname + targetUrl.search, + method: req.method, + headers: req.headers, + }, + (proxyRes) => { + res.writeHead(proxyRes.statusCode!, proxyRes.headers); + proxyRes.pipe(res); + }, + ); + req.pipe(proxyReq); + proxyReq.on("error", () => { + res.writeHead(502); + res.end("Bad Gateway"); + }); + }); + + // Handle CONNECT requests (for HTTPS tunneling) + proxyServer.on("connect", (req, clientSocket, head) => { + proxyWasUsed = true; + const [hostname, portStr] = req.url!.split(":"); + const serverSocket = connect(Number(portStr) || 443, hostname, () => { + clientSocket.write( + "HTTP/1.1 200 Connection Established\r\nProxy-Agent: test-proxy\r\n\r\n", + ); + serverSocket.write(head); + serverSocket.pipe(clientSocket); + clientSocket.pipe(serverSocket); + }); + serverSocket.on("error", () => clientSocket.destroy()); + clientSocket.on("error", () => serverSocket.destroy()); + }); + + await new Promise((resolve) => + proxyServer.listen(0, "127.0.0.1", resolve as () => void), + ); + proxyPort = (proxyServer.address() as { port: number }).port; + }); + + afterEach(async () => { + await new Promise((resolve) => mockRegistryServer.close(() => resolve())); + await new Promise((resolve) => proxyServer.close(() => resolve())); + await rm(tmpDir, { recursive: true, force: true }); + }); + + it("fetchPackageManifest routes HTTP requests through the proxy when HTTP_PROXY is set", async () => { + // Write a small script that uses fetchPackageManifest from the built package + const utilsPath = join(pkgRoot, "dist/src/package-manger/npm-registry-utils.js"); + const scriptPath = join(tmpDir, "test-fetch.mjs"); + + const mockRegistryUrl = `http://127.0.0.1:${mockRegistryPort}`; + const proxyUrl = `http://127.0.0.1:${proxyPort}`; + + await writeFile( + scriptPath, + `import { fetchPackageManifest } from ${JSON.stringify(utilsPath)}; +const manifest = await fetchPackageManifest("typescript", "latest"); +console.log(manifest.name); +`, + ); + + const result = await execAsync(process.execPath, ["--use-env-proxy", scriptPath], { + ...process.env, + TYPESPEC_NPM_REGISTRY_URL: mockRegistryUrl, + HTTP_PROXY: proxyUrl, + http_proxy: proxyUrl, + // Ensure 127.0.0.1 is not excluded from proxying + NO_PROXY: "", + no_proxy: "", + }); + + expect(result.exitCode, `Script failed:\n${result.stderr}`).toBe(0); + expect(result.stdout.trim()).toBe("typescript"); + expect(proxyWasUsed, "Expected the fetch request to be routed through the HTTP proxy").toBe( + true, + ); + }); + }, +); From 63e6f85725880005a9308d0b20f97aff7fdef1e9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Mar 2026 13:47:07 +0000 Subject: [PATCH 03/11] Address review: revert registry URL override, keep --use-env-proxy, use real registry Co-authored-by: timotheeguerin <1031227+timotheeguerin@users.noreply.github.com> --- .../src/package-manger/npm-registry-utils.ts | 2 +- .../test/e2e/npm-registry-utils.e2e.ts | 174 ++++++------------ 2 files changed, 58 insertions(+), 118 deletions(-) diff --git a/packages/compiler/src/package-manger/npm-registry-utils.ts b/packages/compiler/src/package-manger/npm-registry-utils.ts index 42c3f0679cb..58447c7a483 100644 --- a/packages/compiler/src/package-manger/npm-registry-utils.ts +++ b/packages/compiler/src/package-manger/npm-registry-utils.ts @@ -85,7 +85,7 @@ export interface NpmHuman { readonly url?: string | undefined; } -const registry = process.env.TYPESPEC_NPM_REGISTRY_URL ?? `https://registry.npmjs.org`; +const registry = `https://registry.npmjs.org`; export async function fetchPackageManifest( packageName: string, diff --git a/packages/compiler/test/e2e/npm-registry-utils.e2e.ts b/packages/compiler/test/e2e/npm-registry-utils.e2e.ts index 7e5d0f21650..4dbeb1c30f7 100644 --- a/packages/compiler/test/e2e/npm-registry-utils.e2e.ts +++ b/packages/compiler/test/e2e/npm-registry-utils.e2e.ts @@ -1,5 +1,5 @@ import { spawn } from "child_process"; -import { createServer, request as httpRequest } from "http"; +import { createServer } from "http"; import { mkdtemp, rm, writeFile } from "fs/promises"; import { connect } from "net"; import { tmpdir } from "os"; @@ -34,131 +34,71 @@ function execAsync(command: string, args: string[], env: NodeJS.ProcessEnv): Pro }); } -describe.runIf(nodeVersion >= 24)( - "npm-registry-utils: HTTP proxy support (Node >= 24 with --use-env-proxy)", - () => { - let proxyServer: ReturnType; - let mockRegistryServer: ReturnType; - let proxyPort: number; - let mockRegistryPort: number; - let tmpDir: string; - let proxyWasUsed: boolean; - - beforeEach(async () => { - proxyWasUsed = false; - tmpDir = await mkdtemp(join(tmpdir(), "typespec-proxy-test-")); - - // Create mock npm registry that returns a minimal package manifest - mockRegistryServer = createServer((_req, res) => { - const manifest = { - name: "typescript", - version: "5.0.0", - dependencies: {}, - optionalDependencies: {}, - devDependencies: {}, - peerDependencies: {}, - bundleDependencies: false, - dist: { - shasum: "abc123", - tarball: "https://example.com/typescript-5.0.0.tgz", - }, - bin: null, - _shrinkwrap: null, - }; - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify(manifest)); - }); - await new Promise((resolve) => - mockRegistryServer.listen(0, "127.0.0.1", resolve as () => void), - ); - mockRegistryPort = (mockRegistryServer.address() as { port: number }).port; - - // Create a simple HTTP proxy that handles both regular HTTP and HTTPS CONNECT requests - proxyServer = createServer(); - - // Handle regular HTTP requests (non-CONNECT) - proxyServer.on("request", (req, res) => { - proxyWasUsed = true; - const targetUrl = new URL(req.url!); - const proxyReq = httpRequest( - { - hostname: targetUrl.hostname, - port: Number(targetUrl.port) || 80, - path: targetUrl.pathname + targetUrl.search, - method: req.method, - headers: req.headers, - }, - (proxyRes) => { - res.writeHead(proxyRes.statusCode!, proxyRes.headers); - proxyRes.pipe(res); - }, +describe.runIf(nodeVersion >= 24)("npm-registry-utils: HTTP proxy support (Node >= 24)", () => { + let proxyServer: ReturnType; + let proxyPort: number; + let tmpDir: string; + let proxyWasUsed: boolean; + + beforeEach(async () => { + proxyWasUsed = false; + tmpDir = await mkdtemp(join(tmpdir(), "typespec-proxy-test-")); + + // Create a simple HTTP proxy that handles HTTPS CONNECT tunneling + proxyServer = createServer(); + + // Handle CONNECT requests (used for HTTPS tunneling) + proxyServer.on("connect", (req, clientSocket, head) => { + proxyWasUsed = true; + const [hostname, portStr] = req.url!.split(":"); + const serverSocket = connect(Number(portStr) || 443, hostname, () => { + clientSocket.write( + "HTTP/1.1 200 Connection Established\r\nProxy-Agent: test-proxy\r\n\r\n", ); - req.pipe(proxyReq); - proxyReq.on("error", () => { - res.writeHead(502); - res.end("Bad Gateway"); - }); + serverSocket.write(head); + serverSocket.pipe(clientSocket); + clientSocket.pipe(serverSocket); }); - - // Handle CONNECT requests (for HTTPS tunneling) - proxyServer.on("connect", (req, clientSocket, head) => { - proxyWasUsed = true; - const [hostname, portStr] = req.url!.split(":"); - const serverSocket = connect(Number(portStr) || 443, hostname, () => { - clientSocket.write( - "HTTP/1.1 200 Connection Established\r\nProxy-Agent: test-proxy\r\n\r\n", - ); - serverSocket.write(head); - serverSocket.pipe(clientSocket); - clientSocket.pipe(serverSocket); - }); - serverSocket.on("error", () => clientSocket.destroy()); - clientSocket.on("error", () => serverSocket.destroy()); - }); - - await new Promise((resolve) => - proxyServer.listen(0, "127.0.0.1", resolve as () => void), - ); - proxyPort = (proxyServer.address() as { port: number }).port; + serverSocket.on("error", () => clientSocket.destroy()); + clientSocket.on("error", () => serverSocket.destroy()); }); - afterEach(async () => { - await new Promise((resolve) => mockRegistryServer.close(() => resolve())); - await new Promise((resolve) => proxyServer.close(() => resolve())); - await rm(tmpDir, { recursive: true, force: true }); - }); + await new Promise((resolve) => + proxyServer.listen(0, "127.0.0.1", resolve as () => void), + ); + proxyPort = (proxyServer.address() as { port: number }).port; + }); + + afterEach(async () => { + await new Promise((resolve) => proxyServer.close(() => resolve())); + await rm(tmpDir, { recursive: true, force: true }); + }); - it("fetchPackageManifest routes HTTP requests through the proxy when HTTP_PROXY is set", async () => { - // Write a small script that uses fetchPackageManifest from the built package - const utilsPath = join(pkgRoot, "dist/src/package-manger/npm-registry-utils.js"); - const scriptPath = join(tmpDir, "test-fetch.mjs"); + it("fetchPackageManifest routes HTTPS requests through the proxy when HTTPS_PROXY is set", async () => { + // Write a small script that uses fetchPackageManifest from the built package + const utilsPath = join(pkgRoot, "dist/src/package-manger/npm-registry-utils.js"); + const scriptPath = join(tmpDir, "test-fetch.mjs"); - const mockRegistryUrl = `http://127.0.0.1:${mockRegistryPort}`; - const proxyUrl = `http://127.0.0.1:${proxyPort}`; + const proxyUrl = `http://127.0.0.1:${proxyPort}`; - await writeFile( - scriptPath, - `import { fetchPackageManifest } from ${JSON.stringify(utilsPath)}; + await writeFile( + scriptPath, + `import { fetchPackageManifest } from ${JSON.stringify(utilsPath)}; const manifest = await fetchPackageManifest("typescript", "latest"); console.log(manifest.name); `, - ); - - const result = await execAsync(process.execPath, ["--use-env-proxy", scriptPath], { - ...process.env, - TYPESPEC_NPM_REGISTRY_URL: mockRegistryUrl, - HTTP_PROXY: proxyUrl, - http_proxy: proxyUrl, - // Ensure 127.0.0.1 is not excluded from proxying - NO_PROXY: "", - no_proxy: "", - }); + ); - expect(result.exitCode, `Script failed:\n${result.stderr}`).toBe(0); - expect(result.stdout.trim()).toBe("typescript"); - expect(proxyWasUsed, "Expected the fetch request to be routed through the HTTP proxy").toBe( - true, - ); + const result = await execAsync(process.execPath, ["--use-env-proxy", scriptPath], { + ...process.env, + HTTPS_PROXY: proxyUrl, + https_proxy: proxyUrl, }); - }, -); + + expect(result.exitCode, `Script failed:\n${result.stderr}`).toBe(0); + expect(result.stdout.trim()).toBe("typescript"); + expect(proxyWasUsed, "Expected the fetch request to be routed through the HTTPS proxy").toBe( + true, + ); + }); +}); From f059023e98a81c3f56d7426513bbcbfaab9d7cd0 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Wed, 4 Mar 2026 05:57:35 -0800 Subject: [PATCH 04/11] Apply suggestion from @timotheeguerin --- packages/compiler/test/e2e/npm-registry-utils.e2e.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/compiler/test/e2e/npm-registry-utils.e2e.ts b/packages/compiler/test/e2e/npm-registry-utils.e2e.ts index 4dbeb1c30f7..d6f82dcd3d6 100644 --- a/packages/compiler/test/e2e/npm-registry-utils.e2e.ts +++ b/packages/compiler/test/e2e/npm-registry-utils.e2e.ts @@ -34,7 +34,7 @@ function execAsync(command: string, args: string[], env: NodeJS.ProcessEnv): Pro }); } -describe.runIf(nodeVersion >= 24)("npm-registry-utils: HTTP proxy support (Node >= 24)", () => { +describe.runIf(nodeVersion >= 22)("HTTP proxy support (Node >= 24)", () => { let proxyServer: ReturnType; let proxyPort: number; let tmpDir: string; From 64752059f3782250a7adffc1829c03be23d2e87f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Mar 2026 19:56:13 +0000 Subject: [PATCH 05/11] Fix proxy test: use non-existent hostname + local mock registry so test fails without proxy Co-authored-by: timotheeguerin <1031227+timotheeguerin@users.noreply.github.com> --- .../test/e2e/npm-registry-utils.e2e.ts | 82 +++++++++++++------ 1 file changed, 58 insertions(+), 24 deletions(-) diff --git a/packages/compiler/test/e2e/npm-registry-utils.e2e.ts b/packages/compiler/test/e2e/npm-registry-utils.e2e.ts index d6f82dcd3d6..297f710c45d 100644 --- a/packages/compiler/test/e2e/npm-registry-utils.e2e.ts +++ b/packages/compiler/test/e2e/npm-registry-utils.e2e.ts @@ -1,15 +1,27 @@ import { spawn } from "child_process"; -import { createServer } from "http"; import { mkdtemp, rm, writeFile } from "fs/promises"; +import { createServer } from "http"; import { connect } from "net"; import { tmpdir } from "os"; import { join } from "path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { findTestPackageRoot } from "../../src/testing/test-utils.js"; -const pkgRoot = await findTestPackageRoot(import.meta.url); const nodeVersion = parseInt(process.versions.node.split(".")[0], 10); +// Minimal npm package manifest shape expected by fetchPackageManifest +const mockManifest = { + name: "typescript", + version: "5.0.0", + dependencies: {}, + optionalDependencies: {}, + devDependencies: {}, + peerDependencies: {}, + bundleDependencies: false, + dist: { shasum: "abc123", tarball: "http://example.com/ts.tgz" }, + bin: null, + _shrinkwrap: null, +}; + interface ExecResult { exitCode: number; stdout: string; @@ -34,8 +46,15 @@ function execAsync(command: string, args: string[], env: NodeJS.ProcessEnv): Pro }); } -describe.runIf(nodeVersion >= 22)("HTTP proxy support (Node >= 24)", () => { +// The test uses a non-existent hostname for the registry URL so that fetch can only +// succeed when HTTP_PROXY is respected. The proxy's CONNECT handler intercepts the +// connection and tunnels it to a local mock npm registry instead of the real host. +// This makes the test fail when --use-env-proxy is absent (DNS error) and pass only +// when the proxy is properly configured. +describe.runIf(nodeVersion >= 22)("npm-registry-utils: HTTP proxy support (Node >= 24)", () => { + let mockRegistryServer: ReturnType; let proxyServer: ReturnType; + let mockRegistryPort: number; let proxyPort: number; let tmpDir: string; let proxyWasUsed: boolean; @@ -44,14 +63,23 @@ describe.runIf(nodeVersion >= 22)("HTTP proxy support (Node >= 24)", () => { proxyWasUsed = false; tmpDir = await mkdtemp(join(tmpdir(), "typespec-proxy-test-")); - // Create a simple HTTP proxy that handles HTTPS CONNECT tunneling - proxyServer = createServer(); + // Local mock npm registry serving a minimal package manifest + mockRegistryServer = createServer((_req, res) => { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(mockManifest)); + }); + await new Promise((resolve) => + mockRegistryServer.listen(0, "127.0.0.1", resolve as () => void), + ); + mockRegistryPort = (mockRegistryServer.address() as { port: number }).port; - // Handle CONNECT requests (used for HTTPS tunneling) + // HTTP proxy: intercepts CONNECT tunneling (used by undici even for plain HTTP targets) + // and redirects ALL connections to the local mock registry instead. + proxyServer = createServer(); proxyServer.on("connect", (req, clientSocket, head) => { proxyWasUsed = true; - const [hostname, portStr] = req.url!.split(":"); - const serverSocket = connect(Number(portStr) || 443, hostname, () => { + // Redirect the tunnel to our mock registry regardless of the requested host + const serverSocket = connect(mockRegistryPort, "127.0.0.1", () => { clientSocket.write( "HTTP/1.1 200 Connection Established\r\nProxy-Agent: test-proxy\r\n\r\n", ); @@ -62,42 +90,48 @@ describe.runIf(nodeVersion >= 22)("HTTP proxy support (Node >= 24)", () => { serverSocket.on("error", () => clientSocket.destroy()); clientSocket.on("error", () => serverSocket.destroy()); }); - - await new Promise((resolve) => - proxyServer.listen(0, "127.0.0.1", resolve as () => void), - ); + await new Promise((resolve) => proxyServer.listen(0, "127.0.0.1", resolve as () => void)); proxyPort = (proxyServer.address() as { port: number }).port; }); afterEach(async () => { - await new Promise((resolve) => proxyServer.close(() => resolve())); + proxyServer.closeAllConnections(); + mockRegistryServer.closeAllConnections(); + await Promise.all([ + new Promise((resolve) => proxyServer.close(() => resolve())), + new Promise((resolve) => mockRegistryServer.close(() => resolve())), + ]); await rm(tmpDir, { recursive: true, force: true }); }); - it("fetchPackageManifest routes HTTPS requests through the proxy when HTTPS_PROXY is set", async () => { - // Write a small script that uses fetchPackageManifest from the built package - const utilsPath = join(pkgRoot, "dist/src/package-manger/npm-registry-utils.js"); + it("fetch routes through HTTP proxy when --use-env-proxy and HTTP_PROXY are set", async () => { const scriptPath = join(tmpDir, "test-fetch.mjs"); - const proxyUrl = `http://127.0.0.1:${proxyPort}`; + // The script fetches from a non-existent hostname. Without a proxy that intercepts + // and redirects the connection, this will fail with a DNS error. await writeFile( scriptPath, - `import { fetchPackageManifest } from ${JSON.stringify(utilsPath)}; -const manifest = await fetchPackageManifest("typescript", "latest"); -console.log(manifest.name); + ` +const res = await fetch("http://nonexistent-npm-registry.invalid/typescript/latest"); +const data = await res.json(); +console.log(data.name); +process.exit(0); `, ); const result = await execAsync(process.execPath, ["--use-env-proxy", scriptPath], { ...process.env, - HTTPS_PROXY: proxyUrl, - https_proxy: proxyUrl, + HTTP_PROXY: proxyUrl, + http_proxy: proxyUrl, + // Ensure no exclusions bypass the proxy + NO_PROXY: "", + no_proxy: "", }); expect(result.exitCode, `Script failed:\n${result.stderr}`).toBe(0); expect(result.stdout.trim()).toBe("typescript"); - expect(proxyWasUsed, "Expected the fetch request to be routed through the HTTPS proxy").toBe( + expect(proxyWasUsed, "Expected the fetch request to be routed through the HTTP proxy").toBe( true, ); }); From 7a5995ae4d8408cd135fd2ad27a30c33847b0341 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Mar 2026 20:47:39 +0000 Subject: [PATCH 06/11] Move proxy test from e2e to regular test suite so it runs on multiple Node versions Co-authored-by: timotheeguerin <1031227+timotheeguerin@users.noreply.github.com> --- .../npm-registry-utils.test.ts} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename packages/compiler/test/{e2e/npm-registry-utils.e2e.ts => package-manager/npm-registry-utils.test.ts} (97%) diff --git a/packages/compiler/test/e2e/npm-registry-utils.e2e.ts b/packages/compiler/test/package-manager/npm-registry-utils.test.ts similarity index 97% rename from packages/compiler/test/e2e/npm-registry-utils.e2e.ts rename to packages/compiler/test/package-manager/npm-registry-utils.test.ts index 297f710c45d..1c88d6313be 100644 --- a/packages/compiler/test/e2e/npm-registry-utils.e2e.ts +++ b/packages/compiler/test/package-manager/npm-registry-utils.test.ts @@ -8,7 +8,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; const nodeVersion = parseInt(process.versions.node.split(".")[0], 10); -// Minimal npm package manifest shape expected by fetchPackageManifest +// Minimal npm package manifest shape const mockManifest = { name: "typescript", version: "5.0.0", @@ -50,7 +50,7 @@ function execAsync(command: string, args: string[], env: NodeJS.ProcessEnv): Pro // succeed when HTTP_PROXY is respected. The proxy's CONNECT handler intercepts the // connection and tunnels it to a local mock npm registry instead of the real host. // This makes the test fail when --use-env-proxy is absent (DNS error) and pass only -// when the proxy is properly configured. +// when the proxy is properly configured (Node.js 24+). describe.runIf(nodeVersion >= 22)("npm-registry-utils: HTTP proxy support (Node >= 24)", () => { let mockRegistryServer: ReturnType; let proxyServer: ReturnType; From 4096173df511417f28d6f33e0ddcb1b30fc9f42b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Mar 2026 21:01:28 +0000 Subject: [PATCH 07/11] Merge main branch changes: incorporate TYPESPEC_NPM_REGISTRY env var support Co-authored-by: timotheeguerin <1031227+timotheeguerin@users.noreply.github.com> --- ...ix-fetch-ssl-npm-registry-2026-02-25-19-45-00.md | 7 +++++++ .../src/package-manger/npm-registry-utils.ts | 13 +++++++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 .chronus/changes/fix-fetch-ssl-npm-registry-2026-02-25-19-45-00.md diff --git a/.chronus/changes/fix-fetch-ssl-npm-registry-2026-02-25-19-45-00.md b/.chronus/changes/fix-fetch-ssl-npm-registry-2026-02-25-19-45-00.md new file mode 100644 index 00000000000..e5755ca4ecf --- /dev/null +++ b/.chronus/changes/fix-fetch-ssl-npm-registry-2026-02-25-19-45-00.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "@typespec/compiler" +--- + +Support `TYPESPEC_NPM_REGISTRY` environment variable to configure the npm registry used by `tsp init` and `tsp install` when fetching package manifests and downloading packages. diff --git a/packages/compiler/src/package-manger/npm-registry-utils.ts b/packages/compiler/src/package-manger/npm-registry-utils.ts index 58447c7a483..5e68cb262c3 100644 --- a/packages/compiler/src/package-manger/npm-registry-utils.ts +++ b/packages/compiler/src/package-manger/npm-registry-utils.ts @@ -85,13 +85,22 @@ export interface NpmHuman { readonly url?: string | undefined; } -const registry = `https://registry.npmjs.org`; +const defaultRegistry = `https://registry.npmjs.org`; + +/** + * 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. + */ +function getNpmRegistry(): string { + return (process.env["TYPESPEC_NPM_REGISTRY"] ?? defaultRegistry).replace(/\/$/, ""); +} export async function fetchPackageManifest( packageName: string, version: string, ): Promise { - const url = `${registry}/${packageName}/${version}`; + const url = `${getNpmRegistry()}/${packageName}/${version}`; const res = await fetch(url); return await res.json(); } From faabc79e5b099413e0e9af5873c9e37e83a8c6ed Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Mar 2026 21:07:55 +0000 Subject: [PATCH 08/11] Merge main branch: combine TYPESPEC_NPM_REGISTRY tests with proxy tests in single file Co-authored-by: timotheeguerin <1031227+timotheeguerin@users.noreply.github.com> --- .../npm-registry-utils.test.ts | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/packages/compiler/test/package-manager/npm-registry-utils.test.ts b/packages/compiler/test/package-manager/npm-registry-utils.test.ts index 1c88d6313be..46a3eb3c1af 100644 --- a/packages/compiler/test/package-manager/npm-registry-utils.test.ts +++ b/packages/compiler/test/package-manager/npm-registry-utils.test.ts @@ -1,10 +1,13 @@ import { spawn } from "child_process"; import { mkdtemp, rm, writeFile } from "fs/promises"; +import * as http from "http"; import { createServer } from "http"; import { connect } from "net"; +import type { AddressInfo } from "net"; import { tmpdir } from "os"; import { join } from "path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { fetchPackageManifest } from "../../src/package-manger/npm-registry-utils.js"; const nodeVersion = parseInt(process.versions.node.split(".")[0], 10); @@ -136,3 +139,53 @@ process.exit(0); ); }); }); + +describe("TYPESPEC_NPM_REGISTRY", () => { + let server: http.Server; + let registryUrl: string; + let lastRequestUrl: string | undefined; + + beforeEach(async () => { + lastRequestUrl = undefined; + server = http.createServer((req, res) => { + lastRequestUrl = req.url ?? ""; + res.writeHead(200, { "Content-Type": "application/json" }); + 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, + }), + ); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const { port } = server.address() as AddressInfo; + registryUrl = `http://127.0.0.1:${port}`; + }); + + afterEach(async () => { + delete process.env["TYPESPEC_NPM_REGISTRY"]; + await new Promise((resolve) => server.close(() => resolve())); + }); + + 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(lastRequestUrl).toBe("/test-pkg/latest"); + }); + + 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(lastRequestUrl).toBe("/test-pkg/1.0.0"); + }); +}); From 63e40f74632d9d909c0134d17f0eed6ba309af5a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Mar 2026 21:25:28 +0000 Subject: [PATCH 09/11] Merge origin/main: resolve add/add conflict in npm-registry-utils.test.ts Both branches independently added the same test file. Our version already contains both test suites (proxy tests + TYPESPEC_NPM_REGISTRY tests), so we keep it and bring in all other changes from main. Co-authored-by: timotheeguerin <1031227+timotheeguerin@users.noreply.github.com> --- .../glecaros-file-types-2026-2-3-22-49-17.md | 7 ++ .../glecaros-file-types-2026-2-3-23-44-19.md | 7 ++ .github/copilot-instructions.md | 3 + eng/tsp-core/tpm/packages.ts | 44 +--------- eng/tsp-core/utils/packages.ts | 33 ++++--- .../src/CodeModelGenerator.cs | 19 ++++ .../test/GeneratorTests.cs | 86 +++++++++++++++++++ packages/openapi3/README.md | 6 +- packages/openapi3/src/lib.ts | 29 +++++-- packages/openapi3/src/openapi.ts | 50 +++++++---- packages/openapi3/test/output-file.test.ts | 55 ++++++++++++ .../react/settings/emitter-options-form.tsx | 35 +++++++- .../src/components/dashboard.tsx | 16 ++-- .../src/components/tier-filter.tsx | 43 +++------- .../emitters/openapi3/reference/emitter.md | 6 +- website/src/content/docs/docs/handbook/cli.md | 12 +++ 16 files changed, 329 insertions(+), 122 deletions(-) create mode 100644 .chronus/changes/glecaros-file-types-2026-2-3-22-49-17.md create mode 100644 .chronus/changes/glecaros-file-types-2026-2-3-23-44-19.md diff --git a/.chronus/changes/glecaros-file-types-2026-2-3-22-49-17.md b/.chronus/changes/glecaros-file-types-2026-2-3-22-49-17.md new file mode 100644 index 00000000000..c2e5a32570e --- /dev/null +++ b/.chronus/changes/glecaros-file-types-2026-2-3-22-49-17.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - "@typespec/openapi3" +--- + +`file-type` can now receive an array to allow emitting both `json` and `yaml` output in the same run. \ No newline at end of file diff --git a/.chronus/changes/glecaros-file-types-2026-2-3-23-44-19.md b/.chronus/changes/glecaros-file-types-2026-2-3-23-44-19.md new file mode 100644 index 00000000000..ed9f0627572 --- /dev/null +++ b/.chronus/changes/glecaros-file-types-2026-2-3-23-44-19.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - "@typespec/playground" +--- + +Add support for oneOf option schemas. \ No newline at end of file diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 6c06c9b43a2..aa2b8ef8ebb 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -4,6 +4,9 @@ TypeSpec is a language for defining cloud service APIs and shapes. This monorepo contains the TypeSpec compiler, standard library packages, tools, documentation, and various language client emitters. +> [!IMPORTANT] +> **These instructions do NOT apply to the language emitter packages** (`http-client-csharp`, `http-client-java`, `http-client-python`). Those packages are excluded from the pnpm workspace and do not require using pnpm. + ## Essential Setup and Build Commands ### Prerequisites and Installation diff --git a/eng/tsp-core/tpm/packages.ts b/eng/tsp-core/tpm/packages.ts index 42c48c275e1..656e83eaa71 100644 --- a/eng/tsp-core/tpm/packages.ts +++ b/eng/tsp-core/tpm/packages.ts @@ -1,19 +1,4 @@ -import { readdir } from "fs/promises"; -import { join } from "path"; -import { repoRoot } from "../../common/scripts/utils/common.js"; - -// Standalone packages that need special handling with npm instead of pnpm -const STANDALONE_PACKAGES = [ - "packages/http-client-csharp", - // Java package is too large for pkg-pr-new at the moment - // "packages/http-client-java", - "packages/http-client-python", -]; - -// Packages to exclude from pkg-pr-new publishing -const EXCLUDED_PACKAGES = [ - "packages/http-client-java", // Too large for pkg-pr-new -]; +export { getAllPackages, getPublishablePackages, type PackageInfo } from "../utils/packages.js"; /** * Critical packages that must be built before other packages. @@ -26,30 +11,3 @@ const EXCLUDED_PACKAGES = [ * (and its dependency prettier-plugin-typespec) to already be available. */ export const CRITICAL_PACKAGES = ["@typespec/prettier-plugin-typespec", "@typespec/tspd"]; - -export interface PackageInfo { - name: string; - path: string; - isStandalone: boolean; -} - -export async function getAllPackages(): Promise { - const packagesDir = join(repoRoot, "packages"); - const packages = await readdir(packagesDir, { withFileTypes: true }); - - return packages - .filter((dirent) => dirent.isDirectory()) - .map((dirent) => { - const pkgPath = `packages/${dirent.name}`; - return { - name: dirent.name, - path: pkgPath, - isStandalone: STANDALONE_PACKAGES.includes(pkgPath), - }; - }); -} - -export async function getPublishablePackages(): Promise { - const allPackages = await getAllPackages(); - return allPackages.filter((pkg) => !EXCLUDED_PACKAGES.includes(pkg.path)); -} diff --git a/eng/tsp-core/utils/packages.ts b/eng/tsp-core/utils/packages.ts index 20888f98079..55e63e0dfbf 100644 --- a/eng/tsp-core/utils/packages.ts +++ b/eng/tsp-core/utils/packages.ts @@ -1,4 +1,4 @@ -import { readdir } from "fs/promises"; +import { readdir, readFile } from "fs/promises"; import { join } from "path"; import { repoRoot } from "../../common/scripts/utils/common.js"; @@ -19,25 +19,36 @@ export interface PackageInfo { name: string; path: string; isStandalone: boolean; + isPrivate: boolean; } export async function getAllPackages(): Promise { const packagesDir = join(repoRoot, "packages"); const packages = await readdir(packagesDir, { withFileTypes: true }); - return packages - .filter((dirent) => dirent.isDirectory()) - .map((dirent) => { - const pkgPath = `packages/${dirent.name}`; - return { - name: dirent.name, - path: pkgPath, - isStandalone: STANDALONE_PACKAGES.includes(pkgPath), - }; + const results: PackageInfo[] = []; + for (const dirent of packages.filter((d) => d.isDirectory())) { + const pkgPath = `packages/${dirent.name}`; + const pkgJsonPath = join(repoRoot, pkgPath, "package.json"); + let pkgJson: { private?: boolean }; + try { + pkgJson = JSON.parse(await readFile(pkgJsonPath, "utf-8")); + } catch { + // eslint-disable-next-line no-console + console.warn(`Could not read package.json for ${pkgPath}, skipping.`); + continue; + } + results.push({ + name: dirent.name, + path: pkgPath, + isStandalone: STANDALONE_PACKAGES.includes(pkgPath), + isPrivate: pkgJson.private === true, }); + } + return results; } export async function getPublishablePackages(): Promise { const allPackages = await getAllPackages(); - return allPackages.filter((pkg) => !EXCLUDED_PACKAGES.includes(pkg.path)); + return allPackages.filter((pkg) => !pkg.isPrivate && !EXCLUDED_PACKAGES.includes(pkg.path)); } diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/CodeModelGenerator.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/CodeModelGenerator.cs index 4a41327828e..6fc87d7ced9 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/CodeModelGenerator.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/CodeModelGenerator.cs @@ -119,6 +119,25 @@ public virtual void AddVisitor(LibraryVisitor visitor) _visitors.Add(visitor); } + /// + /// Removes all visitors of the specified type from the list of visitors. + /// + /// The type of visitor to remove. + public virtual void RemoveVisitor() where T : LibraryVisitor + { + _visitors.RemoveAll(v => v.GetType() == typeof(T)); + } + + /// + /// Removes all visitors whose type name matches the specified name from the list of visitors. + /// This overload is useful when the visitor type is not publicly accessible. + /// + /// The name of the visitor type to remove. + public virtual void RemoveVisitor(string visitorTypeName) + { + _visitors.RemoveAll(v => v.GetType().Name == visitorTypeName); + } + public virtual void AddRewriter(LibraryRewriter rewriter) { _rewriters.Add(rewriter); diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/GeneratorTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/GeneratorTests.cs index 5cf50c5e25e..bced881740b 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/GeneratorTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/GeneratorTests.cs @@ -15,5 +15,91 @@ public void CanAddVisitors() mockGenerator.AddVisitor(new TestLibraryVisitor()); Assert.AreEqual(1, mockGenerator.Visitors.Count); } + + [Test] + public void CanRemoveVisitorByType() + { + var mockGenerator = new TestGenerator(); + mockGenerator.AddVisitor(new TestLibraryVisitor()); + Assert.AreEqual(1, mockGenerator.Visitors.Count); + + mockGenerator.RemoveVisitor(); + Assert.AreEqual(0, mockGenerator.Visitors.Count); + } + + [Test] + public void RemoveVisitorDoesNothingWhenTypeNotFound() + { + var mockGenerator = new TestGenerator(); + mockGenerator.AddVisitor(new TestLibraryVisitor()); + Assert.AreEqual(1, mockGenerator.Visitors.Count); + + mockGenerator.RemoveVisitor(); + Assert.AreEqual(1, mockGenerator.Visitors.Count); + } + + [Test] + public void RemoveVisitorRemovesAllMatchingInstances() + { + var mockGenerator = new TestGenerator(); + mockGenerator.AddVisitor(new TestLibraryVisitor()); + mockGenerator.AddVisitor(new TestLibraryVisitor()); + mockGenerator.AddVisitor(new DerivedTestLibraryVisitor()); + Assert.AreEqual(3, mockGenerator.Visitors.Count); + + mockGenerator.RemoveVisitor(); + Assert.AreEqual(1, mockGenerator.Visitors.Count); + Assert.IsInstanceOf(mockGenerator.Visitors[0]); + } + + [Test] + public void RemoveVisitorDoesNotRemoveDerivedType() + { + var mockGenerator = new TestGenerator(); + mockGenerator.AddVisitor(new DerivedTestLibraryVisitor()); + Assert.AreEqual(1, mockGenerator.Visitors.Count); + + // Removing by base type should NOT remove derived instances (exact type matching) + mockGenerator.RemoveVisitor(); + Assert.AreEqual(1, mockGenerator.Visitors.Count); + } + + [Test] + public void CanRemoveVisitorByName() + { + var mockGenerator = new TestGenerator(); + mockGenerator.AddVisitor(new TestLibraryVisitor()); + Assert.AreEqual(1, mockGenerator.Visitors.Count); + + mockGenerator.RemoveVisitor(nameof(TestLibraryVisitor)); + Assert.AreEqual(0, mockGenerator.Visitors.Count); + } + + [Test] + public void RemoveVisitorByNameDoesNothingWhenNameNotFound() + { + var mockGenerator = new TestGenerator(); + mockGenerator.AddVisitor(new TestLibraryVisitor()); + Assert.AreEqual(1, mockGenerator.Visitors.Count); + + mockGenerator.RemoveVisitor("NonExistentVisitor"); + Assert.AreEqual(1, mockGenerator.Visitors.Count); + } + + [Test] + public void RemoveVisitorByNameRemovesAllMatchingInstances() + { + var mockGenerator = new TestGenerator(); + mockGenerator.AddVisitor(new TestLibraryVisitor()); + mockGenerator.AddVisitor(new TestLibraryVisitor()); + mockGenerator.AddVisitor(new DerivedTestLibraryVisitor()); + Assert.AreEqual(3, mockGenerator.Visitors.Count); + + mockGenerator.RemoveVisitor(nameof(TestLibraryVisitor)); + Assert.AreEqual(1, mockGenerator.Visitors.Count); + Assert.IsInstanceOf(mockGenerator.Visitors[0]); + } + + private class DerivedTestLibraryVisitor : TestLibraryVisitor { } } } diff --git a/packages/openapi3/README.md b/packages/openapi3/README.md index 1c5c6efb4ed..6512a4c1a86 100644 --- a/packages/openapi3/README.md +++ b/packages/openapi3/README.md @@ -44,9 +44,9 @@ See [Configuring output directory for more info](https://typespec.io/docs/handbo ### `file-type` -**Type:** `"yaml" | "json"` +**Type:** `string,array` -If the content should be serialized as YAML or JSON. Default 'yaml', it not specified infer from the `output-file` extension +If the content should be serialized as YAML or JSON. Can be a single value or an array to emit multiple formats. Default 'yaml', if not specified infer from the `output-file` extension ### `output-file` @@ -58,8 +58,10 @@ Output file will interpolate the following values: - service-name: Name of the service - service-name-if-multiple: Name of the service if multiple - version: Version of the service if multiple +- file-type: The file type being emitted (json or yaml). Useful when `file-type` is an array. Default: `{service-name-if-multiple}.{version}.openapi.yaml` or `.json` if `file-type` is `"json"` +When `file-type` is an array: `{service-name-if-multiple}.{version}.openapi.{file-type}` Example Single service no versioning diff --git a/packages/openapi3/src/lib.ts b/packages/openapi3/src/lib.ts index 7d096319ba0..d5c29f3179b 100644 --- a/packages/openapi3/src/lib.ts +++ b/packages/openapi3/src/lib.ts @@ -5,11 +5,12 @@ export type OpenAPIVersion = "3.0.0" | "3.1.0" | "3.2.0"; export type ExperimentalParameterExamplesStrategy = "data" | "serialized"; export interface OpenAPI3EmitterOptions { /** - * If the content should be serialized as YAML or JSON. + * If the content should be serialized as YAML or JSON. Can be a single value or an array to emit multiple file types. + * When an array is provided, the `{file-type}` variable can be used in `output-file` to produce distinct filenames. * @default yaml, it not specified infer from the `output-file` extension */ - "file-type"?: FileType; + "file-type"?: FileType | FileType[]; /** * Name of the output file. @@ -18,7 +19,7 @@ export interface OpenAPI3EmitterOptions { * - service-name-if-multiple: Name of the service if multiple * - version: Version of the service if multiple * - * @default `{service-name-if-multiple}.{version}.openapi.yaml` or `.json` if {@link OpenAPI3EmitterOptions["file-type"]} is `"json"` + * @default `{service-name-if-multiple}.{version}.openapi.yaml` or `.json` if {@link OpenAPI3EmitterOptions["file-type"]} is `"json"`. When `file-type` is an array, uses `{file-type}` variable. * * @example Single service no versioning * - `openapi.yaml` @@ -129,11 +130,25 @@ const EmitterOptionsSchema: JSONSchemaType = { additionalProperties: false, properties: { "file-type": { - type: "string", - enum: ["yaml", "json"], + type: ["string", "array"], nullable: true, + oneOf: [ + { + type: "string", + enum: ["yaml", "json"], + }, + { + type: "array", + items: { + type: "string", + enum: ["yaml", "json"], + }, + uniqueItems: true, + minItems: 1, + }, + ], description: - "If the content should be serialized as YAML or JSON. Default 'yaml', it not specified infer from the `output-file` extension", + "If the content should be serialized as YAML or JSON. Can be a single value or an array to emit multiple formats. Default 'yaml', if not specified infer from the `output-file` extension", }, "output-file": { type: "string", @@ -144,8 +159,10 @@ const EmitterOptionsSchema: JSONSchemaType = { " - service-name: Name of the service", " - service-name-if-multiple: Name of the service if multiple", " - version: Version of the service if multiple", + " - file-type: The file type being emitted (json or yaml). Useful when `file-type` is an array.", "", ' Default: `{service-name-if-multiple}.{version}.openapi.yaml` or `.json` if `file-type` is `"json"`', + " When `file-type` is an array: `{service-name-if-multiple}.{version}.openapi.{file-type}`", "", " Example Single service no versioning", " - `openapi.yaml`", diff --git a/packages/openapi3/src/openapi.ts b/packages/openapi3/src/openapi.ts index d6cadd39c4e..a4559806325 100644 --- a/packages/openapi3/src/openapi.ts +++ b/packages/openapi3/src/openapi.ts @@ -206,17 +206,22 @@ export function resolveOptions( ): ResolvedOpenAPI3EmitterOptions { const resolvedOptions = { ...defaultOptions, ...context.options }; - const fileType = - resolvedOptions["file-type"] ?? findFileTypeFromFilename(resolvedOptions["output-file"]); + const rawFileType = resolvedOptions["file-type"]; + const fileTypes: FileType[] = Array.isArray(rawFileType) + ? rawFileType + : [rawFileType ?? findFileTypeFromFilename(resolvedOptions["output-file"])]; const outputFile = - resolvedOptions["output-file"] ?? `openapi.{service-name-if-multiple}.{version}.${fileType}`; + resolvedOptions["output-file"] ?? + (fileTypes.length > 1 + ? `openapi.{service-name-if-multiple}.{version}.{file-type}` + : `openapi.{service-name-if-multiple}.{version}.${fileTypes[0]}`); const openapiVersions = resolvedOptions["openapi-versions"] ?? ["3.0.0"]; const specDir = openapiVersions.length > 1 ? "{openapi-version}" : ""; return { - fileType, + fileTypes, newLine: resolvedOptions["new-line"], omitUnreachableTypes: resolvedOptions["omit-unreachable-types"], includeXTypeSpecName: resolvedOptions["include-x-typespec-name"], @@ -257,7 +262,7 @@ function resolveOperationIdDefaultStrategySeparator(strategy: OperationIdStrateg } export interface ResolvedOpenAPI3EmitterOptions { - fileType: FileType; + fileTypes: FileType[]; outputFile: string; openapiVersions: OpenAPIVersion[]; newLine: NewLine; @@ -366,20 +371,27 @@ function createOAPIEmitter( const multipleService = services.length > 1; const writeTimer = perf.startTimer(); for (const serviceRecord of services) { - if (serviceRecord.versioned) { - for (const documentRecord of serviceRecord.versions) { + for (const fileType of options.fileTypes) { + if (serviceRecord.versioned) { + for (const documentRecord of serviceRecord.versions) { + await emitFile(program, { + path: resolveOutputFile( + serviceRecord.service, + multipleService, + fileType, + documentRecord.version, + ), + content: serializeDocument(documentRecord.document, fileType), + newLine: options.newLine, + }); + } + } else { await emitFile(program, { - path: resolveOutputFile(serviceRecord.service, multipleService, documentRecord.version), - content: serializeDocument(documentRecord.document, options.fileType), + path: resolveOutputFile(serviceRecord.service, multipleService, fileType), + content: serializeDocument(serviceRecord.document, fileType), newLine: options.newLine, }); } - } else { - await emitFile(program, { - path: resolveOutputFile(serviceRecord.service, multipleService), - content: serializeDocument(serviceRecord.document, options.fileType), - newLine: options.newLine, - }); } } const writeTime = writeTimer.end(); @@ -598,11 +610,17 @@ function createOAPIEmitter( return document; } - function resolveOutputFile(service: Service, multipleService: boolean, version?: string): string { + function resolveOutputFile( + service: Service, + multipleService: boolean, + fileType: FileType, + version?: string, + ): string { return interpolatePath(options.outputFile, { "openapi-version": specVersion, "service-name-if-multiple": multipleService ? getNamespaceFullName(service.type) : undefined, "service-name": getNamespaceFullName(service.type), + "file-type": fileType, version, }); } diff --git a/packages/openapi3/test/output-file.test.ts b/packages/openapi3/test/output-file.test.ts index 90ffd7165d9..38b5530a600 100644 --- a/packages/openapi3/test/output-file.test.ts +++ b/packages/openapi3/test/output-file.test.ts @@ -136,6 +136,61 @@ describe("openapi3: output file", () => { }); }); + describe("multiple file types", () => { + it("emit both json and yaml when file-type is an array", async () => { + await compileOpenAPI({ "file-type": ["json", "yaml"] }); + expectOutput("openapi.json", expectedJsonEmptySpec); + expectOutput("openapi.yaml", expectedYamlEmptySpec); + }); + + it("emit both formats with custom output-file using {file-type}", async () => { + await compileOpenAPI({ + "file-type": ["json", "yaml"], + "output-file": "my.spec.{file-type}", + }); + expectOutput("my.spec.json", expectedJsonEmptySpec); + expectOutput("my.spec.yaml", expectedYamlEmptySpec); + }); + + it("emit both formats for multiple services", async () => { + await compileOpenAPI( + { "file-type": ["json", "yaml"] }, + ` + @service namespace Service1 {} + @service namespace Service2 {} + `, + ); + expectHasOutput("openapi.Service1.json"); + expectHasOutput("openapi.Service2.json"); + expectHasOutput("openapi.Service1.yaml"); + expectHasOutput("openapi.Service2.yaml"); + }); + + it("emit both formats for versioned services", async () => { + await compileOpenAPI( + { "file-type": ["json", "yaml"] }, + ` + using Versioning; + @versioned(Versions) @service namespace Service1 { + enum Versions {v1, v2} + } + `, + ); + expectHasOutput("openapi.v1.json"); + expectHasOutput("openapi.v2.json"); + expectHasOutput("openapi.v1.yaml"); + expectHasOutput("openapi.v2.yaml"); + }); + + it("{file-type} variable works with single file-type string", async () => { + await compileOpenAPI({ + "file-type": "json", + "output-file": "my.spec.{file-type}", + }); + expectOutput("my.spec.json", expectedJsonEmptySpec); + }); + }); + describe("Predefined variable name behavior", () => { interface ServiceNameCase { description: string; diff --git a/packages/playground/src/react/settings/emitter-options-form.tsx b/packages/playground/src/react/settings/emitter-options-form.tsx index 1c6cc605f47..df753638abb 100644 --- a/packages/playground/src/react/settings/emitter-options-form.tsx +++ b/packages/playground/src/react/settings/emitter-options-form.tsx @@ -50,20 +50,23 @@ export const EmitterOptionsForm: FunctionComponent = ({ return (
{entries.map(([key, value]) => { + const resolved = (value as any).oneOf + ? resolveOneOfProperty(value as JsonSchemaOneOfProperty) + : value; return (
- {(value as any).type === "array" ? ( + {(resolved as any).type === "array" ? ( ) : ( )} @@ -89,6 +92,28 @@ interface JsonSchemaArrayProperty { readonly items: JsonSchemaScalarProperty; } +interface JsonSchemaOneOfProperty { + readonly oneOf: ReadonlyArray; + readonly description?: string; +} + +/** + * Resolve a `oneOf` schema to the most appropriate single schema for rendering. + * Prefers the array branch (if present) since it supports both single and multi-select. + */ +function resolveOneOfProperty( + prop: JsonSchemaOneOfProperty, +): JsonSchemaScalarProperty | JsonSchemaArrayProperty { + const arrayBranch = prop.oneOf.find( + (branch): branch is JsonSchemaArrayProperty => (branch as any).type === "array", + ); + if (arrayBranch) { + return { ...arrayBranch, description: arrayBranch.description ?? prop.description }; + } + const first = prop.oneOf[0] as JsonSchemaScalarProperty; + return { ...first, description: first.description ?? prop.description }; +} + type JsonSchemaArrayPropertyInputProps = Omit & { readonly prop: JsonSchemaArrayProperty; }; @@ -100,7 +125,9 @@ const JsonSchemaArrayPropertyInput: FunctionComponent { const itemsSchema = prop.items; - const value = emitterOptions[name] ?? itemsSchema.default; + const rawValue = emitterOptions[name] ?? itemsSchema.default; + // Normalize to array: handles cases where a oneOf-resolved property stored a single string + const value = Array.isArray(rawValue) ? rawValue : rawValue != null ? [rawValue] : []; const prettyName = useMemo( () => name[0].toUpperCase() + name.slice(1).replace(/-/g, " "), [name], diff --git a/packages/spec-dashboard/src/components/dashboard.tsx b/packages/spec-dashboard/src/components/dashboard.tsx index 2578b45ce11..7ab913f8924 100644 --- a/packages/spec-dashboard/src/components/dashboard.tsx +++ b/packages/spec-dashboard/src/components/dashboard.tsx @@ -5,7 +5,7 @@ import { useTierFiltering } from "../hooks/use-tier-filtering.js"; import { TierConfig } from "../utils/tier-filtering-utils.js"; import { DashboardTable } from "./dashboard-table.js"; import { InfoEntry, InfoReport } from "./info-table.js"; -import { TierFilterDropdown } from "./tier-filter.js"; +import { TierFilterTabs } from "./tier-filter.js"; export interface DashboardProps { coverageSummaries: CoverageSummary[]; @@ -24,11 +24,13 @@ export const Dashboard: FunctionComponent = ({ selectedTier, ); - const summaryTables = filteredSummaries.map((coverageSummary, i) => ( -
- -
- )); + const summaryTables = filteredSummaries + .filter((s) => !selectedTier || s.manifest.scenarios.length > 0) + .map((coverageSummary, i) => ( +
+ +
+ )); const specsCardTable = coverageSummaries.map((coverageSummary, i) => (
@@ -38,7 +40,7 @@ export const Dashboard: FunctionComponent = ({ return (
- = ({ +export const TierFilterTabs: FunctionComponent = ({ allTiers, selectedTier, setSelectedTier, @@ -19,39 +19,20 @@ export const TierFilterDropdown: FunctionComponent = ({ } return ( -
- Filter by Tier: - { - setSelectedTier(data.optionValue === "all" ? undefined : data.optionValue); +
+ { + setSelectedTier(data.value === "all" ? undefined : (data.value as string)); }} - css={{ minWidth: 150 }} > - + All tiers {allTiers.map((tier) => ( - + ))} - - {selectedTier && ( - - Showing {selectedTier} tier scenarios only - - )} +
); }; diff --git a/website/src/content/docs/docs/emitters/openapi3/reference/emitter.md b/website/src/content/docs/docs/emitters/openapi3/reference/emitter.md index d9b78f2f494..a15c4c7e603 100644 --- a/website/src/content/docs/docs/emitters/openapi3/reference/emitter.md +++ b/website/src/content/docs/docs/emitters/openapi3/reference/emitter.md @@ -38,9 +38,9 @@ See [Configuring output directory for more info](https://typespec.io/docs/handbo ### `file-type` -**Type:** `"yaml" | "json"` +**Type:** `string,array` -If the content should be serialized as YAML or JSON. Default 'yaml', it not specified infer from the `output-file` extension +If the content should be serialized as YAML or JSON. Can be a single value or an array to emit multiple formats. Default 'yaml', if not specified infer from the `output-file` extension ### `output-file` @@ -52,8 +52,10 @@ Output file will interpolate the following values: - service-name: Name of the service - service-name-if-multiple: Name of the service if multiple - version: Version of the service if multiple +- file-type: The file type being emitted (json or yaml). Useful when `file-type` is an array. Default: `{service-name-if-multiple}.{version}.openapi.yaml` or `.json` if `file-type` is `"json"` +When `file-type` is an array: `{service-name-if-multiple}.{version}.openapi.{file-type}` Example Single service no versioning diff --git a/website/src/content/docs/docs/handbook/cli.md b/website/src/content/docs/docs/handbook/cli.md index c68a7912be9..c6a56afd3c2 100644 --- a/website/src/content/docs/docs/handbook/cli.md +++ b/website/src/content/docs/docs/handbook/cli.md @@ -31,3 +31,15 @@ Options: s easier to read. [boolean] [default: true] --version Show version number [boolean] ``` + +## Environment Variables + +### `TYPESPEC_NPM_REGISTRY` + +Set the npm registry URL used by `tsp init` and `tsp install` when downloading the package manager and resolving package manifests. This is useful in corporate environments where a private npm registry is required. + +```bash +TYPESPEC_NPM_REGISTRY=https://my-corp-registry.example.com tsp init +``` + +If this variable is not set, TypeSpec defaults to `https://registry.npmjs.org`. From dd0b448cd2fdd385566819d3985372127b3a129c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Mar 2026 21:28:58 +0000 Subject: [PATCH 10/11] Merge branch 'main' into copilot/add-test-npm-registry-utils Co-authored-by: timotheeguerin <1031227+timotheeguerin@users.noreply.github.com> --- ...lot-add-test-npm-registry-utils-proxy-2026-03-04-13-19-05.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.chronus/changes/copilot-add-test-npm-registry-utils-proxy-2026-03-04-13-19-05.md b/.chronus/changes/copilot-add-test-npm-registry-utils-proxy-2026-03-04-13-19-05.md index 12e549c592f..ee447aee606 100644 --- a/.chronus/changes/copilot-add-test-npm-registry-utils-proxy-2026-03-04-13-19-05.md +++ b/.chronus/changes/copilot-add-test-npm-registry-utils-proxy-2026-03-04-13-19-05.md @@ -4,4 +4,4 @@ packages: - "@typespec/compiler" --- -Add e2e test to verify `fetchPackageManifest` from `npm-registry-utils` works through an HTTP proxy using Node.js 24's `--use-env-proxy` flag. +Add test to verify `fetchPackageManifest` from `npm-registry-utils` works through an HTTP proxy using Node.js 24's `--use-env-proxy` flag. From ab66eb63ed6a4e942c064f3451de9680d2c1d377 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Mar 2026 21:31:13 +0000 Subject: [PATCH 11/11] Fix changeset: remove test text from changeset description Co-authored-by: timotheeguerin <1031227+timotheeguerin@users.noreply.github.com> --- ...ilot-add-test-npm-registry-utils-proxy-2026-03-04-13-19-05.md | 1 - 1 file changed, 1 deletion(-) diff --git a/.chronus/changes/copilot-add-test-npm-registry-utils-proxy-2026-03-04-13-19-05.md b/.chronus/changes/copilot-add-test-npm-registry-utils-proxy-2026-03-04-13-19-05.md index d9274f179b3..ee447aee606 100644 --- a/.chronus/changes/copilot-add-test-npm-registry-utils-proxy-2026-03-04-13-19-05.md +++ b/.chronus/changes/copilot-add-test-npm-registry-utils-proxy-2026-03-04-13-19-05.md @@ -5,4 +5,3 @@ packages: --- Add test to verify `fetchPackageManifest` from `npm-registry-utils` works through an HTTP proxy using Node.js 24's `--use-env-proxy` flag. -test