diff --git a/packages/cli/src/utils/install.ts b/packages/cli/src/utils/install.ts index 72af27db8..56332a285 100644 --- a/packages/cli/src/utils/install.ts +++ b/packages/cli/src/utils/install.ts @@ -2,6 +2,7 @@ import { existsSync } from "node:fs"; import { resolve } from "node:path"; import { x } from "tinyexec"; import { detectPackageManager, type PackageManager } from "./detect.js"; +import { shouldUseCorepack } from "./should-use-corepack.js"; export interface InstallPackageOptions { cwd?: string; @@ -38,10 +39,19 @@ export const installPackages = async ( } const installVerb = detectedAgent === "npm" ? "install" : "add"; + const shouldInvokeCorepack = await shouldUseCorepack(detectedAgent, options.cwd ?? process.cwd()); + const packageManagerCommand = shouldInvokeCorepack ? "corepack" : detectedAgent; + const packageManagerArgs = shouldInvokeCorepack ? [detectedAgent] : []; await x( - detectedAgent, - [installVerb, ...(options.isDev !== false ? ["-D"] : []), ...args, ...packages], + packageManagerCommand, + [ + ...packageManagerArgs, + installVerb, + ...(options.isDev !== false ? ["-D"] : []), + ...args, + ...packages, + ], { nodeOptions: { stdio: options.silent ? "ignore" : "inherit", diff --git a/packages/cli/src/utils/should-use-corepack.ts b/packages/cli/src/utils/should-use-corepack.ts new file mode 100644 index 000000000..3032480ca --- /dev/null +++ b/packages/cli/src/utils/should-use-corepack.ts @@ -0,0 +1,31 @@ +import { detect } from "package-manager-detector/detect"; +import { x } from "tinyexec"; +import type { PackageManager } from "./detect.js"; + +export const shouldUseCorepack = async ( + packageManager: PackageManager, + cwd: string, +): Promise => { + if (packageManager !== "pnpm" && packageManager !== "yarn") return false; + + const detectedPackageManager = await detect({ + cwd, + strategies: ["packageManager-field"], + }); + + if ( + detectedPackageManager?.name !== packageManager || + detectedPackageManager.version === undefined + ) { + return false; + } + + try { + const corepackVersionResult = await x("corepack", ["--version"], { + nodeOptions: { cwd, stdio: "ignore" }, + }); + return corepackVersionResult.exitCode === 0; + } catch { + return false; + } +}; diff --git a/packages/cli/test/install.test.ts b/packages/cli/test/install.test.ts index 67667b59f..5beaf25ac 100644 --- a/packages/cli/test/install.test.ts +++ b/packages/cli/test/install.test.ts @@ -1,5 +1,74 @@ -import { describe, expect, it } from "vite-plus/test"; -import { getPackagesToInstall } from "../src/utils/install.js"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +vi.mock("tinyexec", () => ({ + x: vi.fn(), +})); + +vi.mock("../src/utils/should-use-corepack.js", () => ({ + shouldUseCorepack: vi.fn(), +})); + +import { x } from "tinyexec"; +import { getPackagesToInstall, installPackages } from "../src/utils/install.js"; +import { shouldUseCorepack } from "../src/utils/should-use-corepack.js"; + +const mockExecute = vi.mocked(x); +const mockShouldUseCorepack = vi.mocked(shouldUseCorepack); + +beforeEach(() => { + vi.clearAllMocks(); + mockShouldUseCorepack.mockResolvedValue(false); +}); + +describe("installPackages", () => { + it("runs a pinned pnpm version through Corepack", async () => { + mockShouldUseCorepack.mockResolvedValue(true); + mockExecute.mockResolvedValue({ + exitCode: 0, + stderr: "", + stdout: "", + }); + + await installPackages(["react-grab"], { + cwd: "/app", + packageManager: "pnpm", + silent: true, + }); + + expect(mockExecute).toHaveBeenCalledWith( + "corepack", + ["pnpm", "add", "-D", "--prod=false", "react-grab"], + { + nodeOptions: { + stdio: "ignore", + cwd: "/app", + env: { ...process.env, REACT_GRAB_INIT: "1" }, + }, + throwOnError: true, + }, + ); + }); + + it("falls back to the package manager executable", async () => { + mockExecute.mockResolvedValue({ + exitCode: 0, + stderr: "", + stdout: "", + }); + + await installPackages(["react-grab"], { + cwd: "/app", + packageManager: "pnpm", + silent: true, + }); + + expect(mockExecute).toHaveBeenCalledWith( + "pnpm", + ["add", "-D", "--prod=false", "react-grab"], + expect.any(Object), + ); + }); +}); describe("getPackagesToInstall", () => { it("should return react-grab when includeReactGrab is true", () => { diff --git a/packages/cli/test/should-use-corepack.test.ts b/packages/cli/test/should-use-corepack.test.ts new file mode 100644 index 000000000..53eea6d31 --- /dev/null +++ b/packages/cli/test/should-use-corepack.test.ts @@ -0,0 +1,71 @@ +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +vi.mock("package-manager-detector/detect", () => ({ + detect: vi.fn(), +})); + +vi.mock("tinyexec", () => ({ + x: vi.fn(), +})); + +import { detect } from "package-manager-detector/detect"; +import { x } from "tinyexec"; +import { shouldUseCorepack } from "../src/utils/should-use-corepack.js"; + +const mockDetect = vi.mocked(detect); +const mockExecute = vi.mocked(x); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("shouldUseCorepack", () => { + it("uses Corepack for a pinned pnpm version", async () => { + mockDetect.mockResolvedValue({ + agent: "pnpm", + name: "pnpm", + version: "10.24.0", + }); + mockExecute.mockResolvedValue({ + exitCode: 0, + stderr: "", + stdout: "0.34.0", + }); + + await expect(shouldUseCorepack("pnpm", "/app")).resolves.toBe(true); + expect(mockDetect).toHaveBeenCalledWith({ + cwd: "/app", + strategies: ["packageManager-field"], + }); + expect(mockExecute).toHaveBeenCalledWith("corepack", ["--version"], { + nodeOptions: { cwd: "/app", stdio: "ignore" }, + }); + }); + + it("uses the package manager directly when no version is pinned", async () => { + mockDetect.mockResolvedValue({ + agent: "pnpm", + name: "pnpm", + }); + + await expect(shouldUseCorepack("pnpm", "/app")).resolves.toBe(false); + expect(mockExecute).not.toHaveBeenCalled(); + }); + + it("does not use Corepack for unsupported package managers", async () => { + await expect(shouldUseCorepack("bun", "/app")).resolves.toBe(false); + expect(mockDetect).not.toHaveBeenCalled(); + expect(mockExecute).not.toHaveBeenCalled(); + }); + + it("falls back when Corepack is unavailable", async () => { + mockDetect.mockResolvedValue({ + agent: "yarn@berry", + name: "yarn", + version: "4.9.1", + }); + mockExecute.mockRejectedValue(new Error("spawn corepack ENOENT")); + + await expect(shouldUseCorepack("yarn", "/app")).resolves.toBe(false); + }); +});