From ca3fbb62ce0a6a5e3286cf7b2d87cdca3416100d Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Mon, 21 Sep 2026 16:04:50 +0530 Subject: [PATCH 1/3] fix: verify the selected package manager before scaffolding Only npm was checked before writing project files. A pnpm, Yarn, Bun, or Deno that was missing or unusable was discovered when ` install` failed inside an already scaffolded project, and recorded as a dependency_install_failed technical failure. verifyPackageManagerEffect now probes every manager's version through CommandRunner with one shared version parser: - A missing manager is rejected as package_manager_not_found at validate_input, an expected rejection, with install guidance. - Yarn 1 is rejected as unsupported_package_manager_version. It exits with an error in a project whose "packageManager" names Yarn 4. npm keeps its 11.6.0 minimum. pnpm, Bun, and Deno get no minimum because nothing in the repo shows an older release failing. - Any other probe failure, including unreadable version output or a probe directory that cannot be created, is package_manager_check_failed at validate_input instead of dependency_install_failed. The version a manager reports depends on the directory. Corepack's yarn is Yarn 1 outside a project and the pinned release inside one, and pnpm and Corepack refuse to run where "packageManager" names another tool. Only the generated project's manifest decides what the install will run, so the probe always runs in a scoped temporary directory holding a package.json with the generated project's "packageManager" value (none for Deno), and never in the working directory. On Windows execa runs non-.exe commands through cmd.exe, so a missing manager exits non-zero and never raises ENOENT. CommandRunner now resolves the command the way cross-spawn does (working directory, PATH, PATHEXT) and classifies an unresolvable failed command as command_not_found, without reading cmd.exe's localized output. Co-Authored-By: Claude Fable 5.1 --- README.md | 10 +- src/commands/create.ts | 4 +- src/create-outcome.ts | 2 + src/services/command-runner.ts | 3 +- src/telemetry/create.ts | 1 + src/utils/child-process-failure.ts | 49 ++++++++ src/utils/package-manager.ts | 157 +++++++++++++++++++++--- tests/e2e/create-prisma.e2e.test.ts | 5 +- tests/install.test.ts | 184 ++++++++++++++++++++++++---- tests/setup-prisma.test.ts | 38 ++++++ tests/telemetry.test.ts | 55 ++++++++- 11 files changed, 457 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index 077d2d7..563103c 100644 --- a/README.md +++ b/README.md @@ -21,8 +21,14 @@ does not include this runtime itself. MongoDB and Deno-only apps do not need it. For npm, use 11.6.0 or newer, which includes the [upstream resolver fix](https://github.com/npm/cli/pull/8448). Older npm releases can crash resolving the dependency tree (`Cannot read properties of null (reading 'edgesOut')`). Run -`npm install --global npm@11` to update. create-prisma checks the selected npm -version before writing project files and never upgrades your package manager automatically. +`npm install --global npm@11` to update. + +For Yarn, use Yarn 2 or newer, through [Corepack](https://yarnpkg.com/corepack) or a Yarn 4 install. Generated +projects pin Yarn 4 in `"packageManager"`, which a global Yarn 1 refuses to install. + +create-prisma runs the selected package manager's version command before writing project files. It stops with +instructions if the package manager is not installed or is too old, and never installs or upgrades a package +manager automatically. The deployment prompt is: diff --git a/src/commands/create.ts b/src/commands/create.ts index a2181f6..392a68b 100644 --- a/src/commands/create.ts +++ b/src/commands/create.ts @@ -156,8 +156,8 @@ const createProjectEffect = Effect.fn("Create.project")(function* ( yield* Ref.set(contextRef, Option.some(context)); yield* atCreateStage( verifyPackageManagerEffect(context.prismaSetupContext.packageManager), - "install_dependencies", - "dependency_install_failed", + "validate_input", + "package_manager_check_failed", ); return { input, context, result: yield* executeCreateContext(context) }; }); diff --git a/src/create-outcome.ts b/src/create-outcome.ts index 05b67ba..e9a9191 100644 --- a/src/create-outcome.ts +++ b/src/create-outcome.ts @@ -26,6 +26,8 @@ export const CreateFailureReasonSchema = Schema.Literals([ "invalid_input", "unsupported_node_version", "unsupported_package_manager_version", + "package_manager_not_found", + "package_manager_check_failed", "invalid_project_name", "target_path_not_directory", "target_directory_not_empty", diff --git a/src/services/command-runner.ts b/src/services/command-runner.ts index b4eff0b..611547c 100644 --- a/src/services/command-runner.ts +++ b/src/services/command-runner.ts @@ -5,6 +5,7 @@ import { createInterface } from "node:readline"; import { ChildProcessFailureSchema, getChildProcessFailure, + getSpawnedCommandFailure, type ChildProcessFailure, } from "../utils/child-process-failure"; @@ -89,7 +90,7 @@ export class CommandRunner extends Context.Service< exitCode: result.exitCode ?? 1, stdout: typeof result.stdout === "string" ? result.stdout : "", stderr: typeof result.stderr === "string" ? result.stderr : "", - childProcessFailure: getChildProcessFailure(result), + childProcessFailure: getSpawnedCommandFailure(result, spec), }; }, catch: (cause) => diff --git a/src/telemetry/create.ts b/src/telemetry/create.ts index 8237702..a00c689 100644 --- a/src/telemetry/create.ts +++ b/src/telemetry/create.ts @@ -23,6 +23,7 @@ const expectedRejectionReasons = new Set([ "invalid_input", "unsupported_node_version", "unsupported_package_manager_version", + "package_manager_not_found", "invalid_project_name", "target_path_not_directory", "target_directory_not_empty", diff --git a/src/utils/child-process-failure.ts b/src/utils/child-process-failure.ts index cd4ddd0..9e18b73 100644 --- a/src/utils/child-process-failure.ts +++ b/src/utils/child-process-failure.ts @@ -1,4 +1,6 @@ import { Schema } from "effect"; +import { statSync } from "node:fs"; +import path from "node:path"; export const ChildProcessFailureSchema = Schema.Literals([ "cancelled", @@ -38,3 +40,50 @@ export function getChildProcessFailure(error: unknown): ChildProcessFailure | un if (typeof exitCode === "number") return "non_zero_exit"; return "spawn_failed"; } + +export type SpawnedCommand = { + command: string; + cwd: string; + env?: NodeJS.ProcessEnv; + platform?: NodeJS.Platform; +}; + +function isFile(filePath: string): boolean { + try { + return statSync(filePath, { throwIfNoEntry: false })?.isFile() === true; + } catch { + return false; + } +} + +// execa runs every Windows command that is not a .exe or .com through `cmd.exe /d /s /c`, so a +// missing pnpm, yarn, or bun never raises ENOENT: cmd.exe starts, fails, and exits non-zero. Its +// "is not recognized" text is localized, so look the command up the way the spawn layer does +// (cross-spawn resolves it with `which`: the working directory, then PATH, each with PATHEXT). +function isCommandOnWindowsPath({ command, cwd, env }: SpawnedCommand): boolean { + const environment = { ...process.env, ...env }; + const pathKey = Object.keys(environment) + .reverse() + .find((key) => key.toUpperCase() === "PATH"); + const directories = /[\\/]/.test(command) + ? [""] + : [cwd, ...(pathKey ? (environment[pathKey] ?? "") : "").split(";")]; + const extensions = ["", ...(environment.PATHEXT || ".EXE;.CMD;.BAT;.COM").split(";")]; + return directories.some((directory) => + extensions.some((extension) => + isFile(path.resolve(cwd, directory.replace(/^"(.*)"$/, "$1"), command + extension)), + ), + ); +} + +export function getSpawnedCommandFailure( + error: unknown, + spawned: SpawnedCommand, +): ChildProcessFailure | undefined { + const failure = getChildProcessFailure(error); + return failure === "non_zero_exit" && + (spawned.platform ?? process.platform) === "win32" && + !isCommandOnWindowsPath(spawned) + ? "command_not_found" + : failure; +} diff --git a/src/utils/package-manager.ts b/src/utils/package-manager.ts index 1737a87..0f4a413 100644 --- a/src/utils/package-manager.ts +++ b/src/utils/package-manager.ts @@ -6,6 +6,7 @@ import { CreateFailure } from "../create-outcome"; import { applicationRuntime } from "../runtime"; import { CommandRunner } from "../services/command-runner"; import { packageManagers, type PackageManager } from "../types"; +import { getErrorMessage } from "./errors"; type CommandAndArgs = { command: string; @@ -29,36 +30,154 @@ const packageManagerManifestValues = { bun: "bun@1.4.1", } as const; -export const verifyPackageManagerEffect = Effect.fn("PackageManager.verify")(function* ( +type PackageManagerVersion = readonly [major: number, minor: number, patch: number]; + +// A minimum is listed only where an older release is known to break a generated project. +const packageManagerChecks: Record< + PackageManager, + { + name: string; + versionArgs: string[]; + install: string; + minimum?: { version: PackageManagerVersion; guidance: string }; + } +> = { + npm: { + name: "npm", + versionArgs: ["--version"], + install: "Install Node.js from https://nodejs.org to get npm", + // https://github.com/npm/cli/pull/8448 shipped in npm 11.6.0. + minimum: { + version: [11, 6, 0], + guidance: + "Older npm releases can crash while resolving Prisma dependencies. Run npm install --global npm@11, then retry create-prisma.", + }, + }, + pnpm: { + name: "pnpm", + versionArgs: ["--version"], + install: "Install it from https://pnpm.io/installation", + }, + yarn: { + name: "Yarn", + versionArgs: ["--version"], + install: "Install it with Corepack (https://yarnpkg.com/corepack)", + // Yarn 1 exits with an error in a project whose "packageManager" names a newer Yarn. + minimum: { + version: [2, 0, 0], + guidance: `Generated projects use ${packageManagerManifestValues.yarn}, which Yarn 1 refuses to install. Enable Corepack (https://yarnpkg.com/corepack) or install Yarn 4, then retry create-prisma, or choose another package manager with --package-manager.`, + }, + }, + bun: { + name: "Bun", + versionArgs: ["--version"], + install: "Install it from https://bun.sh", + }, + deno: { + name: "Deno", + // `deno --version` also prints the V8 and TypeScript versions; `-V` prints only "deno 2.9.4". + versionArgs: ["-V"], + install: "Install it from https://docs.deno.com/runtime/getting_started/installation", + }, +}; + +const PACKAGE_MANAGER_VERSION_PATTERN = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; + +function parsePackageManagerVersion( + packageManager: PackageManager, + version: string, +): PackageManagerVersion | undefined { + const prefix = `${packageManager} `; + const match = PACKAGE_MANAGER_VERSION_PATTERN.exec( + version.startsWith(prefix) ? version.slice(prefix.length) : version, + ); + if (!match) return undefined; + const parts = [Number(match[1]), Number(match[2]), Number(match[3])] as const; + return parts.every(Number.isSafeInteger) ? parts : undefined; +} + +function isOlderVersion(version: PackageManagerVersion, minimum: PackageManagerVersion): boolean { + for (const [index, part] of version.entries()) { + if (part !== minimum[index]) return part < minimum[index]!; + } + return false; +} + +const probePackageManagerEffect = Effect.fn("PackageManager.probe")(function* ( packageManager: PackageManager, + cwd: string, ) { - if (packageManager !== "npm") return; const runner = yield* CommandRunner; - const result = yield* runner.runChecked({ - command: "npm", - args: ["--version"], - cwd: process.cwd(), - }); - const version = result.stdout.trim(); - const parts = version.split(".").map(Number); - if ( - !/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(version) || - parts.some((part) => !Number.isInteger(part)) - ) { - return yield* Effect.fail( - new Error(`Could not determine the installed npm version: ${version}`), - ); + const { name, versionArgs, install, minimum } = packageManagerChecks[packageManager]; + const result = yield* runner.runChecked({ command: packageManager, args: versionArgs, cwd }).pipe( + Effect.mapError((cause) => + cause.childProcessFailure === "command_not_found" + ? new CreateFailure({ + stage: "validate_input", + reason: "package_manager_not_found", + message: `${name} is not installed or is not on your PATH. ${install}, then retry create-prisma, or choose another package manager with --package-manager.`, + cause, + }) + : new CreateFailure({ + stage: "validate_input", + reason: "package_manager_check_failed", + message: `Could not run ${[packageManager, ...versionArgs].join(" ")}: ${getErrorMessage(cause)}`, + cause, + }), + ), + ); + const output = result.stdout.trim(); + const version = parsePackageManagerVersion(packageManager, output); + if (!version) { + return yield* new CreateFailure({ + stage: "validate_input", + reason: "package_manager_check_failed", + message: `Could not determine the installed ${name} version: ${output}`, + }); } - const [major, minor] = parts; - if (major! < 11 || (major === 11 && minor! < 6)) { + if (minimum && isOlderVersion(version, minimum.version)) { return yield* new CreateFailure({ stage: "validate_input", reason: "unsupported_package_manager_version", - message: `npm ${version} is unsupported. Required: npm 11.6.0 or newer. Older npm releases can crash while resolving Prisma dependencies. Run npm install --global npm@11, then retry create-prisma.`, + message: `${name} ${version.join(".")} is unsupported. Required: ${name} ${minimum.version.join(".")} or newer. ${minimum.guidance}`, }); } }); +// The version a package manager reports depends on where it runs. Corepack's yarn is Yarn 1 outside +// a project and the pinned release inside one, and pnpm and Corepack refuse to run in a directory +// whose "packageManager" names another tool. Only the generated project's own manifest decides what +// the install will run, so the probe runs in a temporary directory that holds that manifest value +// and never in the working directory. Deno projects have no value, so their manifest omits it. +export const verifyPackageManagerEffect = Effect.fn("PackageManager.verify")(function* ( + packageManager: PackageManager, +) { + const fs = yield* FileSystem.FileSystem; + const probeDir = yield* fs.makeTempDirectoryScoped({ prefix: "create-prisma-" }).pipe( + Effect.tap((directory) => + fs.writeFileString( + path.join(directory, "package.json"), + JSON.stringify({ + name: "create-prisma-probe", + private: true, + packageManager: getPackageManagerManifestValue(packageManager), + }), + ), + ), + Effect.mapError( + (cause) => + new CreateFailure({ + stage: "validate_input", + reason: "package_manager_check_failed", + message: `Could not prepare a directory to check ${packageManagerChecks[packageManager].name}: ${getErrorMessage(cause)}`, + cause, + }), + ), + ); + yield* probePackageManagerEffect(packageManager, probeDir); +}, Effect.scoped); + function parseUserAgent(userAgent: string | undefined): PackageManager | null { if (userAgent?.startsWith("pnpm")) { return "pnpm"; diff --git a/tests/e2e/create-prisma.e2e.test.ts b/tests/e2e/create-prisma.e2e.test.ts index 1252dfd..23b8097 100644 --- a/tests/e2e/create-prisma.e2e.test.ts +++ b/tests/e2e/create-prisma.e2e.test.ts @@ -347,7 +347,10 @@ describe("create-prisma e2e", () => { expect(JSON.parse(stdout)).toMatchObject({ schemaVersion: 1, ok: false, - error: { stage: "install_dependencies" }, + error: { + stage: "validate_input", + message: expect.stringContaining("npm is not installed or is not on your PATH."), + }, }); expect(stderr).toBe(""); expect(await pathExists(path.join(rootDir, "failed-app"))).toBe(false); diff --git a/tests/install.test.ts b/tests/install.test.ts index 971d6cb..15bd6f9 100644 --- a/tests/install.test.ts +++ b/tests/install.test.ts @@ -1,23 +1,35 @@ import { describe, expect, test } from "bun:test"; -import { Effect } from "effect"; +import { Effect, FileSystem, PlatformError } from "effect"; +import { existsSync, readFileSync } from "node:fs"; import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { dependencyVersionMap, PRISMA_DENO_CLI_PACKAGE } from "../src/constants/dependencies"; import { applicationRuntime } from "../src/runtime"; -import { CommandRunner } from "../src/services/command-runner"; +import { + CommandExecutionError, + CommandRunner, + type CommandSpec, +} from "../src/services/command-runner"; import { scaffoldCreateTemplate } from "../src/templates/render-create-template"; import { getComposerScriptMap, writeCreateTemplateDependencies, writePrismaDependencies, } from "../src/tasks/install"; -import { authoringStyles, createTemplates, databaseProviders, packageManagers } from "../src/types"; +import { + authoringStyles, + createTemplates, + databaseProviders, + packageManagers, + type PackageManager, +} from "../src/types"; import { getInstallArgs, getLocalPackageBinaryArgs, getPackageExecutionArgs, + getPackageManagerManifestValue, getRunScriptCommand, verifyPackageManagerEffect, } from "../src/utils/package-manager"; @@ -48,6 +60,36 @@ async function readPackageJson(projectDir: string): Promise { return JSON.parse(await readFile(path.join(projectDir, "package.json"), "utf8")) as PackageJson; } +function verifyPackageManager(manager: PackageManager, respond: (spec: CommandSpec) => string) { + const specs: CommandSpec[] = []; + const manifests: PackageJson[] = []; + const result = applicationRuntime.runPromise( + verifyPackageManagerEffect(manager).pipe( + Effect.provideService(CommandRunner, { + run: () => Effect.die("Unexpected unchecked command"), + runChecked: (spec) => { + specs.push(spec); + manifests.push(JSON.parse(readFileSync(path.join(spec.cwd, "package.json"), "utf8"))); + const response = respond(spec); + return response === "command_not_found" || response === "non_zero_exit" + ? Effect.fail( + new CommandExecutionError({ + command: spec.command, + args: [...spec.args], + ...(response === "non_zero_exit" ? { exitCode: 1 } : {}), + stdout: "", + stderr: response === "non_zero_exit" ? "probe failed" : "", + childProcessFailure: response, + }), + ) + : Effect.succeed({ exitCode: 0, stdout: response, stderr: "" }); + }, + }), + ), + ); + return { result, specs, manifests }; +} + async function pathExists(filePath: string) { try { await access(filePath); @@ -142,24 +184,20 @@ describe("Composer package-manager commands", () => { "12.0.2", ...invalidVersions, ]) { - const result = applicationRuntime.runPromise( - verifyPackageManagerEffect("npm").pipe( - Effect.provideService(CommandRunner, { - run: () => Effect.die("Unexpected unchecked command"), - runChecked: (spec) => { - expect(spec.command).toBe("npm"); - expect(spec.args).toEqual(["--version"]); - return Effect.succeed({ exitCode: 0, stdout: `${version}\n`, stderr: "" }); - }, - }), - ), - ); + const { result } = verifyPackageManager("npm", (spec) => { + expect(spec.command).toBe("npm"); + expect(spec.args).toEqual(["--version"]); + return `${version}\n`; + }); if (invalidVersions.includes(version)) { await expect(result).rejects.toMatchObject({ + stage: "validate_input", + reason: "package_manager_check_failed", message: `Could not determine the installed npm version: ${version}`, }); } else if (["10.9.7", "11.5.1", "11.5.2"].includes(version)) { await expect(result).rejects.toMatchObject({ + stage: "validate_input", reason: "unsupported_package_manager_version", message: expect.stringContaining("npm install --global npm@11"), }); @@ -169,17 +207,115 @@ describe("Composer package-manager commands", () => { } }); - test("does not require npm when another package manager is selected", async () => { - for (const manager of ["bun", "pnpm", "yarn", "deno"] as const) { - await applicationRuntime.runPromise( - verifyPackageManagerEffect(manager).pipe( - Effect.provideService(CommandRunner, { - run: () => Effect.die("npm must not run"), - runChecked: () => Effect.die("npm must not run"), + test("probes every selected package manager once, inside the generated project's manifest", async () => { + for (const [manager, args, stdout, manifestValue] of [ + ["npm", ["--version"], "11.6.0\n", "npm@11.6.0"], + ["pnpm", ["--version"], "11.0.0-rc.1\n", "pnpm@11.21.0"], + ["yarn", ["--version"], "4.13.0\n", "yarn@4.13.0"], + ["yarn", ["--version"], "2.0.0\n", "yarn@4.13.0"], + ["bun", ["--version"], "1.4.1\n", "bun@1.4.1"], + ["deno", ["-V"], "deno 2.9.4\n", undefined], + ["deno", ["-V"], "deno 2.9.4+a1b2c3d\n", undefined], + ] as const) { + const { result, specs, manifests } = verifyPackageManager(manager, () => stdout); + await expect(result).resolves.toBeUndefined(); + expect(specs).toHaveLength(1); + expect(specs[0]).toMatchObject({ command: manager, args: [...args] }); + // Corepack's yarn is Yarn 1 outside a project, and pnpm and Corepack refuse to run where + // "packageManager" names another tool, so the working directory must never be probed. + expect(specs[0]!.cwd).not.toBe(process.cwd()); + expect(manifests[0]?.packageManager).toBe(manifestValue); + expect("packageManager" in manifests[0]!).toBe(manifestValue !== undefined); + expect(getPackageManagerManifestValue(manager)).toBe(manifestValue); + expect(existsSync(specs[0]!.cwd)).toBe(false); + } + }); + + test("rejects a package manager that is not installed", async () => { + for (const manager of packageManagers) { + const { result, specs } = verifyPackageManager(manager, () => "command_not_found"); + await expect(result).rejects.toMatchObject({ + stage: "validate_input", + reason: "package_manager_not_found", + message: expect.stringContaining("choose another package manager with --package-manager"), + cause: { childProcessFailure: "command_not_found" }, + }); + expect(specs).toHaveLength(1); + expect(existsSync(specs[0]!.cwd)).toBe(false); + } + }); + + test("rejects Yarn 1, which refuses the generated project's Yarn 4 manifest", async () => { + const { result, specs, manifests } = verifyPackageManager("yarn", () => "1.22.22\n"); + await expect(result).rejects.toMatchObject({ + stage: "validate_input", + reason: "unsupported_package_manager_version", + message: expect.stringMatching(/^Yarn 1\.22\.22 is unsupported\..*Corepack.*Yarn 4/), + }); + expect(specs).toHaveLength(1); + expect(manifests[0]?.packageManager).toBe("yarn@4.13.0"); + expect(existsSync(specs[0]!.cwd)).toBe(false); + }); + + test("rejects version output it cannot read", async () => { + for (const [manager, stdout] of [ + ["pnpm", "v11.21.0"], + ["yarn", "4.13"], + ["bun", "1.4.1 (34cbb9a4)"], + ["deno", "deno 2.9.4 (stable, release, aarch64-apple-darwin)\nv8 15.0.245.2-rusty"], + ["deno", "bun 1.4.1"], + ] as const) { + await expect(verifyPackageManager(manager, () => stdout).result).rejects.toMatchObject({ + stage: "validate_input", + reason: "package_manager_check_failed", + message: expect.stringContaining(`version: ${stdout}`), + }); + } + }); + + test("reports a failed probe before installation, keeping its diagnostics", async () => { + for (const manager of packageManagers) { + const { result, specs } = verifyPackageManager(manager, () => "non_zero_exit"); + await expect(result).rejects.toMatchObject({ + stage: "validate_input", + reason: "package_manager_check_failed", + message: expect.stringContaining("probe failed"), + cause: { childProcessFailure: "non_zero_exit", exitCode: 1 }, + }); + expect(specs).toHaveLength(1); + expect(existsSync(specs[0]!.cwd)).toBe(false); + } + }); + + test("reports a probe directory it cannot create instead of skipping the check", async () => { + const result = applicationRuntime.runPromise( + verifyPackageManagerEffect("pnpm").pipe( + Effect.provideService(CommandRunner, { + run: () => Effect.die("Unexpected unchecked command"), + runChecked: () => Effect.die("The probe must not run without its directory"), + }), + Effect.provide( + FileSystem.layerNoop({ + makeTempDirectoryScoped: () => + Effect.fail( + new PlatformError.PlatformError( + new PlatformError.SystemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "makeTempDirectoryScoped", + description: "read-only temporary directory", + }), + ), + ), }), ), - ); - } + ), + ); + await expect(result).rejects.toMatchObject({ + stage: "validate_input", + reason: "package_manager_check_failed", + message: expect.stringContaining("Could not prepare a directory to check pnpm"), + }); }); test("uses each selected package manager for Prisma CLI execution", () => { diff --git a/tests/setup-prisma.test.ts b/tests/setup-prisma.test.ts index 0c2bf2a..061a0d4 100644 --- a/tests/setup-prisma.test.ts +++ b/tests/setup-prisma.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { Effect, FileSystem } from "effect"; +import { existsSync } from "node:fs"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -40,6 +41,9 @@ test("writes pnpm build permissions before the first dependency installation", a runChecked: (spec) => Effect.gen(function* () { expect(spec.command).toBe("pnpm"); + if (spec.args[0] === "--version") { + return { exitCode: 0, stdout: "11.21.0\n", stderr: "" }; + } expect(spec.args).toEqual(["install"]); const fs = yield* FileSystem.FileSystem; const config = yield* fs @@ -71,6 +75,40 @@ test("writes pnpm build permissions before the first dependency installation", a }); }); +test("rejects a missing package manager before writing project files", async () => { + await withTempProject(async (projectDir) => { + const targetDirectory = path.join(projectDir, "app"); + const result = await applicationRuntime.runPromise( + runCreateCommandEffect({ + name: path.relative(process.cwd(), targetDirectory), + template: "minimal", + packageManager: "bun", + json: true, + deploy: false, + }).pipe( + Effect.provideService(CommandRunner, { + run: () => Effect.die("Unexpected unchecked command"), + runChecked: (spec) => + Effect.fail( + new CommandExecutionError({ + command: spec.command, + args: [...spec.args], + stdout: "", + stderr: "", + childProcessFailure: "command_not_found", + }), + ), + }), + ), + ); + expect(result).toMatchObject({ + ok: false, + error: { stage: "validate_input", message: expect.stringContaining("--package-manager") }, + }); + expect(existsSync(targetDirectory)).toBe(false); + }); +}); + describe("Prisma setup commands", () => { test.each([ { source: "stdout", stdout: "DATABASE_URL=postgres://user:secret@localhost/db", stderr: "" }, diff --git a/tests/telemetry.test.ts b/tests/telemetry.test.ts index 6cba873..0f3bce2 100644 --- a/tests/telemetry.test.ts +++ b/tests/telemetry.test.ts @@ -1,11 +1,17 @@ import { beforeEach, describe, expect, mock, test } from "bun:test"; import { Effect } from "effect"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; import type { CreatePromptContext } from "../src/commands/create"; import type { CreateCommandInput } from "../src/types"; import { CommandExecutionError, CommandRunner } from "../src/services/command-runner"; import { runPrismaJsonCommandEffect } from "../src/tasks/prisma-cli"; -import { getChildProcessFailure } from "../src/utils/child-process-failure"; +import { + getChildProcessFailure, + getSpawnedCommandFailure, +} from "../src/utils/child-process-failure"; const trackCliTelemetry = mock(async () => {}); @@ -94,6 +100,7 @@ describe("create telemetry", () => { "target_has_migrations", "workspace_missing", "unsupported_package_manager_version", + "package_manager_not_found", ] as const) { await trackCreateFailed({ input: createInput, @@ -104,16 +111,26 @@ describe("create telemetry", () => { }); } const calls = trackCliTelemetry.mock.calls as Array<[string, Record]>; - expect(calls).toHaveLength(4); + expect(calls).toHaveLength(5); expect(calls.map(([, properties]) => properties["failure-reason"])).toEqual([ "target_directory_not_empty", "target_has_migrations", "workspace_missing", "unsupported_package_manager_version", + "package_manager_not_found", ]); for (const [, properties] of calls) { expect(properties["failure-class"]).toBe("expected_rejection"); } + + await trackCreateFailed({ + input: createInput, + context: createContext, + durationMs: 10, + stage: "validate_input", + reason: "package_manager_check_failed", + }); + expect(calls.at(-1)?.[1]["failure-class"]).toBe("technical_failure"); }); test("tracks stable Prisma CLI failure fields without raw output", async () => { @@ -181,6 +198,40 @@ describe("create telemetry", () => { } }); + test("recognizes a missing Windows command that cmd.exe reports as a failed exit", async () => { + const binDirectory = await mkdtemp(path.join(tmpdir(), "create-prisma-bin-")); + try { + await writeFile(path.join(binDirectory, "pnpm.CMD"), ""); + const failure = { name: "ExecaError", failed: true, exitCode: 1 }; + const spawned = { + cwd: tmpdir(), + env: { + Path: `"${path.join(binDirectory, "missing")}";${binDirectory}`, + PATHEXT: ".EXE;.CMD", + }, + }; + for (const [command, platform, expectedFailure] of [ + ["pnpm", "win32", "non_zero_exit"], + [path.join(binDirectory, "pnpm"), "win32", "non_zero_exit"], + ["yarn", "win32", "command_not_found"], + [path.join(binDirectory, "yarn"), "win32", "command_not_found"], + ["yarn", "linux", "non_zero_exit"], + ] as const) { + expect(getSpawnedCommandFailure(failure, { ...spawned, command, platform })).toBe( + expectedFailure, + ); + } + expect( + getSpawnedCommandFailure( + { ...failure, timedOut: true }, + { ...spawned, command: "yarn", platform: "win32" }, + ), + ).toBe("timed_out"); + } finally { + await rm(binDirectory, { recursive: true, force: true }); + } + }); + test("preserves real process failures through checked and Prisma JSON commands", async () => { const cases = [ { command: "create-prisma-nonexistent-test-command", args: [], failure: "command_not_found" }, From 8cafe84dcf6c6b665c258dfd4d396c598455b12f Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Mon, 21 Sep 2026 16:34:02 +0530 Subject: [PATCH 2/3] chore: trim package manager check comments, docs, and tests Co-Authored-By: Claude Fable 5.1 --- README.md | 10 +- src/utils/child-process-failure.ts | 6 +- src/utils/package-manager.ts | 10 +- tests/install.test.ts | 148 +++++++---------------------- tests/telemetry.test.ts | 39 ++------ 5 files changed, 45 insertions(+), 168 deletions(-) diff --git a/README.md b/README.md index 563103c..759dd97 100644 --- a/README.md +++ b/README.md @@ -21,14 +21,8 @@ does not include this runtime itself. MongoDB and Deno-only apps do not need it. For npm, use 11.6.0 or newer, which includes the [upstream resolver fix](https://github.com/npm/cli/pull/8448). Older npm releases can crash resolving the dependency tree (`Cannot read properties of null (reading 'edgesOut')`). Run -`npm install --global npm@11` to update. - -For Yarn, use Yarn 2 or newer, through [Corepack](https://yarnpkg.com/corepack) or a Yarn 4 install. Generated -projects pin Yarn 4 in `"packageManager"`, which a global Yarn 1 refuses to install. - -create-prisma runs the selected package manager's version command before writing project files. It stops with -instructions if the package manager is not installed or is too old, and never installs or upgrades a package -manager automatically. +`npm install --global npm@11` to update. create-prisma checks the selected package manager +before writing project files and never upgrades your package manager automatically. Yarn 1 is not supported. The deployment prompt is: diff --git a/src/utils/child-process-failure.ts b/src/utils/child-process-failure.ts index 9e18b73..bf44d7d 100644 --- a/src/utils/child-process-failure.ts +++ b/src/utils/child-process-failure.ts @@ -56,10 +56,8 @@ function isFile(filePath: string): boolean { } } -// execa runs every Windows command that is not a .exe or .com through `cmd.exe /d /s /c`, so a -// missing pnpm, yarn, or bun never raises ENOENT: cmd.exe starts, fails, and exits non-zero. Its -// "is not recognized" text is localized, so look the command up the way the spawn layer does -// (cross-spawn resolves it with `which`: the working directory, then PATH, each with PATHEXT). +// execa runs non-.exe Windows commands through cmd.exe, so a missing one exits non-zero instead +// of raising ENOENT. function isCommandOnWindowsPath({ command, cwd, env }: SpawnedCommand): boolean { const environment = { ...process.env, ...env }; const pathKey = Object.keys(environment) diff --git a/src/utils/package-manager.ts b/src/utils/package-manager.ts index 0f4a413..e2629b1 100644 --- a/src/utils/package-manager.ts +++ b/src/utils/package-manager.ts @@ -32,7 +32,6 @@ const packageManagerManifestValues = { type PackageManagerVersion = readonly [major: number, minor: number, patch: number]; -// A minimum is listed only where an older release is known to break a generated project. const packageManagerChecks: Record< PackageManager, { @@ -75,7 +74,7 @@ const packageManagerChecks: Record< }, deno: { name: "Deno", - // `deno --version` also prints the V8 and TypeScript versions; `-V` prints only "deno 2.9.4". + // `deno -V` prints one line; `deno --version` adds the V8 and TypeScript versions. versionArgs: ["-V"], install: "Install it from https://docs.deno.com/runtime/getting_started/installation", }, @@ -145,11 +144,8 @@ const probePackageManagerEffect = Effect.fn("PackageManager.probe")(function* ( } }); -// The version a package manager reports depends on where it runs. Corepack's yarn is Yarn 1 outside -// a project and the pinned release inside one, and pnpm and Corepack refuse to run in a directory -// whose "packageManager" names another tool. Only the generated project's own manifest decides what -// the install will run, so the probe runs in a temporary directory that holds that manifest value -// and never in the working directory. Deno projects have no value, so their manifest omits it. +// The reported version depends on the directory's "packageManager", so probe in a temporary +// directory carrying the generated project's value. export const verifyPackageManagerEffect = Effect.fn("PackageManager.verify")(function* ( packageManager: PackageManager, ) { diff --git a/tests/install.test.ts b/tests/install.test.ts index 15bd6f9..f8e6007 100644 --- a/tests/install.test.ts +++ b/tests/install.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { Effect, FileSystem, PlatformError } from "effect"; +import { Effect, FileSystem } from "effect"; import { existsSync, readFileSync } from "node:fs"; import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -29,7 +29,6 @@ import { getInstallArgs, getLocalPackageBinaryArgs, getPackageExecutionArgs, - getPackageManagerManifestValue, getRunScriptCommand, verifyPackageManagerEffect, } from "../src/utils/package-manager"; @@ -60,34 +59,36 @@ async function readPackageJson(projectDir: string): Promise { return JSON.parse(await readFile(path.join(projectDir, "package.json"), "utf8")) as PackageJson; } -function verifyPackageManager(manager: PackageManager, respond: (spec: CommandSpec) => string) { - const specs: CommandSpec[] = []; - const manifests: PackageJson[] = []; +function verifyPackageManager( + manager: PackageManager, + stdout: string | undefined, + fileSystem?: Partial, +) { + const probes: Array<{ spec: CommandSpec; manifest: PackageJson }> = []; const result = applicationRuntime.runPromise( verifyPackageManagerEffect(manager).pipe( Effect.provideService(CommandRunner, { run: () => Effect.die("Unexpected unchecked command"), runChecked: (spec) => { - specs.push(spec); - manifests.push(JSON.parse(readFileSync(path.join(spec.cwd, "package.json"), "utf8"))); - const response = respond(spec); - return response === "command_not_found" || response === "non_zero_exit" + const manifest = JSON.parse(readFileSync(path.join(spec.cwd, "package.json"), "utf8")); + probes.push({ spec, manifest }); + return stdout === undefined ? Effect.fail( new CommandExecutionError({ command: spec.command, args: [...spec.args], - ...(response === "non_zero_exit" ? { exitCode: 1 } : {}), stdout: "", - stderr: response === "non_zero_exit" ? "probe failed" : "", - childProcessFailure: response, + stderr: "", + childProcessFailure: "command_not_found", }), ) - : Effect.succeed({ exitCode: 0, stdout: response, stderr: "" }); + : Effect.succeed({ exitCode: 0, stdout, stderr: "" }); }, }), + fileSystem ? Effect.provide(FileSystem.layerNoop(fileSystem)) : (effect) => effect, ), ); - return { result, specs, manifests }; + return { result, probes }; } async function pathExists(filePath: string) { @@ -184,20 +185,14 @@ describe("Composer package-manager commands", () => { "12.0.2", ...invalidVersions, ]) { - const { result } = verifyPackageManager("npm", (spec) => { - expect(spec.command).toBe("npm"); - expect(spec.args).toEqual(["--version"]); - return `${version}\n`; - }); + const { result } = verifyPackageManager("npm", `${version}\n`); if (invalidVersions.includes(version)) { await expect(result).rejects.toMatchObject({ - stage: "validate_input", reason: "package_manager_check_failed", message: `Could not determine the installed npm version: ${version}`, }); } else if (["10.9.7", "11.5.1", "11.5.2"].includes(version)) { await expect(result).rejects.toMatchObject({ - stage: "validate_input", reason: "unsupported_package_manager_version", message: expect.stringContaining("npm install --global npm@11"), }); @@ -207,117 +202,38 @@ describe("Composer package-manager commands", () => { } }); - test("probes every selected package manager once, inside the generated project's manifest", async () => { - for (const [manager, args, stdout, manifestValue] of [ + test("probes the selected package manager once in a generated project manifest", async () => { + for (const [manager, args, stdout, packageManager] of [ ["npm", ["--version"], "11.6.0\n", "npm@11.6.0"], ["pnpm", ["--version"], "11.0.0-rc.1\n", "pnpm@11.21.0"], ["yarn", ["--version"], "4.13.0\n", "yarn@4.13.0"], - ["yarn", ["--version"], "2.0.0\n", "yarn@4.13.0"], ["bun", ["--version"], "1.4.1\n", "bun@1.4.1"], ["deno", ["-V"], "deno 2.9.4\n", undefined], - ["deno", ["-V"], "deno 2.9.4+a1b2c3d\n", undefined], ] as const) { - const { result, specs, manifests } = verifyPackageManager(manager, () => stdout); + const { result, probes } = verifyPackageManager(manager, stdout); await expect(result).resolves.toBeUndefined(); - expect(specs).toHaveLength(1); - expect(specs[0]).toMatchObject({ command: manager, args: [...args] }); - // Corepack's yarn is Yarn 1 outside a project, and pnpm and Corepack refuse to run where - // "packageManager" names another tool, so the working directory must never be probed. - expect(specs[0]!.cwd).not.toBe(process.cwd()); - expect(manifests[0]?.packageManager).toBe(manifestValue); - expect("packageManager" in manifests[0]!).toBe(manifestValue !== undefined); - expect(getPackageManagerManifestValue(manager)).toBe(manifestValue); - expect(existsSync(specs[0]!.cwd)).toBe(false); + expect(probes).toHaveLength(1); + expect(probes[0]!.spec).toMatchObject({ command: manager, args: [...args] }); + expect(probes[0]!.spec.cwd).not.toBe(process.cwd()); + expect(probes[0]!.manifest.packageManager).toBe(packageManager); + expect(existsSync(probes[0]!.spec.cwd)).toBe(false); } }); - test("rejects a package manager that is not installed", async () => { - for (const manager of packageManagers) { - const { result, specs } = verifyPackageManager(manager, () => "command_not_found"); - await expect(result).rejects.toMatchObject({ - stage: "validate_input", - reason: "package_manager_not_found", - message: expect.stringContaining("choose another package manager with --package-manager"), - cause: { childProcessFailure: "command_not_found" }, - }); - expect(specs).toHaveLength(1); - expect(existsSync(specs[0]!.cwd)).toBe(false); - } - }); - - test("rejects Yarn 1, which refuses the generated project's Yarn 4 manifest", async () => { - const { result, specs, manifests } = verifyPackageManager("yarn", () => "1.22.22\n"); - await expect(result).rejects.toMatchObject({ - stage: "validate_input", - reason: "unsupported_package_manager_version", - message: expect.stringMatching(/^Yarn 1\.22\.22 is unsupported\..*Corepack.*Yarn 4/), - }); - expect(specs).toHaveLength(1); - expect(manifests[0]?.packageManager).toBe("yarn@4.13.0"); - expect(existsSync(specs[0]!.cwd)).toBe(false); - }); - - test("rejects version output it cannot read", async () => { - for (const [manager, stdout] of [ - ["pnpm", "v11.21.0"], - ["yarn", "4.13"], - ["bun", "1.4.1 (34cbb9a4)"], - ["deno", "deno 2.9.4 (stable, release, aarch64-apple-darwin)\nv8 15.0.245.2-rusty"], - ["deno", "bun 1.4.1"], + test("rejects a missing package manager, Yarn 1, and a probe it cannot prepare", async () => { + const unwritable = { makeTempDirectoryScoped: () => Effect.succeed("unwritable") }; + for (const [stdout, fileSystem, reason] of [ + [undefined, undefined, "package_manager_not_found"], + ["1.22.22\n", undefined, "unsupported_package_manager_version"], + ["4.13.0\n", unwritable, "package_manager_check_failed"], ] as const) { - await expect(verifyPackageManager(manager, () => stdout).result).rejects.toMatchObject({ + await expect(verifyPackageManager("yarn", stdout, fileSystem).result).rejects.toMatchObject({ stage: "validate_input", - reason: "package_manager_check_failed", - message: expect.stringContaining(`version: ${stdout}`), + reason, }); } }); - test("reports a failed probe before installation, keeping its diagnostics", async () => { - for (const manager of packageManagers) { - const { result, specs } = verifyPackageManager(manager, () => "non_zero_exit"); - await expect(result).rejects.toMatchObject({ - stage: "validate_input", - reason: "package_manager_check_failed", - message: expect.stringContaining("probe failed"), - cause: { childProcessFailure: "non_zero_exit", exitCode: 1 }, - }); - expect(specs).toHaveLength(1); - expect(existsSync(specs[0]!.cwd)).toBe(false); - } - }); - - test("reports a probe directory it cannot create instead of skipping the check", async () => { - const result = applicationRuntime.runPromise( - verifyPackageManagerEffect("pnpm").pipe( - Effect.provideService(CommandRunner, { - run: () => Effect.die("Unexpected unchecked command"), - runChecked: () => Effect.die("The probe must not run without its directory"), - }), - Effect.provide( - FileSystem.layerNoop({ - makeTempDirectoryScoped: () => - Effect.fail( - new PlatformError.PlatformError( - new PlatformError.SystemError({ - _tag: "PermissionDenied", - module: "FileSystem", - method: "makeTempDirectoryScoped", - description: "read-only temporary directory", - }), - ), - ), - }), - ), - ), - ); - await expect(result).rejects.toMatchObject({ - stage: "validate_input", - reason: "package_manager_check_failed", - message: expect.stringContaining("Could not prepare a directory to check pnpm"), - }); - }); - test("uses each selected package manager for Prisma CLI execution", () => { for (const packageManager of ["npm", "pnpm", "yarn", "bun"] as const) { expect(getComposerScriptMap(packageManager)["composer:deploy"]).toBe( diff --git a/tests/telemetry.test.ts b/tests/telemetry.test.ts index 0f3bce2..b2648bf 100644 --- a/tests/telemetry.test.ts +++ b/tests/telemetry.test.ts @@ -122,15 +122,6 @@ describe("create telemetry", () => { for (const [, properties] of calls) { expect(properties["failure-class"]).toBe("expected_rejection"); } - - await trackCreateFailed({ - input: createInput, - context: createContext, - durationMs: 10, - stage: "validate_input", - reason: "package_manager_check_failed", - }); - expect(calls.at(-1)?.[1]["failure-class"]).toBe("technical_failure"); }); test("tracks stable Prisma CLI failure fields without raw output", async () => { @@ -202,31 +193,13 @@ describe("create telemetry", () => { const binDirectory = await mkdtemp(path.join(tmpdir(), "create-prisma-bin-")); try { await writeFile(path.join(binDirectory, "pnpm.CMD"), ""); - const failure = { name: "ExecaError", failed: true, exitCode: 1 }; - const spawned = { - cwd: tmpdir(), - env: { - Path: `"${path.join(binDirectory, "missing")}";${binDirectory}`, - PATHEXT: ".EXE;.CMD", - }, - }; - for (const [command, platform, expectedFailure] of [ - ["pnpm", "win32", "non_zero_exit"], - [path.join(binDirectory, "pnpm"), "win32", "non_zero_exit"], - ["yarn", "win32", "command_not_found"], - [path.join(binDirectory, "yarn"), "win32", "command_not_found"], - ["yarn", "linux", "non_zero_exit"], - ] as const) { - expect(getSpawnedCommandFailure(failure, { ...spawned, command, platform })).toBe( - expectedFailure, - ); - } - expect( + const classify = (command: string) => getSpawnedCommandFailure( - { ...failure, timedOut: true }, - { ...spawned, command: "yarn", platform: "win32" }, - ), - ).toBe("timed_out"); + { name: "ExecaError", failed: true, exitCode: 1 }, + { command, cwd: tmpdir(), env: { Path: binDirectory }, platform: "win32" }, + ); + expect(classify("yarn")).toBe("command_not_found"); + expect(classify("pnpm")).toBe("non_zero_exit"); } finally { await rm(binDirectory, { recursive: true, force: true }); } From 38d40af100554ebde2d2426d42b42eb68d561628 Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Wed, 23 Sep 2026 01:25:10 +0530 Subject: [PATCH 3/3] docs: remove misleading Yarn probe comment --- src/utils/package-manager.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/utils/package-manager.ts b/src/utils/package-manager.ts index e2629b1..7935877 100644 --- a/src/utils/package-manager.ts +++ b/src/utils/package-manager.ts @@ -61,7 +61,6 @@ const packageManagerChecks: Record< name: "Yarn", versionArgs: ["--version"], install: "Install it with Corepack (https://yarnpkg.com/corepack)", - // Yarn 1 exits with an error in a project whose "packageManager" names a newer Yarn. minimum: { version: [2, 0, 0], guidance: `Generated projects use ${packageManagerManifestValues.yarn}, which Yarn 1 refuses to install. Enable Corepack (https://yarnpkg.com/corepack) or install Yarn 4, then retry create-prisma, or choose another package manager with --package-manager.`,