From 4cf436767d5a3a8999a169a96a2bafdf65f28ca4 Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Tue, 14 Jul 2026 13:06:40 +0530 Subject: [PATCH 1/5] feat(cli): add feedback, the anonymous CLI feedback command Top-level command that posts a message to the CLI team's feedback service: anonymous by default, --email opts into being contactable, and every submission carries only non-PII context (CLI version, node version, OS platform/arch). No auth, workspace, project, or prompts. Validation failures are usage errors before any network call; delivery failures (unreachable, 3s timeout, non-2xx) fail with FEEDBACK_SEND_FAILED and surface the service's error detail. PRISMA_CLI_FEEDBACK_URL overrides the endpoint for tests and staging. --- docs/product/command-spec.md | 31 +++- packages/cli/src/cli.ts | 2 + packages/cli/src/commands/feedback/index.ts | 43 +++++ packages/cli/src/controllers/feedback.ts | 141 +++++++++++++++ packages/cli/src/presenters/feedback.ts | 31 ++++ packages/cli/src/shell/command-meta.ts | 9 + packages/cli/src/types/feedback.ts | 13 ++ packages/cli/tests/database.test.ts | 6 +- packages/cli/tests/feedback.test.ts | 180 ++++++++++++++++++++ 9 files changed, 453 insertions(+), 3 deletions(-) create mode 100644 packages/cli/src/commands/feedback/index.ts create mode 100644 packages/cli/src/controllers/feedback.ts create mode 100644 packages/cli/src/presenters/feedback.ts create mode 100644 packages/cli/src/types/feedback.ts create mode 100644 packages/cli/tests/feedback.test.ts diff --git a/docs/product/command-spec.md b/docs/product/command-spec.md index 5ee18ce..4f43805 100644 --- a/docs/product/command-spec.md +++ b/docs/product/command-spec.md @@ -19,13 +19,18 @@ The beta package includes these command groups: - `app` - `build` (includes `build logs`) -The beta package also includes two top-level commands: +The beta package also includes three top-level commands: - `version` - `init` +- `feedback` `version` is intentionally outside the workflow groups: it reports CLI build and environment state, requires no auth, no project context, and no network, and is the canonical answer to "is this CLI installed and on the build I expect?" +`feedback` is also outside the workflow groups: it sends a message to the +Prisma CLI team's feedback service and touches no platform resource, so it +requires no auth, no workspace, and no project context. + `init` is a top-level workflow verb: it acts on the local project directory (writing the committed compute config) rather than managing a remote resource, so it sits beside the ORM's verb register (`generate`, `migrate`, `validate`) @@ -435,6 +440,30 @@ prisma-cli init --format ts prisma-cli init --json ``` +## `prisma-cli feedback --email ` + +Purpose: + +- send feedback about the CLI to the Prisma team + +Behavior: + +- requires no auth, no workspace, no project context, and no config; never prompts, in any mode +- anonymous by default; `--email
` opts into being contactable, and the address is validated when passed +- attaches non-PII environment context to every submission: CLI version, node version, and OS platform and arch; nothing else is collected, and the help text says so +- `` is required, trimmed, and limited to 4000 characters; an empty or oversized message is a usage error and nothing is sent +- posts to the feedback service with a 3-second timeout; delivery failures (unreachable service, timeout, non-2xx response) fail with `FEEDBACK_SEND_FAILED` and exit 1, and the message is not persisted anywhere locally +- `PRISMA_CLI_FEEDBACK_URL` overrides the service endpoint for testing and staging +- in `--json`, `result` includes the submission `id` returned by the service (null when the response carries none), the `email` sent (null when anonymous), and the attached `context` + +Examples: + +```bash +prisma-cli feedback "the deploy flow is great" +prisma-cli feedback "please add X" --email you@example.com +prisma-cli feedback "agent output is solid" --json +``` + ## ` @prisma/cli@latest agent install --agent --all-agents --skill --global --copy` Purpose: diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 04dec58..d617c67 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -8,6 +8,7 @@ import { createAuthCommand } from "./commands/auth"; import { createBranchCommand } from "./commands/branch"; import { createBuildCommand } from "./commands/build"; import { createDatabaseCommand } from "./commands/database"; +import { createFeedbackCommand } from "./commands/feedback"; import { createGitCommand } from "./commands/git"; import { createInitCommand } from "./commands/init"; import { createProjectCommand } from "./commands/project"; @@ -85,6 +86,7 @@ export function createProgram(runtime: CliRuntime): Command { program.addCommand(createVersionCommand(runtime)); program.addCommand(createInitCommand(runtime)); + program.addCommand(createFeedbackCommand(runtime)); program.addCommand(createAgentCommand(runtime)); program.addCommand(createAuthCommand(runtime)); program.addCommand(createProjectCommand(runtime)); diff --git a/packages/cli/src/commands/feedback/index.ts b/packages/cli/src/commands/feedback/index.ts new file mode 100644 index 0000000..e570e21 --- /dev/null +++ b/packages/cli/src/commands/feedback/index.ts @@ -0,0 +1,43 @@ +import { Command, Option } from "commander"; + +import { type FeedbackFlags, runFeedback } from "../../controllers/feedback"; +import { renderFeedbackSuccess } from "../../presenters/feedback"; +import { attachCommandDescriptor } from "../../shell/command-meta"; +import { runCommand } from "../../shell/command-runner"; +import { addGlobalFlags } from "../../shell/global-flags"; +import { type CliRuntime, configureRuntimeCommand } from "../../shell/runtime"; +import type { FeedbackResult } from "../../types/feedback"; + +export function createFeedbackCommand(runtime: CliRuntime): Command { + const command = attachCommandDescriptor( + configureRuntimeCommand(new Command("feedback"), runtime), + "feedback", + ); + + command + .argument("", "Feedback text (up to 4000 characters)") + .addOption( + new Option( + "--email
", + "Contact email if you want a reply; feedback is anonymous without it", + ), + ); + addGlobalFlags(command); + + command.action(async (message: string, options) => { + const flags = options as FeedbackFlags; + + await runCommand( + runtime, + "feedback", + options as Record, + (context) => runFeedback(context, message, { email: flags.email }), + { + renderHuman: (context, descriptor, result) => + renderFeedbackSuccess(context, descriptor, result), + }, + ); + }); + + return command; +} diff --git a/packages/cli/src/controllers/feedback.ts b/packages/cli/src/controllers/feedback.ts new file mode 100644 index 0000000..9714145 --- /dev/null +++ b/packages/cli/src/controllers/feedback.ts @@ -0,0 +1,141 @@ +import { getCliVersion } from "../lib/version"; +import { CliError, usageError } from "../shell/errors"; +import type { CommandSuccess } from "../shell/output"; +import type { CommandContext } from "../shell/runtime"; +import type { FeedbackContext, FeedbackResult } from "../types/feedback"; + +const DEFAULT_FEEDBACK_ENDPOINT = + "https://hiieirp2pwqnjvq9axzyg6d0.fra.prisma.build/feedback"; +// Feedback must never feel slower than the thought it carries; a service +// that cannot answer quickly is treated as unavailable. +const FEEDBACK_TIMEOUT_MS = 3_000; +// Mirrors the feedback service's own limits so refusals happen before the +// network round trip. +const MAX_MESSAGE_LENGTH = 4_000; +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +export interface FeedbackFlags { + email?: string; +} + +export async function runFeedback( + context: CommandContext, + messageArg: string, + flags: FeedbackFlags, +): Promise> { + const message = messageArg.trim(); + if (!message) { + throw usageError( + "Feedback message required", + "The message argument is empty.", + "Pass a non-empty message.", + ['prisma-cli feedback "the deploy flow is great"'], + ); + } + if (message.length > MAX_MESSAGE_LENGTH) { + throw usageError( + "Feedback message too long", + `The message is ${message.length} characters; the limit is ${MAX_MESSAGE_LENGTH}.`, + "Shorten the message.", + ); + } + + const email = flags.email?.trim(); + if (email !== undefined && !EMAIL_PATTERN.test(email)) { + throw usageError( + "Invalid email", + `"${flags.email}" is not a valid email address.`, + "Pass a valid address with --email, or drop the flag to stay anonymous.", + ['prisma-cli feedback "please add X" --email you@example.com'], + ); + } + + const feedbackContext: FeedbackContext = { + cliVersion: getCliVersion(), + nodeVersion: process.version, + platform: process.platform, + arch: process.arch, + }; + + const endpoint = + context.runtime.env.PRISMA_CLI_FEEDBACK_URL || DEFAULT_FEEDBACK_ENDPOINT; + const id = await postFeedback(context, endpoint, { + message, + ...(email ? { email } : {}), + meta: { ...feedbackContext }, + }); + + return { + command: "feedback", + result: { + id, + email: email ?? null, + context: feedbackContext, + }, + warnings: [], + nextSteps: [], + }; +} + +async function postFeedback( + context: CommandContext, + endpoint: string, + body: Record, +): Promise { + let response: Response; + try { + response = await fetch(endpoint, { + method: "POST", + headers: { + "content-type": "application/json", + "user-agent": `prisma-cli/${getCliVersion()}`, + }, + body: JSON.stringify(body), + signal: AbortSignal.any([ + context.runtime.signal, + AbortSignal.timeout(FEEDBACK_TIMEOUT_MS), + ]), + }); + } catch (error) { + if (context.runtime.signal.aborted) { + throw error; + } + const detail = + error instanceof Error && error.name === "TimeoutError" + ? `The feedback service did not answer within ${FEEDBACK_TIMEOUT_MS / 1000} seconds.` + : `The feedback service could not be reached${error instanceof Error && error.cause instanceof Error ? ` (${error.cause.message})` : ""}.`; + throw feedbackSendFailed(detail); + } + + if (!response.ok) { + throw feedbackSendFailed( + `The feedback service responded with HTTP ${response.status}${await readServiceError(response)}.`, + ); + } + + const payload = (await response.json().catch(() => null)) as { + id?: unknown; + } | null; + return typeof payload?.id === "string" ? payload.id : null; +} + +async function readServiceError(response: Response): Promise { + const payload = (await response.json().catch(() => null)) as { + error?: { message?: unknown }; + } | null; + return typeof payload?.error?.message === "string" + ? ` (${payload.error.message})` + : ""; +} + +function feedbackSendFailed(detail: string): CliError { + return new CliError({ + code: "FEEDBACK_SEND_FAILED", + domain: "cli", + summary: "Feedback could not be delivered", + why: detail, + fix: "Check your network and rerun the command.", + exitCode: 1, + nextSteps: [], + }); +} diff --git a/packages/cli/src/presenters/feedback.ts b/packages/cli/src/presenters/feedback.ts new file mode 100644 index 0000000..46caa93 --- /dev/null +++ b/packages/cli/src/presenters/feedback.ts @@ -0,0 +1,31 @@ +import { renderShow } from "../output/patterns"; +import type { CommandDescriptor } from "../shell/command-meta"; +import type { CommandContext } from "../shell/runtime"; +import type { FeedbackResult } from "../types/feedback"; + +export function renderFeedbackSuccess( + context: CommandContext, + descriptor: CommandDescriptor, + result: FeedbackResult, +): string[] { + return renderShow( + { + title: "Feedback sent. Thank you!", + descriptor, + fields: [ + ...(result.id ? [{ key: "id", value: result.id }] : []), + { + key: "sent as", + value: result.email ?? "anonymous", + tone: result.email ? ("default" as const) : ("dim" as const), + }, + { + key: "included", + value: `CLI ${result.context.cliVersion}, node ${result.context.nodeVersion}, ${result.context.platform} ${result.context.arch}`, + tone: "dim" as const, + }, + ], + }, + context.ui, + ); +} diff --git a/packages/cli/src/shell/command-meta.ts b/packages/cli/src/shell/command-meta.ts index 069f213..7c6f146 100644 --- a/packages/cli/src/shell/command-meta.ts +++ b/packages/cli/src/shell/command-meta.ts @@ -44,6 +44,15 @@ const DESCRIPTORS: CommandDescriptor[] = [ description: "Show CLI build and environment", examples: ["prisma-cli version", "prisma-cli version --json"], }, + { + id: "feedback", + path: ["prisma", "feedback"], + description: "Send feedback to the Prisma CLI team", + examples: [ + 'prisma-cli feedback "the deploy flow is great"', + 'prisma-cli feedback "please add X" --email you@example.com', + ], + }, { id: "init", path: ["prisma", "init"], diff --git a/packages/cli/src/types/feedback.ts b/packages/cli/src/types/feedback.ts new file mode 100644 index 0000000..d1f22f8 --- /dev/null +++ b/packages/cli/src/types/feedback.ts @@ -0,0 +1,13 @@ +export interface FeedbackResult { + id: string | null; + email: string | null; + context: FeedbackContext; +} + +/** Non-PII environment details attached to every submission. */ +export interface FeedbackContext { + cliVersion: string; + nodeVersion: string; + platform: string; + arch: string; +} diff --git a/packages/cli/tests/database.test.ts b/packages/cli/tests/database.test.ts index 8564e72..70e3b45 100644 --- a/packages/cli/tests/database.test.ts +++ b/packages/cli/tests/database.test.ts @@ -58,8 +58,10 @@ describe("database commands", () => { }); expect(root.exitCode).toBe(0); - expect(root.stderr).toContain( - "database Manage Prisma Postgres databases for a project", + // The column width flexes with the widest command name, so only the + // row's presence is asserted, not its exact padding. + expect(root.stderr).toMatch( + /database\s+Manage Prisma Postgres databases for a project/, ); expect(database.exitCode).toBe(0); diff --git a/packages/cli/tests/feedback.test.ts b/packages/cli/tests/feedback.test.ts new file mode 100644 index 0000000..938a573 --- /dev/null +++ b/packages/cli/tests/feedback.test.ts @@ -0,0 +1,180 @@ +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { executeCli } from "./helpers"; + +interface ReceivedRequest { + body: unknown; + userAgent: string | undefined; +} + +let server: Server | undefined; + +afterEach(async () => { + if (server) { + await new Promise((resolve) => server?.close(resolve)); + server = undefined; + } +}); + +async function startFeedbackService(options: { + status?: number; + response?: unknown; +}): Promise<{ url: string; requests: ReceivedRequest[] }> { + const requests: ReceivedRequest[] = []; + server = createServer((req, res) => { + let raw = ""; + req.on("data", (chunk) => { + raw += chunk; + }); + req.on("end", () => { + requests.push({ + body: JSON.parse(raw), + userAgent: req.headers["user-agent"], + }); + res.statusCode = options.status ?? 201; + res.setHeader("content-type", "application/json"); + res.end(JSON.stringify(options.response ?? { ok: true, id: "fb_test" })); + }); + }); + await new Promise((resolve) => server?.listen(0, resolve)); + const { port } = server.address() as AddressInfo; + return { url: `http://127.0.0.1:${port}/feedback`, requests }; +} + +async function runFeedbackCli(url: string, argv: string[]) { + return executeCli({ + argv: ["feedback", ...argv], + env: { ...process.env, PRISMA_CLI_FEEDBACK_URL: url }, + }); +} + +describe("feedback", () => { + it("sends anonymous feedback with non-PII context and returns the id", async () => { + const { url, requests } = await startFeedbackService({}); + + const result = await runFeedbackCli(url, [ + "loving the deploy flow", + "--json", + ]); + const payload = JSON.parse(result.stdout); + + expect(result.exitCode).toBe(0); + expect(payload).toMatchObject({ + ok: true, + command: "feedback", + result: { + id: "fb_test", + email: null, + context: { + cliVersion: expect.any(String), + nodeVersion: process.version, + platform: process.platform, + arch: process.arch, + }, + }, + }); + + expect(requests).toHaveLength(1); + expect(requests[0]?.body).toEqual({ + message: "loving the deploy flow", + meta: { + cliVersion: payload.result.context.cliVersion, + nodeVersion: process.version, + platform: process.platform, + arch: process.arch, + }, + }); + expect(requests[0]?.userAgent).toMatch(/^prisma-cli\//); + }); + + it("includes the email only when --email is passed", async () => { + const { url, requests } = await startFeedbackService({}); + + const result = await runFeedbackCli(url, [ + "please add X", + "--email", + "dev@example.com", + "--json", + ]); + const payload = JSON.parse(result.stdout); + + expect(result.exitCode).toBe(0); + expect(payload.result.email).toBe("dev@example.com"); + expect(requests[0]?.body).toMatchObject({ + message: "please add X", + email: "dev@example.com", + }); + }); + + it("rejects an empty message as a usage error without sending", async () => { + const { url, requests } = await startFeedbackService({}); + + const result = await runFeedbackCli(url, [" ", "--json"]); + const payload = JSON.parse(result.stdout); + + expect(result.exitCode).toBe(2); + expect(payload).toMatchObject({ + ok: false, + error: { code: "USAGE_ERROR" }, + }); + expect(requests).toHaveLength(0); + }); + + it("rejects an invalid --email as a usage error without sending", async () => { + const { url, requests } = await startFeedbackService({}); + + const result = await runFeedbackCli(url, [ + "hello", + "--email", + "not-an-email", + "--json", + ]); + const payload = JSON.parse(result.stdout); + + expect(result.exitCode).toBe(2); + expect(payload.error.code).toBe("USAGE_ERROR"); + expect(requests).toHaveLength(0); + }); + + it("fails with FEEDBACK_SEND_FAILED when the service errors", async () => { + const { url } = await startFeedbackService({ + status: 500, + response: { + ok: false, + error: { code: "STORAGE_UNAVAILABLE", message: "db down" }, + }, + }); + + const result = await runFeedbackCli(url, ["hello", "--json"]); + const payload = JSON.parse(result.stdout); + + expect(result.exitCode).toBe(1); + expect(payload.error.code).toBe("FEEDBACK_SEND_FAILED"); + expect(payload.error.why).toContain("HTTP 500"); + expect(payload.error.why).toContain("db down"); + }); + + it("fails with FEEDBACK_SEND_FAILED when the service is unreachable", async () => { + const result = await runFeedbackCli("http://127.0.0.1:9/feedback", [ + "hello", + "--json", + ]); + const payload = JSON.parse(result.stdout); + + expect(result.exitCode).toBe(1); + expect(payload.error.code).toBe("FEEDBACK_SEND_FAILED"); + }); + + it("reports a null id when the service response has none", async () => { + const { url } = await startFeedbackService({ response: { ok: true } }); + + const result = await runFeedbackCli(url, ["hello", "--json"]); + const payload = JSON.parse(result.stdout); + + expect(result.exitCode).toBe(0); + expect(payload.result.id).toBeNull(); + }); +}); From ed4be46a81ec632604b44a313c28f0219344f997 Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Tue, 14 Jul 2026 13:23:55 +0530 Subject: [PATCH 2/5] test(cli): cover the feedback message length limit --- packages/cli/tests/feedback.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/cli/tests/feedback.test.ts b/packages/cli/tests/feedback.test.ts index 938a573..df8f708 100644 --- a/packages/cli/tests/feedback.test.ts +++ b/packages/cli/tests/feedback.test.ts @@ -123,6 +123,17 @@ describe("feedback", () => { expect(requests).toHaveLength(0); }); + it("rejects a message over 4000 characters as a usage error without sending", async () => { + const { url, requests } = await startFeedbackService({}); + + const result = await runFeedbackCli(url, ["x".repeat(4001), "--json"]); + const payload = JSON.parse(result.stdout); + + expect(result.exitCode).toBe(2); + expect(payload.error.code).toBe("USAGE_ERROR"); + expect(requests).toHaveLength(0); + }); + it("rejects an invalid --email as a usage error without sending", async () => { const { url, requests } = await startFeedbackService({}); From bdc19c34e6c760318b9459eaf5b78881c2f463a7 Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Tue, 14 Jul 2026 14:37:32 +0530 Subject: [PATCH 3/5] fix(cli): address codex review of the feedback command Do not treat a stalled or cancelled response-body read as success: only a fully received non-JSON body maps to a null id; timeouts map to FEEDBACK_SEND_FAILED and runtime cancellation propagates. Mirror the service's 320-character email limit locally. Disclose the attached context in the command help, and state in the spec that the service uses the client IP transiently for rate limiting and never stores it. Register feedback in the Global Rules top-level list, the output pattern mapping, and the MVP error-code registry. --- docs/product/command-spec.md | 5 ++- docs/product/error-conventions.md | 1 + docs/product/output-conventions.md | 1 + packages/cli/src/controllers/feedback.ts | 56 +++++++++++++++++++----- packages/cli/src/shell/command-meta.ts | 2 + packages/cli/tests/feedback.test.ts | 39 +++++++++++++++++ 6 files changed, 91 insertions(+), 13 deletions(-) diff --git a/docs/product/command-spec.md b/docs/product/command-spec.md index 4f43805..4414dd0 100644 --- a/docs/product/command-spec.md +++ b/docs/product/command-spec.md @@ -49,7 +49,7 @@ Out of scope for the current beta: ## Global Rules - Canonical shape is `prisma `. -- `version` and `init` are the top-level commands outside that shape (see Scope above). +- `version`, `init`, and `feedback` are the top-level commands outside that shape (see Scope above). - Every command supports `--json`. - Shared global flags are: - `--json` @@ -450,7 +450,8 @@ Behavior: - requires no auth, no workspace, no project context, and no config; never prompts, in any mode - anonymous by default; `--email
` opts into being contactable, and the address is validated when passed -- attaches non-PII environment context to every submission: CLI version, node version, and OS platform and arch; nothing else is collected, and the help text says so +- attaches non-PII environment context to every submission: CLI version, node version, and OS platform and arch; the command help discloses exactly this list +- the feedback service uses the client IP transiently, in memory, to rate limit submissions; the IP is never stored with the feedback - `` is required, trimmed, and limited to 4000 characters; an empty or oversized message is a usage error and nothing is sent - posts to the feedback service with a 3-second timeout; delivery failures (unreachable service, timeout, non-2xx response) fail with `FEEDBACK_SEND_FAILED` and exit 1, and the message is not persisted anywhere locally - `PRISMA_CLI_FEEDBACK_URL` overrides the service endpoint for testing and staging diff --git a/docs/product/error-conventions.md b/docs/product/error-conventions.md index 7939fd3..87ae4ce 100644 --- a/docs/product/error-conventions.md +++ b/docs/product/error-conventions.md @@ -159,6 +159,7 @@ Rules: These codes are the minimum stable set for the MVP: - `USAGE_ERROR` +- `FEEDBACK_SEND_FAILED` - `AUTH_REQUIRED` - `AUTH_CONFIG_INVALID` - `AGENT_SKILLS_INSTALL_FAILED` diff --git a/docs/product/output-conventions.md b/docs/product/output-conventions.md index 127ec09..09c56d3 100644 --- a/docs/product/output-conventions.md +++ b/docs/product/output-conventions.md @@ -96,6 +96,7 @@ Current MVP commands map to patterns like this: | Command | Pattern | | --- | --- | | `version` | `show` | +| `feedback` | `show` (send confirmation card) | | `auth login` | `mutate` | | `auth logout` | `mutate` | | `auth whoami` | `show` | diff --git a/packages/cli/src/controllers/feedback.ts b/packages/cli/src/controllers/feedback.ts index 9714145..a3975a2 100644 --- a/packages/cli/src/controllers/feedback.ts +++ b/packages/cli/src/controllers/feedback.ts @@ -12,6 +12,7 @@ const FEEDBACK_TIMEOUT_MS = 3_000; // Mirrors the feedback service's own limits so refusals happen before the // network round trip. const MAX_MESSAGE_LENGTH = 4_000; +const MAX_EMAIL_LENGTH = 320; const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; export interface FeedbackFlags { @@ -41,10 +42,13 @@ export async function runFeedback( } const email = flags.email?.trim(); - if (email !== undefined && !EMAIL_PATTERN.test(email)) { + if ( + email !== undefined && + (email.length > MAX_EMAIL_LENGTH || !EMAIL_PATTERN.test(email)) + ) { throw usageError( "Invalid email", - `"${flags.email}" is not a valid email address.`, + `"${flags.email}" is not a valid email address of at most ${MAX_EMAIL_LENGTH} characters.`, "Pass a valid address with --email, or drop the flag to stay anonymous.", ['prisma-cli feedback "please add X" --email you@example.com'], ); @@ -102,27 +106,57 @@ async function postFeedback( } const detail = error instanceof Error && error.name === "TimeoutError" - ? `The feedback service did not answer within ${FEEDBACK_TIMEOUT_MS / 1000} seconds.` + ? TIMEOUT_DETAIL : `The feedback service could not be reached${error instanceof Error && error.cause instanceof Error ? ` (${error.cause.message})` : ""}.`; throw feedbackSendFailed(detail); } if (!response.ok) { throw feedbackSendFailed( - `The feedback service responded with HTTP ${response.status}${await readServiceError(response)}.`, + `The feedback service responded with HTTP ${response.status}${await readServiceError(context, response)}.`, ); } - const payload = (await response.json().catch(() => null)) as { - id?: unknown; - } | null; + // The body read runs under the same abort signal as the request, so a + // stalled response or a user cancellation here must not be mistaken for a + // fully received non-JSON body. + let payload: { id?: unknown } | null; + try { + payload = (await response.json()) as { id?: unknown } | null; + } catch (error) { + if (context.runtime.signal.aborted) { + throw error; + } + if (!(error instanceof SyntaxError)) { + throw feedbackSendFailed( + error instanceof Error && error.name === "TimeoutError" + ? TIMEOUT_DETAIL + : "The feedback service response could not be read.", + ); + } + // The body arrived but was not JSON; the submission itself succeeded. + payload = null; + } return typeof payload?.id === "string" ? payload.id : null; } -async function readServiceError(response: Response): Promise { - const payload = (await response.json().catch(() => null)) as { - error?: { message?: unknown }; - } | null; +const TIMEOUT_DETAIL = `The feedback service did not answer within ${FEEDBACK_TIMEOUT_MS / 1000} seconds.`; + +async function readServiceError( + context: CommandContext, + response: Response, +): Promise { + let payload: { error?: { message?: unknown } } | null; + try { + payload = (await response.json()) as { + error?: { message?: unknown }; + } | null; + } catch (error) { + if (context.runtime.signal.aborted) { + throw error; + } + return ""; + } return typeof payload?.error?.message === "string" ? ` (${payload.error.message})` : ""; diff --git a/packages/cli/src/shell/command-meta.ts b/packages/cli/src/shell/command-meta.ts index 7c6f146..4a11edd 100644 --- a/packages/cli/src/shell/command-meta.ts +++ b/packages/cli/src/shell/command-meta.ts @@ -48,6 +48,8 @@ const DESCRIPTORS: CommandDescriptor[] = [ id: "feedback", path: ["prisma", "feedback"], description: "Send feedback to the Prisma CLI team", + longDescription: + "Anonymous unless --email is passed. Every submission includes the CLI version, node version, and OS platform/arch, and nothing else.", examples: [ 'prisma-cli feedback "the deploy flow is great"', 'prisma-cli feedback "please add X" --email you@example.com', diff --git a/packages/cli/tests/feedback.test.ts b/packages/cli/tests/feedback.test.ts index df8f708..2763592 100644 --- a/packages/cli/tests/feedback.test.ts +++ b/packages/cli/tests/feedback.test.ts @@ -150,6 +150,45 @@ describe("feedback", () => { expect(requests).toHaveLength(0); }); + it("rejects a regex-valid email over 320 characters without sending", async () => { + const { url, requests } = await startFeedbackService({}); + const longEmail = `${"a".repeat(320)}@example.com`; + + const result = await runFeedbackCli(url, [ + "hello", + "--email", + longEmail, + "--json", + ]); + const payload = JSON.parse(result.stdout); + + expect(result.exitCode).toBe(2); + expect(payload.error.code).toBe("USAGE_ERROR"); + expect(requests).toHaveLength(0); + }); + + it("fails with FEEDBACK_SEND_FAILED when a 2xx response body stalls", async () => { + server = createServer((req, res) => { + req.on("data", () => {}); + req.on("end", () => { + res.writeHead(201, { "content-type": "application/json" }); + res.write('{"ok":true,'); // start the body, never finish it + }); + }); + await new Promise((resolve) => server?.listen(0, resolve)); + const { port } = server.address() as AddressInfo; + + const result = await runFeedbackCli(`http://127.0.0.1:${port}/feedback`, [ + "hello", + "--json", + ]); + const payload = JSON.parse(result.stdout); + + expect(result.exitCode).toBe(1); + expect(payload.error.code).toBe("FEEDBACK_SEND_FAILED"); + expect(payload.error.why).toContain("did not answer within 3 seconds"); + }, 10_000); + it("fails with FEEDBACK_SEND_FAILED when the service errors", async () => { const { url } = await startFeedbackService({ status: 500, From 4d05b6beb5f655cccd653bd67274e71f896ec4d1 Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Tue, 14 Jul 2026 14:45:36 +0530 Subject: [PATCH 4/5] docs(cli): add the FEEDBACK_SEND_FAILED recommended meaning --- docs/product/error-conventions.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/product/error-conventions.md b/docs/product/error-conventions.md index 87ae4ce..bf912a9 100644 --- a/docs/product/error-conventions.md +++ b/docs/product/error-conventions.md @@ -235,6 +235,7 @@ These codes are the minimum stable set for the MVP: Recommended meanings: - `USAGE_ERROR`: invalid arguments or invalid command combination +- `FEEDBACK_SEND_FAILED`: the feedback service was unreachable, timed out, or returned a non-2xx response - `AUTH_REQUIRED`: command needs an authenticated session - `AUTH_CONFIG_INVALID`: environment auth configuration is present but unusable, such as an empty `PRISMA_SERVICE_TOKEN` - `AGENT_SKILLS_INSTALL_FAILED`: installing Prisma skills through the external skills CLI failed; callers should inspect the command, exit code, and stderr in `error.meta` From ea5b33938a6cff48630889685d8237fc4c42c056 Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Tue, 14 Jul 2026 15:24:15 +0530 Subject: [PATCH 5/5] feat(cli): point unexpected crashes at the feedback command Crashes no longer break the --json contract: the outermost boundary emits a standard UNEXPECTED_ERROR envelope whose nextActions carries a recover run-command pre-filled with the failing command and error line (prisma-cli feedback "..."). Human crash output ends with the same hint, suppressed by --quiet. Expected failures and usage errors never advertise feedback. --- docs/product/command-spec.md | 1 + docs/product/error-conventions.md | 4 +- packages/cli/src/cli.ts | 21 ++++++- packages/cli/src/shell/output.ts | 99 ++++++++++++++++++++++++++++++- packages/cli/tests/shell.test.ts | 72 +++++++++++++++++++++- 5 files changed, 192 insertions(+), 5 deletions(-) diff --git a/docs/product/command-spec.md b/docs/product/command-spec.md index 4414dd0..2c64233 100644 --- a/docs/product/command-spec.md +++ b/docs/product/command-spec.md @@ -455,6 +455,7 @@ Behavior: - `` is required, trimmed, and limited to 4000 characters; an empty or oversized message is a usage error and nothing is sent - posts to the feedback service with a 3-second timeout; delivery failures (unreachable service, timeout, non-2xx response) fail with `FEEDBACK_SEND_FAILED` and exit 1, and the message is not persisted anywhere locally - `PRISMA_CLI_FEEDBACK_URL` overrides the service endpoint for testing and staging +- unexpected CLI crashes point here: the human crash message ends with a `Tell us what happened: prisma-cli feedback "..."` hint pre-filled with the failing command and error line (suppressed by `--quiet`), and `--json` crashes return an `UNEXPECTED_ERROR` envelope whose `nextActions` carries the same pre-filled command as a `recover` action; expected failures and usage errors never advertise feedback - in `--json`, `result` includes the submission `id` returned by the service (null when the response carries none), the `email` sent (null when anonymous), and the attached `context` Examples: diff --git a/docs/product/error-conventions.md b/docs/product/error-conventions.md index bf912a9..438a8c9 100644 --- a/docs/product/error-conventions.md +++ b/docs/product/error-conventions.md @@ -66,7 +66,7 @@ Examples: - unexpected `undefined` - internal serialization or state invariant broken -Bugs should fail fast and preserve stack traces. Catch them only at the outermost boundary for crash formatting. +Bugs should fail fast and preserve stack traces. Catch them only at the outermost boundary for crash formatting. At that boundary, `--json` runs still emit the standard error envelope with code `UNEXPECTED_ERROR`, and both output modes point at `prisma-cli feedback` pre-filled with the failing command and error line (`--quiet` suppresses the human hint; expected failures never carry the feedback suggestion). ## Boundary Handling @@ -159,6 +159,7 @@ Rules: These codes are the minimum stable set for the MVP: - `USAGE_ERROR` +- `UNEXPECTED_ERROR` - `FEEDBACK_SEND_FAILED` - `AUTH_REQUIRED` - `AUTH_CONFIG_INVALID` @@ -235,6 +236,7 @@ These codes are the minimum stable set for the MVP: Recommended meanings: - `USAGE_ERROR`: invalid arguments or invalid command combination +- `UNEXPECTED_ERROR`: the CLI crashed on an unexpected fault; the envelope carries a `recover` next action suggesting `prisma-cli feedback` - `FEEDBACK_SEND_FAILED`: the feedback service was unreachable, timed out, or returned a non-2xx response - `AUTH_REQUIRED`: command needs an authenticated session - `AUTH_CONFIG_INVALID`: environment auth configuration is present but unusable, such as an empty `PRISMA_SERVICE_TOKEN` diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index d617c67..a13937d 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -20,9 +20,11 @@ import { CliError } from "./shell/errors"; import { addCompactGlobalFlags } from "./shell/global-flags"; import { formatUnexpectedError, + unexpectedErrorFeedbackCommand, writeHumanError, writeJsonError, writeJsonSuccess, + writeJsonUnexpectedError, } from "./shell/output"; import { disposePromptState } from "./shell/prompt"; import { @@ -63,8 +65,25 @@ export async function runCli(options: RunCliOptions = {}): Promise { return error.code === "commander.helpDisplayed" ? 0 : 2; } + // Crashes must not break the --json contract; agents get a structured + // UNEXPECTED_ERROR envelope with a recover action pointing at feedback. + if (runtime.argv.includes("--json")) { + writeJsonUnexpectedError( + { stdout: runtime.stdout, stderr: runtime.stderr }, + runtime.argv, + error, + ); + return 1; + } + + const quiet = + runtime.argv.includes("--quiet") || runtime.argv.includes("-q"); runtime.stderr.write( - formatUnexpectedError(error, runtime.argv.includes("--trace")), + formatUnexpectedError( + error, + runtime.argv.includes("--trace"), + quiet ? undefined : unexpectedErrorFeedbackCommand(runtime.argv, error), + ), ); return 1; } finally { diff --git a/packages/cli/src/shell/output.ts b/packages/cli/src/shell/output.ts index d4627c7..21bba9a 100644 --- a/packages/cli/src/shell/output.ts +++ b/packages/cli/src/shell/output.ts @@ -1,5 +1,6 @@ import type { Writable } from "node:stream"; +import { formatCommandArgument } from "./command-arguments"; import type { CliError } from "./errors"; import type { NextAction } from "./next-actions"; import type { ShellUi } from "./ui"; @@ -48,12 +49,19 @@ export function cliErrorToJson(error: CliError) { }; } -export function formatUnexpectedError(error: unknown, trace: boolean): string { +export function formatUnexpectedError( + error: unknown, + trace: boolean, + feedbackCommand?: string, +): string { + const feedbackLine = feedbackCommand + ? `Tell us what happened: ${feedbackCommand}` + : null; const debug = error instanceof Error ? (error.stack ?? error.message) : String(error); if (trace) { - return `${debug}\n`; + return [debug, ...(feedbackLine ? [feedbackLine] : []), ""].join("\n"); } const message = @@ -62,10 +70,97 @@ export function formatUnexpectedError(error: unknown, trace: boolean): string { return [ `Unexpected CLI error: ${message}`, "More: Re-run with --trace for deeper diagnostics", + ...(feedbackLine ? [feedbackLine] : []), "", ].join("\n"); } +/** Command groups whose second argv token names the subcommand. */ +const COMMAND_GROUPS = new Set([ + "agent", + "auth", + "project", + "git", + "branch", + "build", + "database", + "app", +]); + +/** Best-effort `command` label for a crash that predates envelope assembly. */ +export function unexpectedErrorCommandLabel(argv: readonly string[]): string { + const words = argv.filter((arg) => !arg.startsWith("-")); + const [group, action] = words; + if (!group) { + return "unknown"; + } + return action && COMMAND_GROUPS.has(group) ? `${group}.${action}` : group; +} + +/** + * Pre-filled feedback command for a crash, so the report arrives carrying the + * failing command and the error's first line. + */ +export function unexpectedErrorFeedbackCommand( + argv: readonly string[], + error: unknown, +): string { + const command = unexpectedErrorCommandLabel(argv).replace(".", " "); + const message = ( + error instanceof Error && error.message ? error.message : String(error) + ).split("\n")[0]; + const excerpt = `${command} crashed: ${message}`.slice(0, 200); + return `prisma-cli feedback ${formatCommandArgument(excerpt)}`; +} + +/** + * A crash must not break the `--json` contract: agents still get a structured + * envelope, with a recover action pointing at the feedback command. + */ +export function writeJsonUnexpectedError( + output: CliOutput, + argv: readonly string[], + error: unknown, +): void { + const feedbackCommand = unexpectedErrorFeedbackCommand(argv, error); + output.stdout.write( + `${JSON.stringify( + { + ok: false, + command: unexpectedErrorCommandLabel(argv), + error: { + code: "UNEXPECTED_ERROR", + domain: "cli", + severity: "error", + summary: "Unexpected CLI error", + why: + error instanceof Error && error.message + ? error.message + : String(error), + fix: "Re-run with --trace for deeper diagnostics, and report the crash.", + where: null, + meta: {}, + docsUrl: null, + }, + warnings: [], + nextSteps: [feedbackCommand], + nextActions: [ + { + kind: "run-command", + journey: "recover", + label: "Report this crash to the Prisma team", + command: feedbackCommand, + reason: + "This looks like a CLI bug; the pre-filled report carries the failing command and error.", + } satisfies NextAction, + ], + }, + null, + 2, + )}\n`, + ); +} + export function writeJsonError( output: CliOutput, command: string, diff --git a/packages/cli/tests/shell.test.ts b/packages/cli/tests/shell.test.ts index 574f57e..50151fb 100644 --- a/packages/cli/tests/shell.test.ts +++ b/packages/cli/tests/shell.test.ts @@ -4,7 +4,12 @@ import stripAnsi from "strip-ansi"; import { describe, expect, it } from "vitest"; import { formatCommandArgument } from "../src/shell/command-arguments"; -import { formatUnexpectedError } from "../src/shell/output"; +import { + formatUnexpectedError, + unexpectedErrorCommandLabel, + unexpectedErrorFeedbackCommand, + writeJsonUnexpectedError, +} from "../src/shell/output"; import { maskValue } from "../src/shell/ui"; import { createTempCwd, executeCli } from "./helpers"; @@ -33,6 +38,71 @@ describe("shell behavior", () => { expect(formatUnexpectedError(error, true)).toContain("at explode"); }); + it("appends the feedback hint to unexpected errors when provided", () => { + const error = new Error("boom"); + const hint = 'prisma-cli feedback "app deploy crashed: boom"'; + + expect(formatUnexpectedError(error, false, hint)).toContain( + `Tell us what happened: ${hint}`, + ); + expect(formatUnexpectedError(error, true, hint)).toContain( + `Tell us what happened: ${hint}`, + ); + expect(formatUnexpectedError(error, false)).not.toContain( + "Tell us what happened", + ); + }); + + it("labels crashed commands and pre-fills the feedback report", () => { + expect(unexpectedErrorCommandLabel(["app", "deploy", "--json"])).toBe( + "app.deploy", + ); + expect(unexpectedErrorCommandLabel(["init", "--json"])).toBe("init"); + expect(unexpectedErrorCommandLabel(["--json"])).toBe("unknown"); + + const command = unexpectedErrorFeedbackCommand( + ["app", "deploy", "--json"], + new Error("boom happened\nstack line"), + ); + expect(command).toBe( + "prisma-cli feedback 'app deploy crashed: boom happened'", + ); + }); + + it("emits a structured envelope with a recover action for --json crashes", () => { + const chunks: string[] = []; + const sink = { + write(chunk: string) { + chunks.push(chunk); + return true; + }, + }; + + writeJsonUnexpectedError( + // biome-ignore lint/suspicious/noExplicitAny: minimal writable stub for the test + { stdout: sink as any, stderr: sink as any }, + ["app", "deploy", "--json"], + new Error("boom"), + ); + const envelope = JSON.parse(chunks.join("")); + + expect(envelope).toMatchObject({ + ok: false, + command: "app.deploy", + error: { code: "UNEXPECTED_ERROR", domain: "cli", why: "boom" }, + nextActions: [ + { + kind: "run-command", + journey: "recover", + command: "prisma-cli feedback 'app deploy crashed: boom'", + }, + ], + }); + expect(envelope.nextSteps).toEqual([ + "prisma-cli feedback 'app deploy crashed: boom'", + ]); + }); + it("masks sensitive email local parts with RFC-style characters", () => { expect(maskValue("user.name+tag@example.com")).toBe("****@example.com"); expect(maskValue("customer!#$%&'*+-/=?^_`{|}~@example.com")).toBe(