Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
4 changes: 2 additions & 2 deletions src/commands/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) };
});
Expand Down
2 changes: 2 additions & 0 deletions src/create-outcome.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion src/services/command-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { createInterface } from "node:readline";
import {
ChildProcessFailureSchema,
getChildProcessFailure,
getSpawnedCommandFailure,
type ChildProcessFailure,
} from "../utils/child-process-failure";

Expand Down Expand Up @@ -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) =>
Expand Down
1 change: 1 addition & 0 deletions src/telemetry/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const expectedRejectionReasons = new Set<CreateFailureReason>([
"invalid_input",
"unsupported_node_version",
"unsupported_package_manager_version",
"package_manager_not_found",
"invalid_project_name",
"target_path_not_directory",
"target_directory_not_empty",
Expand Down
47 changes: 47 additions & 0 deletions src/utils/child-process-failure.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { Schema } from "effect";
import { statSync } from "node:fs";
import path from "node:path";

export const ChildProcessFailureSchema = Schema.Literals([
"cancelled",
Expand Down Expand Up @@ -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;
}
152 changes: 133 additions & 19 deletions src/utils/package-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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(
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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";
Expand Down
5 changes: 4 additions & 1 deletion tests/e2e/create-prisma.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading