Repository: vercel/next.js
Pull request: [cna] Ensure created app is not considered the workspace root in pnpm

Follow-up to https://github.com/vercel/next.js/pull/83168

With `packages`, pnpm will assume the package is the workspace root and demand the `-w` flag (ERR_PNPM_ADDING_TO_ROOT). Even if `packages` is empty.

Since we only need `packages` for v9 (which doesn't even support `ignoreBuiltDependencies`), we just skip generation of pnpm-workspace.yaml entirely which also means we can drop `packages`

I originally skipped the check because I wanted to avoid an expensive `pnpm` invocation. Cursor figured we can use `npm_config_user_agent` (see https://github.com/pnpm/pnpm/issues/3985) to parse the version from which avoids spawning a sub process entirely. We still fallback to `pnpm --quiet --version` if the env variable is not available.

## test plan

- [x] v9 produces no workspace.yml `p create next-app@http://vercel-packages.vercel.app/next/commits/399b272b713abcb1c63c28c93cc082d2b41b031b/create-next-app next-pnpm-built-workspace-yml`
- [x] v10 produces workspace.yml `p create next-app@http://vercel-packages.vercel.app/next/commits/399b272b713abcb1c63c28c93cc082d2b41b031b/create-next-app next-pnpm-built-workspace-yml`

Changed files:
filename: packages/create-next-app/helpers/get-pkg-manager.ts
patch: @@ -1,3 +1,5 @@
+import { execSync } from 'child_process'
+
 export type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun'
 
 export function getPkgManager(): PackageManager {
@@ -17,3 +19,35 @@ export function getPkgManager(): PackageManager {
 
   return 'npm'
 }
+
+/**
+ * Get the major version of pnpm being used.
+ * Returns null if unable to determine the version.
+ *
+ * First tries to parse from npm_config_user_agent (e.g., "pnpm/9.13.2 npm/? ..."),
+ * then falls back to spawning `pnpm --version --silent`.
+ */
+export function getPnpmMajorVersion(): number | null {
+  // Try to get version from user agent first (e.g., "pnpm/9.13.2 npm/? node/v20.x linux x64")
+  const userAgent = process.env.npm_config_user_agent || ''
+  const pnpmVersionMatch = userAgent.match(/pnpm\/(\d+)/)
+  if (pnpmVersionMatch) {
+    return parseInt(pnpmVersionMatch[1], 10)
+  }
+
+  // Fall back to spawning pnpm --version
+  try {
+    const version = execSync('pnpm --version --silent', {
+      encoding: 'utf8',
+      stdio: ['pipe', 'pipe', 'ignore'],
+    }).trim()
+    const majorVersion = parseInt(version.split('.')[0], 10)
+    if (!Number.isNaN(majorVersion)) {
+      return majorVersion
+    }
+  } catch {
+    // pnpm not available or failed to run
+  }
+
+  return null
+}

filename: packages/create-next-app/templates/index.ts
patch: @@ -1,6 +1,7 @@
 import { install } from "../helpers/install";
 import { runTypegen } from "../helpers/typegen";
 import { copy } from "../helpers/copy";
+import { getPnpmMajorVersion } from "../helpers/get-pkg-manager";
 
 import { async as glob } from "fast-glob";
 import os from "os";
@@ -320,24 +321,29 @@ export const installTemplate = async ({
   }
 
   if (packageManager === "pnpm") {
-    const pnpmWorkspaceYaml = [
-      // required for v9 to avoid "packages field missing or empty", v10 doesn't need it anymore
-      // TODO: remove when v9 no longer supported
-      "packages: []",
-      // v10 setting without counterpart in v9
-      "ignoredBuiltDependencies:",
-      // Sharp has prebuilt binaries for the platforms next-swc has binaries.
-      // If it needs to build binaries from source, next-swc wouldn't work either.
-      // See https://sharp.pixelplumbing.com/install/#:~:text=When%20using%20pnpm%2C%20add%20sharp%20to%20ignoredBuiltDependencies%20to%20silence%20warnings
-      "  - sharp",
-      // Not needed for pnpm: https://github.com/unrs/unrs-resolver/issues/193#issuecomment-3295510146
-      "  - unrs-resolver",
-      "",
-    ].join(os.EOL);
-    await fs.writeFile(
-      path.join(root, "pnpm-workspace.yaml"),
-      pnpmWorkspaceYaml,
-    );
+    // Only create pnpm-workspace.yaml for pnpm v10+.
+    // In v9, having a pnpm-workspace.yaml (even with packages: []) causes
+    // ERR_PNPM_ADDING_TO_ROOT errors when running `pnpm add`.
+    // In v10, the packages field can be omitted entirely.
+    // If we can't determine the version, assume latest (v10+) since we already
+    // know pnpm is being used at this point.
+    const pnpmMajorVersion = getPnpmMajorVersion();
+    if (pnpmMajorVersion === null || pnpmMajorVersion >= 10) {
+      const pnpmWorkspaceYaml = [
+        "ignoredBuiltDependencies:",
+        // Sharp has prebuilt binaries for the platforms next-swc has binaries.
+        // If it needs to build binaries from source, next-swc wouldn't work either.
+        // See https://sharp.pixelplumbing.com/install/#:~:text=When%20using%20pnpm%2C%20add%20sharp%20to%20ignoredBuiltDependencies%20to%20silence%20warnings
+        "  - sharp",
+        // Not needed for pnpm: https://github.com/unrs/unrs-resolver/issues/193#issuecomment-3295510146
+        "  - unrs-resolver",
+        "",
+      ].join(os.EOL);
+      await fs.writeFile(
+        path.join(root, "pnpm-workspace.yaml"),
+        pnpmWorkspaceYaml,
+      );
+    }
   }
 
   if (packageManager === "bun") {

filename: test/integration/create-next-app/package-manager/pnpm.test.ts
patch: @@ -2,6 +2,7 @@ import {
   DEFAULT_FILES,
   FULL_EXAMPLE_PATH,
   projectFilesShouldExist,
+  projectFilesShouldNotExist,
   run,
   useTempDir,
 } from '../utils'
@@ -86,6 +87,74 @@ describe('create-next-app with package manager pnpm', () => {
     })
   })
 
+  // These tests use --skip-install because:
+  // 1. We only need to verify the workspace file is created/not created
+  // 2. The CI runs pnpm v9, but when testing v10 behavior, the workspace file
+  //    created for v10 (without packages field) would fail with pnpm v9
+  it('should create pnpm-workspace.yaml for pnpm v10+', async () => {
+    await useTempDir(async (cwd) => {
+      const projectName = 'pnpm-v10-workspace'
+      const res = await run(
+        [
+          projectName,
+          '--ts',
+          '--app',
+          '--no-turbopack',
+          '--no-linter',
+          '--no-src-dir',
+          '--no-tailwind',
+          '--no-import-alias',
+          '--no-react-compiler',
+          '--skip-install',
+        ],
+        nextTgzFilename,
+        {
+          cwd,
+          env: { npm_config_user_agent: 'pnpm/10.0.0 npm/? node/v20.0.0' },
+        }
+      )
+
+      expect(res.exitCode).toBe(0)
+      projectFilesShouldExist({
+        cwd,
+        projectName,
+        files: ['package.json', 'pnpm-workspace.yaml'],
+      })
+    })
+  })
+
+  it('should NOT create pnpm-workspace.yaml for pnpm v9', async () => {
+    await useTempDir(async (cwd) => {
+      const projectName = 'pnpm-v9-no-workspace'
+      const res = await run(
+        [
+          projectName,
+          '--ts',
+          '--app',
+          '--no-turbopack',
+          '--no-linter',
+          '--no-src-dir',
+          '--no-tailwind',
+          '--no-import-alias',
+          '--no-react-compiler',
+          '--skip-install',
+        ],
+        nextTgzFilename,
+        {
+          cwd,
+          env: { npm_config_user_agent: 'pnpm/9.13.2 npm/? node/v20.0.0' },
+        }
+      )
+
+      expect(res.exitCode).toBe(0)
+      projectFilesShouldNotExist({
+        cwd,
+        projectName,
+        files: ['pnpm-workspace.yaml'],
+      })
+    })
+  })
+
   it('should use pnpm for --use-pnpm flag with example', async () => {
     await useTempDir(async (cwd) => {
       const projectName = 'use-pnpm-with-example'