diff --git a/.changeset/migrate-base-color.md b/.changeset/migrate-base-color.md deleted file mode 100644 index 5686af0ced1..00000000000 --- a/.changeset/migrate-base-color.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"shadcn": minor ---- - -add `npx shadcn migrate base-color` to switch a project's base color. diff --git a/apps/v4/content/docs/registry/github.mdx b/apps/v4/content/docs/registry/github.mdx index cc46e34bd5f..570c52e24ce 100644 --- a/apps/v4/content/docs/registry/github.mdx +++ b/apps/v4/content/docs/registry/github.mdx @@ -1,9 +1,9 @@ --- title: GitHub Registries -description: Use a public GitHub repository as a registry. +description: Use a GitHub repository as a registry. --- -You can now turn **any public GitHub repository into a registry.** +You can now turn **any GitHub repository into a registry.** Add a `registry.json` file to the root of the repo, describe the files you want to share, and users can install them with the `shadcn` CLI. @@ -95,29 +95,33 @@ workflows, rules or project conventions. Use a GitHub registry when: -- You already have reusable code in a public GitHub repository. +- You already have reusable code in a GitHub repository. - You want users to install directly from `owner/repo/item`. - You want to distribute config files, rules, docs, templates, utilities or any other files from the same repository. -- You do not need private repo access or custom request authentication. +- You do not need a custom registry server or request authentication. ## Requirements A GitHub registry must: -- Be a public `github.com` repository. +- Be a `github.com` repository. - Have a `registry.json` file at the repository root. - Use valid `registry.json` and `registry-item.json` schemas. - Reference source files that exist in the repository. -Private repositories and GitHub Enterprise hosts are not currently supported by -GitHub addresses. For private or authenticated registries, use a +Public repositories work with zero configuration. Private repositories work +with GitHub credentials. See +[Private repositories](#private-repositories). + +GitHub Enterprise hosts are not supported by GitHub addresses. For custom +registry servers with request authentication, use a [namespace](/docs/registry/namespace) with [authentication](/docs/registry/authentication). ## Step 1: Add registry.json -Given an existing public repository: +Given an existing repository: ```txt . @@ -595,9 +599,83 @@ The CLI uses Git to resolve branches, tags and short refs into a commit SHA before reading files. Full 40-character commit SHAs are used directly and do not require Git. +## Private repositories + +Private `github.com` repositories work as registries too. You do not set up a +server or configure anything in the registry itself. If you can read the +repository, the CLI can install from it. + +### Use the GitHub CLI + +For local development, authenticate with the GitHub CLI once: + +```bash +gh auth login +``` + +Then install from the private repository like any other GitHub registry. + +```bash +npx shadcn@latest add acme/private-toolkit/project-conventions +``` + +When a repository is not publicly readable, the CLI reads it through `gh` +using your stored credentials. The token stays inside the GitHub CLI. It never +enters the shadcn process. + +The first time a command uses your credentials, it prints a notice: + +```txt +✔ Using gh credentials. +``` + +### Use a token in CI + +Where the GitHub CLI is not installed, set `GH_TOKEN` or `GITHUB_TOKEN`: + +```bash +GH_TOKEN=github_pat_xxx npx shadcn@latest add acme/private-toolkit/project-conventions +``` + +- `GH_TOKEN` takes precedence over `GITHUB_TOKEN`. +- Use a fine-grained personal access token scoped to the repository, with + **Contents: Read-only** access. This is the recommended credential. +- When a token is set, it is used instead of the GitHub CLI and is only ever + sent to `api.github.com`. + + + In GitHub Actions, the built-in `GITHUB_TOKEN` can generally only read the + repository that owns the workflow. To install from a private registry in + another repository, use a fine-grained personal access token or a GitHub App + installation token. + + +### How it works + +- Public repositories are always read anonymously. No credentials are used and + the GitHub CLI is never invoked. +- The CLI tries anonymous access first. It only uses your credentials when the + repository's root `registry.json` is not publicly readable. +- Ref resolution runs `git ls-remote` first. Git may already use a credential + helper you have configured, for example one installed by `gh auth setup-git`. +- Private files are read through GitHub's Contents API, pinned to the resolved + commit SHA. +- GitHub returns the same not-found response for private and missing + repositories. If your credentials cannot read the repository either, the CLI + cannot tell you which case it was. + +### Limits + +- Registry source files are limited to 5 MiB per file. +- GitHub Enterprise hosts are not supported. GitHub addresses always resolve + against `github.com`. +- Avoid symlinks in registry source files. Anonymous reads return the symlink + target path as text, while authenticated reads through the Contents API + return the target file's content. + ## Review before installing -GitHub registry items install code and project files from public repositories. +GitHub registry items install code and project files from GitHub repositories. Treat a GitHub item address like any other third-party code dependency. Before installing from a source you do not control: diff --git a/apps/v4/package.json b/apps/v4/package.json index a9ddc18a033..a5b912e56c0 100644 --- a/apps/v4/package.json +++ b/apps/v4/package.json @@ -90,7 +90,7 @@ "rehype-pretty-code": "^0.14.1", "rimraf": "^6.0.1", "server-only": "^0.0.1", - "shadcn": "4.18.0", + "shadcn": "4.19.0", "shiki": "^3.23.0", "sonner": "^2.0.0", "streamdown": "^2.5.0", diff --git a/packages/shadcn/CHANGELOG.md b/packages/shadcn/CHANGELOG.md index a252b34becf..d7b57f15504 100644 --- a/packages/shadcn/CHANGELOG.md +++ b/packages/shadcn/CHANGELOG.md @@ -1,5 +1,13 @@ # shadcn +## 4.19.0 + +### Minor Changes + +- [#11582](https://github.com/shadcn-ui/ui/pull/11582) [`33c81f991f1013653444d6819107b2b356563f57`](https://github.com/shadcn-ui/ui/commit/33c81f991f1013653444d6819107b2b356563f57) Thanks [@shadcn](https://github.com/shadcn)! - add private repository support to GitHub registries via GitHub CLI credentials or GH_TOKEN. + +- [#11248](https://github.com/shadcn-ui/ui/pull/11248) [`b4f2023b1d5c733db67d4e90eb9485a95c5ed480`](https://github.com/shadcn-ui/ui/commit/b4f2023b1d5c733db67d4e90eb9485a95c5ed480) Thanks [@rbadillap](https://github.com/rbadillap)! - add `npx shadcn migrate base-color` to switch a project's base color. + ## 4.18.0 ### Minor Changes diff --git a/packages/shadcn/package.json b/packages/shadcn/package.json index 6256c914abd..abe84962765 100644 --- a/packages/shadcn/package.json +++ b/packages/shadcn/package.json @@ -1,6 +1,6 @@ { "name": "shadcn", - "version": "4.18.0", + "version": "4.19.0", "description": "Add components to your apps.", "publishConfig": { "access": "public" diff --git a/packages/shadcn/src/mcp/index.ts b/packages/shadcn/src/mcp/index.ts index 549aa06a87a..66f2aa1336a 100644 --- a/packages/shadcn/src/mcp/index.ts +++ b/packages/shadcn/src/mcp/index.ts @@ -1,4 +1,5 @@ import { getRegistryItems, searchRegistries } from "@/src/registry" +import { withRegistryContext } from "@/src/registry/context" import { RegistryError } from "@/src/registry/errors" import { resolveSearchRegistries, @@ -30,12 +31,27 @@ export const server = new Server( }, { capabilities: { + logging: {}, resources: {}, tools: {}, }, } ) +// GitHub authentication notices must reach the MCP client before the first +// authenticated request. Stdout carries the protocol, so the console is not a +// usable surface here. +async function onGitHubAuthNotice(message: string) { + try { + await server.sendLoggingMessage({ + level: "info", + data: message, + }) + } catch { + console.error(message) + } +} + server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ @@ -170,7 +186,13 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { } }) -server.setRequestHandler(CallToolRequestSchema, async (request) => { +server.setRequestHandler(CallToolRequestSchema, async (request) => + withRegistryContext(() => handleCallTool(request), { onGitHubAuthNotice }) +) + +async function handleCallTool(request: { + params: { name: string; arguments?: Record } +}) { try { if (!request.params.arguments) { throw new Error("No tool arguments provided.") @@ -573,4 +595,4 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { isError: true, } } -}) +} diff --git a/packages/shadcn/src/registry/context.ts b/packages/shadcn/src/registry/context.ts index 7e861a197e2..5087256044d 100644 --- a/packages/shadcn/src/registry/context.ts +++ b/packages/shadcn/src/registry/context.ts @@ -3,6 +3,7 @@ import { AsyncLocalStorage } from "async_hooks" interface RegistryContext { headers: Record> env?: NodeJS.ProcessEnv + onGitHubAuthNotice?: (message: string) => void | Promise } const registryContext = new AsyncLocalStorage() @@ -14,6 +15,7 @@ export function withRegistryContext( callback: () => T, options: { env?: NodeJS.ProcessEnv + onGitHubAuthNotice?: (message: string) => void | Promise } = {} ): T { const parentContext = registryContext.getStore() @@ -22,6 +24,8 @@ export function withRegistryContext( { headers: {}, env: options.env ?? parentContext?.env, + onGitHubAuthNotice: + options.onGitHubAuthNotice ?? parentContext?.onGitHubAuthNotice, }, callback ) @@ -50,6 +54,10 @@ export function getRegistryEnvFromContext(key: string): string | undefined { return context?.env ? context.env[key] : process.env[key] } +export function getGitHubAuthNoticeFromContext() { + return registryContext.getStore()?.onGitHubAuthNotice +} + export function clearRegistryContext() { const context = registryContext.getStore() ?? fallbackContext diff --git a/packages/shadcn/src/registry/github-auth.ts b/packages/shadcn/src/registry/github-auth.ts new file mode 100644 index 00000000000..7fb9565611b --- /dev/null +++ b/packages/shadcn/src/registry/github-auth.ts @@ -0,0 +1,94 @@ +import { getGitHubAuthNoticeFromContext } from "@/src/registry/context" +import { + getEnvGitHubToken, + type GitHubAuthMode, +} from "@/src/registry/github-cli" +import type { GitHubSource } from "@/src/registry/github-ref" +import { logAboveSpinner } from "@/src/utils/spinner" +import { gray, green } from "kleur/colors" + +export type GitHubSourceAuthState = { + // Single-flight mode selection shared by ref resolution and content reads. + decision?: Promise + // Set once the anonymous root registry.json succeeded. A locked source + // never sends credentials, so a missing child file stays anonymous. + anonymousLock: boolean + // The pre-auth failure, preserved so an authenticated 404 keeps GitHub's + // private-versus-missing ambiguity. + originalError?: unknown +} + +// Auth state is anchored on the command-local sourceCache object, which the +// resolver already creates once and threads through concurrent item fetches +// and recursive dependency resolution. +const coordinators = new WeakMap>() + +// A command can make several top-level registry calls (preflight, catalog, +// tree resolution), each with its own sourceCache. The notice dedupes +// process-wide per credential mode so it prints once, not once per phase. +const notifiedSources = new Set() + +export function resetGitHubAuthNotices() { + notifiedSources.clear() +} + +export function getGitHubAuthState(anchor: object, source: GitHubSource) { + let sources = coordinators.get(anchor) + if (!sources) { + sources = new Map() + coordinators.set(anchor, sources) + } + + const key = normalizeGitHubSourceKey(source) + let state = sources.get(key) + if (!state) { + state = { anonymousLock: false } + sources.set(key, state) + } + + return state +} + +export function selectGitHubAuthMode( + state: GitHubSourceAuthState, + source: GitHubSource, + originalError: unknown +) { + if (!state.decision) { + state.originalError = originalError + state.decision = decideAndNotify().catch((error) => { + state.decision = undefined + throw error + }) + } + + return state.decision +} + +async function decideAndNotify() { + const mode: GitHubAuthMode = getEnvGitHubToken() ? "token" : "gh" + + if (notifiedSources.has(mode)) { + return mode + } + + // The notice is awaited so it lands before the first authenticated request. + const notice = `Using ${mode === "token" ? "GH_TOKEN" : "gh"} credentials.` + const onNotice = getGitHubAuthNoticeFromContext() + if (onNotice) { + await onNotice(notice) + } else { + // Match ora's persisted-line style so the notice aligns with the + // surrounding spinner output. + logAboveSpinner(`${green("✔")} ${gray(notice)}`) + } + notifiedSources.add(mode) + + return mode +} + +function normalizeGitHubSourceKey(source: GitHubSource) { + return `${source.owner.toLowerCase()}/${source.repo.toLowerCase()}#${ + source.ref ?? "HEAD" + }` +} diff --git a/packages/shadcn/src/registry/github-cli.integration.test.ts b/packages/shadcn/src/registry/github-cli.integration.test.ts new file mode 100644 index 00000000000..82a4811e089 --- /dev/null +++ b/packages/shadcn/src/registry/github-cli.integration.test.ts @@ -0,0 +1,90 @@ +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "fs" +import { tmpdir } from "os" +import path from "path" +import { afterEach, describe, expect, it, vi } from "vitest" + +import { fetchGitHubFileViaGh, GitHubTransportError } from "./github-cli" + +const ADDRESS = { owner: "acme", repo: "ui" } +const SHA = "1111111111111111111111111111111111111111" + +function createFakeGh(script: string) { + const dir = mkdtempSync(path.join(tmpdir(), "shadcn-fake-gh-")) + const binPath = path.join(dir, "gh") + writeFileSync(binPath, `#!/bin/sh\n${script}\n`) + chmodSync(binPath, 0o755) + return dir +} + +describe("gh executor with a real subprocess", () => { + const tempDirs: string[] = [] + + afterEach(() => { + vi.unstubAllEnvs() + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it("returns fake-gh stdout as file content", async () => { + const dir = createFakeGh(`printf 'export function Button() {}'`) + tempDirs.push(dir) + vi.stubEnv("PATH", dir) + + await expect( + fetchGitHubFileViaGh(ADDRESS, SHA, "button.tsx") + ).resolves.toBe("export function Button() {}") + }) + + it("classifies a real nonzero exit with an HTTP status from stderr", async () => { + const secret = "ghp_secret_abcdef123456" + const dir = createFakeGh( + `echo "gh: ${secret} Not Found (HTTP 404)" >&2\nexit 1` + ) + tempDirs.push(dir) + vi.stubEnv("PATH", dir) + + const error = await fetchGitHubFileViaGh(ADDRESS, SHA, "button.tsx").catch( + (caught) => caught + ) + + expect(error).toBeInstanceOf(GitHubTransportError) + expect(error.kind).toBe("http") + expect(error.statusCode).toBe(404) + // Real execa errors embed stderr in their message; the sanitized + // classification must not. + const rendered = JSON.stringify({ + message: error.message, + stack: error.stack, + ...error, + }) + expect(rendered).not.toContain(secret) + }) + + it("never leaks partial stdout from a failed subprocess", async () => { + const dir = createFakeGh( + `printf 'partial private source content'\necho "gh: boom" >&2\nexit 1` + ) + tempDirs.push(dir) + vi.stubEnv("PATH", dir) + + const error = await fetchGitHubFileViaGh(ADDRESS, SHA, "button.tsx").catch( + (caught) => caught + ) + + expect(error).toBeInstanceOf(GitHubTransportError) + expect( + JSON.stringify({ message: error.message, stack: error.stack, ...error }) + ).not.toContain("partial private source") + }) + + it("classifies a missing gh binary from a real spawn failure", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "shadcn-empty-path-")) + tempDirs.push(dir) + vi.stubEnv("PATH", dir) + + await expect( + fetchGitHubFileViaGh(ADDRESS, SHA, "button.tsx") + ).rejects.toMatchObject({ kind: "enoent" }) + }) +}) diff --git a/packages/shadcn/src/registry/github-cli.test.ts b/packages/shadcn/src/registry/github-cli.test.ts new file mode 100644 index 00000000000..b16e6ad4fd9 --- /dev/null +++ b/packages/shadcn/src/registry/github-cli.test.ts @@ -0,0 +1,541 @@ +import { execa } from "execa" +import { http, HttpResponse } from "msw" +import { setupServer } from "msw/node" +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest" + +import { withRegistryContext } from "./context" +import { + encodeGitHubPath, + fetchGitHubFileViaGh, + fetchGitHubFileViaRest, + getEnvGitHubToken, + GitHubTransportError, + readGitHubResponseTextWithLimit, + resolveGitHubRefViaAuth, +} from "./github-cli" + +vi.mock("execa", () => ({ + execa: vi.fn(), +})) + +const server = setupServer() +const ADDRESS = { owner: "acme", repo: "ui" } +const SHA = "1111111111111111111111111111111111111111" +const BRANCH_SHA = "2222222222222222222222222222222222222222" +const TAG_SHA = "3333333333333333333333333333333333333333" + +describe("github-cli", () => { + beforeAll(() => { + server.listen({ onUnhandledRequest: "error" }) + }) + + beforeEach(() => { + vi.stubEnv("GH_TOKEN", "") + vi.stubEnv("GITHUB_TOKEN", "") + vi.mocked(execa).mockReset() + }) + + afterEach(() => { + server.resetHandlers() + vi.unstubAllEnvs() + }) + + afterAll(() => { + server.close() + }) + + describe("getEnvGitHubToken", () => { + it("returns null when no token is configured", () => { + expect(getEnvGitHubToken()).toBeNull() + }) + + it("prefers GH_TOKEN over GITHUB_TOKEN", () => { + vi.stubEnv("GH_TOKEN", "gh-token") + vi.stubEnv("GITHUB_TOKEN", "github-token") + + expect(getEnvGitHubToken()).toBe("gh-token") + }) + + it("trims token values", () => { + vi.stubEnv("GH_TOKEN", " padded-token ") + + expect(getEnvGitHubToken()).toBe("padded-token") + }) + + it("falls back to a valid second variable when the first is unsafe", () => { + vi.stubEnv("GH_TOKEN", "bad token with spaces") + vi.stubEnv("GITHUB_TOKEN", "good-token") + + expect(getEnvGitHubToken()).toBe("good-token") + }) + + it("rejects tokens with control characters", () => { + vi.stubEnv("GH_TOKEN", "bad\ntoken") + + expect(getEnvGitHubToken()).toBeNull() + }) + + it("reads through the scoped registry context env", () => { + vi.stubEnv("GH_TOKEN", "process-token") + + const token = withRegistryContext(() => getEnvGitHubToken(), { + env: { GH_TOKEN: "context-token" }, + }) + + expect(token).toBe("context-token") + }) + + it("does not fall back to process.env when a context env is set", () => { + vi.stubEnv("GH_TOKEN", "process-token") + + const token = withRegistryContext(() => getEnvGitHubToken(), { + env: {}, + }) + + expect(token).toBeNull() + }) + }) + + describe("fetchGitHubFileViaGh", () => { + it("invokes gh with pinned hostname, fixed headers, and a hermetic env", async () => { + vi.mocked(execa).mockResolvedValueOnce({ stdout: "file content" } as any) + + await expect( + fetchGitHubFileViaGh(ADDRESS, SHA, "components/ui/button.tsx") + ).resolves.toBe("file content") + + expect(vi.mocked(execa)).toHaveBeenCalledWith( + "gh", + [ + "api", + "--hostname", + "github.com", + `repos/acme/ui/contents/components/ui/button.tsx?ref=${SHA}`, + "-H", + "Accept: application/vnd.github.raw+json", + "-H", + "X-GitHub-Api-Version: 2022-11-28", + ], + expect.objectContaining({ + extendEnv: false, + timeout: 15_000, + stripFinalNewline: false, + }) + ) + + const env = (vi.mocked(execa).mock.calls[0] as any[])[2] + .env as NodeJS.ProcessEnv + expect(env.GH_HOST).toBe("github.com") + expect(env.GH_PROMPT_DISABLED).toBe("1") + expect(env.GH_NO_UPDATE_NOTIFIER).toBe("1") + expect(env.NO_COLOR).toBe("1") + expect(env).not.toHaveProperty("GH_TOKEN") + expect(env).not.toHaveProperty("GITHUB_TOKEN") + expect(env).not.toHaveProperty("GH_ENTERPRISE_TOKEN") + expect(env).not.toHaveProperty("GITHUB_ENTERPRISE_TOKEN") + expect(env).not.toHaveProperty("GH_DEBUG") + expect(env).not.toHaveProperty("DEBUG") + expect(env).not.toHaveProperty("GH_FORCE_TTY") + expect(env).not.toHaveProperty("GH_TELEMETRY") + }) + + it("scrubs inherited gh env vars even when set in the parent", async () => { + vi.stubEnv("GH_HOST", "github.enterprise.example") + vi.stubEnv("GH_TOKEN", "inherited-token") + vi.stubEnv("GH_DEBUG", "api") + vi.mocked(execa).mockResolvedValueOnce({ stdout: "ok" } as any) + + await fetchGitHubFileViaGh(ADDRESS, SHA, "button.tsx") + + const env = (vi.mocked(execa).mock.calls[0] as any[])[2] + .env as NodeJS.ProcessEnv + expect(env.GH_HOST).toBe("github.com") + expect(env).not.toHaveProperty("GH_TOKEN") + expect(env).not.toHaveProperty("GH_DEBUG") + }) + + it("encodes path segments without encoding separators", async () => { + vi.mocked(execa).mockResolvedValueOnce({ stdout: "ok" } as any) + + await fetchGitHubFileViaGh(ADDRESS, SHA, "dir with space/a?b.tsx") + + expect(vi.mocked(execa).mock.calls[0]![1]).toContain( + `repos/acme/ui/contents/dir%20with%20space/a%3Fb.tsx?ref=${SHA}` + ) + }) + + it("classifies a missing gh binary", async () => { + vi.mocked(execa).mockRejectedValueOnce( + Object.assign(new Error("spawn gh ENOENT"), { code: "ENOENT" }) + ) + + await expect( + fetchGitHubFileViaGh(ADDRESS, SHA, "button.tsx") + ).rejects.toMatchObject({ kind: "enoent" }) + }) + + it("classifies a timeout", async () => { + vi.mocked(execa).mockRejectedValueOnce( + Object.assign(new Error("timed out"), { timedOut: true }) + ) + + await expect( + fetchGitHubFileViaGh(ADDRESS, SHA, "button.tsx") + ).rejects.toMatchObject({ kind: "timeout" }) + }) + + it("classifies an unauthenticated gh", async () => { + vi.mocked(execa).mockRejectedValueOnce( + Object.assign(new Error("exit 4"), { + stderr: "To get started with GitHub CLI, please run: gh auth login", + }) + ) + + await expect( + fetchGitHubFileViaGh(ADDRESS, SHA, "button.tsx") + ).rejects.toMatchObject({ kind: "unauthenticated" }) + }) + + it("parses a validated HTTP status from gh stderr", async () => { + vi.mocked(execa).mockRejectedValueOnce( + Object.assign(new Error("exit 1"), { + stderr: "gh: Not Found (HTTP 404)", + }) + ) + + await expect( + fetchGitHubFileViaGh(ADDRESS, SHA, "button.tsx") + ).rejects.toMatchObject({ kind: "http", statusCode: 404 }) + }) + + it("treats unknown stderr as a generic network failure", async () => { + vi.mocked(execa).mockRejectedValueOnce( + Object.assign(new Error("exit 1"), { stderr: "something odd" }) + ) + + await expect( + fetchGitHubFileViaGh(ADDRESS, SHA, "button.tsx") + ).rejects.toMatchObject({ kind: "network" }) + }) + + it("never leaks subprocess output into the sanitized failure", async () => { + const secret = "ghp_secret_value_1234567890" + vi.mocked(execa).mockRejectedValueOnce( + Object.assign( + new Error(`Command failed: gh api ...\n${secret}\nprivate source`), + { + stderr: `gh: boom ${secret} (HTTP 500)`, + stdout: `partial private content ${secret}`, + } + ) + ) + + const error = await fetchGitHubFileViaGh( + ADDRESS, + SHA, + "button.tsx" + ).catch((caught) => caught) + + expect(error).toBeInstanceOf(GitHubTransportError) + expect(error.statusCode).toBe(500) + const rendered = JSON.stringify({ + message: error.message, + stack: error.stack, + ...error, + }) + expect(rendered).not.toContain(secret) + expect(rendered).not.toContain("private") + }) + + it("bounds concurrent gh processes to eight", async () => { + let active = 0 + let maxActive = 0 + vi.mocked(execa).mockImplementation((() => { + active += 1 + maxActive = Math.max(maxActive, active) + return new Promise((resolve) => + setTimeout(() => { + active -= 1 + resolve({ stdout: "ok" }) + }, 5) + ) + }) as any) + + await Promise.all( + Array.from({ length: 20 }, (_, index) => + fetchGitHubFileViaGh(ADDRESS, SHA, `file-${index}.tsx`) + ) + ) + + expect(maxActive).toBeLessThanOrEqual(8) + expect(maxActive).toBeGreaterThan(1) + }) + }) + + describe("fetchGitHubFileViaRest", () => { + it("sends the token to api.github.com with the raw media type", async () => { + let capturedHeaders: Headers | undefined + server.use( + http.get( + "https://api.github.com/repos/acme/ui/contents/button.tsx", + ({ request }) => { + capturedHeaders = request.headers + return HttpResponse.text("file content") + } + ) + ) + + await expect( + fetchGitHubFileViaRest(ADDRESS, SHA, "button.tsx", "test-token") + ).resolves.toBe("file content") + + expect(capturedHeaders?.get("authorization")).toBe("Bearer test-token") + expect(capturedHeaders?.get("accept")).toBe( + "application/vnd.github.raw+json" + ) + expect(capturedHeaders?.get("x-github-api-version")).toBe("2022-11-28") + }) + + it("classifies HTTP failures without leaking the token", async () => { + server.use( + http.get( + "https://api.github.com/repos/acme/ui/contents/button.tsx", + () => new HttpResponse(null, { status: 401 }) + ) + ) + + const error = await fetchGitHubFileViaRest( + ADDRESS, + SHA, + "button.tsx", + "super-secret-token" + ).catch((caught) => caught) + + expect(error).toBeInstanceOf(GitHubTransportError) + expect(error.statusCode).toBe(401) + expect( + JSON.stringify({ message: error.message, ...error }) + ).not.toContain("super-secret-token") + }) + + it("rejects oversized files by content length", async () => { + server.use( + http.get( + "https://api.github.com/repos/acme/ui/contents/button.tsx", + () => + new HttpResponse("tiny", { + headers: { "Content-Length": String(100 * 1024 * 1024) }, + }) + ) + ) + + await expect( + fetchGitHubFileViaRest(ADDRESS, SHA, "button.tsx", "test-token") + ).rejects.toMatchObject({ kind: "oversize" }) + }) + }) + + describe("readGitHubResponseTextWithLimit", () => { + it("reads a body within the limit", async () => { + await expect( + readGitHubResponseTextWithLimit(new Response("hello"), 10) + ).resolves.toBe("hello") + }) + + it("rejects a streamed body that crosses the limit", async () => { + await expect( + readGitHubResponseTextWithLimit( + new Response("this is longer than the limit"), + 10 + ) + ).rejects.toMatchObject({ kind: "oversize" }) + }) + }) + + describe("resolveGitHubRefViaAuth (token mode)", () => { + beforeEach(() => { + vi.stubEnv("GH_TOKEN", "test-token") + }) + + it("prefers the branch when a branch and tag share a name", async () => { + server.use( + http.get( + "https://api.github.com/repos/acme/ui/commits/heads/release", + () => HttpResponse.json({ sha: BRANCH_SHA }) + ), + http.get( + "https://api.github.com/repos/acme/ui/commits/tags/release", + () => HttpResponse.json({ sha: TAG_SHA }) + ) + ) + + await expect( + resolveGitHubRefViaAuth(ADDRESS, "release", "token") + ).resolves.toBe(BRANCH_SHA) + }) + + it("falls back to the tag only on a branch 404", async () => { + server.use( + http.get( + "https://api.github.com/repos/acme/ui/commits/heads/v1.0.0", + () => new HttpResponse(null, { status: 404 }) + ), + http.get( + "https://api.github.com/repos/acme/ui/commits/tags/v1.0.0", + () => HttpResponse.json({ sha: TAG_SHA }) + ) + ) + + await expect( + resolveGitHubRefViaAuth(ADDRESS, "v1.0.0", "token") + ).resolves.toBe(TAG_SHA) + }) + + it("treats a non-404 branch failure as terminal", async () => { + let tagRequests = 0 + server.use( + http.get( + "https://api.github.com/repos/acme/ui/commits/heads/main", + () => new HttpResponse(null, { status: 500 }) + ), + http.get( + "https://api.github.com/repos/acme/ui/commits/tags/main", + () => { + tagRequests += 1 + return HttpResponse.json({ sha: TAG_SHA }) + } + ) + ) + + await expect( + resolveGitHubRefViaAuth(ADDRESS, "main", "token") + ).rejects.toMatchObject({ kind: "http", statusCode: 500 }) + expect(tagRequests).toBe(0) + }) + + it("resolves HEAD through the commits endpoint", async () => { + server.use( + http.get("https://api.github.com/repos/acme/ui/commits/HEAD", () => + HttpResponse.json({ sha: SHA }) + ) + ) + + await expect( + resolveGitHubRefViaAuth(ADDRESS, "HEAD", "token") + ).resolves.toBe(SHA) + }) + + it("resolves fully qualified branch and tag refs directly", async () => { + server.use( + http.get( + "https://api.github.com/repos/acme/ui/commits/heads/main", + () => HttpResponse.json({ sha: BRANCH_SHA }) + ), + http.get( + "https://api.github.com/repos/acme/ui/commits/tags/v1.0.0", + () => HttpResponse.json({ sha: TAG_SHA }) + ) + ) + + await expect( + resolveGitHubRefViaAuth(ADDRESS, "refs/heads/main", "token") + ).resolves.toBe(BRANCH_SHA) + await expect( + resolveGitHubRefViaAuth(ADDRESS, "refs/tags/v1.0.0", "token") + ).resolves.toBe(TAG_SHA) + }) + + it("resolves other qualified refs through the git refs API with tag peeling", async () => { + const tagObjectSha = "4444444444444444444444444444444444444444" + server.use( + http.get( + "https://api.github.com/repos/acme/ui/git/ref/pull/1/head", + () => + HttpResponse.json({ + object: { type: "tag", sha: tagObjectSha }, + }) + ), + http.get( + `https://api.github.com/repos/acme/ui/git/tags/${tagObjectSha}`, + () => + HttpResponse.json({ + object: { type: "commit", sha: SHA }, + }) + ) + ) + + await expect( + resolveGitHubRefViaAuth(ADDRESS, "refs/pull/1/head", "token") + ).resolves.toBe(SHA) + }) + + it("encodes unsafe ref characters", async () => { + let capturedUrl: string | undefined + server.use( + http.get("https://api.github.com/*", ({ request }) => { + capturedUrl = request.url + return HttpResponse.json({ sha: SHA }) + }) + ) + + await resolveGitHubRefViaAuth(ADDRESS, "a?b&c", "token") + + expect(capturedUrl).toContain("/commits/heads/a%3Fb%26c") + }) + + it("rejects malformed SHAs from the API", async () => { + server.use( + http.get( + "https://api.github.com/repos/acme/ui/commits/heads/main", + () => HttpResponse.json({ sha: "not-a-sha" }) + ) + ) + + await expect( + resolveGitHubRefViaAuth(ADDRESS, "refs/heads/main", "token") + ).rejects.toMatchObject({ kind: "invalid-response" }) + }) + }) + + describe("resolveGitHubRefViaAuth (gh mode)", () => { + it("resolves through gh api and validates the SHA", async () => { + vi.mocked(execa).mockResolvedValueOnce({ + stdout: JSON.stringify({ sha: SHA }), + } as any) + + await expect( + resolveGitHubRefViaAuth(ADDRESS, "refs/heads/main", "gh") + ).resolves.toBe(SHA) + + expect(vi.mocked(execa).mock.calls[0]![1]).toContain( + "repos/acme/ui/commits/heads/main" + ) + expect(vi.mocked(execa).mock.calls[0]![1]).toContain("--hostname") + }) + + it("rejects unparseable gh output", async () => { + vi.mocked(execa).mockResolvedValueOnce({ + stdout: "not json", + } as any) + + await expect( + resolveGitHubRefViaAuth(ADDRESS, "refs/heads/main", "gh") + ).rejects.toMatchObject({ kind: "invalid-response" }) + }) + }) + + describe("encodeGitHubPath", () => { + it("encodes segments and preserves separators", () => { + expect(encodeGitHubPath("a b/c?d/e%f")).toBe("a%20b/c%3Fd/e%25f") + }) + }) +}) diff --git a/packages/shadcn/src/registry/github-cli.ts b/packages/shadcn/src/registry/github-cli.ts new file mode 100644 index 00000000000..404f65d1a48 --- /dev/null +++ b/packages/shadcn/src/registry/github-cli.ts @@ -0,0 +1,531 @@ +import { getRegistryEnvFromContext } from "@/src/registry/context" +import type { GitHubSource } from "@/src/registry/github-ref" +import { fetchWithProxy } from "@/src/registry/proxy" +import { execa } from "execa" + +const GITHUB_API_URL = "https://api.github.com" +const GITHUB_API_VERSION = "2022-11-28" +const GITHUB_ACCEPT_RAW = "application/vnd.github.raw+json" +const GITHUB_ACCEPT_JSON = "application/vnd.github+json" +const GITHUB_SHA_PATTERN = /^[a-fA-F0-9]{40}$/ +const GITHUB_TOKEN_ENV_VARS = ["GH_TOKEN", "GITHUB_TOKEN"] as const +// Printable ASCII without whitespace, i.e. safe inside an HTTP header value. +const HEADER_SAFE_TOKEN_PATTERN = /^[\x21-\x7E]+$/ +const GH_TIMEOUT = 15_000 +const GH_CONCURRENCY = 8 +const GH_STDERR_STATUS_PATTERN = /\(HTTP (\d{3})\)/ +const TAG_DEREFERENCE_DEPTH = 5 + +export const MAX_GITHUB_SOURCE_FILE_SIZE = 5 * 1024 * 1024 + +export type GitHubAuthMode = "token" | "gh" + +export type GitHubFailureKind = + | "http" + | "network" + | "timeout" + | "enoent" + | "unauthenticated" + | "oversize" + | "invalid-response" + +// Internal transport failure carrying only sanitized, validated fields. Raw +// subprocess or response output must never be attached to it. +export class GitHubTransportError extends Error { + public readonly kind: GitHubFailureKind + public readonly statusCode?: number + + constructor( + kind: GitHubFailureKind, + options: { statusCode?: number; message?: string } = {} + ) { + super(options.message ?? `GitHub request failed (${kind}).`) + this.name = "GitHubTransportError" + this.kind = kind + this.statusCode = options.statusCode + } +} + +export function getEnvGitHubToken() { + for (const name of GITHUB_TOKEN_ENV_VARS) { + const value = getRegistryEnvFromContext(name)?.trim() + if (value && HEADER_SAFE_TOKEN_PATTERN.test(value)) { + return value + } + } + + return null +} + +export function encodeGitHubPath(path: string) { + return path + .split("/") + .map((part) => encodeURIComponent(part)) + .join("/") +} + +export function isValidGitHubSha(sha: unknown): sha is string { + return typeof sha === "string" && GITHUB_SHA_PATTERN.test(sha) +} + +export async function readGitHubResponseTextWithLimit( + response: Response, + limit: number = MAX_GITHUB_SOURCE_FILE_SIZE +) { + const contentLength = Number(response.headers.get("content-length")) + if (Number.isFinite(contentLength) && contentLength > limit) { + throw new GitHubTransportError("oversize") + } + + if (!response.body) { + const text = await response.text() + if (Buffer.byteLength(text, "utf8") > limit) { + throw new GitHubTransportError("oversize") + } + return text + } + + const reader = response.body.getReader() + const chunks: Uint8Array[] = [] + let total = 0 + + while (true) { + const { done, value } = await reader.read() + if (done) { + break + } + total += value.byteLength + if (total > limit) { + await reader.cancel() + throw new GitHubTransportError("oversize") + } + chunks.push(value) + } + + return Buffer.concat(chunks).toString("utf8") +} + +async function fetchGitHubApi(endpoint: string, token: string, accept: string) { + let response: Response + try { + response = await fetchWithProxy(`${GITHUB_API_URL}/${endpoint}`, { + headers: new Headers({ + Accept: accept, + Authorization: `Bearer ${token}`, + "User-Agent": "shadcn", + "X-GitHub-Api-Version": GITHUB_API_VERSION, + }), + }) + } catch { + // The underlying error may embed request details, so it is dropped and + // replaced with a fixed-string failure. + throw new GitHubTransportError("network") + } + + if (!response.ok) { + throw new GitHubTransportError("http", { statusCode: response.status }) + } + + return response +} + +export async function fetchGitHubFileViaRest( + address: GitHubSource, + sha: string, + filePath: string, + token: string +) { + const response = await fetchGitHubApi( + buildContentsEndpoint(address, sha, filePath), + token, + GITHUB_ACCEPT_RAW + ) + + return readGitHubResponseTextWithLimit(response) +} + +let ghSlots = GH_CONCURRENCY +const ghQueue: Array<() => void> = [] + +async function withGhSlot(run: () => Promise) { + if (ghSlots > 0) { + ghSlots -= 1 + } else { + // The finisher hands its slot to the woken waiter directly. + await new Promise((resolve) => ghQueue.push(resolve)) + } + + try { + return await run() + } finally { + const next = ghQueue.shift() + if (next) { + next() + } else { + ghSlots += 1 + } + } +} + +function buildGhEnv() { + const env: NodeJS.ProcessEnv = { ...process.env } + + // The gh rung must only ever use the stored github.com credential, with + // stable output and no prompts, regardless of the parent environment. + delete env.GH_TOKEN + delete env.GITHUB_TOKEN + delete env.GH_ENTERPRISE_TOKEN + delete env.GITHUB_ENTERPRISE_TOKEN + delete env.GH_DEBUG + delete env.DEBUG + delete env.GH_FORCE_TTY + delete env.GH_TELEMETRY + + env.GH_HOST = "github.com" + env.GH_PROMPT_DISABLED = "1" + env.GH_NO_UPDATE_NOTIFIER = "1" + env.GH_PAGER = "cat" + env.NO_COLOR = "1" + + return env +} + +function classifyGhFailure(error: unknown) { + if (typeof error !== "object" || error === null) { + return new GitHubTransportError("network") + } + + const failed = error as { + code?: unknown + timedOut?: unknown + stderr?: unknown + } + + if (failed.code === "ENOENT") { + return new GitHubTransportError("enoent") + } + + if (failed.timedOut === true) { + return new GitHubTransportError("timeout") + } + + const stderr = typeof failed.stderr === "string" ? failed.stderr : "" + + if (/gh auth login|not logged in/i.test(stderr)) { + return new GitHubTransportError("unauthenticated") + } + + const statusMatch = stderr.match(GH_STDERR_STATUS_PATTERN) + if (statusMatch) { + const statusCode = Number(statusMatch[1]) + if (statusCode >= 100 && statusCode <= 599) { + return new GitHubTransportError("http", { statusCode }) + } + } + + return new GitHubTransportError("network") +} + +async function runGhApi(endpoint: string, accept: string) { + return withGhSlot(async () => { + try { + const result = await execa( + "gh", + [ + "api", + "--hostname", + "github.com", + endpoint, + "-H", + `Accept: ${accept}`, + "-H", + `X-GitHub-Api-Version: ${GITHUB_API_VERSION}`, + ], + { + env: buildGhEnv(), + extendEnv: false, + timeout: GH_TIMEOUT, + maxBuffer: MAX_GITHUB_SOURCE_FILE_SIZE, + stripFinalNewline: false, + } + ) + return result.stdout + } catch (error) { + throw classifyGhFailure(error) + } + }) +} + +export async function fetchGitHubFileViaGh( + address: GitHubSource, + sha: string, + filePath: string +) { + const stdout = await runGhApi( + buildContentsEndpoint(address, sha, filePath), + GITHUB_ACCEPT_RAW + ) + + if (Buffer.byteLength(stdout, "utf8") > MAX_GITHUB_SOURCE_FILE_SIZE) { + throw new GitHubTransportError("oversize") + } + + return stdout +} + +function buildContentsEndpoint( + address: GitHubSource, + sha: string, + filePath: string +) { + if (!isValidGitHubSha(sha)) { + throw new GitHubTransportError("invalid-response") + } + + return `repos/${address.owner}/${address.repo}/contents/${encodeGitHubPath( + filePath + )}?ref=${sha.toLowerCase()}` +} + +// Fixed-string failure guidance keyed by sanitized classification. Nothing +// from a response body or subprocess stream may flow into these values. +export function getGitHubTransportFailureGuidance( + error: GitHubTransportError, + mode: GitHubAuthMode +) { + if (error.kind === "enoent") { + return { + detail: "The GitHub CLI (gh) is not installed.", + suggestion: + 'Install the GitHub CLI and run "gh auth login", or set GH_TOKEN to a token with read access.', + } + } + + if (error.kind === "unauthenticated") { + return mode === "token" + ? { + detail: "The configured GitHub token was rejected.", + suggestion: + "Check that GH_TOKEN or GITHUB_TOKEN is valid and has read access to the repository.", + } + : { + detail: "The GitHub CLI is not authenticated.", + suggestion: + 'Run "gh auth login --hostname github.com" and try again.', + } + } + + if (error.kind === "timeout") { + return { + detail: "The GitHub request timed out.", + suggestion: "Check your network connection and try again.", + } + } + + if (error.kind === "oversize") { + return { + detail: `The file exceeds the ${MAX_GITHUB_SOURCE_FILE_SIZE} byte registry source file limit.`, + suggestion: + "Registry source files must be smaller than 5 MiB. Reduce the file size or split the item.", + } + } + + if (error.kind === "http") { + if (error.statusCode === 401) { + return mode === "token" + ? { + detail: "GitHub rejected the configured token (401).", + suggestion: + "Check that GH_TOKEN or GITHUB_TOKEN is valid and has read access to the repository.", + } + : { + detail: "GitHub rejected the stored GitHub CLI credentials (401).", + suggestion: + 'Run "gh auth login --hostname github.com" and try again.', + } + } + + if (error.statusCode === 403) { + return { + detail: "GitHub denied access to the repository (403).", + suggestion: + "Check that your credentials have read access to the repository.", + } + } + + if (error.statusCode === 429) { + return { + detail: "GitHub rate limited the request (429).", + suggestion: "Wait a few minutes and try again.", + } + } + + if (error.statusCode && error.statusCode >= 500) { + return { + detail: `GitHub returned an upstream error (${error.statusCode}).`, + suggestion: "GitHub may be having issues. Try again later.", + } + } + + return { + detail: `GitHub returned an unexpected status${ + error.statusCode ? ` (${error.statusCode})` : "" + }.`, + suggestion: "Check the repository and try again.", + } + } + + if (error.kind === "invalid-response") { + return { + detail: "GitHub returned an unexpected response.", + suggestion: "Try again later.", + } + } + + return { + detail: "The GitHub request failed.", + suggestion: "Check your network connection and try again.", + } +} + +type GitHubJsonRequester = (endpoint: string) => Promise + +function createRestJsonRequester(token: string): GitHubJsonRequester { + return async (endpoint) => { + const response = await fetchGitHubApi(endpoint, token, GITHUB_ACCEPT_JSON) + try { + return await response.json() + } catch { + throw new GitHubTransportError("invalid-response") + } + } +} + +function createGhJsonRequester(): GitHubJsonRequester { + return async (endpoint) => { + const stdout = await runGhApi(endpoint, GITHUB_ACCEPT_JSON) + try { + return JSON.parse(stdout) + } catch { + throw new GitHubTransportError("invalid-response") + } + } +} + +export async function resolveGitHubRefViaAuth( + address: GitHubSource, + ref: string, + mode: GitHubAuthMode +) { + const token = mode === "token" ? getEnvGitHubToken() : null + if (mode === "token" && !token) { + throw new GitHubTransportError("unauthenticated") + } + + const request = + mode === "token" && token + ? createRestJsonRequester(token) + : createGhJsonRequester() + + if (ref === "HEAD") { + return resolveCommitishSha(address, request, "HEAD") + } + + if (ref.startsWith("refs/heads/")) { + return resolveCommitishSha( + address, + request, + `heads/${encodeGitHubPath(ref.slice("refs/heads/".length))}` + ) + } + + if (ref.startsWith("refs/tags/")) { + return resolveCommitishSha( + address, + request, + `tags/${encodeGitHubPath(ref.slice("refs/tags/".length))}` + ) + } + + if (ref.startsWith("refs/")) { + return resolveQualifiedGitRefSha(address, request, ref) + } + + // A shorthand ref prefers the branch. Only a missing branch may resolve as + // a tag, matching the git ls-remote candidate ordering. + try { + return await resolveCommitishSha( + address, + request, + `heads/${encodeGitHubPath(ref)}` + ) + } catch (error) { + if ( + error instanceof GitHubTransportError && + error.kind === "http" && + error.statusCode === 404 + ) { + return resolveCommitishSha( + address, + request, + `tags/${encodeGitHubPath(ref)}` + ) + } + throw error + } +} + +async function resolveCommitishSha( + address: GitHubSource, + request: GitHubJsonRequester, + commitish: string +) { + const result = await request( + `repos/${address.owner}/${address.repo}/commits/${commitish}` + ) + const sha = + typeof result === "object" && result !== null + ? (result as { sha?: unknown }).sha + : undefined + + if (!isValidGitHubSha(sha)) { + throw new GitHubTransportError("invalid-response") + } + + return sha.toLowerCase() +} + +async function resolveQualifiedGitRefSha( + address: GitHubSource, + request: GitHubJsonRequester, + ref: string +) { + const refName = encodeGitHubPath(ref.slice("refs/".length)) + const result = await request( + `repos/${address.owner}/${address.repo}/git/ref/${refName}` + ) + let object = + typeof result === "object" && result !== null + ? (result as { object?: { type?: unknown; sha?: unknown } }).object + : undefined + + for (let depth = 0; depth < TAG_DEREFERENCE_DEPTH; depth++) { + if (!object || !isValidGitHubSha(object.sha)) { + throw new GitHubTransportError("invalid-response") + } + + if (object.type !== "tag") { + return object.sha.toLowerCase() + } + + const tag = await request( + `repos/${address.owner}/${address.repo}/git/tags/${object.sha.toLowerCase()}` + ) + object = + typeof tag === "object" && tag !== null + ? (tag as { object?: { type?: unknown; sha?: unknown } }).object + : undefined + } + + throw new GitHubTransportError("invalid-response") +} diff --git a/packages/shadcn/src/registry/github-ref.ts b/packages/shadcn/src/registry/github-ref.ts index bd9061551b1..c3e7e59bcce 100644 --- a/packages/shadcn/src/registry/github-ref.ts +++ b/packages/shadcn/src/registry/github-ref.ts @@ -3,6 +3,15 @@ import type { ResolvedItemAddress, } from "@/src/registry/address" import { RegistrySourceFileError } from "@/src/registry/errors" +import { + getGitHubAuthState, + selectGitHubAuthMode, +} from "@/src/registry/github-auth" +import { + getGitHubTransportFailureGuidance, + GitHubTransportError, + resolveGitHubRefViaAuth, +} from "@/src/registry/github-cli" import { execa } from "execa" const GITHUB_URL = "https://github.com" @@ -14,6 +23,9 @@ export type GitHubSource = GitHubItemAddress | ResolvedGitHubRegistrySource export type GitHubRefResolverOptions = { cache?: Map> + // Command-local anchor for shared GitHub auth state. When present, a failed + // git ls-remote may fall back to an authenticated REST or gh resolution. + authAnchor?: object } export async function resolveGitHubRef( @@ -31,16 +43,22 @@ export async function resolveGitHubRef( return options.cache.get(cacheKey)! } - const promise = resolveGitHubRefUncached(address, ref).catch((error) => { - options.cache?.delete(cacheKey) - throw error - }) + const promise = resolveGitHubRefUncached(address, ref, options).catch( + (error) => { + options.cache?.delete(cacheKey) + throw error + } + ) options.cache?.set(cacheKey, promise) return promise } -async function resolveGitHubRefUncached(address: GitHubSource, ref: string) { +async function resolveGitHubRefUncached( + address: GitHubSource, + ref: string, + options: GitHubRefResolverOptions +) { const repoUrl = `${GITHUB_URL}/${address.owner}/${address.repo}.git` const candidates = getGitHubRefCandidates(ref) @@ -58,7 +76,16 @@ async function resolveGitHubRefUncached(address: GitHubSource, ref: string) { ) stdout = result.stdout } catch (error) { - throw createGitHubRefResolutionError(address, ref, repoUrl, error) + const refError = createGitHubRefResolutionError( + address, + ref, + repoUrl, + error + ) + if (!options.authAnchor) { + throw refError + } + return resolveGitHubRefWithAuth(address, ref, options.authAnchor, refError) } const refs = parseGitLsRemote(stdout) @@ -120,6 +147,61 @@ export function parseGitLsRemote(stdout: string) { return refs } +async function resolveGitHubRefWithAuth( + address: GitHubSource, + ref: string, + authAnchor: object, + refError: RegistrySourceFileError +) { + const state = getGitHubAuthState(authAnchor, address) + + let mode + try { + mode = await selectGitHubAuthMode(state, address, refError) + } catch { + throw refError + } + + try { + return await resolveGitHubRefViaAuth(address, ref, mode) + } catch (error) { + if (!(error instanceof GitHubTransportError)) { + throw refError + } + + // An authenticated 404 preserves the original error so private and + // missing repositories stay ambiguous. + if (error.kind === "http" && error.statusCode === 404) { + throw refError + } + + const guidance = getGitHubTransportFailureGuidance(error, mode) + + // A missing gh binary or missing credentials keeps the original message + // and adds setup guidance. + if (error.kind === "enoent" || error.kind === "unauthenticated") { + throw new RegistrySourceFileError("registry.json", undefined, { + message: refError.message, + context: { + reason: "github-ref-resolution", + source: formatGitHubSource(address), + ref, + }, + suggestion: guidance.suggestion, + }) + } + throw new RegistrySourceFileError("registry.json", undefined, { + message: `Failed to resolve GitHub ref "${ref}" for ${address.owner}/${address.repo}. ${guidance.detail}`, + context: { + reason: "github-ref-resolution", + source: formatGitHubSource(address), + ref, + }, + suggestion: guidance.suggestion, + }) + } +} + function createGitHubRefResolutionError( address: GitHubSource, ref: string, diff --git a/packages/shadcn/src/registry/github.test.ts b/packages/shadcn/src/registry/github.test.ts index 62d7cea3fab..12991f3e72b 100644 --- a/packages/shadcn/src/registry/github.test.ts +++ b/packages/shadcn/src/registry/github.test.ts @@ -12,8 +12,11 @@ import { vi, } from "vitest" +import { logger } from "../utils/logger" +import { withRegistryContext } from "./context" import { RegistrySourceFileError, RegistryValidationError } from "./errors" -import { validateGitHubRegistrySource } from "./github" +import { fetchGitHubRegistryItem, validateGitHubRegistrySource } from "./github" +import { resetGitHubAuthNotices } from "./github-auth" import { fetchRegistryItems, resolveRegistryItemsFromRegistries, @@ -39,22 +42,35 @@ describe("GitHub registry items", () => { }) beforeEach(() => { - vi.mocked(execa).mockResolvedValue({ - stdout: [ - `ref: refs/heads/main\tHEAD`, - `${HEAD_SHA}\tHEAD`, - `${HEAD_SHA}\trefs/heads/main`, - `${TAG_OBJECT_SHA}\trefs/tags/v1.2.0`, - `${TAG_SHA}\trefs/tags/v1.2.0^{}`, - `${BRANCH_SHA}\trefs/heads/feature/forms`, - `${V1_SHA}\trefs/tags/v1.0.0`, - ].join("\n"), - } as any) + // Keep host and CI credentials out of the auth ladder: no env tokens, and + // the gh binary behaves as missing unless a test overrides it. + vi.stubEnv("GH_TOKEN", "") + vi.stubEnv("GITHUB_TOKEN", "") + resetGitHubAuthNotices() + vi.mocked(execa).mockImplementation(((command: string) => { + if (command === "gh") { + return Promise.reject( + Object.assign(new Error("spawn gh ENOENT"), { code: "ENOENT" }) + ) + } + return Promise.resolve({ + stdout: [ + `ref: refs/heads/main\tHEAD`, + `${HEAD_SHA}\tHEAD`, + `${HEAD_SHA}\trefs/heads/main`, + `${TAG_OBJECT_SHA}\trefs/tags/v1.2.0`, + `${TAG_SHA}\trefs/tags/v1.2.0^{}`, + `${BRANCH_SHA}\trefs/heads/feature/forms`, + `${V1_SHA}\trefs/tags/v1.0.0`, + ].join("\n"), + }) + }) as any) }) afterEach(() => { server.resetHandlers() vi.mocked(execa).mockReset() + vi.unstubAllEnvs() }) afterAll(() => { @@ -878,6 +894,348 @@ describe("GitHub registry items", () => { ]) }) + describe("private GitHub registries", () => { + const PRIVATE_REGISTRY = { + name: "acme-ui", + homepage: "https://github.com/acme/ui", + items: [ + { + name: "button", + type: "registry:ui", + files: [{ path: "button.tsx", type: "registry:ui" }], + }, + { + name: "card", + type: "registry:ui", + files: [{ path: "card.tsx", type: "registry:ui" }], + }, + ], + } + + beforeEach(() => { + vi.spyOn(logger, "log").mockImplementation(() => {}) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it("upgrades a root 404 to the env token and sends it only to api.github.com", async () => { + vi.stubEnv("GH_TOKEN", "ci-token") + let rawAuthorization: string | null = "unset" + const apiAuthorizations: Array = [] + + server.use( + http.get( + "https://raw.githubusercontent.com/acme/ui/1111111111111111111111111111111111111111/registry.json", + ({ request }) => { + rawAuthorization = request.headers.get("authorization") + return new HttpResponse(null, { status: 404 }) + } + ), + http.get( + "https://api.github.com/repos/acme/ui/contents/registry.json", + ({ request }) => { + apiAuthorizations.push(request.headers.get("authorization")) + return HttpResponse.json(PRIVATE_REGISTRY) + } + ), + http.get( + "https://api.github.com/repos/acme/ui/contents/button.tsx", + ({ request }) => { + apiAuthorizations.push(request.headers.get("authorization")) + return HttpResponse.text("export function Button() {}") + } + ) + ) + + const [item] = await fetchRegistryItems(["acme/ui/button"], {} as any) + + expect(item.files?.[0]?.content).toBe("export function Button() {}") + expect(rawAuthorization).toBeNull() + expect(apiAuthorizations).toEqual(["Bearer ci-token", "Bearer ci-token"]) + expect( + vi.mocked(execa).mock.calls.filter(([command]) => command === "gh") + ).toHaveLength(0) + }) + + it("keeps env-token failures terminal without falling through to gh", async () => { + vi.stubEnv("GH_TOKEN", "expired-token") + + server.use( + http.get( + "https://raw.githubusercontent.com/acme/ui/1111111111111111111111111111111111111111/registry.json", + () => new HttpResponse(null, { status: 404 }) + ), + http.get( + "https://api.github.com/repos/acme/ui/contents/registry.json", + () => new HttpResponse(null, { status: 401 }) + ) + ) + + await expect( + fetchRegistryItems(["acme/ui/button"], {} as any) + ).rejects.toMatchObject({ + suggestion: + "Check that GH_TOKEN or GITHUB_TOKEN is valid and has read access to the repository.", + }) + expect( + vi.mocked(execa).mock.calls.filter(([command]) => command === "gh") + ).toHaveLength(0) + }) + + it("upgrades a root 404 through gh once across concurrent items", async () => { + let ghRootCalls = 0 + vi.mocked(execa).mockImplementation((( + command: string, + args?: readonly string[] + ) => { + if (command === "git") { + return Promise.resolve({ + stdout: "1111111111111111111111111111111111111111\tHEAD", + }) + } + const endpoint = args?.[3] ?? "" + if (endpoint.includes("contents/registry.json")) { + ghRootCalls += 1 + return Promise.resolve({ stdout: JSON.stringify(PRIVATE_REGISTRY) }) + } + if (endpoint.includes("contents/button.tsx")) { + return Promise.resolve({ stdout: "export function Button() {}" }) + } + if (endpoint.includes("contents/card.tsx")) { + return Promise.resolve({ stdout: "export function Card() {}" }) + } + return Promise.reject( + Object.assign(new Error("exit 1"), { + stderr: "gh: Not Found (HTTP 404)", + }) + ) + }) as any) + + server.use( + http.get( + "https://raw.githubusercontent.com/acme/ui/1111111111111111111111111111111111111111/registry.json", + () => new HttpResponse(null, { status: 404 }) + ) + ) + + const [button, card] = await fetchRegistryItems( + ["acme/ui/button", "acme/ui/card"], + {} as any + ) + + expect(button.files?.[0]?.content).toBe("export function Button() {}") + expect(card.files?.[0]?.content).toBe("export function Card() {}") + expect(ghRootCalls).toBe(1) + expect(vi.mocked(logger.log)).toHaveBeenCalledTimes(1) + expect(vi.mocked(logger.log)).toHaveBeenCalledWith( + expect.stringContaining("Using gh credentials.") + ) + }) + + it("awaits the context notice callback before the first authenticated request", async () => { + const order: string[] = [] + vi.stubEnv("GH_TOKEN", "ci-token") + + server.use( + http.get( + "https://raw.githubusercontent.com/acme/ui/1111111111111111111111111111111111111111/registry.json", + () => new HttpResponse(null, { status: 404 }) + ), + http.get( + "https://api.github.com/repos/acme/ui/contents/registry.json", + () => { + order.push("request") + return HttpResponse.json(PRIVATE_REGISTRY) + } + ), + http.get( + "https://api.github.com/repos/acme/ui/contents/button.tsx", + () => HttpResponse.text("export function Button() {}") + ) + ) + + await withRegistryContext( + () => fetchRegistryItems(["acme/ui/button"], {} as any), + { + onGitHubAuthNotice: async (message) => { + order.push(message) + }, + } + ) + + expect(order[0]).toBe("Using GH_TOKEN credentials.") + expect(order[1]).toBe("request") + expect(vi.mocked(logger.log)).not.toHaveBeenCalled() + }) + + it("reports the original anonymous error when the authenticated root also 404s", async () => { + vi.stubEnv("GH_TOKEN", "ci-token") + + server.use( + http.get( + "https://raw.githubusercontent.com/acme/ui/1111111111111111111111111111111111111111/registry.json", + () => new HttpResponse(null, { status: 404 }) + ), + http.get( + "https://api.github.com/repos/acme/ui/contents/registry.json", + () => new HttpResponse(null, { status: 404 }) + ) + ) + + await expect( + fetchRegistryItems(["acme/ui/button"], {} as any) + ).rejects.toMatchObject({ + message: expect.stringContaining("Failed to read GitHub source file"), + suggestion: expect.stringContaining("private repository"), + }) + }) + + it("locks a public source to anonymous so a missing child never authenticates", async () => { + vi.stubEnv("GH_TOKEN", "ci-token") + + server.use( + http.get( + "https://raw.githubusercontent.com/acme/ui/1111111111111111111111111111111111111111/registry.json", + () => HttpResponse.json(PRIVATE_REGISTRY) + ), + http.get( + "https://raw.githubusercontent.com/acme/ui/1111111111111111111111111111111111111111/button.tsx", + () => new HttpResponse(null, { status: 404 }) + ) + ) + + // No api.github.com handler is registered, so an authenticated attempt + // would fail the unhandled-request guard. + await expect( + fetchRegistryItems(["acme/ui/button"], {} as any) + ).rejects.toMatchObject({ + suggestion: + "Check that the file path exists in the public GitHub repository.", + }) + expect(vi.mocked(logger.log)).not.toHaveBeenCalled() + }) + + it("prints the notice once across separate top-level calls for the same source", async () => { + vi.stubEnv("GH_TOKEN", "ci-token") + + server.use( + http.get( + "https://raw.githubusercontent.com/acme/ui/1111111111111111111111111111111111111111/registry.json", + () => new HttpResponse(null, { status: 404 }) + ), + http.get( + "https://api.github.com/repos/acme/ui/contents/registry.json", + () => HttpResponse.json(PRIVATE_REGISTRY) + ), + http.get( + "https://api.github.com/repos/acme/ui/contents/button.tsx", + () => HttpResponse.text("export function Button() {}") + ), + http.get("https://api.github.com/repos/acme/ui/contents/card.tsx", () => + HttpResponse.text("export function Card() {}") + ) + ) + + // Separate calls create separate sourceCaches, as the add command's + // phases do. The notice must still print only once. + await fetchRegistryItems(["acme/ui/button"], {} as any) + await fetchRegistryItems(["acme/ui/card"], {} as any) + + expect(vi.mocked(logger.log)).toHaveBeenCalledTimes(1) + }) + + it("evicts rejected source promises so a retry can succeed", async () => { + const sourceCache = new Map>() + + server.use( + http.get( + "https://raw.githubusercontent.com/acme/ui/1111111111111111111111111111111111111111/registry.json", + () => new HttpResponse(null, { status: 500 }) + ) + ) + + await expect( + fetchGitHubRegistryItem( + { scheme: "github", owner: "acme", repo: "ui", item: "button" }, + { sourceCache } + ) + ).rejects.toThrow(RegistrySourceFileError) + + server.resetHandlers() + server.use( + http.get( + "https://raw.githubusercontent.com/acme/ui/1111111111111111111111111111111111111111/registry.json", + () => HttpResponse.json(PRIVATE_REGISTRY) + ), + http.get( + "https://raw.githubusercontent.com/acme/ui/1111111111111111111111111111111111111111/button.tsx", + () => HttpResponse.text("export function Button() {}") + ) + ) + + const item = await fetchGitHubRegistryItem( + { scheme: "github", owner: "acme", repo: "ui", item: "button" }, + { sourceCache } + ) + + expect(item.files?.[0]?.content).toBe("export function Button() {}") + }) + + it("falls back to authenticated ref resolution when git ls-remote fails", async () => { + vi.stubEnv("GH_TOKEN", "ci-token") + vi.mocked(execa).mockImplementation((() => + Promise.reject( + Object.assign(new Error("exit 128"), { exitCode: 128 }) + )) as any) + + server.use( + http.get("https://api.github.com/repos/acme/ui/commits/HEAD", () => + HttpResponse.json({ + sha: "1111111111111111111111111111111111111111", + }) + ), + http.get( + "https://api.github.com/repos/acme/ui/contents/registry.json", + () => HttpResponse.json(PRIVATE_REGISTRY) + ), + http.get( + "https://api.github.com/repos/acme/ui/contents/button.tsx", + () => HttpResponse.text("export function Button() {}") + ) + ) + + // No raw.githubusercontent.com handler: once the ref fallback selects + // the token mode, content must go through the Contents API directly. + const [item] = await fetchRegistryItems(["acme/ui/button"], {} as any) + + expect(item.files?.[0]?.content).toBe("export function Button() {}") + expect(vi.mocked(logger.log)).toHaveBeenCalledTimes(1) + }) + + it("preserves the original ref error when authenticated resolution 404s", async () => { + vi.stubEnv("GH_TOKEN", "ci-token") + vi.mocked(execa).mockImplementation((() => + Promise.reject( + Object.assign(new Error("exit 128"), { exitCode: 128 }) + )) as any) + + server.use( + http.get( + "https://api.github.com/repos/acme/ui/commits/HEAD", + () => new HttpResponse(null, { status: 404 }) + ) + ) + + await expect( + fetchRegistryItems(["acme/ui/button"], {} as any) + ).rejects.toMatchObject({ + message: expect.stringContaining('Failed to resolve GitHub ref "HEAD"'), + }) + }) + }) + it("searches items from a GitHub source registry with an explicit ref", async () => { server.use( http.get( diff --git a/packages/shadcn/src/registry/github.ts b/packages/shadcn/src/registry/github.ts index 9164593e591..b3317186dd6 100644 --- a/packages/shadcn/src/registry/github.ts +++ b/packages/shadcn/src/registry/github.ts @@ -3,6 +3,20 @@ import type { ResolvedItemAddress, } from "@/src/registry/address" import { RegistryError, RegistrySourceFileError } from "@/src/registry/errors" +import { + getGitHubAuthState, + selectGitHubAuthMode, + type GitHubSourceAuthState, +} from "@/src/registry/github-auth" +import { + fetchGitHubFileViaGh, + fetchGitHubFileViaRest, + getEnvGitHubToken, + getGitHubTransportFailureGuidance, + GitHubTransportError, + readGitHubResponseTextWithLimit, + type GitHubAuthMode, +} from "@/src/registry/github-cli" import { resolveGitHubRef } from "@/src/registry/github-ref" import type { GitHubSource } from "@/src/registry/github-ref" import { fetchWithProxy } from "@/src/registry/proxy" @@ -146,30 +160,198 @@ function createGitHubRegistrySourceReader( address: GitHubSource, options: GitHubSourceOptions ) { + const sourceCache = options.sourceCache ?? new Map>() + const authState = getGitHubAuthState(sourceCache, address) const shaPromise = resolveGitHubRef(address, { - cache: options.sourceCache, + cache: sourceCache, + authAnchor: sourceCache, }) + const readWithCache = (key: string, fetcher: () => Promise) => { + if (options.useCache !== false && sourceCache.has(key)) { + return sourceCache.get(key)! + } + + const promise = fetcher() + + if (options.useCache !== false) { + sourceCache.set(key, promise) + // Evict rejections so a transient failure is not replayed for the rest + // of the invocation. + promise.catch(() => { + if (sourceCache.get(key) === promise) { + sourceCache.delete(key) + } + }) + } + + return promise + } + + const readAuthenticated = ( + sha: string, + filePath: string, + mode: GitHubAuthMode + ) => { + const key = `${mode}:${address.owner}/${address.repo}/${sha}/${filePath}` + + if (mode === "token") { + return readWithCache(key, async () => { + const token = getEnvGitHubToken() + if (!token) { + throw new GitHubTransportError("unauthenticated") + } + return fetchGitHubFileViaRest(address, sha, filePath, token) + }) + } + + return readWithCache(key, () => + fetchGitHubFileViaGh(address, sha, filePath) + ) + } + return { async readText(filePath: string) { const sha = await shaPromise - const url = buildGitHubRawUrl(address, sha, filePath) + const isRoot = filePath === "registry.json" - if (options.useCache !== false && options.sourceCache?.has(url)) { - return options.sourceCache.get(url)! + if (!authState.anonymousLock && authState.decision) { + const mode = await authState.decision + try { + return await readAuthenticated(sha, filePath, mode) + } catch (error) { + throw toGitHubSourceFileError( + error, + address, + filePath, + mode, + authState + ) + } } - const promise = fetchGitHubSourceFile(url, filePath, address) + const url = buildGitHubRawUrl(address, sha, filePath) + try { + const content = await readWithCache(`anonymous:${url}`, () => + fetchGitHubSourceFile(url, filePath, address) + ) + if (isRoot) { + // A public root locks the source so a missing child file never + // triggers an authenticated request. + authState.anonymousLock = true + } + return content + } catch (error) { + const statusCode = + error instanceof RegistrySourceFileError + ? error.context?.statusCode + : undefined + if (!isRoot || statusCode !== 404 || authState.anonymousLock) { + throw error + } - if (options.useCache !== false) { - options.sourceCache?.set(url, promise) - } + // Only the initial anonymous root 404 may select an authenticated + // mode. The notice is awaited inside the selection. + let mode: GitHubAuthMode + try { + mode = await selectGitHubAuthMode(authState, address, error) + } catch { + throw error + } - return promise + try { + return await readAuthenticated(sha, filePath, mode) + } catch (authError) { + throw toGitHubSourceFileError( + authError, + address, + filePath, + mode, + authState + ) + } + } }, } } +function toGitHubSourceFileError( + error: unknown, + address: GitHubSource, + filePath: string, + mode: GitHubAuthMode, + state: GitHubSourceAuthState +) { + if (!(error instanceof GitHubTransportError)) { + return error instanceof Error + ? error + : new RegistrySourceFileError(filePath, undefined, { + message: `Failed to read GitHub source file "${filePath}" from ${formatGitHubSource( + address + )}.`, + context: { + reason: "github-source-file", + source: formatGitHubSource(address), + filePath, + }, + }) + } + + const guidance = getGitHubTransportFailureGuidance(error, mode) + + // An authenticated root 404 preserves the pre-auth failure so private and + // missing repositories stay ambiguous. + if (error.kind === "http" && error.statusCode === 404) { + if (filePath === "registry.json" && state.originalError instanceof Error) { + return state.originalError + } + + return new RegistrySourceFileError(filePath, undefined, { + message: `Failed to read GitHub source file "${filePath}" from ${formatGitHubSource( + address + )}.`, + context: { + reason: "github-source-file", + statusCode: 404, + source: formatGitHubSource(address), + filePath, + }, + suggestion: "Check that the file path exists in the GitHub repository.", + }) + } + + // A missing or unauthenticated gh during the root upgrade keeps the + // original anonymous message and adds setup guidance. + if ( + (error.kind === "enoent" || error.kind === "unauthenticated") && + filePath === "registry.json" && + state.originalError instanceof Error + ) { + return new RegistrySourceFileError(filePath, undefined, { + message: state.originalError.message, + context: { + reason: "github-source-file", + source: formatGitHubSource(address), + filePath, + }, + suggestion: guidance.suggestion, + }) + } + + return new RegistrySourceFileError(filePath, undefined, { + message: `Failed to read GitHub source file "${filePath}" from ${formatGitHubSource( + address + )}. ${guidance.detail}`, + context: { + reason: "github-source-file", + ...(error.statusCode ? { statusCode: error.statusCode } : {}), + source: formatGitHubSource(address), + filePath, + }, + suggestion: guidance.suggestion, + }) +} + async function fetchGitHubSourceFile( url: string, filePath: string, @@ -213,12 +395,30 @@ async function fetchGitHubSourceFile( }, suggestion: filePath === "registry.json" - ? "The GitHub repository and ref were resolved, but raw.githubusercontent.com did not return a root registry.json file. Check that the public repository has registry.json at its root and that raw.githubusercontent.com is accessible from this network." + ? 'The GitHub repository and ref were resolved, but raw.githubusercontent.com did not return a root registry.json file. Check that the public repository has registry.json at its root and that raw.githubusercontent.com is accessible from this network. If this is a private repository, run "gh auth login" or set GH_TOKEN to a token with read access.' : "Check that the file path exists in the public GitHub repository.", }) } - return response.text() + try { + return await readGitHubResponseTextWithLimit(response) + } catch (error) { + if (error instanceof GitHubTransportError && error.kind === "oversize") { + const guidance = getGitHubTransportFailureGuidance(error, "token") + throw new RegistrySourceFileError(filePath, undefined, { + message: `Failed to read GitHub source file "${filePath}" from ${formatGitHubSource( + address + )}. ${guidance.detail}`, + context: { + reason: "github-source-file", + source: formatGitHubSource(address), + filePath, + }, + suggestion: guidance.suggestion, + }) + } + throw error + } } function buildGitHubRawUrl( diff --git a/packages/shadcn/src/registry/proxy.test.ts b/packages/shadcn/src/registry/proxy.test.ts index 19fcc541a21..ea7f05f2025 100644 --- a/packages/shadcn/src/registry/proxy.test.ts +++ b/packages/shadcn/src/registry/proxy.test.ts @@ -213,4 +213,66 @@ describe("fetchWithProxy", () => { }) ).rejects.toThrow(/Too many redirects/) }) + + describe("Authorization across redirect chains", () => { + it("keeps Authorization on a same-origin api.github.com redirect", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + redirectResponse(302, "https://api.github.com/redirected") + ) + .mockResolvedValueOnce(okResponse()) + vi.stubGlobal("fetch", fetchMock) + + await fetchWithProxy("https://api.github.com/repos/acme/ui/contents/a", { + headers: { Authorization: "Bearer token-value" }, + }) + + expect(headersForCall(fetchMock, 1).get("authorization")).toBe( + "Bearer token-value" + ) + }) + + it("removes Authorization on every cross-origin hop", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + redirectResponse(302, "https://objects.example.com/blob") + ) + .mockResolvedValueOnce( + redirectResponse(302, "https://cdn.example.com/blob") + ) + .mockResolvedValueOnce(okResponse()) + vi.stubGlobal("fetch", fetchMock) + + await fetchWithProxy("https://api.github.com/repos/acme/ui/contents/a", { + headers: { Authorization: "Bearer token-value" }, + }) + + expect(headersForCall(fetchMock, 1).get("authorization")).toBeNull() + expect(headersForCall(fetchMock, 2).get("authorization")).toBeNull() + }) + + it("restores Authorization only after a chain returns to the original origin", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + redirectResponse(302, "https://objects.example.com/blob") + ) + .mockResolvedValueOnce( + redirectResponse(302, "https://api.github.com/final") + ) + .mockResolvedValueOnce(okResponse()) + vi.stubGlobal("fetch", fetchMock) + + await fetchWithProxy("https://api.github.com/repos/acme/ui/contents/a", { + headers: { Authorization: "Bearer token-value" }, + }) + + expect(headersForCall(fetchMock, 1).get("authorization")).toBeNull() + expect(headersForCall(fetchMock, 2).get("authorization")).toBe( + "Bearer token-value" + ) + }) + }) }) diff --git a/packages/shadcn/src/utils/spinner.ts b/packages/shadcn/src/utils/spinner.ts index c8efa18b488..58df77857af 100644 --- a/packages/shadcn/src/utils/spinner.ts +++ b/packages/shadcn/src/utils/spinner.ts @@ -1,4 +1,7 @@ -import ora, { type Options } from "ora" +import { logger } from "@/src/utils/logger" +import ora, { type Options, type Ora } from "ora" + +const activeSpinners = new Set() export function spinner( text: Options["text"], @@ -6,8 +9,27 @@ export function spinner( silent?: boolean } ) { - return ora({ + const instance = ora({ text, isSilent: options?.silent, }) + activeSpinners.add(instance) + + return instance +} + +// Prints a line above any active spinner without stopping it. Clearing and +// re-rendering only applies on a TTY, where ora actually animates. +export function logAboveSpinner(message: string) { + const spinning = process.stderr.isTTY + ? Array.from(activeSpinners).filter((instance) => instance.isSpinning) + : [] + + for (const instance of spinning) { + instance.clear() + } + logger.log(message) + for (const instance of spinning) { + instance.render() + } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 981239e54f8..051a61062a9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -328,7 +328,7 @@ importers: specifier: ^0.0.1 version: 0.0.1 shadcn: - specifier: 4.18.0 + specifier: 4.19.0 version: link:../../packages/shadcn shiki: specifier: ^3.23.0