mirror of
https://github.com/shadcn-ui/ui.git
synced 2026-07-07 22:18:39 +00:00
feat(shadcn): implement --reinstall flag
This commit is contained in:
@@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user