fix: respect detected package manager for monorepo templates

All monorepo templates hardcoded `packageManager: "pnpm"` which
meant running `bunx --bun shadcn@latest init --monorepo` would
still shell out to `pnpm install`, triggering Corepack and crashing
under Bun because `process.mainModule` is readonly there.

This removes the hardcoded pnpm override from every monorepo template
config so `getPackageManager()` can actually detect what the user is
running. The scaffold step now adapts the cloned template on the fly:

- strips the `packageManager` field from package.json (avoids Corepack)
- converts pnpm-workspace.yaml to a `"workspaces"` array in package.json
- removes pnpm-lock.yaml
- rewrites `workspace:*` refs to `"*"` when the detected PM is npm
  (npm doesn't support the workspace: protocol)
- picks the right install flags per PM (`--no-frozen-lockfile` for pnpm,
  nothing extra for bun/npm/yarn)

pnpm behavior is completely unchanged — `adaptWorkspaceConfig` early-
returns when the detected PM is pnpm.
This commit is contained in:
Devin Alexander
2026-03-11 14:52:39 +02:00
parent 821ac7ee4d
commit 0029b3b6f7
8 changed files with 116 additions and 49 deletions

View File

@@ -30,8 +30,6 @@ import { ComponentExample } from "@/components/component-example"
],
monorepo: {
templateDir: "astro-monorepo",
packageManager: "pnpm",
installArgs: ["--no-frozen-lockfile"],
init: fontsourceMonorepoInit,
files: [
{

View File

@@ -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<void>
create: (options: TemplateOptions) => Promise<void>
init?: (options: TemplateInitOptions) => Promise<Config>
@@ -50,8 +46,6 @@ export interface TemplateConfig {
monorepo?: {
templateDir: string
defaultProjectName?: string
packageManager?: string
installArgs?: string[]
init?: (options: TemplateInitOptions) => Promise<Config>
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,
})

View File

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

View File

@@ -27,8 +27,6 @@ export default function Home() {
],
monorepo: {
templateDir: "react-router-monorepo",
packageManager: "pnpm",
installArgs: ["--no-frozen-lockfile"],
init: fontsourceMonorepoInit,
files: [
{

View File

@@ -32,8 +32,6 @@ function App() {
],
monorepo: {
templateDir: "start-monorepo",
packageManager: "pnpm",
installArgs: ["--no-frozen-lockfile"],
init: fontsourceMonorepoInit,
files: [
{

View File

@@ -29,8 +29,6 @@ export default App;
],
monorepo: {
templateDir: "vite-monorepo",
packageManager: "pnpm",
installArgs: ["--no-frozen-lockfile"],
init: fontsourceMonorepoInit,
files: [
{

View File

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

View File

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