feat: add apply command

This commit is contained in:
shadcn
2026-04-06 23:07:44 +04:00
parent 62f6df75f2
commit c1e29824cd
17 changed files with 1010 additions and 117 deletions

View File

@@ -0,0 +1,5 @@
---
"shadcn": minor
---
add shadcn apply command

View File

@@ -5,10 +5,12 @@ description: Use the shadcn CLI to add components to your project.
## init
Use the `init` command to initialize configuration and dependencies for a new project.
Use the `init` command to initialize configuration and dependencies for an existing project, or create a new project with `--name`.
The `init` command installs dependencies, adds the `cn` util and configures CSS variables for the project.
For preset reapplication in an existing project, use [`apply`](#apply) when you want to overwrite preset-driven files and reinstall detected UI components. Use `init --force --no-reinstall` when you want to update config and CSS without reinstalling existing components.
```bash
npx shadcn@latest init
```
@@ -16,31 +18,31 @@ npx shadcn@latest init
**Options**
```bash
Usage: shadcn init [options] [components...]
Usage: shadcn init|create [options] [components...]
initialize your project and install dependencies
Arguments:
components name, url or local path to component
components names, url or local path to component
Options:
-t, --template <template> the template to use. (next, vite, start, react-router, laravel, astro)
-b, --base <base> the component library to use. (radix, base)
-p, --preset [name] use a preset configuration. (name, URL, or preset code)
-n, --name <name> the name for the new project.
-d, --defaults use default configuration. (default: false)
-y, --yes skip confirmation prompt. (default: true)
-f, --force force overwrite of existing configuration. (default: false)
-c, --cwd <cwd> the working directory. defaults to the current directory.
-s, --silent mute output. (default: false)
--monorepo scaffold a monorepo project.
--no-monorepo skip the monorepo prompt.
--reinstall re-install existing UI components.
--no-reinstall do not re-install existing UI components.
--rtl enable RTL support.
--no-rtl disable RTL support.
-p, --preset [name] use a preset configuration
-y, --yes skip confirmation prompt. (default: true)
-d, --defaults use default configuration: --template=next --preset=base-nova (default: false)
-f, --force force overwrite of existing configuration. (default: false)
-c, --cwd <cwd> the working directory. defaults to the current directory.
-n, --name <name> the name for the new project.
-s, --silent mute output. (default: false)
--css-variables use css variables for theming. (default: true)
--no-css-variables do not use css variables for theming.
--rtl enable RTL support.
--no-rtl disable RTL support.
--reinstall re-install existing UI components.
--no-reinstall do not re-install existing UI components.
-h, --help display help for command
```
@@ -52,6 +54,54 @@ npx shadcn@latest create
---
## apply
Use the `apply` command to apply a preset to an existing project.
`apply` overwrites preset-driven config, fonts, CSS variables, and detected installed UI components. Commit or stash your changes before continuing so you can easily go back.
`apply` requires an existing `components.json`. If your project is not initialized yet, run `shadcn init --preset <preset>` first.
```bash
npx shadcn@latest apply --preset a2r6bw
```
You can also pass the preset as a positional argument:
```bash
npx shadcn@latest apply a2r6bw
```
Named presets work the same way:
```bash
npx shadcn@latest apply lyra
```
If no preset is provided, the CLI offers to open the custom preset builder on `ui.shadcn.com/create`.
Use `apply` for overwrite/reinstall flows. If you want to refresh config and CSS without reinstalling existing components, use `init --force --no-reinstall` instead.
**Options**
```bash
Usage: shadcn apply [options] [preset]
apply a preset to an existing project
Arguments:
preset the preset to apply
Options:
--preset <preset> preset configuration to apply
-y, --yes skip confirmation prompt. (default: false)
-c, --cwd <cwd> the working directory. defaults to the current directory.
--silent mute output. (default: false)
-h, --help display help for command
```
---
## add
Use the `add` command to add components and dependencies to your project.

View File

@@ -20,6 +20,16 @@ The `init` command installs dependencies, adds the `cn` util, configures Tailwin
npx shadcn init
```
## apply
Use the `apply` command to apply a preset to an existing project.
The `apply` command overwrites the current preset configuration, reinstalls detected UI components, and updates fonts and CSS variables to match the new preset.
```bash
npx shadcn apply --preset a2r6bw
```
## add
Use the `add` command to add components to your project.

View File

@@ -0,0 +1,47 @@
import { REGISTRY_URL } from "@/src/registry/constants"
import { describe, expect, it } from "vitest"
import { resolveApplyInitUrl } from "./apply"
const SHADCN_URL = REGISTRY_URL.replace(/\/r\/?$/, "")
describe("resolveApplyInitUrl", () => {
it("should include the inferred template for preset codes", () => {
const { initUrl, presetBase } = resolveApplyInitUrl("a0", "base", {
template: "next",
rtl: true,
})
const parsed = new URL(initUrl)
expect(parsed.origin + parsed.pathname).toBe(`${SHADCN_URL}/init`)
expect(parsed.searchParams.get("template")).toBe("next")
expect(parsed.searchParams.get("preset")).toBe("a0")
expect(parsed.searchParams.get("rtl")).toBe("true")
expect(presetBase).toBeUndefined()
})
it("should include the inferred template for named presets", () => {
const { initUrl, presetBase } = resolveApplyInitUrl("lyra", "base", {
template: "next",
rtl: true,
})
const parsed = new URL(initUrl)
expect(parsed.origin + parsed.pathname).toBe(`${SHADCN_URL}/init`)
expect(parsed.searchParams.get("template")).toBe("next")
expect(parsed.searchParams.get("base")).toBe("base")
expect(parsed.searchParams.get("rtl")).toBe("true")
expect(presetBase).toBeUndefined()
})
it("should preserve raw preset URLs without injecting a template", () => {
const presetUrl = `${SHADCN_URL}/init?base=radix&style=nova&baseColor=neutral&theme=neutral&iconLibrary=lucide&font=inter&rtl=false&menuAccent=subtle&menuColor=default&radius=default`
const { initUrl } = resolveApplyInitUrl(presetUrl, "base", {
template: "next",
})
const parsed = new URL(initUrl)
expect(parsed.searchParams.get("template")).toBeNull()
expect(parsed.searchParams.get("track")).toBe("1")
})
})

View File

@@ -0,0 +1,325 @@
import path from "path"
import { confirmBaseSwitch, runInit } from "@/src/commands/init"
import { preFlightApply } from "@/src/preflights/preflight-apply"
import { decodePreset, isPresetCode } from "@/src/preset/preset"
import {
DEFAULT_PRESETS,
promptToOpenPresetBuilder,
resolveCreateUrl,
resolveInitUrl,
resolveRegistryBaseConfig,
} from "@/src/preset/presets"
import { SHADCN_URL } from "@/src/registry/constants"
import { clearRegistryContext } from "@/src/registry/context"
import { registryConfigSchema } from "@/src/registry/schema"
import { isUrl } from "@/src/registry/utils"
import { getTemplateForFramework } from "@/src/templates/index"
import { loadEnvFiles } from "@/src/utils/env-loader"
import * as ERRORS from "@/src/utils/errors"
import { withFileBackup } from "@/src/utils/file-helper"
import { getBase } from "@/src/utils/get-config"
import {
getProjectComponents,
getProjectInfo,
} from "@/src/utils/get-project-info"
import { handleError } from "@/src/utils/handle-error"
import { highlighter } from "@/src/utils/highlighter"
import { logger } from "@/src/utils/logger"
import { Command } from "commander"
import prompts from "prompts"
import { z } from "zod"
export const applyOptionsSchema = z.object({
cwd: z.string(),
positionalPreset: z.string().optional(),
preset: z.string().optional(),
yes: z.boolean(),
silent: z.boolean(),
})
export const apply = new Command()
.name("apply")
.description("apply a preset to an existing project")
.argument("[preset]", "the preset to apply")
.option("--preset <preset>", "preset configuration to apply")
.option("-y, --yes", "skip confirmation prompt.", false)
.option(
"-c, --cwd <cwd>",
"the working directory. defaults to the current directory.",
process.cwd()
)
.option("--silent", "mute output.", false)
.action(async (positionalPreset, opts) => {
try {
const options = applyOptionsSchema.parse({
...opts,
cwd: path.resolve(opts.cwd),
positionalPreset,
})
const preset = resolveApplyPreset(options)
const preflight = await preFlightApply(options)
if (preflight.errors[ERRORS.MISSING_DIR_OR_EMPTY_PROJECT]) {
logger.break()
logger.error(
`The ${highlighter.info(
"apply"
)} command only works in an existing project.`
)
logger.error(`Run ${highlighter.info(getInitCommand(preset))} first.`)
logger.break()
process.exit(1)
}
if (preflight.errors[ERRORS.MISSING_CONFIG]) {
logger.break()
logger.error(
`No ${highlighter.info("components.json")} found at ${highlighter.info(
options.cwd
)}.`
)
logger.error(`Run ${highlighter.info(getInitCommand(preset))} first.`)
logger.break()
process.exit(1)
}
const existingConfig = preflight.config
if (!existingConfig) {
process.exit(1)
}
const rtl = existingConfig.rtl ?? false
const template = await resolveApplyTemplate(options.cwd)
if (!preset) {
const createUrl = resolveCreateUrl({
command: "init",
template,
base: getBase(existingConfig.style),
rtl,
})
await promptToOpenPresetBuilder({
createUrl,
followUp: `Then run ${highlighter.info(
"shadcn apply --preset <preset>"
)} with the preset code or preset URL from ui.shadcn.com.`,
prompt: !options.yes,
})
process.exit(0)
}
validatePreset(preset)
const reinstallComponents = await getProjectComponents(options.cwd)
if (!options.yes) {
logger.break()
logger.warn(
highlighter.warn(
`Applying a new preset will overwrite existing UI components, fonts, and CSS variables.`
)
)
logger.warn(
`Commit or stash your changes before continuing so you can easily go back.`
)
logger.break()
logger.log(" The following components will be re-installed:")
if (reinstallComponents.length) {
for (let i = 0; i < reinstallComponents.length; i += 8) {
logger.log(` - ${reinstallComponents.slice(i, i + 8).join(", ")}`)
}
} else {
logger.log(" - No installed UI components were detected.")
}
logger.break()
const { proceed } = await prompts({
type: "confirm",
name: "proceed",
message: "Would you like to continue?",
initial: false,
})
if (!proceed) {
logger.break()
process.exit(1)
}
}
await loadEnvFiles(options.cwd)
const currentBase = getBase(existingConfig.style)
const { initUrl, presetBase } = resolveApplyInitUrl(preset, currentBase, {
template,
rtl,
})
let resolvedBase = presetBase ?? currentBase
resolvedBase = await confirmBaseSwitch(existingConfig.style, resolvedBase)
const nextInitUrl = setApplyInitUrlBase(initUrl, resolvedBase)
await withFileBackup(
path.resolve(options.cwd, "components.json"),
async () => {
const {
registryBaseConfig,
installStyleIndex,
url: cleanUrl,
} = await resolveRegistryBaseConfig(nextInitUrl, options.cwd, {
registries: existingConfig.registries as
| z.infer<typeof registryConfigSchema>
| undefined,
})
await runInit({
cwd: options.cwd,
yes: true,
force: true,
reinstall: true,
defaults: false,
silent: options.silent,
isNewProject: false,
cssVariables: true,
installStyleIndex,
registryBaseConfig,
existingConfig,
components: [cleanUrl, ...reinstallComponents],
})
},
{
onBackupFailure: () => {
logger.warn(
`Could not back up ${highlighter.info("components.json")}.`
)
},
}
)
logger.break()
logger.log(`Preset applied successfully.`)
logger.break()
} catch (error) {
logger.break()
handleError(error)
} finally {
clearRegistryContext()
}
})
function resolveApplyPreset(options: z.infer<typeof applyOptionsSchema>) {
const positionalPreset = options.positionalPreset?.trim()
const flagPreset = options.preset?.trim()
if (positionalPreset && flagPreset && positionalPreset !== flagPreset) {
logger.error(
`Received two different preset values. Use either the positional preset or ${highlighter.info(
"--preset"
)}, or pass the same value to both.`
)
logger.break()
process.exit(1)
}
return flagPreset ?? positionalPreset
}
function validatePreset(preset: string) {
if (isUrl(preset) || isPresetCode(preset)) {
return
}
const knownPresetNames = Object.keys(DEFAULT_PRESETS)
if (!knownPresetNames.includes(preset)) {
logger.error(
`Invalid preset: ${highlighter.info(
preset
)}.\nUse one of the available presets: ${knownPresetNames.join(", ")} \nor build your own at ${highlighter.info(`${SHADCN_URL}/create`)}`
)
logger.break()
process.exit(1)
}
}
async function resolveApplyTemplate(cwd: string) {
const projectInfo = await getProjectInfo(cwd)
return getTemplateForFramework(projectInfo?.framework.name)
}
export function resolveApplyInitUrl(
preset: string,
currentBase: "radix" | "base",
options: { template?: string; rtl?: boolean } = {}
) {
if (isUrl(preset)) {
const url = new URL(preset)
if (url.pathname === "/init" && preset.startsWith(SHADCN_URL)) {
url.searchParams.set("track", "1")
}
return {
initUrl: url.toString(),
presetBase:
(url.searchParams.get("base") as "radix" | "base" | null) ?? undefined,
}
}
if (isPresetCode(preset)) {
const decoded = decodePreset(preset)
if (!decoded) {
logger.error(`Invalid preset code: ${highlighter.info(preset)}`)
logger.break()
process.exit(1)
}
return {
initUrl: resolveInitUrl(
{
...decoded,
base: "radix",
rtl: options.rtl ?? false,
},
{ preset, template: options.template }
),
presetBase: undefined,
}
}
const resolvedPreset = DEFAULT_PRESETS[preset as keyof typeof DEFAULT_PRESETS]
return {
initUrl: resolveInitUrl(
{
...resolvedPreset,
base: currentBase,
rtl: options.rtl ?? resolvedPreset.rtl,
},
{ template: options.template }
),
presetBase: undefined,
}
}
function setApplyInitUrlBase(initUrl: string, base: "radix" | "base") {
const url = new URL(initUrl)
url.searchParams.set("base", base)
return url.toString()
}
function quoteShellArg(value: string) {
return /[^A-Za-z0-9_./:-]/.test(value) ? JSON.stringify(value) : value
}
function getInitCommand(preset?: string) {
if (!preset) {
return "shadcn init"
}
return `shadcn init --preset ${quoteShellArg(preset)}`
}

View File

@@ -24,12 +24,7 @@ import { addComponents } from "@/src/utils/add-components"
import { createProject } from "@/src/utils/create-project"
import { loadEnvFiles } from "@/src/utils/env-loader"
import * as ERRORS from "@/src/utils/errors"
import {
createFileBackup,
deleteFileBackup,
FILE_BACKUP_SUFFIX,
restoreFileBackup,
} from "@/src/utils/file-helper"
import { FILE_BACKUP_SUFFIX, withFileBackup } from "@/src/utils/file-helper"
import {
DEFAULT_COMPONENTS,
DEFAULT_TAILWIND_CONFIG,
@@ -128,19 +123,8 @@ export const init = new Command()
.option("--reinstall", "re-install existing UI components.")
.option("--no-reinstall", "do not re-install existing UI components.")
.action(async (components, opts) => {
let componentsJsonBackupPath: string | undefined
let reinstallComponents: string[] = []
// Restore components.json backup on unexpected exit (e.g. process.exit in preflight).
const restoreBackupOnExit = () => {
if (componentsJsonBackupPath) {
restoreFileBackup(
componentsJsonBackupPath.replace(FILE_BACKUP_SUFFIX, "")
)
}
}
process.on("exit", restoreBackupOnExit)
try {
const options = initOptionsSchema.parse({
...opts,
@@ -180,7 +164,7 @@ export const init = new Command()
logger.error(
`Invalid preset: ${highlighter.info(
options.preset
)}. Available presets: ${knownPresetNames.join(", ")}`
)}.\nUse one of the available presets: ${knownPresetNames.join(", ")} \nor build your own at ${highlighter.info(`${SHADCN_URL}/create`)}`
)
logger.break()
process.exit(1)
@@ -496,62 +480,53 @@ export const init = new Command()
await loadEnvFiles(options.cwd)
// We need to check if we're initializing with a new style.
// This will allow us to determine if we need to install the base style.
if (components.length > 0) {
// Back up existing components.json if it exists.
// Since components.json might not be valid at this point,
// temporarily rename it to allow preflight to run.
const componentsJsonPath = path.resolve(cwd, "components.json")
await withFileBackup(
path.resolve(cwd, "components.json"),
async () => {
// We need to check if we're initializing with a new style.
// This will allow us to determine if we need to install the base style.
if (components.length > 0) {
// Resolve registry:base config from the first component.
const {
registryBaseConfig,
installStyleIndex,
url: cleanUrl,
} = await resolveRegistryBaseConfig(components[0], cwd, {
registries: existingConfig?.registries as
| z.infer<typeof registryConfigSchema>
| undefined,
})
if (hasExistingConfig) {
componentsJsonBackupPath =
createFileBackup(componentsJsonPath) ?? undefined
if (!componentsJsonBackupPath) {
// Use the clean URL (track param stripped) for subsequent fetches.
components[0] = cleanUrl
if (!installStyleIndex) {
options.installStyleIndex = false
}
if (registryBaseConfig) {
options.registryBaseConfig = registryBaseConfig
}
}
await runInit(options)
},
{
enabled: hasExistingConfig && components.length > 0,
onBackupFailure: () => {
logger.warn(
`Could not back up ${highlighter.info("components.json")}.`
)
}
},
}
// Resolve registry:base config from the first component.
const {
registryBaseConfig,
installStyleIndex,
url: cleanUrl,
} = await resolveRegistryBaseConfig(components[0], cwd, {
registries: existingConfig?.registries as
| z.infer<typeof registryConfigSchema>
| undefined,
})
// Use the clean URL (track param stripped) for subsequent fetches.
components[0] = cleanUrl
if (!installStyleIndex) {
options.installStyleIndex = false
}
if (registryBaseConfig) {
options.registryBaseConfig = registryBaseConfig
}
}
await runInit(options)
)
logger.break()
logger.log(
`Project initialization completed.\nYou may now add components.`
)
// Success — remove the backup and exit listener.
process.removeListener("exit", restoreBackupOnExit)
deleteFileBackup(path.resolve(cwd, "components.json"))
logger.break()
} catch (error) {
// Restore handled by exit listener, but also do it here for non-exit errors.
process.removeListener("exit", restoreBackupOnExit)
restoreBackupOnExit()
logger.break()
handleError(error)
} finally {
@@ -950,7 +925,10 @@ async function promptForMinimalConfig(
})
}
async function confirmBaseSwitch(existingStyle: string, resolvedBase: string) {
export async function confirmBaseSwitch(
existingStyle: string,
resolvedBase: string
) {
// Styles prefixed with "base-" use Base UI. Everything else is Radix.
const oldBase = existingStyle.startsWith("base-") ? "base" : "radix"
if (resolvedBase === oldBase) return resolvedBase

View File

@@ -1,5 +1,6 @@
#!/usr/bin/env node
import { add } from "@/src/commands/add"
import { apply } from "@/src/commands/apply"
import { build } from "@/src/commands/build"
import { diff } from "@/src/commands/diff"
import { docs } from "@/src/commands/docs"
@@ -29,6 +30,7 @@ async function main() {
program
.addCommand(init)
.addCommand(apply)
.addCommand(add)
.addCommand(diff)
.addCommand(docs)

View File

@@ -0,0 +1,74 @@
import path from "path"
import { applyOptionsSchema } from "@/src/commands/apply"
import { SHADCN_URL } from "@/src/registry/constants"
import * as ERRORS from "@/src/utils/errors"
import { getConfig } from "@/src/utils/get-config"
import {
formatMonorepoMessage,
getMonorepoTargets,
isMonorepoRoot,
} from "@/src/utils/get-monorepo-info"
import { highlighter } from "@/src/utils/highlighter"
import { logger } from "@/src/utils/logger"
import fs from "fs-extra"
import { z } from "zod"
export async function preFlightApply(
options: z.infer<typeof applyOptionsSchema>
) {
const errors: Record<string, boolean> = {}
if (
!fs.existsSync(options.cwd) ||
!fs.existsSync(path.resolve(options.cwd, "package.json"))
) {
errors[ERRORS.MISSING_DIR_OR_EMPTY_PROJECT] = true
return {
errors,
config: null,
}
}
if (!fs.existsSync(path.resolve(options.cwd, "components.json"))) {
if (await isMonorepoRoot(options.cwd)) {
const targets = await getMonorepoTargets(options.cwd)
if (targets.length > 0) {
formatMonorepoMessage("apply --preset <preset>", targets, {
cwdFlag: "-c",
})
process.exit(1)
}
}
errors[ERRORS.MISSING_CONFIG] = true
return {
errors,
config: null,
}
}
try {
const config = await getConfig(options.cwd)
return {
errors,
config: config!,
}
} catch {
logger.break()
logger.error(
`An invalid ${highlighter.info(
"components.json"
)} file was found at ${highlighter.info(
options.cwd
)}.\nBefore you can apply a preset, you must create a valid ${highlighter.info(
"components.json"
)} file by running the ${highlighter.info("init")} command.`
)
logger.error(
`Learn more at ${highlighter.info(`${SHADCN_URL}/docs/components-json`)}.`
)
logger.break()
process.exit(1)
}
}

View File

@@ -130,6 +130,34 @@ export function resolveCreateUrl(
return url.toString()
}
export async function promptToOpenPresetBuilder(options: {
createUrl: string
followUp: string
prompt?: boolean
}) {
logger.break()
logger.log(
` Build your custom preset on ${highlighter.info(options.createUrl)}`
)
logger.log(` ${options.followUp}`)
logger.break()
if (options.prompt === false) {
return
}
const { proceed } = await prompts({
type: "confirm",
name: "proceed",
message: "Open in browser?",
initial: true,
})
if (proceed) {
await open(options.createUrl)
}
}
export function resolveInitUrl(
preset: {
base: string
@@ -234,26 +262,13 @@ export async function promptForPreset(options: {
base: options.base,
...(options.template && { template: options.template }),
})
logger.break()
logger.log(` Build your custom preset on ${highlighter.info(createUrl)}`)
logger.log(
` Then ${highlighter.info(
await promptToOpenPresetBuilder({
createUrl,
followUp: `Then ${highlighter.info(
"copy and run the command"
)} from ui.shadcn.com.`
)
logger.break()
const { proceed } = await prompts({
type: "confirm",
name: "proceed",
message: "Open in browser?",
initial: true,
)} from ui.shadcn.com.`,
})
if (proceed) {
await open(createUrl)
}
process.exit(0)
}

View File

@@ -0,0 +1,49 @@
import os from "os"
import path from "path"
import fs from "fs-extra"
import { afterEach, describe, expect, it } from "vitest"
import { FILE_BACKUP_SUFFIX, withFileBackup } from "./file-helper"
const tempDirs: string[] = []
async function createTempFile() {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "shadcn-file-helper-"))
tempDirs.push(dir)
const filePath = path.join(dir, "components.json")
await fs.writeFile(filePath, '{"style":"before"}\n', "utf8")
return filePath
}
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((dir) => fs.remove(dir)))
})
describe("withFileBackup", () => {
it("should restore the original file when the task throws", async () => {
const filePath = await createTempFile()
await expect(
withFileBackup(filePath, async () => {
await fs.writeFile(filePath, '{"style":"after"}\n', "utf8")
throw new Error("boom")
})
).rejects.toThrow("boom")
expect(await fs.readFile(filePath, "utf8")).toBe('{"style":"before"}\n')
expect(await fs.pathExists(`${filePath}${FILE_BACKUP_SUFFIX}`)).toBe(false)
})
it("should remove the backup after a successful task", async () => {
const filePath = await createTempFile()
await withFileBackup(filePath, async () => {
await fs.writeFile(filePath, '{"style":"after"}\n', "utf8")
})
expect(await fs.readFile(filePath, "utf8")).toBe('{"style":"after"}\n')
expect(await fs.pathExists(`${filePath}${FILE_BACKUP_SUFFIX}`)).toBe(false)
})
})

View File

@@ -2,6 +2,11 @@ import fsExtra from "fs-extra"
export const FILE_BACKUP_SUFFIX = ".bak"
type WithFileBackupOptions = {
enabled?: boolean
onBackupFailure?: (filePath: string) => void
}
export function createFileBackup(filePath: string): string | null {
if (!fsExtra.existsSync(filePath)) {
return null
@@ -50,3 +55,35 @@ export function deleteFileBackup(filePath: string): boolean {
return false
}
}
export async function withFileBackup<T>(
filePath: string,
task: () => Promise<T>,
options: WithFileBackupOptions = {}
) {
if (options.enabled === false || !fsExtra.existsSync(filePath)) {
return task()
}
const backupPath = createFileBackup(filePath)
if (!backupPath) {
options.onBackupFailure?.(filePath)
return task()
}
const restoreBackupOnExit = () => restoreFileBackup(filePath)
process.on("exit", restoreBackupOnExit)
try {
const result = await task()
process.removeListener("exit", restoreBackupOnExit)
deleteFileBackup(filePath)
return result
} catch (error) {
process.removeListener("exit", restoreBackupOnExit)
restoreFileBackup(filePath)
throw error
}
}

View File

@@ -100,8 +100,13 @@ export async function getMonorepoTargets(cwd: string) {
// Formats and logs the monorepo detection message.
export function formatMonorepoMessage(
command: string,
targets: { name: string; hasConfig: boolean }[]
targets: { name: string; hasConfig: boolean }[],
options?: {
cwdFlag?: string
}
) {
const cwdFlag = options?.cwdFlag ?? "-c"
logger.break()
logger.log(
`It looks like you are running ${highlighter.info(
@@ -110,13 +115,13 @@ export function formatMonorepoMessage(
)
logger.log(
`To use shadcn in a specific workspace, use the ${highlighter.info(
"-c"
cwdFlag
)} flag:`
)
logger.break()
for (const target of targets) {
logger.log(` shadcn ${command} -c ${target.name}`)
logger.log(` shadcn ${command} ${cwdFlag} ${target.name}`)
}
logger.break()

View File

@@ -0,0 +1,263 @@
import os from "os"
import path from "path"
import fs from "fs-extra"
import { describe, expect, it } from "vitest"
import {
createFixtureTestDirectory,
getRegistryUrl,
npxShadcn,
} from "../utils/helpers"
async function createInitializedProject() {
const fixturePath = await createFixtureTestDirectory("next-app")
await npxShadcn(fixturePath, ["init", "--defaults"])
await npxShadcn(fixturePath, ["add", "button"])
return fixturePath
}
async function createInitializedRtlProject() {
const fixturePath = await createFixtureTestDirectory("next-app")
await npxShadcn(fixturePath, ["init", "--defaults", "--rtl"])
await npxShadcn(fixturePath, ["add", "button"])
return fixturePath
}
describe("shadcn apply", () => {
it("should apply a preset with --preset <code>", async () => {
const fixturePath = await createInitializedProject()
const result = await npxShadcn(fixturePath, [
"apply",
"--preset",
"a0",
"-y",
])
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain("Preset applied successfully")
expect(
await fs.pathExists(path.join(fixturePath, "components/ui/button.tsx"))
).toBe(true)
})
it("should apply a preset with positional <code>", async () => {
const fixturePath = await createInitializedProject()
const result = await npxShadcn(fixturePath, ["apply", "a0", "-y"])
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain("Preset applied successfully")
})
it("should allow the same positional and flag preset values", async () => {
const fixturePath = await createInitializedProject()
const result = await npxShadcn(fixturePath, [
"apply",
"a0",
"--preset",
"a0",
"-y",
])
expect(result.exitCode).toBe(0)
})
it("should reject different positional and flag preset values", async () => {
const fixturePath = await createInitializedProject()
const result = await npxShadcn(fixturePath, [
"apply",
"a0",
"--preset",
"b0",
"-y",
])
expect(result.exitCode).toBe(1)
expect(result.stdout).toContain("Received two different preset values")
})
it("should offer the preset builder when no preset is provided", async () => {
const fixturePath = await createInitializedProject()
const componentsJson = await fs.readJson(
path.join(fixturePath, "components.json")
)
const createUrl = new URL(
`${getRegistryUrl().replace(/\/r\/?$/, "")}/create`
)
createUrl.searchParams.set("command", "init")
createUrl.searchParams.set("template", "next")
createUrl.searchParams.set(
"base",
componentsJson.style.startsWith("base-") ? "base" : "radix"
)
const result = await npxShadcn(fixturePath, ["apply"], {
input: "n\n",
})
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain("Build your custom preset on")
expect(result.stdout).toContain(createUrl.toString())
expect(result.stdout).toContain("shadcn apply --preset <preset>")
})
it("should print the preset builder url without prompting when no preset is provided with -y", async () => {
const fixturePath = await createInitializedProject()
const result = await npxShadcn(fixturePath, ["apply", "-y"])
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain("Build your custom preset on")
expect(result.stdout).not.toContain("Open in browser?")
})
it("should warn before applying and list detected components", async () => {
const fixturePath = await createInitializedProject()
const result = await npxShadcn(fixturePath, ["apply", "--preset", "a0"], {
input: "y\n",
})
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain(
"Applying a new preset will overwrite existing UI components, fonts, and CSS variables."
)
expect(result.stdout).toContain(
"Commit or stash your changes before continuing so you can easily go back."
)
expect(result.stdout).toContain(
"The following components will be re-installed:"
)
expect(result.stdout).toContain("button")
})
it("should skip confirmation with -y", async () => {
const fixturePath = await createInitializedProject()
const result = await npxShadcn(fixturePath, [
"apply",
"--preset",
"a0",
"-y",
])
expect(result.exitCode).toBe(0)
expect(result.stdout).not.toContain("Would you like to continue?")
})
it("should suggest init when components.json is missing", async () => {
const fixturePath = await createFixtureTestDirectory("next-app")
const result = await npxShadcn(fixturePath, ["apply", "--preset", "a0"])
expect(result.exitCode).toBe(1)
expect(result.stdout).toContain("components.json")
expect(result.stdout).toContain("shadcn init --preset a0")
})
it("should not show undefined in init guidance when components.json is missing and no preset is provided", async () => {
const fixturePath = await createFixtureTestDirectory("next-app")
const result = await npxShadcn(fixturePath, ["apply"])
expect(result.exitCode).toBe(1)
expect(result.stdout).toContain("components.json")
expect(result.stdout).toContain("shadcn init")
expect(result.stdout).not.toContain("undefined")
})
it("should fail on invalid components.json", async () => {
const fixturePath = await createFixtureTestDirectory("next-app")
await fs.writeFile(path.join(fixturePath, "components.json"), "{", "utf8")
const result = await npxShadcn(fixturePath, ["apply", "--preset", "a0"])
expect(result.exitCode).toBe(1)
expect(result.stdout).toContain("An invalid components.json file was found")
})
it("should restore components.json when applying a preset fails", async () => {
const fixturePath = await createInitializedProject()
const componentsJsonPath = path.join(fixturePath, "components.json")
const original = await fs.readFile(componentsJsonPath, "utf8")
const result = await npxShadcn(fixturePath, [
"apply",
"--preset",
`${getRegistryUrl()}/does-not-exist.json`,
"-y",
])
expect(result.exitCode).toBe(1)
expect(await fs.readFile(componentsJsonPath, "utf8")).toBe(original)
expect(await fs.pathExists(`${componentsJsonPath}.bak`)).toBe(false)
})
it("should guide the user to a workspace when run from a monorepo root", async () => {
const rootDir = path.join(
os.tmpdir(),
`shadcn-apply-monorepo-${process.pid}-${Date.now()}`
)
await fs.ensureDir(path.join(rootDir, "apps/web"))
await fs.writeJson(path.join(rootDir, "package.json"), {
private: true,
workspaces: ["apps/*"],
})
await fs.writeFile(
path.join(rootDir, "pnpm-workspace.yaml"),
'packages:\n - "apps/*"\n',
"utf8"
)
await fs.writeJson(path.join(rootDir, "apps/web/package.json"), {
name: "web",
version: "0.0.0",
})
await fs.writeFile(
path.join(rootDir, "apps/web/next.config.ts"),
"",
"utf8"
)
const result = await npxShadcn(rootDir, ["apply", "--preset", "a0"])
expect(result.exitCode).toBe(1)
expect(result.stdout).toContain("monorepo root")
expect(result.stdout).toContain(
"shadcn apply --preset <preset> -c apps/web"
)
await fs.remove(rootDir)
})
it("should update the project config and reinstall detected components", async () => {
const fixturePath = await createInitializedProject()
const result = await npxShadcn(fixturePath, [
"apply",
"--preset",
"lyra",
"-y",
])
expect(result.exitCode).toBe(0)
const componentsJson = await fs.readJson(
path.join(fixturePath, "components.json")
)
expect(componentsJson.style).toBe("base-lyra")
expect(componentsJson.iconLibrary).toBe("phosphor")
expect(
await fs.pathExists(path.join(fixturePath, "components/ui/button.tsx"))
).toBe(true)
})
it("should preserve rtl when applying a named preset", async () => {
const fixturePath = await createInitializedRtlProject()
const result = await npxShadcn(fixturePath, [
"apply",
"--preset",
"lyra",
"-y",
])
expect(result.exitCode).toBe(0)
const componentsJson = await fs.readJson(
path.join(fixturePath, "components.json")
)
expect(componentsJson.rtl).toBe(true)
})
})

View File

@@ -71,9 +71,11 @@ export async function npxShadcn(
args: string[],
{
debug = false,
input,
timeout,
}: {
debug?: boolean
input?: string
timeout?: number
} = {}
) {
@@ -82,6 +84,7 @@ export async function npxShadcn(
REGISTRY_URL: getRegistryUrl(),
SHADCN_TEMPLATE_DIR: TEMPLATES_DIR,
},
input,
timeout,
})

View File

@@ -77,7 +77,7 @@ 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 init --preset <code>`.
- **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.
## Key Patterns
@@ -173,11 +173,11 @@ npx shadcn@latest docs button dialog select
6. **Fix imports in third-party components** — After adding components from community registries (e.g. `@bundui`, `@magicui`), check the added non-UI files for hardcoded import paths like `@/components/ui/...`. These won't match the project's actual aliases. Use `npx shadcn@latest info` to get the correct `ui` alias (e.g. `@workspace/ui/components`) and rewrite the imports accordingly. The CLI rewrites imports for its own UI files, but third-party registry components may use default paths that don't match the project.
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: **reinstall**, **merge**, or **skip**?
- **Reinstall**: `npx shadcn@latest init --preset <code> --force --reinstall`. Overwrites all components.
9. **Switching presets** — Ask the user first: **overwrite**, **merge**, or **skip**?
- **Overwrite**: `npx shadcn@latest apply --preset <code>`. Overwrites detected components, fonts, and CSS variables.
- **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. 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.
- **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.
## Updating Components
@@ -206,6 +206,10 @@ npx shadcn@latest init --name my-app --preset base-nova --template next --monore
npx shadcn@latest init --preset base-nova
npx shadcn@latest init --defaults # shortcut: --template=next --preset=base-nova
# Apply a preset to an existing project.
npx shadcn@latest apply --preset a2r6bw
npx shadcn@latest apply a2r6bw
# Add components.
npx shadcn@latest add button card dialog
npx shadcn@latest add @magicui/shimmer-button
@@ -227,9 +231,9 @@ npx shadcn@latest docs button dialog select
npx shadcn@latest view @shadcn/button
```
**Named presets:** `base-nova`, `radix-nova`
**Named presets:** `nova`, `vega`, `maia`, `lyra`, `mira`, `luma`
**Templates:** `next`, `vite`, `start`, `react-router`, `astro` (all support `--monorepo`) and `laravel` (not supported for monorepo)
**Preset codes:** Base62 strings starting with `a` (e.g. `a2r6bw`), from [ui.shadcn.com](https://ui.shadcn.com).
**Preset codes:** Version-prefixed base62 strings (e.g. `a2r6bw` or `b0`), from [ui.shadcn.com](https://ui.shadcn.com).
## Detailed References

View File

@@ -8,7 +8,7 @@ Configuration is read from `components.json`.
## Contents
- Commands: init, add (dry-run, smart merge), search, view, docs, info, build
- Commands: init, apply, add (dry-run, smart merge), search, view, docs, info, build
- Templates: next, vite, start, react-router, astro
- Presets: named, code, URL formats and fields
- Switching presets
@@ -42,6 +42,24 @@ Initializes shadcn/ui in an existing project or creates a new project (when `--n
`npx shadcn@latest create` is an alias for `npx shadcn@latest init`.
### `apply` — Apply a preset to an existing project
```bash
npx shadcn@latest apply [preset] [options]
```
Applies a preset to an existing project, overwriting preset-driven config, fonts, CSS variables, and detected UI components.
| Flag | Short | Description | Default |
| ------------------- | ----- | ------------------------------------------ | ------- |
| `--preset <preset>` | — | Preset configuration (named, code, or URL) | — |
| `--yes` | `-y` | Skip confirmation prompt | `false` |
| `--cwd <cwd>` | `-c` | Working directory | current |
| `--silent` | — | Mute output | `false` |
`[preset]` is a shorthand for `--preset <preset>`. If both are provided, they must match.
If no preset is provided, the CLI offers to open the custom preset builder on `ui.shadcn.com/create`.
### `add` — Add components
> **IMPORTANT:** To compare local components against upstream or to preview changes, ALWAYS use `npx shadcn@latest add <component> --dry-run`, `--diff`, or `--view`. NEVER fetch raw files from GitHub or other sources manually. The CLI handles registry resolution, file paths, and CSS diffing automatically.
@@ -240,18 +258,19 @@ All templates support monorepo scaffolding via the `--monorepo` flag. When passe
Three ways to specify a preset via `--preset`:
1. **Named:** `--preset base-nova` or `--preset radix-nova`
2. **Code:** `--preset a2r6bw` (base62 string, starts with lowercase `a`)
1. **Named:** `--preset nova` or `--preset lyra`
2. **Code:** `--preset a2r6bw` (version-prefixed base62 string, e.g. `a2r6bw` or `b0`)
3. **URL:** `--preset "https://ui.shadcn.com/init?base=radix&style=nova&..."`
> **IMPORTANT:** Never try to decode, fetch, or resolve preset codes manually. Preset codes are opaque — pass them directly to `npx shadcn@latest init --preset <code>` and let the CLI handle resolution.
> Use `npx shadcn@latest apply --preset <code>` when overwriting an existing project's preset.
## Switching Presets
Ask the user first: **reinstall**, **merge**, or **skip** existing components?
Ask the user first: **overwrite**, **merge**, or **skip** existing components?
- **Re-install** → `npx shadcn@latest init --preset <code> --force --reinstall`. Overwrites all component files with the new preset styles. Use when the user hasn't customized components.
- **Overwrite / Re-install** → `npx shadcn@latest apply --preset <code>`. Overwrites all detected component files with the new preset styles. Use when the user hasn't customized components.
- **Merge** → `npx shadcn@latest init --preset <code> --force --no-reinstall`, then run `npx shadcn@latest info` to get the list of installed components and use the [smart merge workflow](./SKILL.md#updating-components) to update them one by one, preserving local changes. Use when the user has customized components.
- **Skip** → `npx shadcn@latest init --preset <code> --force --no-reinstall`. Only updates config and CSS variables, leaves existing components as-is.
Always run preset commands inside the user's project directory. 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.
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.

View File

@@ -54,7 +54,7 @@ Class-based toggle via `.dark` on the root element. In Next.js, use `next-themes
```tsx
import { ThemeProvider } from "next-themes"
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
;<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
{children}
</ThemeProvider>
```
@@ -65,14 +65,19 @@ import { ThemeProvider } from "next-themes"
```bash
# Apply a preset code from ui.shadcn.com.
npx shadcn@latest init --preset a2r6bw --force
npx shadcn@latest apply --preset a2r6bw
# Switch to a named preset.
npx shadcn@latest init --preset radix-nova --force
npx shadcn@latest init --reinstall # update existing components to match
# Positional shorthand also works.
npx shadcn@latest apply a2r6bw
# Switch to a named preset and overwrite existing components.
npx shadcn@latest apply --preset nova
# Preserve existing components instead.
npx shadcn@latest init --preset nova --force --no-reinstall
# Use a custom theme URL.
npx shadcn@latest init --preset "https://ui.shadcn.com/init?base=radix&style=nova&theme=blue&..." --force
npx shadcn@latest apply --preset "https://ui.shadcn.com/init?base=radix&style=nova&theme=blue&..."
```
Or edit CSS variables directly in `globals.css`.
@@ -142,13 +147,15 @@ Prefer these approaches in order:
### 1. Built-in variants
```tsx
<Button variant="outline" size="sm">Click</Button>
<Button variant="outline" size="sm">
Click
</Button>
```
### 2. Tailwind classes via `className`
```tsx
<Card className="max-w-md mx-auto">...</Card>
<Card className="mx-auto max-w-md">...</Card>
```
### 3. Add a new variant