diff --git a/packages/shadcn/src/commands/init.ts b/packages/shadcn/src/commands/init.ts index b261cb0fe5..391074ae5a 100644 --- a/packages/shadcn/src/commands/init.ts +++ b/packages/shadcn/src/commands/init.ts @@ -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 | 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 + | 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. + } +} diff --git a/packages/shadcn/src/utils/get-project-info.ts b/packages/shadcn/src/utils/get-project-info.ts index f70b609b62..2e1139cd49 100644 --- a/packages/shadcn/src/utils/get-project-info.ts +++ b/packages/shadcn/src/utils/get-project-info.ts @@ -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)) +} diff --git a/packages/shadcn/src/utils/presets.test.ts b/packages/shadcn/src/utils/presets.test.ts index c78644347f..826ffef413 100644 --- a/packages/shadcn/src/utils/presets.test.ts +++ b/packages/shadcn/src/utils/presets.test.ts @@ -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) + }) }) diff --git a/packages/shadcn/src/utils/presets.ts b/packages/shadcn/src/utils/presets.ts index c02bb66192..b98c2570ff 100644 --- a/packages/shadcn/src/utils/presets.ts +++ b/packages/shadcn/src/utils/presets.ts @@ -73,7 +73,8 @@ export function resolveCreateUrl( } export function resolveInitUrl( - preset: Omit + preset: Omit, + 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( diff --git a/packages/shadcn/src/utils/updaters/update-files.ts b/packages/shadcn/src/utils/updaters/update-files.ts index fee8ba770d..7f966fe182 100644 --- a/packages/shadcn/src/utils/updaters/update-files.ts +++ b/packages/shadcn/src/utils/updaters/update-files.ts @@ -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( diff --git a/packages/shadcn/test/utils/get-project-info.test.ts b/packages/shadcn/test/utils/get-project-info.test.ts index 54d08a1062..e6d68907e2 100644 --- a/packages/shadcn/test/utils/get-project-info.test.ts +++ b/packages/shadcn/test/utils/get-project-info.test.ts @@ -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([]) + }) +})