Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 0 additions & 5 deletions .changeset/migrate-base-color.md

This file was deleted.

96 changes: 87 additions & 9 deletions apps/v4/content/docs/registry/github.mdx
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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
.
Expand Down Expand Up @@ -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`.

<Callout>
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.
</Callout>

### 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:
Expand Down
2 changes: 1 addition & 1 deletion apps/v4/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 8 additions & 0 deletions packages/shadcn/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/shadcn/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "shadcn",
"version": "4.18.0",
"version": "4.19.0",
"description": "Add components to your apps.",
"publishConfig": {
"access": "public"
Expand Down
26 changes: 24 additions & 2 deletions packages/shadcn/src/mcp/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { getRegistryItems, searchRegistries } from "@/src/registry"
import { withRegistryContext } from "@/src/registry/context"
import { RegistryError } from "@/src/registry/errors"
import {
resolveSearchRegistries,
Expand Down Expand Up @@ -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: [
Expand Down Expand Up @@ -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<string, unknown> }
}) {
try {
if (!request.params.arguments) {
throw new Error("No tool arguments provided.")
Expand Down Expand Up @@ -573,4 +595,4 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
isError: true,
}
}
})
}
8 changes: 8 additions & 0 deletions packages/shadcn/src/registry/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { AsyncLocalStorage } from "async_hooks"
interface RegistryContext {
headers: Record<string, Record<string, string>>
env?: NodeJS.ProcessEnv
onGitHubAuthNotice?: (message: string) => void | Promise<void>
}

const registryContext = new AsyncLocalStorage<RegistryContext>()
Expand All @@ -14,6 +15,7 @@ export function withRegistryContext<T>(
callback: () => T,
options: {
env?: NodeJS.ProcessEnv
onGitHubAuthNotice?: (message: string) => void | Promise<void>
} = {}
): T {
const parentContext = registryContext.getStore()
Expand All @@ -22,6 +24,8 @@ export function withRegistryContext<T>(
{
headers: {},
env: options.env ?? parentContext?.env,
onGitHubAuthNotice:
options.onGitHubAuthNotice ?? parentContext?.onGitHubAuthNotice,
},
callback
)
Expand Down Expand Up @@ -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

Expand Down
94 changes: 94 additions & 0 deletions packages/shadcn/src/registry/github-auth.ts
Original file line number Diff line number Diff line change
@@ -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<GitHubAuthMode>
// 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<object, Map<string, GitHubSourceAuthState>>()

// 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<string>()

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"
}`
}
Loading
Loading