diff --git a/README.md b/README.md index 077d2d7..759dd97 100644 --- a/README.md +++ b/README.md @@ -21,8 +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. 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. 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/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 53773da..8535799 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..bf44d7d 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,48 @@ 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 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) + .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..7935877 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,149 @@ 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]; + +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)", + 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 -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", + }, +}; + +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 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, +) { + 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..f8e6007 100644 --- a/tests/install.test.ts +++ b/tests/install.test.ts @@ -1,19 +1,30 @@ import { describe, expect, test } from "bun:test"; -import { Effect } 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"; 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, @@ -48,6 +59,38 @@ async function readPackageJson(projectDir: string): Promise { return JSON.parse(await readFile(path.join(projectDir, "package.json"), "utf8")) as 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) => { + 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], + stdout: "", + stderr: "", + childProcessFailure: "command_not_found", + }), + ) + : Effect.succeed({ exitCode: 0, stdout, stderr: "" }); + }, + }), + fileSystem ? Effect.provide(FileSystem.layerNoop(fileSystem)) : (effect) => effect, + ), + ); + return { result, probes }; +} + async function pathExists(filePath: string) { try { await access(filePath); @@ -142,20 +185,10 @@ 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", `${version}\n`); if (invalidVersions.includes(version)) { await expect(result).rejects.toMatchObject({ + 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)) { @@ -169,16 +202,35 @@ 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 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"], + ["bun", ["--version"], "1.4.1\n", "bun@1.4.1"], + ["deno", ["-V"], "deno 2.9.4\n", undefined], + ] as const) { + const { result, probes } = verifyPackageManager(manager, stdout); + await expect(result).resolves.toBeUndefined(); + 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 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("yarn", stdout, fileSystem).result).rejects.toMatchObject({ + stage: "validate_input", + reason, + }); } }); 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 9f0f598..341677b 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,12 +111,13 @@ 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"); @@ -181,6 +189,22 @@ 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 classify = (command: string) => + getSpawnedCommandFailure( + { 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 }); + } + }); + 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" },