diff --git a/.changeset/socks-proxy-support.md b/.changeset/socks-proxy-support.md new file mode 100644 index 00000000000..d2eb8dfa369 --- /dev/null +++ b/.changeset/socks-proxy-support.md @@ -0,0 +1,7 @@ +--- +"shadcn": minor +--- + +Add SOCKS4/SOCKS5 proxy support to the registry HTTP stack via `ALL_PROXY=socks5://...` (the curl convention), backed by the `socks` package. + +Proxy selection now goes through a `createProxyDispatcher(env)` factory that checks `ALL_PROXY` / `all_proxy` for a `socks*://` URL before falling back to the existing HTTP/HTTPS handling. `ALL_PROXY` with a non-SOCKS scheme is ignored here — `HTTP_PROXY` / `HTTPS_PROXY` remain the way to configure those. Existing `HTTPS_PROXY` / `HTTP_PROXY` / `NO_PROXY` handling via `undici.EnvHttpProxyAgent` is unchanged. diff --git a/packages/shadcn/package.json b/packages/shadcn/package.json index 8148b8e2885..3edf029350e 100644 --- a/packages/shadcn/package.json +++ b/packages/shadcn/package.json @@ -109,6 +109,7 @@ "postcss-selector-parser": "^7.1.0", "prompts": "^2.4.2", "recast": "^0.23.11", + "socks": "^2.8.8", "stringify-object": "^5.0.0", "tailwind-merge": "^3.0.1", "ts-morph": "^26.0.0", diff --git a/packages/shadcn/src/registry/proxy.integration.test.ts b/packages/shadcn/src/registry/proxy.integration.test.ts new file mode 100644 index 00000000000..180848da3d0 --- /dev/null +++ b/packages/shadcn/src/registry/proxy.integration.test.ts @@ -0,0 +1,289 @@ +import { createServer, type Server } from "http" +import { + connect as netConnect, + createServer as netCreateServer, + type AddressInfo, + type Server as NetServer, + type Socket, +} from "net" +import { fetch } from "undici" +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest" + +import { createProxyDispatcher } from "./proxy" + +const PROXY_ENV_VARS = [ + "HTTP_PROXY", + "HTTPS_PROXY", + "http_proxy", + "https_proxy", + "NO_PROXY", + "no_proxy", + "ALL_PROXY", + "all_proxy", +] as const + +let originServer: Server +let originUrl: string +let proxyServer: Server +let proxyUrl: string +let socksServer: NetServer +let socksUrl: string +let proxyHits: { url: string; method: string }[] = [] +let socksHits: { host: string; port: number }[] = [] +let savedEnv: Record = {} + +function listen(server: Server): Promise { + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + const { address, port } = server.address() as AddressInfo + resolve(`http://${address}:${port}`) + }) + }) +} + +function listenTcp(server: NetServer): Promise<{ host: string; port: number }> { + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + const { address, port } = server.address() as AddressInfo + resolve({ host: address, port }) + }) + }) +} + +function close(server: Server | NetServer): Promise { + return new Promise((resolve, reject) => { + server.close((err) => (err ? reject(err) : resolve())) + }) +} + +// Minimal SOCKS5 server (no auth) for integration tests. Implements the +// CONNECT command for IPv4 and domain destinations — enough to route an +// HTTP fetch through it. References RFC 1928. +function createSocksServer(): NetServer { + return netCreateServer((client: Socket) => { + let phase: "greeting" | "request" | "tunneling" = "greeting" + let buffer = Buffer.alloc(0) + + const onData = (chunk: Buffer) => { + buffer = Buffer.concat([buffer, chunk]) + + if (phase === "greeting") { + if (buffer.length < 2) return + const nMethods = buffer[1] + if (buffer.length < 2 + nMethods) return + // Always reply with no-auth (0x00). + client.write(Buffer.from([0x05, 0x00])) + buffer = buffer.subarray(2 + nMethods) + phase = "request" + } + + if (phase === "request") { + if (buffer.length < 4) return + const version = buffer[0] + const cmd = buffer[1] + const atyp = buffer[3] + if (version !== 0x05 || cmd !== 0x01) { + client.end(Buffer.from([0x05, 0x07, 0x00, 0x01, 0, 0, 0, 0, 0, 0])) + return + } + let host: string + let headerLen: number + if (atyp === 0x01) { + if (buffer.length < 10) return + host = `${buffer[4]}.${buffer[5]}.${buffer[6]}.${buffer[7]}` + headerLen = 10 + } else if (atyp === 0x03) { + if (buffer.length < 5) return + const dlen = buffer[4] + if (buffer.length < 5 + dlen + 2) return + host = buffer.subarray(5, 5 + dlen).toString("utf8") + headerLen = 5 + dlen + 2 + } else { + client.end(Buffer.from([0x05, 0x08, 0x00, 0x01, 0, 0, 0, 0, 0, 0])) + return + } + const port = buffer.readUInt16BE(headerLen - 2) + socksHits.push({ host, port }) + + const upstream = netConnect(port, host, () => { + // Reply: success, IPv4, 0.0.0.0:0 as bound address (acceptable per RFC). + client.write(Buffer.from([0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0])) + phase = "tunneling" + const remainder = buffer.subarray(headerLen) + if (remainder.length > 0) upstream.write(remainder) + buffer = Buffer.alloc(0) + upstream.pipe(client) + client.pipe(upstream) + }) + upstream.on("error", () => client.end()) + client.on("error", () => upstream.end()) + } + } + + client.on("data", onData) + client.on("error", () => {}) + }) +} + +beforeAll(async () => { + originServer = createServer((_req, res) => { + res.writeHead(200, { "Content-Type": "application/json" }) + res.end(JSON.stringify({ from: "origin" })) + }) + originUrl = await listen(originServer) + + // The test proxy is CONNECT-only — it tunnels TCP via the `connect` handler + // below. Direct HTTP requests (treating the proxy as an origin server) are + // not a path undici exercises, so we reject them with 502 rather than + // returning content that no test would assert on. + proxyServer = createServer((req, res) => { + proxyHits.push({ url: req.url ?? "", method: req.method ?? "" }) + res.writeHead(502, { Connection: "close" }) + res.end("Bad Gateway: this proxy only supports CONNECT tunneling") + }) + proxyServer.on("connect", (req, clientSocket, head) => { + proxyHits.push({ url: req.url ?? "", method: "CONNECT" }) + const [host, portStr] = (req.url ?? "").split(":") + const port = Number(portStr) + if (!host || !Number.isFinite(port)) { + clientSocket.end("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n") + return + } + const upstream = netConnect(port, host, () => { + clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n") + if (head.length > 0) upstream.write(head) + upstream.pipe(clientSocket) + clientSocket.pipe(upstream) + }) + upstream.on("error", () => clientSocket.end()) + clientSocket.on("error", () => upstream.end()) + }) + proxyUrl = await listen(proxyServer) + + socksServer = createSocksServer() + const socksAddr = await listenTcp(socksServer) + socksUrl = `socks5://${socksAddr.host}:${socksAddr.port}` + + for (const name of PROXY_ENV_VARS) { + savedEnv[name] = process.env[name] + delete process.env[name] + } +}) + +afterAll(async () => { + await close(originServer) + await close(proxyServer) + await close(socksServer) + for (const name of PROXY_ENV_VARS) { + if (savedEnv[name] === undefined) { + delete process.env[name] + } else { + process.env[name] = savedEnv[name] + } + } +}) + +afterEach(() => { + proxyHits = [] + socksHits = [] + for (const name of PROXY_ENV_VARS) { + delete process.env[name] + } +}) + +describe("test proxy server contract", () => { + // Documents the test proxy's intended behavior so future edits to the + // request handler don't silently introduce a code path that no test + // exercises. The proxy implements CONNECT tunneling only — direct HTTP + // requests to it (treating it as an origin server) are not supported. + it("responds 502 to direct (non-CONNECT) HTTP requests", async () => { + const response = await fetch(`${proxyUrl}/anything`) + expect(response.status).toBe(502) + }) +}) + +describe("proxy dispatcher integration", () => { + it("makes direct requests when no proxy env is set", async () => { + const dispatcher = createProxyDispatcher() + expect(dispatcher).toBeUndefined() + + const response = await fetch(`${originUrl}/test.json`) + const body = (await response.json()) as { from: string } + expect(body.from).toBe("origin") + expect(proxyHits).toHaveLength(0) + }) + + it.each(["HTTP_PROXY", "http_proxy"] as const)( + "routes HTTP requests through the proxy via CONNECT tunnel when %s is set", + async (name) => { + process.env[name] = proxyUrl + const dispatcher = createProxyDispatcher() + + const response = await fetch(`${originUrl}/test.json`, { dispatcher }) + const body = (await response.json()) as { from: string } + // Response body comes from origin (proxy just tunneled the bytes). + expect(body.from).toBe("origin") + // Proof the proxy was used: a CONNECT was recorded against the origin. + const originHost = new URL(originUrl).host + expect(proxyHits).toEqual([{ url: originHost, method: "CONNECT" }]) + } + ) + + it.each(["NO_PROXY", "no_proxy"] as const)( + "bypasses the proxy when %s matches the destination host", + async (noProxyName) => { + process.env.HTTP_PROXY = proxyUrl + process.env[noProxyName] = "127.0.0.1" + const dispatcher = createProxyDispatcher() + + const response = await fetch(`${originUrl}/test.json`, { dispatcher }) + const body = (await response.json()) as { from: string } + expect(body.from).toBe("origin") + expect(proxyHits).toHaveLength(0) + } + ) + + it("routes through the proxy when destination is not in NO_PROXY list", async () => { + process.env.HTTP_PROXY = proxyUrl + process.env.NO_PROXY = "example.com" + const dispatcher = createProxyDispatcher() + + const response = await fetch(`${originUrl}/test.json`, { dispatcher }) + const body = (await response.json()) as { from: string } + expect(body.from).toBe("origin") + const originHost = new URL(originUrl).host + expect(proxyHits).toEqual([{ url: originHost, method: "CONNECT" }]) + }) + + describe("SOCKS via ALL_PROXY", () => { + it.each(["ALL_PROXY", "all_proxy"] as const)( + "routes HTTP requests through the SOCKS5 proxy when %s=socks5://...", + async (name) => { + process.env[name] = socksUrl + const dispatcher = createProxyDispatcher() + + const response = await fetch(`${originUrl}/test.json`, { dispatcher }) + const body = (await response.json()) as { from: string } + expect(body.from).toBe("origin") + // SOCKS server saw a CONNECT to the origin host:port. + const originAddr = new URL(originUrl) + expect(socksHits).toEqual([ + { host: originAddr.hostname, port: Number(originAddr.port) }, + ]) + // HTTP proxy was not involved. + expect(proxyHits).toHaveLength(0) + } + ) + + it("does not invoke SOCKS when ALL_PROXY scheme is http (falls through to direct)", async () => { + process.env.ALL_PROXY = `http://127.0.0.1:1` + const dispatcher = createProxyDispatcher() + expect(dispatcher).toBeUndefined() + + const response = await fetch(`${originUrl}/test.json`) + const body = (await response.json()) as { from: string } + expect(body.from).toBe("origin") + expect(socksHits).toHaveLength(0) + }) + }) +}) diff --git a/packages/shadcn/src/registry/proxy.test.ts b/packages/shadcn/src/registry/proxy.test.ts index cfea7f33d9d..19fcc541a21 100644 --- a/packages/shadcn/src/registry/proxy.test.ts +++ b/packages/shadcn/src/registry/proxy.test.ts @@ -1,12 +1,104 @@ +import { Agent, EnvHttpProxyAgent } from "undici" import { afterEach, describe, expect, it, vi } from "vitest" -import { fetchWithProxy } from "./proxy" +import { createProxyDispatcher, fetchWithProxy } from "./proxy" afterEach(() => { vi.unstubAllGlobals() vi.restoreAllMocks() }) +describe("createProxyDispatcher", () => { + it("returns undefined when no proxy env vars are set", () => { + expect(createProxyDispatcher({})).toBeUndefined() + }) + + it("returns undefined when only no_proxy is set (no proxy to bypass)", () => { + expect(createProxyDispatcher({ no_proxy: "*" })).toBeUndefined() + expect(createProxyDispatcher({ NO_PROXY: "*" })).toBeUndefined() + }) + + it.each([ + ["https_proxy", "http://proxy.example.com:8080"], + ["HTTPS_PROXY", "http://proxy.example.com:8080"], + ["http_proxy", "http://proxy.example.com:8080"], + ["HTTP_PROXY", "http://proxy.example.com:8080"], + ])("returns an EnvHttpProxyAgent when %s is set", (name, value) => { + const dispatcher = createProxyDispatcher({ [name]: value }) + expect(dispatcher).toBeInstanceOf(EnvHttpProxyAgent) + }) + + it("ignores empty proxy env var values", () => { + expect(createProxyDispatcher({ HTTPS_PROXY: "" })).toBeUndefined() + expect(createProxyDispatcher({ https_proxy: "" })).toBeUndefined() + expect(createProxyDispatcher({ HTTP_PROXY: "" })).toBeUndefined() + expect(createProxyDispatcher({ http_proxy: "" })).toBeUndefined() + }) + + it("returns an EnvHttpProxyAgent when multiple proxy vars are set", () => { + const dispatcher = createProxyDispatcher({ + HTTPS_PROXY: "http://proxy.example.com:8080", + HTTP_PROXY: "http://proxy.example.com:8080", + NO_PROXY: "localhost", + }) + expect(dispatcher).toBeInstanceOf(EnvHttpProxyAgent) + }) + + it("defaults to process.env when no env argument is passed", () => { + // Behavior with the real process.env depends on the host; just assert it + // returns either undefined or a Dispatcher, never throws. + const dispatcher = createProxyDispatcher() + if (dispatcher !== undefined) { + expect(dispatcher).toBeDefined() + } + }) + + describe("SOCKS via ALL_PROXY", () => { + it.each([ + ["ALL_PROXY", "socks5://proxy.example.com:1080"], + ["all_proxy", "socks5://proxy.example.com:1080"], + ["ALL_PROXY", "socks4://proxy.example.com:1080"], + ["ALL_PROXY", "socks://proxy.example.com:1080"], + ["ALL_PROXY", "socks5h://proxy.example.com:1080"], + ])( + "returns a SOCKS-routed Agent (not EnvHttpProxyAgent) when %s=%s", + (name, value) => { + const dispatcher = createProxyDispatcher({ [name]: value }) + expect(dispatcher).toBeInstanceOf(Agent) + expect(dispatcher).not.toBeInstanceOf(EnvHttpProxyAgent) + } + ) + + it("does NOT trigger SOCKS path when ALL_PROXY scheme is http", () => { + const dispatcher = createProxyDispatcher({ + ALL_PROXY: "http://proxy.example.com:8080", + }) + // ALL_PROXY=http://... is unhandled; user should use HTTP_PROXY explicitly. + expect(dispatcher).toBeUndefined() + }) + + it("ignores empty ALL_PROXY values", () => { + expect(createProxyDispatcher({ ALL_PROXY: "" })).toBeUndefined() + expect(createProxyDispatcher({ all_proxy: "" })).toBeUndefined() + }) + + it("ignores ALL_PROXY values that don't parse as URLs", () => { + expect(createProxyDispatcher({ ALL_PROXY: "not a url" })).toBeUndefined() + }) + }) + + describe("priority ordering", () => { + it("prefers SOCKS over HTTP when both are set", () => { + const dispatcher = createProxyDispatcher({ + ALL_PROXY: "socks5://socks.example.com:1080", + HTTPS_PROXY: "http://proxy.example.com:8080", + }) + expect(dispatcher).toBeInstanceOf(Agent) + expect(dispatcher).not.toBeInstanceOf(EnvHttpProxyAgent) + }) + }) +}) + function redirectResponse(status: number, location: string) { return new Response(null, { status, headers: { location } }) } diff --git a/packages/shadcn/src/registry/proxy.ts b/packages/shadcn/src/registry/proxy.ts index 289107a4e08..95a8be37599 100644 --- a/packages/shadcn/src/registry/proxy.ts +++ b/packages/shadcn/src/registry/proxy.ts @@ -1,15 +1,85 @@ -import { EnvHttpProxyAgent } from "undici" +import { SocksClient, type SocksProxy } from "socks" +import { Agent, Dispatcher, EnvHttpProxyAgent } from "undici" + +const HTTP_PROXY_ENV_VARS = [ + "HTTPS_PROXY", + "https_proxy", + "HTTP_PROXY", + "http_proxy", +] as const + +const ALL_PROXY_ENV_VARS = ["ALL_PROXY", "all_proxy"] as const + +const SOCKS_VERSION_BY_SCHEME: Record = { + "socks:": 5, + "socks4:": 4, + "socks4a:": 4, + "socks5:": 5, + "socks5h:": 5, +} + +function parseSocksUrl(value: string): SocksProxy | undefined { + let url: URL + try { + url = new URL(value) + } catch { + return undefined + } + const type = SOCKS_VERSION_BY_SCHEME[url.protocol] + if (!type) return undefined + const port = Number(url.port) || 1080 + return { + host: url.hostname, + port, + type, + ...(url.username + ? { userId: decodeURIComponent(url.username) } + : undefined), + ...(url.password + ? { password: decodeURIComponent(url.password) } + : undefined), + } +} + +function createSocksAgent(proxy: SocksProxy): Agent { + return new Agent({ + connect: (opts, callback) => { + SocksClient.createConnection({ + proxy, + command: "connect", + destination: { + host: opts.hostname ?? "", + port: Number(opts.port), + }, + }) + .then(({ socket }) => callback(null, socket)) + .catch((err: Error) => callback(err, null)) + }, + }) +} + +export function createProxyDispatcher( + env: NodeJS.ProcessEnv = process.env +): Dispatcher | undefined { + // SOCKS via ALL_PROXY (curl convention). Only triggers for socks* schemes; + // other schemes (http/https) are unhandled here — users configure those via + // HTTP_PROXY / HTTPS_PROXY explicitly. + for (const name of ALL_PROXY_ENV_VARS) { + const value = env[name] + if (!value) continue + const socks = parseSocksUrl(value) + if (socks) return createSocksAgent(socks) + } + + // HTTP/HTTPS proxy. EnvHttpProxyAgent honors http_proxy, https_proxy and + // no_proxy (upper and lowercase). + const hasHttpProxy = HTTP_PROXY_ENV_VARS.some((name) => env[name]) + return hasHttpProxy ? new EnvHttpProxyAgent() : undefined +} // Native fetch ignores the http.Agent-based `agent` option, so proxy support -// goes through an undici dispatcher instead. EnvHttpProxyAgent honors -// http_proxy, https_proxy and no_proxy (upper and lowercase). -const proxyDispatcher = - process.env.https_proxy || - process.env.HTTPS_PROXY || - process.env.http_proxy || - process.env.HTTP_PROXY - ? new EnvHttpProxyAgent() - : undefined +// goes through an undici dispatcher instead. +const proxyDispatcher = createProxyDispatcher() // Standard fetch strips Authorization/Cookie/Proxy-Authorization on // cross-origin redirects, but preserves custom-named headers. Since private @@ -71,6 +141,10 @@ async function fetchOnce( // missing from the ambient RequestInit type, hence the cast. Redirects are // followed manually (see fetchWithProxy) so headers can be re-scoped per // hop, hence `redirect: "manual"`. + // + // This must stay the global `fetch` binding: MSW's Node adapter patches + // `globalThis.fetch`, so importing `fetch` from undici here would bypass + // MSW's interceptor and break the registry tests. return await fetch(url, { ...init, headers, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 903d18877bf..78f4ac07a26 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -554,6 +554,9 @@ importers: recast: specifier: ^0.23.11 version: 0.23.11 + socks: + specifier: ^2.8.8 + version: 2.8.9 stringify-object: specifier: ^5.0.0 version: 5.0.0 @@ -6710,8 +6713,8 @@ packages: resolution: {integrity: sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==} engines: {node: '>= 12'} - ip-address@9.0.5: - resolution: {integrity: sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==} + ip-address@10.4.0: + resolution: {integrity: sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==} engines: {node: '>= 12'} ipaddr.js@1.9.1: @@ -7013,9 +7016,6 @@ packages: resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} hasBin: true - jsbn@1.1.0: - resolution: {integrity: sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==} - jsdom@28.1.0: resolution: {integrity: sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -8787,8 +8787,8 @@ packages: resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==} engines: {node: '>= 14'} - socks@2.8.6: - resolution: {integrity: sha512-pe4Y2yzru68lXCb38aAqRf5gvN8YdjP1lok5o0J7BOHljkyCGKVz7H3vpVIXKD27rj2giOJ7DwVyk/GWrPHDWA==} + socks@2.8.9: + resolution: {integrity: sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==} engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} sonner@2.0.7: @@ -8836,9 +8836,6 @@ packages: sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} - sprintf-js@1.1.3: - resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} - stable-hash@0.0.5: resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} @@ -16276,10 +16273,7 @@ snapshots: ip-address@10.0.1: {} - ip-address@9.0.5: - dependencies: - jsbn: 1.1.0 - sprintf-js: 1.1.3 + ip-address@10.4.0: {} ipaddr.js@1.9.1: {} @@ -16539,8 +16533,6 @@ snapshots: dependencies: argparse: 2.0.1 - jsbn@1.1.0: {} - jsdom@28.1.0(@noble/hashes@1.8.0): dependencies: '@acemir/cssom': 0.9.31 @@ -18766,13 +18758,13 @@ snapshots: dependencies: agent-base: 7.1.4 debug: 4.4.3 - socks: 2.8.6 + socks: 2.8.9 transitivePeerDependencies: - supports-color - socks@2.8.6: + socks@2.8.9: dependencies: - ip-address: 9.0.5 + ip-address: 10.4.0 smart-buffer: 4.2.0 sonner@2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3): @@ -18812,8 +18804,6 @@ snapshots: sprintf-js@1.0.3: {} - sprintf-js@1.1.3: {} - stable-hash@0.0.5: {} stackback@0.0.2: {}