From 2ddd920e4d7c251eb96c88398b98bfe1f6ea0b8a Mon Sep 17 00:00:00 2001 From: shadcn Date: Sat, 21 Feb 2026 15:17:06 +0400 Subject: [PATCH] feat: warn if in monorepo --- .changeset/shaggy-ways-check.md | 5 + packages/shadcn/src/commands/diff.ts | 14 + packages/shadcn/src/commands/init.ts | 18 ++ .../shadcn/src/preflights/preflight-add.ts | 14 + .../shadcn/src/preflights/preflight-init.ts | 15 + .../src/utils/get-monorepo-info.test.ts | 289 ++++++++++++++++++ .../shadcn/src/utils/get-monorepo-info.ts | 157 ++++++++++ packages/shadcn/src/utils/presets.ts | 5 +- 8 files changed, 516 insertions(+), 1 deletion(-) create mode 100644 .changeset/shaggy-ways-check.md create mode 100644 packages/shadcn/src/utils/get-monorepo-info.test.ts create mode 100644 packages/shadcn/src/utils/get-monorepo-info.ts diff --git a/.changeset/shaggy-ways-check.md b/.changeset/shaggy-ways-check.md new file mode 100644 index 0000000000..233125da3e --- /dev/null +++ b/.changeset/shaggy-ways-check.md @@ -0,0 +1,5 @@ +--- +"shadcn": patch +--- + +warn if in monorepo and cwd not set diff --git a/packages/shadcn/src/commands/diff.ts b/packages/shadcn/src/commands/diff.ts index 3e1dfa5738..a75d0b35fd 100644 --- a/packages/shadcn/src/commands/diff.ts +++ b/packages/shadcn/src/commands/diff.ts @@ -8,6 +8,11 @@ import { } from "@/src/registry/api" import { registryIndexSchema } from "@/src/schema" import { Config, getConfig } from "@/src/utils/get-config" +import { + formatMonorepoMessage, + getMonorepoTargets, + isMonorepoRoot, +} from "@/src/utils/get-monorepo-info" import { handleError } from "@/src/utils/handle-error" import { highlighter } from "@/src/utils/highlighter" import { logger } from "@/src/utils/logger" @@ -49,6 +54,15 @@ export const diff = new Command() const config = await getConfig(cwd) if (!config) { + // Check if we're in a monorepo root. + if (await isMonorepoRoot(cwd)) { + const targets = await getMonorepoTargets(cwd) + if (targets.length > 0) { + formatMonorepoMessage("diff [component]", targets) + process.exit(1) + } + } + logger.warn( `Configuration is missing. Please run ${highlighter.success( `init` diff --git a/packages/shadcn/src/commands/init.ts b/packages/shadcn/src/commands/init.ts index b70b350cb6..b82e170883 100644 --- a/packages/shadcn/src/commands/init.ts +++ b/packages/shadcn/src/commands/init.ts @@ -26,6 +26,11 @@ import { resolveConfigPaths, type Config, } from "@/src/utils/get-config" +import { + formatMonorepoMessage, + getMonorepoTargets, + isMonorepoRoot, +} from "@/src/utils/get-monorepo-info" import { getProjectComponents, getProjectConfig, @@ -168,6 +173,18 @@ export const init = new Command() path.resolve(cwd, "components.json") ) + // Check if we're in a monorepo root before proceeding. + if (!hasExistingConfig && (await isMonorepoRoot(cwd))) { + const projectInfo = await getProjectInfo(cwd) + if (!projectInfo || projectInfo.framework.name === "manual") { + const targets = await getMonorepoTargets(cwd) + if (targets.length > 0) { + formatMonorepoMessage("init", targets) + process.exit(1) + } + } + } + if (hasExistingConfig && !options.force) { const { overwrite } = await prompts({ type: "confirm", @@ -286,6 +303,7 @@ export const init = new Command() const result = await promptForPreset({ rtl: options.rtl ?? false, template: options.template, + base: options.base, }) components = [result.url, ...components] presetBase = result.base diff --git a/packages/shadcn/src/preflights/preflight-add.ts b/packages/shadcn/src/preflights/preflight-add.ts index 24b141af44..0c43fe30e4 100644 --- a/packages/shadcn/src/preflights/preflight-add.ts +++ b/packages/shadcn/src/preflights/preflight-add.ts @@ -2,6 +2,11 @@ import path from "path" import { addOptionsSchema } from "@/src/commands/add" import * as ERRORS from "@/src/utils/errors" import { getConfig } from "@/src/utils/get-config" +import { + formatMonorepoMessage, + getMonorepoTargets, + isMonorepoRoot, +} from "@/src/utils/get-monorepo-info" import { highlighter } from "@/src/utils/highlighter" import { logger } from "@/src/utils/logger" import fs from "fs-extra" @@ -25,6 +30,15 @@ export async function preFlightAdd(options: z.infer) { // Check for existing components.json file. if (!fs.existsSync(path.resolve(options.cwd, "components.json"))) { + // Check if we're in a monorepo root. + if (await isMonorepoRoot(options.cwd)) { + const targets = await getMonorepoTargets(options.cwd) + if (targets.length > 0) { + formatMonorepoMessage("add [component]", targets) + process.exit(1) + } + } + errors[ERRORS.MISSING_CONFIG] = true return { errors, diff --git a/packages/shadcn/src/preflights/preflight-init.ts b/packages/shadcn/src/preflights/preflight-init.ts index ce5c6f5d35..3efb633047 100644 --- a/packages/shadcn/src/preflights/preflight-init.ts +++ b/packages/shadcn/src/preflights/preflight-init.ts @@ -1,6 +1,11 @@ import path from "path" import { initOptionsSchema } from "@/src/commands/init" import * as ERRORS from "@/src/utils/errors" +import { + formatMonorepoMessage, + getMonorepoTargets, + isMonorepoRoot, +} from "@/src/utils/get-monorepo-info" import { getProjectInfo } from "@/src/utils/get-project-info" import { highlighter } from "@/src/utils/highlighter" import { logger } from "@/src/utils/logger" @@ -63,6 +68,16 @@ export async function preFlightInit( if (!projectInfo || projectInfo?.framework.name === "manual") { errors[ERRORS.UNSUPPORTED_FRAMEWORK] = true frameworkSpinner?.fail() + + // Check if we're in a monorepo root. + if (await isMonorepoRoot(options.cwd)) { + const targets = await getMonorepoTargets(options.cwd) + if (targets.length > 0) { + formatMonorepoMessage("init", targets) + process.exit(1) + } + } + logger.break() if (projectInfo?.framework.links.installation) { logger.error( diff --git a/packages/shadcn/src/utils/get-monorepo-info.test.ts b/packages/shadcn/src/utils/get-monorepo-info.test.ts new file mode 100644 index 0000000000..ff800b43ca --- /dev/null +++ b/packages/shadcn/src/utils/get-monorepo-info.test.ts @@ -0,0 +1,289 @@ +import path from "path" +import { logger } from "@/src/utils/logger" +import fs from "fs-extra" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +import { + formatMonorepoMessage, + getMonorepoTargets, + isMonorepoRoot, +} from "./get-monorepo-info" + +let tmpDir: string + +beforeEach(async () => { + tmpDir = path.join( + await fs.realpath(require("os").tmpdir()), + `shadcn-monorepo-test-${Date.now()}` + ) + await fs.ensureDir(tmpDir) +}) + +afterEach(async () => { + await fs.remove(tmpDir) +}) + +describe("isMonorepoRoot", () => { + it("should detect pnpm-workspace.yaml", async () => { + await fs.writeFile( + path.join(tmpDir, "pnpm-workspace.yaml"), + "packages:\n - apps/*\n" + ) + expect(await isMonorepoRoot(tmpDir)).toBe(true) + }) + + it("should detect package.json with workspaces array", async () => { + await fs.writeJson(path.join(tmpDir, "package.json"), { + name: "root", + workspaces: ["apps/*", "packages/*"], + }) + expect(await isMonorepoRoot(tmpDir)).toBe(true) + }) + + it("should detect package.json with workspaces.packages", async () => { + await fs.writeJson(path.join(tmpDir, "package.json"), { + name: "root", + workspaces: { packages: ["apps/*"] }, + }) + expect(await isMonorepoRoot(tmpDir)).toBe(true) + }) + + it("should detect lerna.json", async () => { + await fs.writeJson(path.join(tmpDir, "lerna.json"), { version: "0.0.0" }) + expect(await isMonorepoRoot(tmpDir)).toBe(true) + }) + + it("should detect nx.json", async () => { + await fs.writeJson(path.join(tmpDir, "nx.json"), {}) + expect(await isMonorepoRoot(tmpDir)).toBe(true) + }) + + it("should return false for a regular project", async () => { + await fs.writeJson(path.join(tmpDir, "package.json"), { name: "my-app" }) + expect(await isMonorepoRoot(tmpDir)).toBe(false) + }) + + it("should return false for an empty directory", async () => { + expect(await isMonorepoRoot(tmpDir)).toBe(false) + }) +}) + +describe("getMonorepoTargets", () => { + it("should find targets from pnpm-workspace.yaml", async () => { + // Set up monorepo structure. + await fs.writeFile( + path.join(tmpDir, "pnpm-workspace.yaml"), + "packages:\n - 'apps/*'\n" + ) + await fs.writeJson(path.join(tmpDir, "package.json"), { name: "root" }) + + // Create an app with a Next.js config. + const webDir = path.join(tmpDir, "apps", "web") + await fs.ensureDir(webDir) + await fs.writeJson(path.join(webDir, "package.json"), { name: "web" }) + await fs.writeFile( + path.join(webDir, "next.config.mjs"), + "export default {}" + ) + + const targets = await getMonorepoTargets(tmpDir) + expect(targets).toEqual([{ name: "apps/web", hasConfig: false }]) + }) + + it("should find targets from package.json workspaces", async () => { + await fs.writeJson(path.join(tmpDir, "package.json"), { + name: "root", + workspaces: ["apps/*"], + }) + + const webDir = path.join(tmpDir, "apps", "web") + await fs.ensureDir(webDir) + await fs.writeJson(path.join(webDir, "package.json"), { name: "web" }) + await fs.writeFile(path.join(webDir, "vite.config.ts"), "export default {}") + + const targets = await getMonorepoTargets(tmpDir) + expect(targets).toEqual([{ name: "apps/web", hasConfig: false }]) + }) + + it("should set hasConfig when components.json exists", async () => { + await fs.writeFile( + path.join(tmpDir, "pnpm-workspace.yaml"), + "packages:\n - apps/*\n" + ) + await fs.writeJson(path.join(tmpDir, "package.json"), { name: "root" }) + + const webDir = path.join(tmpDir, "apps", "web") + await fs.ensureDir(webDir) + await fs.writeJson(path.join(webDir, "package.json"), { name: "web" }) + await fs.writeFile( + path.join(webDir, "next.config.mjs"), + "export default {}" + ) + await fs.writeJson(path.join(webDir, "components.json"), {}) + + const targets = await getMonorepoTargets(tmpDir) + expect(targets).toEqual([{ name: "apps/web", hasConfig: true }]) + }) + + it("should find multiple targets", async () => { + await fs.writeFile( + path.join(tmpDir, "pnpm-workspace.yaml"), + "packages:\n - apps/*\n" + ) + await fs.writeJson(path.join(tmpDir, "package.json"), { name: "root" }) + + // apps/web with Next.js. + const webDir = path.join(tmpDir, "apps", "web") + await fs.ensureDir(webDir) + await fs.writeJson(path.join(webDir, "package.json"), { name: "web" }) + await fs.writeFile( + path.join(webDir, "next.config.mjs"), + "export default {}" + ) + + // apps/docs with Vite. + const docsDir = path.join(tmpDir, "apps", "docs") + await fs.ensureDir(docsDir) + await fs.writeJson(path.join(docsDir, "package.json"), { name: "docs" }) + await fs.writeFile( + path.join(docsDir, "vite.config.ts"), + "export default {}" + ) + + const targets = await getMonorepoTargets(tmpDir) + expect(targets).toHaveLength(2) + expect(targets.map((t) => t.name).sort()).toEqual(["apps/docs", "apps/web"]) + }) + + it("should skip directories without package.json", async () => { + await fs.writeFile( + path.join(tmpDir, "pnpm-workspace.yaml"), + "packages:\n - apps/*\n" + ) + await fs.writeJson(path.join(tmpDir, "package.json"), { name: "root" }) + + // Directory without package.json. + const libDir = path.join(tmpDir, "apps", "lib") + await fs.ensureDir(libDir) + await fs.writeFile( + path.join(libDir, "next.config.mjs"), + "export default {}" + ) + + const targets = await getMonorepoTargets(tmpDir) + expect(targets).toEqual([]) + }) + + it("should skip directories without framework config or components.json", async () => { + await fs.writeFile( + path.join(tmpDir, "pnpm-workspace.yaml"), + "packages:\n - packages/*\n" + ) + await fs.writeJson(path.join(tmpDir, "package.json"), { name: "root" }) + + // A utility package with no framework config. + const utilsDir = path.join(tmpDir, "packages", "utils") + await fs.ensureDir(utilsDir) + await fs.writeJson(path.join(utilsDir, "package.json"), { name: "utils" }) + + const targets = await getMonorepoTargets(tmpDir) + expect(targets).toEqual([]) + }) + + it("should return empty for no workspace patterns", async () => { + await fs.writeJson(path.join(tmpDir, "package.json"), { name: "root" }) + const targets = await getMonorepoTargets(tmpDir) + expect(targets).toEqual([]) + }) + + it("should detect astro, remix, and svelte configs", async () => { + await fs.writeFile( + path.join(tmpDir, "pnpm-workspace.yaml"), + "packages:\n - apps/*\n" + ) + await fs.writeJson(path.join(tmpDir, "package.json"), { name: "root" }) + + const astroDir = path.join(tmpDir, "apps", "astro-app") + await fs.ensureDir(astroDir) + await fs.writeJson(path.join(astroDir, "package.json"), { + name: "astro-app", + }) + await fs.writeFile( + path.join(astroDir, "astro.config.mjs"), + "export default {}" + ) + + const targets = await getMonorepoTargets(tmpDir) + expect(targets).toEqual([{ name: "apps/astro-app", hasConfig: false }]) + }) + + it("should deduplicate patterns from both pnpm-workspace.yaml and package.json", async () => { + await fs.writeFile( + path.join(tmpDir, "pnpm-workspace.yaml"), + "packages:\n - apps/*\n" + ) + await fs.writeJson(path.join(tmpDir, "package.json"), { + name: "root", + workspaces: ["apps/*"], + }) + + const webDir = path.join(tmpDir, "apps", "web") + await fs.ensureDir(webDir) + await fs.writeJson(path.join(webDir, "package.json"), { name: "web" }) + await fs.writeFile( + path.join(webDir, "next.config.mjs"), + "export default {}" + ) + + const targets = await getMonorepoTargets(tmpDir) + // Should not duplicate the target. + expect(targets).toEqual([{ name: "apps/web", hasConfig: false }]) + }) +}) + +describe("formatMonorepoMessage", () => { + it("should log the monorepo message with targets", () => { + const logSpy = vi.spyOn(logger, "log") + const breakSpy = vi.spyOn(logger, "break") + + formatMonorepoMessage("init", [ + { name: "apps/web", hasConfig: false }, + { name: "apps/docs", hasConfig: true }, + ]) + + expect(breakSpy).toHaveBeenCalled() + const allLogCalls = logSpy.mock.calls.map((c) => c[0]) + + // Should mention monorepo root. + expect(allLogCalls.some((msg) => msg.includes("monorepo root"))).toBe(true) + // Should mention -c flag. + expect(allLogCalls.some((msg) => msg.includes("-c"))).toBe(true) + // Should list both targets. + expect( + allLogCalls.some((msg) => msg.includes("shadcn init -c apps/web")) + ).toBe(true) + expect( + allLogCalls.some((msg) => msg.includes("shadcn init -c apps/docs")) + ).toBe(true) + + logSpy.mockRestore() + breakSpy.mockRestore() + }) + + it("should use the correct command name", () => { + const logSpy = vi.spyOn(logger, "log") + + formatMonorepoMessage("add [component]", [ + { name: "apps/web", hasConfig: false }, + ]) + + const allLogCalls = logSpy.mock.calls.map((c) => c[0]) + expect( + allLogCalls.some((msg) => + msg.includes("shadcn add [component] -c apps/web") + ) + ).toBe(true) + + logSpy.mockRestore() + }) +}) diff --git a/packages/shadcn/src/utils/get-monorepo-info.ts b/packages/shadcn/src/utils/get-monorepo-info.ts new file mode 100644 index 0000000000..b8d1b21ce9 --- /dev/null +++ b/packages/shadcn/src/utils/get-monorepo-info.ts @@ -0,0 +1,157 @@ +import path from "path" +import { highlighter } from "@/src/utils/highlighter" +import { logger } from "@/src/utils/logger" +import fg from "fast-glob" +import fs from "fs-extra" + +const FRAMEWORK_CONFIG_FILES = [ + "next.config.*", + "vite.config.*", + "astro.config.*", + "remix.config.*", + "nuxt.config.*", + "svelte.config.*", + "gatsby-config.*", + "angular.json", +] + +// Checks for workspace signals at the given directory. +export async function isMonorepoRoot(cwd: string) { + // pnpm workspaces. + if (fs.existsSync(path.resolve(cwd, "pnpm-workspace.yaml"))) { + return true + } + + // npm/yarn workspaces. + const packageJsonPath = path.resolve(cwd, "package.json") + if (fs.existsSync(packageJsonPath)) { + try { + const packageJson = await fs.readJson(packageJsonPath) + if (packageJson.workspaces) { + return true + } + } catch { + // Ignore parse errors. + } + } + + // Lerna. + if (fs.existsSync(path.resolve(cwd, "lerna.json"))) { + return true + } + + // Nx. + if (fs.existsSync(path.resolve(cwd, "nx.json"))) { + return true + } + + return false +} + +// Finds app directories in a monorepo that contain framework configs or components.json. +export async function getMonorepoTargets(cwd: string) { + const patterns = await getWorkspacePatterns(cwd) + + if (!patterns.length) { + return [] + } + + // Resolve patterns to directories. + const dirs = await fg(patterns, { + cwd, + onlyDirectories: true, + ignore: ["**/node_modules/**"], + }) + + const targets: { name: string; hasConfig: boolean }[] = [] + + for (const dir of dirs) { + const fullPath = path.resolve(cwd, dir) + + // Check if it has a package.json (it's an actual workspace). + if (!fs.existsSync(path.resolve(fullPath, "package.json"))) { + continue + } + + const hasComponentsJson = fs.existsSync( + path.resolve(fullPath, "components.json") + ) + + // Check for framework config files. + const hasFrameworkConfig = FRAMEWORK_CONFIG_FILES.some((pattern) => { + const matches = fg.sync(pattern, { + cwd: fullPath, + dot: true, + }) + return matches.length > 0 + }) + + if (hasComponentsJson || hasFrameworkConfig) { + targets.push({ + name: dir, + hasConfig: hasComponentsJson, + }) + } + } + + return targets +} + +// Formats and logs the monorepo detection message. +export function formatMonorepoMessage( + command: string, + targets: { name: string; hasConfig: boolean }[] +) { + logger.break() + logger.log( + `It looks like you are running ${highlighter.info( + command + )} from a monorepo root.` + ) + logger.log( + `To use shadcn in a specific workspace, use the ${highlighter.info( + "-c" + )} flag:` + ) + logger.break() + + for (const target of targets) { + logger.log(` shadcn ${command} -c ${target.name}`) + } + + logger.break() +} + +async function getWorkspacePatterns(cwd: string) { + const patterns: string[] = [] + + // Read pnpm-workspace.yaml. + const pnpmWorkspacePath = path.resolve(cwd, "pnpm-workspace.yaml") + if (fs.existsSync(pnpmWorkspacePath)) { + const content = await fs.readFile(pnpmWorkspacePath, "utf8") + // Simple regex parse to extract patterns from packages list. + const matches = content.matchAll(/^\s*-\s*["']?([^"'\n#]+)["']?\s*$/gm) + for (const match of matches) { + patterns.push(match[1].trim()) + } + } + + // Read package.json workspaces. + const packageJsonPath = path.resolve(cwd, "package.json") + if (fs.existsSync(packageJsonPath)) { + try { + const packageJson = await fs.readJson(packageJsonPath) + const workspaces = Array.isArray(packageJson.workspaces) + ? packageJson.workspaces + : packageJson.workspaces?.packages + if (Array.isArray(workspaces)) { + // Filter out negation patterns. + patterns.push(...workspaces.filter((w: string) => !w.startsWith("!"))) + } + } catch { + // Ignore parse errors. + } + } + + return [...new Set(patterns)] +} diff --git a/packages/shadcn/src/utils/presets.ts b/packages/shadcn/src/utils/presets.ts index 3bb169c752..018ca5d7c3 100644 --- a/packages/shadcn/src/utils/presets.ts +++ b/packages/shadcn/src/utils/presets.ts @@ -99,8 +99,11 @@ export function resolveInitUrl( export async function promptForPreset(options: { rtl: boolean template?: string + base?: string }) { - const presets = Object.values(DEFAULT_PRESETS) + const presets = Object.values(DEFAULT_PRESETS).filter( + (preset) => !options.base || preset.base === options.base + ) const { selectedPreset } = await prompts({ type: "select",