diff --git a/docs/product/command-spec.md b/docs/product/command-spec.md index 5ee18ce..2c64233 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`) @@ -44,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` @@ -435,6 +440,32 @@ 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; 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 +- 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: + +```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/docs/product/error-conventions.md b/docs/product/error-conventions.md index 7939fd3..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,8 @@ Rules: These codes are the minimum stable set for the MVP: - `USAGE_ERROR` +- `UNEXPECTED_ERROR` +- `FEEDBACK_SEND_FAILED` - `AUTH_REQUIRED` - `AUTH_CONFIG_INVALID` - `AGENT_SKILLS_INSTALL_FAILED` @@ -234,6 +236,8 @@ 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` - `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` 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/cli.ts b/packages/cli/src/cli.ts index 04dec58..a13937d 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"; @@ -19,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 { @@ -62,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 { @@ -85,6 +105,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..a3975a2 --- /dev/null +++ b/packages/cli/src/controllers/feedback.ts @@ -0,0 +1,175 @@ +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 MAX_EMAIL_LENGTH = 320; +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.length > MAX_EMAIL_LENGTH || !EMAIL_PATTERN.test(email)) + ) { + throw usageError( + "Invalid email", + `"${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'], + ); + } + + 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" + ? 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(context, response)}.`, + ); + } + + // 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; +} + +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})` + : ""; +} + +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..4a11edd 100644 --- a/packages/cli/src/shell/command-meta.ts +++ b/packages/cli/src/shell/command-meta.ts @@ -44,6 +44,17 @@ 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", + 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', + ], + }, { id: "init", path: ["prisma", "init"], 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/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..2763592 --- /dev/null +++ b/packages/cli/tests/feedback.test.ts @@ -0,0 +1,230 @@ +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 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({}); + + 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("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, + 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(); + }); +}); 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(