feat: warn if in monorepo

This commit is contained in:
shadcn
2026-02-21 15:17:06 +04:00
parent e1e9940a04
commit 2ddd920e4d
8 changed files with 516 additions and 1 deletions

View File

@@ -0,0 +1,5 @@
---
"shadcn": patch
---
warn if in monorepo and cwd not set

View File

@@ -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`

View File

@@ -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

View File

@@ -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<typeof addOptionsSchema>) {
// 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,

View File

@@ -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(

View File

@@ -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()
})
})

View File

@@ -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)]
}

View File

@@ -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",