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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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`.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
9 changes: 9 additions & 0 deletions src/create-outcome.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,19 +88,28 @@ export class CreateFailure extends Schema.TaggedError<CreateFailure>()("CreateFa
errorReported: Schema.optionalKey(Schema.Boolean),
}) {}

const STRUCTURED_ERROR_CODE_PATTERN = /^[A-Z][A-Z0-9_]*(\.[A-Z0-9_]+)+$/;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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>()(
"PrismaCliCommandError",
{
message: Schema.String,
command: Schema.optionalKey(Schema.String),
code: Schema.optionalKey(Schema.String),
// The delegated tool's own failure code.
causeCode: Schema.optionalKey(Schema.String),
stderr: Schema.optionalKey(Schema.String),
exitCode: Schema.optionalKey(Schema.Number),
childProcessFailure: Schema.optional(ChildProcessFailureSchema),
},
) {
readonly prismaCliCommand = this.command;
readonly prismaCliErrorCode = this.code;
readonly prismaCliCauseCode = this.causeCode;
}

export function isCreateFailure(error: unknown): error is CreateFailure {
Expand Down
73 changes: 73 additions & 0 deletions src/tasks/composer/deploy-report.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { Effect, FileSystem, Option, Schema } from "effect";
import path from "node:path";

import { isStructuredErrorCode, PrismaCliCommandError } from "../../create-outcome";
import type { PackageManager } from "../../types";
import { runPrismaJsonCommandEffect } from "../prisma-cli";

// Only `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;
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, 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,
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,
);
6 changes: 3 additions & 3 deletions src/tasks/deploy-with-composer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,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 =
Expand Down Expand Up @@ -150,10 +151,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`);
Expand Down
20 changes: 19 additions & 1 deletion src/telemetry/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Effect } from "effect";

import type { CreatePromptContext } from "../commands/create";
import {
isStructuredErrorCode,
PrismaCliCommandError,
type CreateCancellationStage,
type CreateFailureReason,
Expand All @@ -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";

Expand Down Expand Up @@ -100,9 +102,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;
Expand All @@ -112,6 +120,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;
Expand Down Expand Up @@ -148,6 +161,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),
Expand Down
46 changes: 46 additions & 0 deletions src/utils/package-manager-error-code.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { stripVTControlCharacters } from "node:util";

type ErrorCodeGrammar = { line: RegExp; allow: RegExp };

const grammars: Record<string, ErrorCodeGrammar> = {
// npm error code E404 (npm <= 9: npm ERR! code E404)
npm: {
line: /^npm (?:error|ERR!) code (\S+)$/,
allow: /^E[A-Z0-9_]{2,40}$/,
},
// [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})$/,
},
// ➤ YN0035: ...; 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;
}

// 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 },
): string | undefined {
const grammar = getGrammar(command);
if (!grammar) return undefined;

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;
}
64 changes: 64 additions & 0 deletions tests/package-manager-error-code.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { describe, expect, test } from "bun:test";

import { getPackageManagerErrorCode } from "../src/utils/package-manager-error-code";

describe("getPackageManagerErrorCode", () => {
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", () => {
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();
}
}
});
});
Loading
Loading