From 0f6967cfddb6f370d5a6af7461131b00335d349a Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Mon, 21 Sep 2026 16:03:00 +0530 Subject: [PATCH 1/5] feat: report package manager and Composer failure codes in telemetry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Install failures carried only an exit code, and deploys whose delegated process failed arrived as the generic CLI.CHILD_PROCESS_FAILED. - Add `package-manager-error-code`: the manager's own identifier read from captured install output (npm `E…`, pnpm `ERR_PNPM_…`/`E…`, Yarn Berry `YN0035`-style). Only values matching the manager's identifier grammar are emitted; Bun, Deno and Yarn Classic print prose, so they report null. - Pass `--report ` to `prisma deploy` using a scoped temporary directory, decode the run report, and carry its `failure.code` on PrismaCliCommandError as `causeCode`. Send it as `prisma-cli-cause-code` when it matches the structured-code grammar. A missing or invalid report leaves the original error untouched. Messages, paths, package names and command output are never sent. Co-Authored-By: Claude Fable 5.1 --- README.md | 2 +- package.json | 2 +- src/create-outcome.ts | 10 + src/tasks/composer/deploy-report.ts | 76 +++++++ src/tasks/deploy-with-composer.ts | 6 +- src/telemetry/create.ts | 20 +- src/utils/package-manager-error-code.ts | 62 ++++++ tests/deploy-with-composer.test.ts | 150 +++++++++++++ tests/fixtures/composer-deploy.ts | 72 +++++++ tests/fixtures/package-manager-output.ts | 257 +++++++++++++++++++++++ tests/package-manager-error-code.test.ts | 114 ++++++++++ tests/telemetry.test.ts | 111 ++++++++++ 12 files changed, 876 insertions(+), 6 deletions(-) create mode 100644 src/tasks/composer/deploy-report.ts create mode 100644 src/utils/package-manager-error-code.ts create mode 100644 tests/fixtures/composer-deploy.ts create mode 100644 tests/fixtures/package-manager-output.ts create mode 100644 tests/package-manager-error-code.test.ts diff --git a/README.md b/README.md index 077d2d7..46d7abf 100644 --- a/README.md +++ b/README.md @@ -110,4 +110,4 @@ bun run build ## Telemetry -Published builds may send anonymous usage telemetry. It never includes project names, file paths, or database URLs. Disable it with `DO_NOT_TRACK`, `CREATE_PRISMA_DISABLE_TELEMETRY`, or `CREATE_PRISMA_TELEMETRY_DISABLED`. +Published builds may send anonymous usage telemetry. It never includes project names, file paths, or database URLs. Failure events describe the cause only with stable identifiers, such as an exit code, the package manager's own error code (`E404`, `ERR_PNPM_FETCH_404`, `YN0035`), or a Prisma error code (`DEPLOY.ENGINE_FAILED`); error messages and command output are never sent. Disable it with `DO_NOT_TRACK`, `CREATE_PRISMA_DISABLE_TELEMETRY`, or `CREATE_PRISMA_TELEMETRY_DISABLED`. diff --git a/package.json b/package.json index b5b7350..371b01f 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "dev": "tsdown --watch", "start": "bun run ./dist/cli.mjs", "test": "bun run test:unit && bun run test:e2e", - "test:unit": "bun test ./tests/dependencies.test.ts ./tests/deploy-with-composer.test.ts ./tests/initialize-git.test.ts ./tests/install.test.ts ./tests/json-output.test.ts ./tests/node-version.test.ts ./tests/setup-prisma.test.ts ./tests/telemetry-client.test.ts ./tests/telemetry.test.ts", + "test:unit": "bun test ./tests/dependencies.test.ts ./tests/deploy-with-composer.test.ts ./tests/initialize-git.test.ts ./tests/install.test.ts ./tests/json-output.test.ts ./tests/node-version.test.ts ./tests/package-manager-error-code.test.ts ./tests/setup-prisma.test.ts ./tests/telemetry-client.test.ts ./tests/telemetry.test.ts", "test:e2e": "bun test --timeout 180000 ./tests/e2e/create-prisma.e2e.test.ts", "check": "bun run format:check && bun run lint", "lint": "oxlint . --deny-warnings", diff --git a/src/create-outcome.ts b/src/create-outcome.ts index 05b67ba..5f43d43 100644 --- a/src/create-outcome.ts +++ b/src/create-outcome.ts @@ -85,12 +85,21 @@ export class CreateFailure extends Schema.TaggedError()("CreateFa errorReported: Schema.optionalKey(Schema.Boolean), }) {} +/** The `DOMAIN.REASON` grammar shared by Prisma CLI and Composer error codes. */ +const STRUCTURED_ERROR_CODE_PATTERN = /^[A-Z][A-Z0-9_]*(\.[A-Z0-9_]+)+$/; + +export function isStructuredErrorCode(value: unknown): value is string { + return typeof value === "string" && STRUCTURED_ERROR_CODE_PATTERN.test(value); +} + export class PrismaCliCommandError extends Schema.TaggedError()( "PrismaCliCommandError", { message: Schema.String, command: Schema.optionalKey(Schema.String), code: Schema.optionalKey(Schema.String), + // The delegated tool's own failure code, when the CLI reports only a generic one. + causeCode: Schema.optionalKey(Schema.String), stderr: Schema.optionalKey(Schema.String), exitCode: Schema.optionalKey(Schema.Number), childProcessFailure: Schema.optional(ChildProcessFailureSchema), @@ -98,6 +107,7 @@ export class PrismaCliCommandError extends Schema.TaggedError` writes the deploy's outcome before the CLI +// settles a failed child process as the generic `CLI.CHILD_PROCESS_FAILED`. +// Only the failure code is read; the message can contain local paths. +const ComposerRunReportSchema = Schema.fromJsonString( + Schema.Struct({ + version: Schema.Literal(1), + failure: Schema.NullOr(Schema.Struct({ code: Schema.String })), + }), +); +const decodeComposerRunReport = Schema.decodeUnknownOption(ComposerRunReportSchema); + +export function parseComposerDeployFailureCode(report: string): string | undefined { + const code = Option.getOrUndefined(decodeComposerRunReport(report))?.failure?.code; + return isStructuredErrorCode(code) ? code : undefined; +} + +function withCauseCode(error: PrismaCliCommandError, causeCode: string): PrismaCliCommandError { + return new PrismaCliCommandError({ + message: error.message, + ...(error.command === undefined ? {} : { command: error.command }), + ...(error.code === undefined ? {} : { code: error.code }), + causeCode, + ...(error.stderr === undefined ? {} : { stderr: error.stderr }), + ...(error.exitCode === undefined ? {} : { exitCode: error.exitCode }), + childProcessFailure: error.childProcessFailure, + }); +} + +const readComposerDeployFailureCode = Effect.fn("Deployment.readFailureCode")(function* ( + reportPath: string, +) { + const fs = yield* FileSystem.FileSystem; + // A deploy that never reached Composer leaves no report behind. + const report = yield* fs.readFileString(reportPath).pipe(Effect.option); + return Option.isSome(report) ? parseComposerDeployFailureCode(report.value) : undefined; +}); + +export const runComposerDeployEffect = Effect.fn("Deployment.runComposerDeploy")( + function* (options: { + packageManager: PackageManager; + projectDir: string; + onStderrLine?: (line: string) => void; + }) { + const fs = yield* FileSystem.FileSystem; + // The report is diagnostic only, so a missing temp directory must not block the deploy. + const reportPath = yield* fs.makeTempDirectoryScoped({ prefix: "create-prisma-deploy-" }).pipe( + Effect.map((directory) => path.join(directory, "report.json")), + Effect.option, + Effect.map(Option.getOrUndefined), + ); + + return yield* runPrismaJsonCommandEffect({ + packageManager: options.packageManager, + projectDir: options.projectDir, + args: ["deploy", "module.ts", ...(reportPath ? ["--report", reportPath] : [])], + ...(options.onStderrLine ? { onStderrLine: options.onStderrLine } : {}), + }).pipe( + Effect.catchTag("PrismaCliCommandError", (error) => + Effect.gen(function* () { + const causeCode = reportPath + ? yield* readComposerDeployFailureCode(reportPath) + : undefined; + return yield* Effect.fail(causeCode ? withCauseCode(error, causeCode) : error); + }), + ), + ); + }, + Effect.scoped, +); diff --git a/src/tasks/deploy-with-composer.ts b/src/tasks/deploy-with-composer.ts index d908bac..4ae5a55 100644 --- a/src/tasks/deploy-with-composer.ts +++ b/src/tasks/deploy-with-composer.ts @@ -20,7 +20,8 @@ import { ComposerDeployCommandResultSchema, parseComposerDeployResult, } from "./composer/deployment-result"; -import { decodePrismaCommandResult, runPrismaJsonCommandEffect } from "./prisma-cli"; +import { runComposerDeployEffect } from "./composer/deploy-report"; +import { decodePrismaCommandResult } from "./prisma-cli"; import { ensureProjectNameAvailable, getProjectDetails } from "./composer/projects"; export type ComposerDeployExecutionResult = @@ -149,10 +150,9 @@ export const deployNewProjectWithComposerEffect = Effect.fn("Deployment.deploy") } }); const rawDeployment = yield* atCreateStage( - runPrismaJsonCommandEffect({ + runComposerDeployEffect({ packageManager: options.packageManager, projectDir: options.projectDir, - args: ["deploy", "module.ts"], onStderrLine: (line) => { const redacted = redactSecrets(line); if (options.verbose) output.write(`${redacted}\n`); diff --git a/src/telemetry/create.ts b/src/telemetry/create.ts index 8237702..2afbe84 100644 --- a/src/telemetry/create.ts +++ b/src/telemetry/create.ts @@ -2,6 +2,7 @@ import { Effect } from "effect"; import type { CreatePromptContext } from "../commands/create"; import { + isStructuredErrorCode, PrismaCliCommandError, type CreateCancellationStage, type CreateFailureReason, @@ -10,6 +11,7 @@ import { import type { CreateCommandInput } from "../types"; import { applicationRuntime } from "../runtime"; import { CommandExecutionError } from "../services/command-runner"; +import { getPackageManagerErrorCode } from "../utils/package-manager-error-code"; import { TELEMETRY_TIMEOUT_MS, trackCliTelemetryEffect } from "./client"; @@ -99,9 +101,15 @@ function getChildProcessFailureProperty(error: unknown): string | null { : null; } +function getPackageManagerErrorCodeProperty(error: unknown): string | null { + return error instanceof CommandExecutionError + ? (getPackageManagerErrorCode(error.command, error) ?? null) + : null; +} + function getPrismaCliFailureProperty( error: unknown, - property: "prismaCliCommand" | "prismaCliErrorCode", + property: "prismaCliCommand" | "prismaCliErrorCode" | "prismaCliCauseCode", ): string | null { if (typeof error !== "object" || error === null) { return null; @@ -111,6 +119,11 @@ function getPrismaCliFailureProperty( return typeof value === "string" && value.length > 0 ? value : null; } +function getPrismaCliCauseCodeProperty(error: unknown): string | null { + const causeCode = getPrismaCliFailureProperty(error, "prismaCliCauseCode"); + return isStructuredErrorCode(causeCode) ? causeCode : null; +} + export const trackCreateCompletedEffect = Effect.fn("Telemetry.createCompleted")( function* (params: { input: CreateCommandInput; @@ -147,6 +160,11 @@ export const trackCreateFailedEffect = Effect.fn("Telemetry.createFailed")(funct "child-process-failure": getChildProcessFailureProperty(params.error), "prisma-cli-command": getPrismaCliFailureProperty(params.error, "prismaCliCommand"), "prisma-cli-error-code": getPrismaCliFailureProperty(params.error, "prismaCliErrorCode"), + "prisma-cli-cause-code": getPrismaCliCauseCodeProperty(params.error), + "package-manager-error-code": + params.stage === "install_dependencies" + ? getPackageManagerErrorCodeProperty(params.error) + : null, }).pipe( Effect.scoped, Effect.timeout(TELEMETRY_TIMEOUT_MS), diff --git a/src/utils/package-manager-error-code.ts b/src/utils/package-manager-error-code.ts new file mode 100644 index 0000000..0ba8e5d --- /dev/null +++ b/src/utils/package-manager-error-code.ts @@ -0,0 +1,62 @@ +import { stripVTControlCharacters } from "node:util"; + +type ErrorCodeGrammar = { + /** Captures the identifier from one output line, anchored to where the manager prints it. */ + line: RegExp; + /** The only values that may leave this module. */ + allow: RegExp; +}; + +// Only managers that print a stable, machine-readable identifier are listed. +// Bun, Deno and Yarn Classic report failures as prose, so they have no entry. +const grammars: Record = { + // npm >= 10 prints `npm error code E404`; older releases print `npm ERR! code E404`. + // A failed lifecycle script reports its numeric exit status there, which is not an identifier. + npm: { + line: /^npm (?:error|ERR!) code (\S+)$/, + allow: /^E[A-Z0-9_]{2,40}$/, + }, + // pnpm >= 11 prints `[ERR_PNPM_FETCH_404] ...`; older releases pad the code with thin spaces. + // Errors pnpm did not raise itself keep their own code, such as `ELIFECYCLE`. + pnpm: { + line: /^(?:\[([A-Z0-9_]+)\] |\u2009([A-Z0-9_]+)\u2009 )/, + allow: /^(?:ERR_PNPM_[A-Z0-9_]{1,60}|E[A-Z0-9_]{2,40})$/, + }, + // Yarn Berry prefixes every line with a message name; YN0000 is the unnamed one. + yarn: { + line: /^(?:➤ )?(YN\d{4}): /, + allow: /^YN(?!0000)\d{4}$/, + }, +}; + +function getGrammar(command: string): ErrorCodeGrammar | undefined { + const name = command + .replace(/^.*[\\/]/, "") + .replace(/\.(?:cmd|exe)$/i, "") + .toLowerCase(); + return Object.hasOwn(grammars, name) ? grammars[name] : undefined; +} + +/** + * Returns the package manager's own error identifier from captured command output. + * + * The result is either undefined or a value matching that manager's identifier + * grammar, so it can never carry a path, package name, URL, or credential. + * The last identifier wins because managers print the terminal error last. + */ +export function getPackageManagerErrorCode( + command: string, + output: { stdout: string; stderr: string }, +): string | undefined { + const grammar = getGrammar(command); + if (!grammar) return undefined; + + // pnpm and Yarn report errors on stdout; npm reports them on stderr. + const lines = stripVTControlCharacters(`${output.stdout}\n${output.stderr}`).split(/\r?\n/); + for (const line of lines.reverse()) { + const match = grammar.line.exec(line); + const candidate = match?.[1] ?? match?.[2]; + if (candidate !== undefined && grammar.allow.test(candidate)) return candidate; + } + return undefined; +} diff --git a/tests/deploy-with-composer.test.ts b/tests/deploy-with-composer.test.ts index 5d3036b..889670e 100644 --- a/tests/deploy-with-composer.test.ts +++ b/tests/deploy-with-composer.test.ts @@ -1,4 +1,6 @@ import { describe, expect, test } from "bun:test"; +import { access } from "node:fs/promises"; +import path from "node:path"; import { PassThrough } from "node:stream"; import { @@ -9,7 +11,22 @@ import { parsePrismaCliEnvelope, PrismaCliCommandError, } from "../src/tasks/deploy-with-composer"; +import { parseComposerDeployFailureCode } from "../src/tasks/composer/deploy-report"; import { getErrorMessage, redactSecrets } from "../src/utils/errors"; +import { + childProcessFailedResult, + composerRunReport, + runFakeComposerDeploy, +} from "./fixtures/composer-deploy"; + +async function pathExists(filePath: string) { + try { + await access(filePath); + return true; + } catch { + return false; + } +} describe("redactSecrets", () => { test("redacts supported database URLs", () => { @@ -187,6 +204,139 @@ describe("parseComposerDeployResult", () => { }); }); +describe("parseComposerDeployFailureCode", () => { + test("reads the failure code and nothing else", () => { + expect( + parseComposerDeployFailureCode( + composerRunReport({ + code: "DEPLOY.ENGINE_FAILED", + message: "alchemy failed in /Users/jane/projects/my-app", + }), + ), + ).toBe("DEPLOY.ENGINE_FAILED"); + }); + + test("returns nothing for a successful run", () => { + expect(parseComposerDeployFailureCode(composerRunReport(null))).toBeUndefined(); + }); + + test("rejects reports it cannot trust", () => { + const failure = { code: "DEPLOY.ENGINE_FAILED", message: "failed" }; + for (const report of [ + "", + "not json", + "null", + JSON.stringify({ version: 2, failure }), + JSON.stringify({ failure }), + JSON.stringify({ version: 1, failure: { code: 42 } }), + JSON.stringify({ version: 1 }), + ]) { + expect(parseComposerDeployFailureCode(report)).toBeUndefined(); + } + }); + + test("rejects codes outside the structured-code grammar", () => { + for (const code of [ + "", + "ENGINE_FAILED", + "deploy.engine_failed", + "DEPLOY.", + ".FAILED", + "DEPLOY.ENGINE FAILED", + "DEPLOY.ENGINE_FAILED\nDATABASE_URL=postgresql://jane:hunter2@host/db", + "DEPLOY./Users/jane/projects/my-app", + "jane@EXAMPLE.COM", + "HTTPS://REGISTRY.EXAMPLE.COM/TOKEN", + ]) { + expect( + parseComposerDeployFailureCode(composerRunReport({ code, message: "failed" })), + ).toBeUndefined(); + } + }); +}); + +describe("runComposerDeployEffect", () => { + test("asks Composer for a run report without changing the deploy result", async () => { + const deployment = { summary: { app: "my-app", nodes: [] } }; + const deploy = await runFakeComposerDeploy({ + result: { + exitCode: 0, + stdout: JSON.stringify({ kind: "result", envelope: { ok: true, result: deployment } }), + stderr: "", + }, + report: composerRunReport(null), + }); + + expect(deploy.result).toEqual(deployment); + expect(deploy.specs).toHaveLength(1); + expect(deploy.specs[0]?.args.slice(-6)).toEqual([ + "deploy", + "module.ts", + "--report", + deploy.reportPath!, + "--json", + "--no-interactive", + ]); + expect(path.isAbsolute(deploy.reportPath!)).toBe(true); + }); + + test("carries Composer's failure code beside the generic CLI code", async () => { + const deploy = await runFakeComposerDeploy({ + result: childProcessFailedResult, + report: composerRunReport({ + code: "DEPLOY.ENGINE_FAILED", + message: "alchemy failed in /Users/jane/projects/my-app", + }), + }); + + expect(deploy.error).toBeInstanceOf(PrismaCliCommandError); + expect(deploy.error).toMatchObject({ + message: "The delegated process exited with code 1.", + prismaCliCommand: "deploy", + prismaCliErrorCode: "CLI.CHILD_PROCESS_FAILED", + prismaCliCauseCode: "DEPLOY.ENGINE_FAILED", + exitCode: 1, + childProcessFailure: "non_zero_exit", + }); + expect(getErrorMessage(deploy.error)).toBe("The delegated process exited with code 1."); + expect(JSON.stringify(deploy.error)).not.toContain("jane"); + }); + + test("preserves the original error when the report is missing or invalid", async () => { + for (const report of [undefined, "not json", composerRunReport(null)]) { + const deploy = await runFakeComposerDeploy({ + result: childProcessFailedResult, + ...(report === undefined ? {} : { report }), + }); + + expect(deploy.error).toBeInstanceOf(PrismaCliCommandError); + expect(deploy.error).toMatchObject({ + message: "The delegated process exited with code 1.", + prismaCliErrorCode: "CLI.CHILD_PROCESS_FAILED", + exitCode: 1, + }); + expect(deploy.error).not.toHaveProperty("causeCode"); + expect((deploy.error as PrismaCliCommandError).prismaCliCauseCode).toBeUndefined(); + } + }); + + test("removes the report directory after success and failure", async () => { + for (const result of [ + childProcessFailedResult, + { exitCode: 0, stdout: '{"ok":true,"result":{"summary":null}}', stderr: "" }, + ]) { + const deploy = await runFakeComposerDeploy({ + result, + report: composerRunReport({ code: "DEPLOY.ENGINE_FAILED", message: "failed" }), + }); + + expect(deploy.reportPath).toBeDefined(); + expect(await pathExists(deploy.reportPath!)).toBe(false); + expect(await pathExists(path.dirname(deploy.reportPath!))).toBe(false); + } + }); +}); + describe("deployNewProjectWithComposer", () => { test("returns the authentication failure instead of swallowing it", async () => { const originalPath = process.env.PATH; diff --git a/tests/fixtures/composer-deploy.ts b/tests/fixtures/composer-deploy.ts new file mode 100644 index 0000000..2085c21 --- /dev/null +++ b/tests/fixtures/composer-deploy.ts @@ -0,0 +1,72 @@ +import { NodeFileSystem } from "@effect/platform-node-shared"; +import { Cause, Effect, Exit } from "effect"; +import { writeFile } from "node:fs/promises"; + +import { + CommandRunner, + type CommandResult, + type CommandSpec, +} from "../../src/services/command-runner"; +import { runComposerDeployEffect } from "../../src/tasks/composer/deploy-report"; + +// What the Prisma CLI prints when the delegated Alchemy process exits non-zero. +export const childProcessFailedResult: CommandResult = { + exitCode: 1, + stdout: JSON.stringify({ + kind: "result", + envelope: { + ok: false, + commandId: "deploy", + error: { + code: "CLI.CHILD_PROCESS_FAILED", + severity: "error", + summary: "The delegated process exited with code 1.", + }, + }, + }), + stderr: "", + childProcessFailure: "non_zero_exit", +}; + +// The file Composer writes to the `--report` path before the CLI settles the run. +export function composerRunReport(failure: { code: string; message: string } | null): string { + return `${JSON.stringify( + { + version: 1, + outcome: failure ? "failed" : "succeeded", + app: failure ? null : "my-app", + stage: null, + nodes: [], + failure, + }, + null, + 2, + )}\n`; +} + +/** Runs the deploy step against a fake Prisma CLI that optionally writes a run report. */ +export async function runFakeComposerDeploy(options: { result: CommandResult; report?: string }) { + const specs: CommandSpec[] = []; + let reportPath: string | undefined; + const run = (spec: CommandSpec) => + Effect.promise(async () => { + specs.push(spec); + const flagIndex = spec.args.indexOf("--report"); + reportPath = flagIndex === -1 ? undefined : spec.args[flagIndex + 1]; + if (reportPath && options.report !== undefined) await writeFile(reportPath, options.report); + return options.result; + }); + + const exit = await Effect.runPromiseExit( + runComposerDeployEffect({ packageManager: "npm", projectDir: process.cwd() }).pipe( + Effect.provideService(CommandRunner, { run, runChecked: run }), + Effect.provide(NodeFileSystem.layer), + ), + ); + return { + specs, + reportPath, + result: Exit.isSuccess(exit) ? exit.value : undefined, + error: Exit.isFailure(exit) ? exit.cause.reasons.find(Cause.isFailReason)?.error : undefined, + }; +} diff --git a/tests/fixtures/package-manager-output.ts b/tests/fixtures/package-manager-output.ts new file mode 100644 index 0000000..390e1cf --- /dev/null +++ b/tests/fixtures/package-manager-output.ts @@ -0,0 +1,257 @@ +// Output captured from real failed `install` runs with piped stdio, trimmed, with the +// local home and project paths replaced. The remaining paths, URLs and package names +// are what a user's output contains, which is exactly what must never reach telemetry. + +export type PackageManagerOutputFixture = { + name: string; + command: string; + stdout: string; + stderr: string; + expected: string | undefined; +}; + +const lines = (...parts: string[]) => parts.join("\n"); + +export const packageManagerOutputFixtures: PackageManagerOutputFixture[] = [ + { + name: "npm 11: version does not exist", + command: "npm", + stdout: "", + stderr: lines( + "npm error code ETARGET", + "npm error notarget No matching version found for left-pad@99.99.99.", + "npm error notarget In most cases you or one of your dependencies are requesting a package version that doesn't exist.", + "npm error A complete log of this run can be found in: /Users/jane/.npm/_logs/2026-09-21T10_20_59_996Z-debug-0.log", + ), + expected: "ETARGET", + }, + { + name: "npm 11: package does not exist", + command: "npm", + stdout: "", + stderr: lines( + "npm error code E404", + "npm error 404 Not Found - GET https://registry.npmjs.org/create-prisma-telemetry-probe-does-not-exist - Not found", + "npm error 404", + "npm error 404 The requested resource 'create-prisma-telemetry-probe-does-not-exist@1.0.0' could not be found or you do not have permission to access it.", + "npm error A complete log of this run can be found in: /Users/jane/.npm/_logs/2026-09-21T10_22_26_869Z-debug-0.log", + ), + expected: "E404", + }, + { + name: "npm 11: registry unreachable", + command: "npm", + stdout: "", + stderr: lines( + "npm error code ECONNREFUSED", + "npm error syscall connect", + "npm error errno ECONNREFUSED", + "npm error FetchError: request to http://127.0.0.1:9/left-pad failed, reason: connect ECONNREFUSED 127.0.0.1:9", + "npm error at ClientRequest. (/Users/jane/.local/share/mise/installs/node/26.7.0/lib/node_modules/npm/node_modules/minipass-fetch/lib/index.js:130:14)", + "npm error code: 'ECONNREFUSED',", + "npm error address: '127.0.0.1',", + "npm error If you are behind a proxy, please make sure that the 'proxy' config is set properly. See: 'npm help config'", + ), + expected: "ECONNREFUSED", + }, + { + name: "npm 11: lifecycle script failed with a numeric status", + command: "npm", + stdout: lines("", "> postinstall", "> exit 3", ""), + stderr: lines( + "npm error code 3", + "npm error path /Users/jane/projects/my-app", + "npm error command failed", + "npm error command sh -c exit 3", + ), + expected: undefined, + }, + { + name: "npm 8: legacy error prefix", + command: "npm", + stdout: "", + stderr: lines( + "npm ERR! code ETARGET", + "npm ERR! notarget No matching version found for left-pad@99.99.99.", + "", + "npm ERR! A complete log of this run can be found in:", + "npm ERR! /Users/jane/.npm/_logs/2026-09-21T10_24_12_497Z-debug-0.log", + ), + expected: "ETARGET", + }, + { + name: "pnpm 11: version does not exist", + command: "pnpm", + stdout: lines( + "[ERR_PNPM_NO_MATCHING_VERSION] No matching version found for left-pad@99.99.99 while fetching it from https://registry.npmjs.org/", + "", + "This error happened while installing a direct dependency of /Users/jane/projects/my-app", + "", + 'The latest release of left-pad is "1.3.0".', + ), + stderr: "", + expected: "ERR_PNPM_NO_MATCHING_VERSION", + }, + { + name: "pnpm 11: package does not exist", + command: "pnpm", + stdout: lines( + "[ERR_PNPM_FETCH_404] GET https://registry.npmjs.org/create-prisma-telemetry-probe-does-not-exist: Not Found - 404", + "", + "This error happened while installing a direct dependency of /Users/jane/projects/my-app", + "", + "No authorization header was set for the request.", + ), + stderr: "", + expected: "ERR_PNPM_FETCH_404", + }, + { + name: "pnpm 11: registry unreachable after retry warnings", + command: "pnpm", + stdout: lines( + "[WARN] GET http://127.0.0.1:9/left-pad error (unknown). Will retry in 10 seconds. 2 retries left.", + "[WARN] GET http://127.0.0.1:9/left-pad error (unknown). Will retry in 1 minute. 1 retries left.", + "[ERR_PNPM_META_FETCH_FAIL] GET http://127.0.0.1:9/left-pad: fetch failed", + "", + "This error happened while installing a direct dependency of /Users/jane/projects/my-app", + ), + stderr: "", + expected: "ERR_PNPM_META_FETCH_FAIL", + }, + { + name: "pnpm 11: lifecycle script failed", + command: "pnpm", + stdout: lines("Already up to date", "[ELIFECYCLE] Command failed with exit code 3."), + stderr: "$ exit 3", + expected: "ELIFECYCLE", + }, + { + name: "pnpm 10: legacy thin-space padding", + command: "pnpm", + stdout: lines( + "\u2009ERR_PNPM_NO_MATCHING_VERSION\u2009 No matching version found for left-pad@99.99.99 while fetching it from https://registry.npmjs.org/", + "", + "This error happened while installing a direct dependency of /Users/jane/projects/my-app", + ), + stderr: "", + expected: "ERR_PNPM_NO_MATCHING_VERSION", + }, + { + name: "pnpm 10: forced colour", + command: "pnpm", + stdout: + "\u001b[41m\u001b[30m\u2009ERR_PNPM_NO_MATCHING_VERSION\u2009\u001b[39m\u001b[49m \u001b[31mNo matching version found for left-pad@99.99.99\u001b[39m", + stderr: "", + expected: "ERR_PNPM_NO_MATCHING_VERSION", + }, + { + name: "pnpm 10: lifecycle script failed before a trailing warning", + command: "pnpm", + stdout: lines( + "Already up to date", + "", + "> probe@ postinstall /Users/jane/projects/my-app", + "> exit 3", + "", + "\u2009ELIFECYCLE\u2009 Command failed with exit code 3.", + "\u2009WARN\u2009 Local package.json exists, but node_modules missing, did you mean to install?", + ), + stderr: "", + expected: "ELIFECYCLE", + }, + { + name: "Yarn 4: version does not exist", + command: "yarn", + stdout: lines( + "➤ YN0000: · Yarn 4.18.0", + "➤ YN0000: ┌ Resolution step", + "➤ YN0082: │ left-pad@npm:99.99.99: No candidates found", + "➤ YN0000: └ Completed in 0s 399ms", + "➤ YN0000: · Failed with errors in 0s 403ms", + ), + stderr: "", + expected: "YN0082", + }, + { + name: "Yarn 4: package does not exist", + command: "yarn", + stdout: lines( + "➤ YN0000: · Yarn 4.18.0", + "➤ YN0000: ┌ Resolution step", + "➤ YN0035: │ create-prisma-telemetry-probe-does-not-exist@npm:1.0.0: Package not found", + "➤ YN0035: │ Response Code: 404 (Not Found)", + "➤ YN0035: │ Request URL: https://registry.yarnpkg.com/create-prisma-telemetry-probe-does-not-exist", + "➤ YN0000: └ Completed in 0s 344ms", + "➤ YN0000: · Failed with errors in 0s 348ms", + ), + stderr: "", + expected: "YN0035", + }, + { + name: "Yarn 4: lifecycle script failed after an informational message", + command: "yarn", + stdout: lines( + "➤ YN0000: · Yarn 4.18.0", + "➤ YN0000: ┌ Link step", + "➤ YN0007: │ probe@workspace:. must be built because it never has been before or the last one failed", + "➤ YN0009: │ probe@workspace:. couldn't be built successfully (exit code 3, logs can be found here: /private/var/folders/tg/xrj97k111zx156t1s_wpkxr40000gn/T/xfs-5b34503f/build.log)", + "➤ YN0000: └ Completed", + "➤ YN0000: · Failed with errors in 0s 20ms", + ), + stderr: "", + expected: "YN0009", + }, + { + name: "Yarn 4: registry unreachable", + command: "yarn", + stdout: lines( + "➤ YN0000: · Yarn 4.18.0", + "➤ YN0000: ┌ Resolution step", + "➤ YN0001: │ RequestError: connect ECONNREFUSED 127.0.0.1:9", + " at ClientRequest. (/Users/jane/.yarn/releases/yarn-4.18.0.cjs:148:14258)", + " at TCPConnectWrap.afterConnect [as oncomplete] (node:net:2017:16)", + "➤ YN0000: └ Completed", + "➤ YN0000: · Failed with errors in 0s 17ms", + ), + stderr: "", + expected: "YN0001", + }, + { + name: "Yarn 1: prose only", + command: "yarn", + stdout: lines( + "yarn install v1.22.22", + "info No lockfile found.", + "[1/4] Resolving packages...", + ), + stderr: 'error Couldn\'t find any versions for "left-pad" that matches "99.99.99"', + expected: undefined, + }, + { + name: "Bun 1.4: prose only", + command: "bun", + stdout: "bun install v1.4.0 (34cbb9a40)", + stderr: lines( + "Resolving dependencies", + 'error: No version matching "99.99.99" found for specifier "left-pad" (but package exists)', + "error: left-pad@99.99.99 failed to resolve", + ), + expected: undefined, + }, + { + name: "Deno 2.9: prose only", + command: "deno", + stdout: "", + stderr: lines( + "Download https://registry.npmjs.org/left-pad", + "error: Could not find npm package 'left-pad' matching '99.99.99'.", + ), + expected: undefined, + }, +]; + +export function getPackageManagerOutputFixture(name: string): PackageManagerOutputFixture { + const fixture = packageManagerOutputFixtures.find((candidate) => candidate.name === name); + if (!fixture) throw new Error(`Unknown package manager output fixture: ${name}`); + return fixture; +} diff --git a/tests/package-manager-error-code.test.ts b/tests/package-manager-error-code.test.ts new file mode 100644 index 0000000..283aef9 --- /dev/null +++ b/tests/package-manager-error-code.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, test } from "bun:test"; + +import { getPackageManagerErrorCode } from "../src/utils/package-manager-error-code"; +import { packageManagerOutputFixtures } from "./fixtures/package-manager-output"; + +// Deliberately restated here so a loosened grammar in the module fails this suite. +const SAFE_IDENTIFIER = /^(?:E[A-Z0-9_]{2,40}|ERR_PNPM_[A-Z0-9_]{1,60}|YN\d{4})$/; + +const secrets = [ + "/Users/jane/projects/my-app", + "C:\\Users\\jane\\my-app", + "https://jane:hunter2@registry.example.com/", + "jane@example.com", + "npm_AbCdEf0123456789", + "@acme/internal-package", + "DATABASE_URL=postgresql://jane:hunter2@db.example.com/app", +]; + +describe("getPackageManagerErrorCode", () => { + for (const fixture of packageManagerOutputFixtures) { + test(fixture.name, () => { + expect(getPackageManagerErrorCode(fixture.command, fixture)).toBe(fixture.expected); + }); + } + + test("resolves the manager from a Windows shim path", () => { + expect( + getPackageManagerErrorCode("C:\\Program Files\\nodejs\\npm.cmd", { + stdout: "", + stderr: "npm error code E404\r\nnpm error 404 Not Found\r\n", + }), + ).toBe("E404"); + }); + + test("ignores identifiers printed by a command that is not a known package manager", () => { + for (const command of ["node", "bun", "deno", "prisma", "npmx", "__proto__", "constructor"]) { + expect( + getPackageManagerErrorCode(command, { + stdout: "[ERR_PNPM_FETCH_404] nope\n➤ YN0035: │ nope", + stderr: "npm error code E404", + }), + ).toBeUndefined(); + } + }); + + test("reads another manager's format as prose", () => { + expect( + getPackageManagerErrorCode("npm", { stdout: "[ERR_PNPM_FETCH_404] nope", stderr: "" }), + ).toBeUndefined(); + expect( + getPackageManagerErrorCode("yarn", { stdout: "", stderr: "npm error code E404" }), + ).toBeUndefined(); + }); + + test("ignores an identifier relayed from a nested package manager", () => { + expect( + getPackageManagerErrorCode("npm", { + stdout: "", + stderr: "npm error code 1\nnpm error npm error code E404\nnpm error code: 'E404',", + }), + ).toBeUndefined(); + }); + + test("never returns free text from the identifier position", () => { + const cases = secrets.flatMap((secret) => [ + { command: "npm", stdout: "", stderr: `npm error code ${secret}` }, + { command: "npm", stdout: "", stderr: `npm error code E404 ${secret}` }, + { command: "npm", stdout: "", stderr: `npm ERR! code E${secret}` }, + { command: "pnpm", stdout: `[${secret}] failed`, stderr: "" }, + { command: "pnpm", stdout: `[ERR_PNPM_${secret}] failed`, stderr: "" }, + { command: "pnpm", stdout: `\u2009${secret}\u2009 failed`, stderr: "" }, + { command: "yarn", stdout: `➤ ${secret}: │ failed`, stderr: "" }, + { command: "yarn", stdout: `➤ YN0001${secret}: │ failed`, stderr: "" }, + ]); + for (const output of cases) { + expect(getPackageManagerErrorCode(output.command, output)).toBeUndefined(); + } + }); + + test("rejects identifiers outside each grammar", () => { + const cases = [ + { command: "npm", stdout: "", stderr: "npm error code e404" }, + { command: "npm", stdout: "", stderr: "npm error code 1" }, + { command: "npm", stdout: "", stderr: `npm error code E${"A".repeat(41)}` }, + { command: "npm", stdout: "", stderr: " npm error code E404" }, + { command: "pnpm", stdout: "[WARN] deprecated left-pad@1.3.0", stderr: "" }, + { command: "pnpm", stdout: `[ERR_PNPM_${"A".repeat(61)}] failed`, stderr: "" }, + { command: "pnpm", stdout: "see [ERR_PNPM_FETCH_404] in the docs", stderr: "" }, + { command: "yarn", stdout: "➤ YN0000: · Failed with errors in 0s 403ms", stderr: "" }, + { command: "yarn", stdout: "➤ YN00821: │ failed", stderr: "" }, + ]; + for (const output of cases) { + expect(getPackageManagerErrorCode(output.command, output)).toBeUndefined(); + } + }); + + test("returns nothing or a grammar-conforming identifier when output is full of secrets", () => { + for (const fixture of packageManagerOutputFixtures) { + const noise = secrets.join("\n"); + const code = getPackageManagerErrorCode(fixture.command, { + stdout: `${noise}\n${fixture.stdout}\n${noise}`, + stderr: `${noise}\n${fixture.stderr}\n${noise}`, + }); + expect(code).toBe(fixture.expected); + if (code !== undefined) expect(code).toMatch(SAFE_IDENTIFIER); + } + }); + + test("returns nothing when verbose installs inherit stdio and capture no output", () => { + for (const command of ["npm", "pnpm", "yarn", "bun", "deno"]) { + expect(getPackageManagerErrorCode(command, { stdout: "", stderr: "" })).toBeUndefined(); + } + }); +}); diff --git a/tests/telemetry.test.ts b/tests/telemetry.test.ts index 6cba873..c5b771b 100644 --- a/tests/telemetry.test.ts +++ b/tests/telemetry.test.ts @@ -6,6 +6,15 @@ 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 { + childProcessFailedResult, + composerRunReport, + runFakeComposerDeploy, +} from "./fixtures/composer-deploy"; +import { + getPackageManagerOutputFixture, + packageManagerOutputFixtures, +} from "./fixtures/package-manager-output"; const trackCliTelemetry = mock(async () => {}); @@ -240,6 +249,108 @@ describe("create telemetry", () => { } }); + test("tracks the package manager's own error identifier for install failures", async () => { + for (const fixture of packageManagerOutputFixtures) { + await trackCreateFailed({ + input: createInput, + context: createContext, + durationMs: 10, + error: new CommandExecutionError({ + command: fixture.command, + args: ["install"], + exitCode: 1, + stdout: fixture.stdout, + stderr: fixture.stderr, + childProcessFailure: "non_zero_exit", + }), + stage: "install_dependencies", + reason: "dependency_install_failed", + }); + + const [, properties] = trackCliTelemetry.mock.calls.at(-1) as [ + string, + Record, + ]; + expect(properties["package-manager-error-code"]).toBe(fixture.expected ?? null); + expect(JSON.stringify(properties)).not.toMatch(/jane|left-pad|registry|127\.0\.0\.1/); + } + }); + + test("reports a package manager identifier only for the install stage", async () => { + const fixture = getPackageManagerOutputFixture("pnpm 11: lifecycle script failed"); + await trackCreateFailed({ + input: createInput, + context: createContext, + durationMs: 10, + error: new CommandExecutionError({ + command: fixture.command, + args: ["run", "build"], + exitCode: 1, + stdout: fixture.stdout, + stderr: fixture.stderr, + }), + stage: "build", + reason: "build_failed", + }); + const [, properties] = trackCliTelemetry.mock.calls[0] as [string, Record]; + expect(properties["package-manager-error-code"]).toBeNull(); + }); + + test("tracks Composer's failure code behind a generic Prisma CLI failure", async () => { + const { error } = await runFakeComposerDeploy({ + result: childProcessFailedResult, + report: composerRunReport({ + code: "DEPLOY.ENGINE_FAILED", + message: "alchemy failed in /Users/jane/projects/my-app", + }), + }); + await trackCreateFailed({ + input: createInput, + context: createContext, + durationMs: 10, + error, + stage: "composer_deploy", + reason: "composer_deploy_failed", + }); + const [, properties] = trackCliTelemetry.mock.calls[0] as [string, Record]; + expect(properties).toEqual( + expect.objectContaining({ + "prisma-cli-command": "deploy", + "prisma-cli-error-code": "CLI.CHILD_PROCESS_FAILED", + "prisma-cli-cause-code": "DEPLOY.ENGINE_FAILED", + "package-manager-error-code": null, + }), + ); + expect(JSON.stringify(properties)).not.toContain("jane"); + }); + + test("omits the cause code when the run report is missing or unstructured", async () => { + const { error: withoutReport } = await runFakeComposerDeploy({ + result: childProcessFailedResult, + }); + for (const error of [ + withoutReport, + Object.assign(new Error("failed"), { + prismaCliCauseCode: "failed in /Users/jane/projects/my-app", + }), + ]) { + await trackCreateFailed({ + input: createInput, + context: createContext, + durationMs: 10, + error, + stage: "composer_deploy", + reason: "composer_deploy_failed", + }); + const [, properties] = trackCliTelemetry.mock.calls.at(-1) as [ + string, + Record, + ]; + expect(properties["prisma-cli-cause-code"]).toBeNull(); + expect(JSON.stringify(properties)).not.toContain("jane"); + } + }); + test("tracks prompt cancellation as a separate outcome", async () => { await trackCreateCancelled({ input: createInput, From ca3fbb62ce0a6a5e3286cf7b2d87cdca3416100d Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Mon, 21 Sep 2026 16:04:50 +0530 Subject: [PATCH 2/5] 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 984892441def36b7c70fcd0d0c383fc3ebd0d41d Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Mon, 21 Sep 2026 16:31:29 +0530 Subject: [PATCH 3/5] refactor: trim telemetry failure-code comments, docs and tests Co-Authored-By: Claude Fable 5.1 --- README.md | 2 +- src/create-outcome.ts | 3 +- src/tasks/composer/deploy-report.ts | 7 +- src/utils/package-manager-error-code.ts | 26 +-- tests/deploy-with-composer.test.ts | 150 ------------- tests/fixtures/composer-deploy.ts | 72 ------- tests/fixtures/package-manager-output.ts | 257 ----------------------- tests/package-manager-error-code.test.ts | 156 +++++--------- tests/telemetry.test.ts | 164 ++++++--------- 9 files changed, 127 insertions(+), 710 deletions(-) delete mode 100644 tests/fixtures/composer-deploy.ts delete mode 100644 tests/fixtures/package-manager-output.ts diff --git a/README.md b/README.md index 46d7abf..c51bd3c 100644 --- a/README.md +++ b/README.md @@ -110,4 +110,4 @@ bun run build ## Telemetry -Published builds may send anonymous usage telemetry. It never includes project names, file paths, or database URLs. Failure events describe the cause only with stable identifiers, such as an exit code, the package manager's own error code (`E404`, `ERR_PNPM_FETCH_404`, `YN0035`), or a Prisma error code (`DEPLOY.ENGINE_FAILED`); error messages and command output are never sent. Disable it with `DO_NOT_TRACK`, `CREATE_PRISMA_DISABLE_TELEMETRY`, or `CREATE_PRISMA_TELEMETRY_DISABLED`. +Published builds may send anonymous usage telemetry. It never includes project names, file paths, or database URLs. Failure events carry only stable identifiers such as exit codes and tool error codes, never messages or command output. Disable it with `DO_NOT_TRACK`, `CREATE_PRISMA_DISABLE_TELEMETRY`, or `CREATE_PRISMA_TELEMETRY_DISABLED`. diff --git a/src/create-outcome.ts b/src/create-outcome.ts index 5f43d43..b961b55 100644 --- a/src/create-outcome.ts +++ b/src/create-outcome.ts @@ -85,7 +85,6 @@ export class CreateFailure extends Schema.TaggedError()("CreateFa errorReported: Schema.optionalKey(Schema.Boolean), }) {} -/** The `DOMAIN.REASON` grammar shared by Prisma CLI and Composer error codes. */ const STRUCTURED_ERROR_CODE_PATTERN = /^[A-Z][A-Z0-9_]*(\.[A-Z0-9_]+)+$/; export function isStructuredErrorCode(value: unknown): value is string { @@ -98,7 +97,7 @@ export class PrismaCliCommandError extends Schema.TaggedError` writes the deploy's outcome before the CLI -// settles a failed child process as the generic `CLI.CHILD_PROCESS_FAILED`. -// Only the failure code is read; the message can contain local paths. +// Only `failure.code` is read; the message can contain local paths. const ComposerRunReportSchema = Schema.fromJsonString( Schema.Struct({ version: Schema.Literal(1), @@ -37,7 +35,6 @@ const readComposerDeployFailureCode = Effect.fn("Deployment.readFailureCode")(fu reportPath: string, ) { const fs = yield* FileSystem.FileSystem; - // A deploy that never reached Composer leaves no report behind. const report = yield* fs.readFileString(reportPath).pipe(Effect.option); return Option.isSome(report) ? parseComposerDeployFailureCode(report.value) : undefined; }); @@ -49,7 +46,7 @@ export const runComposerDeployEffect = Effect.fn("Deployment.runComposerDeploy") onStderrLine?: (line: string) => void; }) { const fs = yield* FileSystem.FileSystem; - // The report is diagnostic only, so a missing temp directory must not block the deploy. + // The report is diagnostic, so its absence never blocks the deploy. const reportPath = yield* fs.makeTempDirectoryScoped({ prefix: "create-prisma-deploy-" }).pipe( Effect.map((directory) => path.join(directory, "report.json")), Effect.option, diff --git a/src/utils/package-manager-error-code.ts b/src/utils/package-manager-error-code.ts index 0ba8e5d..95f389a 100644 --- a/src/utils/package-manager-error-code.ts +++ b/src/utils/package-manager-error-code.ts @@ -1,28 +1,19 @@ import { stripVTControlCharacters } from "node:util"; -type ErrorCodeGrammar = { - /** Captures the identifier from one output line, anchored to where the manager prints it. */ - line: RegExp; - /** The only values that may leave this module. */ - allow: RegExp; -}; +type ErrorCodeGrammar = { line: RegExp; allow: RegExp }; -// Only managers that print a stable, machine-readable identifier are listed. -// Bun, Deno and Yarn Classic report failures as prose, so they have no entry. const grammars: Record = { - // npm >= 10 prints `npm error code E404`; older releases print `npm ERR! code E404`. - // A failed lifecycle script reports its numeric exit status there, which is not an identifier. + // npm error code E404 (npm <= 9: npm ERR! code E404) npm: { line: /^npm (?:error|ERR!) code (\S+)$/, allow: /^E[A-Z0-9_]{2,40}$/, }, - // pnpm >= 11 prints `[ERR_PNPM_FETCH_404] ...`; older releases pad the code with thin spaces. - // Errors pnpm did not raise itself keep their own code, such as `ELIFECYCLE`. + // [ERR_PNPM_FETCH_404] ... (pnpm <= 10 pads with thin spaces) pnpm: { line: /^(?:\[([A-Z0-9_]+)\] |\u2009([A-Z0-9_]+)\u2009 )/, allow: /^(?:ERR_PNPM_[A-Z0-9_]{1,60}|E[A-Z0-9_]{2,40})$/, }, - // Yarn Berry prefixes every line with a message name; YN0000 is the unnamed one. + // ➤ YN0035: ...; YN0000 is the unnamed one yarn: { line: /^(?:➤ )?(YN\d{4}): /, allow: /^YN(?!0000)\d{4}$/, @@ -37,13 +28,7 @@ function getGrammar(command: string): ErrorCodeGrammar | undefined { return Object.hasOwn(grammars, name) ? grammars[name] : undefined; } -/** - * Returns the package manager's own error identifier from captured command output. - * - * The result is either undefined or a value matching that manager's identifier - * grammar, so it can never carry a path, package name, URL, or credential. - * The last identifier wins because managers print the terminal error last. - */ +// The result always matches the manager's allow-pattern, so it cannot carry free text. export function getPackageManagerErrorCode( command: string, output: { stdout: string; stderr: string }, @@ -51,7 +36,6 @@ export function getPackageManagerErrorCode( const grammar = getGrammar(command); if (!grammar) return undefined; - // pnpm and Yarn report errors on stdout; npm reports them on stderr. const lines = stripVTControlCharacters(`${output.stdout}\n${output.stderr}`).split(/\r?\n/); for (const line of lines.reverse()) { const match = grammar.line.exec(line); diff --git a/tests/deploy-with-composer.test.ts b/tests/deploy-with-composer.test.ts index 889670e..5d3036b 100644 --- a/tests/deploy-with-composer.test.ts +++ b/tests/deploy-with-composer.test.ts @@ -1,6 +1,4 @@ import { describe, expect, test } from "bun:test"; -import { access } from "node:fs/promises"; -import path from "node:path"; import { PassThrough } from "node:stream"; import { @@ -11,22 +9,7 @@ import { parsePrismaCliEnvelope, PrismaCliCommandError, } from "../src/tasks/deploy-with-composer"; -import { parseComposerDeployFailureCode } from "../src/tasks/composer/deploy-report"; import { getErrorMessage, redactSecrets } from "../src/utils/errors"; -import { - childProcessFailedResult, - composerRunReport, - runFakeComposerDeploy, -} from "./fixtures/composer-deploy"; - -async function pathExists(filePath: string) { - try { - await access(filePath); - return true; - } catch { - return false; - } -} describe("redactSecrets", () => { test("redacts supported database URLs", () => { @@ -204,139 +187,6 @@ describe("parseComposerDeployResult", () => { }); }); -describe("parseComposerDeployFailureCode", () => { - test("reads the failure code and nothing else", () => { - expect( - parseComposerDeployFailureCode( - composerRunReport({ - code: "DEPLOY.ENGINE_FAILED", - message: "alchemy failed in /Users/jane/projects/my-app", - }), - ), - ).toBe("DEPLOY.ENGINE_FAILED"); - }); - - test("returns nothing for a successful run", () => { - expect(parseComposerDeployFailureCode(composerRunReport(null))).toBeUndefined(); - }); - - test("rejects reports it cannot trust", () => { - const failure = { code: "DEPLOY.ENGINE_FAILED", message: "failed" }; - for (const report of [ - "", - "not json", - "null", - JSON.stringify({ version: 2, failure }), - JSON.stringify({ failure }), - JSON.stringify({ version: 1, failure: { code: 42 } }), - JSON.stringify({ version: 1 }), - ]) { - expect(parseComposerDeployFailureCode(report)).toBeUndefined(); - } - }); - - test("rejects codes outside the structured-code grammar", () => { - for (const code of [ - "", - "ENGINE_FAILED", - "deploy.engine_failed", - "DEPLOY.", - ".FAILED", - "DEPLOY.ENGINE FAILED", - "DEPLOY.ENGINE_FAILED\nDATABASE_URL=postgresql://jane:hunter2@host/db", - "DEPLOY./Users/jane/projects/my-app", - "jane@EXAMPLE.COM", - "HTTPS://REGISTRY.EXAMPLE.COM/TOKEN", - ]) { - expect( - parseComposerDeployFailureCode(composerRunReport({ code, message: "failed" })), - ).toBeUndefined(); - } - }); -}); - -describe("runComposerDeployEffect", () => { - test("asks Composer for a run report without changing the deploy result", async () => { - const deployment = { summary: { app: "my-app", nodes: [] } }; - const deploy = await runFakeComposerDeploy({ - result: { - exitCode: 0, - stdout: JSON.stringify({ kind: "result", envelope: { ok: true, result: deployment } }), - stderr: "", - }, - report: composerRunReport(null), - }); - - expect(deploy.result).toEqual(deployment); - expect(deploy.specs).toHaveLength(1); - expect(deploy.specs[0]?.args.slice(-6)).toEqual([ - "deploy", - "module.ts", - "--report", - deploy.reportPath!, - "--json", - "--no-interactive", - ]); - expect(path.isAbsolute(deploy.reportPath!)).toBe(true); - }); - - test("carries Composer's failure code beside the generic CLI code", async () => { - const deploy = await runFakeComposerDeploy({ - result: childProcessFailedResult, - report: composerRunReport({ - code: "DEPLOY.ENGINE_FAILED", - message: "alchemy failed in /Users/jane/projects/my-app", - }), - }); - - expect(deploy.error).toBeInstanceOf(PrismaCliCommandError); - expect(deploy.error).toMatchObject({ - message: "The delegated process exited with code 1.", - prismaCliCommand: "deploy", - prismaCliErrorCode: "CLI.CHILD_PROCESS_FAILED", - prismaCliCauseCode: "DEPLOY.ENGINE_FAILED", - exitCode: 1, - childProcessFailure: "non_zero_exit", - }); - expect(getErrorMessage(deploy.error)).toBe("The delegated process exited with code 1."); - expect(JSON.stringify(deploy.error)).not.toContain("jane"); - }); - - test("preserves the original error when the report is missing or invalid", async () => { - for (const report of [undefined, "not json", composerRunReport(null)]) { - const deploy = await runFakeComposerDeploy({ - result: childProcessFailedResult, - ...(report === undefined ? {} : { report }), - }); - - expect(deploy.error).toBeInstanceOf(PrismaCliCommandError); - expect(deploy.error).toMatchObject({ - message: "The delegated process exited with code 1.", - prismaCliErrorCode: "CLI.CHILD_PROCESS_FAILED", - exitCode: 1, - }); - expect(deploy.error).not.toHaveProperty("causeCode"); - expect((deploy.error as PrismaCliCommandError).prismaCliCauseCode).toBeUndefined(); - } - }); - - test("removes the report directory after success and failure", async () => { - for (const result of [ - childProcessFailedResult, - { exitCode: 0, stdout: '{"ok":true,"result":{"summary":null}}', stderr: "" }, - ]) { - const deploy = await runFakeComposerDeploy({ - result, - report: composerRunReport({ code: "DEPLOY.ENGINE_FAILED", message: "failed" }), - }); - - expect(deploy.reportPath).toBeDefined(); - expect(await pathExists(deploy.reportPath!)).toBe(false); - expect(await pathExists(path.dirname(deploy.reportPath!))).toBe(false); - } - }); -}); - describe("deployNewProjectWithComposer", () => { test("returns the authentication failure instead of swallowing it", async () => { const originalPath = process.env.PATH; diff --git a/tests/fixtures/composer-deploy.ts b/tests/fixtures/composer-deploy.ts deleted file mode 100644 index 2085c21..0000000 --- a/tests/fixtures/composer-deploy.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { NodeFileSystem } from "@effect/platform-node-shared"; -import { Cause, Effect, Exit } from "effect"; -import { writeFile } from "node:fs/promises"; - -import { - CommandRunner, - type CommandResult, - type CommandSpec, -} from "../../src/services/command-runner"; -import { runComposerDeployEffect } from "../../src/tasks/composer/deploy-report"; - -// What the Prisma CLI prints when the delegated Alchemy process exits non-zero. -export const childProcessFailedResult: CommandResult = { - exitCode: 1, - stdout: JSON.stringify({ - kind: "result", - envelope: { - ok: false, - commandId: "deploy", - error: { - code: "CLI.CHILD_PROCESS_FAILED", - severity: "error", - summary: "The delegated process exited with code 1.", - }, - }, - }), - stderr: "", - childProcessFailure: "non_zero_exit", -}; - -// The file Composer writes to the `--report` path before the CLI settles the run. -export function composerRunReport(failure: { code: string; message: string } | null): string { - return `${JSON.stringify( - { - version: 1, - outcome: failure ? "failed" : "succeeded", - app: failure ? null : "my-app", - stage: null, - nodes: [], - failure, - }, - null, - 2, - )}\n`; -} - -/** Runs the deploy step against a fake Prisma CLI that optionally writes a run report. */ -export async function runFakeComposerDeploy(options: { result: CommandResult; report?: string }) { - const specs: CommandSpec[] = []; - let reportPath: string | undefined; - const run = (spec: CommandSpec) => - Effect.promise(async () => { - specs.push(spec); - const flagIndex = spec.args.indexOf("--report"); - reportPath = flagIndex === -1 ? undefined : spec.args[flagIndex + 1]; - if (reportPath && options.report !== undefined) await writeFile(reportPath, options.report); - return options.result; - }); - - const exit = await Effect.runPromiseExit( - runComposerDeployEffect({ packageManager: "npm", projectDir: process.cwd() }).pipe( - Effect.provideService(CommandRunner, { run, runChecked: run }), - Effect.provide(NodeFileSystem.layer), - ), - ); - return { - specs, - reportPath, - result: Exit.isSuccess(exit) ? exit.value : undefined, - error: Exit.isFailure(exit) ? exit.cause.reasons.find(Cause.isFailReason)?.error : undefined, - }; -} diff --git a/tests/fixtures/package-manager-output.ts b/tests/fixtures/package-manager-output.ts deleted file mode 100644 index 390e1cf..0000000 --- a/tests/fixtures/package-manager-output.ts +++ /dev/null @@ -1,257 +0,0 @@ -// Output captured from real failed `install` runs with piped stdio, trimmed, with the -// local home and project paths replaced. The remaining paths, URLs and package names -// are what a user's output contains, which is exactly what must never reach telemetry. - -export type PackageManagerOutputFixture = { - name: string; - command: string; - stdout: string; - stderr: string; - expected: string | undefined; -}; - -const lines = (...parts: string[]) => parts.join("\n"); - -export const packageManagerOutputFixtures: PackageManagerOutputFixture[] = [ - { - name: "npm 11: version does not exist", - command: "npm", - stdout: "", - stderr: lines( - "npm error code ETARGET", - "npm error notarget No matching version found for left-pad@99.99.99.", - "npm error notarget In most cases you or one of your dependencies are requesting a package version that doesn't exist.", - "npm error A complete log of this run can be found in: /Users/jane/.npm/_logs/2026-09-21T10_20_59_996Z-debug-0.log", - ), - expected: "ETARGET", - }, - { - name: "npm 11: package does not exist", - command: "npm", - stdout: "", - stderr: lines( - "npm error code E404", - "npm error 404 Not Found - GET https://registry.npmjs.org/create-prisma-telemetry-probe-does-not-exist - Not found", - "npm error 404", - "npm error 404 The requested resource 'create-prisma-telemetry-probe-does-not-exist@1.0.0' could not be found or you do not have permission to access it.", - "npm error A complete log of this run can be found in: /Users/jane/.npm/_logs/2026-09-21T10_22_26_869Z-debug-0.log", - ), - expected: "E404", - }, - { - name: "npm 11: registry unreachable", - command: "npm", - stdout: "", - stderr: lines( - "npm error code ECONNREFUSED", - "npm error syscall connect", - "npm error errno ECONNREFUSED", - "npm error FetchError: request to http://127.0.0.1:9/left-pad failed, reason: connect ECONNREFUSED 127.0.0.1:9", - "npm error at ClientRequest. (/Users/jane/.local/share/mise/installs/node/26.7.0/lib/node_modules/npm/node_modules/minipass-fetch/lib/index.js:130:14)", - "npm error code: 'ECONNREFUSED',", - "npm error address: '127.0.0.1',", - "npm error If you are behind a proxy, please make sure that the 'proxy' config is set properly. See: 'npm help config'", - ), - expected: "ECONNREFUSED", - }, - { - name: "npm 11: lifecycle script failed with a numeric status", - command: "npm", - stdout: lines("", "> postinstall", "> exit 3", ""), - stderr: lines( - "npm error code 3", - "npm error path /Users/jane/projects/my-app", - "npm error command failed", - "npm error command sh -c exit 3", - ), - expected: undefined, - }, - { - name: "npm 8: legacy error prefix", - command: "npm", - stdout: "", - stderr: lines( - "npm ERR! code ETARGET", - "npm ERR! notarget No matching version found for left-pad@99.99.99.", - "", - "npm ERR! A complete log of this run can be found in:", - "npm ERR! /Users/jane/.npm/_logs/2026-09-21T10_24_12_497Z-debug-0.log", - ), - expected: "ETARGET", - }, - { - name: "pnpm 11: version does not exist", - command: "pnpm", - stdout: lines( - "[ERR_PNPM_NO_MATCHING_VERSION] No matching version found for left-pad@99.99.99 while fetching it from https://registry.npmjs.org/", - "", - "This error happened while installing a direct dependency of /Users/jane/projects/my-app", - "", - 'The latest release of left-pad is "1.3.0".', - ), - stderr: "", - expected: "ERR_PNPM_NO_MATCHING_VERSION", - }, - { - name: "pnpm 11: package does not exist", - command: "pnpm", - stdout: lines( - "[ERR_PNPM_FETCH_404] GET https://registry.npmjs.org/create-prisma-telemetry-probe-does-not-exist: Not Found - 404", - "", - "This error happened while installing a direct dependency of /Users/jane/projects/my-app", - "", - "No authorization header was set for the request.", - ), - stderr: "", - expected: "ERR_PNPM_FETCH_404", - }, - { - name: "pnpm 11: registry unreachable after retry warnings", - command: "pnpm", - stdout: lines( - "[WARN] GET http://127.0.0.1:9/left-pad error (unknown). Will retry in 10 seconds. 2 retries left.", - "[WARN] GET http://127.0.0.1:9/left-pad error (unknown). Will retry in 1 minute. 1 retries left.", - "[ERR_PNPM_META_FETCH_FAIL] GET http://127.0.0.1:9/left-pad: fetch failed", - "", - "This error happened while installing a direct dependency of /Users/jane/projects/my-app", - ), - stderr: "", - expected: "ERR_PNPM_META_FETCH_FAIL", - }, - { - name: "pnpm 11: lifecycle script failed", - command: "pnpm", - stdout: lines("Already up to date", "[ELIFECYCLE] Command failed with exit code 3."), - stderr: "$ exit 3", - expected: "ELIFECYCLE", - }, - { - name: "pnpm 10: legacy thin-space padding", - command: "pnpm", - stdout: lines( - "\u2009ERR_PNPM_NO_MATCHING_VERSION\u2009 No matching version found for left-pad@99.99.99 while fetching it from https://registry.npmjs.org/", - "", - "This error happened while installing a direct dependency of /Users/jane/projects/my-app", - ), - stderr: "", - expected: "ERR_PNPM_NO_MATCHING_VERSION", - }, - { - name: "pnpm 10: forced colour", - command: "pnpm", - stdout: - "\u001b[41m\u001b[30m\u2009ERR_PNPM_NO_MATCHING_VERSION\u2009\u001b[39m\u001b[49m \u001b[31mNo matching version found for left-pad@99.99.99\u001b[39m", - stderr: "", - expected: "ERR_PNPM_NO_MATCHING_VERSION", - }, - { - name: "pnpm 10: lifecycle script failed before a trailing warning", - command: "pnpm", - stdout: lines( - "Already up to date", - "", - "> probe@ postinstall /Users/jane/projects/my-app", - "> exit 3", - "", - "\u2009ELIFECYCLE\u2009 Command failed with exit code 3.", - "\u2009WARN\u2009 Local package.json exists, but node_modules missing, did you mean to install?", - ), - stderr: "", - expected: "ELIFECYCLE", - }, - { - name: "Yarn 4: version does not exist", - command: "yarn", - stdout: lines( - "➤ YN0000: · Yarn 4.18.0", - "➤ YN0000: ┌ Resolution step", - "➤ YN0082: │ left-pad@npm:99.99.99: No candidates found", - "➤ YN0000: └ Completed in 0s 399ms", - "➤ YN0000: · Failed with errors in 0s 403ms", - ), - stderr: "", - expected: "YN0082", - }, - { - name: "Yarn 4: package does not exist", - command: "yarn", - stdout: lines( - "➤ YN0000: · Yarn 4.18.0", - "➤ YN0000: ┌ Resolution step", - "➤ YN0035: │ create-prisma-telemetry-probe-does-not-exist@npm:1.0.0: Package not found", - "➤ YN0035: │ Response Code: 404 (Not Found)", - "➤ YN0035: │ Request URL: https://registry.yarnpkg.com/create-prisma-telemetry-probe-does-not-exist", - "➤ YN0000: └ Completed in 0s 344ms", - "➤ YN0000: · Failed with errors in 0s 348ms", - ), - stderr: "", - expected: "YN0035", - }, - { - name: "Yarn 4: lifecycle script failed after an informational message", - command: "yarn", - stdout: lines( - "➤ YN0000: · Yarn 4.18.0", - "➤ YN0000: ┌ Link step", - "➤ YN0007: │ probe@workspace:. must be built because it never has been before or the last one failed", - "➤ YN0009: │ probe@workspace:. couldn't be built successfully (exit code 3, logs can be found here: /private/var/folders/tg/xrj97k111zx156t1s_wpkxr40000gn/T/xfs-5b34503f/build.log)", - "➤ YN0000: └ Completed", - "➤ YN0000: · Failed with errors in 0s 20ms", - ), - stderr: "", - expected: "YN0009", - }, - { - name: "Yarn 4: registry unreachable", - command: "yarn", - stdout: lines( - "➤ YN0000: · Yarn 4.18.0", - "➤ YN0000: ┌ Resolution step", - "➤ YN0001: │ RequestError: connect ECONNREFUSED 127.0.0.1:9", - " at ClientRequest. (/Users/jane/.yarn/releases/yarn-4.18.0.cjs:148:14258)", - " at TCPConnectWrap.afterConnect [as oncomplete] (node:net:2017:16)", - "➤ YN0000: └ Completed", - "➤ YN0000: · Failed with errors in 0s 17ms", - ), - stderr: "", - expected: "YN0001", - }, - { - name: "Yarn 1: prose only", - command: "yarn", - stdout: lines( - "yarn install v1.22.22", - "info No lockfile found.", - "[1/4] Resolving packages...", - ), - stderr: 'error Couldn\'t find any versions for "left-pad" that matches "99.99.99"', - expected: undefined, - }, - { - name: "Bun 1.4: prose only", - command: "bun", - stdout: "bun install v1.4.0 (34cbb9a40)", - stderr: lines( - "Resolving dependencies", - 'error: No version matching "99.99.99" found for specifier "left-pad" (but package exists)', - "error: left-pad@99.99.99 failed to resolve", - ), - expected: undefined, - }, - { - name: "Deno 2.9: prose only", - command: "deno", - stdout: "", - stderr: lines( - "Download https://registry.npmjs.org/left-pad", - "error: Could not find npm package 'left-pad' matching '99.99.99'.", - ), - expected: undefined, - }, -]; - -export function getPackageManagerOutputFixture(name: string): PackageManagerOutputFixture { - const fixture = packageManagerOutputFixtures.find((candidate) => candidate.name === name); - if (!fixture) throw new Error(`Unknown package manager output fixture: ${name}`); - return fixture; -} diff --git a/tests/package-manager-error-code.test.ts b/tests/package-manager-error-code.test.ts index 283aef9..ee82acf 100644 --- a/tests/package-manager-error-code.test.ts +++ b/tests/package-manager-error-code.test.ts @@ -1,114 +1,64 @@ import { describe, expect, test } from "bun:test"; import { getPackageManagerErrorCode } from "../src/utils/package-manager-error-code"; -import { packageManagerOutputFixtures } from "./fixtures/package-manager-output"; - -// Deliberately restated here so a loosened grammar in the module fails this suite. -const SAFE_IDENTIFIER = /^(?:E[A-Z0-9_]{2,40}|ERR_PNPM_[A-Z0-9_]{1,60}|YN\d{4})$/; - -const secrets = [ - "/Users/jane/projects/my-app", - "C:\\Users\\jane\\my-app", - "https://jane:hunter2@registry.example.com/", - "jane@example.com", - "npm_AbCdEf0123456789", - "@acme/internal-package", - "DATABASE_URL=postgresql://jane:hunter2@db.example.com/app", -]; describe("getPackageManagerErrorCode", () => { - for (const fixture of packageManagerOutputFixtures) { - test(fixture.name, () => { - expect(getPackageManagerErrorCode(fixture.command, fixture)).toBe(fixture.expected); - }); - } - - test("resolves the manager from a Windows shim path", () => { - expect( - getPackageManagerErrorCode("C:\\Program Files\\nodejs\\npm.cmd", { - stdout: "", - stderr: "npm error code E404\r\nnpm error 404 Not Found\r\n", - }), - ).toBe("E404"); - }); - - test("ignores identifiers printed by a command that is not a known package manager", () => { - for (const command of ["node", "bun", "deno", "prisma", "npmx", "__proto__", "constructor"]) { - expect( - getPackageManagerErrorCode(command, { - stdout: "[ERR_PNPM_FETCH_404] nope\n➤ YN0035: │ nope", - stderr: "npm error code E404", - }), - ).toBeUndefined(); - } - }); - - test("reads another manager's format as prose", () => { - expect( - getPackageManagerErrorCode("npm", { stdout: "[ERR_PNPM_FETCH_404] nope", stderr: "" }), - ).toBeUndefined(); - expect( - getPackageManagerErrorCode("yarn", { stdout: "", stderr: "npm error code E404" }), - ).toBeUndefined(); - }); - - test("ignores an identifier relayed from a nested package manager", () => { - expect( - getPackageManagerErrorCode("npm", { - stdout: "", - stderr: "npm error code 1\nnpm error npm error code E404\nnpm error code: 'E404',", - }), - ).toBeUndefined(); + test.each([ + [ + "npm", + "npm error code ETARGET\nnpm error notarget No matching version found for left-pad@99.99.99.", + "ETARGET", + ], + [ + "npm", + "npm ERR! code ETARGET\nnpm ERR! notarget No matching version found for left-pad@99.99.99.", + "ETARGET", + ], + [ + "npm", + "npm error code 3\nnpm error path /Users/jane/my-app\nnpm error command failed", + undefined, + ], + [ + "pnpm", + "[ERR_PNPM_FETCH_404] GET https://registry.npmjs.org/nope: Not Found - 404", + "ERR_PNPM_FETCH_404", + ], + [ + "pnpm", + "\u2009ERR_PNPM_NO_MATCHING_VERSION\u2009 No matching version found for left-pad@99.99.99", + "ERR_PNPM_NO_MATCHING_VERSION", + ], + [ + "yarn", + "➤ YN0000: ┌ Resolution step\n➤ YN0082: │ left-pad@npm:99.99.99: No candidates found\n➤ YN0000: · Failed with errors in 0s 403ms", + "YN0082", + ], + [ + "bun", + 'error: No version matching "99.99.99" found for specifier "left-pad" (but package exists)', + undefined, + ], + ])("%s: %s", (command, output, expected) => { + expect(getPackageManagerErrorCode(command, { stdout: output, stderr: "" })).toBe(expected); + expect(getPackageManagerErrorCode(command, { stdout: "", stderr: output })).toBe(expected); }); test("never returns free text from the identifier position", () => { - const cases = secrets.flatMap((secret) => [ - { command: "npm", stdout: "", stderr: `npm error code ${secret}` }, - { command: "npm", stdout: "", stderr: `npm error code E404 ${secret}` }, - { command: "npm", stdout: "", stderr: `npm ERR! code E${secret}` }, - { command: "pnpm", stdout: `[${secret}] failed`, stderr: "" }, - { command: "pnpm", stdout: `[ERR_PNPM_${secret}] failed`, stderr: "" }, - { command: "pnpm", stdout: `\u2009${secret}\u2009 failed`, stderr: "" }, - { command: "yarn", stdout: `➤ ${secret}: │ failed`, stderr: "" }, - { command: "yarn", stdout: `➤ YN0001${secret}: │ failed`, stderr: "" }, - ]); - for (const output of cases) { - expect(getPackageManagerErrorCode(output.command, output)).toBeUndefined(); - } - }); - - test("rejects identifiers outside each grammar", () => { - const cases = [ - { command: "npm", stdout: "", stderr: "npm error code e404" }, - { command: "npm", stdout: "", stderr: "npm error code 1" }, - { command: "npm", stdout: "", stderr: `npm error code E${"A".repeat(41)}` }, - { command: "npm", stdout: "", stderr: " npm error code E404" }, - { command: "pnpm", stdout: "[WARN] deprecated left-pad@1.3.0", stderr: "" }, - { command: "pnpm", stdout: `[ERR_PNPM_${"A".repeat(61)}] failed`, stderr: "" }, - { command: "pnpm", stdout: "see [ERR_PNPM_FETCH_404] in the docs", stderr: "" }, - { command: "yarn", stdout: "➤ YN0000: · Failed with errors in 0s 403ms", stderr: "" }, - { command: "yarn", stdout: "➤ YN00821: │ failed", stderr: "" }, - ]; - for (const output of cases) { - expect(getPackageManagerErrorCode(output.command, output)).toBeUndefined(); - } - }); - - test("returns nothing or a grammar-conforming identifier when output is full of secrets", () => { - for (const fixture of packageManagerOutputFixtures) { - const noise = secrets.join("\n"); - const code = getPackageManagerErrorCode(fixture.command, { - stdout: `${noise}\n${fixture.stdout}\n${noise}`, - stderr: `${noise}\n${fixture.stderr}\n${noise}`, - }); - expect(code).toBe(fixture.expected); - if (code !== undefined) expect(code).toMatch(SAFE_IDENTIFIER); - } - }); - - test("returns nothing when verbose installs inherit stdio and capture no output", () => { - for (const command of ["npm", "pnpm", "yarn", "bun", "deno"]) { - expect(getPackageManagerErrorCode(command, { stdout: "", stderr: "" })).toBeUndefined(); + for (const secret of [ + "/Users/jane/my-app", + "https://jane:hunter2@registry.example.com/", + "npm_AbCdEf0123456789", + ]) { + for (const [command, output] of [ + ["npm", `npm error code ${secret}`], + ["pnpm", `[${secret}] failed`], + ["yarn", `➤ ${secret}: │ failed`], + ] as const) { + expect( + getPackageManagerErrorCode(command, { stdout: output, stderr: output }), + ).toBeUndefined(); + } } }); }); diff --git a/tests/telemetry.test.ts b/tests/telemetry.test.ts index c5b771b..5455c3a 100644 --- a/tests/telemetry.test.ts +++ b/tests/telemetry.test.ts @@ -1,20 +1,15 @@ import { beforeEach, describe, expect, mock, test } from "bun:test"; +import { NodeFileSystem } from "@effect/platform-node-shared"; import { Effect } from "effect"; +import { existsSync, writeFileSync } from "node:fs"; +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 { runComposerDeployEffect } from "../src/tasks/composer/deploy-report"; import { runPrismaJsonCommandEffect } from "../src/tasks/prisma-cli"; import { getChildProcessFailure } from "../src/utils/child-process-failure"; -import { - childProcessFailedResult, - composerRunReport, - runFakeComposerDeploy, -} from "./fixtures/composer-deploy"; -import { - getPackageManagerOutputFixture, - packageManagerOutputFixtures, -} from "./fixtures/package-manager-output"; const trackCliTelemetry = mock(async () => {}); @@ -54,6 +49,40 @@ const createContext: CreatePromptContext = { beforeEach(() => trackCliTelemetry.mockClear()); +async function failComposerDeploy(report?: string) { + let reportPath = ""; + const run = (spec: { args: readonly string[] }) => + Effect.sync(() => { + reportPath = spec.args[spec.args.indexOf("--report") + 1]!; + if (report !== undefined) writeFileSync(reportPath, report); + return { + exitCode: 1, + stdout: '{"ok":false,"commandId":"deploy","error":{"code":"CLI.CHILD_PROCESS_FAILED"}}', + stderr: "", + }; + }); + const error = await Effect.runPromise( + runComposerDeployEffect({ packageManager: "npm", projectDir: process.cwd() }).pipe( + Effect.provideService(CommandRunner, { run, runChecked: run }), + Effect.provide(NodeFileSystem.layer), + Effect.flip, + ), + ); + return { error, reportPath }; +} + +async function trackFailure(error: unknown, stage: "install_dependencies" | "composer_deploy") { + await trackCreateFailed({ + input: createInput, + context: createContext, + durationMs: 10, + error, + stage, + reason: stage === "composer_deploy" ? "composer_deploy_failed" : "dependency_install_failed", + }); + return (trackCliTelemetry.mock.calls.at(-1) as unknown as [string, Record])[1]; +} + describe("create telemetry", () => { test("tracks Composer deployment intent on completion", async () => { await trackCreateCompleted({ input: createInput, context: createContext, durationMs: 123 }); @@ -249,105 +278,42 @@ describe("create telemetry", () => { } }); - test("tracks the package manager's own error identifier for install failures", async () => { - for (const fixture of packageManagerOutputFixtures) { - await trackCreateFailed({ - input: createInput, - context: createContext, - durationMs: 10, - error: new CommandExecutionError({ - command: fixture.command, - args: ["install"], - exitCode: 1, - stdout: fixture.stdout, - stderr: fixture.stderr, - childProcessFailure: "non_zero_exit", - }), - stage: "install_dependencies", - reason: "dependency_install_failed", - }); - - const [, properties] = trackCliTelemetry.mock.calls.at(-1) as [ - string, - Record, - ]; - expect(properties["package-manager-error-code"]).toBe(fixture.expected ?? null); - expect(JSON.stringify(properties)).not.toMatch(/jane|left-pad|registry|127\.0\.0\.1/); - } - }); - - test("reports a package manager identifier only for the install stage", async () => { - const fixture = getPackageManagerOutputFixture("pnpm 11: lifecycle script failed"); - await trackCreateFailed({ - input: createInput, - context: createContext, - durationMs: 10, - error: new CommandExecutionError({ - command: fixture.command, - args: ["run", "build"], + test("tracks the package manager's error code for install failures", async () => { + const properties = await trackFailure( + new CommandExecutionError({ + command: "npm", + args: ["install"], exitCode: 1, - stdout: fixture.stdout, - stderr: fixture.stderr, + stdout: "", + stderr: + "npm error code E404\nnpm error 404 Not Found - GET https://registry.npmjs.org/nope", }), - stage: "build", - reason: "build_failed", - }); - const [, properties] = trackCliTelemetry.mock.calls[0] as [string, Record]; - expect(properties["package-manager-error-code"]).toBeNull(); + "install_dependencies", + ); + expect(properties["package-manager-error-code"]).toBe("E404"); + expect(JSON.stringify(properties)).not.toContain("registry"); }); - test("tracks Composer's failure code behind a generic Prisma CLI failure", async () => { - const { error } = await runFakeComposerDeploy({ - result: childProcessFailedResult, - report: composerRunReport({ - code: "DEPLOY.ENGINE_FAILED", - message: "alchemy failed in /Users/jane/projects/my-app", - }), - }); - await trackCreateFailed({ - input: createInput, - context: createContext, - durationMs: 10, - error, - stage: "composer_deploy", - reason: "composer_deploy_failed", - }); - const [, properties] = trackCliTelemetry.mock.calls[0] as [string, Record]; - expect(properties).toEqual( - expect.objectContaining({ - "prisma-cli-command": "deploy", - "prisma-cli-error-code": "CLI.CHILD_PROCESS_FAILED", - "prisma-cli-cause-code": "DEPLOY.ENGINE_FAILED", - "package-manager-error-code": null, + test("tracks Composer's failure code from the deploy report", async () => { + const { error, reportPath } = await failComposerDeploy( + JSON.stringify({ + version: 1, + failure: { code: "DEPLOY.ENGINE_FAILED", message: "failed in /Users/jane/my-app" }, }), ); + const properties = await trackFailure(error, "composer_deploy"); + expect(properties["prisma-cli-error-code"]).toBe("CLI.CHILD_PROCESS_FAILED"); + expect(properties["prisma-cli-cause-code"]).toBe("DEPLOY.ENGINE_FAILED"); expect(JSON.stringify(properties)).not.toContain("jane"); + expect(existsSync(path.dirname(reportPath))).toBe(false); }); - test("omits the cause code when the run report is missing or unstructured", async () => { - const { error: withoutReport } = await runFakeComposerDeploy({ - result: childProcessFailedResult, - }); - for (const error of [ - withoutReport, - Object.assign(new Error("failed"), { - prismaCliCauseCode: "failed in /Users/jane/projects/my-app", - }), - ]) { - await trackCreateFailed({ - input: createInput, - context: createContext, - durationMs: 10, - error, - stage: "composer_deploy", - reason: "composer_deploy_failed", - }); - const [, properties] = trackCliTelemetry.mock.calls.at(-1) as [ - string, - Record, - ]; - expect(properties["prisma-cli-cause-code"]).toBeNull(); - expect(JSON.stringify(properties)).not.toContain("jane"); + test("keeps the original deploy error when the report is missing or invalid", async () => { + for (const report of [undefined, "not json"]) { + const { error } = await failComposerDeploy(report); + expect(error).toMatchObject({ code: "CLI.CHILD_PROCESS_FAILED", exitCode: 1 }); + expect(error).not.toHaveProperty("causeCode"); + expect((await trackFailure(error, "composer_deploy"))["prisma-cli-cause-code"]).toBeNull(); } }); From 8cafe84dcf6c6b665c258dfd4d396c598455b12f Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Mon, 21 Sep 2026 16:34:02 +0530 Subject: [PATCH 4/5] 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 5/5] 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.`,