diff --git a/.changeset/apply-monorepo-packages-ui.md b/.changeset/apply-monorepo-packages-ui.md new file mode 100644 index 00000000000..0a7a96dc6c7 --- /dev/null +++ b/.changeset/apply-monorepo-packages-ui.md @@ -0,0 +1,5 @@ +--- +"shadcn": patch +--- + +fix `apply` failing with "could not detect a supported framework" when targeting a monorepo workspace (e.g. `packages/ui`) that has a valid `components.json` but no framework config diff --git a/packages/shadcn/src/preflights/preflight-init.test.ts b/packages/shadcn/src/preflights/preflight-init.test.ts new file mode 100644 index 00000000000..e28fdaa5a50 --- /dev/null +++ b/packages/shadcn/src/preflights/preflight-init.test.ts @@ -0,0 +1,127 @@ +import path from "path" +import fs from "fs-extra" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +import { preFlightInit } from "./preflight-init" + +let tmpDir: string + +beforeEach(async () => { + tmpDir = path.join( + await fs.realpath(require("os").tmpdir()), + `shadcn-preflight-init-test-${Date.now()}-${Math.random() + .toString(36) + .slice(2)}` + ) + await fs.ensureDir(tmpDir) +}) + +afterEach(async () => { + await fs.remove(tmpDir) +}) + +describe("preFlightInit", () => { + it("should not error on missing framework when existingConfig is provided", async () => { + // Simulate a shadcn monorepo UI workspace: a package.json with + // tailwind and an import alias but no framework config. This mirrors + // `packages/ui` in a shadcn-generated monorepo where the UI library + // is a sibling of the app workspace (e.g. `apps/web`). + await fs.writeJson(path.join(tmpDir, "package.json"), { + name: "@acme/ui", + dependencies: { + tailwindcss: "^4.0.0", + }, + }) + await fs.writeFile( + path.join(tmpDir, "styles.css"), + `@import "tailwindcss";\n` + ) + await fs.writeJson(path.join(tmpDir, "tsconfig.json"), { + compilerOptions: { + paths: { + "@acme/ui/*": ["./src/*"], + }, + }, + }) + + const exitSpy = vi + .spyOn(process, "exit") + .mockImplementation(() => undefined as never) + + const result = await preFlightInit({ + cwd: tmpDir, + yes: true, + defaults: false, + // Force bypasses the components.json-exists check — apply uses + // a backup file to side-step that, but for the test this is equivalent. + force: true, + silent: true, + isNewProject: false, + cssVariables: true, + installStyleIndex: true, + existingConfig: { + $schema: "https://ui.shadcn.com/schema.json", + style: "new-york", + tailwind: { + config: "", + css: "styles.css", + baseColor: "neutral", + cssVariables: true, + prefix: "", + }, + aliases: { + components: "@acme/ui/components", + ui: "@acme/ui/components/ui", + hooks: "@acme/ui/hooks", + lib: "@acme/ui/lib", + utils: "@acme/ui/lib/utils", + }, + }, + }) + + expect(exitSpy).not.toHaveBeenCalled() + expect(result.errors).toEqual({}) + expect(result.projectInfo).toBeTruthy() + + exitSpy.mockRestore() + }) + + it("should error on missing framework when no existingConfig is provided", async () => { + await fs.writeJson(path.join(tmpDir, "package.json"), { + name: "@acme/ui", + dependencies: { + tailwindcss: "^4.0.0", + }, + }) + await fs.writeFile( + path.join(tmpDir, "styles.css"), + `@import "tailwindcss";\n` + ) + await fs.writeJson(path.join(tmpDir, "tsconfig.json"), { + compilerOptions: { + paths: { + "@acme/ui/*": ["./src/*"], + }, + }, + }) + + const exitSpy = vi + .spyOn(process, "exit") + .mockImplementation(() => undefined as never) + + await preFlightInit({ + cwd: tmpDir, + yes: true, + defaults: false, + force: true, + silent: true, + isNewProject: false, + cssVariables: true, + installStyleIndex: true, + }) + + expect(exitSpy).toHaveBeenCalledWith(1) + + exitSpy.mockRestore() + }) +}) diff --git a/packages/shadcn/src/preflights/preflight-init.ts b/packages/shadcn/src/preflights/preflight-init.ts index be5340fe37d..e610c640086 100644 --- a/packages/shadcn/src/preflights/preflight-init.ts +++ b/packages/shadcn/src/preflights/preflight-init.ts @@ -65,7 +65,17 @@ export async function preFlightInit( const projectInfo = await getProjectInfo(options.cwd, { configCssFile: typeof tailwind?.css === "string" ? tailwind.css : undefined, }) - if (!projectInfo || projectInfo?.framework.name === "manual") { + // When an existing components.json is already present (re-init or the + // `apply` command), the project has been configured for shadcn. This is + // common for UI-library workspaces in monorepos (e.g. `packages/ui`) which + // don't ship their own framework config. Trust the existing config and + // skip the framework check rather than failing with + // UNSUPPORTED_FRAMEWORK. + const hasExistingConfig = Boolean(options.existingConfig) + if ( + !hasExistingConfig && + (!projectInfo || projectInfo?.framework.name === "manual") + ) { errors[ERRORS.UNSUPPORTED_FRAMEWORK] = true frameworkSpinner?.fail() @@ -93,15 +103,19 @@ export async function preFlightInit( logger.break() process.exit(1) } - frameworkSpinner?.succeed( - `Verifying framework. Found ${highlighter.info( - projectInfo.framework.label - )}.` - ) + if (projectInfo && projectInfo.framework.name !== "manual") { + frameworkSpinner?.succeed( + `Verifying framework. Found ${highlighter.info( + projectInfo.framework.label + )}.` + ) + } else { + frameworkSpinner?.succeed(`Verifying framework.`) + } let tailwindSpinnerMessage = "Validating Tailwind CSS." - if (projectInfo.tailwindVersion === "v4") { + if (projectInfo?.tailwindVersion === "v4") { tailwindSpinnerMessage = `Validating Tailwind CSS. Found ${highlighter.info( "v4" )}.` @@ -111,18 +125,18 @@ export async function preFlightInit( silent: options.silent, }).start() if ( - projectInfo.tailwindVersion === "v3" && + projectInfo?.tailwindVersion === "v3" && (!projectInfo?.tailwindConfigFile || !projectInfo?.tailwindCssFile) ) { errors[ERRORS.TAILWIND_NOT_CONFIGURED] = true tailwindSpinner?.fail() } else if ( - projectInfo.tailwindVersion === "v4" && + projectInfo?.tailwindVersion === "v4" && !projectInfo?.tailwindCssFile ) { errors[ERRORS.TAILWIND_NOT_CONFIGURED] = true tailwindSpinner?.fail() - } else if (!projectInfo.tailwindVersion) { + } else if (!projectInfo?.tailwindVersion) { errors[ERRORS.TAILWIND_NOT_CONFIGURED] = true tailwindSpinner?.fail() } else {