From 1ecc8066dbcc1ed8bd335c563ee7e55b4b65f87e Mon Sep 17 00:00:00 2001 From: shadcn Date: Sat, 14 Feb 2026 21:01:50 +0400 Subject: [PATCH] fix --- packages/shadcn/src/commands/create.ts | 3 +- packages/shadcn/src/commands/init.ts | 324 ++++++++---------- .../shadcn/src/templates/create-template.ts | 2 + packages/shadcn/src/templates/index.ts | 5 +- packages/shadcn/src/templates/next.ts | 4 +- .../tests/fixtures/remix-app/app/globals.css | 1 + .../tests/fixtures/remix-app/package.json | 28 ++ .../fixtures/remix-app/postcss.config.mjs | 5 + .../tests/fixtures/remix-app/tsconfig.json | 21 ++ packages/tests/src/tests/init.test.ts | 71 ++-- 10 files changed, 246 insertions(+), 218 deletions(-) create mode 100644 packages/tests/fixtures/remix-app/app/globals.css create mode 100644 packages/tests/fixtures/remix-app/package.json create mode 100644 packages/tests/fixtures/remix-app/postcss.config.mjs create mode 100644 packages/tests/fixtures/remix-app/tsconfig.json diff --git a/packages/shadcn/src/commands/create.ts b/packages/shadcn/src/commands/create.ts index 5b00f71e4a..4697c8f34c 100644 --- a/packages/shadcn/src/commands/create.ts +++ b/packages/shadcn/src/commands/create.ts @@ -201,8 +201,7 @@ export const create = new Command() overwrite: true, }) - const selectedTemplate = - templates[template as keyof typeof templates] + const selectedTemplate = templates[template as keyof typeof templates] if (selectedTemplate?.files?.length) { await updateFiles(selectedTemplate.files, config, { diff --git a/packages/shadcn/src/commands/init.ts b/packages/shadcn/src/commands/init.ts index d0ab72ee33..f472c4939c 100644 --- a/packages/shadcn/src/commands/init.ts +++ b/packages/shadcn/src/commands/init.ts @@ -50,22 +50,20 @@ import { spinner } from "@/src/utils/spinner" import { Command } from "commander" import deepmerge from "deepmerge" import fsExtra from "fs-extra" -import { bold, gray, green } from "kleur/colors" import open from "open" import prompts from "prompts" import { z } from "zod" -process.on("exit", (code) => { - const filePath = path.resolve(process.cwd(), "components.json") - - // Delete backup if successful. - if (code === 0) { - return deleteFileBackup(filePath) - } - - // Restore backup if error. - return restoreFileBackup(filePath) -}) +const DEFAULT_INIT_PRESET = { + style: "nova", + baseColor: "neutral", + theme: "neutral", + iconLibrary: "lucide", + font: "geist", + menuAccent: "subtle", + menuColor: "default", + radius: "default", +} as const export const initOptionsSchema = z.object({ cwd: z.string(), @@ -125,31 +123,22 @@ export const init = new Command() .option("--no-css-variables", "do not use css variables for theming.") .option("--rtl", "enable RTL support.", false) .action(async (components, opts) => { + let componentsJsonBackupPath: string | undefined + try { - // Apply defaults when --defaults flag is set. if (opts.defaults) { opts.template = opts.template || "next" - - // Use base-nova preset as default. const initUrl = buildInitUrl( { + ...DEFAULT_INIT_PRESET, base: "base", - style: "nova", - baseColor: "neutral", - theme: "neutral", - iconLibrary: "lucide", - font: "geist", rtl: opts.rtl ?? false, - menuAccent: "subtle", - menuColor: "default", - radius: "default", }, opts.rtl ?? false ) components = [initUrl, ...components] } - // Validate template early. if (opts.template && !(opts.template in templates)) { logger.break() logger.error( @@ -163,7 +152,6 @@ export const init = new Command() process.exit(1) } - // Run early preflight check for existing components.json. const cwd = path.resolve(opts.cwd) if ( fsExtra.existsSync(path.resolve(cwd, "components.json")) && @@ -183,7 +171,6 @@ export const init = new Command() process.exit(1) } - // Handle --preset option. if (opts.preset !== undefined) { const presetResult = await handlePresetOption( opts.preset === true ? true : opts.preset, @@ -207,11 +194,9 @@ export const init = new Command() initUrl = buildInitUrl(presetResult, opts.rtl ?? false) } - // Prepend the preset URL to the components list. components = [initUrl, ...components] } - // Prompt for preset when no preset, no components, and no defaults. if ( opts.preset === undefined && components.length === 0 && @@ -222,44 +207,16 @@ export const init = new Command() path.resolve(cwd, "package.json") ) - // Detect framework for existing projects. - let detectedTemplate: string | undefined - if (hasPackageJson) { - const frameworkTemplateMap: Record = { - "next-app": "next", - "next-pages": "next", - vite: "vite", - "tanstack-start": "start", - } - const projectInfo = await getProjectInfo(cwd) - if (projectInfo) { - detectedTemplate = frameworkTemplateMap[projectInfo.framework.name] - } - } - - // Use detected framework or prompt for template. - const templateChoices = Object.entries(templates).map(([value, t]) => ({ - title: t.title, - value, - })) - if (opts.template) { - // Template provided via -t flag, use it directly. - } else if (detectedTemplate) { - opts.template = detectedTemplate - const title = - templates[detectedTemplate as keyof typeof templates]?.title ?? - detectedTemplate - logger.log( - `${green("✔")} ${bold("Select a template")} ${gray( - "›" - )} ${title} ${gray("(detected)")}` - ) - } else { + // Prompt for template only for new projects without -t flag. + if (!opts.template && !hasPackageJson) { const { template } = await prompts({ type: "select", name: "template", message: "Select a template", - choices: templateChoices, + choices: Object.entries(templates).map(([value, t]) => ({ + title: t.title, + value, + })), }) if (!template) { @@ -314,16 +271,9 @@ export const init = new Command() // User chose a default base (radix or base). const initUrl = buildInitUrl( { + ...DEFAULT_INIT_PRESET, base: preset, - style: "nova", - baseColor: "neutral", - theme: "neutral", - iconLibrary: "lucide", - font: "geist", rtl: opts.rtl ?? false, - menuAccent: "subtle", - menuColor: "default", - radius: "default", }, opts.rtl ?? false ) @@ -331,10 +281,10 @@ export const init = new Command() } const options = initOptionsSchema.parse({ - cwd: path.resolve(opts.cwd), isNewProject: false, components, ...opts, + cwd: path.resolve(opts.cwd), installStyleIndex: true, }) @@ -376,7 +326,13 @@ export const init = new Command() // Since components.json might not be valid at this point. // Temporarily rename components.json to allow preflight to run. // We'll rename it back after preflight. - createFileBackup(componentsJsonPath) + componentsJsonBackupPath = + createFileBackup(componentsJsonPath) ?? undefined + if (!componentsJsonBackupPath) { + logger.warn( + `Could not back up ${highlighter.info("components.json")}.` + ) + } } // Ensure all registries used in components are configured. @@ -428,6 +384,11 @@ export const init = new Command() deleteFileBackup(path.resolve(options.cwd, "components.json")) logger.break() } catch (error) { + if (componentsJsonBackupPath) { + restoreFileBackup( + componentsJsonBackupPath.replace(FILE_BACKUP_SUFFIX, "") + ) + } logger.break() handleError(error) } finally { @@ -441,7 +402,7 @@ export async function runInit( } ) { let projectInfo - let newProjectTemplate + let newProjectTemplate: keyof typeof templates | undefined if (!options.skipPreflight) { const preflight = await preFlightInit(options) if (preflight.errors[ERRORS.MISSING_DIR_OR_EMPTY_PROJECT]) { @@ -462,140 +423,125 @@ export async function runInit( } const selectedTemplate = newProjectTemplate - ? templates[newProjectTemplate as keyof typeof templates] + ? templates[newProjectTemplate] : undefined - let result + const components = [ + ...(options.installStyleIndex ? ["index"] : []), + ...(options.components ?? []), + ] + if (selectedTemplate?.init) { - const components = [ - ...(options.installStyleIndex ? ["index"] : []), - ...(options.components ?? []), - ] - result = await selectedTemplate.init({ + const result = await selectedTemplate.init({ projectPath: options.cwd, components, registryBaseConfig: options.registryBaseConfig, rtl: options.rtl ?? false, silent: options.silent, }) - } else { - const projectConfig = await getProjectConfig(options.cwd, projectInfo) - let config = projectConfig - ? await promptForMinimalConfig(projectConfig, options) - : await promptForConfig(await getConfig(options.cwd)) + // Run postInit for new projects (e.g. git init). + await selectedTemplate.postInit({ projectPath: options.cwd }) - if (!options.yes) { - const { proceed } = await prompts({ - type: "confirm", - name: "proceed", - message: `Write configuration to ${highlighter.info( - "components.json" - )}. Proceed?`, - initial: true, - }) + return result + } - if (!proceed) { - process.exit(0) - } - } + // Standard init path for existing projects. + const projectConfig = await getProjectConfig(options.cwd, projectInfo) - // Prepare the list of components to be added. - const components = [ - // "index" is the default shadcn style. - // Why index? Because when style is true, we read style from components.json and fetch that. - // i.e new-york from components.json then fetch /styles/new-york/index. - // TODO: Fix this so that we can extend any style i.e --style=new-york. - ...(options.installStyleIndex ? ["index"] : []), - ...(options.components ?? []), - ] + let config = projectConfig + ? await promptForMinimalConfig(projectConfig, options) + : await promptForConfig(await getConfig(options.cwd)) - // Ensure registries are configured for the components we're about to add. - const fullConfigForRegistry = await resolveConfigPaths(options.cwd, config) - const { config: configWithRegistries } = await ensureRegistriesInConfig( - components, - fullConfigForRegistry, - { - silent: true, - } - ) - - // Update config with any new registries found. - if (configWithRegistries.registries) { - config.registries = configWithRegistries.registries - } - - const componentSpinner = spinner(`Writing components.json.`).start() - const targetPath = path.resolve(options.cwd, "components.json") - const backupPath = `${targetPath}${FILE_BACKUP_SUFFIX}` - - // Merge and keep registries at the end. - const mergeConfig = (base: typeof config, override: object) => { - const { registries, ...merged } = deepmerge(base, override) - return { ...merged, registries } as typeof config - } - - // Merge with backup config if it exists. - if (fsExtra.existsSync(backupPath)) { - const existingConfig = await fsExtra.readJson(backupPath) - if (options.force) { - // With --force, only preserve registries from existing config. - if (existingConfig.registries) { - config.registries = { - ...existingConfig.registries, - ...(config.registries || {}), - } - } - } else { - config = mergeConfig(existingConfig, config) - } - } - - // Merge config from registry:base item. - if (options.registryBaseConfig) { - config = mergeConfig(config, options.registryBaseConfig) - } - - // Ensure rtl is set from CLI option (takes priority over registryBaseConfig). - if (options.rtl !== undefined) { - config.rtl = options.rtl - } - - // Make sure to filter out built-in registries. - // TODO: fix this in ensureRegistriesInConfig. - config.registries = Object.fromEntries( - Object.entries(config.registries || {}).filter( - ([key]) => !Object.keys(BUILTIN_REGISTRIES).includes(key) - ) - ) - - // Write components.json. - await fs.writeFile( - targetPath, - `${JSON.stringify(config, null, 2)}\n`, - "utf8" - ) - componentSpinner.succeed() - - // Add components. - const fullConfig = await resolveConfigPaths(options.cwd, config) - await addComponents(components, fullConfig, { - // Init will always overwrite files. - overwrite: true, - silent: options.silent, - isNewProject: - options.isNewProject || projectInfo?.framework.name === "next-app", + if (!options.yes) { + const { proceed } = await prompts({ + type: "confirm", + name: "proceed", + message: `Write configuration to ${highlighter.info( + "components.json" + )}. Proceed?`, + initial: true, }) - result = fullConfig + if (!proceed) { + process.exit(0) + } } - // Run postInit for new projects. - if (selectedTemplate?.postInit) { - await selectedTemplate.postInit({ projectPath: options.cwd }) + // Ensure registries are configured for the components we're about to add. + const fullConfigForRegistry = await resolveConfigPaths(options.cwd, config) + const { config: configWithRegistries } = await ensureRegistriesInConfig( + components, + fullConfigForRegistry, + { + silent: true, + } + ) + + // Update config with any new registries found. + if (configWithRegistries.registries) { + config.registries = configWithRegistries.registries } - return result + const componentSpinner = spinner(`Writing components.json.`).start() + const targetPath = path.resolve(options.cwd, "components.json") + const backupPath = `${targetPath}${FILE_BACKUP_SUFFIX}` + + // Merge and keep registries at the end. + const mergeConfig = (base: typeof config, override: object) => { + const { registries, ...merged } = deepmerge(base, override) + return { ...merged, registries } as typeof config + } + + // Merge with backup config if it exists. + if (fsExtra.existsSync(backupPath)) { + const existingConfig = await fsExtra.readJson(backupPath) + if (options.force) { + // With --force, only preserve registries from existing config. + if (existingConfig.registries) { + config.registries = { + ...existingConfig.registries, + ...(config.registries || {}), + } + } + } else { + config = mergeConfig(existingConfig, config) + } + } + + // Merge config from registry:base item. + if (options.registryBaseConfig) { + config = mergeConfig(config, options.registryBaseConfig) + } + + // Ensure rtl is set from CLI option (takes priority over registryBaseConfig). + if (options.rtl !== undefined) { + config.rtl = options.rtl + } + + // Make sure to filter out built-in registries. + // TODO: fix this in ensureRegistriesInConfig. + config.registries = Object.fromEntries( + Object.entries(config.registries || {}).filter( + ([key]) => !Object.keys(BUILTIN_REGISTRIES).includes(key) + ) + ) + + // Write components.json. + await fs.writeFile(targetPath, `${JSON.stringify(config, null, 2)}\n`, "utf8") + componentSpinner.succeed() + + // Add components. + const fullConfig = await resolveConfigPaths(options.cwd, config) + await addComponents(components, fullConfig, { + // Init will always overwrite files. + overwrite: true, + silent: options.silent, + isNewProject: + options.isNewProject || projectInfo?.framework.name === "next-app", + }) + + return fullConfig } async function promptForConfig(defaultConfig: Config | null = null) { @@ -692,6 +638,10 @@ async function promptForConfig(defaultConfig: Config | null = null) { }, ]) + if (!options.style) { + process.exit(0) + } + return rawConfigSchema.parse({ $schema: "https://ui.shadcn.com/schema.json", style: options.style, diff --git a/packages/shadcn/src/templates/create-template.ts b/packages/shadcn/src/templates/create-template.ts index aa616b80dc..40b9cbf0ec 100644 --- a/packages/shadcn/src/templates/create-template.ts +++ b/packages/shadcn/src/templates/create-template.ts @@ -32,6 +32,8 @@ export function createTemplate(config: { } } +// Initialize a git repository and create an initial commit. +// Silently ignores failures (e.g. git not installed). async function defaultPostInit({ projectPath }: { projectPath: string }) { try { await execa("git", ["init"], { cwd: projectPath }) diff --git a/packages/shadcn/src/templates/index.ts b/packages/shadcn/src/templates/index.ts index 87d06871e9..d8a7b05da9 100644 --- a/packages/shadcn/src/templates/index.ts +++ b/packages/shadcn/src/templates/index.ts @@ -4,10 +4,7 @@ import { start } from "./start" import { vite } from "./vite" export { createTemplate, GITHUB_TEMPLATE_URL } from "./create-template" -export type { - TemplateInitOptions, - TemplateOptions, -} from "./create-template" +export type { TemplateInitOptions, TemplateOptions } from "./create-template" export const templates = { next, diff --git a/packages/shadcn/src/templates/next.ts b/packages/shadcn/src/templates/next.ts index e303e29835..b4b73bee54 100644 --- a/packages/shadcn/src/templates/next.ts +++ b/packages/shadcn/src/templates/next.ts @@ -80,7 +80,9 @@ export const next = createTemplate({ createSpinner?.succeed("Creating a new Next.js project.") } catch (error) { - createSpinner?.fail("Something went wrong creating a new Next.js project.") + createSpinner?.fail( + "Something went wrong creating a new Next.js project." + ) handleError(error) } }, diff --git a/packages/tests/fixtures/remix-app/app/globals.css b/packages/tests/fixtures/remix-app/app/globals.css new file mode 100644 index 0000000000..f1d8c73cdc --- /dev/null +++ b/packages/tests/fixtures/remix-app/app/globals.css @@ -0,0 +1 @@ +@import "tailwindcss"; diff --git a/packages/tests/fixtures/remix-app/package.json b/packages/tests/fixtures/remix-app/package.json new file mode 100644 index 0000000000..872223bb04 --- /dev/null +++ b/packages/tests/fixtures/remix-app/package.json @@ -0,0 +1,28 @@ +{ + "name": "my-remix-app", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "remix vite:dev", + "build": "remix vite:build" + }, + "dependencies": { + "@remix-run/node": "^2.0.0", + "@remix-run/react": "^2.0.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.527.0", + "react": "19.1.0", + "react-dom": "19.1.0", + "tailwind-merge": "^3.3.1" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "tailwindcss": "^4", + "tw-animate-css": "^1.3.6", + "typescript": "^5" + } +} diff --git a/packages/tests/fixtures/remix-app/postcss.config.mjs b/packages/tests/fixtures/remix-app/postcss.config.mjs new file mode 100644 index 0000000000..c7bcb4b1ee --- /dev/null +++ b/packages/tests/fixtures/remix-app/postcss.config.mjs @@ -0,0 +1,5 @@ +const config = { + plugins: ["@tailwindcss/postcss"], +}; + +export default config; diff --git a/packages/tests/fixtures/remix-app/tsconfig.json b/packages/tests/fixtures/remix-app/tsconfig.json new file mode 100644 index 0000000000..600bea85ef --- /dev/null +++ b/packages/tests/fixtures/remix-app/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "paths": { + "@/*": ["./*"] + } + }, + "include": ["**/*.ts", "**/*.tsx"], + "exclude": ["node_modules"] +} diff --git a/packages/tests/src/tests/init.test.ts b/packages/tests/src/tests/init.test.ts index 956ac20d75..e0cac04d09 100644 --- a/packages/tests/src/tests/init.test.ts +++ b/packages/tests/src/tests/init.test.ts @@ -57,11 +57,7 @@ describe("shadcn init - next-app", () => { it("should init without CSS variables", async () => { const fixturePath = await createFixtureTestDirectory("next-app") - await npxShadcn(fixturePath, [ - "init", - "--defaults", - "--no-css-variables", - ]) + await npxShadcn(fixturePath, ["init", "--defaults", "--no-css-variables"]) const componentsJson = await fs.readJson( path.join(fixturePath, "components.json") @@ -399,6 +395,44 @@ describe("shadcn init - custom style", async () => { }) }) +describe("shadcn init - unsupported framework", () => { + it("should init with --defaults on unsupported framework", async () => { + const fixturePath = await createFixtureTestDirectory("remix-app") + await npxShadcn(fixturePath, ["init", "--defaults"]) + + const componentsJsonPath = path.join(fixturePath, "components.json") + expect(await fs.pathExists(componentsJsonPath)).toBe(true) + + const componentsJson = await fs.readJson(componentsJsonPath) + expect(componentsJson).toMatchObject({ + style: "base-nova", + tailwind: { + baseColor: "neutral", + cssVariables: true, + }, + }) + + expect(await fs.pathExists(path.join(fixturePath, "lib/utils.ts"))).toBe( + true + ) + }) + + it("should init with --defaults and components on unsupported framework", async () => { + const fixturePath = await createFixtureTestDirectory("remix-app") + await npxShadcn(fixturePath, ["init", "--defaults", "button"]) + + expect( + await fs.pathExists(path.join(fixturePath, "components/ui/button.tsx")) + ).toBe(true) + + const cssPath = path.join(fixturePath, "app/globals.css") + const cssContent = await fs.readFile(cssPath, "utf-8") + expect(cssContent).toContain("@layer base") + expect(cssContent).toContain("--background") + expect(cssContent).toContain("--foreground") + }) +}) + describe("shadcn init - template flag", () => { it("should reject invalid template", async () => { const fixturePath = await createFixtureTestDirectory("next-app") @@ -410,12 +444,7 @@ describe("shadcn init - template flag", () => { it("should accept valid template with --defaults", async () => { const fixturePath = await createFixtureTestDirectory("next-app") - await npxShadcn(fixturePath, [ - "init", - "-t", - "next", - "--defaults", - ]) + await npxShadcn(fixturePath, ["init", "-t", "next", "--defaults"]) const componentsJsonPath = path.join(fixturePath, "components.json") expect(await fs.pathExists(componentsJsonPath)).toBe(true) @@ -445,19 +474,17 @@ describe("shadcn init - --name flag", () => { const emptyDir = path.join(testBaseDir, "empty-next") await fs.ensureDir(emptyDir) - await npxShadcn( - emptyDir, - ["init", "--defaults", "--name", projectName], - { timeout: 120000 } - ) + await npxShadcn(emptyDir, ["init", "--defaults", "--name", projectName], { + timeout: 120000, + }) const projectPath = path.join(emptyDir, projectName) // Verify project was created with the correct name. expect(await fs.pathExists(projectPath)).toBe(true) - expect( - await fs.pathExists(path.join(projectPath, "package.json")) - ).toBe(true) + expect(await fs.pathExists(path.join(projectPath, "package.json"))).toBe( + true + ) // Verify components.json was created. const componentsJsonPath = path.join(projectPath, "components.json") @@ -525,11 +552,7 @@ describe("shadcn init - existing components.json", () => { await fs.writeJson(componentsJsonPath, config) // Reinit with --force. - await npxShadcn(fixturePath, [ - "init", - "--force", - "--defaults", - ]) + await npxShadcn(fixturePath, ["init", "--force", "--defaults"]) const newConfig = await fs.readJson(componentsJsonPath) expect(newConfig.style).toBe("new-york")