diff --git a/packages/shadcn/src/templates/astro.ts b/packages/shadcn/src/templates/astro.ts index 424477881b..0261c7b224 100644 --- a/packages/shadcn/src/templates/astro.ts +++ b/packages/shadcn/src/templates/astro.ts @@ -30,8 +30,6 @@ import { ComponentExample } from "@/components/component-example" ], monorepo: { templateDir: "astro-monorepo", - packageManager: "pnpm", - installArgs: ["--no-frozen-lockfile"], init: fontsourceMonorepoInit, files: [ { diff --git a/packages/shadcn/src/templates/create-template.ts b/packages/shadcn/src/templates/create-template.ts index 8845e28bd9..f3f00f3104 100644 --- a/packages/shadcn/src/templates/create-template.ts +++ b/packages/shadcn/src/templates/create-template.ts @@ -34,12 +34,8 @@ export interface TemplateConfig { defaultProjectName: string // The template directory name (e.g. "next-app", "vite-app"). templateDir: string - // Force a specific package manager for this template. - packageManager?: string // Framework names that map to this template. frameworks?: string[] - // Custom args passed to `packageManager install`. - installArgs?: string[] scaffold?: (options: TemplateOptions) => Promise create: (options: TemplateOptions) => Promise init?: (options: TemplateInitOptions) => Promise @@ -50,8 +46,6 @@ export interface TemplateConfig { monorepo?: { templateDir: string defaultProjectName?: string - packageManager?: string - installArgs?: string[] init?: (options: TemplateInitOptions) => Promise files?: RegistryItem["files"] } @@ -66,7 +60,6 @@ export function createTemplate(config: TemplateConfig) { defaultScaffold({ title: config.title, templateDir: config.templateDir, - installArgs: config.installArgs, }), postInit: config.postInit ?? defaultPostInit, } @@ -86,8 +79,6 @@ export function resolveTemplate( ...template, templateDir: m.templateDir, defaultProjectName: m.defaultProjectName ?? m.templateDir, - packageManager: m.packageManager ?? template.packageManager, - installArgs: m.installArgs ?? template.installArgs, init: m.init ?? template.init, files: m.files ?? template.files, } @@ -96,21 +87,122 @@ export function resolveTemplate( resolved.scaffold = defaultScaffold({ title: template.title, templateDir: m.templateDir, - installArgs: resolved.installArgs, }) return resolved } +// Get the appropriate install args for the given package manager. +function getInstallArgs(packageManager: string): string[] { + switch (packageManager) { + case "pnpm": + // pnpm enables frozen lockfile in CI by default. + // The template lockfile may drift, so force-disable it explicitly. + return ["--no-frozen-lockfile"] + default: + return [] + } +} + +// Adapt a pnpm-based monorepo template to the target package manager. +async function adaptWorkspaceConfig( + projectPath: string, + packageManager: string +) { + if (packageManager === "pnpm") { + return + } + + const pnpmWorkspacePath = path.join(projectPath, "pnpm-workspace.yaml") + const packageJsonPath = path.join(projectPath, "package.json") + + // Remove pnpm-lock.yaml. + const lockFilePath = path.join(projectPath, "pnpm-lock.yaml") + if (fs.existsSync(lockFilePath)) { + await fs.remove(lockFilePath) + } + + const isMonorepo = fs.existsSync(pnpmWorkspacePath) + + // Update root package.json: strip "packageManager" field to avoid + // triggering Corepack, and add "workspaces" for npm/bun/yarn. + if (fs.existsSync(packageJsonPath)) { + const packageJsonContent = await fs.readFile(packageJsonPath, "utf8") + const packageJson = JSON.parse(packageJsonContent) + delete packageJson.packageManager + + if (isMonorepo) { + // Read workspace patterns from pnpm-workspace.yaml. + const workspaceContent = await fs.readFile(pnpmWorkspacePath, "utf8") + const patterns: string[] = [] + for (const line of workspaceContent.split("\n")) { + const match = line.match(/^\s*-\s*["']?(.+?)["']?\s*$/) + if (match) { + patterns.push(match[1]) + } + } + + packageJson.workspaces = patterns + await fs.remove(pnpmWorkspacePath) + } + + await fs.writeFile( + packageJsonPath, + JSON.stringify(packageJson, null, 2) + ) + } + + // Rewrite workspace: protocol references in nested package.json files. + // npm does not support workspace: protocol; bun and yarn do, so only + // rewrite for npm monorepo templates. + if (isMonorepo && packageManager === "npm") { + await rewriteWorkspaceProtocol(projectPath) + } +} + +// Recursively find all package.json files and replace workspace: protocol +// version specifiers with "*", which npm understands. +async function rewriteWorkspaceProtocol(dir: string) { + const entries = await fs.readdir(dir, { withFileTypes: true }) + for (const entry of entries) { + if (entry.name === "node_modules") continue + const fullPath = path.join(dir, entry.name) + if (entry.isDirectory()) { + await rewriteWorkspaceProtocol(fullPath) + } else if (entry.name === "package.json") { + const content = await fs.readFile(fullPath, "utf8") + if (!content.includes("workspace:")) continue + const pkg = JSON.parse(content) + let changed = false + for (const depKey of [ + "dependencies", + "devDependencies", + "peerDependencies", + "optionalDependencies", + ]) { + const deps = pkg[depKey] + if (!deps) continue + for (const [name, version] of Object.entries(deps)) { + if (typeof version === "string" && version.startsWith("workspace:")) { + deps[name] = "*" + changed = true + } + } + } + if (changed) { + await fs.writeFile(fullPath, JSON.stringify(pkg, null, 2)) + } + } + } +} + // Default scaffold that downloads a template from GitHub. function defaultScaffold({ title, templateDir, - installArgs, }: { title: string templateDir: string - installArgs?: string[] }) { return async ({ projectPath, packageManager }: TemplateOptions) => { const createSpinner = spinner( @@ -157,16 +249,12 @@ function defaultScaffold({ await fs.remove(templatePath) } - // Remove pnpm-lock.yaml if using a different package manager. - if (packageManager !== "pnpm") { - const lockFilePath = path.join(projectPath, "pnpm-lock.yaml") - if (fs.existsSync(lockFilePath)) { - await fs.remove(lockFilePath) - } - } + // Adapt workspace config and lockfiles for the target package manager. + await adaptWorkspaceConfig(projectPath, packageManager) // Run install. - const args = ["install", ...(installArgs ?? [])] + const installArgs = getInstallArgs(packageManager) + const args = ["install", ...installArgs] await execa(packageManager, args, { cwd: projectPath, }) diff --git a/packages/shadcn/src/templates/next.ts b/packages/shadcn/src/templates/next.ts index ef16aad99f..f090f99f1a 100644 --- a/packages/shadcn/src/templates/next.ts +++ b/packages/shadcn/src/templates/next.ts @@ -39,10 +39,6 @@ export default function Page() { ], monorepo: { templateDir: "next-monorepo", - packageManager: "pnpm", - // pnpm enables frozen lockfile in CI by default. - // The template lockfile may drift, so force-disable it explicitly. - installArgs: ["--no-frozen-lockfile"], init: async (options) => { const packagesUiPath = path.resolve(options.projectPath, "packages/ui") const appsWebPath = path.resolve(options.projectPath, "apps/web") diff --git a/packages/shadcn/src/templates/react-router.ts b/packages/shadcn/src/templates/react-router.ts index 24b6bd7060..43e6b1d735 100644 --- a/packages/shadcn/src/templates/react-router.ts +++ b/packages/shadcn/src/templates/react-router.ts @@ -27,8 +27,6 @@ export default function Home() { ], monorepo: { templateDir: "react-router-monorepo", - packageManager: "pnpm", - installArgs: ["--no-frozen-lockfile"], init: fontsourceMonorepoInit, files: [ { diff --git a/packages/shadcn/src/templates/start.ts b/packages/shadcn/src/templates/start.ts index ce232d871b..c12ddeff35 100644 --- a/packages/shadcn/src/templates/start.ts +++ b/packages/shadcn/src/templates/start.ts @@ -32,8 +32,6 @@ function App() { ], monorepo: { templateDir: "start-monorepo", - packageManager: "pnpm", - installArgs: ["--no-frozen-lockfile"], init: fontsourceMonorepoInit, files: [ { diff --git a/packages/shadcn/src/templates/vite.ts b/packages/shadcn/src/templates/vite.ts index 4af5294fc9..d4ed54c260 100644 --- a/packages/shadcn/src/templates/vite.ts +++ b/packages/shadcn/src/templates/vite.ts @@ -29,8 +29,6 @@ export default App; ], monorepo: { templateDir: "vite-monorepo", - packageManager: "pnpm", - installArgs: ["--no-frozen-lockfile"], init: fontsourceMonorepoInit, files: [ { diff --git a/packages/shadcn/src/utils/create-project.ts b/packages/shadcn/src/utils/create-project.ts index edcedfca13..d4d908ed1a 100644 --- a/packages/shadcn/src/utils/create-project.ts +++ b/packages/shadcn/src/utils/create-project.ts @@ -70,11 +70,9 @@ export async function createProject( monorepo: options.monorepo, }) - const packageManager = - effectiveTemplate.packageManager ?? - (await getPackageManager(options.cwd, { - withFallback: true, - })) + const packageManager = await getPackageManager(options.cwd, { + withFallback: true, + }) const projectPath = path.join(options.cwd, projectName) diff --git a/packages/shadcn/src/utils/scaffold.test.ts b/packages/shadcn/src/utils/scaffold.test.ts index a1356ee11c..9125ad9532 100644 --- a/packages/shadcn/src/utils/scaffold.test.ts +++ b/packages/shadcn/src/utils/scaffold.test.ts @@ -209,24 +209,17 @@ describe("defaultScaffold", () => { await template.scaffold({ projectPath: "/test/my-app", - packageManager: "npm", + packageManager: "bun", cwd: "/test", }) - expect(vi.mocked(execa)).toHaveBeenCalledWith("npm", ["install"], { + expect(vi.mocked(execa)).toHaveBeenCalledWith("bun", ["install"], { cwd: "/test/my-app", }) }) - it("should pass custom install args", async () => { - const template = createTemplate({ - name: "start", - title: "TanStack Start", - defaultProjectName: "start-app", - templateDir: "start-app", - installArgs: ["--shamefully-hoist"], - create: vi.fn(), - }) + it("should pass --no-frozen-lockfile for pnpm", async () => { + const template = createTestTemplate() await template.scaffold({ projectPath: "/test/my-app", @@ -236,7 +229,7 @@ describe("defaultScaffold", () => { expect(vi.mocked(execa)).toHaveBeenCalledWith( "pnpm", - ["install", "--shamefully-hoist"], + ["install", "--no-frozen-lockfile"], { cwd: "/test/my-app" } ) })