fix(shadcn): apply for monorepo (#10524)

* fix(shadcn): apply for monorepo

* fix
This commit is contained in:
shadcn
2026-04-28 09:52:10 +04:00
committed by GitHub
parent ba10089b8d
commit 6dea65ebcb
6 changed files with 392 additions and 33 deletions

View File

@@ -0,0 +1,5 @@
---
"shadcn": patch
---
fix apply in monorepo

View File

@@ -16,8 +16,18 @@ import { isUrl } from "@/src/registry/utils"
import { getTemplateForFramework } from "@/src/templates/index"
import { loadEnvFiles } from "@/src/utils/env-loader"
import * as ERRORS from "@/src/utils/errors"
import { withFileBackup } from "@/src/utils/file-helper"
import { getBase } from "@/src/utils/get-config"
import {
createFileBackup,
deleteFileBackup,
FileBackupError,
restoreFileBackup,
withFileBackup,
} from "@/src/utils/file-helper"
import {
getBase,
getWorkspaceConfig,
type Config,
} from "@/src/utils/get-config"
import {
getProjectComponents,
getProjectInfo,
@@ -26,6 +36,7 @@ import { handleError } from "@/src/utils/handle-error"
import { highlighter } from "@/src/utils/highlighter"
import { logger } from "@/src/utils/logger"
import { Command } from "commander"
import fs from "fs-extra"
import prompts from "prompts"
import { z } from "zod"
@@ -48,6 +59,13 @@ class ApplyOnlyError extends Error {
}
}
class ApplyWorkspaceSyncError extends Error {
constructor(message: string) {
super(message)
this.name = "ApplyWorkspaceSyncError"
}
}
export const apply = new Command()
.name("apply")
.description("apply a preset to an existing project")
@@ -190,7 +208,7 @@ export const apply = new Command()
only: only?.join(","),
})
await withFileBackup(
const config = await withFileBackup(
path.resolve(options.cwd, "components.json"),
async () => {
const {
@@ -209,7 +227,7 @@ export const apply = new Command()
only,
})
await runInit({
return await runInit({
cwd: options.cwd,
yes: true,
force: false,
@@ -223,18 +241,11 @@ export const apply = new Command()
existingConfig,
components: [cleanUrl, ...reinstallComponents],
})
},
{
onBackupFailure: () => {
logger.error(
`Could not back up ${highlighter.info(
"components.json"
)}. Aborting.`
)
},
}
)
await syncApplyWorkspaceConfigs(config, { only })
logger.break()
logger.log("Preset applied successfully.")
logger.break()
@@ -247,6 +258,20 @@ export const apply = new Command()
process.exit(1)
}
if (error instanceof FileBackupError) {
logger.error(
`Could not back up ${highlighter.info("components.json")}. Aborting.`
)
logger.break()
process.exit(1)
}
if (error instanceof ApplyWorkspaceSyncError) {
logger.error(error.message)
logger.break()
process.exit(1)
}
logger.break()
handleError(error)
} finally {
@@ -406,6 +431,108 @@ async function resolveApplyTemplate(cwd: string) {
return getTemplateForFramework(projectInfo?.framework.name)
}
async function syncApplyWorkspaceConfigs(
config: Config,
options?: {
only?: string[]
}
) {
if (options?.only && !options.only.includes("theme")) {
return
}
const linkedConfigs = await getApplyWorkspaceConfigs(config)
if (!linkedConfigs.length) {
return
}
const patch = {
style: config.style,
tailwind: {
baseColor: config.tailwind.baseColor,
cssVariables: config.tailwind.cssVariables,
},
...(config.iconLibrary ? { iconLibrary: config.iconLibrary } : {}),
...(config.rtl !== undefined ? { rtl: config.rtl } : {}),
...(config.menuColor ? { menuColor: config.menuColor } : {}),
...(config.menuAccent ? { menuAccent: config.menuAccent } : {}),
}
const workspaceConfigs = []
for (const linkedConfig of linkedConfigs) {
const configPath = path.resolve(
linkedConfig.resolvedPaths.cwd,
"components.json"
)
if (!(await fs.pathExists(configPath))) {
continue
}
workspaceConfigs.push({
configPath,
existingConfig: await fs.readJson(configPath),
})
}
try {
for (const workspaceConfig of workspaceConfigs) {
const backupPath = createFileBackup(workspaceConfig.configPath)
if (!backupPath) {
throw new FileBackupError(workspaceConfig.configPath)
}
}
for (const workspaceConfig of workspaceConfigs) {
await fs.writeJson(
workspaceConfig.configPath,
{
...workspaceConfig.existingConfig,
...patch,
tailwind: {
...workspaceConfig.existingConfig.tailwind,
...patch.tailwind,
},
},
{ spaces: 2 }
)
}
for (const workspaceConfig of workspaceConfigs) {
deleteFileBackup(workspaceConfig.configPath)
}
} catch (error) {
for (const workspaceConfig of [...workspaceConfigs].reverse()) {
restoreFileBackup(workspaceConfig.configPath)
}
throw new ApplyWorkspaceSyncError(
`Failed to sync linked workspace configs.${error instanceof Error ? ` ${error.message}` : ""}`
)
}
}
async function getApplyWorkspaceConfigs(config: Config) {
const workspaceConfig = await getWorkspaceConfig(config)
if (!workspaceConfig) {
return []
}
const linkedConfigs = new Map<string, Config>()
for (const linkedConfig of Object.values(workspaceConfig)) {
if (linkedConfig.resolvedPaths.cwd === config.resolvedPaths.cwd) {
continue
}
linkedConfigs.set(linkedConfig.resolvedPaths.cwd, linkedConfig)
}
return Array.from(linkedConfigs.values()).sort((a, b) =>
a.resolvedPaths.cwd.localeCompare(b.resolvedPaths.cwd)
)
}
export function resolveApplyInitUrl(
preset: string,
currentBase: "radix" | "base",

View File

@@ -7,6 +7,7 @@ import {
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"
import fs from "fs-extra"
@@ -28,8 +29,19 @@ export async function preFlightApply(options: { cwd: string }) {
if (!fs.existsSync(path.resolve(options.cwd, "components.json"))) {
if (await isMonorepoRoot(options.cwd)) {
const targets = await getMonorepoTargets(options.cwd)
if (targets.length > 0) {
formatMonorepoMessage("apply --preset <preset>", targets, {
const applyTargets: typeof targets = []
for (const target of targets) {
const projectInfo = await getProjectInfo(
path.resolve(options.cwd, target.name)
)
if (projectInfo?.framework && projectInfo.framework.name !== "manual") {
applyTargets.push(target)
}
}
if (applyTargets.length > 0) {
formatMonorepoMessage("apply --preset <preset>", applyTargets, {
cwdFlag: "-c",
})
process.exit(1)

View File

@@ -3,7 +3,11 @@ import path from "path"
import fs from "fs-extra"
import { afterEach, describe, expect, it, vi } from "vitest"
import { FILE_BACKUP_SUFFIX, withFileBackup } from "./file-helper"
import {
FILE_BACKUP_SUFFIX,
FileBackupError,
withFileBackup,
} from "./file-helper"
const tempDirs: string[] = []
@@ -18,6 +22,7 @@ async function createTempFile() {
}
afterEach(async () => {
vi.restoreAllMocks()
await Promise.all(tempDirs.splice(0).map((dir) => fs.remove(dir)))
})
@@ -49,22 +54,21 @@ describe("withFileBackup", () => {
it("should abort when backup creation fails", async () => {
const filePath = await createTempFile()
const consoleErrorSpy = vi
.spyOn(console, "error")
.mockImplementation(() => {})
const task = vi.fn(async () => {
await fs.writeFile(filePath, '{"style":"after"}\n', "utf8")
})
const renameSyncSpy = vi.spyOn(fs, "renameSync").mockImplementation(() => {
throw new Error("boom")
})
await expect(
withFileBackup(filePath, async () => {
await fs.writeFile(filePath, '{"style":"after"}\n', "utf8")
})
).rejects.toThrow(`Could not back up ${filePath}.`)
await expect(withFileBackup(filePath, task)).rejects.toThrow(
FileBackupError
)
expect(task).not.toHaveBeenCalled()
expect(await fs.readFile(filePath, "utf8")).toBe('{"style":"before"}\n')
expect(await fs.pathExists(`${filePath}${FILE_BACKUP_SUFFIX}`)).toBe(false)
renameSyncSpy.mockRestore()
consoleErrorSpy.mockRestore()
})
})

View File

@@ -2,8 +2,14 @@ import fsExtra from "fs-extra"
export const FILE_BACKUP_SUFFIX = ".bak"
type WithFileBackupOptions = {
onBackupFailure?: (filePath: string) => void
export class FileBackupError extends Error {
filePath: string
constructor(filePath: string) {
super(`Could not back up ${filePath}.`)
this.name = "FileBackupError"
this.filePath = filePath
}
}
export function createFileBackup(filePath: string): string | null {
@@ -15,8 +21,7 @@ export function createFileBackup(filePath: string): string | null {
try {
fsExtra.renameSync(filePath, backupPath)
return backupPath
} catch (error) {
console.error(`Failed to create backup of ${filePath}: ${error}`)
} catch {
return null
}
}
@@ -57,8 +62,7 @@ export function deleteFileBackup(filePath: string): boolean {
export async function withFileBackup<T>(
filePath: string,
task: () => Promise<T>,
options: WithFileBackupOptions = {}
task: () => Promise<T>
) {
if (!fsExtra.existsSync(filePath)) {
return task()
@@ -67,8 +71,7 @@ export async function withFileBackup<T>(
const backupPath = createFileBackup(filePath)
if (!backupPath) {
options.onBackupFailure?.(filePath)
throw new Error(`Could not back up ${filePath}.`)
throw new FileBackupError(filePath)
}
const restoreBackupOnExit = () => restoreFileBackup(filePath)

View File

@@ -36,6 +36,40 @@ async function createInitializedViteRtlProject() {
return fixturePath
}
async function createInitializedMonorepoProject() {
const rootDir = path.join(
os.tmpdir(),
`shadcn-apply-monorepo-${process.pid}-${Date.now()}`
)
const projectName = `test-apply-monorepo-${process.pid}`
await fs.ensureDir(rootDir)
const result = await npxShadcn(
rootDir,
[
"init",
"--name",
projectName,
"-t",
"vite",
"--monorepo",
"--preset",
"nova",
"--base",
"radix",
],
{
timeout: 300000,
}
)
expect(result.exitCode).toBe(0)
return {
projectPath: path.join(rootDir, projectName),
rootDir,
}
}
async function createInitializedRadixProject() {
const fixturePath = await createFixtureTestDirectory("next-app")
await npxShadcn(fixturePath, ["init", "--preset", "a0", "--base", "radix"])
@@ -43,7 +77,31 @@ async function createInitializedRadixProject() {
return fixturePath
}
function getMonorepoPresetConfig(config: {
style: string
tailwind: {
baseColor: string
cssVariables: boolean
}
iconLibrary?: string
rtl?: boolean
menuColor?: string
menuAccent?: string
}) {
return {
style: config.style,
baseColor: config.tailwind.baseColor,
cssVariables: config.tailwind.cssVariables,
iconLibrary: config.iconLibrary,
rtl: config.rtl ?? false,
menuColor: config.menuColor ?? "default",
menuAccent: config.menuAccent ?? "subtle",
}
}
describe("shadcn apply", () => {
const itIfNotCi = process.env.CI ? it.skip : it
it("should apply a preset with --preset <code>", async () => {
const fixturePath = await createInitializedProject()
const result = await npxShadcn(fixturePath, [
@@ -206,6 +264,31 @@ describe("shadcn apply", () => {
expect(await fs.pathExists(`${componentsJsonPath}.bak`)).toBe(false)
})
it("should abort before applying when components.json cannot be backed up", async () => {
const fixturePath = await createInitializedProject()
const componentsJsonPath = path.join(fixturePath, "components.json")
const buttonPath = path.join(fixturePath, "components/ui/button.tsx")
const originalConfig = await fs.readFile(componentsJsonPath, "utf8")
const originalButton = await fs.readFile(buttonPath, "utf8")
await fs.ensureDir(`${componentsJsonPath}.bak`)
const result = await npxShadcn(fixturePath, [
"apply",
"--preset",
"lyra",
"-y",
])
expect(result.exitCode).toBe(1)
expect(result.stdout).toContain("Could not back up components.json")
expect(await fs.readFile(componentsJsonPath, "utf8")).toBe(originalConfig)
expect(await fs.readFile(buttonPath, "utf8")).toBe(originalButton)
expect((await fs.stat(`${componentsJsonPath}.bak`)).isDirectory()).toBe(
true
)
})
it("should guide the user to a workspace when run from a monorepo root", async () => {
const rootDir = path.join(
os.tmpdir(),
@@ -239,6 +322,9 @@ describe("shadcn apply", () => {
expect(result.stdout).toContain(
"shadcn apply --preset <preset> -c apps/web"
)
expect(result.stdout).not.toContain(
"shadcn apply --preset <preset> -c packages/ui"
)
await fs.remove(rootDir)
})
@@ -405,4 +491,126 @@ describe("shadcn apply", () => {
expect(updatedPagination).not.toBe(initialPagination)
expect(updatedSidebar).not.toBe(initialSidebar)
})
itIfNotCi(
"should keep the app config updated when linked workspace sync fails",
async () => {
const { projectPath, rootDir } = await createInitializedMonorepoProject()
const presetUrl = `${getRegistryUrl().replace(/\/r\/?$/, "")}/init?base=base&style=lyra&baseColor=neutral&theme=neutral&iconLibrary=phosphor&font=manrope&rtl=true&menuAccent=bold&menuColor=inverted&radius=default`
const appConfigPath = path.join(projectPath, "apps/web/components.json")
const uiConfigPath = path.join(projectPath, "packages/ui/components.json")
const buttonPath = path.join(
projectPath,
"packages/ui/src/components/button.tsx"
)
const originalButton = await fs.readFile(buttonPath, "utf8")
await fs.ensureDir(`${uiConfigPath}.bak`)
try {
const result = await npxShadcn(
projectPath,
["apply", "--preset", presetUrl, "-y", "-c", "apps/web"],
{
timeout: 300000,
}
)
expect(result.exitCode).toBe(1)
expect(result.stdout).toContain(
"Failed to sync linked workspace configs"
)
const appConfig = await fs.readJson(appConfigPath)
const uiConfig = await fs.readJson(uiConfigPath)
expect(appConfig.style).toBe("radix-lyra")
expect(appConfig.iconLibrary).toBe("phosphor")
expect(appConfig.menuColor).toBe("inverted")
expect(appConfig.menuAccent).toBe("bold")
expect(appConfig.rtl).toBe(false)
expect(uiConfig.style).toBe("radix-nova")
expect(uiConfig.tailwind.baseColor).toBe("neutral")
expect(uiConfig.tailwind.cssVariables).toBe(true)
expect(uiConfig.iconLibrary).toBe("phosphor")
expect(uiConfig.menuColor).toBe("inverted")
expect(uiConfig.menuAccent).toBe("bold")
expect(uiConfig.rtl).toBe(false)
expect(await fs.readFile(buttonPath, "utf8")).not.toBe(originalButton)
} finally {
await fs.remove(rootDir)
}
},
300000
)
itIfNotCi(
"should sync linked workspace configs when applying from apps/web",
async () => {
const { projectPath, rootDir } = await createInitializedMonorepoProject()
const presetUrl = `${getRegistryUrl().replace(/\/r\/?$/, "")}/init?base=base&style=lyra&baseColor=neutral&theme=neutral&iconLibrary=phosphor&font=manrope&rtl=true&menuAccent=bold&menuColor=inverted&radius=default`
try {
const result = await npxShadcn(
projectPath,
["apply", "--preset", presetUrl, "-y", "-c", "apps/web"],
{
timeout: 300000,
}
)
expect(result.exitCode).toBe(0)
const appConfig = await fs.readJson(
path.join(projectPath, "apps/web/components.json")
)
const uiConfig = await fs.readJson(
path.join(projectPath, "packages/ui/components.json")
)
expect(getMonorepoPresetConfig(appConfig)).toEqual(
getMonorepoPresetConfig(uiConfig)
)
expect(appConfig.style).toBe("radix-lyra")
expect(appConfig.iconLibrary).toBe("phosphor")
expect(appConfig.menuColor).toBe("inverted")
expect(appConfig.menuAccent).toBe("bold")
expect(appConfig.rtl).toBe(false)
expect(appConfig.tailwind.css).toBe(
"../../packages/ui/src/styles/globals.css"
)
expect(uiConfig.tailwind.css).toBe("src/styles/globals.css")
} finally {
await fs.remove(rootDir)
}
},
300000
)
itIfNotCi(
"should require an app workspace when applying from a monorepo",
async () => {
const { projectPath, rootDir } = await createInitializedMonorepoProject()
const presetUrl = `${getRegistryUrl().replace(/\/r\/?$/, "")}/init?base=base&style=lyra&baseColor=neutral&theme=neutral&iconLibrary=phosphor&font=manrope&rtl=true&menuAccent=bold&menuColor=inverted&radius=default`
try {
const result = await npxShadcn(
projectPath,
["apply", "--preset", presetUrl, "-y", "-c", "packages/ui"],
{
timeout: 300000,
}
)
expect(result.exitCode).toBe(1)
expect(result.stdout).toContain(
"We could not detect a supported framework"
)
expect(result.stdout).toContain("packages/ui")
} finally {
await fs.remove(rootDir)
}
},
300000
)
})