mirror of
https://github.com/shadcn-ui/ui.git
synced 2026-06-11 09:51:40 +00:00
feat: add apply command
This commit is contained in:
5
.changeset/common-pears-accept.md
Normal file
5
.changeset/common-pears-accept.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"shadcn": minor
|
||||
---
|
||||
|
||||
add shadcn apply command
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
47
packages/shadcn/src/commands/apply.test.ts
Normal file
47
packages/shadcn/src/commands/apply.test.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
325
packages/shadcn/src/commands/apply.ts
Normal file
325
packages/shadcn/src/commands/apply.ts
Normal 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)}`
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
74
packages/shadcn/src/preflights/preflight-apply.ts
Normal file
74
packages/shadcn/src/preflights/preflight-apply.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
49
packages/shadcn/src/utils/file-helper.test.ts
Normal file
49
packages/shadcn/src/utils/file-helper.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
263
packages/tests/src/tests/apply.test.ts
Normal file
263
packages/tests/src/tests/apply.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user