diff --git a/.changeset/odd-keys-visit.md b/.changeset/odd-keys-visit.md new file mode 100644 index 0000000000..21baf3d368 --- /dev/null +++ b/.changeset/odd-keys-visit.md @@ -0,0 +1,5 @@ +--- +"shadcn": minor +--- + +add shadcn preset commands diff --git a/apps/v4/components/announcement.tsx b/apps/v4/components/announcement.tsx index e32fbe83b0..33d1c38481 100644 --- a/apps/v4/components/announcement.tsx +++ b/apps/v4/components/announcement.tsx @@ -6,8 +6,8 @@ import { Badge } from "@/registry/new-york-v4/ui/badge" export function Announcement() { return ( - - Introducing Sera + + New preset commands ) diff --git a/apps/v4/content/docs/(root)/cli.mdx b/apps/v4/content/docs/(root)/cli.mdx index 86f283249e..dbf7019ad4 100644 --- a/apps/v4/content/docs/(root)/cli.mdx +++ b/apps/v4/content/docs/(root)/cli.mdx @@ -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] + +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 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] + +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] + +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. diff --git a/apps/v4/content/docs/changelog/2026-04-preset-commands.mdx b/apps/v4/content/docs/changelog/2026-04-preset-commands.mdx new file mode 100644 index 0000000000..f34d45cb42 --- /dev/null +++ b/apps/v4/content/docs/changelog/2026-04-preset-commands.mdx @@ -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. + + diff --git a/packages/shadcn/src/commands/info.ts b/packages/shadcn/src/commands/info.ts index 6fca34c483..a23026d014 100644 --- a/packages/shadcn/src/commands/info.ts +++ b/packages/shadcn/src/commands/info.ts @@ -191,58 +191,7 @@ export function printInfo(data: Awaited>) { }) 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>) { logger.break() } +export function printPresetInfo( + preset: Awaited>["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) { - 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}`) } diff --git a/packages/shadcn/src/commands/preset.test.ts b/packages/shadcn/src/commands/preset.test.ts new file mode 100644 index 0000000000..c9115165bd --- /dev/null +++ b/packages/shadcn/src/commands/preset.test.ts @@ -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() + 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> + ) + vi.mocked(getProjectInfo).mockResolvedValue( + {} as Awaited> + ) + 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> + }) + + 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}`) + }) +} diff --git a/packages/shadcn/src/commands/preset.ts b/packages/shadcn/src/commands/preset.ts new file mode 100644 index 0000000000..b3e237f75c --- /dev/null +++ b/packages/shadcn/src/commands/preset.ts @@ -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 & { + chartColor: NonNullable +} + +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("", "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("", "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("", "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 ", + "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) +} diff --git a/packages/shadcn/src/index.ts b/packages/shadcn/src/index.ts index 31b45b46bf..c0e9b6ca4a 100644 --- a/packages/shadcn/src/index.ts +++ b/packages/shadcn/src/index.ts @@ -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() diff --git a/packages/tests/src/tests/init.test.ts b/packages/tests/src/tests/init.test.ts index 2497efb512..7c8abc47cc 100644 --- a/packages/tests/src/tests/init.test.ts +++ b/packages/tests/src/tests/init.test.ts @@ -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")) diff --git a/skills/shadcn/SKILL.md b/skills/shadcn/SKILL.md index 2aab0b0711..016f824d17 100644 --- a/skills/shadcn/SKILL.md +++ b/skills/shadcn/SKILL.md @@ -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 ` for existing projects, or `npx shadcn@latest init --preset ` when initializing. +- **Never decode preset codes or build preset URLs manually.** Use `npx shadcn@latest preset decode `, `preset url `, or `preset open `. For project-aware preset detection, use `npx shadcn@latest preset resolve`. +- **Apply preset codes directly with the CLI.** Use `npx shadcn@latest apply ` for existing projects, or `npx shadcn@latest init --preset ` 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 `. Overwrites detected components, fonts, and CSS variables. - - **Partial**: `npx shadcn@latest apply --preset --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 `. Use `preset url ` or `preset open ` to share or open the preset builder. + - **Overwrite**: `npx shadcn@latest apply `. Overwrites detected components, fonts, and CSS variables. + - **Partial**: `npx shadcn@latest apply --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 --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 --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 ` 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