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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/create-outcome.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { Schema } from "effect";

import { ChildProcessFailureSchema } from "./utils/child-process-failure";

export const CreateFailureStageSchema = Schema.Literals([
"validate_input",
"collect_context",
Expand Down Expand Up @@ -91,6 +93,7 @@ export class PrismaCliCommandError extends Schema.TaggedError<PrismaCliCommandEr
code: Schema.optionalKey(Schema.String),
stderr: Schema.optionalKey(Schema.String),
exitCode: Schema.optionalKey(Schema.Number),
childProcessFailure: Schema.optional(ChildProcessFailureSchema),
},
) {
readonly prismaCliCommand = this.command;
Expand Down
11 changes: 11 additions & 0 deletions src/services/command-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@ import { Context, Effect, Layer, Schema } from "effect";
import { execa } from "execa";
import { createInterface } from "node:readline";

import {
ChildProcessFailureSchema,
getChildProcessFailure,
type ChildProcessFailure,
} from "../utils/child-process-failure";

export type CommandSpec = {
command: string;
args: readonly string[];
Expand All @@ -15,6 +21,7 @@ export type CommandResult = {
exitCode: number;
stdout: string;
stderr: string;
childProcessFailure?: ChildProcessFailure;
};

export class CommandExecutionError extends Schema.TaggedError<CommandExecutionError>()(
Expand All @@ -26,6 +33,7 @@ export class CommandExecutionError extends Schema.TaggedError<CommandExecutionEr
stdout: Schema.String,
stderr: Schema.String,
cause: Schema.optionalKey(Schema.Defect()),
childProcessFailure: Schema.optional(ChildProcessFailureSchema),
},
) {
override get message(): string {
Expand Down Expand Up @@ -81,6 +89,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),
};
},
catch: (cause) =>
Expand All @@ -90,6 +99,7 @@ export class CommandRunner extends Context.Service<
stdout: "",
stderr: "",
cause,
childProcessFailure: getChildProcessFailure(cause),
}),
}),
);
Expand All @@ -104,6 +114,7 @@ export class CommandRunner extends Context.Service<
exitCode: result.exitCode,
stdout: result.stdout,
stderr: result.stderr,
childProcessFailure: result.childProcessFailure,
}),
),
),
Expand Down
2 changes: 2 additions & 0 deletions src/tasks/prisma-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
}

Expand All @@ -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;
Expand Down
17 changes: 13 additions & 4 deletions src/telemetry/create.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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(
Expand Down
40 changes: 40 additions & 0 deletions src/utils/child-process-failure.ts
Original file line number Diff line number Diff line change
@@ -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";
}
10 changes: 5 additions & 5 deletions tests/e2e/create-prisma.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")),
Expand All @@ -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");
Expand Down Expand Up @@ -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 () => {
Expand Down
106 changes: 106 additions & 0 deletions tests/telemetry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) =>
Effect.promise(() => trackCliTelemetry(event, properties)),
}));
Expand Down Expand Up @@ -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<string, unknown>,
];
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<string, unknown>,
];
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,
Expand Down
Loading