mirror of
https://github.com/shadcn-ui/ui.git
synced 2026-06-11 09:51:40 +00:00
fix(shadcn): apply for monorepo (#10524)
* fix(shadcn): apply for monorepo * fix
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user