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