feat(shadcn): implement --reinstall flag

This commit is contained in:
shadcn
2026-02-17 22:39:49 +04:00
parent 383bcc4fc1
commit 382a5220e0
6 changed files with 336 additions and 52 deletions

View File

@@ -27,6 +27,7 @@ import {
type Config,
} from "@/src/utils/get-config"
import {
getProjectComponents,
getProjectConfig,
getProjectInfo,
getProjectTailwindVersionFromConfig,
@@ -56,6 +57,7 @@ export const initOptionsSchema = z.object({
yes: z.boolean(),
defaults: z.boolean(),
force: z.boolean(),
reinstall: z.boolean(),
silent: z.boolean(),
isNewProject: z.boolean().default(false),
cssVariables: z.boolean().default(true),
@@ -91,8 +93,10 @@ export const init = new Command()
.option("--css-variables", "use css variables for theming.", true)
.option("--no-css-variables", "do not use css variables for theming.")
.option("--rtl", "enable RTL support.", false)
.option("--reinstall", "re-install existing UI components.", false)
.action(async (components, opts) => {
let componentsJsonBackupPath: string | undefined
let reinstallComponents: string[] = []
try {
const options = initOptionsSchema.parse({
@@ -106,10 +110,13 @@ export const init = new Command()
if (options.defaults) {
options.template = options.template || "next"
const initUrl = resolveInitUrl({
...DEFAULT_PRESETS["base-nova"],
rtl: options.rtl,
})
const initUrl = resolveInitUrl(
{
...DEFAULT_PRESETS["base-nova"],
rtl: options.rtl,
},
{ template: options.template }
)
components = [initUrl, ...components]
}
@@ -140,21 +147,70 @@ export const init = new Command()
}
const cwd = options.cwd
if (
fsExtra.existsSync(path.resolve(cwd, "components.json")) &&
!options.force
) {
logger.error(
`A ${highlighter.info(
const hasExistingConfig = fsExtra.existsSync(
path.resolve(cwd, "components.json")
)
if (hasExistingConfig && !options.force) {
const { overwrite } = await prompts({
type: "confirm",
name: "overwrite",
message: `A ${highlighter.info(
"components.json"
)} file already exists at ${highlighter.info(
cwd
)}.\nTo start over, remove the ${highlighter.info(
"components.json"
)} file and run ${highlighter.info("init")} again.`
)
logger.break()
process.exit(1)
)} file already exists. Would you like to overwrite it?`,
initial: false,
})
if (!overwrite) {
logger.info(
` To start over, remove the ${highlighter.info(
"components.json"
)} file and run ${highlighter.info("init")} again.`
)
logger.break()
process.exit(0)
}
options.force = true
}
let existingConfig: Record<string, unknown> | undefined
if (hasExistingConfig) {
try {
existingConfig = await fsExtra.readJson(
path.resolve(cwd, "components.json")
)
} catch {
// Ignore read errors.
}
let shouldReinstall = options.reinstall
if (!shouldReinstall) {
const { reinstall } = await prompts({
type: "confirm",
name: "reinstall",
message: `Would you like to re-install existing UI components?`,
initial: false,
})
shouldReinstall = reinstall
}
if (shouldReinstall) {
reinstallComponents = await getProjectComponents(cwd)
if (reinstallComponents.length) {
logger.break()
logger.log(
" The following components will be re-installed and overwritten:"
)
for (let i = 0; i < reinstallComponents.length; i += 8) {
logger.log(
` - ${reinstallComponents.slice(i, i + 8).join(", ")}`
)
}
logger.break()
}
}
}
if (
@@ -223,23 +279,36 @@ export const init = new Command()
initUrl = url.toString()
} else {
const preset = presetsByName.get(presetArg)!
initUrl = resolveInitUrl({
...preset,
rtl: options.rtl || preset.rtl,
})
initUrl = resolveInitUrl(
{
...preset,
rtl: options.rtl || preset.rtl,
},
{ template: options.template }
)
}
components = [initUrl, ...components]
}
}
const parsedOptions = initOptionsSchema.parse({
components,
...options,
cwd: options.cwd,
})
// Add re-install components after preset selection.
if (reinstallComponents.length) {
components = [...components, ...reinstallComponents]
}
await loadEnvFiles(parsedOptions.cwd)
// Warn if the user is switching bases during reinit.
if (
reinstallComponents.length &&
existingConfig?.style &&
components.length > 0
) {
warnOnBaseSwitch(existingConfig.style as string, components[0])
}
options.components = components
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.
@@ -247,21 +316,9 @@ export const init = new Command()
// 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(
parsedOptions.cwd,
"components.json"
)
// Read existing registries before backing up.
let existingRegistries
if (fsExtra.existsSync(componentsJsonPath)) {
try {
const existingConfig = await fsExtra.readJson(componentsJsonPath)
existingRegistries = existingConfig.registries
} catch {
// Ignore read errors.
}
const componentsJsonPath = path.resolve(cwd, "components.json")
if (hasExistingConfig) {
componentsJsonBackupPath =
createFileBackup(componentsJsonPath) ?? undefined
if (!componentsJsonBackupPath) {
@@ -273,27 +330,29 @@ export const init = new Command()
// Resolve registry:base config from the first component.
const { registryBaseConfig, installStyleIndex } =
await resolveRegistryBaseConfig(components[0], parsedOptions.cwd, {
registries: existingRegistries,
await resolveRegistryBaseConfig(components[0], cwd, {
registries: existingConfig?.registries as
| Record<string, unknown>
| undefined,
})
if (!installStyleIndex) {
parsedOptions.installStyleIndex = false
options.installStyleIndex = false
}
if (registryBaseConfig) {
parsedOptions.registryBaseConfig = registryBaseConfig
options.registryBaseConfig = registryBaseConfig
}
}
await runInit(parsedOptions)
await runInit(options)
logger.log(
`Project initialization completed.\nYou may now add components.`
)
// We need when running with custom cwd.
deleteFileBackup(path.resolve(parsedOptions.cwd, "components.json"))
deleteFileBackup(path.resolve(cwd, "components.json"))
logger.break()
} catch (error) {
if (componentsJsonBackupPath) {
@@ -632,3 +691,29 @@ async function promptForMinimalConfig(
aliases: defaultConfig?.aliases,
})
}
function warnOnBaseSwitch(existingStyle: string, initUrl: string) {
try {
const url = new URL(initUrl)
const newBase = url.searchParams.get("base")
// Styles prefixed with "base-" use Base UI. Everything else is Radix.
const oldBase = existingStyle.startsWith("base-") ? "base" : "radix"
if (newBase && newBase !== oldBase) {
logger.warn(
` You are switching from ${highlighter.info(
oldBase
)} to ${highlighter.info(newBase)}.`
)
logger.warn(
` Components outside the ${highlighter.info(
"ui"
)} directory that depend on ${highlighter.info(
oldBase
)} primitives may need manual updates.`
)
logger.break()
}
} catch {
// Not a valid URL, skip warning.
}
}

View File

@@ -1,4 +1,6 @@
import { promises as fsPromises } from "fs"
import path from "path"
import { getShadcnRegistryIndex } from "@/src/registry/api"
import { rawConfigSchema } from "@/src/schema"
import { FRAMEWORKS, Framework } from "@/src/utils/frameworks"
import { Config, getConfig, resolveConfigPaths } from "@/src/utils/get-config"
@@ -410,3 +412,25 @@ export async function getProjectTailwindVersionFromConfig(config: {
return projectInfo.tailwindVersion
}
export async function getProjectComponents(cwd: string) {
const existingConfig = await getConfig(cwd)
if (!existingConfig) {
return []
}
const resolvedConfig = await resolveConfigPaths(cwd, existingConfig)
const uiDir = resolvedConfig.resolvedPaths.ui
if (!fs.existsSync(uiDir)) {
return []
}
const registryIndex = await getShadcnRegistryIndex()
const registryNames = new Set(registryIndex?.map((item) => item.name) ?? [])
const files = await fsPromises.readdir(uiDir)
return files
.filter((f) => /\.(tsx|jsx)$/.test(f))
.map((f) => path.basename(f, path.extname(f)))
.filter((name) => registryNames.has(name))
}

View File

@@ -59,4 +59,16 @@ describe("buildInitUrl", () => {
const parsed = new URL(url)
expect(parsed.searchParams.get("rtl")).toBe("true")
})
it("should include template when provided", () => {
const url = resolveInitUrl(mockPreset, { template: "next" })
const parsed = new URL(url)
expect(parsed.searchParams.get("template")).toBe("next")
})
it("should not include template when not provided", () => {
const url = resolveInitUrl(mockPreset)
const parsed = new URL(url)
expect(parsed.searchParams.has("template")).toBe(false)
})
})

View File

@@ -73,7 +73,8 @@ export function resolveCreateUrl(
}
export function resolveInitUrl(
preset: Omit<Preset, "name" | "title" | "description">
preset: Omit<Preset, "name" | "title" | "description">,
options?: { template?: string }
) {
const params = new URLSearchParams({
base: preset.base,
@@ -88,6 +89,10 @@ export function resolveInitUrl(
radius: preset.radius,
})
if (options?.template) {
params.set("template", options.template)
}
return `${SHADCN_URL}/init?${params.toString()}`
}
@@ -109,7 +114,7 @@ export async function promptForPreset(options: {
})),
{
title: "Custom",
description: "Build your own on https://ui.shadcn.com",
description: "Build your own on https://ui.shadcn.com/create",
value: "custom",
},
],
@@ -153,7 +158,12 @@ export async function promptForPreset(options: {
process.exit(0)
}
return resolveInitUrl({ ...preset, rtl: options.rtl })
return resolveInitUrl(
{ ...preset, rtl: options.rtl },
{
template: options.template,
}
)
}
export async function resolveRegistryBaseConfig(

View File

@@ -117,6 +117,17 @@ export async function updateFiles(
const existingFile = existsSync(filePath)
// TODO: revisit this when we implement utils transform instead of override.
if (
file.type === "registry:lib" &&
basename(file.path) === "utils.ts" &&
projectInfo?.framework.name === "laravel" &&
existingFile
) {
filesSkipped.push(path.relative(config.resolvedPaths.cwd, filePath))
continue
}
// Check if the path exists and is a directory - we can't write to directories.
if (existingFile && statSync(filePath).isDirectory()) {
throw new Error(

View File

@@ -1,12 +1,23 @@
import { promises as fs } from "fs"
import path from "path"
import { describe, expect, test } from "vitest"
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"
import { FRAMEWORKS } from "../../src/utils/frameworks"
import {
getFrameworkVersion,
getProjectComponents,
getProjectInfo,
} from "../../src/utils/get-project-info"
vi.mock("../../src/utils/get-config", () => ({
getConfig: vi.fn(),
resolveConfigPaths: vi.fn(),
}))
vi.mock("../../src/registry/api", () => ({
getShadcnRegistryIndex: vi.fn(),
}))
describe("get project info", async () => {
test.each([
{
@@ -274,3 +285,134 @@ describe("getFrameworkVersion", () => {
)
})
})
import { getShadcnRegistryIndex } from "../../src/registry/api"
import { getConfig, resolveConfigPaths } from "../../src/utils/get-config"
describe("getProjectComponents", () => {
let tmpDir: string
beforeEach(async () => {
tmpDir = await fs.mkdtemp(
path.join(process.env.TMPDIR || "/tmp", "test-")
)
})
afterEach(async () => {
vi.resetAllMocks()
await fs.rm(tmpDir, { recursive: true, force: true })
})
test("should return empty array when no config exists", async () => {
vi.mocked(getConfig).mockResolvedValue(null)
const result = await getProjectComponents(tmpDir)
expect(result).toEqual([])
})
test("should return empty array when ui directory does not exist", async () => {
vi.mocked(getConfig).mockResolvedValue({} as any)
vi.mocked(resolveConfigPaths).mockResolvedValue({
resolvedPaths: { ui: path.join(tmpDir, "ui") },
} as any)
const result = await getProjectComponents(tmpDir)
expect(result).toEqual([])
})
test("should return only components that exist in the registry", async () => {
const uiDir = path.join(tmpDir, "ui")
await fs.mkdir(uiDir, { recursive: true })
await fs.writeFile(path.join(uiDir, "button.tsx"), "")
await fs.writeFile(path.join(uiDir, "card.tsx"), "")
await fs.writeFile(path.join(uiDir, "my-custom-component.tsx"), "")
vi.mocked(getConfig).mockResolvedValue({} as any)
vi.mocked(resolveConfigPaths).mockResolvedValue({
resolvedPaths: { ui: uiDir },
} as any)
vi.mocked(getShadcnRegistryIndex).mockResolvedValue([
{ name: "button" },
{ name: "card" },
{ name: "dialog" },
] as any)
const result = await getProjectComponents(tmpDir)
expect(result).toEqual(["button", "card"])
})
test("should handle jsx files", async () => {
const uiDir = path.join(tmpDir, "ui")
await fs.mkdir(uiDir, { recursive: true })
await fs.writeFile(path.join(uiDir, "button.jsx"), "")
vi.mocked(getConfig).mockResolvedValue({} as any)
vi.mocked(resolveConfigPaths).mockResolvedValue({
resolvedPaths: { ui: uiDir },
} as any)
vi.mocked(getShadcnRegistryIndex).mockResolvedValue([
{ name: "button" },
] as any)
const result = await getProjectComponents(tmpDir)
expect(result).toEqual(["button"])
})
test("should ignore non-tsx/jsx files", async () => {
const uiDir = path.join(tmpDir, "ui")
await fs.mkdir(uiDir, { recursive: true })
await fs.writeFile(path.join(uiDir, "button.tsx"), "")
await fs.writeFile(path.join(uiDir, "utils.ts"), "")
await fs.writeFile(path.join(uiDir, "styles.css"), "")
await fs.writeFile(path.join(uiDir, "README.md"), "")
vi.mocked(getConfig).mockResolvedValue({} as any)
vi.mocked(resolveConfigPaths).mockResolvedValue({
resolvedPaths: { ui: uiDir },
} as any)
vi.mocked(getShadcnRegistryIndex).mockResolvedValue([
{ name: "button" },
] as any)
const result = await getProjectComponents(tmpDir)
expect(result).toEqual(["button"])
})
test("should return empty array when registry index returns undefined", async () => {
const uiDir = path.join(tmpDir, "ui")
await fs.mkdir(uiDir, { recursive: true })
await fs.writeFile(path.join(uiDir, "button.tsx"), "")
vi.mocked(getConfig).mockResolvedValue({} as any)
vi.mocked(resolveConfigPaths).mockResolvedValue({
resolvedPaths: { ui: uiDir },
} as any)
vi.mocked(getShadcnRegistryIndex).mockResolvedValue(undefined)
const result = await getProjectComponents(tmpDir)
expect(result).toEqual([])
})
test("should return empty array when ui directory is empty", async () => {
const uiDir = path.join(tmpDir, "ui")
await fs.mkdir(uiDir, { recursive: true })
vi.mocked(getConfig).mockResolvedValue({} as any)
vi.mocked(resolveConfigPaths).mockResolvedValue({
resolvedPaths: { ui: uiDir },
} as any)
vi.mocked(getShadcnRegistryIndex).mockResolvedValue([
{ name: "button" },
] as any)
const result = await getProjectComponents(tmpDir)
expect(result).toEqual([])
})
})