From 6dea65ebcbbdb8773b3072ca74c9cee4e386988b Mon Sep 17 00:00:00 2001 From: shadcn Date: Tue, 28 Apr 2026 09:52:10 +0400 Subject: [PATCH] fix(shadcn): apply for monorepo (#10524) * fix(shadcn): apply for monorepo * fix --- .changeset/thirty-aliens-live.md | 5 + packages/shadcn/src/commands/apply.ts | 153 +++++++++++-- .../shadcn/src/preflights/preflight-apply.ts | 16 +- packages/shadcn/src/utils/file-helper.test.ts | 24 +- packages/shadcn/src/utils/file-helper.ts | 19 +- packages/tests/src/tests/apply.test.ts | 208 ++++++++++++++++++ 6 files changed, 392 insertions(+), 33 deletions(-) create mode 100644 .changeset/thirty-aliens-live.md diff --git a/.changeset/thirty-aliens-live.md b/.changeset/thirty-aliens-live.md new file mode 100644 index 000000000..561f4c28d --- /dev/null +++ b/.changeset/thirty-aliens-live.md @@ -0,0 +1,5 @@ +--- +"shadcn": patch +--- + +fix apply in monorepo diff --git a/packages/shadcn/src/commands/apply.ts b/packages/shadcn/src/commands/apply.ts index 3a3aade59..bdc3add21 100644 --- a/packages/shadcn/src/commands/apply.ts +++ b/packages/shadcn/src/commands/apply.ts @@ -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() + + 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", diff --git a/packages/shadcn/src/preflights/preflight-apply.ts b/packages/shadcn/src/preflights/preflight-apply.ts index 233f9c607..2803b50a9 100644 --- a/packages/shadcn/src/preflights/preflight-apply.ts +++ b/packages/shadcn/src/preflights/preflight-apply.ts @@ -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 ", 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 ", applyTargets, { cwdFlag: "-c", }) process.exit(1) diff --git a/packages/shadcn/src/utils/file-helper.test.ts b/packages/shadcn/src/utils/file-helper.test.ts index 5b8c34db3..4fbe74d85 100644 --- a/packages/shadcn/src/utils/file-helper.test.ts +++ b/packages/shadcn/src/utils/file-helper.test.ts @@ -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() }) }) diff --git a/packages/shadcn/src/utils/file-helper.ts b/packages/shadcn/src/utils/file-helper.ts index dceccba12..f93dd863d 100644 --- a/packages/shadcn/src/utils/file-helper.ts +++ b/packages/shadcn/src/utils/file-helper.ts @@ -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( filePath: string, - task: () => Promise, - options: WithFileBackupOptions = {} + task: () => Promise ) { if (!fsExtra.existsSync(filePath)) { return task() @@ -67,8 +71,7 @@ export async function withFileBackup( 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) diff --git a/packages/tests/src/tests/apply.test.ts b/packages/tests/src/tests/apply.test.ts index fb22fc47b..92fc8934e 100644 --- a/packages/tests/src/tests/apply.test.ts +++ b/packages/tests/src/tests/apply.test.ts @@ -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 ", 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 -c apps/web" ) + expect(result.stdout).not.toContain( + "shadcn apply --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 + ) })