diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index e1ac208..8c6303a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -104,12 +104,6 @@ jobs: echo "dist_tag=$DIST_TAG" >> "$GITHUB_OUTPUT" echo "publish_next=$PUBLISH_NEXT" >> "$GITHUB_OUTPUT" - - name: Ensure npm version for trusted publishing - run: | - npm install -g npm@11.6.0 - echo "Node: $(node --version)" - echo "npm: $(npm --version)" - - name: Setup Bun uses: oven-sh/setup-bun@v2 with: @@ -148,9 +142,15 @@ jobs: CREATE_PRISMA_TELEMETRY_API_KEY: ${{ steps.preview.outputs.publish_next == 'true' && secrets.CREATE_PRISMA_TELEMETRY_API_KEY || '' }} CREATE_PRISMA_TELEMETRY_HOST: ${{ steps.preview.outputs.publish_next == 'true' && vars.CREATE_PRISMA_TELEMETRY_HOST || '' }} - - name: Test packed CLI on the minimum Node version + - name: Test packed CLI on the minimum Node version and its bundled npm run: node scripts/smoke-node.mjs + - name: Ensure npm version for trusted publishing + run: | + npm install -g npm@11.6.0 + echo "Node: $(node --version)" + echo "npm: $(npm --version)" + - name: Publish to npm run: npm publish --access public --tag "${{ steps.preview.outputs.dist_tag }}" @@ -288,13 +288,6 @@ jobs: echo "exists=false" >> "$GITHUB_OUTPUT" fi - - name: Ensure npm version for trusted publishing - if: steps.check-version.outputs.exists == 'false' - run: | - npm install -g npm@11.6.0 - echo "Node: $(node --version)" - echo "npm: $(npm --version)" - - name: Setup Bun if: steps.check-version.outputs.exists == 'false' uses: oven-sh/setup-bun@v2 @@ -325,10 +318,17 @@ jobs: if: steps.check-version.outputs.exists == 'false' run: bun run build - - name: Test packed CLI on the minimum Node version + - name: Test packed CLI on the minimum Node version and its bundled npm if: steps.check-version.outputs.exists == 'false' run: node scripts/smoke-node.mjs + - name: Ensure npm version for trusted publishing + if: steps.check-version.outputs.exists == 'false' + run: | + npm install -g npm@11.6.0 + echo "Node: $(node --version)" + echo "npm: $(npm --version)" + - name: Publish to npm if: steps.check-version.outputs.exists == 'false' run: npm publish --access public diff --git a/README.md b/README.md index 39f9afd..f768ee7 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,13 @@ Create a Prisma 8 app with Prisma Composer built in. -Requires Node.js 22.18 or newer, matching the Prisma CLI. -Generated apps must also meet their selected framework's Node.js requirements. +## Supported versions + +create-prisma supports exactly what Prisma ORM supports: Node.js 22.18 or newer on the 22 line, 24.11 or newer on the 24 line, or 26 and newer (`^22.18.0 || ^24.11.0 || >=26.0.0`). Node.js 23 and 24.0 to 24.10 are not supported. create-prisma refuses unsupported Node.js versions before it writes any files, and the generated minimal, hono, elysia, nest and turborepo projects declare the same range in `engines.node`. + +Each supported Node.js release works with the npm it ships, including npm 10 on Node.js 22. pnpm, Yarn 4.10 or newer, Bun and Deno are also supported. Yarn 1 is not supported. When create-prisma runs on Bun or Deno instead of Node.js, it skips the Node.js check. + +The one exception is the `nuxt` template. Nuxt itself requires Node.js `^22.22.3 || ^24.15.0 || >=26.0.0`, so Nuxt projects declare that narrower range. ## Quick start @@ -22,10 +27,7 @@ PostgreSQL apps with Composer include `@prisma/dev` as a project devDependency f local Postgres. In a monorepo it belongs at the root, where Composer runs. Prisma 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 package manager -before writing project files and never upgrades your package manager automatically. Yarn 1 is not supported. +create-prisma checks the selected package manager before writing project files and never upgrades your package manager automatically. The deployment prompt is: diff --git a/package.json b/package.json index eccb45b..93f8b9a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "create-prisma", - "version": "0.14.1", + "version": "0.14.2", "private": false, "description": "Create Prisma 8 projects with first-party templates and great DX.", "homepage": "https://github.com/prisma/create-prisma", @@ -70,7 +70,7 @@ }, "engines": { "bun": ">=1.3.0", - "node": ">=22.18.0" + "node": "^22.18.0 || ^24.11.0 || >=26.0.0" }, "packageManager": "bun@1.4.1" } diff --git a/scripts/smoke-node.mjs b/scripts/smoke-node.mjs index 05a0171..80822d2 100644 --- a/scripts/smoke-node.mjs +++ b/scripts/smoke-node.mjs @@ -1,5 +1,5 @@ import { execa } from "execa"; -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -59,11 +59,19 @@ try { { cwd: directory, env }, ); if (!JSON.parse(created.stdout).ok) throw new Error("Packed CLI did not scaffold successfully"); - const options = { cwd: path.join(directory, "app"), env, stdout: "inherit", stderr: "inherit" }; + const appDirectory = path.join(directory, "app"); + const { stdout: npmVersion } = await execa("npm", ["--version"], { cwd: appDirectory }); + const { packageManager } = JSON.parse( + await readFile(path.join(appDirectory, "package.json"), "utf8"), + ); + if (packageManager !== `npm@${npmVersion}`) { + throw new Error(`Generated packageManager is ${packageManager}, expected npm@${npmVersion}`); + } + const options = { cwd: appDirectory, env, stdout: "inherit", stderr: "inherit" }; await execa("npm", ["run", "build"], options); await execa("npm", ["exec", "--", "tsc", "--noEmit"], options); console.log( - `Packed CLI engine check, scaffold, build and typecheck passed on Node ${process.versions.node}.`, + `Packed CLI engine check, scaffold, npm install, build and typecheck passed on Node ${process.versions.node} with npm ${npmVersion}.`, ); } finally { await rm(directory, { recursive: true, force: true }); diff --git a/src/commands/create.ts b/src/commands/create.ts index ce36ec9..6a6465a 100644 --- a/src/commands/create.ts +++ b/src/commands/create.ts @@ -23,8 +23,8 @@ import { decodeCreateCommandInput, type CreateCommandInput } from "../types"; import { getCreatePrismaIntro } from "../ui/branding"; import { resolveExecutionSettings } from "../ui/output"; import { getErrorMessage } from "../utils/errors"; -import { getUnsupportedNodeMessage, supportsPrisma } from "../utils/node-version"; -import { verifyPackageManagerEffect } from "../utils/package-manager"; +import { getUnsupportedNodeMessage, runsOnNode, supportsPrisma } from "../utils/node-version"; +import { type PackageManagerVersion, verifyPackageManagerEffect } from "../utils/package-manager"; import { atCreateStage } from "../workflow/failure"; import { collectCreateContext, @@ -75,7 +75,10 @@ const initializeExistingApp = Effect.fn("Create.initExisting")(function* ( } satisfies CreateCommandResult; }); -const executeCreateContext = Effect.fn("Create.execute")(function* (context: CreatePromptContext) { +const executeCreateContext = Effect.fn("Create.execute")(function* ( + context: CreatePromptContext, + packageManagerVersion: PackageManagerVersion, +) { const output = context.prismaSetupContext.output; const createSpinner = context.prismaSetupContext.verbose ? undefined : spinner({ output }); yield* Effect.sync(() => { @@ -93,6 +96,7 @@ const executeCreateContext = Effect.fn("Create.execute")(function* (context: Cre provider: context.prismaSetupContext.databaseProvider, authoring: context.prismaSetupContext.authoring, packageManager: context.prismaSetupContext.packageManager, + packageManagerVersion, skillAgents: context.prismaSetupContext.skillAgents, }).pipe( Effect.andThen( @@ -180,7 +184,7 @@ const createProjectEffect = Effect.fn("Create.project")(function* ( message: "--verbose cannot be used with --json because JSON mode is output-only.", }); } - if (!supportsPrisma()) { + if (runsOnNode() && !supportsPrisma()) { const message = getUnsupportedNodeMessage(); yield* Effect.sync(() => cancel(message, { output })); return yield* new CreateFailure({ @@ -198,7 +202,7 @@ const createProjectEffect = Effect.fn("Create.project")(function* ( "unexpected_error", ); yield* Ref.set(contextRef, Option.some(context)); - yield* atCreateStage( + const packageManagerVersion = yield* atCreateStage( context.operation === "orm_init" ? verifyPackageManagerEffect(context.packageManager, context.targetDirectory) : verifyPackageManagerEffect(context.prismaSetupContext.packageManager), @@ -211,7 +215,7 @@ const createProjectEffect = Effect.fn("Create.project")(function* ( result: context.operation === "orm_init" ? yield* initializeExistingApp(context, input) - : yield* executeCreateContext(context), + : yield* executeCreateContext(context, packageManagerVersion), }; }); diff --git a/src/templates/render-create-template.ts b/src/templates/render-create-template.ts index 587c806..2268120 100644 --- a/src/templates/render-create-template.ts +++ b/src/templates/render-create-template.ts @@ -10,6 +10,10 @@ import { type DatabaseProvider, type PackageManager, } from "../types"; +import { + getPackageManagerManifestValue, + type PackageManagerVersion, +} from "../utils/package-manager"; import { renderTemplateTreeEffect, resolveTemplatesDirEffect } from "./shared"; const DEFAULT_PRISMA_SOURCE_DIR = "src/prisma"; @@ -25,6 +29,7 @@ type CreateTemplateContext = { provider: DatabaseProvider; authoring: AuthoringStyle; packageManager?: PackageManager; + packageManagerManifest: string | null; skillAgents: readonly AgentSkillTarget[]; tsdownEntry: string | null; }; @@ -36,6 +41,8 @@ export type ScaffoldCreateTemplateOptions = { provider: DatabaseProvider; authoring: AuthoringStyle; packageManager?: PackageManager; + /** The version create-prisma detected; decides the npm written to `packageManager`. */ + packageManagerVersion?: PackageManagerVersion; /** Agents whose skill files the project gets; defaults to all of them. */ skillAgents?: readonly AgentSkillTarget[]; }; @@ -55,6 +62,8 @@ function createTemplateContext(options: ScaffoldCreateTemplateOptions): CreateTe provider: options.provider, authoring: options.authoring, packageManager: options.packageManager, + packageManagerManifest: + getPackageManagerManifestValue(options.packageManager, options.packageManagerVersion) ?? null, skillAgents: options.skillAgents ?? agentSkillTargets, tsdownEntry: tsdownEntries[options.template] ?? null, }; diff --git a/src/templates/shared.ts b/src/templates/shared.ts index 6829d98..291dd77 100644 --- a/src/templates/shared.ts +++ b/src/templates/shared.ts @@ -5,11 +5,7 @@ import { fileURLToPath } from "node:url"; import { applicationRuntime } from "../runtime"; import type { PackageManager } from "../types"; -import { - getPackageManagerManifestValue, - getRuntimeScriptCommand, - getRunScriptCommand, -} from "../utils/package-manager"; +import { getRuntimeScriptCommand, getRunScriptCommand } from "../utils/package-manager"; Handlebars.registerHelper("eq", (left: unknown, right: unknown) => left === right); Handlebars.registerHelper( @@ -17,11 +13,6 @@ Handlebars.registerHelper( (packageManager: PackageManager | undefined, scriptName: string) => packageManager ? getRunScriptCommand(packageManager, scriptName) : "", ); -Handlebars.registerHelper( - "packageManagerManifestValue", - (packageManager: PackageManager | undefined) => - getPackageManagerManifestValue(packageManager) ?? "", -); Handlebars.registerHelper( "runtimeScript", ( diff --git a/src/utils/node-version.ts b/src/utils/node-version.ts index 9515664..9378354 100644 --- a/src/utils/node-version.ts +++ b/src/utils/node-version.ts @@ -1,23 +1,44 @@ -const MINIMUM_NODE_VERSION = [22, 18, 0] as const; +type Version = readonly [major: number, minor: number, patch: number]; -function parseVersion(version: string): [number, number, number] { +export const SUPPORTED_NODE_RANGE = "^22.18.0 || ^24.11.0 || >=26.0.0"; + +const minimumByMajor: ReadonlyMap = new Map([ + [22, [22, 18, 0]], + [24, [24, 11, 0]], +]); +const FIRST_MAJOR_WITH_EVERY_RELEASE_SUPPORTED = 26; + +export const MINIMUM_NODE_VERSION = minimumByMajor.get(22)!; + +function parseVersion(version: string): Version { const [major = "0", minor = "0", patch = "0"] = version.replace(/^v/, "").split("."); return [Number(major), Number(minor), Number.parseInt(patch, 10)]; } -export function supportsPrisma(nodeVersion = process.versions.node): boolean { - const current = parseVersion(nodeVersion); - for (let index = 0; index < MINIMUM_NODE_VERSION.length; index += 1) { - if (current[index]! > MINIMUM_NODE_VERSION[index]!) return true; - if (current[index]! < MINIMUM_NODE_VERSION[index]!) return false; +function isAtLeast(current: Version, minimum: Version): boolean { + for (const [index, part] of current.entries()) { + if (part !== minimum[index]) return part > minimum[index]!; } return true; } +export function runsOnNode( + versions: Partial> = process.versions, +): boolean { + return versions.bun === undefined && versions.deno === undefined; +} + +export function supportsPrisma(nodeVersion = process.versions.node): boolean { + const current = parseVersion(nodeVersion); + if (current[0] >= FIRST_MAJOR_WITH_EVERY_RELEASE_SUPPORTED) return true; + const minimum = minimumByMajor.get(current[0]); + return minimum !== undefined && isAtLeast(current, minimum); +} + export function getUnsupportedNodeMessage(nodeVersion = process.versions.node): string { return [ `Node.js ${nodeVersion} is unsupported by create-prisma@latest.`, - "Required: Node.js 22.18 or newer.", + "Required: Node.js 22.18 or newer on the 22 line, 24.11 or newer on the 24 line, or 26 and newer.", "Update Node.js and run the command again.", ].join("\n"); } diff --git a/src/utils/package-manager.ts b/src/utils/package-manager.ts index 784b7ce..ac9e3f2 100644 --- a/src/utils/package-manager.ts +++ b/src/utils/package-manager.ts @@ -23,14 +23,27 @@ type RuntimeScriptOptions = { // Scaffolding must be able to resolve the freshly published, explicitly pinned Prisma release. const DENO_ALLOW_FRESH_DEPENDENCIES = "--minimum-dependency-age=0"; +// Revisit with MINIMUM_NODE_VERSION in node-version.ts: the npm that Node.js release ships, +// and the npm majors that run on it and on every later supported release. +export const npmForMinimumNode = { + node: [22, 18, 0], + fallback: "10.9.3", + majors: new Set([10, 11]), +} as const; + const packageManagerManifestValues = { - npm: "npm@12.2.0", + npm: `npm@${npmForMinimumNode.fallback}`, pnpm: "pnpm@11.28.5", yarn: "yarn@4.18.1", bun: "bun@1.4.2", } as const; -type PackageManagerVersion = readonly [major: number, minor: number, patch: number]; +type VersionParts = readonly [major: number, minor: number, patch: number]; + +export type PackageManagerVersion = { + readonly version: string; + readonly parts: VersionParts; +}; const packageManagerChecks: Record< PackageManager, @@ -38,19 +51,13 @@ const packageManagerChecks: Record< name: string; versionArgs: string[]; install: string; - minimum?: { version: PackageManagerVersion; guidance: string }; + minimum?: { version: VersionParts; 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", @@ -92,10 +99,10 @@ function parsePackageManagerVersion( ); if (!match) return undefined; const parts = [Number(match[1]), Number(match[2]), Number(match[3])] as const; - return parts.every(Number.isSafeInteger) ? parts : undefined; + return parts.every(Number.isSafeInteger) ? { version: match[0], parts } : undefined; } -function isOlderVersion(version: PackageManagerVersion, minimum: PackageManagerVersion): boolean { +function isOlderVersion(version: VersionParts, minimum: VersionParts): boolean { for (const [index, part] of version.entries()) { if (part !== minimum[index]) return part < minimum[index]!; } @@ -134,13 +141,14 @@ const probePackageManagerEffect = Effect.fn("PackageManager.probe")(function* ( message: `Could not determine the installed ${name} version: ${output}`, }); } - if (minimum && isOlderVersion(version, minimum.version)) { + if (minimum && isOlderVersion(version.parts, minimum.version)) { return yield* new CreateFailure({ stage: "validate_input", reason: "unsupported_package_manager_version", - message: `${name} ${version.join(".")} is unsupported. Required: ${name} ${minimum.version.join(".")} or newer. ${minimum.guidance}`, + message: `${name} ${version.version} is unsupported. Required: ${name} ${minimum.version.join(".")} or newer. ${minimum.guidance}`, }); } + return version; }); // The reported version depends on the directory's "packageManager", so probe in a temporary @@ -172,7 +180,7 @@ export const verifyPackageManagerEffect = Effect.fn("PackageManager.verify")(fun }), ), ); - yield* probePackageManagerEffect(packageManager, probeDir); + return yield* probePackageManagerEffect(packageManager, probeDir); }, Effect.scoped); function parseUserAgent(userAgent: string | undefined): PackageManager | null { @@ -296,6 +304,7 @@ export function detectPackageManager(projectDir = process.cwd()): Promise { }); describe("Composer package-manager commands", () => { - test("checks the selected npm binary against the supported resolver version", async () => { - const invalidVersions = ["", "11.6.", "11..0", "11.6.1e3", "11.0x6.0", "11.06.0"]; - for (const version of [ - "10.9.7", - "11.5.1", - "11.5.2", - "11.6.0", - "11.6.1", - "11.6.2", - "11.19.0", - "12.0.2", - ...invalidVersions, - ]) { - 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)) { - await expect(result).rejects.toMatchObject({ - reason: "unsupported_package_manager_version", - message: expect.stringContaining("npm install --global npm@11"), - }); - } else { - await expect(result).resolves.toBeUndefined(); - } + test("accepts any npm version and returns it", async () => { + for (const [stdout, version] of [ + ["10.9.3\n", [10, 9, 3]], + ["11.6.0\n", [11, 6, 0]], + ["12.2.0\n", [12, 2, 0]], + ["11.0.0-pre.1\n", [11, 0, 0]], + ["11.0.0-pre.1+build.7\n", [11, 0, 0]], + ] as const) { + await expect(verifyPackageManager("npm", stdout).result).resolves.toEqual({ + version: stdout.trim(), + parts: version, + }); + } + }); + + test("rejects npm output that is not a version", async () => { + for (const output of ["", "11.6.", "11..0", "11.6.1e3", "11.0x6.0", "11.06.0"]) { + await expect(verifyPackageManager("npm", `${output}\n`).result).rejects.toMatchObject({ + reason: "package_manager_check_failed", + message: `Could not determine the installed npm version: ${output}`, + }); } }); test("probes the selected package manager once in a generated project manifest", async () => { - for (const [manager, args, stdout, packageManager] of [ - ["npm", ["--version"], "12.2.0\n", "npm@12.2.0"], - ["pnpm", ["--version"], "11.28.5\n", "pnpm@11.28.5"], - ["yarn", ["--version"], "4.18.1\n", "yarn@4.18.1"], - ["bun", ["--version"], "1.4.2\n", "bun@1.4.2"], - ["deno", ["-V"], "deno 2.9.4\n", undefined], + for (const [manager, args, stdout, packageManager, version] of [ + ["npm", ["--version"], "11.6.0\n", "npm@10.9.3", [11, 6, 0]], + ["pnpm", ["--version"], "11.28.5\n", "pnpm@11.28.5", [11, 28, 5]], + ["yarn", ["--version"], "4.18.1\n", "yarn@4.18.1", [4, 18, 1]], + ["bun", ["--version"], "1.4.2\n", "bun@1.4.2", [1, 4, 2]], + ["deno", ["-V"], "deno 2.9.4\n", undefined, [2, 9, 4]], ] as const) { const { result, probes } = verifyPackageManager(manager, stdout); - await expect(result).resolves.toBeUndefined(); + await expect(result).resolves.toEqual({ version: version.join("."), parts: version }); expect(probes).toHaveLength(1); expect(probes[0]!.spec).toMatchObject({ command: manager, args: [...args] }); expect(probes[0]!.spec.cwd).not.toBe(process.cwd()); @@ -323,6 +317,34 @@ describe("generated templates", () => { } }); + test("declares the detected npm when it runs on every supported Node.js release, else npm 10.9.3", async () => { + for (const [packageManagerVersion, expected] of [ + [{ version: "10.9.7", parts: [10, 9, 7] }, "npm@10.9.7"], + [{ version: "11.6.0", parts: [11, 6, 0] }, "npm@11.6.0"], + [{ version: "11.0.0-pre.1", parts: [11, 0, 0] }, "npm@11.0.0-pre.1"], + [{ version: "11.0.0-pre.1+build.7", parts: [11, 0, 0] }, "npm@11.0.0-pre.1+build.7"], + [{ version: "12.2.0", parts: [12, 2, 0] }, "npm@10.9.3"], + [{ version: "9.9.4", parts: [9, 9, 4] }, "npm@10.9.3"], + [undefined, "npm@10.9.3"], + ] as const) { + const projectDir = await mkdtemp(path.join(tmpdir(), "create-prisma-npm-manifest-")); + try { + await scaffoldCreateTemplate({ + projectDir, + projectName: "npm-app", + template: "turborepo", + provider: "postgres", + authoring: "psl", + packageManager: "npm", + packageManagerVersion, + }); + expect((await readPackageJson(projectDir)).packageManager).toBe(expected); + } finally { + await rm(projectDir, { recursive: true, force: true }); + } + } + }); + test("renders Composer into every supported combination", async () => { for (const template of createTemplates) { for (const provider of databaseProviders) { @@ -357,6 +379,9 @@ describe("generated templates", () => { if (packageManager === "bun") { expect(packageJson.packageManager).toBe("bun@1.4.2"); } + if (packageManager === "npm") { + expect(packageJson.packageManager).toBe("npm@10.9.3"); + } const prismaSourceRelative = template === "turborepo" ? "packages/database/src" : "src/prisma"; const dbSource = await readFile( diff --git a/tests/node-version.test.ts b/tests/node-version.test.ts index 93a1a54..52e5308 100644 --- a/tests/node-version.test.ts +++ b/tests/node-version.test.ts @@ -1,16 +1,60 @@ import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import path from "node:path"; -import { getUnsupportedNodeMessage, supportsPrisma } from "../src/utils/node-version"; +import { + getUnsupportedNodeMessage, + MINIMUM_NODE_VERSION, + runsOnNode, + SUPPORTED_NODE_RANGE, + supportsPrisma, +} from "../src/utils/node-version"; +import { npmForMinimumNode } from "../src/utils/package-manager"; + +const repositoryRoot = path.join(import.meta.dir, ".."); describe("Prisma 8 Node compatibility", () => { - test("requires Node 22.18 or newer", () => { - expect(supportsPrisma("22.17.9")).toBe(false); - expect(supportsPrisma("22.18.0")).toBe(true); - expect(supportsPrisma("22.20.0")).toBe(true); - expect(supportsPrisma("24.0.0")).toBe(true); + test("accepts exactly the Node.js versions Prisma ORM supports", () => { + for (const [version, supported] of [ + ["20.19.0", false], + ["22.17.9", false], + ["v22.17.9", false], + ["22.18.0", true], + ["v22.18.0", true], + ["22.22.3", true], + ["23.0.0", false], + ["23.11.0", false], + ["24.0.0", false], + ["24.10.9", false], + ["24.11.0", true], + ["24.15.0", true], + ["25.9.0", false], + ["26.0.0", true], + ["27.1.0", true], + ] as const) { + expect([version, supportsPrisma(version)]).toEqual([version, supported]); + } + }); + + test("applies the Node.js range only when running on Node.js, not Bun or Deno", () => { + expect(runsOnNode({ node: "22.18.0" })).toBe(true); + expect(runsOnNode({ node: "24.3.0", bun: "1.3.13" })).toBe(false); + expect(runsOnNode({ node: "24.2.0", deno: "2.9.4" })).toBe(false); + }); + + test("names the supported range in the refusal message", () => { + expect(getUnsupportedNodeMessage("24.10.0")).toContain( + "Required: Node.js 22.18 or newer on the 22 line, 24.11 or newer on the 24 line, or 26 and newer.", + ); + }); + + test("declares the supported range in package.json engines", () => { + const packageJson = JSON.parse(readFileSync(path.join(repositoryRoot, "package.json"), "utf8")); + expect(SUPPORTED_NODE_RANGE).toBe("^22.18.0 || ^24.11.0 || >=26.0.0"); + expect(packageJson.engines.node).toBe(SUPPORTED_NODE_RANGE); }); - test("returns an actionable message", () => { - expect(getUnsupportedNodeMessage("20.19.0")).toContain("Required: Node.js 22.18 or newer."); + test("pairs the npm fallback for generated projects with the minimum Node.js version", () => { + expect(npmForMinimumNode.node).toEqual(MINIMUM_NODE_VERSION); }); }); diff --git a/tests/setup-prisma.test.ts b/tests/setup-prisma.test.ts index ccded9d..d604439 100644 --- a/tests/setup-prisma.test.ts +++ b/tests/setup-prisma.test.ts @@ -75,6 +75,47 @@ test("writes pnpm build permissions before the first dependency installation", a }); }); +test("declares the npm version it detected in the generated package.json", async () => { + await withTempProject(async (projectDir) => { + let installChecked = false; + const result = await applicationRuntime.runPromise( + runCreateCommandEffect({ + name: path.relative(process.cwd(), path.join(projectDir, "app")), + template: "minimal", + packageManager: "npm", + json: true, + deploy: false, + }).pipe( + Effect.provideService(CommandRunner, { + run: () => Effect.die("Unexpected unchecked command"), + runChecked: (spec) => + Effect.gen(function* () { + if (spec.args[0] === "--version") { + return { exitCode: 0, stdout: "11.6.2-pre.1\n", stderr: "" }; + } + expect(spec.args).toEqual(["install"]); + const fs = yield* FileSystem.FileSystem; + const packageJson = yield* fs + .readFileString(path.join(spec.cwd, "package.json")) + .pipe(Effect.orDie); + expect(JSON.parse(packageJson).packageManager).toBe("npm@11.6.2-pre.1"); + installChecked = true; + return yield* new CommandExecutionError({ + command: spec.command, + args: [...spec.args], + exitCode: 1, + stdout: "", + stderr: "Stopped before installing test dependencies", + }); + }), + }), + ), + ); + expect(installChecked).toBe(true); + expect(result).toMatchObject({ ok: false, error: { stage: "install_dependencies" } }); + }); +}); + test("rejects a missing package manager before writing project files", async () => { await withTempProject(async (projectDir) => { const targetDirectory = path.join(projectDir, "app");