diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 3962500..c2e6756 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -21,8 +21,47 @@ concurrency: cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: + windows-smoke: + name: Windows creation smoke + if: >- + github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name == github.repository && + (github.event.action != 'labeled' || github.event.label.name == 'release:next') + runs-on: windows-latest + timeout-minutes: 20 + permissions: + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: "22.18.0" + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Scaffold and build a Prisma app + env: + CREATE_PRISMA_E2E_TIMEOUT_MS: "600000" + run: >- + bun test --timeout 600000 + --test-name-pattern "builds a Next.js app with a TypeScript-authored contract" + ./tests/e2e/create-prisma.e2e.test.ts + preview: name: Publish PR preview + needs: windows-smoke if: >- github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && diff --git a/README.md b/README.md index e82633a..1abe565 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ yarn dlx create-prisma@latest my-app bunx create-prisma@latest my-app ``` -The CLI initializes Prisma 8 with `prisma@next`, installs dependencies, emits the contract, and generates a deployable Composer app. PostgreSQL projects use Composer's native Prisma Postgres provider, including migrations and a typed runtime client. +The CLI initializes Prisma 8 with `prisma@latest`, installs dependencies, emits the contract, and generates a deployable Composer app. PostgreSQL projects use Composer's native Prisma Postgres provider, including migrations and a typed runtime client. The deployment prompt is: diff --git a/src/constants/dependencies.ts b/src/constants/dependencies.ts index df0d6fd..1550b20 100644 --- a/src/constants/dependencies.ts +++ b/src/constants/dependencies.ts @@ -18,7 +18,7 @@ export const dependencyVersionMap = { mongodb: "^7.1.0", "mongodb-memory-server": "^11.1.0", nitro: "^3.0.260610-beta", - prisma: "8.0.0-rc.12", + prisma: "latest", // The ORM runtime's timestamp columns need a global Temporal, which no // stable Node or Bun ships yet. "temporal-polyfill": "^1.0.4", @@ -26,17 +26,11 @@ export const dependencyVersionMap = { typescript: "^5.9.3", } as const; -// Pinned, not `prisma@next`: the scaffold's own invocations must not float -// with the dist-tag — the rc line ships breaking changes between releases -// (rc.10 broke every create). The pin must move in lockstep with the pins -// above: the CLI bundles its own copies of @prisma/composer-cli and -// @prisma/orm-toolchain, and those must match the @prisma/composer* and -// @prisma/orm-* versions this map installs into the project. -export const PRISMA_PLATFORM_CLI_PACKAGE = "prisma@8.0.0-rc.12"; -// Deno runs the same pinned consolidated CLI. The former `prisma-next` -// fallback is dead: under Deno the bare `npm:prisma-next` specifier resolves -// to the highest non-prerelease version (0.12.0, frozen), which cannot emit -// against the ORM releases pinned above. +// `prisma@next` is a compatibility tag that may intentionally lag behind the +// current release. New scaffolds and every delegated command use `latest`. +export const PRISMA_PLATFORM_CLI_PACKAGE = "prisma@latest"; +// Deno runs the same consolidated CLI. The former `prisma-next` fallback is +// frozen and cannot emit against the current ORM releases. export const PRISMA_DENO_CLI_PACKAGE = PRISMA_PLATFORM_CLI_PACKAGE; export type AvailableDependency = keyof typeof dependencyVersionMap; diff --git a/src/tasks/deploy-with-composer.ts b/src/tasks/deploy-with-composer.ts index c08a438..8e8bcb3 100644 --- a/src/tasks/deploy-with-composer.ts +++ b/src/tasks/deploy-with-composer.ts @@ -22,10 +22,24 @@ import { runSetupCommand } from "../utils/run-command"; type PrismaCliEnvelope = { ok: boolean; + command?: string; + commandId?: string; result?: Result; - error?: { summary?: string; message?: string; why?: string }; + error?: { code?: string; summary?: string; message?: string; why?: string }; }; +export class PrismaCliCommandError extends Error { + readonly prismaCliCommand?: string; + readonly prismaCliErrorCode?: string; + + constructor(options: { message: string; command?: string; code?: string }) { + super(options.message); + this.name = "PrismaCliCommandError"; + this.prismaCliCommand = options.command; + this.prismaCliErrorCode = options.code; + } +} + type PrismaWorkspace = { id: string; name: string | null; @@ -175,11 +189,16 @@ async function runPrismaJsonCommand(options: { if (result.exitCode !== 0 || !envelope.ok || envelope.result === undefined) { const summary = envelope.error?.summary ?? envelope.error?.message; - throw new Error( - [summary, envelope.error?.why].filter(Boolean).join(": ") || + throw new PrismaCliCommandError({ + message: + [summary, envelope.error?.why].filter(Boolean).join(": ") || result.stderr.trim() || "Prisma CLI command failed.", - ); + ...(envelope.commandId || envelope.command + ? { command: envelope.commandId ?? envelope.command } + : {}), + ...(envelope.error?.code ? { code: envelope.error.code } : {}), + }); } return envelope.result; } diff --git a/src/telemetry/create.ts b/src/telemetry/create.ts index f8d8c9d..53f2900 100644 --- a/src/telemetry/create.ts +++ b/src/telemetry/create.ts @@ -14,6 +14,23 @@ export const CREATE_PRISMA_NEXT_CANCELLED_EVENT = "cli:create_prisma_next_comman export type CreateTelemetryFailureStage = CreateFailureStage; +const expectedRejectionReasons = new Set([ + "invalid_input", + "unsupported_node_version", + "invalid_project_name", + "target_path_not_directory", + "target_directory_not_empty", + "unsupported_configuration", + "not_authenticated", + "workspace_missing", + "workspace_mismatch", + "project_name_collision", +]); + +function getFailureClass(reason: CreateFailureReason): "expected_rejection" | "technical_failure" { + return expectedRejectionReasons.has(reason) ? "expected_rejection" : "technical_failure"; +} + function getTargetDirectoryState(context: CreatePromptContext): string { if (!context.targetPathState.exists) { return "new"; @@ -68,6 +85,18 @@ function getErrorCode(error: unknown): number | string | null { return typeof code === "number" || typeof code === "string" ? code : null; } +function getPrismaCliFailureProperty( + error: unknown, + property: "prismaCliCommand" | "prismaCliErrorCode", +): string | null { + if (typeof error !== "object" || error === null) { + return null; + } + + const value = Reflect.get(error, property); + return typeof value === "string" && value.length > 0 ? value : null; +} + export async function trackCreateCompleted(params: { input: CreateCommandInput; context: CreatePromptContext; @@ -90,10 +119,13 @@ export async function trackCreateFailed(params: { await trackCliTelemetry(CREATE_PRISMA_NEXT_FAILED_EVENT, { ...getBaseCreateProperties(params.input, params.context), "duration-ms": params.durationMs, + "failure-class": getFailureClass(params.reason), "failure-stage": params.stage, "failure-reason": params.reason, "error-name": getErrorName(params.error), "error-code": getErrorCode(params.error), + "prisma-cli-command": getPrismaCliFailureProperty(params.error, "prismaCliCommand"), + "prisma-cli-error-code": getPrismaCliFailureProperty(params.error, "prismaCliErrorCode"), }); } diff --git a/src/utils/node-version.ts b/src/utils/node-version.ts index c8ffb55..9515664 100644 --- a/src/utils/node-version.ts +++ b/src/utils/node-version.ts @@ -16,7 +16,7 @@ export function supportsPrisma(nodeVersion = process.versions.node): boolean { export function getUnsupportedNodeMessage(nodeVersion = process.versions.node): string { return [ - `Node.js ${nodeVersion} is unsupported by create-prisma@next.`, + `Node.js ${nodeVersion} is unsupported by create-prisma@latest.`, "Required: Node.js 22.18 or newer.", "Update Node.js and run the command again.", ].join("\n"); diff --git a/tests/dependencies.test.ts b/tests/dependencies.test.ts index f6f021a..f647472 100644 --- a/tests/dependencies.test.ts +++ b/tests/dependencies.test.ts @@ -8,7 +8,7 @@ describe("Prisma 8 dependency versions", () => { expect(getDependencyVersion("@prisma/orm-mongo")).toBe("8.0.0-rc.8"); expect(getDependencyVersion("@prisma/composer")).toBe("0.16.0"); expect(getDependencyVersion("@prisma/composer-prisma-cloud")).toBe("0.16.0"); - expect(getDependencyVersion("prisma")).toBe("8.0.0-rc.12"); + expect(getDependencyVersion("prisma")).toBe("latest"); expect(getDependencyVersion("alchemy")).toBe("2.0.0-beta.74"); expect(getDependencyVersion("effect")).toBe("4.0.0-rc.112"); }); diff --git a/tests/deploy-with-composer.test.ts b/tests/deploy-with-composer.test.ts index 66f5130..37f5fb4 100644 --- a/tests/deploy-with-composer.test.ts +++ b/tests/deploy-with-composer.test.ts @@ -7,6 +7,7 @@ import { getConsoleProjectUrl, parseComposerDeployResult, parsePrismaCliEnvelope, + PrismaCliCommandError, } from "../src/tasks/deploy-with-composer"; import { getErrorMessage, redactSecrets } from "../src/utils/errors"; @@ -91,6 +92,46 @@ describe("parsePrismaCliEnvelope", () => { ), ).toEqual({ ok: true, result: { summary: null } }); }); + + test("preserves stable command and error codes from a failure envelope", () => { + const envelope = parsePrismaCliEnvelope( + JSON.stringify({ + kind: "result", + envelope: { + ok: false, + commandId: "app.deploy", + error: { + code: "APP.DEPLOY_FAILED", + summary: "Deployment failed", + why: "The compute service was not created", + }, + }, + }), + ); + + expect(envelope).toMatchObject({ + ok: false, + commandId: "app.deploy", + error: { code: "APP.DEPLOY_FAILED" }, + }); + }); +}); + +describe("PrismaCliCommandError", () => { + test("exposes only stable structured fields for telemetry", () => { + const error = new PrismaCliCommandError({ + message: "Deployment failed", + command: "app.deploy", + code: "APP.DEPLOY_FAILED", + }); + + expect(error).toMatchObject({ + name: "PrismaCliCommandError", + message: "Deployment failed", + prismaCliCommand: "app.deploy", + prismaCliErrorCode: "APP.DEPLOY_FAILED", + }); + }); }); describe("parseComposerDeployResult", () => { diff --git a/tests/e2e/create-prisma.e2e.test.ts b/tests/e2e/create-prisma.e2e.test.ts index 5e56291..973d8cc 100644 --- a/tests/e2e/create-prisma.e2e.test.ts +++ b/tests/e2e/create-prisma.e2e.test.ts @@ -368,7 +368,7 @@ describe("create-prisma e2e", () => { expect( await pathExists(path.join(projectDir, ".claude/skills/prisma-composer/SKILL.md")), ).toBe(true); - expect(packageJson.devDependencies.prisma).toBe("8.0.0-rc.12"); + expect(packageJson.devDependencies.prisma).toBe("latest"); expect(packageJson.scripts.postinstall).toBe("prisma skills sync || exit 0"); expect(packageJson.scripts.deploy).toContain("bun run composer:deploy"); expect(packageJson.overrides.effect).toBe("4.0.0-rc.112"); @@ -420,6 +420,14 @@ describe("create-prisma e2e", () => { expect(await pathExists(path.join(projectDir, "src/prisma/generated/contract.d.ts"))).toBe( true, ); + expect(await pathExists(path.join(projectDir, "prisma.config.ts"))).toBe(true); + expect(await pathExists(path.join(projectDir, "migrations/app"))).toBe(true); + expect( + await pathExists(path.join(projectDir, ".agents/skills/prisma-composer/SKILL.md")), + ).toBe(true); + expect( + await pathExists(path.join(projectDir, ".claude/skills/prisma-composer/SKILL.md")), + ).toBe(true); await runCommand(projectDir, ["bun", "run", "build"]); await runCommand(projectDir, ["bunx", "tsc", "--noEmit"]); diff --git a/tests/telemetry.test.ts b/tests/telemetry.test.ts index c745986..c840d6f 100644 --- a/tests/telemetry.test.ts +++ b/tests/telemetry.test.ts @@ -69,6 +69,7 @@ describe("create telemetry", () => { expect(properties).toEqual( expect.objectContaining({ "duration-ms": 456, + "failure-class": "technical_failure", "error-code": "ERR_TEST", "failure-stage": "plan_migration", "failure-reason": "migration_plan_failed", @@ -78,6 +79,49 @@ describe("create telemetry", () => { expect(JSON.stringify(properties)).not.toContain("secret"); }); + test("separates expected input and environment rejections from technical failures", async () => { + for (const reason of ["target_directory_not_empty", "workspace_missing"] as const) { + await trackCreateFailed({ + input: createInput, + context: createContext, + durationMs: 10, + stage: reason === "workspace_missing" ? "select_workspace" : "collect_context", + reason, + }); + } + const calls = trackCliTelemetry.mock.calls as Array<[string, Record]>; + expect(calls).toHaveLength(2); + expect(calls.map(([, properties]) => properties["failure-reason"])).toEqual([ + "target_directory_not_empty", + "workspace_missing", + ]); + for (const [, properties] of calls) { + expect(properties["failure-class"]).toBe("expected_rejection"); + } + }); + + test("tracks stable Prisma CLI failure fields without raw output", async () => { + await trackCreateFailed({ + input: createInput, + context: createContext, + durationMs: 456, + error: Object.assign(new Error("token=secret"), { + prismaCliCommand: "app.deploy", + prismaCliErrorCode: "APP.DEPLOY_FAILED", + }), + stage: "composer_deploy", + reason: "composer_deploy_failed", + }); + const [, properties] = trackCliTelemetry.mock.calls[0] as [string, Record]; + expect(properties).toEqual( + expect.objectContaining({ + "prisma-cli-command": "app.deploy", + "prisma-cli-error-code": "APP.DEPLOY_FAILED", + }), + ); + expect(JSON.stringify(properties)).not.toContain("secret"); + }); + test("tracks prompt cancellation as a separate outcome", async () => { await trackCreateCancelled({ input: createInput,