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:
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user