diff --git a/src/create-outcome.ts b/src/create-outcome.ts index fb6e65b..05b67ba 100644 --- a/src/create-outcome.ts +++ b/src/create-outcome.ts @@ -1,5 +1,7 @@ import { Schema } from "effect"; +import { ChildProcessFailureSchema } from "./utils/child-process-failure"; + export const CreateFailureStageSchema = Schema.Literals([ "validate_input", "collect_context", @@ -91,6 +93,7 @@ export class PrismaCliCommandError extends Schema.TaggedError()( @@ -26,6 +33,7 @@ export class CommandExecutionError extends Schema.TaggedError @@ -90,6 +99,7 @@ export class CommandRunner extends Context.Service< stdout: "", stderr: "", cause, + childProcessFailure: getChildProcessFailure(cause), }), }), ); @@ -104,6 +114,7 @@ export class CommandRunner extends Context.Service< exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr, + childProcessFailure: result.childProcessFailure, }), ), ), diff --git a/src/tasks/prisma-cli.ts b/src/tasks/prisma-cli.ts index 4f9afb3..1c69801 100644 --- a/src/tasks/prisma-cli.ts +++ b/src/tasks/prisma-cli.ts @@ -73,6 +73,7 @@ export const runPrismaJsonCommandEffect = Effect.fn("PrismaCli.runJson")(functio redactSecrets(result.stderr.trim() || result.stdout.trim()) || getErrorMessage(cause), stderr: redactSecrets(result.stderr), exitCode: result.exitCode, + childProcessFailure: result.childProcessFailure, }); } @@ -90,6 +91,7 @@ export const runPrismaJsonCommandEffect = Effect.fn("PrismaCli.runJson")(functio ...(envelope.error?.code ? { code: envelope.error.code } : {}), stderr: redactSecrets(result.stderr), exitCode: result.exitCode, + childProcessFailure: result.childProcessFailure, }); } return envelope.result; diff --git a/src/telemetry/create.ts b/src/telemetry/create.ts index a0e3296..8237702 100644 --- a/src/telemetry/create.ts +++ b/src/telemetry/create.ts @@ -1,13 +1,15 @@ import { Effect } from "effect"; import type { CreatePromptContext } from "../commands/create"; -import type { - CreateCancellationStage, - CreateFailureReason, - CreateFailureStage, +import { + PrismaCliCommandError, + type CreateCancellationStage, + type CreateFailureReason, + type CreateFailureStage, } from "../create-outcome"; import type { CreateCommandInput } from "../types"; import { applicationRuntime } from "../runtime"; +import { CommandExecutionError } from "../services/command-runner"; import { TELEMETRY_TIMEOUT_MS, trackCliTelemetryEffect } from "./client"; @@ -91,6 +93,12 @@ function getErrorCode(error: unknown): number | string | null { return typeof code === "number" || typeof code === "string" ? code : null; } +function getChildProcessFailureProperty(error: unknown): string | null { + return error instanceof CommandExecutionError || error instanceof PrismaCliCommandError + ? (error.childProcessFailure ?? null) + : null; +} + function getPrismaCliFailureProperty( error: unknown, property: "prismaCliCommand" | "prismaCliErrorCode", @@ -136,6 +144,7 @@ export const trackCreateFailedEffect = Effect.fn("Telemetry.createFailed")(funct "failure-reason": params.reason, "error-name": getErrorName(params.error), "error-code": getErrorCode(params.error), + "child-process-failure": getChildProcessFailureProperty(params.error), "prisma-cli-command": getPrismaCliFailureProperty(params.error, "prismaCliCommand"), "prisma-cli-error-code": getPrismaCliFailureProperty(params.error, "prismaCliErrorCode"), }).pipe( diff --git a/src/utils/child-process-failure.ts b/src/utils/child-process-failure.ts new file mode 100644 index 0000000..cd4ddd0 --- /dev/null +++ b/src/utils/child-process-failure.ts @@ -0,0 +1,40 @@ +import { Schema } from "effect"; + +export const ChildProcessFailureSchema = Schema.Literals([ + "cancelled", + "command_not_found", + "interrupted", + "max_buffer", + "non_zero_exit", + "permission_denied", + "spawn_failed", + "terminated", + "timed_out", +]); +export type ChildProcessFailure = typeof ChildProcessFailureSchema.Type; + +const WINDOWS_CONTROL_C_EXIT_CODE = 0xc000013a; + +export function getChildProcessFailure(error: unknown): ChildProcessFailure | undefined { + if (typeof error !== "object" || error === null) return undefined; + if (Reflect.get(error, "name") !== "ExecaError" || Reflect.get(error, "failed") !== true) { + return undefined; + } + + if (Reflect.get(error, "timedOut") === true) return "timed_out"; + if (Reflect.get(error, "isCanceled") === true) return "cancelled"; + if (Reflect.get(error, "isMaxBuffer") === true) return "max_buffer"; + + const exitCode = Reflect.get(error, "exitCode"); + const signal = Reflect.get(error, "signal"); + if (signal === "SIGINT" || exitCode === 130 || exitCode === WINDOWS_CONTROL_C_EXIT_CODE) { + return "interrupted"; + } + if (Reflect.get(error, "isTerminated") === true) return "terminated"; + + const code = Reflect.get(error, "code"); + if (code === "ENOENT") return "command_not_found"; + if (code === "EACCES" || code === "EPERM") return "permission_denied"; + if (typeof exitCode === "number") return "non_zero_exit"; + return "spawn_failed"; +} diff --git a/tests/e2e/create-prisma.e2e.test.ts b/tests/e2e/create-prisma.e2e.test.ts index 2237017..02c40e1 100644 --- a/tests/e2e/create-prisma.e2e.test.ts +++ b/tests/e2e/create-prisma.e2e.test.ts @@ -286,9 +286,9 @@ describe("create-prisma e2e", () => { ok: false, error: { stage: "collect_context", message: expect.stringContaining("migration history") }, }); - expect(await readdir(path.join(projectDir, "migrations"), { recursive: true })).toEqual( - migrationPaths, - ); + expect( + (await readdir(path.join(projectDir, "migrations"), { recursive: true })).sort(), + ).toEqual(migrationPaths.toSorted()); expect( await Promise.all( preservedPaths.map((filePath) => readFile(path.join(projectDir, filePath), "utf8")), @@ -298,7 +298,7 @@ describe("create-prisma e2e", () => { TEST_TIMEOUT, ); - test("returns a non-zero exit code when project setup fails", async () => { + test("returns a non-zero exit code before scaffolding when npm is unavailable", async () => { const rootDir = await mkdtemp(path.join(tmpdir(), "create-prisma-exit-code-e2e-")); tempRoots.push(rootDir); const emptyBinDir = path.join(rootDir, "empty-bin"); @@ -346,7 +346,7 @@ describe("create-prisma e2e", () => { error: { stage: "install_dependencies" }, }); expect(stderr).toBe(""); - expect(await pathExists(path.join(rootDir, "failed-app", "package.json"))).toBe(true); + expect(await pathExists(path.join(rootDir, "failed-app"))).toBe(false); }); test("rejects unsupported Deno combinations with a non-zero exit code", async () => { diff --git a/tests/telemetry.test.ts b/tests/telemetry.test.ts index 7c6fe92..6cba873 100644 --- a/tests/telemetry.test.ts +++ b/tests/telemetry.test.ts @@ -3,10 +3,14 @@ import { Effect } from "effect"; 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"; const trackCliTelemetry = mock(async () => {}); mock.module("../src/telemetry/client", () => ({ + TELEMETRY_TIMEOUT_MS: 2_000, trackCliTelemetryEffect: (event: string, properties: Record) => Effect.promise(() => trackCliTelemetry(event, properties)), })); @@ -134,6 +138,108 @@ describe("create telemetry", () => { expect(JSON.stringify(properties)).not.toContain("secret"); }); + test("classifies child-process failures without capturing command output", async () => { + const cases = [ + [{ timedOut: true }, "timed_out"], + [{ isCanceled: true }, "cancelled"], + [{ isMaxBuffer: true }, "max_buffer"], + [{ signal: "SIGINT", isTerminated: true }, "interrupted"], + [{ exitCode: 0xc000013a }, "interrupted"], + [{ signal: "SIGTERM", isTerminated: true }, "terminated"], + [{ code: "ENOENT" }, "command_not_found"], + [{ code: "EACCES" }, "permission_denied"], + [{ exitCode: 1 }, "non_zero_exit"], + [{ code: "UNKNOWN" }, "spawn_failed"], + ] as const; + + for (const [details, expectedFailure] of cases) { + await trackCreateFailed({ + input: createInput, + context: createContext, + durationMs: 10, + error: new CommandExecutionError({ + command: "secret-command", + args: ["secret-argument"], + stdout: "token=secret", + stderr: "token=secret", + childProcessFailure: getChildProcessFailure({ + name: "ExecaError", + failed: true, + ...details, + }), + }), + stage: "install_dependencies", + reason: "dependency_install_failed", + }); + + const [, properties] = trackCliTelemetry.mock.calls.at(-1) as [ + string, + Record, + ]; + expect(properties["child-process-failure"]).toBe(expectedFailure); + expect(JSON.stringify(properties)).not.toContain("secret"); + } + }); + + 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" }, + { command: process.execPath, args: ["-e", "process.exit(130)"], failure: "interrupted" }, + { + command: process.execPath, + args: ["-e", "console.error('token=secret'); process.exit(2)"], + failure: "non_zero_exit", + }, + { + command: process.execPath, + args: [ + "-e", + `console.log(JSON.stringify({ok: false, error: {code: "AUTH.LOGIN_DENIED", summary: "Sign-in was not authorized."}})); process.exit(1)`, + ], + failure: "non_zero_exit", + }, + ]; + + for (const spec of cases) { + for (const json of [false, true]) { + const error = await Effect.runPromise( + Effect.gen(function* () { + const runner = yield* CommandRunner; + const command = { command: spec.command, args: spec.args, cwd: process.cwd() }; + return yield* json + ? runPrismaJsonCommandEffect({ + packageManager: "npm", + projectDir: process.cwd(), + args: ["init"], + }).pipe( + Effect.provideService(CommandRunner, { + ...runner, + run: () => runner.run(command), + }), + ) + : runner.runChecked(command); + }).pipe(Effect.provide(CommandRunner.layer), Effect.flip), + ); + await trackCreateFailed({ + input: createInput, + durationMs: 10, + error, + stage: json ? "initialize_prisma" : "install_dependencies", + reason: json ? "prisma_init_failed" : "dependency_install_failed", + }); + const [, properties] = trackCliTelemetry.mock.calls.at(-1) as [ + string, + Record, + ]; + expect(properties["child-process-failure"]).toBe(spec.failure); + expect(properties["error-name"]).toBe( + json ? "PrismaCliCommandError" : "CommandExecutionError", + ); + expect(JSON.stringify(properties)).not.toContain("secret"); + } + } + }); + test("tracks prompt cancellation as a separate outcome", async () => { await trackCreateCancelled({ input: createInput,