mirror of
https://github.com/shadcn-ui/ui.git
synced 2026-06-11 09:51:40 +00:00
feat: add preset commands (#10530)
* feat(cli): add preset commands * docs(skill): update preset command guidance * docs(cli): document preset commands * chore: changeset * fix(cli): refine preset command output * fix(cli): align preset decode output * fix(cli): update preset output fields * docs(changelog): add preset commands entry * docs(changelog): show preset command output * docs(changelog): clarify preset resolve examples * docs(changelog): refine preset examples * docs(changelog): add preset command sections * docs(changelog): show preset resolve output * docs(changelog): clarify preset open example * docs(changelog): update preset resolve example * docs: update preset announcement * docs: link preset announcement to changelog * test: increase next init timeout
This commit is contained in:
5
.changeset/odd-keys-visit.md
Normal file
5
.changeset/odd-keys-visit.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"shadcn": minor
|
||||
---
|
||||
|
||||
add shadcn preset commands
|
||||
@@ -6,8 +6,8 @@ import { Badge } from "@/registry/new-york-v4/ui/badge"
|
||||
export function Announcement() {
|
||||
return (
|
||||
<Badge asChild variant="secondary" className="bg-muted">
|
||||
<Link href="/sera">
|
||||
Introducing Sera <ArrowRightIcon />
|
||||
<Link href="/docs/changelog">
|
||||
New preset commands <ArrowRightIcon />
|
||||
</Link>
|
||||
</Badge>
|
||||
)
|
||||
|
||||
@@ -92,13 +92,13 @@ Options:
|
||||
Use the `apply` command to apply a preset to an existing project.
|
||||
|
||||
```bash
|
||||
npx shadcn@latest apply --preset a2r6bw
|
||||
npx shadcn@latest apply a2r6bw
|
||||
```
|
||||
|
||||
You can apply only the theme or fonts from a preset without reinstalling UI components:
|
||||
|
||||
```bash
|
||||
npx shadcn@latest apply --preset a2r6bw --only theme
|
||||
npx shadcn@latest apply a2r6bw --only theme
|
||||
```
|
||||
|
||||
Supported values for `--only` are `theme` and `font`.
|
||||
@@ -124,6 +124,110 @@ Options:
|
||||
|
||||
---
|
||||
|
||||
## preset
|
||||
|
||||
Use the `preset` command to inspect preset codes and resolve the preset for an existing project.
|
||||
|
||||
```bash
|
||||
npx shadcn@latest preset decode a2r6bw
|
||||
```
|
||||
|
||||
### preset decode
|
||||
|
||||
Use `preset decode` to decode a preset code.
|
||||
|
||||
```bash
|
||||
npx shadcn@latest preset decode a2r6bw
|
||||
```
|
||||
|
||||
**Options**
|
||||
|
||||
```bash
|
||||
Usage: shadcn preset decode [options] <code>
|
||||
|
||||
decode a preset code
|
||||
|
||||
Arguments:
|
||||
code the preset code to decode
|
||||
|
||||
Options:
|
||||
--json output as JSON. (default: false)
|
||||
-h, --help display help for command
|
||||
```
|
||||
|
||||
### preset resolve
|
||||
|
||||
Use `preset resolve` to resolve the preset from the current project.
|
||||
|
||||
```bash
|
||||
npx shadcn@latest preset resolve
|
||||
```
|
||||
|
||||
The `preset info` command is an alias for `preset resolve`:
|
||||
|
||||
```bash
|
||||
npx shadcn@latest preset info
|
||||
```
|
||||
|
||||
**Options**
|
||||
|
||||
```bash
|
||||
Usage: shadcn preset resolve|info [options]
|
||||
|
||||
resolve a preset from your project
|
||||
|
||||
Options:
|
||||
-c, --cwd <cwd> the working directory. defaults to the current directory.
|
||||
--json output as JSON. (default: false)
|
||||
-h, --help display help for command
|
||||
```
|
||||
|
||||
### preset url
|
||||
|
||||
Use `preset url` to print the create URL for a preset code.
|
||||
|
||||
```bash
|
||||
npx shadcn@latest preset url a2r6bw
|
||||
```
|
||||
|
||||
**Options**
|
||||
|
||||
```bash
|
||||
Usage: shadcn preset url [options] <code>
|
||||
|
||||
get the create URL for a preset code
|
||||
|
||||
Arguments:
|
||||
code the preset code
|
||||
|
||||
Options:
|
||||
-h, --help display help for command
|
||||
```
|
||||
|
||||
### preset open
|
||||
|
||||
Use `preset open` to open a preset code in the browser.
|
||||
|
||||
```bash
|
||||
npx shadcn@latest preset open a2r6bw
|
||||
```
|
||||
|
||||
**Options**
|
||||
|
||||
```bash
|
||||
Usage: shadcn preset open [options] <code>
|
||||
|
||||
open a preset code in the browser
|
||||
|
||||
Arguments:
|
||||
code the preset code
|
||||
|
||||
Options:
|
||||
-h, --help display help for command
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## view
|
||||
|
||||
Use the `view` command to view items from the registry before installing them.
|
||||
|
||||
93
apps/v4/content/docs/changelog/2026-04-preset-commands.mdx
Normal file
93
apps/v4/content/docs/changelog/2026-04-preset-commands.mdx
Normal file
@@ -0,0 +1,93 @@
|
||||
---
|
||||
title: April 2026 - shadcn preset
|
||||
description: Decode, share, open, and resolve preset codes from the shadcn CLI.
|
||||
date: 2026-04-28
|
||||
---
|
||||
|
||||
We added `shadcn preset` commands for working with preset codes.
|
||||
|
||||
## Decode a preset
|
||||
|
||||
You can decode a preset code to see exactly what it contains:
|
||||
|
||||
```bash
|
||||
npx shadcn@latest preset decode b5owWMfJ8l
|
||||
```
|
||||
|
||||
```txt
|
||||
Preset
|
||||
code b5owWMfJ8l
|
||||
version b
|
||||
style mira
|
||||
baseColor mauve
|
||||
theme mauve
|
||||
chartColor amber
|
||||
iconLibrary hugeicons
|
||||
font inter
|
||||
fontHeading oxanium
|
||||
radius large
|
||||
menuAccent subtle
|
||||
menuColor inverted-translucent
|
||||
url https://ui.shadcn.com/create?preset=b5owWMfJ8l
|
||||
```
|
||||
|
||||
## Resolve from a project
|
||||
|
||||
Use `preset resolve` in an existing project to see the preset that matches your current configuration.
|
||||
|
||||
```bash
|
||||
npx shadcn@latest preset resolve
|
||||
```
|
||||
|
||||
```txt
|
||||
Preset
|
||||
code b5Kc6P0Vc
|
||||
version b
|
||||
style luma
|
||||
baseColor olive
|
||||
theme lime
|
||||
chartColor sky
|
||||
iconLibrary hugeicons
|
||||
font geist
|
||||
fontHeading inherit
|
||||
radius default
|
||||
menuAccent subtle
|
||||
menuColor default
|
||||
url https://ui.shadcn.com/create?preset=b5Kc6P0Vc
|
||||
```
|
||||
|
||||
It works with monorepos too:
|
||||
|
||||
```bash
|
||||
npx shadcn@latest preset resolve -c apps/web
|
||||
```
|
||||
|
||||
## Share or open
|
||||
|
||||
Use `preset url` when you need a shareable link:
|
||||
|
||||
```bash
|
||||
npx shadcn@latest preset url b5owWMfJ8l
|
||||
```
|
||||
|
||||
```txt
|
||||
https://ui.shadcn.com/create?preset=b5owWMfJ8l
|
||||
```
|
||||
|
||||
Use `preset open` to open the preset on shadcn/create for customization:
|
||||
|
||||
```bash
|
||||
npx shadcn@latest preset open b5owWMfJ8l
|
||||
```
|
||||
|
||||
```txt
|
||||
Opening https://ui.shadcn.com/create?preset=b5owWMfJ8l in your browser.
|
||||
```
|
||||
|
||||
This makes presets easier to inspect, share, and hand off to coding agents without manually decoding codes or building URLs.
|
||||
|
||||
<Button asChild size="sm">
|
||||
<Link href="/create" className="mt-6 no-underline!">
|
||||
Try a Preset
|
||||
</Link>
|
||||
</Button>
|
||||
@@ -191,58 +191,7 @@ export function printInfo(data: Awaited<ReturnType<typeof collectInfo>>) {
|
||||
})
|
||||
|
||||
logger.break()
|
||||
logger.log(highlighter.info("Preset"))
|
||||
if (!data.preset?.code) {
|
||||
printEntries({
|
||||
"--preset": "-",
|
||||
})
|
||||
} else {
|
||||
const fallbacks = data.preset.fallbacks ?? []
|
||||
const formatPresetValue = (key: string, value: string | undefined) => {
|
||||
const suffix = fallbacks.includes(key) ? "*" : ""
|
||||
return `${value ?? "-"}${suffix}`
|
||||
}
|
||||
|
||||
printEntries({
|
||||
"--preset": data.preset.code,
|
||||
url: `${SHADCN_URL}/create?preset=${data.preset.code}`,
|
||||
style: data.preset.values?.style ?? "-",
|
||||
baseColor: formatPresetValue(
|
||||
"baseColor",
|
||||
data.preset.values?.baseColor
|
||||
),
|
||||
theme: formatPresetValue("theme", data.preset.values?.theme),
|
||||
chartColor: formatPresetValue(
|
||||
"chartColor",
|
||||
data.preset.values?.chartColor
|
||||
),
|
||||
iconLibrary: formatPresetValue(
|
||||
"iconLibrary",
|
||||
data.preset.values?.iconLibrary
|
||||
),
|
||||
font: formatPresetValue("font", data.preset.values?.font),
|
||||
fontHeading: formatPresetValue(
|
||||
"fontHeading",
|
||||
data.preset.values?.fontHeading
|
||||
),
|
||||
radius: formatPresetValue("radius", data.preset.values?.radius),
|
||||
menuAccent: formatPresetValue(
|
||||
"menuAccent",
|
||||
data.preset.values?.menuAccent
|
||||
),
|
||||
menuColor: formatPresetValue(
|
||||
"menuColor",
|
||||
data.preset.values?.menuColor
|
||||
),
|
||||
})
|
||||
|
||||
if (fallbacks.length > 0) {
|
||||
logger.log("")
|
||||
logger.log(
|
||||
" * Uses preset defaults for values not available as options on shadcn/create."
|
||||
)
|
||||
}
|
||||
}
|
||||
printPresetInfo(data.preset)
|
||||
|
||||
// Aliases.
|
||||
logger.break()
|
||||
@@ -296,8 +245,54 @@ export function printInfo(data: Awaited<ReturnType<typeof collectInfo>>) {
|
||||
logger.break()
|
||||
}
|
||||
|
||||
export function printPresetInfo(
|
||||
preset: Awaited<ReturnType<typeof collectInfo>>["preset"],
|
||||
options: {
|
||||
fallbackNote?: string
|
||||
} = {}
|
||||
) {
|
||||
logger.log(highlighter.info("Preset"))
|
||||
if (!preset?.code) {
|
||||
printEntries({
|
||||
code: "-",
|
||||
})
|
||||
} else {
|
||||
const fallbacks = preset.fallbacks ?? []
|
||||
const formatPresetValue = (key: string, value: string | undefined) => {
|
||||
const suffix = fallbacks.includes(key) ? "*" : ""
|
||||
return `${value ?? "-"}${suffix}`
|
||||
}
|
||||
|
||||
printEntries({
|
||||
code: preset.code,
|
||||
version: preset.code[0],
|
||||
style: preset.values?.style ?? "-",
|
||||
baseColor: formatPresetValue("baseColor", preset.values?.baseColor),
|
||||
theme: formatPresetValue("theme", preset.values?.theme),
|
||||
chartColor: formatPresetValue("chartColor", preset.values?.chartColor),
|
||||
iconLibrary: formatPresetValue("iconLibrary", preset.values?.iconLibrary),
|
||||
font: formatPresetValue("font", preset.values?.font),
|
||||
fontHeading: formatPresetValue("fontHeading", preset.values?.fontHeading),
|
||||
radius: formatPresetValue("radius", preset.values?.radius),
|
||||
menuAccent: formatPresetValue("menuAccent", preset.values?.menuAccent),
|
||||
menuColor: formatPresetValue("menuColor", preset.values?.menuColor),
|
||||
url: `${SHADCN_URL}/create?preset=${preset.code}`,
|
||||
})
|
||||
|
||||
if (fallbacks.length > 0) {
|
||||
logger.log("")
|
||||
logger.log(
|
||||
options.fallbackNote ??
|
||||
" * Uses preset defaults for values not available as options on shadcn/create."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function printEntries(entries: Record<string, string>) {
|
||||
const maxKeyLength = Math.max(...Object.keys(entries).map((k) => k.length))
|
||||
const maxKeyLength = Math.max(
|
||||
...Object.keys(entries).map((key) => key.length)
|
||||
)
|
||||
for (const [key, value] of Object.entries(entries)) {
|
||||
logger.log(` ${key.padEnd(maxKeyLength + 2)}${value}`)
|
||||
}
|
||||
|
||||
492
packages/shadcn/src/commands/preset.test.ts
Normal file
492
packages/shadcn/src/commands/preset.test.ts
Normal file
@@ -0,0 +1,492 @@
|
||||
import { existsSync } from "fs"
|
||||
import path from "path"
|
||||
import { resolveProjectPreset } from "@/src/preset/resolve"
|
||||
import { getConfig } from "@/src/utils/get-config"
|
||||
import {
|
||||
formatMonorepoMessage,
|
||||
getMonorepoTargets,
|
||||
isMonorepoRoot,
|
||||
} from "@/src/utils/get-monorepo-info"
|
||||
import { getProjectInfo } from "@/src/utils/get-project-info"
|
||||
import { logger } from "@/src/utils/logger"
|
||||
import open from "open"
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
import {
|
||||
decodePresetCode,
|
||||
getPresetUrl,
|
||||
openCommand,
|
||||
preset as presetCommand,
|
||||
printPresetDecode,
|
||||
resolve as resolveCommand,
|
||||
url,
|
||||
} from "./preset"
|
||||
|
||||
vi.mock("fs", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("fs")>()
|
||||
return {
|
||||
...actual,
|
||||
existsSync: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock("open", () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@/src/preset/resolve", () => ({
|
||||
resolveProjectPreset: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@/src/utils/get-config", () => ({
|
||||
getBase: vi.fn(() => "radix"),
|
||||
getConfig: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@/src/utils/get-monorepo-info", () => ({
|
||||
formatMonorepoMessage: vi.fn(),
|
||||
getMonorepoTargets: vi.fn(),
|
||||
isMonorepoRoot: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@/src/utils/get-project-info", () => ({
|
||||
getProjectComponents: vi.fn(),
|
||||
getProjectInfo: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@/src/utils/handle-error", () => ({
|
||||
handleError: vi.fn((error) => {
|
||||
throw error
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock("@/src/utils/highlighter", () => ({
|
||||
highlighter: {
|
||||
info: (value: string) => value,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("@/src/utils/logger", () => ({
|
||||
logger: {
|
||||
break: vi.fn(),
|
||||
error: vi.fn(),
|
||||
log: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
describe("preset commands", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(existsSync).mockReturnValue(true)
|
||||
vi.mocked(isMonorepoRoot).mockResolvedValue(false)
|
||||
vi.mocked(getMonorepoTargets).mockResolvedValue([])
|
||||
vi.mocked(getConfig).mockResolvedValue(
|
||||
{} as Awaited<ReturnType<typeof getConfig>>
|
||||
)
|
||||
vi.mocked(getProjectInfo).mockResolvedValue(
|
||||
{} as Awaited<ReturnType<typeof getProjectInfo>>
|
||||
)
|
||||
vi.mocked(resolveProjectPreset).mockResolvedValue({
|
||||
code: "b123",
|
||||
fallbacks: [],
|
||||
values: {
|
||||
style: "luma",
|
||||
baseColor: "mist",
|
||||
theme: "blue",
|
||||
chartColor: "emerald",
|
||||
iconLibrary: "phosphor",
|
||||
font: "inter",
|
||||
fontHeading: "lora",
|
||||
radius: "large",
|
||||
menuAccent: "bold",
|
||||
menuColor: "inverted",
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("decodes current preset codes", () => {
|
||||
const result = decodePresetCode("b0")
|
||||
|
||||
expect(result).toEqual({
|
||||
code: "b0",
|
||||
version: "b",
|
||||
values: {
|
||||
style: "nova",
|
||||
baseColor: "neutral",
|
||||
theme: "neutral",
|
||||
chartColor: "neutral",
|
||||
iconLibrary: "lucide",
|
||||
font: "inter",
|
||||
fontHeading: "inherit",
|
||||
radius: "default",
|
||||
menuAccent: "subtle",
|
||||
menuColor: "default",
|
||||
},
|
||||
derived: [],
|
||||
url: getPresetUrl("b0"),
|
||||
})
|
||||
})
|
||||
|
||||
it("applies compatibility values for older preset codes", () => {
|
||||
const result = decodePresetCode("a0")
|
||||
|
||||
expect(result.version).toBe("a")
|
||||
expect(result.values.chartColor).toBe("blue")
|
||||
expect(result.derived).toEqual(["chartColor"])
|
||||
})
|
||||
|
||||
it("prints compatibility markers in human output", () => {
|
||||
printPresetDecode(decodePresetCode("a0"))
|
||||
|
||||
expect(logger.log).toHaveBeenCalledWith("Preset")
|
||||
expect(logger.log).toHaveBeenCalledWith(" code a0")
|
||||
expect(logger.log).toHaveBeenCalledWith(" version a")
|
||||
expect(logger.log).toHaveBeenCalledWith(" chartColor blue*")
|
||||
expect(logger.log).toHaveBeenCalledWith(
|
||||
` url ${getPresetUrl("a0")}`
|
||||
)
|
||||
expect(logger.log).toHaveBeenCalledWith(
|
||||
" * Compatibility value for older preset versions."
|
||||
)
|
||||
})
|
||||
|
||||
it("matches decoded preset output ordering to resolved preset output", () => {
|
||||
printPresetDecode(decodePresetCode("b0"))
|
||||
|
||||
expect(logger.log).toHaveBeenCalledWith("Preset")
|
||||
expect(vi.mocked(logger.log).mock.calls.map((call) => call[0])).toEqual([
|
||||
"Preset",
|
||||
" code b0",
|
||||
" version b",
|
||||
" style nova",
|
||||
" baseColor neutral",
|
||||
" theme neutral",
|
||||
" chartColor neutral",
|
||||
" iconLibrary lucide",
|
||||
" font inter",
|
||||
" fontHeading inherit",
|
||||
" radius default",
|
||||
" menuAccent subtle",
|
||||
" menuColor default",
|
||||
` url ${getPresetUrl("b0")}`,
|
||||
])
|
||||
})
|
||||
|
||||
it("prints decoded preset JSON with derived values", async () => {
|
||||
const log = vi.spyOn(console, "log").mockImplementation(() => {})
|
||||
|
||||
await presetCommand.parseAsync(["decode", "a0", "--json"], { from: "user" })
|
||||
|
||||
expect(JSON.parse(log.mock.calls[0][0] as string)).toEqual({
|
||||
code: "a0",
|
||||
version: "a",
|
||||
values: {
|
||||
style: "nova",
|
||||
baseColor: "neutral",
|
||||
theme: "neutral",
|
||||
chartColor: "blue",
|
||||
iconLibrary: "lucide",
|
||||
font: "inter",
|
||||
fontHeading: "inherit",
|
||||
radius: "default",
|
||||
menuAccent: "subtle",
|
||||
menuColor: "default",
|
||||
},
|
||||
derived: ["chartColor"],
|
||||
url: getPresetUrl("a0"),
|
||||
})
|
||||
log.mockRestore()
|
||||
})
|
||||
|
||||
it("rejects invalid codes", () => {
|
||||
expect(() => decodePresetCode("c0")).toThrow("Invalid preset code: c0")
|
||||
})
|
||||
|
||||
it("rejects URLs", () => {
|
||||
expect(() =>
|
||||
decodePresetCode("https://ui.shadcn.com/create?preset=a0")
|
||||
).toThrow("Invalid preset code: https://ui.shadcn.com/create?preset=a0")
|
||||
})
|
||||
|
||||
it("prints the create URL from preset url", async () => {
|
||||
await url.parseAsync(["a0"], { from: "user" })
|
||||
|
||||
expect(logger.break).not.toHaveBeenCalled()
|
||||
expect(logger.log).toHaveBeenCalledWith(getPresetUrl("a0"))
|
||||
})
|
||||
|
||||
it("prints the create URL before opening it", async () => {
|
||||
vi.mocked(open).mockImplementation(async () => {
|
||||
expect(logger.log).toHaveBeenCalledWith(
|
||||
` Opening ${getPresetUrl("a0")} in your browser.`
|
||||
)
|
||||
return {} as Awaited<ReturnType<typeof open>>
|
||||
})
|
||||
|
||||
await openCommand.parseAsync(["a0"], { from: "user" })
|
||||
|
||||
expect(logger.break).toHaveBeenCalledTimes(2)
|
||||
expect(logger.log).toHaveBeenCalledWith(
|
||||
` Opening ${getPresetUrl("a0")} in your browser.`
|
||||
)
|
||||
expect(open).toHaveBeenCalledWith(getPresetUrl("a0"))
|
||||
})
|
||||
|
||||
it("does not open invalid preset codes", async () => {
|
||||
const exit = mockProcessExit()
|
||||
|
||||
await expect(
|
||||
openCommand.parseAsync(["https://ui.shadcn.com/create?preset=a0"], {
|
||||
from: "user",
|
||||
})
|
||||
).rejects.toThrow("process.exit:1")
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
"Invalid preset code: https://ui.shadcn.com/create?preset=a0"
|
||||
)
|
||||
expect(open).not.toHaveBeenCalled()
|
||||
exit.mockRestore()
|
||||
})
|
||||
|
||||
it("fails when opening the browser fails", async () => {
|
||||
const exit = mockProcessExit()
|
||||
vi.mocked(open).mockRejectedValue(new Error("open failed"))
|
||||
|
||||
await expect(
|
||||
openCommand.parseAsync(["a0"], { from: "user" })
|
||||
).rejects.toThrow("process.exit:1")
|
||||
|
||||
expect(logger.log).toHaveBeenCalledWith(
|
||||
` Opening ${getPresetUrl("a0")} in your browser.`
|
||||
)
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
"Failed to open preset URL: open failed"
|
||||
)
|
||||
exit.mockRestore()
|
||||
})
|
||||
|
||||
it("resolves a project preset using the existing resolver", async () => {
|
||||
await resolveCommand.parseAsync([], { from: "user" })
|
||||
|
||||
expect(logger.log).toHaveBeenCalledWith("Preset")
|
||||
expect(logger.log).toHaveBeenCalledWith(" code b123")
|
||||
expect(logger.log).toHaveBeenCalledWith(" version b")
|
||||
expect(logger.log).toHaveBeenCalledWith(expect.stringContaining("b123"))
|
||||
expect(logger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining("https://ui.shadcn.com/create?preset=b123")
|
||||
)
|
||||
expect(resolveProjectPreset).toHaveBeenCalledWith({}, {})
|
||||
})
|
||||
|
||||
it("prints fallback markers when resolving a project preset", async () => {
|
||||
vi.mocked(resolveProjectPreset).mockResolvedValueOnce({
|
||||
code: "b123",
|
||||
fallbacks: ["theme"],
|
||||
values: {
|
||||
style: "luma",
|
||||
baseColor: "mist",
|
||||
theme: "neutral",
|
||||
chartColor: "emerald",
|
||||
iconLibrary: "phosphor",
|
||||
font: "inter",
|
||||
fontHeading: "lora",
|
||||
radius: "large",
|
||||
menuAccent: "bold",
|
||||
menuColor: "inverted",
|
||||
},
|
||||
})
|
||||
|
||||
await resolveCommand.parseAsync([], { from: "user" })
|
||||
|
||||
expect(logger.log).toHaveBeenCalledWith(expect.stringContaining("neutral*"))
|
||||
expect(logger.log).toHaveBeenCalledWith(
|
||||
" * Uses preset defaults for values not available as options on shadcn/create."
|
||||
)
|
||||
})
|
||||
|
||||
it("prints the resolved preset as JSON", async () => {
|
||||
const log = vi.spyOn(console, "log").mockImplementation(() => {})
|
||||
|
||||
await resolveCommand.parseAsync(["--json"], { from: "user" })
|
||||
|
||||
expect(JSON.parse(log.mock.calls[0][0] as string)).toEqual({
|
||||
code: "b123",
|
||||
fallbacks: [],
|
||||
values: {
|
||||
style: "luma",
|
||||
baseColor: "mist",
|
||||
theme: "blue",
|
||||
chartColor: "emerald",
|
||||
iconLibrary: "phosphor",
|
||||
font: "inter",
|
||||
fontHeading: "lora",
|
||||
radius: "large",
|
||||
menuAccent: "bold",
|
||||
menuColor: "inverted",
|
||||
},
|
||||
})
|
||||
log.mockRestore()
|
||||
})
|
||||
|
||||
it("prints fallback metadata in resolved preset JSON", async () => {
|
||||
const log = vi.spyOn(console, "log").mockImplementation(() => {})
|
||||
vi.mocked(resolveProjectPreset).mockResolvedValueOnce({
|
||||
code: "b123",
|
||||
fallbacks: ["theme"],
|
||||
values: {
|
||||
style: "luma",
|
||||
baseColor: "mist",
|
||||
theme: "neutral",
|
||||
chartColor: "emerald",
|
||||
iconLibrary: "phosphor",
|
||||
font: "inter",
|
||||
fontHeading: "lora",
|
||||
radius: "large",
|
||||
menuAccent: "bold",
|
||||
menuColor: "inverted",
|
||||
},
|
||||
})
|
||||
|
||||
await resolveCommand.parseAsync(["--json"], { from: "user" })
|
||||
|
||||
expect(JSON.parse(log.mock.calls[0][0] as string)).toMatchObject({
|
||||
fallbacks: ["theme"],
|
||||
values: {
|
||||
theme: "neutral",
|
||||
},
|
||||
})
|
||||
log.mockRestore()
|
||||
})
|
||||
|
||||
it("supports preset info as an alias for preset resolve", async () => {
|
||||
const log = vi.spyOn(console, "log").mockImplementation(() => {})
|
||||
|
||||
await presetCommand.parseAsync(["info", "--json"], { from: "user" })
|
||||
|
||||
expect(JSON.parse(log.mock.calls[0][0] as string)).toMatchObject({
|
||||
code: "b123",
|
||||
})
|
||||
log.mockRestore()
|
||||
})
|
||||
|
||||
it("resolves presets from the provided cwd", async () => {
|
||||
await resolveCommand.parseAsync(["--cwd", "apps/web"], { from: "user" })
|
||||
|
||||
expect(getConfig).toHaveBeenCalledWith(path.resolve("apps/web"))
|
||||
expect(getProjectInfo).toHaveBeenCalledWith(path.resolve("apps/web"))
|
||||
})
|
||||
|
||||
it("prints no-config output when no components.json exists", async () => {
|
||||
vi.mocked(existsSync).mockReturnValue(false)
|
||||
vi.mocked(getConfig).mockResolvedValueOnce(null)
|
||||
|
||||
await resolveCommand.parseAsync([], { from: "user" })
|
||||
|
||||
expect(logger.log).toHaveBeenCalledWith("No components.json found.")
|
||||
})
|
||||
|
||||
it("prints null JSON when no components.json exists", async () => {
|
||||
const log = vi.spyOn(console, "log").mockImplementation(() => {})
|
||||
vi.mocked(existsSync).mockReturnValue(false)
|
||||
vi.mocked(getConfig).mockResolvedValueOnce(null)
|
||||
|
||||
await resolveCommand.parseAsync(["--json"], { from: "user" })
|
||||
|
||||
expect(JSON.parse(log.mock.calls[0][0] as string)).toBeNull()
|
||||
log.mockRestore()
|
||||
})
|
||||
|
||||
it("prints null JSON when no preset can be resolved", async () => {
|
||||
const log = vi.spyOn(console, "log").mockImplementation(() => {})
|
||||
vi.mocked(resolveProjectPreset).mockResolvedValueOnce({
|
||||
code: null,
|
||||
fallbacks: [],
|
||||
values: null,
|
||||
})
|
||||
|
||||
await resolveCommand.parseAsync(["--json"], { from: "user" })
|
||||
|
||||
expect(JSON.parse(log.mock.calls[0][0] as string)).toBeNull()
|
||||
log.mockRestore()
|
||||
})
|
||||
|
||||
it("matches info monorepo-root behavior", async () => {
|
||||
const exit = mockProcessExit()
|
||||
const targets = [{ name: "apps/web" }]
|
||||
vi.mocked(existsSync).mockReturnValue(false)
|
||||
vi.mocked(isMonorepoRoot).mockResolvedValue(true)
|
||||
vi.mocked(getMonorepoTargets).mockResolvedValue(targets as never)
|
||||
|
||||
await expect(
|
||||
resolveCommand.parseAsync([], { from: "user" })
|
||||
).rejects.toThrow("process.exit:1")
|
||||
|
||||
expect(formatMonorepoMessage).toHaveBeenCalledWith(
|
||||
"preset resolve",
|
||||
targets
|
||||
)
|
||||
expect(getConfig).not.toHaveBeenCalled()
|
||||
exit.mockRestore()
|
||||
})
|
||||
|
||||
it("prints monorepo-root JSON for preset resolve --json", async () => {
|
||||
const exit = mockProcessExit()
|
||||
const log = vi.spyOn(console, "log").mockImplementation(() => {})
|
||||
const targets = [{ name: "apps/web" }]
|
||||
vi.mocked(existsSync).mockReturnValue(false)
|
||||
vi.mocked(isMonorepoRoot).mockResolvedValue(true)
|
||||
vi.mocked(getMonorepoTargets).mockResolvedValue(targets as never)
|
||||
|
||||
await expect(
|
||||
resolveCommand.parseAsync(["--json"], { from: "user" })
|
||||
).rejects.toThrow("process.exit:1")
|
||||
|
||||
expect(JSON.parse(log.mock.calls[0][0] as string)).toEqual({
|
||||
error: "monorepo_root",
|
||||
message:
|
||||
"You are running preset resolve from a monorepo root. Use the -c flag to specify a workspace.",
|
||||
targets: ["apps/web"],
|
||||
})
|
||||
expect(formatMonorepoMessage).not.toHaveBeenCalled()
|
||||
expect(getConfig).not.toHaveBeenCalled()
|
||||
log.mockRestore()
|
||||
exit.mockRestore()
|
||||
})
|
||||
|
||||
it("prints help when no preset subcommand is provided", async () => {
|
||||
const outputHelp = vi
|
||||
.spyOn(presetCommand, "outputHelp")
|
||||
.mockImplementation(() => {})
|
||||
|
||||
await presetCommand.parseAsync([], { from: "user" })
|
||||
|
||||
expect(outputHelp).toHaveBeenCalledOnce()
|
||||
outputHelp.mockRestore()
|
||||
})
|
||||
|
||||
it("errors on unknown preset subcommands", async () => {
|
||||
const exit = mockProcessExit()
|
||||
const outputHelp = vi
|
||||
.spyOn(presetCommand, "outputHelp")
|
||||
.mockImplementation(() => {})
|
||||
const writeErr = vi
|
||||
.spyOn(process.stderr, "write")
|
||||
.mockImplementation(() => true)
|
||||
|
||||
await expect(
|
||||
presetCommand.parseAsync(["bogus"], { from: "user" })
|
||||
).rejects.toThrow("process.exit:1")
|
||||
|
||||
expect(outputHelp).not.toHaveBeenCalled()
|
||||
expect(writeErr.mock.calls.join("")).toContain("too many arguments")
|
||||
writeErr.mockRestore()
|
||||
outputHelp.mockRestore()
|
||||
exit.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
function mockProcessExit() {
|
||||
return vi.spyOn(process, "exit").mockImplementation((code) => {
|
||||
throw new Error(`process.exit:${code}`)
|
||||
})
|
||||
}
|
||||
231
packages/shadcn/src/commands/preset.ts
Normal file
231
packages/shadcn/src/commands/preset.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import { existsSync } from "fs"
|
||||
import path from "path"
|
||||
import { printPresetInfo } from "@/src/commands/info"
|
||||
import {
|
||||
decodePreset,
|
||||
V1_CHART_COLOR_MAP,
|
||||
type PresetConfig,
|
||||
} from "@/src/preset/preset"
|
||||
import { resolveProjectPreset } from "@/src/preset/resolve"
|
||||
import { SHADCN_URL } from "@/src/registry/constants"
|
||||
import { getConfig } from "@/src/utils/get-config"
|
||||
import {
|
||||
formatMonorepoMessage,
|
||||
getMonorepoTargets,
|
||||
isMonorepoRoot,
|
||||
} from "@/src/utils/get-monorepo-info"
|
||||
import { getProjectInfo } from "@/src/utils/get-project-info"
|
||||
import { handleError } from "@/src/utils/handle-error"
|
||||
import { logger } from "@/src/utils/logger"
|
||||
import { Command } from "commander"
|
||||
import open from "open"
|
||||
|
||||
type PresetValues = Omit<PresetConfig, "chartColor"> & {
|
||||
chartColor: NonNullable<PresetConfig["chartColor"]>
|
||||
}
|
||||
|
||||
type PresetDecodeResult = {
|
||||
code: string
|
||||
version: string
|
||||
values: PresetValues
|
||||
derived: string[]
|
||||
url: string
|
||||
}
|
||||
|
||||
export function getPresetUrl(code: string) {
|
||||
return `${SHADCN_URL}/create?preset=${code}`
|
||||
}
|
||||
|
||||
export function decodePresetCode(code: string): PresetDecodeResult {
|
||||
const decoded = decodePreset(code)
|
||||
|
||||
if (!decoded) {
|
||||
throw new Error(`Invalid preset code: ${code}`)
|
||||
}
|
||||
|
||||
const derived: string[] = []
|
||||
const chartColor = (decoded.chartColor ??
|
||||
V1_CHART_COLOR_MAP[decoded.theme] ??
|
||||
decoded.theme) as PresetValues["chartColor"]
|
||||
|
||||
if (!decoded.chartColor) {
|
||||
derived.push("chartColor")
|
||||
}
|
||||
|
||||
return {
|
||||
code,
|
||||
version: code[0],
|
||||
values: {
|
||||
...decoded,
|
||||
chartColor,
|
||||
},
|
||||
derived,
|
||||
url: getPresetUrl(code),
|
||||
}
|
||||
}
|
||||
|
||||
export function printPresetDecode(result: PresetDecodeResult) {
|
||||
printPresetInfo(
|
||||
{
|
||||
code: result.code,
|
||||
fallbacks: result.derived,
|
||||
values: result.values,
|
||||
},
|
||||
{
|
||||
fallbackNote: " * Compatibility value for older preset versions.",
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export const decode = new Command()
|
||||
.name("decode")
|
||||
.description("decode a preset code")
|
||||
.argument("<code>", "the preset code to decode")
|
||||
.option("--json", "output as JSON.", false)
|
||||
.action((code, opts) => {
|
||||
try {
|
||||
const result = decodePresetCode(code)
|
||||
|
||||
if (opts.json) {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
code: result.code,
|
||||
version: result.version,
|
||||
values: result.values,
|
||||
derived: result.derived,
|
||||
url: result.url,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
printPresetDecode(result)
|
||||
} catch (error) {
|
||||
handlePresetError(error)
|
||||
}
|
||||
})
|
||||
|
||||
export const url = new Command()
|
||||
.name("url")
|
||||
.description("get the create URL for a preset code")
|
||||
.argument("<code>", "the preset code")
|
||||
.action((code) => {
|
||||
try {
|
||||
logger.log(decodePresetCode(code).url)
|
||||
} catch (error) {
|
||||
handlePresetError(error)
|
||||
}
|
||||
})
|
||||
|
||||
export const openCommand = new Command()
|
||||
.name("open")
|
||||
.description("open a preset code in the browser")
|
||||
.argument("<code>", "the preset code")
|
||||
.action(async (code) => {
|
||||
let presetUrl: string
|
||||
|
||||
try {
|
||||
presetUrl = decodePresetCode(code).url
|
||||
} catch (error) {
|
||||
handlePresetError(error)
|
||||
return
|
||||
}
|
||||
|
||||
logger.break()
|
||||
logger.log(` Opening ${presetUrl} in your browser.`)
|
||||
logger.break()
|
||||
|
||||
try {
|
||||
await open(presetUrl)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
handlePresetError(new Error(`Failed to open preset URL: ${message}`))
|
||||
}
|
||||
})
|
||||
|
||||
export const resolve = new Command()
|
||||
.name("resolve")
|
||||
.alias("info")
|
||||
.description("resolve a preset from your project")
|
||||
.option(
|
||||
"-c, --cwd <cwd>",
|
||||
"the working directory. defaults to the current directory.",
|
||||
process.cwd()
|
||||
)
|
||||
.option("--json", "output as JSON.", false)
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
const cwd = path.resolve(opts.cwd)
|
||||
|
||||
if (
|
||||
!existsSync(path.resolve(cwd, "components.json")) &&
|
||||
(await isMonorepoRoot(cwd))
|
||||
) {
|
||||
const targets = await getMonorepoTargets(cwd)
|
||||
if (targets.length > 0) {
|
||||
if (opts.json) {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
error: "monorepo_root",
|
||||
message:
|
||||
"You are running preset resolve from a monorepo root. Use the -c flag to specify a workspace.",
|
||||
targets: targets.map((target) => target.name),
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
} else {
|
||||
formatMonorepoMessage("preset resolve", targets)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
const config = await getConfig(cwd)
|
||||
if (!config) {
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(null, null, 2))
|
||||
return
|
||||
}
|
||||
|
||||
logger.log("No components.json found.")
|
||||
return
|
||||
}
|
||||
|
||||
const projectInfo = await getProjectInfo(cwd)
|
||||
const preset = await resolveProjectPreset(config, projectInfo)
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(preset.code ? preset : null, null, 2))
|
||||
return
|
||||
}
|
||||
|
||||
printPresetInfo(preset)
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
}
|
||||
})
|
||||
|
||||
export const preset = new Command()
|
||||
.name("preset")
|
||||
.description("manage presets")
|
||||
.addCommand(decode)
|
||||
.addCommand(resolve)
|
||||
.addCommand(url)
|
||||
.addCommand(openCommand)
|
||||
.action(() => {
|
||||
preset.outputHelp()
|
||||
})
|
||||
|
||||
function handlePresetError(error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
logger.error(error.message)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { info } from "@/src/commands/info"
|
||||
import { init } from "@/src/commands/init"
|
||||
import { mcp } from "@/src/commands/mcp"
|
||||
import { migrate } from "@/src/commands/migrate"
|
||||
import { preset } from "@/src/commands/preset"
|
||||
import { registry } from "@/src/commands/registry"
|
||||
import { search } from "@/src/commands/search"
|
||||
import { view } from "@/src/commands/view"
|
||||
@@ -40,6 +41,7 @@ async function main() {
|
||||
.addCommand(info)
|
||||
.addCommand(build)
|
||||
.addCommand(mcp)
|
||||
.addCommand(preset)
|
||||
.addCommand(registry)
|
||||
|
||||
program.parse()
|
||||
|
||||
@@ -13,7 +13,7 @@ import { createRegistryServer } from "../utils/registry"
|
||||
describe("shadcn init - next-app", () => {
|
||||
it("should init with default configuration", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app")
|
||||
await npxShadcn(fixturePath, ["init", "--defaults"])
|
||||
await npxShadcn(fixturePath, ["init", "--defaults"], { timeout: 120000 })
|
||||
|
||||
const componentsJsonPath = path.join(fixturePath, "components.json")
|
||||
expect(await fs.pathExists(componentsJsonPath)).toBe(true)
|
||||
@@ -54,7 +54,9 @@ describe("shadcn init - next-app", () => {
|
||||
|
||||
it("should init without CSS variables", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app")
|
||||
await npxShadcn(fixturePath, ["init", "--defaults", "--no-css-variables"])
|
||||
await npxShadcn(fixturePath, ["init", "--defaults", "--no-css-variables"], {
|
||||
timeout: 120000,
|
||||
})
|
||||
|
||||
const componentsJson = await fs.readJson(
|
||||
path.join(fixturePath, "components.json")
|
||||
@@ -64,7 +66,9 @@ describe("shadcn init - next-app", () => {
|
||||
|
||||
it("should init with components", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app")
|
||||
await npxShadcn(fixturePath, ["init", "--defaults", "button"])
|
||||
await npxShadcn(fixturePath, ["init", "--defaults", "button"], {
|
||||
timeout: 120000,
|
||||
})
|
||||
|
||||
expect(
|
||||
await fs.pathExists(path.join(fixturePath, "components/ui/button.tsx"))
|
||||
|
||||
@@ -77,7 +77,8 @@ These rules are **always enforced**. Each links to a file with Incorrect/Correct
|
||||
|
||||
### CLI
|
||||
|
||||
- **Never decode or fetch preset codes manually.** Pass them directly to `npx shadcn@latest apply --preset <code>` for existing projects, or `npx shadcn@latest init --preset <code>` when initializing.
|
||||
- **Never decode preset codes or build preset URLs manually.** Use `npx shadcn@latest preset decode <code>`, `preset url <code>`, or `preset open <code>`. For project-aware preset detection, use `npx shadcn@latest preset resolve`.
|
||||
- **Apply preset codes directly with the CLI.** Use `npx shadcn@latest apply <code>` for existing projects, or `npx shadcn@latest init --preset <code>` when initializing.
|
||||
|
||||
## Key Patterns
|
||||
|
||||
@@ -150,6 +151,7 @@ The injected project context contains these key fields:
|
||||
- **`resolvedPaths`** → exact file-system destinations for components, utils, hooks, etc.
|
||||
- **`framework`** → routing and file conventions (e.g. Next.js App Router vs Vite SPA).
|
||||
- **`packageManager`** → use this for any non-shadcn dependency installs (e.g. `pnpm add date-fns` vs `npm install date-fns`).
|
||||
- **`preset`** → resolved preset code and values for the current project. Use `npx shadcn@latest preset resolve --json` when you only need preset information.
|
||||
|
||||
See [cli.md — `info` command](./cli.md) for the full field reference.
|
||||
|
||||
@@ -174,8 +176,10 @@ npx shadcn@latest docs button dialog select
|
||||
7. **Review added components** — After adding a component or block from any registry, **always read the added files and verify they are correct**. Check for missing sub-components (e.g. `SelectItem` without `SelectGroup`), missing imports, incorrect composition, or violations of the [Critical Rules](#critical-rules). Also replace any icon imports with the project's `iconLibrary` from the project context (e.g. if the registry item uses `lucide-react` but the project uses `hugeicons`, swap the imports and icon names accordingly). Fix all issues before moving on.
|
||||
8. **Registry must be explicit** — When the user asks to add a block or component, **do not guess the registry**. If no registry is specified (e.g. user says "add a login block" without specifying `@shadcn`, `@tailark`, etc.), ask which registry to use. Never default to a registry on behalf of the user.
|
||||
9. **Switching presets** — Ask the user first: **overwrite**, **partial**, **merge**, or **skip**?
|
||||
- **Overwrite**: `npx shadcn@latest apply --preset <code>`. Overwrites detected components, fonts, and CSS variables.
|
||||
- **Partial**: `npx shadcn@latest apply --preset <code> --only theme,font`. Updates only the selected preset parts without reinstalling UI components. Supported values are `theme` and `font`; comma-separated combinations are allowed. `icon` is intentionally not supported, because icon changes may require full component reinstall and transforms.
|
||||
- **Inspect current preset**: `npx shadcn@latest preset resolve`. Use `--json` when you need structured values.
|
||||
- **Inspect incoming preset**: `npx shadcn@latest preset decode <code>`. Use `preset url <code>` or `preset open <code>` to share or open the preset builder.
|
||||
- **Overwrite**: `npx shadcn@latest apply <code>`. Overwrites detected components, fonts, and CSS variables.
|
||||
- **Partial**: `npx shadcn@latest apply <code> --only theme,font`. Updates only the selected preset parts without reinstalling UI components. Supported values are `theme` and `font`; comma-separated combinations are allowed. `icon` is intentionally not supported, because icon changes may require full component reinstall and transforms.
|
||||
- **Merge**: `npx shadcn@latest init --preset <code> --force --no-reinstall`, then run `npx shadcn@latest info` to list installed components, then for each installed component use `--dry-run` and `--diff` to [smart merge](#updating-components) it individually.
|
||||
- **Skip**: `npx shadcn@latest init --preset <code> --force --no-reinstall`. Only updates config and CSS, leaves components as-is.
|
||||
- **Important**: Always run preset commands inside the user's project directory. `apply` only works in an existing project with a `components.json` file. The CLI automatically preserves the current base (`base` vs `radix`) from `components.json`. If you must use a scratch/temp directory (e.g. for `--dry-run` comparisons), pass `--base <current-base>` explicitly — preset codes do not encode the base.
|
||||
@@ -208,11 +212,17 @@ npx shadcn@latest init --preset base-nova
|
||||
npx shadcn@latest init --defaults # shortcut: --template=next --preset=nova (base style implied)
|
||||
|
||||
# Apply a preset to an existing project.
|
||||
npx shadcn@latest apply --preset a2r6bw
|
||||
npx shadcn@latest apply a2r6bw
|
||||
npx shadcn@latest apply --preset a2r6bw --only theme
|
||||
npx shadcn@latest apply --preset a2r6bw --only font
|
||||
npx shadcn@latest apply --preset a2r6bw --only theme,font
|
||||
npx shadcn@latest apply a2r6bw --only theme
|
||||
npx shadcn@latest apply a2r6bw --only font
|
||||
npx shadcn@latest apply a2r6bw --only theme,font
|
||||
|
||||
# Inspect preset codes and project preset state.
|
||||
npx shadcn@latest preset decode a2r6bw
|
||||
npx shadcn@latest preset url a2r6bw
|
||||
npx shadcn@latest preset open a2r6bw
|
||||
npx shadcn@latest preset resolve
|
||||
npx shadcn@latest preset resolve --json
|
||||
|
||||
# Add components.
|
||||
npx shadcn@latest add button card dialog
|
||||
|
||||
Reference in New Issue
Block a user