From e6cb6aec4c20b69cf0f02ca1746bf13b507ca403 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 25 Aug 2026 16:12:05 +0200 Subject: [PATCH 1/3] docs: add the canonical error-reference registry and completeness check Mirrors prisma/prisma's error-reference pattern so docs.prisma.io can aggregate the CLI's structured errors: - docs/reference/error-reference.md documents all 128 NAMESPACE.SUBCODE codes in production source, by namespace, with the condition that raises each one - scripts/list-error-codes.mjs enumerates codes from source (json, markdown skeleton, and --verify modes, --root for external checkouts) - pnpm check:error-reference wired into pr-quality.yml: a new code cannot ship undocumented - the command families now set docsBaseUrl, so emitted errors carry docsUrl = https://www.prisma.io/docs/cli/error-reference/ (the docs site redirects the path form to the # anchor) - CLI_DOCS_URL points at /docs/cli now that the unified CLI has its own docs section; update-check tests pin the new URL Known gap, deliberately out of scope: a few boundaries build codes dynamically (SERVICE., GIT., POSTGRES., BUCKET. passthroughs), which a literal scanner cannot enumerate; the registry intro documents the passthrough rule instead. Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .github/workflows/pr-quality.yml | 25 + docs/product/error-conventions.md | 2 + docs/reference/error-reference.md | 559 +++++++++++++++++++++ package.json | 1 + packages/cli/src/cli-name.ts | 16 +- packages/cli/src/cli.ts | 3 +- packages/cli/src/commands/skills/family.ts | 2 + packages/cli/tests/update-check.test.ts | 8 +- scripts/list-error-codes.mjs | 129 +++++ 9 files changed, 735 insertions(+), 10 deletions(-) create mode 100644 docs/reference/error-reference.md create mode 100644 scripts/list-error-codes.mjs diff --git a/.github/workflows/pr-quality.yml b/.github/workflows/pr-quality.yml index 59f153ee..73f257ae 100644 --- a/.github/workflows/pr-quality.yml +++ b/.github/workflows/pr-quality.yml @@ -86,6 +86,31 @@ jobs: - name: Check grammar completeness run: pnpm check:grammar + error-reference: + name: Error Reference Completeness + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Set up pnpm + uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 + + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version-file: .node-version + + # Every structured error code in production source must have an entry + # in docs/reference/error-reference.md, the canonical registry behind + # https://docs.prisma.io/docs/cli/error-reference. The scanner has no + # dependencies, so no install step is needed. + - name: Check error-reference completeness + run: pnpm check:error-reference + test: name: Test runs-on: ubuntu-latest diff --git a/docs/product/error-conventions.md b/docs/product/error-conventions.md index 8263bb23..7f6591e5 100644 --- a/docs/product/error-conventions.md +++ b/docs/product/error-conventions.md @@ -156,6 +156,8 @@ Rules: ## MVP Error Codes +> **Superseded for per-code lookup.** This section predates the dotted `NAMESPACE.SUBCODE` scheme now used in production source. The canonical per-code registry is [docs/reference/error-reference.md](../reference/error-reference.md), kept complete by `pnpm check:error-reference` in CI. The taxonomy and shape rules in this document still apply. + These codes are the minimum stable set for the MVP: - `USAGE_ERROR` diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md new file mode 100644 index 00000000..17aeeea7 --- /dev/null +++ b/docs/reference/error-reference.md @@ -0,0 +1,559 @@ +# Error reference + +Every user-facing error the unified Prisma CLI emits is a structured envelope identified by a dotted `NAMESPACE.SUBCODE` code (see [Error Conventions](../product/error-conventions.md) and [ADR 0003](../architecture/adrs/0003-structured-output-and-errors.md)). This page lists every published code. It is the canonical source for the hosted reference at `https://docs.prisma.io/docs/cli/error-reference` (each code anchors as `#`), and CI verifies completeness on every PR: `pnpm check:error-reference` fails if any code in production source is missing from this page. + +Recognize an error programmatically by running with `--json` and matching on `error.code` in the emitted envelope — never on message text. Envelopes carry `message`, and optionally `why`, `fix`, `where`, `meta`, `cause`, `nextActions`, and `docsUrl`. + +Most codes on this page are expected failures. Some are warn-severity diagnostics that ride a successful run: the command completes and exits `0`, and the diagnostic carries the code (the entry says so where it applies). The process exit-code contract is in [Error Conventions](../product/error-conventions.md). + +Some command boundaries pass unrecognized upstream codes through by prefixing their namespace — a legacy platform code becomes `SERVICE.` or `GIT.`, and a server-supplied API code becomes `POSTGRES.` or `BUCKET.`. This page documents the codes defined in production source; a passthrough code not listed here carries the meaning of the upstream code it wraps. + +Namespaces: + +| Namespace | Covers | +| --- | --- | +| `AUTH` | Workspace authentication and sessions (`prisma auth`, credential resolution) | +| `BRANCH` | Branch listing (`prisma branch`) | +| `BUCKET` | Bucket and bucket-key management (`prisma bucket`) | +| `CLI` | Engine-level invocation: arguments, config loading, prompts, consent, credentials, child processes | +| `FEEDBACK` | Sending product feedback (`prisma feedback`) | +| `GIT` | Git repository connections (`prisma git`) | +| `INIT` | Project initialization diagnostics (`prisma init`) | +| `POSTGRES` | Database management (`prisma postgres`) | +| `PROJECT` | Project and environment management (`prisma project`) | +| `SERVICE` | Deployed service management (`prisma service`) | +| `SKILLS` | Agent-skill delivery (`prisma skills`) | + +## AUTH + +### AUTH.CREDENTIAL_WORKSPACE_MISMATCH + +A credential's `workspace_id` claim disagrees with the workspace it is being stored under — raised by every CredentialManager (the CLI's file-backed manager and the engine's in-memory one) both when `createSession` receives a token claiming a different workspace and when a rotated token written back during refresh would re-scope an existing session. The fix is to run `prisma auth login` again and pick the intended workspace. Meta: none. + +### AUTH.LOGIN_WORKSPACE_UNKNOWN + +`prisma auth login` completed the browser sign-in but the minted credential carries no `workspace_id` claim, so no workspace session can be keyed by it. The fix is to sign in again and pick a workspace in the browser. Meta: none. + +### AUTH.NO_SESSION_FOR_WORKSPACE + +A workspace reference matched none of the stored workspace sessions — raised by the command-side ref resolver behind `prisma auth workspace use` and `prisma auth workspace logout` (exact id match first, then case-insensitive name match), and by the credential managers when a session operation names a workspace with no stored record. Sessions are created only by `prisma auth login`, so the suggested fix is to sign in and pick that workspace in the browser; the workspace reference appears in the message, not in meta. Meta: none. + +### AUTH.NO_WORKSPACE_SESSIONS + +`prisma auth workspace use` was run with zero stored workspace sessions, so there is nothing to select among — the command only selects, it never creates a session or opens a browser. The fix is to run `prisma auth login` first. Meta: none. + +### AUTH.SERVICE_TOKEN_EMPTY + +The `PRISMA_SERVICE_TOKEN` environment variable is set but blank; a blank token authenticates nothing while still overriding stored workspace sessions, so the CLI surfaces it instead of silently ignoring it. It is raised identically wherever the environment credential is read — `activeCredential()`, the command needs check, and the engine's request path, including at the start of `prisma auth login` before a browser opens. The suggested actions are to unset the variable or set it to a valid service token. Meta: none. + +### AUTH.SERVICE_TOKEN_REJECTED + +The management API rejected (401) the service token supplied through `PRISMA_SERVICE_TOKEN`; such a token carries no refresh token and can never be renewed, and nothing stored is cleared. Built only through the shared `credentialRejectedError` dispatcher in the engine's API request path — the one place wording differs by credential origin (a stored session with the same failure gets `CLI.CREDENTIALS_REQUIRED` instead). The suggested action is to replace the variable with a valid service token or unset it to fall back to stored sessions. Meta: none. + +### AUTH.SESSIONS_UNSUPPORTED + +A session mutation (`createSession`, `selectSession`, `endSession`, `endAllSessions`) was attempted on a host that uses the environment-only credential manager, whose sole credential source is `PRISMA_SERVICE_TOKEN` (plus `PRISMA_WORKSPACE_ID`) — such hosts, like composer's rebuilt CLI, hold no stored sessions, so there is nothing to create, select, or end. The suggested action is to set or change the environment variable instead. Meta: none. + +### AUTH.USAGE_ERROR + +A command that needs an active workspace found an authenticated credential that names no workspace — raised by the resource commands' `resolveActiveWorkspace` and the service commands' `requireWorkspace`, which read the engine's pinned credential; an environment token whose claims carry no workspace id is the usual cause. The suggested fix is to run `prisma auth login` and choose a workspace. Meta: none. + +### AUTH.WORKSPACE_AMBIGUOUS + +A user-typed workspace name matched more than one workspace, from two raise sites with different meta: the session-ref resolver behind `prisma auth workspace use`/`logout` when several stored sessions share the name (meta carries `workspaceIds`), and `prisma project transfer` when a `--to-workspace` reference matches several authenticated workspaces (meta carries `workspaceRef` and `matches`, each match holding `id`, `name`, `credentialWorkspaceId`). Both point the user at `prisma auth workspace list` to retry with an exact workspace id. Meta: `workspaceIds` (workspace commands) or `workspaceRef`, `matches` (project transfer). + +### AUTH.WORKSPACE_NOT_AUTHENTICATED + +`prisma project transfer` could not resolve the transfer recipient: the `--to-workspace` reference matched no stored OAuth session, or the matched recipient session proved invalid. The suggested fix is to run `prisma auth login` and authorize that workspace, after checking `prisma auth workspace list`. Meta: `workspaceRef`. + +## BRANCH + +### BRANCH.API_ERROR + +A Management API call made by `prisma branch list` failed and the response body carried no API error code — when the body does carry one, that code passes through as `BRANCH.` instead. The `why` carries the API's message or, failing that, the HTTP status, and the fix suggests rerunning with `--log-level verbose` for the response details. Meta: none. + +## BUCKET + +### BUCKET.API_ERROR + +A bucket Management API request (listing, creating, or deleting buckets or bucket keys) failed and the response body carried no error code of its own — raised at the bucket commands' mapping boundary (`commands/bucket/errors.ts`) from the legacy `BUCKET_API_ERROR` shape; a response that does name a code passes through as `BUCKET.` instead. The `why` carries the API's message or HTTP status, and the fix is the API's hint when it sent one. Meta: none. + +### BUCKET.KEY_SECRET_MISSING + +`prisma bucket key create` created the key, but the Management API response omitted part of the one-time credential payload (secret access key, access key id, endpoint, or bucket name), so the CLI cannot show credentials it will never see again. The fix is to create another key and store the returned credentials immediately. Meta: none. + +### BUCKET.USAGE_ERROR + +A bucket subcommand was called without its required id argument: `bucket delete` and `bucket key create`/`bucket key list` need a bucket id, and `bucket key delete` needs both a bucket id and a key id. The nextActions point at `bucket list` (or `bucket key list`) to find the ids. Meta: none. + +## CLI + +### CLI.ABORTED + +The run's abort signal fired before the command completed — a thrown abort error is recognised in settlement and reported as this code rather than as a bug. When the abort came from a delivered SIGINT/SIGTERM the run exits 130/143; an abort with no recorded signal (an engine-internal abort) exits 3. Meta: none. + +### CLI.AUTH_SERVICE_ERROR + +The authentication service failed transiently while refreshing a stored OAuth session; the stored credentials are left untouched, and the guidance is to retry rather than sign in again, because the credentials themselves were not rejected. Meta: none. + +### CLI.BROWSER_WAIT_TIMEOUT + +A `ctx.prompt.browserWait` flow (the command opened a URL and polled for the user to finish there) reached its timeout before the poll succeeded. `prisma git connect` catches this code from its GitHub-app install wait and rethrows a command-specific error, so consumers usually see it from other browserWait flows. Meta: `url`, `timeoutMs`. + +### CLI.CHILD_PROCESS_FAILED + +Emitted only as a json-mode error envelope when a command that handed the terminal to a child process (`exitWithChildStatus`) saw that child exit non-zero or die on a signal; the run's exit code is the child's own status verbatim, not the CLI's usual 2. Meta: `exitCode`, `signal`. + +### CLI.COMMAND_MOVED + +The user typed a retired command path, or a retired flag on a surviving command, that the redirect table claims; instead of failing as unknown, the run names the replacement invocation as a run-command next action, with the retirement reason in `why` when one is recorded. Exits 2. Meta: none. + +### CLI.CONFIG_MISSING_MARKER + +The evaluated `prisma.config.ts` default export carries no `$prismaConfig` version marker — most likely a Prisma 7 config, which uses the same filename — so the loader stops rather than misread it; the fix is to wrap the exported object in `definePrismaConfig`. Raised by the config loader for any command with a `needs.config` section. The file's absolute path is in `where.path`. Meta: none. + +### CLI.CONFIG_NOT_FOUND + +The file `--config` named does not exist. Only an explicitly named file is an error — an absent `prisma.config.ts` found by discovery is fine, because section validators own absence and supply defaults. The path is in `where.path`. Meta: none. + +### CLI.CONFIG_SECTION_INVALID + +The config section a command declared in `needs.config` failed its validator; the individual problems travel as accompanying diagnostics on the envelope, and the summary names the section and the config file actually read (respecting `--config`). Raised by the engine's needs check before the handler runs. Meta: none. + +### CLI.CONFIG_UNKNOWN_SECTION + +The config file has a top-level key that is not a section any mounted command or command family declares; the set of section names is closed, so an unrecognised key is a typo or leftover the CLI refuses to silently ignore. The `why` lists the recognised section names, and the file path is in `where.path`. Raised by the engine's needs check, deliberately outside the host-replaceable loader. Meta: none. + +### CLI.CONFIG_UNREADABLE + +Evaluating `prisma.config.ts` threw. Two variants share the code: when the error chain shows the `prisma/config` entry point could not be resolved (the prisma package missing from the project, or too old), the guidance is to install the prisma package matching the CLI's version; otherwise the summary carries the first line of the evaluation error and the fix is to repair the file. The path is in `where.path`. Meta: none. + +### CLI.CONFIG_VERSION_UNSUPPORTED + +The config file's `$prismaConfig` marker declares a version other than the one this CLI supports; the fix is to regenerate the config with a matching `definePrismaConfig` or update the CLI. The path is in `where.path`. Meta: none. + +### CLI.CONSENT_REQUIRED + +A consent prompt was reached under `--yes` or in a non-interactive session, and consent is structurally undefaultable — `--yes` never grants it. When the consent declares a token, the message and next action say to pass `--confirm `, and the token travels in meta; without a token, the only path is running the command interactively. Meta: `consentToken` (only when the consent declares a token). + +### CLI.CREDENTIALS_LOCKED + +The advisory lock on the stored-credentials file was held by another prisma process for longer than the wait timeout, so this run's credential mutation gave up; the fix is to wait for the other command and retry. Raised by the auth state file's lock helper in `packages/cli`. Meta: none. + +### CLI.CREDENTIALS_REQUIRED + +The command needs a signed-in credential and none is usable. One constructor covers five reasons: not signed in at all, an expired session, a session expiring too soon for a command that hands credentials to a child process (which cannot refresh them), a workspace session that ended mid-run, and workspace sessions held with none selected as current. Raised identically by the engine's needs check, `ctx.activeCredential`, and the request path; next actions point at signing in or `prisma auth workspace use`. Meta: none. + +### CLI.CREDENTIALS_UNREADABLE + +The stored-credentials file exists but could not be read (any read failure other than the file being absent, which yields empty state instead); the guidance is to check the file's permissions. The underlying error is attached as `cause`. Meta: none. + +### CLI.INTERACTION_REQUIRED + +The session is not interactive (no TTY stdin, CI, or `--no-interactive`) and the command cannot proceed without a person. Two raise sites: the engine's needs check for a command declaring `needs.interaction`, and `ctx.prompt.browserWait`, which refuses to start a browser wait it could never finish — in that case the URL travels in the error so the user can finish there manually. Meta: `url` (browserWait raise only; none from the needs check). + +### CLI.INTERNAL_ERROR + +A bug, not a user error: a non-structured throw from a handler, an engine invariant violation (undocumented exit code, malformed result), or a stricli internal failure that never settled. The summary is the first line of the underlying error message. Exits 1, the taxonomy's bug code, rather than the usual 2. Meta: none. + +### CLI.INVALID_ARGUMENTS + +The invocation's arguments did not parse or contradict each other. Raise sites: stricli's argument-parse failure mapped at the adapter boundary (its usage text becomes summary and `why`), `--config=` given an empty value, `prisma init --skills` given `none` combined with agent names or an unknown agent name, and `prisma skills sync` given both `--disable` and `--enable`. Exits 2 as a usage error. Meta: none. + +### CLI.MISSING_DEPENDENCY + +A command declared an optional peer dependency in `needs.dependencies` that does not resolve from the project; the engine probes with `require.resolve` and phrases the install command for the package manager it detected. Meta: `specifier`, `installCommand`. + +### CLI.PACKAGE_MANAGER_FAILED + +A `ctx.packages` operation (an install, or running a package through the manager) exited non-zero — or nothing ran at all because the host wires no package-manager runner, recorded in `meta.reason`. The redacted command line is offered as a run-command next action so the user can run it themselves. Meta: `form`, `manager`, `command`, `exitCode`, `stderrTail`, `reason` (only when the runner is unavailable). + +### CLI.PROMPT_CANCELLED + +The user cancelled a prompt: EOF on stdin at a line-rendered prompt, a clack cancel (Ctrl-C at the prompt UI), an abort during a browserWait poll, or — via the service commands' `userCancelledError` — consent declined interactively. Settles with exit 3, the cancellation code, instead of 2. Meta: none. + +### CLI.PROMPT_INVALID + +An answer could not be interpreted: not a yes/no for a confirm, not one of a select's options with no default to fall back to, or a consent token typed wrong where re-prompting is impossible (scripted answers or piped stdin — the interactive clack renderer re-prompts instead). Meta: `consentToken` (token-mismatch raise only). + +### CLI.PROMPT_REQUIRED + +A prompt with no declared default was reached where it cannot be shown — under `--yes` or in a non-interactive session — so the run halts; the fix is to run from an interactive terminal or pass a flag that answers the prompt. Meta: none. + +### CLI.SPAWN_FAILED + +`ctx.spawn` could not start the requested program, or the child's lifecycle promise rejected; the first line of the underlying failure is in `why`, and the guidance is to check the program is installed and on PATH. Meta: `command`. + +### CLI.TELEMETRY_PREFERENCE_UNAVAILABLE + +The `prisma telemetry status|enable|disable` commands could not resolve the user-level config directory because none of XDG_CONFIG_HOME, HOME, APPDATA, or USERPROFILE is set; unreachable in production, where HOME or USERPROFILE is always set. Meta: none. + +### CLI.UNKNOWN_COMMAND + +The typed path routed to no command (stricli's route failure, mapped at the adapter boundary) and no redirect claims it; next actions carry up to three "did you mean" suggestions ranked by edit distance over command paths and their prefixes, plus a pointer to `--help`. Exits 2. Meta: none. + +## FEEDBACK + +### FEEDBACK.EMAIL_INVALID + +`prisma feedback --email` was given a value that fails the CLI's local check (a basic address pattern, at most 320 characters), which mirrors the feedback service's own limit so the refusal happens before any network round trip. The suggested actions are to pass a valid address or drop the flag to send anonymously. Meta: none. + +### FEEDBACK.MESSAGE_REQUIRED + +`prisma feedback` was run with a message that is empty after trimming. The fix is to pass a non-empty message. Meta: none. + +### FEEDBACK.MESSAGE_TOO_LONG + +The `prisma feedback` message exceeds 4000 characters, the feedback service's limit, checked locally before the network round trip; the actual length and the limit are stated in `why`. The fix is to shorten the message. Meta: none. + +### FEEDBACK.SEND_FAILED + +`prisma feedback` could not deliver the submission: the feedback endpoint was unreachable (or timed out), the service answered a non-OK HTTP status (the status and any service-supplied error message go into `why`), or the response body could not be read; a body that arrived but was not JSON is treated as success, and a user cancellation is rethrown rather than wrapped. The suggested action is to check the network and rerun. Meta: none. + +## GIT + +### GIT.REPO_ALREADY_CONNECTED + +`prisma git connect` found the resolved project already connected to a different GitHub repository than the one requested (reconnecting the same repository is idempotent and succeeds). The fix is to run `prisma git disconnect` first. Meta: `repository`. + +### GIT.REPO_CONNECTION_FAILED + +A management API call in the `prisma git connect`/`disconnect` flow failed — creating the install intent, listing installations or repositories, reading or writing the source-repository connection, or a pagination cursor that stopped advancing; the API's message and hint, when present, become `why` and the suggested fix, and a 401/403 is routed to the auth error path instead. Meta: `status`, `apiCode` (when the API supplied one). + +### GIT.REPO_INSTALLATION_REQUIRED + +`prisma git connect` waited for a GitHub App installation but the wait ended (timeout or final poll) with the workspace still holding no inspectable installation that could link the repository. The fix is to finish installing the GitHub App at the install URL, then rerun `prisma git connect`; the URL is also offered as an open-url next action. Meta: `repository`, `installUrl`. + +### GIT.REPO_NOT_ACCESSIBLE + +`prisma git connect` waited for repository access but the wait ended with the workspace's existing GitHub App installations still not exposing the requested repository — the same install wait as `GIT.REPO_INSTALLATION_REQUIRED`, distinguished by at least one inspectable installation existing. The fix is to grant the App access to this repository at the install URL, then rerun `prisma git connect`. Meta: `repository`, `installUrl`. + +### GIT.REPO_NOT_CONNECTED + +`prisma git disconnect` found no active GitHub repository connection on the resolved project, so there is nothing to disconnect. The fix is to run `prisma git connect` first. Meta: none. + +### GIT.REPO_PROVIDER_UNSUPPORTED + +The URL given to `prisma git connect` (or read from the local `origin` remote) did not parse as a GitHub repository URL; repository connection supports GitHub only. The fix is to pass a GitHub repository URL. Meta: none. + +### GIT.USAGE_ERROR + +`prisma git connect` was run with no repository URL argument and the local repository has no `origin` remote to fall back on. The fix is to pass a GitHub repository URL or add a GitHub `origin` remote and rerun. Meta: none. + +## INIT + +### INIT.CONFIG_KEPT + +A warn diagnostic from `prisma init`: a `prisma.config.ts` already exists that does not configure `skills.agents`, and init never edits an existing config, so it was left untouched. The nextAction carries the exact `skills: { agents: [...] }` snippet to add to the `definePrismaConfig` call; a config that already sets `skills.agents` produces no diagnostic at all. Meta: none. + +### INIT.CONFIG_UNWRITABLE + +A warn diagnostic from `prisma init`: writing the scaffolded `prisma.config.ts` failed, so the config step was skipped. The nextAction asks the user to create the file themselves with the intended `skills: { agents: [...] }` section. Meta: none. + +### INIT.DEV_DEPENDENCIES_NOT_AN_OBJECT + +A warn diagnostic from `prisma init`: the `devDependencies` field in package.json is not an object, so init did not add the `prisma` dev dependency (the postinstall-hook edit still proceeds when its own field is fine). The nextAction is to fix the field, add `"prisma": ""` by hand, and run the package manager's install. Meta: none. + +### INIT.NO_PACKAGE_JSON + +A warn diagnostic from `prisma init`: there is no package.json in the current directory, so neither the skills-sync postinstall hook nor the `prisma` dev dependency was added. The nextActions are to rerun init from the directory that holds package.json, or to add the dependency by hand. Meta: none. + +### INIT.PACKAGE_JSON_UNREADABLE + +A warn diagnostic from `prisma init`: package.json exists but could not be parsed as JSON, so the postinstall hook and the `prisma` dev dependency were not added. The nextActions give the exact postinstall script and dependency entry to add by hand. Meta: none. + +### INIT.PACKAGE_JSON_UNWRITABLE + +A warn diagnostic from `prisma init`: the manifest edit was prepared but writing package.json back failed, so the file was left unchanged. The nextActions cover only what the failed write would have added — the postinstall script, the dev dependency, or both. Meta: none. + +### INIT.POSTINSTALL_KEPT + +A warn diagnostic from `prisma init`: package.json already has a postinstall script that is not the CLI's own, and init never overwrites a user's script, so it was left alone. The nextAction is to append `prisma skills sync || exit 0` to the existing script yourself so skills resync on every install. Meta: none. + +### INIT.SCRIPTS_NOT_AN_OBJECT + +A warn diagnostic from `prisma init`: the `scripts` field in package.json is not an object, so init left the manifest untouched instead of adding the postinstall hook (which also skips adding the dev dependency, since the manifest is not edited at all). The nextAction gives the postinstall script to add by hand. Meta: none. + +### INIT.SKILLS_SYNC_FAILED + +A warn diagnostic from `prisma init`: the final step, syncing the agent skills from installed Prisma packages, threw; the cause's message is appended to the summary. The nextAction is to retry with `prisma skills sync` on its own. Meta: none. + +## POSTGRES + +### POSTGRES.AMBIGUOUS + +A database name passed to a `prisma postgres` subcommand matched more than one database in the resolved project — raised by the shared database resolver (`controllers/database.ts`, legacy `DATABASE_AMBIGUOUS`). The fix is to pass the database id, or `--branch ` to narrow the match; the candidates are carried in meta as `matches`, each with `id`, `name`, and `branchName`. Meta: `matches`. + +### POSTGRES.API_ERROR + +A database Management API request failed without a more specific code — the generic fallback for every `prisma postgres` operation (legacy `DATABASE_API_ERROR`), also raised when a database response omits its project id; a response that names its own error code passes through as `POSTGRES.` instead. The `why` carries the API's message or HTTP status, and the fix is the API's hint when it sent one. Meta: none. + +### POSTGRES.BACKUP_NOT_FOUND + +`prisma postgres backup restore` got a 404 from the restore endpoint; the source and target databases are resolved before the call, so the 404 identifies the backup id (legacy `DATABASE_BACKUP_NOT_FOUND`). The fix is to pass a backup id from `prisma postgres backup list `. Meta: none. + +### POSTGRES.BACKUPS_UNSUPPORTED + +Listing backups returned a 422 because the platform does not manage backups for this database — for example a remote/BYO database (legacy `DATABASE_BACKUPS_UNSUPPORTED`, raised by `prisma postgres backup list`). The fix is to use your own backup tooling for externally managed databases. Meta: none. + +### POSTGRES.CONNECTION_MISSING + +`prisma postgres create` created the database, but the API response did not include the first one-time connection payload (legacy `DATABASE_CONNECTION_MISSING`). The fix is to create a connection explicitly with `prisma postgres connection create `. Meta: none. + +### POSTGRES.CONNECTION_STRING_MISSING + +A connection create or rotate succeeded, but the API response did not include the one-time connection string the CLI would show exactly once (legacy `DATABASE_CONNECTION_STRING_MISSING`, raised by `prisma postgres create`, `postgres connection create`, and `postgres connection rotate`). The fix is to rerun the operation, or create a replacement connection and store the returned URL immediately. Meta: none. + +### POSTGRES.NOT_FOUND + +The database a `prisma postgres` subcommand targets could not be resolved: either no database matched the given id or name in the project (and optional `--branch`) scope, or a database that was just listed returned 404 on read because it was removed while the command ran (legacy `DATABASE_NOT_FOUND`, raised by the shared resolver in `controllers/database.ts`). The fix is to pass an id or name from `prisma postgres list`. Meta: none. + +### POSTGRES.PLAN_LIMIT_REACHED + +A database operation was blocked because the workspace has used up the operations included in its plan — the API's structured plan-limit discriminator, detected on any `prisma postgres` Management API call; this is a workspace plan restriction, not a Prisma outage. The one nextAction is to upgrade the workspace plan, with the upgrade URL and current plan name when the workspace subscription could be read. Meta: `workspaceId`, `blockedFeature`, `planName`, `usageBlocked`, `upgradeUrl` (each `null` when unavailable). + +### POSTGRES.RESTORE_CONFLICT + +`prisma postgres backup restore` got a 409 because the target database is provisioning or already recovering (legacy `DATABASE_RESTORE_CONFLICT`). The fix is to wait for the database to become ready — check with `prisma postgres show ` — then retry. Meta: none. + +### POSTGRES.USAGE_ERROR + +A `prisma postgres` subcommand was called with missing or invalid arguments: `create` without a name, `connection delete`/`connection rotate` without a connection id, `backup restore` without `--backup`, or `usage` with `--from` later than `--to`. The nextActions show the corrected command form. Meta: none. + +## PROJECT + +### PROJECT.AMBIGUOUS + +Project resolution matched more than one project: an explicit project reference (matched by id first, then by name) hit several projects, or the implicit directory context did — raised by any command that resolves a project, including `branch list`. The fix is to pass `--project `, and the next actions include `prisma project link ` with the first match's id verbatim so the user can copy an exact disambiguating reference. Meta: `matches`. + +### PROJECT.API_ERROR + +A Management API project operation (list, rename, delete, or transfer) failed and the response body carried no API error code; a body that does carry one passes through as `PROJECT.`. Listing projects deliberately throws this rather than returning an empty list, so a rejected request is distinguishable from a workspace that genuinely has no projects. The `why` carries the API's message or HTTP status. Meta: none. + +### PROJECT.CONFIRMATION_REQUIRED + +Reserved by the project group's legacy-error mapper (`commands/project/errors.ts`) for the legacy `CONFIRMATION_REQUIRED` CliError code, but nothing in the current source raises that code: the destructive `project delete` and `project transfer` commands confirm through the engine's consent prompt, which raises `CLI.CONSENT_REQUIRED` instead. Meta: none. + +### PROJECT.CREATE_FAILED + +The platform rejected creating a project — raised by `prisma project create` and by the create-a-new-project path of `prisma project link`. An HTTP 401/403 gets a permissions-focused fix; any other failure surfaces the underlying error message as the `why`. Meta: none. + +### PROJECT.DELETE_BLOCKED + +The Management API answered `prisma project delete` with HTTP 400, which typically means the project still has active deployments; the fix is to delete the project's services first (`prisma service delete --service `) and retry. The API's own message replaces the default `why` when present. Meta: none. + +### PROJECT.ENV_API_ERROR + +A Management API call made by the `prisma project env` commands (reading, writing, or deleting variables, or resolving and creating branches for a scope) failed without an API error code in the body — a body carrying one passes through as `PROJECT.`, and an HTTP 401/403 is converted to the auth-required error instead of this code. Meta: none. + +### PROJECT.ENV_BRANCH_CREATE_REQUIRES_DEFAULT_BRANCH + +`prisma project env add --branch ` will create a missing preview branch, but the project has no default branch yet, and creating the first branch would make it the default while branch env overrides are preview-only. The fix is to create or deploy the default branch first, for example via `prisma git connect`. Meta: none. + +### PROJECT.ENV_BRANCH_NOT_FOUND + +A `prisma project env` update, list, or delete named a branch scope (`--branch `) that does not exist — only `env add` creates missing branches. The fix is to create the branch by deploying it, or to use `project env add --branch` to create its first override. Meta: none. + +### PROJECT.ENV_BRANCH_SCOPE_IS_PRODUCTION + +A `prisma project env` command's `--branch` flag resolved to the project's production branch; production variables are project-level only, and branch overrides apply to preview branches. The fix is to use `--role production` instead. Meta: none. + +### PROJECT.ENV_FILE_APPLY_FAILED + +`prisma project env add --file` or `update --file` failed while writing one of the file's keys, after zero or more earlier keys were already written. The `why` names the keys written before the failure and the underlying cause, and the next actions include a retry command scoped to a file of the remaining keys. Meta: `file`, `failedKey`, `writtenKeys`. + +### PROJECT.ENV_PREVIEW_DEFAULT_MISSING + +Not an error: a warn diagnostic emitted on a successful `prisma project env add` to a branch scope (single key or `--file`) for each key that has no preview-level default, meaning the variable will exist only on that branch. Meta: none. + +### PROJECT.ENV_VARIABLE_ALREADY_EXISTS + +`prisma project env add` targeted a key (or, in `--file` mode, one or more keys) that already exists in the targeted scope. The single-key fix is to use `prisma project env update`; the file-mode fix is to split the input file and update existing keys separately from adding new ones. Meta: `keys` (file mode only; the single-key form carries no meta). + +### PROJECT.ENV_VARIABLE_NOT_FOUND + +`prisma project env update` or `env delete` targeted a key (or, in update's `--file` mode, one or more keys) that does not exist in the targeted scope. The fix for update is to create the variable with `env add` (or split a mixed file); for delete it is to list the scope's variables first. Meta: `keys` (file-mode update only; the single-key forms carry no meta). + +### PROJECT.LINK_TARGET_REQUIRED + +Reserved by the project group's legacy-error mapper (`commands/project/errors.ts`) for the legacy `PROJECT_LINK_TARGET_REQUIRED` CliError code, but nothing in the current source raises that code: `prisma project link` without a target prompts interactively and reports a cancelled prompt as a usage error instead. Meta: none. + +### PROJECT.LOCAL_STATE_STALE + +The local project binding in `.prisma/local.json` is unusable: the pinned project is no longer in the selected workspace's project list, or the pin file is invalid JSON or has an invalid shape — raised by any command that resolves the project implicitly through the pin. The fix is to delete the pin file and choose a project explicitly. Meta: `pinPath`. + +### PROJECT.LOCAL_STATE_WRITE_FAILED + +`prisma project link` or `project create` could not save the local binding: writing `.prisma/local.json` failed, or updating `.gitignore` to keep the binding out of git failed — the fix is to check directory permissions and retry. The same code is also emitted as a warn diagnostic (not an error) by `project delete` and `project transfer` when the operation itself succeeded but the now-stale local pin could not be removed or rewritten. Meta: `pinPath` or `gitignorePath`, plus `operation` (the error form; the diagnostic form carries none). + +### PROJECT.LOCAL_WORKSPACE_MISMATCH + +`.prisma/local.json` links the directory to a project in one workspace, but the CLI session's active workspace is a different one — raised by any command that resolves the project through the pin. The fix is to switch to the pinned workspace (`prisma auth workspace use `) or relink the directory to a project in the current workspace. Meta: `pinPath`, `pinnedWorkspaceId`, `pinnedProjectId`, `activeWorkspaceId`, `activeWorkspaceName`. + +### PROJECT.NOT_FOUND + +An explicit project reference matched no project in the active workspace, either because it does not exist or because the credential cannot see it — raised during project resolution for any command that accepts one, including `branch list` and the `project link`/`transfer`/`delete` target lookup. The fix is to pass an id or name from `prisma project list`. Meta: none. + +### PROJECT.RENAME_FAILED + +The Management API answered `prisma project rename` with HTTP 400 or 422, meaning the platform rejected the new name; the API's message and hint replace the default `why` and fix when present, and the fallback fix is to retry with a different name. Meta: none. + +### PROJECT.SETUP_REQUIRED + +A command needed a project but the directory is not linked and no `--project` flag was given; the CLI deliberately refuses to pick a project from package or directory names, treating them as suggestions only. The meta carries the inferred name suggestion and any matching candidate projects, and the next actions walk through choosing between linking an existing project (`prisma project link`) and creating a new one. Meta: `suggestedProjectName`, `suggestedProjectNameSource`, `candidates`, `recoveryCommands`. + +### PROJECT.TRANSFER_RECIPIENT_REQUIRED + +`prisma project transfer` was invoked without naming a receiving workspace: neither `--to-workspace ` (for a locally authenticated workspace) nor `--recipient-token ` (for a cross-account transfer) was passed. Meta: none. + +### PROJECT.TRANSFER_RECIPIENT_UNAVAILABLE + +`prisma project transfer --to-workspace` needs to resolve locally stored OAuth workspace sessions, but `PRISMA_SERVICE_TOKEN` is set and service-token mode does not read them. The fix is to pass `--recipient-token ` for the receiving workspace, or to unset the service token. Meta: none. + +### PROJECT.TRANSFER_REJECTED + +The Management API answered `prisma project transfer` with HTTP 400 — for example because the recipient token is invalid or expired; the API's message replaces the default `why` when present, and the fix is to check the recipient session or token and retry. Meta: none. + +### PROJECT.USAGE_ERROR + +A `project` or `branch` group command was invoked with unusable arguments — for example a `project env` write without an explicit `--role` or `--branch` scope, `project transfer` with both recipient flags at once, `project create` with an empty name, or an interactive `project link` whose selection was cancelled. Usage errors exit 2. Meta: none. + +## SERVICE + +### SERVICE.BRANCH_INVALID + +A `--branch` flag was passed with an empty or whitespace-only value to a service command; the check runs before any resolution because a blank value must never fall through to the default-branch behavior of omitting the flag. The fix is to pass a non-empty branch name, or omit `--branch` to target the default branch. Meta: none. + +### SERVICE.BRANCH_NOT_DEPLOYABLE + +A `service domain` command was pointed at a non-production branch, which the domain-target resolver refuses because custom domains on preview branches are not supported in Public Beta. The fix is to use `--branch production`, or attach the domain after promoting to the production branch. Meta: none. + +### SERVICE.DELETE_FAILED + +`service delete` called the platform's app-teardown API and it rejected; the underlying error's message becomes `why` and the original error is carried in `cause`. Next actions point at `service show` and `service version list` for the service. Meta: none. + +### SERVICE.DEPLOY_FAILED + +The general "Management API call failed" wrapper for the `service` command family — despite the name there is no deploy command here: it wraps failures to create a service, list services or versions, show/promote/roll back/delete/start/stop a version, resolve a service URL, and unrecognized custom-domain API failures. The underlying error's message becomes `why` and the original error is carried in `cause`; each raise site attaches its own next actions. On the domain fallback path only, a `DomainApiError` adds debug meta. Meta: `status`, `apiCode`, `hint` (domain fallback path only; otherwise none). + +### SERVICE.DOMAIN_ALREADY_REGISTERED + +`service domain add` got HTTP 409 from the domain API because the hostname is already registered, to this or another service. The fix is to delete the domain on the service that owns it, or contact Prisma support if that service is not accessible. Meta: `status`, `apiCode`, `hint`. + +### SERVICE.DOMAIN_DNS_NOT_CONFIGURED + +`service domain add` got HTTP 400 or 422 whose message the CLI recognises as a DNS problem (no CNAME, DNS verification failed, and similar). When the API's text names a `*.prisma.build` target, the CLI composes the exact CNAME record to add and carries it in `meta.dnsRecord` and in the advice action; without a target it advises rerunning with `--log-level verbose` to see the API response. Meta: `status`, `apiCode`, `hint`, `dnsRecord` (when the DNS target could be extracted). + +### SERVICE.DOMAIN_HOSTNAME_INVALID + +The hostname given to a `service domain` command is not a usable custom domain — raised either by local validation before any API call (protocol, path, port, wildcard, single label, or bad DNS labels) or when `service domain add` gets a plain HTTP 400 rejection from the domain API. The fix is to pass a bare hostname such as `shop.acme.com`. Meta: `status`, `apiCode`, `hint` (API-rejection path only; none for local validation). + +### SERVICE.DOMAIN_NOT_FOUND + +A `service domain` command targeted a hostname that is not attached to the resolved service — raised when the service's domain listing has no matching hostname, or when a show/delete/retry/wait call gets HTTP 404. The fix is to check the hostname and service, or add the domain first. Meta: none. + +### SERVICE.DOMAIN_QUOTA_EXCEEDED + +`service domain add` was refused because the custom-domain quota is reached — HTTP 429, or a 409 whose text mentions a quota, maximum, or limit. The fix is to delete an existing custom domain before adding another. Meta: `status`, `apiCode`, `hint`. + +### SERVICE.DOMAIN_RETRY_NOT_ELIGIBLE + +`service domain retry` got HTTP 409: the domain is not in a state that can be retried, typically because a verification or TLS step is still in progress. The fix is to wait for the current step to finish and retry only if the domain then fails. Meta: `status`, `apiCode`, `hint`. + +### SERVICE.DOMAIN_VERIFICATION_FAILED + +`service domain wait` observed the domain reach the terminal `failed` status; `why` carries the platform's failure category and reason when reported, and a failure-specific fix line is attached when the CLI can derive one from the domain record. Next actions point at `service domain show` and `service domain retry`. Meta: none. + +### SERVICE.DOMAIN_VERIFICATION_TIMEOUT + +`service domain wait` ran out of time (default 15m, or `--timeout 0` for a single check) before the domain became active; `why` reports the status the domain was last seen in. The fix is to inspect the domain with `service domain show` or rerun the wait with a longer `--timeout`. Meta: none. + +### SERVICE.FEATURE_UNAVAILABLE + +`service open` found a live version but the provider does not expose a stable live service URL for this service yet, so there is nothing to open. The next action is to inspect the service state with `service show`. Meta: none. + +### SERVICE.LIVE_VERSION_UNKNOWN + +`service version rollback` without `--to` needs to know which version is live, because the default rollback target is defined relative to it, and the service record names no live version — the CLI refuses rather than guess what production is serving. The fix is to pass `--to ` explicitly. Meta: none. + +### SERVICE.LOGS_FAILED + +`service logs` could not read the log — either the logs endpoint answered with a non-OK HTTP status (other than 404, which becomes SERVICE.VERSION_NOT_FOUND), or a page's closing terminal record reported `kind: "error"`, meaning the platform itself says the log read failed. The meta differs by path: the HTTP path carries `status`; the terminal-record path carries the platform's error `code`, `retryable`, and the resume `cursor` when present (in `--follow` mode one retryable terminal error is retried once before this settles the run). Meta: `status` (HTTP path) or `code`, `retryable`, `cursor` (stream path). + +### SERVICE.LOGS_INCOMPLETE + +A `service logs` response body ended without the terminal record that closes a page, so the read was truncated and the lines already printed may be only part of the page; the run must not settle as if it had read the whole page. Distinct from SERVICE.LOGS_NO_CURSOR, which is a properly closed page with nothing to resume from. The fix is to rerun the command. Meta: none. + +### SERVICE.LOGS_NO_CURSOR + +`service logs --follow` needs a resume cursor from each page to fetch the next one, and the page ended without one — continuing would re-request the default tail and silently print the same lines every interval, so the run stops and says why. It settles as an error rather than a clean end because `--follow` has no successful ending. The fix is to rerun without `--follow`, or retry if the version is still starting. Meta: none. + +### SERVICE.LOGS_RANGE_CONFLICT + +`service logs` was invoked with both `--tail` and `--from-start`, which ask for opposite ends of the log; the run is refused before any work. The fix is to pass `--tail ` for the last n lines or `--from-start` for the whole log, not both. Meta: none. + +### SERVICE.NAME_REQUIRED + +`service create` received an empty or whitespace-only name positional; the check runs before any resolution. The fix is to pass a name, as in `service create my-service`. Meta: none. + +### SERVICE.NO_PREVIOUS_VERSION + +`service version rollback` without `--to` found no earlier version to switch back to — the service has no versions at all, or every version is the live one. The fix is to deploy a second version first, or pass `--to ` for a specific version. Meta: none. + +### SERVICE.NO_VERSIONS + +The resolved service has no usable version for the command — raised by `service open` when the service has no versions, by `service logs` when it has no live version, and by `service domain add` when the API answers 422 because the production service has no promoted version that can receive a custom domain. The fix on the domain path is to promote a version on the production branch first, then add the domain again. Meta: `status`, `apiCode`, `hint` (domain-add path only; otherwise none). + +### SERVICE.PROJECT_NOT_FOUND + +The project a service command resolved to does not exist in the authenticated workspace or is no longer accessible — raised directly when listing services answers "Resource Not Found" for the resolved project id, and via the legacy-error mapping when project resolution cannot match an explicit `--project` reference (every legacy service-family error code is prefixed `SERVICE.` at that boundary). Next actions point at `project show` to inspect the directory binding and `project link` to fix it. Meta: none. + +### SERVICE.SELECTION_INVALID + +The named service could not be found among the resolved project branch's services — the match tries the stable platform id first, then the name. The fix is to pass the id or name of an existing service; the suggested command is `service list`, deliberately not `service version list`, which itself has to resolve a service and would fail the same way. Meta: none. + +### SERVICE.TARGET_REQUIRED + +A service command that acts on an existing service was run without naming one; service commands act only on an explicitly named target — nothing is inferred, remembered, or prompted for. The fix is to pass the service id or name as the first argument, with `service list` to find one. Meta: none. + +### SERVICE.TIMEOUT_INVALID + +The `--timeout` value passed to `service domain wait` is not a duration the parser accepts (`0`, or an integer with `ms`/`s`/`m`/`h`, such as `30s` or `15m`). Meta: none. + +### SERVICE.VERSION_ALREADY_LIVE + +Not an error: a warn-severity diagnostic attached by `service version promote` and `service version rollback` when the selected version is already live for the service — the command skips the promote call, still reports the result, and exits 0. Meta: none. + +### SERVICE.VERSION_ALREADY_RUNNING + +Not an error: a warn-severity diagnostic attached by `service version start` when the selected version already reports `running` status — the start call is skipped, the result carries `alreadyInState: true`, and the run exits 0. Meta: none. + +### SERVICE.VERSION_ALREADY_STOPPED + +Not an error: a warn-severity diagnostic attached by `service version stop` when the selected version already reports `stopped` status — the stop call is skipped, the result carries `alreadyInState: true`, and the run exits 0. Meta: none. + +### SERVICE.VERSION_DETACHED + +A version was resolved by its globally-unique id but the Management API returned it without an owning service, so there is nothing to report or act on it as. The next action shows the version with `service version show`. Meta: none. + +### SERVICE.VERSION_NOT_FOUND + +The requested service version does not exist or is not available — raised when resolving a version by its globally-unique id finds nothing (including a 404 from the logs endpoint), and, in the "for service" variant used by rollback and logs `--version-id`, when the id exists but does not belong to the resolved service. The fix is to pick an id from `service version list`. Meta: none. + +### SERVICE.WORKSPACE_REQUIRED + +A service command ran without a credential that names a workspace — either no authenticated session, or an environment token whose claims carry no workspace, which cannot scope these commands and is treated the same as having no credential. The fix is `auth login`. Meta: none. + +## SKILLS + +### SKILLS.CONFIG_INVALID + +An error diagnostic from validating the `skills` section of `prisma.config.ts`, surfaced by the commands that consume it (`init`, `skills sync`, `skills list`): the section is not an object, `skills.check` is not a boolean, `skills.agents` is not an array of strings, or an agent name is one this CLI does not know. Each variant's nextAction says what to write instead; code that reads the config outside a command handler (the staleness check, the post-login tip) treats an invalid config as absent and falls back to the default agent set rather than silencing the check. Meta: none. + +### SKILLS.UNMANAGED_DIRECTORY + +A warn diagnostic from the skills sync (`skills sync`, and the sync step of `init`): a target agent skill directory already holds a skill this CLI does not manage, so sync left it untouched instead of installing the packaged skill there. The nextAction is to move or remove the unmanaged directory and rerun `skills sync`. Meta: none. + +### SKILLS.VERSION_CONFLICT + +A warn diagnostic from the skills sync (`skills sync`, and the sync step of `init`): workspace members install different versions of the same skill-bearing Prisma package, so the skills for the highest version were installed and the members pinning a lower version get a skill describing a version they did not install. The nextAction is to pin one version of the package across the workspace. Meta: none. diff --git a/package.json b/package.json index 066ac815..e9cc242d 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "build:cli": "turbo run build --filter=@prisma/cli", "build:compute": "pnpm --filter @prisma/compute build", "check:grammar": "turbo run check:grammar", + "check:error-reference": "node scripts/list-error-codes.mjs --verify docs/reference/error-reference.md", "format": "biome format . --write", "lint": "biome check . --error-on-warnings", "lint:fix": "biome check . --write", diff --git a/packages/cli/src/cli-name.ts b/packages/cli/src/cli-name.ts index 7cb2d549..065536d5 100644 --- a/packages/cli/src/cli-name.ts +++ b/packages/cli/src/cli-name.ts @@ -8,8 +8,14 @@ */ export const CLI_NAME = "prisma"; -/** The CLI docs page (also the update-check fallback instruction URL). - * The old /docs/orm/tools/prisma-cli path 308-redirects to the ORM CLI - * reference — the wrong docs for the unified CLI — so this points at - * the docs root until the unified CLI has its own page. */ -export const CLI_DOCS_URL = "https://www.prisma.io/docs"; +/** The unified CLI's docs section (also the update-check fallback + * instruction URL). */ +export const CLI_DOCS_URL = "https://www.prisma.io/docs/cli"; + +/** + * Base URL for structured-error documentation links. The engine composes + * each diagnostic's docsUrl as base + code; every code is documented at + * this page (registry: docs/reference/error-reference.md). + */ +export const DOCS_ERRORS_BASE_URL = + "https://www.prisma.io/docs/cli/error-reference/"; diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 9ee20d43..8454f6ff 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -8,7 +8,7 @@ import { } from "@prisma/cli-engine"; import { createComposerFamily } from "@prisma/composer-cli/family"; import { ormCommandFamily as ormToolchainFamily } from "@prisma/orm-toolchain/cli"; -import { CLI_DOCS_URL, CLI_NAME } from "./cli-name"; +import { CLI_DOCS_URL, CLI_NAME, DOCS_ERRORS_BASE_URL } from "./cli-name"; import { authLoginCommand } from "./commands/auth/login"; import { authLogoutCommand } from "./commands/auth/logout"; import { authWhoamiCommand } from "./commands/auth/whoami"; @@ -70,6 +70,7 @@ import { skillsCommandFamily } from "./commands/skills/family"; import { getCliVersion } from "./lib/version"; export const platformCommandFamily: CommandFamily = defineCommandFamily({ + docsBaseUrl: DOCS_ERRORS_BASE_URL, commands: { login: authLoginCommand, logout: authLogoutCommand, diff --git a/packages/cli/src/commands/skills/family.ts b/packages/cli/src/commands/skills/family.ts index a08c049a..d93e8f17 100644 --- a/packages/cli/src/commands/skills/family.ts +++ b/packages/cli/src/commands/skills/family.ts @@ -1,4 +1,5 @@ import { type CommandFamily, defineCommandFamily } from "@prisma/cli-engine"; +import { DOCS_ERRORS_BASE_URL } from "../../cli-name"; import { skillsConfigSection } from "./config"; import { skillsListCommand } from "./list"; import { skillsSyncCommand } from "./sync"; @@ -8,6 +9,7 @@ import { skillsSyncCommand } from "./sync"; * part of either product's. */ export const skillsCommandFamily: CommandFamily = defineCommandFamily({ configSection: skillsConfigSection, + docsBaseUrl: DOCS_ERRORS_BASE_URL, commands: { sync: skillsSyncCommand, list: skillsListCommand, diff --git a/packages/cli/tests/update-check.test.ts b/packages/cli/tests/update-check.test.ts index f823e8c6..cfc88fcb 100644 --- a/packages/cli/tests/update-check.test.ts +++ b/packages/cli/tests/update-check.test.ts @@ -116,7 +116,7 @@ describe("update discovery and instructions", () => { argv: ["node", "/Users/alice/.npm/_npx/123/node_modules/.bin/prisma-cli"], expected: { type: "docs", - value: "https://www.prisma.io/docs", + value: "https://www.prisma.io/docs/cli", }, }, { @@ -128,7 +128,7 @@ describe("update discovery and instructions", () => { argv: ["node", "/repo/node_modules/.bin/prisma-cli"], expected: { type: "docs", - value: "https://www.prisma.io/docs", + value: "https://www.prisma.io/docs/cli", }, }, { @@ -137,7 +137,7 @@ describe("update discovery and instructions", () => { argv: ["node", "/Users/alice/.bun/install/cache/@prisma/cli/prisma-cli"], expected: { type: "docs", - value: "https://www.prisma.io/docs", + value: "https://www.prisma.io/docs/cli", }, }, { @@ -146,7 +146,7 @@ describe("update discovery and instructions", () => { argv: ["node", "/some/path/prisma-cli"], expected: { type: "docs", - value: "https://www.prisma.io/docs", + value: "https://www.prisma.io/docs/cli", }, }, ])("selects update instructions for $name", ({ env, argv, expected }) => { diff --git a/scripts/list-error-codes.mjs b/scripts/list-error-codes.mjs new file mode 100644 index 00000000..2f2afc81 --- /dev/null +++ b/scripts/list-error-codes.mjs @@ -0,0 +1,129 @@ +#!/usr/bin/env node +/** + * Enumerate every published error code (dotted `NAMESPACE.SUBCODE`, see + * docs/product/error-conventions.md) defined in production source, for the + * error registry at docs/reference/error-reference.md and the hosted + * reference at https://docs.prisma.io/docs/cli/error-reference. + * + * Modes: + * node scripts/list-error-codes.mjs # JSON to stdout + * node scripts/list-error-codes.mjs --format markdown # reference skeleton + * node scripts/list-error-codes.mjs --verify # exit 1 + list any + * # known code missing + * # from the given file + * --root repo root to scan (default: this script's parent repo); + * lets the docs-site repo run it against a prisma-cli checkout. + * + * Codes are string literals whose namespace is on the closed list below. + * Scanned: git-tracked .ts files under each package's src tree (production + * source only — tests assert codes, they don't define them). + */ + +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { argv, exit, stderr, stdout } from "node:process"; +import { fileURLToPath } from "node:url"; + +const NAMESPACES = [ + "AUTH", + "BRANCH", + "BUCKET", + "CLI", + "FEEDBACK", + "GIT", + "INIT", + "POSTGRES", + "PROJECT", + "SERVICE", + "SKILLS", +]; + +const CODE_RE = new RegExp( + `["']((?:${NAMESPACES.join("|")})\\.[A-Z][A-Z0-9_]*)["']`, + "g", +); + +const TEST_FILE_RE = /\.(test|test-d)\.ts$/; + +export function extractCodes(root) { + const files = execFileSync("git", ["ls-files", "packages/**/*.ts"], { + cwd: root, + encoding: "utf-8", + }) + .split("\n") + .filter( + (f) => + f.includes("/src/") && !TEST_FILE_RE.test(f) && !f.includes("/test/"), + ); + + const codes = new Map(); + for (const file of files) { + const text = readFileSync(join(root, file), "utf-8"); + for (const match of text.matchAll(CODE_RE)) { + const code = match[1]; + if (!codes.has(code)) codes.set(code, new Set()); + codes.get(code).add(file); + } + } + return [...codes.entries()] + .map(([code, fileSet]) => ({ + code, + namespace: code.split(".")[0], + files: [...fileSet].sort(), + })) + .sort((a, b) => a.code.localeCompare(b.code)); +} + +export function toMarkdown(entries) { + const lines = ["# Error reference", ""]; + let current = ""; + for (const entry of entries) { + if (entry.namespace !== current) { + current = entry.namespace; + lines.push(`## ${current}`, ""); + } + lines.push(`### ${entry.code}`, ""); + } + return lines.join("\n"); +} + +export function verify(entries, pageText) { + return entries.filter((e) => !pageText.includes(e.code)).map((e) => e.code); +} + +function main() { + const args = argv.slice(2); + const readFlag = (name) => { + const i = args.indexOf(name); + return i === -1 ? undefined : args[i + 1]; + }; + + const root = + readFlag("--root") ?? + join(fileURLToPath(new URL(".", import.meta.url)), ".."); + const entries = extractCodes(root); + + const verifyPath = readFlag("--verify"); + if (verifyPath !== undefined) { + const missing = verify(entries, readFileSync(verifyPath, "utf-8")); + if (missing.length > 0) { + stderr.write( + `error-reference is missing ${missing.length} of ${entries.length} known codes:\n`, + ); + for (const code of missing) stderr.write(` ${code}\n`); + exit(1); + } + stdout.write(`error-reference lists all ${entries.length} known codes.\n`); + return; + } + + const format = readFlag("--format") ?? "json"; + stdout.write( + format === "markdown" + ? `${toMarkdown(entries)}\n` + : `${JSON.stringify(entries, null, 2)}\n`, + ); +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) main(); From d1d7baf196f16a3fa27e408a6e2ea9ff743e3f16 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 25 Aug 2026 17:19:11 +0200 Subject: [PATCH 2/3] refactor(cli): structure errors at origin and delete the legacy error layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLI carried two error systems: the engine's structured CliStructuredError, and an older flat-code CliError that per-domain boundary mappers rewrote into dotted codes on the way out. Two of those rewrites built codes by concatenation, which is how codes reached users that exist nowhere in source: - fromLegacyCliError prefixed SERVICE. onto whatever code it was handed, so an unmatched --project was SERVICE.PROJECT_AMBIGUOUS through a service command and PROJECT.AMBIGUOUS through a project command — two codes for one condition, one of them unlistable and unbranchable. - The API mappers used the server's own code as the error code, so a 403 surfaced as PROJECT.forbidden. A test had pinned that shape. Neither is a documentation problem, so neither is fixed by documenting it. Every raise site now constructs CliStructuredError with its registered code directly, and the layer that rewrote them is gone: - deleted src/errors.ts (CliError, usageError, authRequiredError, ...), src/next-actions.ts, and the five mappers under commands/*/errors.ts - removed the try/catch map-and-rethrow wrapper from every command handler; a thrown structured error settles itself - a failed Management API call raises the domain's registered *.API_ERROR with the API's code and status in meta.apiCode / meta.status - a 401/403 no longer mints an *.AUTH_REQUIRED code that never existed in source; it is the domain's API error plus an auth login next action - the legacy free-text fix is the first user-choice next action and nextSteps are run-command actions, built at the raise site; install URLs are open-url actions rather than commands Registry and conventions follow the code: the passthrough rule is deleted rather than described, two entries with no raise site are removed, and error-conventions.md states the two rules this enforces — assign the code at origin, and treat a server's code as data. The scanner also skips tracked-but-deleted files, so a branch mid-delete reports instead of crashing. SERVICE.PROJECT_NOT_FOUND deliberately survives: it is authored at a raise site for the services API refusing an already-resolved project, which is a different moment from a --project reference matching nothing. Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- docs/product/error-conventions.md | 210 ++--------- docs/reference/error-reference.md | 36 +- packages/cli/src/commands/branch/errors.ts | 68 ---- packages/cli/src/commands/branch/list.ts | 43 +-- packages/cli/src/commands/bucket/context.ts | 4 + packages/cli/src/commands/bucket/create.ts | 69 ++-- packages/cli/src/commands/bucket/delete.ts | 55 ++- packages/cli/src/commands/bucket/errors.ts | 70 ---- .../cli/src/commands/bucket/key-create.ts | 58 ++- .../cli/src/commands/bucket/key-delete.ts | 54 +-- packages/cli/src/commands/bucket/key-list.ts | 52 ++- packages/cli/src/commands/bucket/list.ts | 45 +-- packages/cli/src/commands/git/connect.ts | 163 +++++---- packages/cli/src/commands/git/disconnect.ts | 67 ++-- packages/cli/src/commands/git/errors.ts | 115 +----- .../cli/src/commands/postgres/backup-list.ts | 58 ++- .../src/commands/postgres/backup-restore.ts | 138 ++++---- .../commands/postgres/connection-create.ts | 89 +++-- .../commands/postgres/connection-delete.ts | 106 +++--- .../src/commands/postgres/connection-list.ts | 53 ++- .../commands/postgres/connection-rotate.ts | 106 +++--- packages/cli/src/commands/postgres/context.ts | 9 - packages/cli/src/commands/postgres/create.ts | 105 +++--- packages/cli/src/commands/postgres/delete.ts | 45 +-- packages/cli/src/commands/postgres/errors.ts | 132 ------- packages/cli/src/commands/postgres/list.ts | 49 ++- packages/cli/src/commands/postgres/show.ts | 49 ++- packages/cli/src/commands/postgres/usage.ts | 116 +++--- packages/cli/src/commands/project/context.ts | 10 +- packages/cli/src/commands/project/create.ts | 97 +++-- packages/cli/src/commands/project/delete.ts | 69 ++-- packages/cli/src/commands/project/env-add.ts | 245 +++++++------ .../cli/src/commands/project/env-delete.ts | 97 +++-- packages/cli/src/commands/project/env-list.ts | 97 +++-- .../cli/src/commands/project/env-shared.ts | 5 +- .../cli/src/commands/project/env-update.ts | 195 +++++----- packages/cli/src/commands/project/errors.ts | 122 ------- packages/cli/src/commands/project/link.ts | 48 +-- packages/cli/src/commands/project/list.ts | 54 ++- .../cli/src/commands/project/presentation.ts | 18 - packages/cli/src/commands/project/rename.ts | 59 ++-- packages/cli/src/commands/project/show.ts | 64 ++-- packages/cli/src/commands/project/transfer.ts | 155 ++++---- packages/cli/src/commands/service/errors.ts | 41 --- packages/cli/src/commands/service/target.ts | 11 +- packages/cli/src/controllers/app-env-api.ts | 34 +- packages/cli/src/controllers/app-env-file.ts | 151 ++++---- packages/cli/src/controllers/app-env.ts | 77 ++-- packages/cli/src/controllers/branch.ts | 32 +- packages/cli/src/controllers/database.ts | 151 ++++---- packages/cli/src/controllers/project.ts | 287 ++++++++------- packages/cli/src/errors.ts | 167 --------- packages/cli/src/legacy/output.ts | 6 +- packages/cli/src/lib/app/env-config.ts | 33 +- packages/cli/src/lib/app/env-errors.ts | 35 ++ packages/cli/src/lib/app/env-file.ts | 21 +- packages/cli/src/lib/bucket/provider.ts | 113 ++++-- packages/cli/src/lib/database/provider.ts | 333 ++++++++++-------- packages/cli/src/lib/project/provider.ts | 130 ++++--- packages/cli/src/lib/project/resolution.ts | 217 ++++++------ packages/cli/src/lib/project/setup.ts | 121 ++++--- packages/cli/src/next-actions.ts | 20 -- packages/cli/tests/branch.test.ts | 5 +- packages/cli/tests/bucket.test.ts | 18 +- .../cli/tests/database-plan-limit.test.ts | 75 +++- packages/cli/tests/git.test.ts | 15 +- packages/cli/tests/postgres.test.ts | 32 +- packages/cli/tests/project-resolution.test.ts | 12 +- packages/cli/tests/project.test.ts | 29 +- packages/cli/tests/service-session.test.ts | 4 +- scripts/list-error-codes.mjs | 8 +- 71 files changed, 2465 insertions(+), 3212 deletions(-) delete mode 100644 packages/cli/src/commands/branch/errors.ts delete mode 100644 packages/cli/src/commands/bucket/errors.ts delete mode 100644 packages/cli/src/commands/postgres/errors.ts delete mode 100644 packages/cli/src/commands/project/errors.ts delete mode 100644 packages/cli/src/errors.ts create mode 100644 packages/cli/src/lib/app/env-errors.ts delete mode 100644 packages/cli/src/next-actions.ts diff --git a/docs/product/error-conventions.md b/docs/product/error-conventions.md index 7f6591e5..3e6c1d16 100644 --- a/docs/product/error-conventions.md +++ b/docs/product/error-conventions.md @@ -70,9 +70,9 @@ Bugs should fail fast and preserve stack traces. Catch them only at the outermos ## Boundary Handling -- internals may throw structured failures to abort quickly and preserve context -- command boundaries convert expected failures into the documented error envelope -- operational errors may be translated at boundaries when helpful +- the site that detects a failure raises it as a structured error, already carrying its code and its next actions +- a structured error travels to the engine untouched; a command boundary does not catch one only to re-emit it under a different code +- an operational fault from a dependency (an API response, a child process) is raised as a structured error where it is recognized, with the underlying error kept as `cause` - unknown errors should not be broadly wrapped into fake "expected" failures ## Human Error Shape @@ -84,7 +84,7 @@ Human-readable errors should follow this shape: 3. why 4. fix 5. where when relevant -6. hint for `-v` or `--trace` when helpful +6. hint for `--log-level verbose` when helpful Example: @@ -115,192 +115,48 @@ https://cv-... ## JSON Error Shape -Commands run with `--json` should emit this envelope on failure: +Commands run with `--json` emit this envelope on failure: ```json { "ok": false, - "command": "app.deploy", "error": { - "code": "BUILD_FAILED", - "domain": "app", + "code": "SERVICE.DEPLOY_FAILED", "severity": "error", - "summary": "Deployment failed during build", - "why": "Next.js build returned a non-zero exit code", - "fix": "Inspect logs and redeploy after fixing the build", - "where": null, - "meta": {}, - "docsUrl": null - }, - "warnings": [], - "nextSteps": [], - "nextActions": [] + "summary": "Failed to list services", + "why": "The Management API returned status 500.", + "meta": { "status": 500 }, + "nextActions": [ + { "kind": "user-choice", "label": "Re-run with --log-level verbose for the underlying API response details." }, + { "kind": "run-command", "label": "prisma project show", "command": "prisma project show" } + ] + } } ``` Rules: - `ok` is always `false` -- `command` is always present -- `error.code` is stable and machine-readable -- `error.domain` is a stable logical area such as `cli`, `agent`, `auth`, `project`, `branch`, `app`, `database`, or `bucket` -- `error.severity` is stable and machine-readable -- `error.summary` is the short human-readable headline +- `error.code` is a stable dotted `NAMESPACE.SUBCODE` string, listed in the error reference +- `error.severity` and `error.summary` are always present; `summary` is the short human-readable headline - `error.why` explains the immediate cause when known -- `error.fix` explains the next useful recovery step when known +- `error.nextActions` is always present (empty when there are none). What used to be a free-text `fix` is the first action, a `user-choice`; recovery commands are `run-command` actions, and an address to visit is an `open-url` action, never a `command` - `error.where` points to the relevant location when applicable -- `error.meta` is structured, not free-form prose -- `error.docsUrl` may be `null` when no per-code doc exists yet -- `warnings`, `nextSteps`, and `nextActions` are always present -- agents and CI should branch on structured error fields, not prose strings - -## MVP Error Codes - -> **Superseded for per-code lookup.** This section predates the dotted `NAMESPACE.SUBCODE` scheme now used in production source. The canonical per-code registry is [docs/reference/error-reference.md](../reference/error-reference.md), kept complete by `pnpm check:error-reference` in CI. The taxonomy and shape rules in this document still apply. - -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` -- `WORKSPACE_SWITCH_UNAVAILABLE` -- `WORKSPACE_NOT_AUTHENTICATED` -- `WORKSPACE_AMBIGUOUS` -- `PROJECT_SETUP_REQUIRED` -- `PROJECT_LINK_TARGET_REQUIRED` -- `PROJECT_CREATE_FAILED` -- `PROJECT_RENAME_FAILED` -- `PROJECT_DELETE_BLOCKED` -- `PROJECT_TRANSFER_REJECTED` -- `PROJECT_API_ERROR` -- `TRANSFER_RECIPIENT_REQUIRED` -- `TRANSFER_RECIPIENT_UNAVAILABLE` -- `PROJECT_NOT_FOUND` -- `PROJECT_AMBIGUOUS` -- `APP_AMBIGUOUS` -- `LOCAL_PROJECT_WORKSPACE_MISMATCH` -- `LOCAL_STATE_WRITE_FAILED` -- `LOCAL_STATE_STALE` -- `BRANCH_NOT_DEPLOYABLE` -- `DEPLOYMENT_NOT_FOUND` -- `NO_DEPLOYMENTS` -- `NO_PREVIOUS_DEPLOYMENT` -- `PROD_DEPLOY_REQUIRES_FLAG` -- `PROMOTE_SOURCE_INVALID` -- `ROLLBACK_UNAVAILABLE` -- `CONFIRMATION_REQUIRED` -- `DOMAIN_HOSTNAME_INVALID` -- `DOMAIN_DNS_NOT_CONFIGURED` -- `DOMAIN_ALREADY_REGISTERED` -- `DOMAIN_QUOTA_EXCEEDED` -- `DOMAIN_NOT_FOUND` -- `DOMAIN_RETRY_NOT_ELIGIBLE` -- `DOMAIN_VERIFICATION_FAILED` -- `DOMAIN_VERIFICATION_TIMEOUT` -- `DELETE_FAILED` -- `FEATURE_UNAVAILABLE` -- `REPO_PROVIDER_UNSUPPORTED` -- `REPO_INSTALLATION_REQUIRED` -- `REPO_NOT_ACCESSIBLE` -- `REPO_NOT_CONNECTED` -- `REPO_ALREADY_CONNECTED` -- `REPO_CONNECTION_FAILED` -- `BUILD_FAILED` -- `BRANCH_DATABASE_SETUP_FAILED` -- `SCHEMA_SETUP_FAILED` -- `DATABASE_NOT_FOUND` -- `DATABASE_AMBIGUOUS` -- `DATABASE_CONNECTION_NOT_FOUND` -- `DATABASE_CONNECTION_MISSING` -- `DATABASE_CONNECTION_STRING_MISSING` -- `DATABASE_API_ERROR` -- `PLAN_LIMIT_REACHED` -- `DATABASE_BACKUPS_UNSUPPORTED` -- `DATABASE_BACKUP_NOT_FOUND` -- `DATABASE_RESTORE_CONFLICT` -- `BUCKET_NOT_FOUND` -- `BUCKET_KEY_NOT_FOUND` -- `BUCKET_KEY_SECRET_MISSING` -- `BRANCH_NOT_FOUND` -- `RUN_FAILED` -- `DEPLOY_FAILED` -- `VERSION_UNAVAILABLE` -- `COMMAND_CANCELED` - -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 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` -- `WORKSPACE_SWITCH_UNAVAILABLE`: `PRISMA_SERVICE_TOKEN` is the active auth source, so local OAuth workspace switching cannot apply -- `WORKSPACE_NOT_AUTHENTICATED`: requested workspace is not present in the local OAuth credentials store for a switch/logout operation; callers should run `auth login` for that workspace -- `WORKSPACE_AMBIGUOUS`: requested workspace name matches more than one local OAuth workspace; callers should switch by workspace id -- `PROJECT_SETUP_REQUIRED`: command needs explicit or durable Project context before it can continue -- `PROJECT_LINK_TARGET_REQUIRED`: `project link` needs the user to choose an existing Project or create a new one -- `PROJECT_CREATE_FAILED`: Project creation failed before deployment or linking could continue -- `PROJECT_RENAME_FAILED`: the platform rejected the new project name -- `PROJECT_DELETE_BLOCKED`: project deletion is blocked while it still has active deployments -- `PROJECT_TRANSFER_REJECTED`: the platform rejected the transfer, for example an invalid or expired recipient token -- `PROJECT_API_ERROR`: project Management API request failed without a more specific CLI error code -- `TRANSFER_RECIPIENT_REQUIRED`: project transfer needs --to-workspace or --recipient-token -- `TRANSFER_RECIPIENT_UNAVAILABLE`: --to-workspace cannot resolve local OAuth sessions while PRISMA_SERVICE_TOKEN is set -- `PROJECT_NOT_FOUND`: requested project does not exist or is not accessible -- `PROJECT_AMBIGUOUS`: multiple safe project candidates matched -- `APP_AMBIGUOUS`: multiple apps matched the inferred or explicit app target -- `LOCAL_PROJECT_WORKSPACE_MISMATCH`: local Project pin points at a different workspace than the active authenticated workspace; callers should switch to the linked workspace or relink the directory -- `LOCAL_STATE_WRITE_FAILED`: the CLI could not save local Project binding state such as `.prisma/local.json` or the matching `.gitignore` entry; callers should fix directory permissions or filesystem state before retrying -- `LOCAL_STATE_STALE`: local Project pin no longer matches platform data and continuing would be ambiguous -- `BRANCH_NOT_DEPLOYABLE`: command tried to deploy to a non-deployable branch context -- `DEPLOYMENT_NOT_FOUND`: requested deployment id does not exist -- `NO_DEPLOYMENTS`: command resolved a branch or app but found no deployments -- `NO_PREVIOUS_DEPLOYMENT`: rollback could not find an earlier deployment for the selected app -- `PROD_DEPLOY_REQUIRES_FLAG`: app deploy resolved a production Branch with a prior production deployment, but `--prod` was not passed -- `PROMOTE_SOURCE_INVALID`: source for promote is missing, invalid, or not promotable -- `ROLLBACK_UNAVAILABLE`: no previous healthy production deployment exists -- `CONFIRMATION_REQUIRED`: command cannot continue without confirmation in the current mode -- `DOMAIN_HOSTNAME_INVALID`: custom-domain hostname is malformed or rejected by the platform -- `DOMAIN_DNS_NOT_CONFIGURED`: custom-domain hostname does not yet point to the required Prisma DNS target -- `DOMAIN_ALREADY_REGISTERED`: custom-domain hostname is already attached outside the selected app -- `DOMAIN_QUOTA_EXCEEDED`: selected app has reached its custom-domain quota -- `DOMAIN_NOT_FOUND`: requested custom domain is not attached to the selected app -- `DOMAIN_RETRY_NOT_ELIGIBLE`: requested custom domain is not in a state where verification can be retried -- `DOMAIN_VERIFICATION_FAILED`: custom-domain verification reached a terminal failed state -- `DOMAIN_VERIFICATION_TIMEOUT`: custom-domain verification did not reach a terminal state before the requested timeout -- `DELETE_FAILED`: service deletion could not complete remotely -- `FEATURE_UNAVAILABLE`: the command exists in the CLI model, but the current preview cannot support it yet -- `REPO_PROVIDER_UNSUPPORTED`: repository connection received a non-GitHub repository URL -- `REPO_INSTALLATION_REQUIRED`: repository connection needs a GitHub App installation before the project can be linked -- `REPO_NOT_ACCESSIBLE`: the connected GitHub App installations do not expose the requested repository -- `REPO_NOT_CONNECTED`: a command expected a project repository connection, but none exists -- `REPO_ALREADY_CONNECTED`: a project already has a different GitHub repository connected -- `REPO_CONNECTION_FAILED`: the Management API repository connection operation failed -- `BUILD_FAILED`: build failed before a healthy deployment existed -- `BRANCH_DATABASE_SETUP_FAILED`: database creation or env-var wiring failed before deployment started -- `SCHEMA_SETUP_FAILED`: local Prisma schema source setup against a newly created database failed before deployment started -- `DATABASE_NOT_FOUND`: requested database id or name does not exist in the resolved project scope -- `DATABASE_AMBIGUOUS`: requested database name matches multiple databases and needs an id or branch filter -- `DATABASE_CONNECTION_NOT_FOUND`: requested database connection id does not exist or is not accessible -- `DATABASE_CONNECTION_MISSING`: database creation succeeded but the API response did not include the first one-time connection payload -- `DATABASE_CONNECTION_STRING_MISSING`: connection creation succeeded but the API response did not include the one-time connection string -- `DATABASE_API_ERROR`: database Management API request failed without a more specific CLI error code -- `PLAN_LIMIT_REACHED`: a database operation returned the structured `planLimitReached` discriminator; `error.meta` includes `workspaceId`, `blockedFeature`, `planName`, `usageBlocked`, and `upgradeUrl`, using `null` when optional recovery data is unavailable. This is a workspace plan restriction, not a Prisma outage. Agents and CI must branch on the code and metadata rather than the human prose -- `DATABASE_BACKUPS_UNSUPPORTED`: the platform does not manage backups for the database, for example remote/BYO databases -- `DATABASE_BACKUP_NOT_FOUND`: requested backup id does not exist for the resolved source database -- `DATABASE_RESTORE_CONFLICT`: restore target database is provisioning or already recovering -- `BUCKET_NOT_FOUND`: requested bucket id does not exist or is not accessible -- `BUCKET_KEY_NOT_FOUND`: requested key id does not exist for the resolved bucket -- `BUCKET_KEY_SECRET_MISSING`: bucket key creation succeeded but the API response did not include the one-time credential payload -- `BRANCH_NOT_FOUND`: the branch name passed to a bucket command does not exist in the resolved project -- `RUN_FAILED`: local framework run command could not be started or exited unsuccessfully -- `DEPLOY_FAILED`: deployment or post-build health failed -- `VERSION_UNAVAILABLE`: CLI could not read its own bundled package metadata to report a version (defensive; not expected in normal installs) -- `COMMAND_CANCELED`: command execution was canceled by a runtime cancellation signal such as `SIGINT` or `SIGTERM` +- `error.meta` is structured, not free-form prose — it is where a server's own error code and HTTP status belong +- `error.docsUrl` may be absent when no per-code doc exists yet +- absent optional fields are omitted rather than sent as `null` +- agents and CI should branch on `error.code`, never on prose strings + +## Error Codes + +Every user-facing error carries a dotted `NAMESPACE.SUBCODE` code, assigned where the error is raised. The canonical registry of every code, with the condition that raises it, is [docs/reference/error-reference.md](../reference/error-reference.md); `pnpm check:error-reference` fails CI if a code in production source is missing from it. + +Two rules govern codes: + +- **Assign at origin.** The site that detects the failure names the code. No boundary rewrites a code on the way out, and no code is built by concatenation — a code assembled from a prefix and a variable is a code the registry cannot list and a caller cannot rely on. +- **A server's code is data, not your code.** When a Management API request fails, raise the domain's registered `*.API_ERROR` and put the API's own code and status in `meta.apiCode` and `meta.status`. + +Adding a code means adding its entry to the registry in the same change. ## Exit Codes @@ -313,7 +169,7 @@ The MVP should use these process exit codes: Stable structured error codes, not exit code granularity, are the main branching surface for agents and CI. -Cancellation intentionally uses `130` instead of the generic runtime failure code because it has established shell semantics for interrupted commands and is useful to operators and process supervisors. Agents and CI should still branch on `COMMAND_CANCELED` rather than the numeric exit code. +Cancellation intentionally uses `130` instead of the generic runtime failure code because it has established shell semantics for interrupted commands and is useful to operators and process supervisors. Agents and CI should still branch on the structured code (`CLI.PROMPT_CANCELLED`, `CLI.ABORTED`) rather than the numeric exit code. ## Production Safety diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index 17aeeea7..f3eba4c8 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -6,7 +6,7 @@ Recognize an error programmatically by running with `--json` and matching on `er Most codes on this page are expected failures. Some are warn-severity diagnostics that ride a successful run: the command completes and exits `0`, and the diagnostic carries the code (the entry says so where it applies). The process exit-code contract is in [Error Conventions](../product/error-conventions.md). -Some command boundaries pass unrecognized upstream codes through by prefixing their namespace — a legacy platform code becomes `SERVICE.` or `GIT.`, and a server-supplied API code becomes `POSTGRES.` or `BUCKET.`. This page documents the codes defined in production source; a passthrough code not listed here carries the meaning of the upstream code it wraps. +Every code on this page is assigned where the error is raised, and this page lists every code the CLI can emit. No boundary invents a code from a server response or rewrites one on the way out, so an error's code never depends on which command you reached the failure through. When a Management API request fails, the CLI raises the domain's registered `*.API_ERROR` code and carries the API's own code and HTTP status in `meta.apiCode` and `meta.status`, where they are data you can read rather than a code you have to guess at. Namespaces: @@ -70,13 +70,13 @@ A user-typed workspace name matched more than one workspace, from two raise site ### BRANCH.API_ERROR -A Management API call made by `prisma branch list` failed and the response body carried no API error code — when the body does carry one, that code passes through as `BRANCH.` instead. The `why` carries the API's message or, failing that, the HTTP status, and the fix suggests rerunning with `--log-level verbose` for the response details. Meta: none. +A Management API call made by `prisma branch list` failed. The `why` carries the API's message or, failing that, the HTTP status, and the fix suggests rerunning with `--log-level verbose` for the response details. Meta: `status`, `apiCode` (the API's own error code, when the response supplied one). ## BUCKET ### BUCKET.API_ERROR -A bucket Management API request (listing, creating, or deleting buckets or bucket keys) failed and the response body carried no error code of its own — raised at the bucket commands' mapping boundary (`commands/bucket/errors.ts`) from the legacy `BUCKET_API_ERROR` shape; a response that does name a code passes through as `BUCKET.` instead. The `why` carries the API's message or HTTP status, and the fix is the API's hint when it sent one. Meta: none. +A bucket Management API request (listing, creating, or deleting buckets or bucket keys) failed — raised by the provider (`lib/bucket/provider.ts`). The `why` carries the API's message or HTTP status, and the fix is the API's hint when it sent one. An HTTP 401 or 403 raises this code too, with a `why` saying the API rejected the request as unauthorized and a `prisma auth login` next action. Meta: `status`, `apiCode` (the API's own error code, when the response supplied one). ### BUCKET.KEY_SECRET_MISSING @@ -282,31 +282,31 @@ A warn diagnostic from `prisma init`: the final step, syncing the agent skills f ### POSTGRES.AMBIGUOUS -A database name passed to a `prisma postgres` subcommand matched more than one database in the resolved project — raised by the shared database resolver (`controllers/database.ts`, legacy `DATABASE_AMBIGUOUS`). The fix is to pass the database id, or `--branch ` to narrow the match; the candidates are carried in meta as `matches`, each with `id`, `name`, and `branchName`. Meta: `matches`. +A database name passed to a `prisma postgres` subcommand matched more than one database in the resolved project — raised by the shared database resolver (`controllers/database.ts`). The fix is to pass the database id, or `--branch ` to narrow the match; the candidates are carried in meta as `matches`, each with `id`, `name`, and `branchName`. Meta: `matches`. ### POSTGRES.API_ERROR -A database Management API request failed without a more specific code — the generic fallback for every `prisma postgres` operation (legacy `DATABASE_API_ERROR`), also raised when a database response omits its project id; a response that names its own error code passes through as `POSTGRES.` instead. The `why` carries the API's message or HTTP status, and the fix is the API's hint when it sent one. Meta: none. +A database Management API request failed without a more specific code — the generic fallback for every `prisma postgres` operation, raised by the provider (`lib/database/provider.ts`), and also when a database response omits its project id. The `why` carries the API's message or HTTP status, and the fix is the API's hint when it sent one. An HTTP 401 or 403 raises this code too, with a `why` saying the API rejected the request as unauthorized and a `prisma auth login` next action. Meta: `status`, `apiCode` (the API's own error code, when the response supplied one). ### POSTGRES.BACKUP_NOT_FOUND -`prisma postgres backup restore` got a 404 from the restore endpoint; the source and target databases are resolved before the call, so the 404 identifies the backup id (legacy `DATABASE_BACKUP_NOT_FOUND`). The fix is to pass a backup id from `prisma postgres backup list `. Meta: none. +`prisma postgres backup restore` got a 404 from the restore endpoint; the source and target databases are resolved before the call, so the 404 identifies the backup id. The fix is to pass a backup id from `prisma postgres backup list `. Meta: none. ### POSTGRES.BACKUPS_UNSUPPORTED -Listing backups returned a 422 because the platform does not manage backups for this database — for example a remote/BYO database (legacy `DATABASE_BACKUPS_UNSUPPORTED`, raised by `prisma postgres backup list`). The fix is to use your own backup tooling for externally managed databases. Meta: none. +Listing backups returned a 422 because the platform does not manage backups for this database — for example a remote/BYO database (raised by `prisma postgres backup list`). The fix is to use your own backup tooling for externally managed databases. Meta: none. ### POSTGRES.CONNECTION_MISSING -`prisma postgres create` created the database, but the API response did not include the first one-time connection payload (legacy `DATABASE_CONNECTION_MISSING`). The fix is to create a connection explicitly with `prisma postgres connection create `. Meta: none. +`prisma postgres create` created the database, but the API response did not include the first one-time connection payload. The fix is to create a connection explicitly with `prisma postgres connection create `. Meta: none. ### POSTGRES.CONNECTION_STRING_MISSING -A connection create or rotate succeeded, but the API response did not include the one-time connection string the CLI would show exactly once (legacy `DATABASE_CONNECTION_STRING_MISSING`, raised by `prisma postgres create`, `postgres connection create`, and `postgres connection rotate`). The fix is to rerun the operation, or create a replacement connection and store the returned URL immediately. Meta: none. +A connection create or rotate succeeded, but the API response did not include the one-time connection string the CLI would show exactly once (raised by `prisma postgres create`, `postgres connection create`, and `postgres connection rotate`). The fix is to rerun the operation, or create a replacement connection and store the returned URL immediately. Meta: none. ### POSTGRES.NOT_FOUND -The database a `prisma postgres` subcommand targets could not be resolved: either no database matched the given id or name in the project (and optional `--branch`) scope, or a database that was just listed returned 404 on read because it was removed while the command ran (legacy `DATABASE_NOT_FOUND`, raised by the shared resolver in `controllers/database.ts`). The fix is to pass an id or name from `prisma postgres list`. Meta: none. +The database a `prisma postgres` subcommand targets could not be resolved: either no database matched the given id or name in the project (and optional `--branch`) scope, or a database that was just listed returned 404 on read because it was removed while the command ran (raised by the shared resolver in `controllers/database.ts`). The fix is to pass an id or name from `prisma postgres list`. Meta: none. ### POSTGRES.PLAN_LIMIT_REACHED @@ -314,7 +314,7 @@ A database operation was blocked because the workspace has used up the operation ### POSTGRES.RESTORE_CONFLICT -`prisma postgres backup restore` got a 409 because the target database is provisioning or already recovering (legacy `DATABASE_RESTORE_CONFLICT`). The fix is to wait for the database to become ready — check with `prisma postgres show ` — then retry. Meta: none. +`prisma postgres backup restore` got a 409 because the target database is provisioning or already recovering. The fix is to wait for the database to become ready — check with `prisma postgres show ` — then retry. Meta: none. ### POSTGRES.USAGE_ERROR @@ -328,11 +328,7 @@ Project resolution matched more than one project: an explicit project reference ### PROJECT.API_ERROR -A Management API project operation (list, rename, delete, or transfer) failed and the response body carried no API error code; a body that does carry one passes through as `PROJECT.`. Listing projects deliberately throws this rather than returning an empty list, so a rejected request is distinguishable from a workspace that genuinely has no projects. The `why` carries the API's message or HTTP status. Meta: none. - -### PROJECT.CONFIRMATION_REQUIRED - -Reserved by the project group's legacy-error mapper (`commands/project/errors.ts`) for the legacy `CONFIRMATION_REQUIRED` CliError code, but nothing in the current source raises that code: the destructive `project delete` and `project transfer` commands confirm through the engine's consent prompt, which raises `CLI.CONSENT_REQUIRED` instead. Meta: none. +A Management API project operation (list, rename, delete, or transfer) failed. Listing projects deliberately throws this rather than returning an empty list, so a rejected request is distinguishable from a workspace that genuinely has no projects. The `why` carries the API's message or HTTP status, and the API's own error code, when it sent one, is in `meta.apiCode`. Meta: `status`, `apiCode` (each present only when the response supplied it). ### PROJECT.CREATE_FAILED @@ -344,7 +340,7 @@ The Management API answered `prisma project delete` with HTTP 400, which typical ### PROJECT.ENV_API_ERROR -A Management API call made by the `prisma project env` commands (reading, writing, or deleting variables, or resolving and creating branches for a scope) failed without an API error code in the body — a body carrying one passes through as `PROJECT.`, and an HTTP 401/403 is converted to the auth-required error instead of this code. Meta: none. +A Management API call made by the `prisma project env` commands (reading, writing, or deleting variables, or resolving and creating branches for a scope) failed. The summary names the call that failed, for example "Failed to add STRIPE_KEY". An HTTP 401 or 403 raises this code too, with a `why` saying the API rejected the request as unauthorized and a `prisma auth login` next action. Meta: `status`, `apiCode` (the API's own error code, when the response supplied one). ### PROJECT.ENV_BRANCH_CREATE_REQUIRES_DEFAULT_BRANCH @@ -374,10 +370,6 @@ Not an error: a warn diagnostic emitted on a successful `prisma project env add` `prisma project env update` or `env delete` targeted a key (or, in update's `--file` mode, one or more keys) that does not exist in the targeted scope. The fix for update is to create the variable with `env add` (or split a mixed file); for delete it is to list the scope's variables first. Meta: `keys` (file-mode update only; the single-key forms carry no meta). -### PROJECT.LINK_TARGET_REQUIRED - -Reserved by the project group's legacy-error mapper (`commands/project/errors.ts`) for the legacy `PROJECT_LINK_TARGET_REQUIRED` CliError code, but nothing in the current source raises that code: `prisma project link` without a target prompts interactively and reports a cancelled prompt as a usage error instead. Meta: none. - ### PROJECT.LOCAL_STATE_STALE The local project binding in `.prisma/local.json` is unusable: the pinned project is no longer in the selected workspace's project list, or the pin file is invalid JSON or has an invalid shape — raised by any command that resolves the project implicitly through the pin. The fix is to delete the pin file and choose a project explicitly. Meta: `pinPath`. @@ -506,7 +498,7 @@ The resolved service has no usable version for the command — raised by `servic ### SERVICE.PROJECT_NOT_FOUND -The project a service command resolved to does not exist in the authenticated workspace or is no longer accessible — raised directly when listing services answers "Resource Not Found" for the resolved project id, and via the legacy-error mapping when project resolution cannot match an explicit `--project` reference (every legacy service-family error code is prefixed `SERVICE.` at that boundary). Next actions point at `project show` to inspect the directory binding and `project link` to fix it. Meta: none. +The project a service command resolved to does not exist in the authenticated workspace or is no longer accessible — raised when listing services answers "Resource Not Found" for the resolved project id. A service command that cannot match an explicit `--project` reference fails with the project group's own `PROJECT.NOT_FOUND` instead, because the condition is the same one whichever command met it. Next actions point at `project show` to inspect the directory binding and `project link` to fix it. Meta: none. ### SERVICE.SELECTION_INVALID diff --git a/packages/cli/src/commands/branch/errors.ts b/packages/cli/src/commands/branch/errors.ts deleted file mode 100644 index b6fc9ccd..00000000 --- a/packages/cli/src/commands/branch/errors.ts +++ /dev/null @@ -1,68 +0,0 @@ -/** - * Mapping from the branch controller's legacy CliError shapes to the - * engine protocol's dotted BRANCH.* structured errors, following - * `commands/auth/errors.ts`. Unmapped codes fall through to - * `BRANCH.` (the API-passthrough rule); the project - * resolution codes are shared with the project group's mapper. - */ - -import type { NextAction } from "@prisma/cli-engine/protocol"; -import { CliStructuredError } from "@prisma/cli-engine/protocol"; -import { CliError } from "../../errors"; -import { mapProjectOperationError, portCommandString } from "../project/errors"; - -const BRANCH_CODE_MAP: Readonly> = { - BRANCH_API_ERROR: "BRANCH.API_ERROR", -}; - -/** The project-resolution codes `branch list` can raise; they keep the - * project group's dotted codes and copy. */ -const PROJECT_CODES: ReadonlySet = new Set([ - "PROJECT_NOT_FOUND", - "PROJECT_AMBIGUOUS", - "PROJECT_SETUP_REQUIRED", - "LOCAL_STATE_STALE", - "LOCAL_PROJECT_WORKSPACE_MISMATCH", -]); - -const STALE_INTERACTIVE_SIGN_IN = - /, or rerun the command in a TTY to sign in interactively\./g; -const TRACE_FLAG = /--trace/g; - -/** `--trace` is gone in this CLI; the log level replaces it. Interactive - * sign-in is gone too (R-S2b-2), so the legacy offer to rerun in a TTY - * describes something this CLI cannot do; `auth login` is the whole remedy. */ -function portFixText(fix: string): string { - return fix - .replace(TRACE_FLAG, "--log-level verbose") - .replace(STALE_INTERACTIVE_SIGN_IN, "."); -} - -function nextActionsFor(error: CliError): NextAction[] { - return [ - ...(error.fix - ? [{ kind: "user-choice" as const, label: portFixText(error.fix) }] - : []), - ...error.nextSteps.map((step) => { - const command = portCommandString(step); - return { kind: "run-command" as const, label: command, command }; - }), - ]; -} - -export function mapBranchOperationError( - error: unknown, -): CliStructuredError | null { - if (!(error instanceof CliError)) { - return null; - } - if (PROJECT_CODES.has(error.code)) { - return mapProjectOperationError(error); - } - const code = BRANCH_CODE_MAP[error.code] ?? `BRANCH.${error.code}`; - return new CliStructuredError(code as `${string}.${string}`, error.summary, { - why: error.why ?? undefined, - meta: Object.keys(error.meta).length > 0 ? error.meta : undefined, - nextActions: nextActionsFor(error), - }); -} diff --git a/packages/cli/src/commands/branch/list.ts b/packages/cli/src/commands/branch/list.ts index 8fb1b750..677efa93 100644 --- a/packages/cli/src/commands/branch/list.ts +++ b/packages/cli/src/commands/branch/list.ts @@ -5,7 +5,7 @@ import { flag, type Presentations, } from "@prisma/cli-engine"; -import { notOk, ok } from "@prisma/cli-engine/protocol"; +import { ok } from "@prisma/cli-engine/protocol"; import { listBranches, sortBranches, @@ -14,7 +14,6 @@ import { import type { BranchListResult } from "../../types/branch"; import { resolvePinnedProject } from "../project/context"; import { resolveActiveWorkspace } from "../resources-shared/workspace"; -import { mapBranchOperationError } from "./errors"; const TITLE = "Listing branches for the resolved project."; @@ -68,32 +67,20 @@ export const branchListCommand = defineCommand({ }, needs: { credentials: true }, handler: async (args, ctx) => { - try { - const workspace = await resolveActiveWorkspace(ctx); - const target = await resolvePinnedProject( - ctx, - workspace, - args.flags.project, - "branch list", - ); - const branches = await listBranches( - ctx.api, - target.project.id, - ctx.signal, - ); + const workspace = await resolveActiveWorkspace(ctx); + const target = await resolvePinnedProject( + ctx, + workspace, + args.flags.project, + "branch list", + ); + const branches = await listBranches(ctx.api, target.project.id, ctx.signal); - const result: BranchListResult = { - projectId: target.project.id, - projectName: target.project.name, - branches: sortBranches(branches.map(toBranchSummary)), - }; - return ok(ctx.present({ data: result }, listPresentations(result))); - } catch (error) { - const mapped = mapBranchOperationError(error); - if (mapped) { - return notOk(mapped); - } - throw error; - } + const result: BranchListResult = { + projectId: target.project.id, + projectName: target.project.name, + branches: sortBranches(branches.map(toBranchSummary)), + }; + return ok(ctx.present({ data: result }, listPresentations(result))); }, }); diff --git a/packages/cli/src/commands/bucket/context.ts b/packages/cli/src/commands/bucket/context.ts index 3911061e..3af9909a 100644 --- a/packages/cli/src/commands/bucket/context.ts +++ b/packages/cli/src/commands/bucket/context.ts @@ -1,5 +1,6 @@ /** Workspace, project and provider for the `bucket *` commands. */ import { type CommandContext, flag, positional } from "@prisma/cli-engine"; +import { CLI_NAME } from "../../cli-name"; import { type BucketProvider, createManagementBucketProvider, @@ -9,6 +10,9 @@ import { resolveActiveWorkspace } from "../resources-shared/workspace"; export type BucketCommandContext = CommandContext; +/** Where a caller who is missing a bucket id finds one. */ +export const LIST_BUCKETS_COMMAND = `${CLI_NAME} bucket list`; + export const projectFlag = flag.string({ brief: "Project id or name", placeholder: "id-or-name", diff --git a/packages/cli/src/commands/bucket/create.ts b/packages/cli/src/commands/bucket/create.ts index 8ebc3c90..3b8ae577 100644 --- a/packages/cli/src/commands/bucket/create.ts +++ b/packages/cli/src/commands/bucket/create.ts @@ -1,9 +1,8 @@ /** The `bucket create` command. */ import { defineCommand, flag } from "@prisma/cli-engine"; -import { notOk, ok } from "@prisma/cli-engine/protocol"; +import { ok } from "@prisma/cli-engine/protocol"; import type { BucketCreateResult } from "../../types/bucket"; import { branchFlag, projectFlag, resolveBucketContext } from "./context"; -import { mapBucketOperationError } from "./errors"; import { bucketTargetLabel } from "./presentation"; export const bucketCreateCommand = defineCommand({ @@ -27,43 +26,35 @@ export const bucketCreateCommand = defineCommand({ }, needs: { credentials: true }, handler: async (args, ctx) => { - try { - const { provider, projectId, projectName } = await resolveBucketContext( - ctx, - args.flags, - "bucket create", - ); - const bucket = await provider.createBucket({ - projectId, - name: args.flags.name?.trim() || undefined, - branchGitName: args.flags.branch, - signal: ctx.signal, - }); + const { provider, projectId, projectName } = await resolveBucketContext( + ctx, + args.flags, + "bucket create", + ); + const bucket = await provider.createBucket({ + projectId, + name: args.flags.name?.trim() || undefined, + branchGitName: args.flags.branch, + signal: ctx.signal, + }); - const result: BucketCreateResult = { projectId, projectName, bucket }; - return ok( - ctx.present( - { data: result }, - { - human: () => [ - { - kind: "summary", - status: "ok", - text: `Created bucket "${bucket.name}" in ${bucketTargetLabel(projectName, bucket.branchId)}.`, - }, - ], - stdout: () => [], - json: () => result, - next: () => [], - }, - ), - ); - } catch (error) { - const mapped = mapBucketOperationError(error); - if (mapped) { - return notOk(mapped); - } - throw error; - } + const result: BucketCreateResult = { projectId, projectName, bucket }; + return ok( + ctx.present( + { data: result }, + { + human: () => [ + { + kind: "summary", + status: "ok", + text: `Created bucket "${bucket.name}" in ${bucketTargetLabel(projectName, bucket.branchId)}.`, + }, + ], + stdout: () => [], + json: () => result, + next: () => [], + }, + ), + ); }, }); diff --git a/packages/cli/src/commands/bucket/delete.ts b/packages/cli/src/commands/bucket/delete.ts index 0198341e..a5f04fcb 100644 --- a/packages/cli/src/commands/bucket/delete.ts +++ b/packages/cli/src/commands/bucket/delete.ts @@ -4,12 +4,13 @@ import { defineCommand, type Presentations, } from "@prisma/cli-engine"; -import { notOk, ok } from "@prisma/cli-engine/protocol"; -import { CLI_NAME } from "../../cli-name"; -import { usageError } from "../../errors"; +import { CliStructuredError, ok } from "@prisma/cli-engine/protocol"; import type { BucketDeleteResult } from "../../types/bucket"; -import { bucketPositional, resolveBucketProviderOnly } from "./context"; -import { mapBucketOperationError } from "./errors"; +import { + bucketPositional, + LIST_BUCKETS_COMMAND, + resolveBucketProviderOnly, +} from "./context"; const CONSENT_QUESTION = "Deleting this bucket permanently removes all objects and access keys."; @@ -38,32 +39,28 @@ export const bucketDeleteCommand = defineCommand({ }, needs: { credentials: true }, handler: async (args, ctx) => { - try { - const bucketId = args.positionals.bucketId.trim(); - if (!bucketId) { - throw usageError( - "Bucket id required", - "Bucket deletion needs a bucket id.", - "Pass the bucket id to delete.", - [`${CLI_NAME} bucket list`], - "bucket", - ); - } + const bucketId = args.positionals.bucketId.trim(); + if (!bucketId) { + throw new CliStructuredError("BUCKET.USAGE_ERROR", "Bucket id required", { + why: "Bucket deletion needs a bucket id.", + nextActions: [ + { kind: "user-choice", label: "Pass the bucket id to delete." }, + { + kind: "run-command", + label: LIST_BUCKETS_COMMAND, + command: LIST_BUCKETS_COMMAND, + }, + ], + }); + } - await ctx.prompt.consent(CONSENT_QUESTION, { token: bucketId }); + await ctx.prompt.consent(CONSENT_QUESTION, { token: bucketId }); - await resolveBucketProviderOnly(ctx).deleteBucket(bucketId, { - signal: ctx.signal, - }); + await resolveBucketProviderOnly(ctx).deleteBucket(bucketId, { + signal: ctx.signal, + }); - const result: BucketDeleteResult = { bucket: { id: bucketId } }; - return ok(ctx.present({ data: result }, deletePresentations(result))); - } catch (error) { - const mapped = mapBucketOperationError(error); - if (mapped) { - return notOk(mapped); - } - throw error; - } + const result: BucketDeleteResult = { bucket: { id: bucketId } }; + return ok(ctx.present({ data: result }, deletePresentations(result))); }, }); diff --git a/packages/cli/src/commands/bucket/errors.ts b/packages/cli/src/commands/bucket/errors.ts deleted file mode 100644 index d23e7fa4..00000000 --- a/packages/cli/src/commands/bucket/errors.ts +++ /dev/null @@ -1,70 +0,0 @@ -/** - * Mapping from the bucket controller's and provider's legacy CliError - * shapes to the engine protocol's dotted BUCKET.* structured errors, - * following `commands/auth/errors.ts`. Unmapped codes fall through to - * `BUCKET.` (the API-passthrough rule); the project - * resolution codes are shared with the project group's mapper. - */ - -import type { NextAction } from "@prisma/cli-engine/protocol"; -import { CliStructuredError } from "@prisma/cli-engine/protocol"; -import { CliError } from "../../errors"; -import { mapProjectOperationError, portCommandString } from "../project/errors"; - -const BUCKET_CODE_MAP: Readonly> = { - USAGE_ERROR: "BUCKET.USAGE_ERROR", - BUCKET_KEY_SECRET_MISSING: "BUCKET.KEY_SECRET_MISSING", - BUCKET_API_ERROR: "BUCKET.API_ERROR", -}; - -/** The project-resolution codes `bucket list` and `bucket create` can - * raise; they keep the project group's dotted codes and copy. */ -const PROJECT_CODES: ReadonlySet = new Set([ - "PROJECT_NOT_FOUND", - "PROJECT_AMBIGUOUS", - "PROJECT_SETUP_REQUIRED", - "LOCAL_STATE_STALE", - "LOCAL_PROJECT_WORKSPACE_MISMATCH", -]); - -const STALE_INTERACTIVE_SIGN_IN = - /, or rerun the command in a TTY to sign in interactively\./g; -const TRACE_FLAG = /--trace/g; - -/** `--trace` is gone in this CLI; the log level replaces it. Interactive - * sign-in is gone too (R-S2b-2), so the legacy offer to rerun in a TTY - * describes something this CLI cannot do; `auth login` is the whole remedy. */ -function portFixText(fix: string): string { - return fix - .replace(TRACE_FLAG, "--log-level verbose") - .replace(STALE_INTERACTIVE_SIGN_IN, "."); -} - -function nextActionsFor(error: CliError): NextAction[] { - return [ - ...(error.fix - ? [{ kind: "user-choice" as const, label: portFixText(error.fix) }] - : []), - ...error.nextSteps.map((step) => { - const command = portCommandString(step); - return { kind: "run-command" as const, label: command, command }; - }), - ]; -} - -export function mapBucketOperationError( - error: unknown, -): CliStructuredError | null { - if (!(error instanceof CliError)) { - return null; - } - if (PROJECT_CODES.has(error.code)) { - return mapProjectOperationError(error); - } - const code = BUCKET_CODE_MAP[error.code] ?? `BUCKET.${error.code}`; - return new CliStructuredError(code as `${string}.${string}`, error.summary, { - why: error.why ?? undefined, - meta: Object.keys(error.meta).length > 0 ? error.meta : undefined, - nextActions: nextActionsFor(error), - }); -} diff --git a/packages/cli/src/commands/bucket/key-create.ts b/packages/cli/src/commands/bucket/key-create.ts index 156c373d..5459f5cc 100644 --- a/packages/cli/src/commands/bucket/key-create.ts +++ b/packages/cli/src/commands/bucket/key-create.ts @@ -5,11 +5,13 @@ import { flag, type Presentations, } from "@prisma/cli-engine"; -import { notOk, ok } from "@prisma/cli-engine/protocol"; -import { usageError } from "../../errors"; +import { CliStructuredError, ok } from "@prisma/cli-engine/protocol"; import type { BucketKeyCreateResult } from "../../types/bucket"; -import { bucketPositional, resolveBucketProviderOnly } from "./context"; -import { mapBucketOperationError } from "./errors"; +import { + bucketPositional, + LIST_BUCKETS_COMMAND, + resolveBucketProviderOnly, +} from "./context"; /** Legacy `resolveKeyRole`: anything that is not exactly `read` — the * omitted flag included — is `read_write`. */ @@ -85,33 +87,29 @@ export const bucketKeyCreateCommand = defineCommand({ }, needs: { credentials: true }, handler: async (args, ctx) => { - try { - const bucketId = args.positionals.bucketId.trim(); - if (!bucketId) { - throw usageError( - "Bucket id required", - "Bucket key creation needs a bucket id.", - "Pass the bucket id.", - ["prisma bucket list"], - "bucket", - ); - } - - const created = await resolveBucketProviderOnly(ctx).createKey({ - bucketId, - name: args.flags.name?.trim() || undefined, - role: resolveKeyRole(args.flags.role), - signal: ctx.signal, + const bucketId = args.positionals.bucketId.trim(); + if (!bucketId) { + throw new CliStructuredError("BUCKET.USAGE_ERROR", "Bucket id required", { + why: "Bucket key creation needs a bucket id.", + nextActions: [ + { kind: "user-choice", label: "Pass the bucket id." }, + { + kind: "run-command", + label: LIST_BUCKETS_COMMAND, + command: LIST_BUCKETS_COMMAND, + }, + ], }); - - const result: BucketKeyCreateResult = { bucketId, ...created }; - return ok(ctx.present({ data: result }, createPresentations(result))); - } catch (error) { - const mapped = mapBucketOperationError(error); - if (mapped) { - return notOk(mapped); - } - throw error; } + + const created = await resolveBucketProviderOnly(ctx).createKey({ + bucketId, + name: args.flags.name?.trim() || undefined, + role: resolveKeyRole(args.flags.role), + signal: ctx.signal, + }); + + const result: BucketKeyCreateResult = { bucketId, ...created }; + return ok(ctx.present({ data: result }, createPresentations(result))); }, }); diff --git a/packages/cli/src/commands/bucket/key-delete.ts b/packages/cli/src/commands/bucket/key-delete.ts index 748d9a82..f8895f75 100644 --- a/packages/cli/src/commands/bucket/key-delete.ts +++ b/packages/cli/src/commands/bucket/key-delete.ts @@ -5,11 +5,10 @@ import { type Presentations, positional, } from "@prisma/cli-engine"; -import { notOk, ok } from "@prisma/cli-engine/protocol"; -import { usageError } from "../../errors"; +import { CliStructuredError, ok } from "@prisma/cli-engine/protocol"; +import { CLI_NAME } from "../../cli-name"; import type { BucketKeyDeleteResult } from "../../types/bucket"; import { bucketPositional, resolveBucketProviderOnly } from "./context"; -import { mapBucketOperationError } from "./errors"; function deletePresentations(result: BucketKeyDeleteResult): Presentations { return { @@ -37,31 +36,32 @@ export const bucketKeyDeleteCommand = defineCommand({ }, needs: { credentials: true }, handler: async (args, ctx) => { - try { - const bucketId = args.positionals.bucketId.trim(); - const keyId = args.positionals.keyId.trim(); - if (!bucketId || !keyId) { - throw usageError( - "Bucket id and key id required", - "Bucket key deletion needs both a bucket id and a key id.", - "Pass the bucket id and key id.", - ["prisma bucket key list "], - "bucket", - ); - } + const bucketId = args.positionals.bucketId.trim(); + const keyId = args.positionals.keyId.trim(); + if (!bucketId || !keyId) { + const listKeysCommand = `${CLI_NAME} bucket key list `; + throw new CliStructuredError( + "BUCKET.USAGE_ERROR", + "Bucket id and key id required", + { + why: "Bucket key deletion needs both a bucket id and a key id.", + nextActions: [ + { kind: "user-choice", label: "Pass the bucket id and key id." }, + { + kind: "run-command", + label: listKeysCommand, + command: listKeysCommand, + }, + ], + }, + ); + } - await resolveBucketProviderOnly(ctx).deleteKey(bucketId, keyId, { - signal: ctx.signal, - }); + await resolveBucketProviderOnly(ctx).deleteKey(bucketId, keyId, { + signal: ctx.signal, + }); - const result: BucketKeyDeleteResult = { key: { id: keyId } }; - return ok(ctx.present({ data: result }, deletePresentations(result))); - } catch (error) { - const mapped = mapBucketOperationError(error); - if (mapped) { - return notOk(mapped); - } - throw error; - } + const result: BucketKeyDeleteResult = { key: { id: keyId } }; + return ok(ctx.present({ data: result }, deletePresentations(result))); }, }); diff --git a/packages/cli/src/commands/bucket/key-list.ts b/packages/cli/src/commands/bucket/key-list.ts index 87306537..247cd2c4 100644 --- a/packages/cli/src/commands/bucket/key-list.ts +++ b/packages/cli/src/commands/bucket/key-list.ts @@ -4,12 +4,14 @@ import { defineCommand, type Presentations, } from "@prisma/cli-engine"; -import { notOk, ok } from "@prisma/cli-engine/protocol"; -import { usageError } from "../../errors"; +import { CliStructuredError, ok } from "@prisma/cli-engine/protocol"; import { serializeBucketKeyList } from "../../presenters/bucket"; import type { BucketKeyListResult } from "../../types/bucket"; -import { bucketPositional, resolveBucketProviderOnly } from "./context"; -import { mapBucketOperationError } from "./errors"; +import { + bucketPositional, + LIST_BUCKETS_COMMAND, + resolveBucketProviderOnly, +} from "./context"; import { bucketKeyRows } from "./presentation"; const TITLE = "Listing access keys for bucket."; @@ -50,30 +52,26 @@ export const bucketKeyListCommand = defineCommand({ }, needs: { credentials: true }, handler: async (args, ctx) => { - try { - const bucketId = args.positionals.bucketId.trim(); - if (!bucketId) { - throw usageError( - "Bucket id required", - "Bucket key listing needs a bucket id.", - "Pass the bucket id.", - ["prisma bucket list"], - "bucket", - ); - } - - const keys = await resolveBucketProviderOnly(ctx).listKeys(bucketId, { - signal: ctx.signal, + const bucketId = args.positionals.bucketId.trim(); + if (!bucketId) { + throw new CliStructuredError("BUCKET.USAGE_ERROR", "Bucket id required", { + why: "Bucket key listing needs a bucket id.", + nextActions: [ + { kind: "user-choice", label: "Pass the bucket id." }, + { + kind: "run-command", + label: LIST_BUCKETS_COMMAND, + command: LIST_BUCKETS_COMMAND, + }, + ], }); - - const result: BucketKeyListResult = { bucketId, keys }; - return ok(ctx.present({ data: result }, listPresentations(result))); - } catch (error) { - const mapped = mapBucketOperationError(error); - if (mapped) { - return notOk(mapped); - } - throw error; } + + const keys = await resolveBucketProviderOnly(ctx).listKeys(bucketId, { + signal: ctx.signal, + }); + + const result: BucketKeyListResult = { bucketId, keys }; + return ok(ctx.present({ data: result }, listPresentations(result))); }, }); diff --git a/packages/cli/src/commands/bucket/list.ts b/packages/cli/src/commands/bucket/list.ts index 2c87dcc2..7f88bd40 100644 --- a/packages/cli/src/commands/bucket/list.ts +++ b/packages/cli/src/commands/bucket/list.ts @@ -4,11 +4,10 @@ import { defineCommand, type Presentations, } from "@prisma/cli-engine"; -import { notOk, ok } from "@prisma/cli-engine/protocol"; +import { ok } from "@prisma/cli-engine/protocol"; import { serializeBucketList } from "../../presenters/bucket"; import type { BucketListResult } from "../../types/bucket"; import { branchFlag, projectFlag, resolveBucketContext } from "./context"; -import { mapBucketOperationError } from "./errors"; import { bucketRows, bucketStdoutRows } from "./presentation"; const TITLE = "Listing object-store buckets for the resolved project."; @@ -62,31 +61,23 @@ export const bucketListCommand = defineCommand({ }, needs: { credentials: true }, handler: async (args, ctx) => { - try { - const { provider, projectId, projectName } = await resolveBucketContext( - ctx, - args.flags, - "bucket list", - ); - const buckets = await provider.listBuckets({ - projectId, - branchName: args.flags.branch, - signal: ctx.signal, - }); + const { provider, projectId, projectName } = await resolveBucketContext( + ctx, + args.flags, + "bucket list", + ); + const buckets = await provider.listBuckets({ + projectId, + branchName: args.flags.branch, + signal: ctx.signal, + }); - const result: BucketListResult = { - projectId, - projectName, - branchName: args.flags.branch ?? null, - buckets, - }; - return ok(ctx.present({ data: result }, listPresentations(result))); - } catch (error) { - const mapped = mapBucketOperationError(error); - if (mapped) { - return notOk(mapped); - } - throw error; - } + const result: BucketListResult = { + projectId, + projectName, + branchName: args.flags.branch ?? null, + buckets, + }; + return ok(ctx.present({ data: result }, listPresentations(result))); }, }); diff --git a/packages/cli/src/commands/git/connect.ts b/packages/cli/src/commands/git/connect.ts index 7f8f6241..0594fdbf 100644 --- a/packages/cli/src/commands/git/connect.ts +++ b/packages/cli/src/commands/git/connect.ts @@ -6,7 +6,7 @@ import { type Presentations, positional, } from "@prisma/cli-engine"; -import { CliStructuredError, notOk, ok } from "@prisma/cli-engine/protocol"; +import { CliStructuredError, ok } from "@prisma/cli-engine/protocol"; import type { ManagementApiClient } from "@prisma/management-api-sdk"; import type { GitHubRepositoryReference } from "../../adapters/git"; import { @@ -29,7 +29,6 @@ import { toRepositoryConnection, unsupportedRepositoryProviderError, } from "../../controllers/project"; -import { usageError } from "../../errors"; import { formatGitConnectionDetail } from "../../presenters/project"; import type { ProjectRepositoryConnectionResult } from "../../types/project"; import { @@ -37,7 +36,7 @@ import { projectFlag, resolveGitContext, } from "./context"; -import { installWaitFailedError, mapGitOperationError } from "./errors"; +import { installWaitFailedError } from "./errors"; /** The legacy wait line, printed once before the poll loop. */ const WAIT_MESSAGE = @@ -174,96 +173,94 @@ export const gitConnectCommand = defineCommand({ // wait: the repository already connected, or the app already installed. needs: { credentials: true }, handler: async (args, ctx) => { - try { - const { api, target } = await resolveGitContext( - ctx, - args.flags.project, - "git connect", + const { api, target } = await resolveGitContext( + ctx, + args.flags.project, + "git connect", + ); + + const remoteUrl = + args.positionals.gitUrl ?? + (await readGitOriginRemote(ctx.cwd, ctx.signal)); + if (!remoteUrl) { + const example = `${CLI_NAME} git connect git@github.com:prisma/prisma-cli.git`; + throw new CliStructuredError( + "GIT.USAGE_ERROR", + "Repository connection requires a GitHub repository URL", + { + why: "No git-url was provided and the local repo does not have an origin remote.", + nextActions: [ + { + kind: "user-choice", + label: `Pass a GitHub repository URL, or add a GitHub origin remote and rerun ${CLI_NAME} git connect.`, + }, + { kind: "run-command", label: example, command: example }, + ], + }, ); + } - const remoteUrl = - args.positionals.gitUrl ?? - (await readGitOriginRemote(ctx.cwd, ctx.signal)); - if (!remoteUrl) { - throw usageError( - "Repository connection requires a GitHub repository URL", - "No git-url was provided and the local repo does not have an origin remote.", - `Pass a GitHub repository URL, or add a GitHub origin remote and rerun ${CLI_NAME} git connect.`, - [`${CLI_NAME} git connect git@github.com:prisma/prisma-cli.git`], - "project", - ); - } + const repository = parseGitHubRepositoryUrl(remoteUrl); + if (!repository) { + throw unsupportedRepositoryProviderError(); + } - const repository = parseGitHubRepositoryUrl(remoteUrl); - if (!repository) { - throw unsupportedRepositoryProviderError(); + const existing = await readFirstSourceRepository( + api, + target.project.id, + ctx.signal, + ); + if (existing) { + const existingConnection = toRepositoryConnection(existing); + if ( + !repositoryFullNamesMatch( + existingConnection.repository.fullName, + repository.fullName, + ) + ) { + throw repoAlreadyConnectedError(existingConnection.repository.fullName); } - const existing = await readFirstSourceRepository( - api, - target.project.id, - ctx.signal, + const idempotent: ProjectRepositoryConnectionResult = { + ...target, + repositoryConnection: existingConnection, + }; + return ok( + ctx.present({ data: idempotent }, connectPresentations(idempotent)), ); - if (existing) { - const existingConnection = toRepositoryConnection(existing); - if ( - !repositoryFullNamesMatch( - existingConnection.repository.fullName, - repository.fullName, - ) - ) { - throw repoAlreadyConnectedError( - existingConnection.repository.fullName, - ); - } - - const idempotent: ProjectRepositoryConnectionResult = { - ...target, - repositoryConnection: existingConnection, - }; - return ok( - ctx.present({ data: idempotent }, connectPresentations(idempotent)), - ); - } + } - const installed = await resolveInstalledRepository( - ctx, - api, - target.workspace.id, - repository, - ); + const installed = await resolveInstalledRepository( + ctx, + api, + target.workspace.id, + repository, + ); - const { data, error, response } = await api.POST( - "/v1/source-repositories", - { - body: { - projectId: target.project.id, - provider: "github", - providerRepositoryId: installed.repository.id, - installationId: installed.installation.id, - }, - signal: ctx.signal, + const { data, error, response } = await api.POST( + "/v1/source-repositories", + { + body: { + projectId: target.project.id, + provider: "github", + providerRepositoryId: installed.repository.id, + installationId: installed.installation.id, }, + signal: ctx.signal, + }, + ); + if (error || !data) { + throw repoConnectionApiError( + "Failed to connect GitHub repository", + response, + error, ); - if (error || !data) { - throw repoConnectionApiError( - "Failed to connect GitHub repository", - response, - error, - ); - } - - const result: ProjectRepositoryConnectionResult = { - ...target, - repositoryConnection: toRepositoryConnection(data.data), - }; - return ok(ctx.present({ data: result }, connectPresentations(result))); - } catch (error) { - const mapped = mapGitOperationError(error); - if (mapped) { - return notOk(mapped); - } - throw error; } + + const result: ProjectRepositoryConnectionResult = { + ...target, + repositoryConnection: toRepositoryConnection(data.data), + }; + return ok(ctx.present({ data: result }, connectPresentations(result))); }, }); diff --git a/packages/cli/src/commands/git/disconnect.ts b/packages/cli/src/commands/git/disconnect.ts index e4d1d2d9..4c4eb103 100644 --- a/packages/cli/src/commands/git/disconnect.ts +++ b/packages/cli/src/commands/git/disconnect.ts @@ -4,7 +4,7 @@ import { defineCommand, type Presentations, } from "@prisma/cli-engine"; -import { notOk, ok } from "@prisma/cli-engine/protocol"; +import { ok } from "@prisma/cli-engine/protocol"; import { readFirstSourceRepository, repoConnectionApiError, @@ -13,7 +13,6 @@ import { } from "../../controllers/project"; import type { ProjectRepositoryConnectionResult } from "../../types/project"; import { projectFlag, resolveGitContext } from "./context"; -import { mapGitOperationError } from "./errors"; function disconnectPresentations( result: ProjectRepositoryConnectionResult, @@ -57,44 +56,36 @@ export const gitDisconnectCommand = defineCommand({ }, needs: { credentials: true }, handler: async (args, ctx) => { - try { - const { api, target } = await resolveGitContext( - ctx, - args.flags.project, - "git disconnect", - ); - const existing = await readFirstSourceRepository( - api, - target.project.id, - ctx.signal, - ); - if (!existing) { - throw repoNotConnectedError(); - } + const { api, target } = await resolveGitContext( + ctx, + args.flags.project, + "git disconnect", + ); + const existing = await readFirstSourceRepository( + api, + target.project.id, + ctx.signal, + ); + if (!existing) { + throw repoNotConnectedError(); + } - const { error, response } = await api.DELETE( - "/v1/source-repositories/{id}", - { params: { path: { id: existing.id } }, signal: ctx.signal }, + const { error, response } = await api.DELETE( + "/v1/source-repositories/{id}", + { params: { path: { id: existing.id } }, signal: ctx.signal }, + ); + if (error) { + throw repoConnectionApiError( + "Failed to disconnect GitHub repository", + response, + error, ); - if (error) { - throw repoConnectionApiError( - "Failed to disconnect GitHub repository", - response, - error, - ); - } - - const result: ProjectRepositoryConnectionResult = { - ...target, - repositoryConnection: toRepositoryConnection(existing), - }; - return ok(ctx.present({ data: result }, disconnectPresentations(result))); - } catch (error) { - const mapped = mapGitOperationError(error); - if (mapped) { - return notOk(mapped); - } - throw error; } + + const result: ProjectRepositoryConnectionResult = { + ...target, + repositoryConnection: toRepositoryConnection(existing), + }; + return ok(ctx.present({ data: result }, disconnectPresentations(result))); }, }); diff --git a/packages/cli/src/commands/git/errors.ts b/packages/cli/src/commands/git/errors.ts index 9f60c229..180db23a 100644 --- a/packages/cli/src/commands/git/errors.ts +++ b/packages/cli/src/commands/git/errors.ts @@ -1,122 +1,27 @@ /** - * Mapping from the git-connection flow's legacy CliError shapes to the - * engine protocol's dotted GIT.* structured errors, following - * `commands/auth/errors.ts`. Unmapped codes fall through to - * `GIT.` (the API-passthrough rule), including the legacy - * `AUTH_REQUIRED` residue a returned 403 still produces — the engine - * settles every real credentials failure itself. The project - * resolution codes are shared with the project group's mapper. + * The git-connection flow's structured errors. The raise sites own their + * `GIT.*` codes (see `controllers/project.ts`); this file holds only the + * install-wait outcome, which picks between two of them. */ -import type { NextAction } from "@prisma/cli-engine/protocol"; -import { CliStructuredError } from "@prisma/cli-engine/protocol"; +import type { CliStructuredError } from "@prisma/cli-engine/protocol"; import type { GitHubRepositoryReference } from "../../adapters/git"; import { repoInstallationRequiredError, repoNotAccessibleError, } from "../../controllers/project"; -import { CliError } from "../../errors"; -import { mapProjectOperationError, portCommandString } from "../project/errors"; - -const GIT_CODE_MAP: Readonly> = { - USAGE_ERROR: "GIT.USAGE_ERROR", - REPO_PROVIDER_UNSUPPORTED: "GIT.REPO_PROVIDER_UNSUPPORTED", - REPO_ALREADY_CONNECTED: "GIT.REPO_ALREADY_CONNECTED", - REPO_INSTALLATION_REQUIRED: "GIT.REPO_INSTALLATION_REQUIRED", - REPO_NOT_ACCESSIBLE: "GIT.REPO_NOT_ACCESSIBLE", - REPO_NOT_CONNECTED: "GIT.REPO_NOT_CONNECTED", - REPO_CONNECTION_FAILED: "GIT.REPO_CONNECTION_FAILED", -}; - -/** The project-resolution codes the git commands can raise; they keep - * the project group's dotted codes and copy. */ -const PROJECT_CODES: ReadonlySet = new Set([ - "PROJECT_NOT_FOUND", - "PROJECT_AMBIGUOUS", - "PROJECT_SETUP_REQUIRED", - "LOCAL_STATE_STALE", - "LOCAL_PROJECT_WORKSPACE_MISMATCH", -]); - -const STALE_INTERACTIVE_SIGN_IN = - /, or rerun the command in a TTY to sign in interactively\./g; -const TRACE_FLAG = /--trace/g; - -/** `--trace` is gone in this CLI; the log level replaces it. Interactive - * sign-in is gone too (R-S2b-2), so the legacy offer to rerun in a TTY - * describes something this CLI cannot do; `auth login` is the whole remedy. */ -function portFixText(fix: string): string { - return fix - .replace(TRACE_FLAG, "--log-level verbose") - .replace(STALE_INTERACTIVE_SIGN_IN, "."); -} - -/** The install-required and not-accessible errors put the raw install - * URL in their nextSteps beside real commands. A URL is not a command, - * so it becomes an `open-url` action. */ -function nextStepAction(step: string): NextAction { - if (step.startsWith("https://") || step.startsWith("http://")) { - return { kind: "open-url", label: step, url: step }; - } - const command = portCommandString(step); - return { kind: "run-command", label: command, command }; -} - -function nextActionsFor(error: CliError): NextAction[] { - return [ - ...(error.fix - ? [{ kind: "user-choice" as const, label: portFixText(error.fix) }] - : []), - ...error.nextSteps.map(nextStepAction), - ]; -} - -/** The legacy errors branch their fix text on whether a browser was - * opened. The engine's browser wait always shows the URL, so the - * opened branch is the one that describes what this CLI does. */ -const BROWSER_OPENED = true; /** - * The install wait's two terminal outcomes. The legacy constructors own - * every copy string; the design drops `opened` from the meta they build - * (`browserWait` does not report it and the URL is always shown), so - * the structured error is assembled from their fields with the meta - * d3 §3.8 pins. + * The install wait's two terminal outcomes: the workspace has an + * inspectable installation that simply does not expose the repository, + * or it has none at all. */ export function installWaitFailedError( repository: GitHubRepositoryReference, installUrl: string, inspectableInstallationCount: number, ): CliStructuredError { - const legacy = - inspectableInstallationCount > 0 - ? repoNotAccessibleError(repository, installUrl, BROWSER_OPENED) - : repoInstallationRequiredError(repository, installUrl, BROWSER_OPENED); - - return new CliStructuredError( - GIT_CODE_MAP[legacy.code] as `${string}.${string}`, - legacy.summary, - { - why: legacy.why ?? undefined, - meta: { repository: repository.fullName, installUrl }, - nextActions: nextActionsFor(legacy), - }, - ); -} - -export function mapGitOperationError( - error: unknown, -): CliStructuredError | null { - if (!(error instanceof CliError)) { - return null; - } - if (PROJECT_CODES.has(error.code)) { - return mapProjectOperationError(error); - } - const code = GIT_CODE_MAP[error.code] ?? `GIT.${error.code}`; - return new CliStructuredError(code as `${string}.${string}`, error.summary, { - why: error.why ?? undefined, - meta: Object.keys(error.meta).length > 0 ? error.meta : undefined, - nextActions: nextActionsFor(error), - }); + return inspectableInstallationCount > 0 + ? repoNotAccessibleError(repository, installUrl) + : repoInstallationRequiredError(repository, installUrl); } diff --git a/packages/cli/src/commands/postgres/backup-list.ts b/packages/cli/src/commands/postgres/backup-list.ts index 4e1145fb..16e0b6b8 100644 --- a/packages/cli/src/commands/postgres/backup-list.ts +++ b/packages/cli/src/commands/postgres/backup-list.ts @@ -5,18 +5,16 @@ import { flag, type Presentations, } from "@prisma/cli-engine"; -import { notOk, ok } from "@prisma/cli-engine/protocol"; +import { ok } from "@prisma/cli-engine/protocol"; import { parseBackupLimit, resolveDatabase } from "../../controllers/database"; import { serializeDatabaseBackupList } from "../../presenters/database"; import type { DatabaseBackupListResult } from "../../types/database"; import { branchFlag, databasePositional, - legacyCommandFormatter, projectFlag, resolvePostgresContext, } from "./context"; -import { mapPostgresOperationError } from "./errors"; import { backupRows, backupStdoutRows } from "./presentation"; const TITLE = "Listing platform-created database backups."; @@ -89,37 +87,29 @@ export const postgresBackupListCommand = defineCommand({ }, needs: { credentials: true }, handler: async (args, ctx) => { - try { - const limit = parseBackupLimit(args.flags.limit, legacyCommandFormatter); - const { provider, target, projectId, projectName } = - await resolvePostgresContext(ctx, args.flags, "postgres backup list"); - const database = await resolveDatabase( - provider, - target, - args.positionals.database, - args.flags.branch, - ctx.signal, - ); - const backups = await provider.listBackups(database.id, { - limit, - signal: ctx.signal, - }); + const limit = parseBackupLimit(args.flags.limit); + const { provider, target, projectId, projectName } = + await resolvePostgresContext(ctx, args.flags, "postgres backup list"); + const database = await resolveDatabase( + provider, + target, + args.positionals.database, + args.flags.branch, + ctx.signal, + ); + const backups = await provider.listBackups(database.id, { + limit, + signal: ctx.signal, + }); - const result: DatabaseBackupListResult = { - projectId, - projectName, - database, - backups: backups.backups, - retentionDays: backups.retentionDays, - hasMore: backups.hasMore, - }; - return ok(ctx.present({ data: result }, backupListPresentations(result))); - } catch (error) { - const mapped = mapPostgresOperationError(error); - if (mapped) { - return notOk(mapped); - } - throw error; - } + const result: DatabaseBackupListResult = { + projectId, + projectName, + database, + backups: backups.backups, + retentionDays: backups.retentionDays, + hasMore: backups.hasMore, + }; + return ok(ctx.present({ data: result }, backupListPresentations(result))); }, }); diff --git a/packages/cli/src/commands/postgres/backup-restore.ts b/packages/cli/src/commands/postgres/backup-restore.ts index 5153f62c..ad4a2bf7 100644 --- a/packages/cli/src/commands/postgres/backup-restore.ts +++ b/packages/cli/src/commands/postgres/backup-restore.ts @@ -6,18 +6,11 @@ import { type Presentations, positional, } from "@prisma/cli-engine"; -import { notOk, ok } from "@prisma/cli-engine/protocol"; +import { CliStructuredError, ok } from "@prisma/cli-engine/protocol"; import { CLI_NAME } from "../../cli-name"; import { resolveDatabase } from "../../controllers/database"; -import { usageError } from "../../errors"; import type { DatabaseRestoreResult } from "../../types/database"; -import { - branchFlag, - legacyCommandFormatter, - projectFlag, - resolvePostgresContext, -} from "./context"; -import { mapPostgresOperationError } from "./errors"; +import { branchFlag, projectFlag, resolvePostgresContext } from "./context"; import type { FieldRow } from "./presentation"; const CONSENT_QUESTION = @@ -93,80 +86,69 @@ export const postgresBackupRestoreCommand = defineCommand({ }, needs: { credentials: true }, handler: async (args, ctx) => { - try { - const backupId = args.flags.backup?.trim(); - if (!backupId) { - throw usageError( - "Backup id required", - "Database restore needs the backup to restore from.", - `Pass --backup from ${legacyCommandFormatter(["postgres", "backup", "list", ""])}.`, - [ - legacyCommandFormatter([ - "postgres", - "backup", - "list", - "", - ]), + const backupId = args.flags.backup?.trim(); + if (!backupId) { + const listCommand = `${CLI_NAME} postgres backup list `; + throw new CliStructuredError( + "POSTGRES.USAGE_ERROR", + "Backup id required", + { + why: "Database restore needs the backup to restore from.", + nextActions: [ + { + kind: "user-choice", + label: `Pass --backup from ${listCommand}.`, + }, + { kind: "run-command", label: listCommand, command: listCommand }, ], - "database", - ); - } - - const { provider, target, projectId, projectName } = - await resolvePostgresContext( - ctx, - args.flags, - "postgres backup restore", - ); - const database = await resolveDatabase( - provider, - target, - args.positionals.database, - args.flags.branch, - ctx.signal, + }, ); - const sourceDatabase = args.flags.sourceDatabase - ? await resolveDatabase( - provider, - target, - args.flags.sourceDatabase, - args.flags.branch, - ctx.signal, - ) - : database; + } - await ctx.prompt.consent(CONSENT_QUESTION, { token: database.id }); + const { provider, target, projectId, projectName } = + await resolvePostgresContext(ctx, args.flags, "postgres backup restore"); + const database = await resolveDatabase( + provider, + target, + args.positionals.database, + args.flags.branch, + ctx.signal, + ); + const sourceDatabase = args.flags.sourceDatabase + ? await resolveDatabase( + provider, + target, + args.flags.sourceDatabase, + args.flags.branch, + ctx.signal, + ) + : database; - const restored = await provider.restoreDatabase({ - targetDatabaseId: database.id, - sourceDatabaseId: sourceDatabase.id, - backupId, - projectId, - signal: ctx.signal, - }); + await ctx.prompt.consent(CONSENT_QUESTION, { token: database.id }); - const result: DatabaseRestoreResult = { - projectId, - projectName, - database: restored, - source: { databaseId: sourceDatabase.id, backupId }, - }; - return ok( - ctx.present( - { data: result }, - restorePresentations( - result, - sourceDatabase.id === database.id ? null : sourceDatabase.id, - database.id, - ), + const restored = await provider.restoreDatabase({ + targetDatabaseId: database.id, + sourceDatabaseId: sourceDatabase.id, + backupId, + projectId, + signal: ctx.signal, + }); + + const result: DatabaseRestoreResult = { + projectId, + projectName, + database: restored, + source: { databaseId: sourceDatabase.id, backupId }, + }; + return ok( + ctx.present( + { data: result }, + restorePresentations( + result, + sourceDatabase.id === database.id ? null : sourceDatabase.id, + database.id, ), - ); - } catch (error) { - const mapped = mapPostgresOperationError(error); - if (mapped) { - return notOk(mapped); - } - throw error; - } + ), + ); }, }); diff --git a/packages/cli/src/commands/postgres/connection-create.ts b/packages/cli/src/commands/postgres/connection-create.ts index d4fae79e..ab0dbe3c 100644 --- a/packages/cli/src/commands/postgres/connection-create.ts +++ b/packages/cli/src/commands/postgres/connection-create.ts @@ -1,6 +1,6 @@ /** The `postgres connection create` command. */ import { defineCommand, flag } from "@prisma/cli-engine"; -import { notOk, ok } from "@prisma/cli-engine/protocol"; +import { ok } from "@prisma/cli-engine/protocol"; import { defaultConnectionName, resolveDatabase, @@ -12,7 +12,6 @@ import { projectFlag, resolvePostgresContext, } from "./context"; -import { mapPostgresOperationError } from "./errors"; import { postgresTargetLabel, secretBlocks } from "./presentation"; export const postgresConnectionCreateCommand = defineCommand({ @@ -34,54 +33,46 @@ export const postgresConnectionCreateCommand = defineCommand({ }, needs: { credentials: true }, handler: async (args, ctx) => { - try { - const { provider, target, projectId, projectName } = - await resolvePostgresContext( - ctx, - args.flags, - "postgres connection create", - ); - const database = await resolveDatabase( - provider, - target, - args.positionals.database, - args.flags.branch, - ctx.signal, + const { provider, target, projectId, projectName } = + await resolvePostgresContext( + ctx, + args.flags, + "postgres connection create", ); - const created = await provider.createConnection({ - databaseId: database.id, - name: args.flags.name?.trim() || defaultConnectionName(), - signal: ctx.signal, - }); + const database = await resolveDatabase( + provider, + target, + args.positionals.database, + args.flags.branch, + ctx.signal, + ); + const created = await provider.createConnection({ + databaseId: database.id, + name: args.flags.name?.trim() || defaultConnectionName(), + signal: ctx.signal, + }); - const result: DatabaseConnectionCreateResult = { - projectId, - projectName, - database, - connection: created.connection, - connectionString: created.connectionString, - }; - return ok( - ctx.present( - { data: result }, - { - human: () => - secretBlocks( - `Added a connection to "${database.name}" in ${postgresTargetLabel(projectName, database.branchName)}.`, - result.connectionString, - ), - stdout: () => [result.connectionString], - json: () => result, - next: () => [], - }, - ), - ); - } catch (error) { - const mapped = mapPostgresOperationError(error); - if (mapped) { - return notOk(mapped); - } - throw error; - } + const result: DatabaseConnectionCreateResult = { + projectId, + projectName, + database, + connection: created.connection, + connectionString: created.connectionString, + }; + return ok( + ctx.present( + { data: result }, + { + human: () => + secretBlocks( + `Added a connection to "${database.name}" in ${postgresTargetLabel(projectName, database.branchName)}.`, + result.connectionString, + ), + stdout: () => [result.connectionString], + json: () => result, + next: () => [], + }, + ), + ); }, }); diff --git a/packages/cli/src/commands/postgres/connection-delete.ts b/packages/cli/src/commands/postgres/connection-delete.ts index 22641fc9..74c5253b 100644 --- a/packages/cli/src/commands/postgres/connection-delete.ts +++ b/packages/cli/src/commands/postgres/connection-delete.ts @@ -1,11 +1,9 @@ /** The `postgres connection delete` command. */ import { type Block, defineCommand, positional } from "@prisma/cli-engine"; -import { notOk, ok } from "@prisma/cli-engine/protocol"; +import { CliStructuredError, ok } from "@prisma/cli-engine/protocol"; import { CLI_NAME } from "../../cli-name"; -import { usageError } from "../../errors"; import type { DatabaseConnectionDeleteResult } from "../../types/database"; import { resolvePostgresProviderOnly } from "./context"; -import { mapPostgresOperationError } from "./errors"; const CONSENT_QUESTION = "Deleting this database connection is destructive and requires the exact id."; @@ -25,61 +23,59 @@ export const postgresConnectionDeleteCommand = defineCommand({ }, needs: { credentials: true }, handler: async (args, ctx) => { - try { - const connectionId = args.positionals.connection.trim(); - if (!connectionId) { - throw usageError( - "Connection id required", - "Database connection deletion needs a connection id.", - "Pass the connection id to delete.", - [ - `${CLI_NAME} postgres connection delete --confirm `, + const connectionId = args.positionals.connection.trim(); + if (!connectionId) { + const example = `${CLI_NAME} postgres connection delete --confirm `; + throw new CliStructuredError( + "POSTGRES.USAGE_ERROR", + "Connection id required", + { + why: "Database connection deletion needs a connection id.", + nextActions: [ + { + kind: "user-choice", + label: "Pass the connection id to delete.", + }, + { kind: "run-command", label: example, command: example }, ], - "database", - ); - } + }, + ); + } - await ctx.prompt.consent(CONSENT_QUESTION, { token: connectionId }); + await ctx.prompt.consent(CONSENT_QUESTION, { token: connectionId }); - const provider = await resolvePostgresProviderOnly(ctx); - await provider.removeConnection(connectionId, { signal: ctx.signal }); + const provider = await resolvePostgresProviderOnly(ctx); + await provider.removeConnection(connectionId, { signal: ctx.signal }); - const result: DatabaseConnectionDeleteResult = { - connection: { id: connectionId }, - }; - return ok( - ctx.present( - { data: result }, - { - human: (): Block[] => [ - { - kind: "summary", - status: "ok", - text: "Deleting database connection.", - }, - { - kind: "fields", - rows: [{ label: "connection", value: connectionId }], - }, - { - kind: "list", - items: [ - "The connection metadata was deleted. Existing one-time secrets were not shown.", - ], - }, - ], - stdout: () => [], - json: () => ({ connection: result.connection }), - next: () => [], - }, - ), - ); - } catch (error) { - const mapped = mapPostgresOperationError(error); - if (mapped) { - return notOk(mapped); - } - throw error; - } + const result: DatabaseConnectionDeleteResult = { + connection: { id: connectionId }, + }; + return ok( + ctx.present( + { data: result }, + { + human: (): Block[] => [ + { + kind: "summary", + status: "ok", + text: "Deleting database connection.", + }, + { + kind: "fields", + rows: [{ label: "connection", value: connectionId }], + }, + { + kind: "list", + items: [ + "The connection metadata was deleted. Existing one-time secrets were not shown.", + ], + }, + ], + stdout: () => [], + json: () => ({ connection: result.connection }), + next: () => [], + }, + ), + ); }, }); diff --git a/packages/cli/src/commands/postgres/connection-list.ts b/packages/cli/src/commands/postgres/connection-list.ts index c521b169..fee6cec4 100644 --- a/packages/cli/src/commands/postgres/connection-list.ts +++ b/packages/cli/src/commands/postgres/connection-list.ts @@ -4,7 +4,7 @@ import { defineCommand, type Presentations, } from "@prisma/cli-engine"; -import { notOk, ok } from "@prisma/cli-engine/protocol"; +import { ok } from "@prisma/cli-engine/protocol"; import { resolveDatabase } from "../../controllers/database"; import { serializeDatabaseConnectionList } from "../../presenters/database"; import type { DatabaseConnectionListResult } from "../../types/database"; @@ -14,7 +14,6 @@ import { projectFlag, resolvePostgresContext, } from "./context"; -import { mapPostgresOperationError } from "./errors"; const TITLE = "Listing database connection metadata."; @@ -85,37 +84,25 @@ export const postgresConnectionListCommand = defineCommand({ }, needs: { credentials: true }, handler: async (args, ctx) => { - try { - const { provider, target, projectId, projectName } = - await resolvePostgresContext( - ctx, - args.flags, - "postgres connection list", - ); - const database = await resolveDatabase( - provider, - target, - args.positionals.database, - args.flags.branch, - ctx.signal, - ); - const connections = await provider.listConnections(database.id, { - signal: ctx.signal, - }); + const { provider, target, projectId, projectName } = + await resolvePostgresContext(ctx, args.flags, "postgres connection list"); + const database = await resolveDatabase( + provider, + target, + args.positionals.database, + args.flags.branch, + ctx.signal, + ); + const connections = await provider.listConnections(database.id, { + signal: ctx.signal, + }); - const result: DatabaseConnectionListResult = { - projectId, - projectName, - database, - connections, - }; - return ok(ctx.present({ data: result }, listPresentations(result))); - } catch (error) { - const mapped = mapPostgresOperationError(error); - if (mapped) { - return notOk(mapped); - } - throw error; - } + const result: DatabaseConnectionListResult = { + projectId, + projectName, + database, + connections, + }; + return ok(ctx.present({ data: result }, listPresentations(result))); }, }); diff --git a/packages/cli/src/commands/postgres/connection-rotate.ts b/packages/cli/src/commands/postgres/connection-rotate.ts index 72946c15..8a57504f 100644 --- a/packages/cli/src/commands/postgres/connection-rotate.ts +++ b/packages/cli/src/commands/postgres/connection-rotate.ts @@ -1,10 +1,9 @@ /** The `postgres connection rotate` command. */ import { defineCommand, positional } from "@prisma/cli-engine"; -import { notOk, ok } from "@prisma/cli-engine/protocol"; -import { usageError } from "../../errors"; +import { CliStructuredError, ok } from "@prisma/cli-engine/protocol"; +import { CLI_NAME } from "../../cli-name"; import type { DatabaseConnectionRotateResult } from "../../types/database"; -import { legacyCommandFormatter, resolvePostgresProviderOnly } from "./context"; -import { mapPostgresOperationError } from "./errors"; +import { resolvePostgresProviderOnly } from "./context"; import { secretBlocks } from "./presentation"; const CONSENT_QUESTION = @@ -26,63 +25,54 @@ export const postgresConnectionRotateCommand = defineCommand({ }, needs: { credentials: true }, handler: async (args, ctx) => { - try { - const connectionId = args.positionals.connection.trim(); - if (!connectionId) { - throw usageError( - "Connection id required", - "Database connection rotation needs a connection id.", - "Pass the connection id to rotate.", - [ - legacyCommandFormatter([ - "database", - "connection", - "rotate", - "", - "--confirm", - "", - ]), + const connectionId = args.positionals.connection.trim(); + if (!connectionId) { + const example = `${CLI_NAME} postgres connection rotate --confirm `; + throw new CliStructuredError( + "POSTGRES.USAGE_ERROR", + "Connection id required", + { + why: "Database connection rotation needs a connection id.", + nextActions: [ + { + kind: "user-choice", + label: "Pass the connection id to rotate.", + }, + { kind: "run-command", label: example, command: example }, ], - "database", - ); - } + }, + ); + } - await ctx.prompt.consent(CONSENT_QUESTION, { token: connectionId }); + await ctx.prompt.consent(CONSENT_QUESTION, { token: connectionId }); - const provider = await resolvePostgresProviderOnly(ctx); - const rotated = await provider.rotateConnection(connectionId, { - signal: ctx.signal, - }); + const provider = await resolvePostgresProviderOnly(ctx); + const rotated = await provider.rotateConnection(connectionId, { + signal: ctx.signal, + }); - const result: DatabaseConnectionRotateResult = { - connection: rotated.connection, - database: rotated.database, - connectionString: rotated.connectionString, - }; - const subject = result.database - ? `"${result.database.name}"` - : `connection ${result.connection.id}`; - return ok( - ctx.present( - { data: result }, - { - human: () => - secretBlocks( - `Rotated credentials for ${subject}. The previous credentials no longer work.`, - result.connectionString, - ), - stdout: () => [result.connectionString], - json: () => result, - next: () => [], - }, - ), - ); - } catch (error) { - const mapped = mapPostgresOperationError(error); - if (mapped) { - return notOk(mapped); - } - throw error; - } + const result: DatabaseConnectionRotateResult = { + connection: rotated.connection, + database: rotated.database, + connectionString: rotated.connectionString, + }; + const subject = result.database + ? `"${result.database.name}"` + : `connection ${result.connection.id}`; + return ok( + ctx.present( + { data: result }, + { + human: () => + secretBlocks( + `Rotated credentials for ${subject}. The previous credentials no longer work.`, + result.connectionString, + ), + stdout: () => [result.connectionString], + json: () => result, + next: () => [], + }, + ), + ); }, }); diff --git a/packages/cli/src/commands/postgres/context.ts b/packages/cli/src/commands/postgres/context.ts index 5e73bc5d..36b7c4fd 100644 --- a/packages/cli/src/commands/postgres/context.ts +++ b/packages/cli/src/commands/postgres/context.ts @@ -1,7 +1,5 @@ /** Workspace, project and provider for the `postgres *` commands. */ import { type CommandContext, flag, positional } from "@prisma/cli-engine"; -import { CLI_NAME } from "../../cli-name"; -import type { PrismaCliPackageCommandFormatter } from "../../lib/agent/cli-command"; import { createManagementDatabaseProvider, type DatabaseProvider, @@ -27,13 +25,6 @@ export const databasePositional = positional.string({ placeholder: "database", }); -/** The legacy helpers build their nextSteps through a command - * formatter. This CLI phrases every command string as `${CLI_NAME} …`; the - * error mapper rewrites the `database` group name to `postgres`. */ -export const legacyCommandFormatter: PrismaCliPackageCommandFormatter = ( - args, -) => [CLI_NAME, ...args].join(" "); - export interface PostgresContext { readonly provider: DatabaseProvider; readonly target: ResolvedProjectTarget; diff --git a/packages/cli/src/commands/postgres/create.ts b/packages/cli/src/commands/postgres/create.ts index f36dca93..778ba111 100644 --- a/packages/cli/src/commands/postgres/create.ts +++ b/packages/cli/src/commands/postgres/create.ts @@ -1,12 +1,10 @@ /** The `postgres create` command. */ import { defineCommand, flag, positional } from "@prisma/cli-engine"; -import { notOk, ok } from "@prisma/cli-engine/protocol"; +import { CliStructuredError, ok } from "@prisma/cli-engine/protocol"; import { CLI_NAME } from "../../cli-name"; import { ensureProjectId } from "../../controllers/database"; -import { usageError } from "../../errors"; import type { DatabaseCreateResult } from "../../types/database"; import { branchFlag, projectFlag, resolvePostgresContext } from "./context"; -import { mapPostgresOperationError } from "./errors"; import { postgresTargetLabel, secretBlocks } from "./presentation"; export const postgresCreateCommand = defineCommand({ @@ -36,59 +34,56 @@ export const postgresCreateCommand = defineCommand({ }, needs: { credentials: true }, handler: async (args, ctx) => { - try { - const name = args.positionals.name.trim(); - if (!name) { - throw usageError( - "Database name required", - "Database create needs a non-empty name.", - "Pass a database name.", - [`${CLI_NAME} postgres create `], - "database", - ); - } - - const { provider, projectId, projectName } = await resolvePostgresContext( - ctx, - args.flags, - "postgres create", - ); - const created = await provider.createDatabase({ - projectId, - name, - branchName: args.flags.branch, - region: args.flags.region, - signal: ctx.signal, - }); - - const result: DatabaseCreateResult = { - projectId, - projectName, - database: ensureProjectId(created.database, projectId), - connection: created.connection, - connectionString: created.connectionString, - }; - return ok( - ctx.present( - { data: result }, - { - human: () => - secretBlocks( - `Created database "${result.database.name}" in ${postgresTargetLabel(projectName, result.database.branchName)}.`, - result.connectionString, - ), - stdout: () => [result.connectionString], - json: () => result, - next: () => [], - }, - ), + const name = args.positionals.name.trim(); + if (!name) { + const example = `${CLI_NAME} postgres create `; + throw new CliStructuredError( + "POSTGRES.USAGE_ERROR", + "Database name required", + { + why: "Database create needs a non-empty name.", + nextActions: [ + { kind: "user-choice", label: "Pass a database name." }, + { kind: "run-command", label: example, command: example }, + ], + }, ); - } catch (error) { - const mapped = mapPostgresOperationError(error); - if (mapped) { - return notOk(mapped); - } - throw error; } + + const { provider, projectId, projectName } = await resolvePostgresContext( + ctx, + args.flags, + "postgres create", + ); + const created = await provider.createDatabase({ + projectId, + name, + branchName: args.flags.branch, + region: args.flags.region, + signal: ctx.signal, + }); + + const result: DatabaseCreateResult = { + projectId, + projectName, + database: ensureProjectId(created.database, projectId), + connection: created.connection, + connectionString: created.connectionString, + }; + return ok( + ctx.present( + { data: result }, + { + human: () => + secretBlocks( + `Created database "${result.database.name}" in ${postgresTargetLabel(projectName, result.database.branchName)}.`, + result.connectionString, + ), + stdout: () => [result.connectionString], + json: () => result, + next: () => [], + }, + ), + ); }, }); diff --git a/packages/cli/src/commands/postgres/delete.ts b/packages/cli/src/commands/postgres/delete.ts index 752dd2af..6995379d 100644 --- a/packages/cli/src/commands/postgres/delete.ts +++ b/packages/cli/src/commands/postgres/delete.ts @@ -4,7 +4,7 @@ import { defineCommand, type Presentations, } from "@prisma/cli-engine"; -import { notOk, ok } from "@prisma/cli-engine/protocol"; +import { ok } from "@prisma/cli-engine/protocol"; import { resolveDatabase } from "../../controllers/database"; import type { DatabaseDeleteResult } from "../../types/database"; import { @@ -13,7 +13,6 @@ import { projectFlag, resolvePostgresContext, } from "./context"; -import { mapPostgresOperationError } from "./errors"; const CONSENT_QUESTION = "Deleting this database is destructive and requires the exact id."; @@ -52,33 +51,25 @@ export const postgresDeleteCommand = defineCommand({ }, needs: { credentials: true }, handler: async (args, ctx) => { - try { - const { provider, target, projectId, projectName } = - await resolvePostgresContext(ctx, args.flags, "postgres delete"); - const database = await resolveDatabase( - provider, - target, - args.positionals.database, - args.flags.branch, - ctx.signal, - ); + const { provider, target, projectId, projectName } = + await resolvePostgresContext(ctx, args.flags, "postgres delete"); + const database = await resolveDatabase( + provider, + target, + args.positionals.database, + args.flags.branch, + ctx.signal, + ); - await ctx.prompt.consent(CONSENT_QUESTION, { token: database.id }); + await ctx.prompt.consent(CONSENT_QUESTION, { token: database.id }); - await provider.removeDatabase(database.id, { signal: ctx.signal }); + await provider.removeDatabase(database.id, { signal: ctx.signal }); - const result: DatabaseDeleteResult = { - projectId, - projectName, - database, - }; - return ok(ctx.present({ data: result }, deletePresentations(result))); - } catch (error) { - const mapped = mapPostgresOperationError(error); - if (mapped) { - return notOk(mapped); - } - throw error; - } + const result: DatabaseDeleteResult = { + projectId, + projectName, + database, + }; + return ok(ctx.present({ data: result }, deletePresentations(result))); }, }); diff --git a/packages/cli/src/commands/postgres/errors.ts b/packages/cli/src/commands/postgres/errors.ts deleted file mode 100644 index ddd1403f..00000000 --- a/packages/cli/src/commands/postgres/errors.ts +++ /dev/null @@ -1,132 +0,0 @@ -/** - * Mapping from the database controllers' and provider's legacy - * CliError shapes to the engine protocol's dotted POSTGRES.* structured - * errors, following `commands/auth/errors.ts`. Unmapped codes fall through - * to `POSTGRES.` (the API-passthrough rule); the project - * resolution codes are shared with the project group's mapper. - */ - -import type { NextAction } from "@prisma/cli-engine/protocol"; -import { CliStructuredError } from "@prisma/cli-engine/protocol"; -import { CLI_NAME } from "../../cli-name"; -import { CliError } from "../../errors"; -import { mapProjectOperationError } from "../project/errors"; - -const POSTGRES_CODE_MAP: Readonly> = { - USAGE_ERROR: "POSTGRES.USAGE_ERROR", - DATABASE_NOT_FOUND: "POSTGRES.NOT_FOUND", - DATABASE_AMBIGUOUS: "POSTGRES.AMBIGUOUS", - DATABASE_CONNECTION_MISSING: "POSTGRES.CONNECTION_MISSING", - DATABASE_CONNECTION_STRING_MISSING: "POSTGRES.CONNECTION_STRING_MISSING", - DATABASE_BACKUPS_UNSUPPORTED: "POSTGRES.BACKUPS_UNSUPPORTED", - DATABASE_RESTORE_CONFLICT: "POSTGRES.RESTORE_CONFLICT", - DATABASE_BACKUP_NOT_FOUND: "POSTGRES.BACKUP_NOT_FOUND", - DATABASE_API_ERROR: "POSTGRES.API_ERROR", -}; - -/** The project-resolution codes this group can raise; they keep the - * project group's dotted codes and copy. */ -const PROJECT_CODES: ReadonlySet = new Set([ - "PROJECT_NOT_FOUND", - "PROJECT_AMBIGUOUS", - "PROJECT_SETUP_REQUIRED", - "LOCAL_STATE_STALE", - "LOCAL_PROJECT_WORKSPACE_MISMATCH", -]); - -const PACKAGE_RUNNER = /[\w./@-]+(?: [\w.-]+)? @prisma\/cli@\S+ /g; -const COMMENT_PREFIX = /^#\s*/; -const LEGACY_GROUP = new RegExp(`${CLI_NAME} database `, "g"); - -/** §0 rename: wherever a legacy command reference appears — a - * nextSteps string or prose inside `fix` — the package-runner prefix - * becomes `${CLI_NAME}` and the `database` group becomes `postgres`. - * The resource noun "database" in prose is left alone. */ -function portCommandReferences(text: string): string { - return text - .replace(PACKAGE_RUNNER, `${CLI_NAME} `) - .replace(LEGACY_GROUP, `${CLI_NAME} postgres `); -} - -export function portPostgresCommand(command: string): string { - const named = portCommandReferences(command); - return named.startsWith(`${CLI_NAME} `) ? named : `${CLI_NAME} ${named}`; -} - -const STALE_INTERACTIVE_SIGN_IN = - /, or rerun the command in a TTY to sign in interactively\./g; -const TRACE_FLAG = /--trace/g; - -/** `--trace` is gone in this CLI; the log level replaces it. Interactive - * sign-in is gone too (R-S2b-2), so the legacy offer to rerun in a TTY - * describes something this CLI cannot do; `auth login` is the whole remedy. */ -function portFixText(fix: string): string { - return portCommandReferences(fix) - .replace(TRACE_FLAG, "--log-level verbose") - .replace(STALE_INTERACTIVE_SIGN_IN, "."); -} - -function runCommandActions(nextSteps: readonly string[]): NextAction[] { - const actions: NextAction[] = []; - let reason: string | undefined; - for (const step of nextSteps) { - if (step.startsWith("#")) { - reason = step.replace(COMMENT_PREFIX, ""); - continue; - } - const command = portPostgresCommand(step); - actions.push({ - kind: "run-command", - label: command, - command, - ...(reason === undefined ? {} : { reason }), - }); - reason = undefined; - } - return actions; -} - -/** PR #127's plan-limit error: the legacy full-page `humanLines` - * rendering does not port, so the recovery guidance becomes the one - * nextAction beside the verbatim why and meta. */ -function planLimitError(error: CliError): CliStructuredError { - const upgradeUrl = error.meta.upgradeUrl; - const planName = error.meta.planName; - const reason = - typeof upgradeUrl === "string" && upgradeUrl - ? `Upgrade at ${upgradeUrl}${typeof planName === "string" && planName ? ` (current plan: ${planName})` : ""}.` - : "Open Prisma Console and upgrade the affected workspace plan."; - - return new CliStructuredError("POSTGRES.PLAN_LIMIT_REACHED", error.summary, { - why: error.why ?? undefined, - meta: error.meta, - nextActions: [ - { kind: "user-choice", label: "Upgrade the workspace plan", reason }, - ], - }); -} - -export function mapPostgresOperationError( - error: unknown, -): CliStructuredError | null { - if (!(error instanceof CliError)) { - return null; - } - if (error.code === "PLAN_LIMIT_REACHED") { - return planLimitError(error); - } - if (PROJECT_CODES.has(error.code)) { - return mapProjectOperationError(error); - } - const code = POSTGRES_CODE_MAP[error.code] ?? `POSTGRES.${error.code}`; - return new CliStructuredError(code as `${string}.${string}`, error.summary, { - why: error.why ?? undefined, - meta: Object.keys(error.meta).length > 0 ? error.meta : undefined, - nextActions: [ - ...(error.fix - ? [{ kind: "user-choice" as const, label: portFixText(error.fix) }] - : []), - ...runCommandActions(error.nextSteps), - ], - }); -} diff --git a/packages/cli/src/commands/postgres/list.ts b/packages/cli/src/commands/postgres/list.ts index 84654044..d1b7411d 100644 --- a/packages/cli/src/commands/postgres/list.ts +++ b/packages/cli/src/commands/postgres/list.ts @@ -4,12 +4,11 @@ import { defineCommand, type Presentations, } from "@prisma/cli-engine"; -import { notOk, ok } from "@prisma/cli-engine/protocol"; +import { ok } from "@prisma/cli-engine/protocol"; import { sortDatabases } from "../../controllers/database"; import { serializeDatabaseList } from "../../presenters/database"; import type { DatabaseListResult } from "../../types/database"; import { branchFlag, projectFlag, resolvePostgresContext } from "./context"; -import { mapPostgresOperationError } from "./errors"; import { branchLabel, formatStatus, statusValue } from "./presentation"; const TITLE = "Listing databases for the resolved project."; @@ -85,33 +84,25 @@ export const postgresListCommand = defineCommand({ }, needs: { credentials: true }, handler: async (args, ctx) => { - try { - const { provider, projectId, projectName } = await resolvePostgresContext( - ctx, - args.flags, - "postgres list", - ); - const databases = sortDatabases( - await provider.listDatabases({ - projectId, - branchName: args.flags.branch, - signal: ctx.signal, - }), - ); - - const result: DatabaseListResult = { + const { provider, projectId, projectName } = await resolvePostgresContext( + ctx, + args.flags, + "postgres list", + ); + const databases = sortDatabases( + await provider.listDatabases({ projectId, - projectName, - branchName: args.flags.branch ?? null, - databases, - }; - return ok(ctx.present({ data: result }, listPresentations(result))); - } catch (error) { - const mapped = mapPostgresOperationError(error); - if (mapped) { - return notOk(mapped); - } - throw error; - } + branchName: args.flags.branch, + signal: ctx.signal, + }), + ); + + const result: DatabaseListResult = { + projectId, + projectName, + branchName: args.flags.branch ?? null, + databases, + }; + return ok(ctx.present({ data: result }, listPresentations(result))); }, }); diff --git a/packages/cli/src/commands/postgres/show.ts b/packages/cli/src/commands/postgres/show.ts index 3954dbfc..4701ae7d 100644 --- a/packages/cli/src/commands/postgres/show.ts +++ b/packages/cli/src/commands/postgres/show.ts @@ -4,7 +4,7 @@ import { defineCommand, type Presentations, } from "@prisma/cli-engine"; -import { notOk, ok } from "@prisma/cli-engine/protocol"; +import { ok } from "@prisma/cli-engine/protocol"; import { resolveDatabase } from "../../controllers/database"; import type { DatabaseShowResult } from "../../types/database"; import { @@ -13,7 +13,6 @@ import { projectFlag, resolvePostgresContext, } from "./context"; -import { mapPostgresOperationError } from "./errors"; import { branchLabel, type FieldRow, @@ -78,33 +77,25 @@ export const postgresShowCommand = defineCommand({ }, needs: { credentials: true }, handler: async (args, ctx) => { - try { - const { provider, target, projectId, projectName } = - await resolvePostgresContext(ctx, args.flags, "postgres show"); - const database = await resolveDatabase( - provider, - target, - args.positionals.database, - args.flags.branch, - ctx.signal, - ); - const connections = await provider.listConnections(database.id, { - signal: ctx.signal, - }); + const { provider, target, projectId, projectName } = + await resolvePostgresContext(ctx, args.flags, "postgres show"); + const database = await resolveDatabase( + provider, + target, + args.positionals.database, + args.flags.branch, + ctx.signal, + ); + const connections = await provider.listConnections(database.id, { + signal: ctx.signal, + }); - const result: DatabaseShowResult = { - projectId, - projectName, - database, - connections, - }; - return ok(ctx.present({ data: result }, showPresentations(result))); - } catch (error) { - const mapped = mapPostgresOperationError(error); - if (mapped) { - return notOk(mapped); - } - throw error; - } + const result: DatabaseShowResult = { + projectId, + projectName, + database, + connections, + }; + return ok(ctx.present({ data: result }, showPresentations(result))); }, }); diff --git a/packages/cli/src/commands/postgres/usage.ts b/packages/cli/src/commands/postgres/usage.ts index d8287f6f..021677dd 100644 --- a/packages/cli/src/commands/postgres/usage.ts +++ b/packages/cli/src/commands/postgres/usage.ts @@ -5,18 +5,19 @@ import { flag, type Presentations, } from "@prisma/cli-engine"; -import { notOk, ok } from "@prisma/cli-engine/protocol"; -import { parseUsageDate, resolveDatabase } from "../../controllers/database"; -import { usageError } from "../../errors"; +import { CliStructuredError, ok } from "@prisma/cli-engine/protocol"; +import { + parseUsageDate, + resolveDatabase, + USAGE_PERIOD_EXAMPLE_COMMAND, +} from "../../controllers/database"; import type { DatabaseUsageResult } from "../../types/database"; import { branchFlag, databasePositional, - legacyCommandFormatter, projectFlag, resolvePostgresContext, } from "./context"; -import { mapPostgresOperationError } from "./errors"; import { type FieldRow, formatUsageMetric, @@ -103,69 +104,52 @@ export const postgresUsageCommand = defineCommand({ }, needs: { credentials: true }, handler: async (args, ctx) => { - try { - const from = parseUsageDate( - args.flags.from, - "--from", - "start", - legacyCommandFormatter, - ); - const to = parseUsageDate( - args.flags.to, - "--to", - "end", - legacyCommandFormatter, - ); - if (from && to && Date.parse(from) > Date.parse(to)) { - throw usageError( - "Invalid usage period", - "--from must not be later than --to.", - "Pass a --from date that is on or before the --to date.", - [ - legacyCommandFormatter([ - "database", - "usage", - "", - "--from", - "2026-06-01", - "--to", - "2026-06-30", - ]), + const from = parseUsageDate(args.flags.from, "--from", "start"); + const to = parseUsageDate(args.flags.to, "--to", "end"); + if (from && to && Date.parse(from) > Date.parse(to)) { + throw new CliStructuredError( + "POSTGRES.USAGE_ERROR", + "Invalid usage period", + { + why: "--from must not be later than --to.", + nextActions: [ + { + kind: "user-choice", + label: "Pass a --from date that is on or before the --to date.", + }, + { + kind: "run-command", + label: USAGE_PERIOD_EXAMPLE_COMMAND, + command: USAGE_PERIOD_EXAMPLE_COMMAND, + }, ], - "database", - ); - } - - const { provider, target, projectId, projectName } = - await resolvePostgresContext(ctx, args.flags, "postgres usage"); - const database = await resolveDatabase( - provider, - target, - args.positionals.database, - args.flags.branch, - ctx.signal, + }, ); - const usage = await provider.getUsage(database.id, { - from, - to, - signal: ctx.signal, - }); - - const result: DatabaseUsageResult = { - projectId, - projectName, - database, - period: usage.period, - metrics: usage.metrics, - generatedAt: usage.generatedAt, - }; - return ok(ctx.present({ data: result }, usagePresentations(result))); - } catch (error) { - const mapped = mapPostgresOperationError(error); - if (mapped) { - return notOk(mapped); - } - throw error; } + + const { provider, target, projectId, projectName } = + await resolvePostgresContext(ctx, args.flags, "postgres usage"); + const database = await resolveDatabase( + provider, + target, + args.positionals.database, + args.flags.branch, + ctx.signal, + ); + const usage = await provider.getUsage(database.id, { + from, + to, + signal: ctx.signal, + }); + + const result: DatabaseUsageResult = { + projectId, + projectName, + database, + period: usage.period, + metrics: usage.metrics, + generatedAt: usage.generatedAt, + }; + return ok(ctx.present({ data: result }, usagePresentations(result))); }, }); diff --git a/packages/cli/src/commands/project/context.ts b/packages/cli/src/commands/project/context.ts index 6d36c92b..12e2f509 100644 --- a/packages/cli/src/commands/project/context.ts +++ b/packages/cli/src/commands/project/context.ts @@ -15,11 +15,11 @@ import { } from "../../lib/project/local-pin"; import { type ProjectCandidate, - projectResolutionErrorToCliError, + projectResolutionErrorToStructured, type ResolvedProjectTarget, resolveProjectTarget, } from "../../lib/project/resolution"; -import { projectDirectoryBindingErrorToCliError } from "../../lib/project/setup"; +import { projectDirectoryBindingErrorToStructured } from "../../lib/project/setup"; import type { AuthWorkspace } from "../../types/auth"; import type { ProjectSetupResult, ProjectSummary } from "../../types/project"; @@ -87,7 +87,7 @@ export async function resolvePinnedProject( commandName, }); if (target.isErr()) { - throw projectResolutionErrorToCliError(target.error); + throw projectResolutionErrorToStructured(target.error); } return target.value; } @@ -106,12 +106,12 @@ export async function bindDirectoryToProject( ctx.signal, ); if (written.isErr()) { - throw projectDirectoryBindingErrorToCliError(written.error); + throw projectDirectoryBindingErrorToStructured(written.error); } const ignored = await ensureLocalResolutionPinGitignore(ctx.cwd, ctx.signal); if (ignored.isErr()) { - throw projectDirectoryBindingErrorToCliError(ignored.error); + throw projectDirectoryBindingErrorToStructured(ignored.error); } return { diff --git a/packages/cli/src/commands/project/create.ts b/packages/cli/src/commands/project/create.ts index 9d2e1b4c..9fd0eee4 100644 --- a/packages/cli/src/commands/project/create.ts +++ b/packages/cli/src/commands/project/create.ts @@ -1,6 +1,6 @@ /** The `project create` command. */ import { defineCommand, flag, positional } from "@prisma/cli-engine"; -import { notOk, ok } from "@prisma/cli-engine/protocol"; +import { ok } from "@prisma/cli-engine/protocol"; import { createAppProvider } from "../../lib/app/app-provider"; import { isValidProjectSetupName, @@ -9,7 +9,6 @@ import { } from "../../lib/project/setup"; import { resolveActiveWorkspace } from "../resources-shared/workspace"; import { bindDirectoryToProject } from "./context"; -import { mapProjectOperationError } from "./errors"; import { setupPresentations } from "./presentation"; export const projectCreateCommand = defineCommand({ @@ -33,59 +32,51 @@ export const projectCreateCommand = defineCommand({ }, needs: { credentials: true }, handler: async (args, ctx) => { - try { - const workspace = await resolveActiveWorkspace(ctx); - if (!isValidProjectSetupName(args.positionals.name)) { - throw projectSetupNameRequiredError("project create"); - } + const workspace = await resolveActiveWorkspace(ctx); + if (!isValidProjectSetupName(args.positionals.name)) { + throw projectSetupNameRequiredError("project create"); + } - const name = args.positionals.name.trim(); - const created = await createAppProvider(ctx.api) - .createProject({ - name, - region: args.flags.region, - signal: ctx.signal, - }) - .catch((error: unknown) => { - /** A cancelled run is cancelled, not a failed creation. The - * provider flattens the underlying AbortError into a plain - * Error, which the engine would settle as a bug, so hand it - * back its own abort reason and let it settle the run as - * cancelled. */ - if (ctx.signal.aborted) { - throw ctx.signal.reason; - } - throw projectCreateFailedError(error, name, workspace, { - nextSteps: [ - "prisma project list", - "prisma project link ", - ], - permissionFix: - "Grant the token permission to create Projects in this workspace, or link an existing Project.", - fallbackFix: - "Retry the command, or choose an existing Project with prisma project link .", - }); + const name = args.positionals.name.trim(); + const created = await createAppProvider(ctx.api) + .createProject({ + name, + region: args.flags.region, + signal: ctx.signal, + }) + .catch((error: unknown) => { + /** A cancelled run is cancelled, not a failed creation. The + * provider flattens the underlying AbortError into a plain + * Error, which the engine would settle as a bug, so hand it + * back its own abort reason and let it settle the run as + * cancelled. */ + if (ctx.signal.aborted) { + throw ctx.signal.reason; + } + throw projectCreateFailedError(error, name, workspace, { + nextSteps: [ + "prisma project list", + "prisma project link ", + ], + permissionFix: + "Grant the token permission to create Projects in this workspace, or link an existing Project.", + fallbackFix: + "Retry the command, or choose an existing Project with prisma project link .", }); + }); - const result = await bindDirectoryToProject( - ctx, - workspace, - { - id: created.id, - name: created.name, - ...(created.defaultRegion != null - ? { defaultRegion: created.defaultRegion } - : {}), - }, - "created", - ); - return ok(ctx.present({ data: result }, setupPresentations(result))); - } catch (error) { - const mapped = mapProjectOperationError(error); - if (mapped) { - return notOk(mapped); - } - throw error; - } + const result = await bindDirectoryToProject( + ctx, + workspace, + { + id: created.id, + name: created.name, + ...(created.defaultRegion != null + ? { defaultRegion: created.defaultRegion } + : {}), + }, + "created", + ); + return ok(ctx.present({ data: result }, setupPresentations(result))); }, }); diff --git a/packages/cli/src/commands/project/delete.ts b/packages/cli/src/commands/project/delete.ts index c59167af..bc8c5bba 100644 --- a/packages/cli/src/commands/project/delete.ts +++ b/packages/cli/src/commands/project/delete.ts @@ -6,7 +6,7 @@ import { positional, } from "@prisma/cli-engine"; import type { Diagnostic } from "@prisma/cli-engine/protocol"; -import { notOk, ok } from "@prisma/cli-engine/protocol"; +import { ok } from "@prisma/cli-engine/protocol"; import { cleanupLocalPinForProject } from "../../controllers/project"; import { createManagementProjectProvider } from "../../lib/project/provider"; import { @@ -16,7 +16,6 @@ import { import type { ProjectDeleteResult } from "../../types/project"; import { resolveActiveWorkspace } from "../resources-shared/workspace"; import { legacyOperationContext, listWorkspaceProjects } from "./context"; -import { mapProjectOperationError } from "./errors"; import { localPinDiagnostics } from "./presentation"; const CONSENT_QUESTION = @@ -65,46 +64,38 @@ export const projectDeleteCommand = defineCommand({ }, needs: { credentials: true }, handler: async (args, ctx) => { - try { - const workspace = await resolveActiveWorkspace(ctx); - const projects = await listWorkspaceProjects(ctx); - const project = toProjectSummary( - resolveProjectForSetup( - args.positionals.project.trim(), - projects, - workspace, - ), - ); + const workspace = await resolveActiveWorkspace(ctx); + const projects = await listWorkspaceProjects(ctx); + const project = toProjectSummary( + resolveProjectForSetup( + args.positionals.project.trim(), + projects, + workspace, + ), + ); - await ctx.prompt.consent(CONSENT_QUESTION, { token: project.id }); + await ctx.prompt.consent(CONSENT_QUESTION, { token: project.id }); - await createManagementProjectProvider(ctx.api).removeProject({ - projectId: project.id, - signal: ctx.signal, - }); + await createManagementProjectProvider(ctx.api).removeProject({ + projectId: project.id, + signal: ctx.signal, + }); - const warnings: string[] = []; - const cleared = await cleanupLocalPinForProject( - legacyOperationContext(ctx), - project.id, - { onError: (message) => warnings.push(message) }, - ); + const warnings: string[] = []; + const cleared = await cleanupLocalPinForProject( + legacyOperationContext(ctx), + project.id, + { onError: (message) => warnings.push(message) }, + ); - const result: ProjectDeleteResult = { - workspace, - project, - localPin: { cleared }, - }; - const diagnostics: Diagnostic[] = localPinDiagnostics(warnings); - return ok( - ctx.present({ data: result, diagnostics }, deletePresentations(result)), - ); - } catch (error) { - const mapped = mapProjectOperationError(error); - if (mapped) { - return notOk(mapped); - } - throw error; - } + const result: ProjectDeleteResult = { + workspace, + project, + localPin: { cleared }, + }; + const diagnostics: Diagnostic[] = localPinDiagnostics(warnings); + return ok( + ctx.present({ data: result, diagnostics }, deletePresentations(result)), + ); }, }); diff --git a/packages/cli/src/commands/project/env-add.ts b/packages/cli/src/commands/project/env-add.ts index 5482854f..7f65187e 100644 --- a/packages/cli/src/commands/project/env-add.ts +++ b/packages/cli/src/commands/project/env-add.ts @@ -5,7 +5,7 @@ import { type Presentations, positional, } from "@prisma/cli-engine"; -import { notOk, ok } from "@prisma/cli-engine/protocol"; +import { CliStructuredError, ok } from "@prisma/cli-engine/protocol"; import { formatScopeFlag, resolveEnvWriteInput, @@ -18,8 +18,8 @@ import { toMetadata, } from "../../controllers/app-env-api"; import { runEnvAddFile } from "../../controllers/app-env-file"; -import { CliError } from "../../errors"; import { formatScopeLabel } from "../../lib/app/env-config"; +import { runCommand, userChoice } from "../../lib/app/env-errors"; import type { EnvAddResult } from "../../types/app-env"; import { legacyOperationContext } from "./context"; import { @@ -33,7 +33,6 @@ import { roleFlag, variableFieldRows, } from "./env-shared"; -import { mapProjectOperationError } from "./errors"; const TITLE = "Setting a new environment variable."; @@ -86,144 +85,140 @@ export const projectEnvAddCommand = defineCommand({ }, needs: { credentials: true }, handler: async (args, ctx) => { - try { - const source = resolveEnvWriteSource( - args.positionals.assignment, - args.flags.file, - "add", - ); - const scope = requireEnvScope(args.flags, "add"); - const input = await resolveEnvWriteInput( - legacyOperationContext(ctx), - source, - "add", - ); - const { projectId, verboseContext, resolved } = await resolveEnvTarget( - ctx, - args.flags, - scope, - "project env add", - true, - ); - - if (input.kind === "file") { - const written = await runEnvAddFile( - legacyOperationContext(ctx), - ctx.api, - projectId, - resolved, - input.filePath, - input.assignments, - verboseContext, - ); - const result: EnvAddResult = { - projectId, - scope: resolved.descriptor, - // biome-ignore lint/style/noNonNullAssertion: the file branch always carries the variables. - variables: written.result.variables!, - // biome-ignore lint/style/noNonNullAssertion: the file branch always carries the file metadata. - file: written.result.file!, - }; - return ok( - ctx.present( - { - data: result, - diagnostics: previewDefaultDiagnostics(written.warnings), - }, - fileWritePresentations( - { - title: "Setting new environment variables from file.", - emptyMessage: "No environment variables imported.", - scope: result.scope, - filePath: result.file.path, - variables: result.variables, - }, - result, - ), - ), - ); - } + const source = resolveEnvWriteSource( + args.positionals.assignment, + args.flags.file, + "add", + ); + const scope = requireEnvScope(args.flags, "add"); + const input = await resolveEnvWriteInput( + legacyOperationContext(ctx), + source, + "add", + ); + const { projectId, verboseContext, resolved } = await resolveEnvTarget( + ctx, + args.flags, + scope, + "project env add", + true, + ); - const existing = await findVariableByNaturalKey( + if (input.kind === "file") { + const written = await runEnvAddFile( + legacyOperationContext(ctx), ctx.api, projectId, - input.key, resolved, - ctx.signal, + input.filePath, + input.assignments, + verboseContext, ); - if (existing) { - throw new CliError({ - code: "ENV_VARIABLE_ALREADY_EXISTS", - domain: "app", - summary: `Variable "${input.key}" already exists in ${formatScopeLabel(scope)}`, - why: "A variable with this key already exists in the targeted scope.", - fix: "Use `prisma project env update` to change an existing variable's value.", - exitCode: 1, - nextSteps: [ - `prisma project env update ${input.key}= ${formatScopeFlag(scope)}`, - ], - }); - } - - const warnings = - scope.kind === "branch" && - !(await findVariableByNaturalKey( - ctx.api, - projectId, - input.key, - { - descriptor: { kind: "role", role: "preview" }, - apiTarget: { class: "preview", branchId: null }, - }, - ctx.signal, - )) - ? [ - `Variable "${input.key}" does not exist in preview. It will only exist on ${formatScopeLabel(scope)}.`, - ] - : []; - - const { data, error, response } = await ctx.api.POST( - "/v1/environment-variables", - { - body: { - projectId, - class: resolved.apiTarget.class, - ...(resolved.apiTarget.branchId !== null - ? { branchId: resolved.apiTarget.branchId } - : {}), - key: input.key, - value: input.value, - }, - signal: ctx.signal, - }, - ); - if (error || !data) { - throw apiCallError(`Failed to add ${input.key}`, response, error); - } - const result: EnvAddResult = { projectId, scope: resolved.descriptor, - variable: toMetadata( - data.data as RawEnvironmentVariable, - resolved.descriptor, - ), + // biome-ignore lint/style/noNonNullAssertion: the file branch always carries the variables. + variables: written.result.variables!, + // biome-ignore lint/style/noNonNullAssertion: the file branch always carries the file metadata. + file: written.result.file!, }; return ok( ctx.present( { data: result, - diagnostics: previewDefaultDiagnostics(warnings), + diagnostics: previewDefaultDiagnostics(written.warnings), }, - singlePresentations(result), + fileWritePresentations( + { + title: "Setting new environment variables from file.", + emptyMessage: "No environment variables imported.", + scope: result.scope, + filePath: result.file.path, + variables: result.variables, + }, + result, + ), ), ); - } catch (error) { - const mapped = mapProjectOperationError(error); - if (mapped) { - return notOk(mapped); - } - throw error; } + + const existing = await findVariableByNaturalKey( + ctx.api, + projectId, + input.key, + resolved, + ctx.signal, + ); + if (existing) { + throw new CliStructuredError( + "PROJECT.ENV_VARIABLE_ALREADY_EXISTS", + `Variable "${input.key}" already exists in ${formatScopeLabel(scope)}`, + { + why: "A variable with this key already exists in the targeted scope.", + nextActions: [ + userChoice( + "Use `prisma project env update` to change an existing variable's value.", + ), + runCommand( + `prisma project env update ${input.key}= ${formatScopeFlag(scope)}`, + ), + ], + }, + ); + } + + const warnings = + scope.kind === "branch" && + !(await findVariableByNaturalKey( + ctx.api, + projectId, + input.key, + { + descriptor: { kind: "role", role: "preview" }, + apiTarget: { class: "preview", branchId: null }, + }, + ctx.signal, + )) + ? [ + `Variable "${input.key}" does not exist in preview. It will only exist on ${formatScopeLabel(scope)}.`, + ] + : []; + + const { data, error, response } = await ctx.api.POST( + "/v1/environment-variables", + { + body: { + projectId, + class: resolved.apiTarget.class, + ...(resolved.apiTarget.branchId !== null + ? { branchId: resolved.apiTarget.branchId } + : {}), + key: input.key, + value: input.value, + }, + signal: ctx.signal, + }, + ); + if (error || !data) { + throw apiCallError(`Failed to add ${input.key}`, response, error); + } + + const result: EnvAddResult = { + projectId, + scope: resolved.descriptor, + variable: toMetadata( + data.data as RawEnvironmentVariable, + resolved.descriptor, + ), + }; + return ok( + ctx.present( + { + data: result, + diagnostics: previewDefaultDiagnostics(warnings), + }, + singlePresentations(result), + ), + ); }, }); diff --git a/packages/cli/src/commands/project/env-delete.ts b/packages/cli/src/commands/project/env-delete.ts index 945ed8d9..2a0b920c 100644 --- a/packages/cli/src/commands/project/env-delete.ts +++ b/packages/cli/src/commands/project/env-delete.ts @@ -5,14 +5,14 @@ import { type Presentations, positional, } from "@prisma/cli-engine"; -import { notOk, ok } from "@prisma/cli-engine/protocol"; +import { CliStructuredError, ok } from "@prisma/cli-engine/protocol"; import { formatScopeFlag } from "../../controllers/app-env"; import { apiCallError, findVariableByNaturalKey, } from "../../controllers/app-env-api"; -import { CliError } from "../../errors"; import { formatScopeLabel } from "../../lib/app/env-config"; +import { runCommand, userChoice } from "../../lib/app/env-errors"; import { scopeLabel } from "../../presenters/app-env"; import type { EnvRmResult } from "../../types/app-env"; import { @@ -22,7 +22,6 @@ import { resolveEnvTarget, roleFlag, } from "./env-shared"; -import { mapProjectOperationError } from "./errors"; const TITLE = "Deleting the environment variable from the scope."; @@ -69,59 +68,55 @@ export const projectEnvDeleteCommand = defineCommand({ }, needs: { credentials: true }, handler: async (args, ctx) => { - try { - const key = args.positionals.key; - const scope = requireEnvScope(args.flags, "delete"); - const { projectId, resolved } = await resolveEnvTarget( - ctx, - args.flags, - scope, - "project env delete", - false, - ); + const key = args.positionals.key; + const scope = requireEnvScope(args.flags, "delete"); + const { projectId, resolved } = await resolveEnvTarget( + ctx, + args.flags, + scope, + "project env delete", + false, + ); - const existing = await findVariableByNaturalKey( - ctx.api, - projectId, - key, - resolved, - ctx.signal, - ); - if (!existing) { - throw new CliError({ - code: "ENV_VARIABLE_NOT_FOUND", - domain: "app", - summary: `Variable "${key}" not found in ${formatScopeLabel(scope)}`, - why: "No variable with this key exists in the targeted scope, so there is nothing to delete.", - fix: "Run prisma project env list with the same scope to see the available variables.", - exitCode: 1, - nextSteps: [`prisma project env list ${formatScopeFlag(scope)}`], - }); - } - - const { error, response } = await ctx.api.DELETE( - "/v1/environment-variables/{envVarId}", + const existing = await findVariableByNaturalKey( + ctx.api, + projectId, + key, + resolved, + ctx.signal, + ); + if (!existing) { + throw new CliStructuredError( + "PROJECT.ENV_VARIABLE_NOT_FOUND", + `Variable "${key}" not found in ${formatScopeLabel(scope)}`, { - params: { path: { envVarId: existing.id } }, - signal: ctx.signal, + why: "No variable with this key exists in the targeted scope, so there is nothing to delete.", + nextActions: [ + userChoice( + "Run prisma project env list with the same scope to see the available variables.", + ), + runCommand(`prisma project env list ${formatScopeFlag(scope)}`), + ], }, ); - if (error) { - throw apiCallError(`Failed to delete ${key}`, response, error); - } + } - const result: EnvRmResult = { - projectId, - scope: resolved.descriptor, - key, - }; - return ok(ctx.present({ data: result }, deletePresentations(result))); - } catch (error) { - const mapped = mapProjectOperationError(error); - if (mapped) { - return notOk(mapped); - } - throw error; + const { error, response } = await ctx.api.DELETE( + "/v1/environment-variables/{envVarId}", + { + params: { path: { envVarId: existing.id } }, + signal: ctx.signal, + }, + ); + if (error) { + throw apiCallError(`Failed to delete ${key}`, response, error); } + + const result: EnvRmResult = { + projectId, + scope: resolved.descriptor, + key, + }; + return ok(ctx.present({ data: result }, deletePresentations(result))); }, }); diff --git a/packages/cli/src/commands/project/env-list.ts b/packages/cli/src/commands/project/env-list.ts index 5b2efd75..67b5d904 100644 --- a/packages/cli/src/commands/project/env-list.ts +++ b/packages/cli/src/commands/project/env-list.ts @@ -5,7 +5,7 @@ import { flag, type Presentations, } from "@prisma/cli-engine"; -import { notOk, ok } from "@prisma/cli-engine/protocol"; +import { ok } from "@prisma/cli-engine/protocol"; import { CLI_NAME } from "../../cli-name"; import { formatScopeFlag, @@ -25,7 +25,6 @@ import { variableRows, variableStdoutRows, } from "./env-shared"; -import { mapProjectOperationError } from "./errors"; const TITLE = "Listing environment variables for the selected scope."; @@ -95,58 +94,50 @@ export const projectEnvListCommand = defineCommand({ }, needs: { credentials: true }, handler: async (args, ctx) => { - try { - const explicit = resolveEnvScope( - { roleName: args.flags.role, branchName: args.flags.branch }, - { requireExplicit: false, command: "list" }, - ); - const workspace = await resolveActiveWorkspace(ctx); - const target = await resolvePinnedProject( - ctx, - workspace, - args.flags.project, - "project env list", - ); - const projectId = target.project.id; - const resolved = await resolveListScopeToApi( - ctx.api, - projectId, - explicit ?? undefined, - { signal: ctx.signal }, - ); + const explicit = resolveEnvScope( + { roleName: args.flags.role, branchName: args.flags.branch }, + { requireExplicit: false, command: "list" }, + ); + const workspace = await resolveActiveWorkspace(ctx); + const target = await resolvePinnedProject( + ctx, + workspace, + args.flags.project, + "project env list", + ); + const projectId = target.project.id; + const resolved = await resolveListScopeToApi( + ctx.api, + projectId, + explicit ?? undefined, + { signal: ctx.signal }, + ); - const rows = - resolved.kind === "scoped" - ? await listVariables( - ctx.api, - projectId, - { - scope: resolved.addScope, - descriptor: resolved.descriptor, - apiTarget: resolved.apiTarget, - }, - ctx.signal, - ) - : await listOverviewVariables(ctx.api, projectId, ctx.signal); + const rows = + resolved.kind === "scoped" + ? await listVariables( + ctx.api, + projectId, + { + scope: resolved.addScope, + descriptor: resolved.descriptor, + apiTarget: resolved.apiTarget, + }, + ctx.signal, + ) + : await listOverviewVariables(ctx.api, projectId, ctx.signal); - const result: EnvListResult = { - projectId, - scope: resolved.descriptor, - target: resolved.target, - variables: rows.map((row) => toMetadata(row, resolved.descriptor)), - }; - return ok( - ctx.present( - { data: result }, - listPresentations(result, formatScopeFlag(resolved.addScope)), - ), - ); - } catch (error) { - const mapped = mapProjectOperationError(error); - if (mapped) { - return notOk(mapped); - } - throw error; - } + const result: EnvListResult = { + projectId, + scope: resolved.descriptor, + target: resolved.target, + variables: rows.map((row) => toMetadata(row, resolved.descriptor)), + }; + return ok( + ctx.present( + { data: result }, + listPresentations(result, formatScopeFlag(resolved.addScope)), + ), + ); }, }); diff --git a/packages/cli/src/commands/project/env-shared.ts b/packages/cli/src/commands/project/env-shared.ts index 4eef26ca..dfeff07e 100644 --- a/packages/cli/src/commands/project/env-shared.ts +++ b/packages/cli/src/commands/project/env-shared.ts @@ -4,8 +4,8 @@ import { type Block, flag, type Presentations } from "@prisma/cli-engine"; import type { Diagnostic } from "@prisma/cli-engine/protocol"; import { resolveScopeToApi } from "../../controllers/app-env"; import type { ResolvedEnvFileScope } from "../../controllers/app-env-file"; -import { usageError } from "../../errors"; import { type EnvScope, resolveEnvScope } from "../../lib/app/env-config"; +import { envUsageError } from "../../lib/app/env-errors"; import { scopeLabel } from "../../presenters/app-env"; import type { EnvResolvedContext, @@ -50,12 +50,11 @@ export function requireEnvScope( { requireExplicit: true, command }, ); if (!scope) { - throw usageError( + throw envUsageError( `prisma project env ${command} requires --role or --branch`, "Writing without an explicit scope is rejected.", "Pass --role production, --role preview, or --branch .", [`prisma project env ${command} KEY=value --role production`], - "app", ); } return scope; diff --git a/packages/cli/src/commands/project/env-update.ts b/packages/cli/src/commands/project/env-update.ts index c65b5a50..803936cf 100644 --- a/packages/cli/src/commands/project/env-update.ts +++ b/packages/cli/src/commands/project/env-update.ts @@ -5,7 +5,7 @@ import { type Presentations, positional, } from "@prisma/cli-engine"; -import { notOk, ok } from "@prisma/cli-engine/protocol"; +import { CliStructuredError, ok } from "@prisma/cli-engine/protocol"; import { formatScopeFlag, resolveEnvWriteInput, @@ -18,8 +18,8 @@ import { toMetadata, } from "../../controllers/app-env-api"; import { runEnvUpdateFile } from "../../controllers/app-env-file"; -import { CliError } from "../../errors"; import { formatScopeLabel } from "../../lib/app/env-config"; +import { runCommand, userChoice } from "../../lib/app/env-errors"; import type { EnvUpdateResult } from "../../types/app-env"; import { legacyOperationContext } from "./context"; import { @@ -32,7 +32,6 @@ import { roleFlag, variableFieldRows, } from "./env-shared"; -import { mapProjectOperationError } from "./errors"; const TITLE = "Replacing the environment variable's value."; @@ -83,113 +82,109 @@ export const projectEnvUpdateCommand = defineCommand({ }, needs: { credentials: true }, handler: async (args, ctx) => { - try { - const source = resolveEnvWriteSource( - args.positionals.assignment, - args.flags.file, - "update", - ); - const scope = requireEnvScope(args.flags, "update"); - const input = await resolveEnvWriteInput( - legacyOperationContext(ctx), - source, - "update", - ); - const { projectId, verboseContext, resolved } = await resolveEnvTarget( - ctx, - args.flags, - scope, - "project env update", - false, - ); + const source = resolveEnvWriteSource( + args.positionals.assignment, + args.flags.file, + "update", + ); + const scope = requireEnvScope(args.flags, "update"); + const input = await resolveEnvWriteInput( + legacyOperationContext(ctx), + source, + "update", + ); + const { projectId, verboseContext, resolved } = await resolveEnvTarget( + ctx, + args.flags, + scope, + "project env update", + false, + ); - if (input.kind === "file") { - const written = await runEnvUpdateFile( - legacyOperationContext(ctx), - ctx.api, - projectId, - resolved, - input.filePath, - input.assignments, - verboseContext, - ); - const result: EnvUpdateResult = { - projectId, - scope: resolved.descriptor, - // biome-ignore lint/style/noNonNullAssertion: the file branch always carries the variables. - variables: written.result.variables!, - // biome-ignore lint/style/noNonNullAssertion: the file branch always carries the file metadata. - file: written.result.file!, - }; - return ok( - ctx.present( - { data: result }, - fileWritePresentations( - { - title: "Replacing environment variable values from file.", - emptyMessage: "No environment variables updated.", - scope: result.scope, - filePath: result.file.path, - variables: result.variables, - }, - result, - ), - ), - ); - } - - const existing = await findVariableByNaturalKey( + if (input.kind === "file") { + const written = await runEnvUpdateFile( + legacyOperationContext(ctx), ctx.api, projectId, - input.key, resolved, - ctx.signal, + input.filePath, + input.assignments, + verboseContext, ); - if (!existing) { - throw new CliError({ - code: "ENV_VARIABLE_NOT_FOUND", - domain: "app", - summary: `Variable "${input.key}" not found in ${formatScopeLabel(scope)}`, - why: "No variable with this key exists in the targeted scope.", - fix: "Use `prisma project env add` to create a new variable.", - exitCode: 1, - nextSteps: [ - `prisma project env add ${input.key}= ${formatScopeFlag(scope)}`, - ], - }); - } + const result: EnvUpdateResult = { + projectId, + scope: resolved.descriptor, + // biome-ignore lint/style/noNonNullAssertion: the file branch always carries the variables. + variables: written.result.variables!, + // biome-ignore lint/style/noNonNullAssertion: the file branch always carries the file metadata. + file: written.result.file!, + }; + return ok( + ctx.present( + { data: result }, + fileWritePresentations( + { + title: "Replacing environment variable values from file.", + emptyMessage: "No environment variables updated.", + scope: result.scope, + filePath: result.file.path, + variables: result.variables, + }, + result, + ), + ), + ); + } - const { data, error, response } = await ctx.api.PATCH( - "/v1/environment-variables/{envVarId}", + const existing = await findVariableByNaturalKey( + ctx.api, + projectId, + input.key, + resolved, + ctx.signal, + ); + if (!existing) { + throw new CliStructuredError( + "PROJECT.ENV_VARIABLE_NOT_FOUND", + `Variable "${input.key}" not found in ${formatScopeLabel(scope)}`, { - params: { path: { envVarId: existing.id } }, - body: { value: input.value }, - signal: ctx.signal, + why: "No variable with this key exists in the targeted scope.", + nextActions: [ + userChoice( + "Use `prisma project env add` to create a new variable.", + ), + runCommand( + `prisma project env add ${input.key}= ${formatScopeFlag(scope)}`, + ), + ], }, ); - if (error || !data) { - throw apiCallError( - `Failed to update value for ${input.key}`, - response, - error, - ); - } + } - const result: EnvUpdateResult = { - projectId, - scope: resolved.descriptor, - variable: toMetadata( - data.data as RawEnvironmentVariable, - resolved.descriptor, - ), - }; - return ok(ctx.present({ data: result }, singlePresentations(result))); - } catch (error) { - const mapped = mapProjectOperationError(error); - if (mapped) { - return notOk(mapped); - } - throw error; + const { data, error, response } = await ctx.api.PATCH( + "/v1/environment-variables/{envVarId}", + { + params: { path: { envVarId: existing.id } }, + body: { value: input.value }, + signal: ctx.signal, + }, + ); + if (error || !data) { + throw apiCallError( + `Failed to update value for ${input.key}`, + response, + error, + ); } + + const result: EnvUpdateResult = { + projectId, + scope: resolved.descriptor, + variable: toMetadata( + data.data as RawEnvironmentVariable, + resolved.descriptor, + ), + }; + return ok(ctx.present({ data: result }, singlePresentations(result))); }, }); diff --git a/packages/cli/src/commands/project/errors.ts b/packages/cli/src/commands/project/errors.ts deleted file mode 100644 index 24eaf783..00000000 --- a/packages/cli/src/commands/project/errors.ts +++ /dev/null @@ -1,122 +0,0 @@ -/** - * Mapping from the project controllers' legacy CliError shapes to the - * engine protocol's dotted PROJECT.* structured errors, following - * `commands/auth/errors.ts`. Unmapped codes fall through to - * `PROJECT.` (the API-passthrough rule), including the - * legacy `AUTH_REQUIRED`: the engine settles every real credentials - * failure itself, so what reaches this mapper is the permission - * residue (a returned 403), which is not a sign-in problem. - */ - -import type { NextAction } from "@prisma/cli-engine/protocol"; -import { CliStructuredError } from "@prisma/cli-engine/protocol"; -import { CLI_NAME } from "../../cli-name"; -import { CliError } from "../../errors"; - -const PROJECT_CODE_MAP: Readonly> = { - USAGE_ERROR: "PROJECT.USAGE_ERROR", - PROJECT_NOT_FOUND: "PROJECT.NOT_FOUND", - PROJECT_AMBIGUOUS: "PROJECT.AMBIGUOUS", - PROJECT_SETUP_REQUIRED: "PROJECT.SETUP_REQUIRED", - LOCAL_STATE_STALE: "PROJECT.LOCAL_STATE_STALE", - LOCAL_PROJECT_WORKSPACE_MISMATCH: "PROJECT.LOCAL_WORKSPACE_MISMATCH", - LOCAL_STATE_WRITE_FAILED: "PROJECT.LOCAL_STATE_WRITE_FAILED", - PROJECT_CREATE_FAILED: "PROJECT.CREATE_FAILED", - PROJECT_RENAME_FAILED: "PROJECT.RENAME_FAILED", - PROJECT_DELETE_BLOCKED: "PROJECT.DELETE_BLOCKED", - PROJECT_TRANSFER_REJECTED: "PROJECT.TRANSFER_REJECTED", - TRANSFER_RECIPIENT_REQUIRED: "PROJECT.TRANSFER_RECIPIENT_REQUIRED", - TRANSFER_RECIPIENT_UNAVAILABLE: "PROJECT.TRANSFER_RECIPIENT_UNAVAILABLE", - CONFIRMATION_REQUIRED: "PROJECT.CONFIRMATION_REQUIRED", - PROJECT_LINK_TARGET_REQUIRED: "PROJECT.LINK_TARGET_REQUIRED", - ENV_VARIABLE_ALREADY_EXISTS: "PROJECT.ENV_VARIABLE_ALREADY_EXISTS", - ENV_VARIABLE_NOT_FOUND: "PROJECT.ENV_VARIABLE_NOT_FOUND", - ENV_BRANCH_NOT_FOUND: "PROJECT.ENV_BRANCH_NOT_FOUND", - ENV_BRANCH_SCOPE_IS_PRODUCTION: "PROJECT.ENV_BRANCH_SCOPE_IS_PRODUCTION", - ENV_BRANCH_CREATE_REQUIRES_DEFAULT_BRANCH: - "PROJECT.ENV_BRANCH_CREATE_REQUIRES_DEFAULT_BRANCH", - ENV_FILE_APPLY_FAILED: "PROJECT.ENV_FILE_APPLY_FAILED", - ENV_API_ERROR: "PROJECT.ENV_API_ERROR", - PROJECT_API_ERROR: "PROJECT.API_ERROR", -}; - -const PACKAGE_RUNNER_PREFIX = /^\S+(?: -y)? @prisma\/cli@\S+ /; -const COMMENT_PREFIX = /^#\s*/; - -/** Ported command strings already name this binary; what still needs - * porting is the package-runner spelling the legacy formatter emitted - * (`npx -y @prisma/cli@next auth login`), which becomes a plain - * invocation. Anything else is passed through untouched. */ -export function portCommandString(command: string): string { - if (command.startsWith(`${CLI_NAME} `)) { - return command; - } - return command.replace(PACKAGE_RUNNER_PREFIX, `${CLI_NAME} `); -} - -const STALE_INTERACTIVE_SIGN_IN = - /, or rerun the command in a TTY to sign in interactively\./g; -const TRACE_FLAG = /--trace/g; - -/** `--trace` is gone in this CLI; the log level replaces it. Interactive - * sign-in is gone too (R-S2b-2), so the legacy offer to rerun in a TTY - * describes something this CLI cannot do; `auth login` is the whole remedy. */ -function portFixText(fix: string): string { - return fix - .replace(TRACE_FLAG, "--log-level verbose") - .replace(STALE_INTERACTIVE_SIGN_IN, "."); -} - -/** A `#`-comment line in the legacy nextSteps is not an action: it - * explains the command that follows it, so it becomes that action's - * `reason`. */ -function runCommandActions(nextSteps: readonly string[]): NextAction[] { - const actions: NextAction[] = []; - let reason: string | undefined; - for (const step of nextSteps) { - if (step.startsWith("#")) { - reason = step.replace(COMMENT_PREFIX, ""); - continue; - } - const command = portCommandString(step); - actions.push({ - kind: "run-command", - label: command, - command, - ...(reason === undefined ? {} : { reason }), - }); - reason = undefined; - } - return actions; -} - -function nextActionsFor(error: CliError): NextAction[] { - return [ - ...(error.fix - ? [{ kind: "user-choice" as const, label: portFixText(error.fix) }] - : []), - ...runCommandActions(error.nextSteps), - ]; -} - -export function mapProjectOperationError( - error: unknown, -): CliStructuredError | null { - if (!(error instanceof CliError)) { - return null; - } - const code = PROJECT_CODE_MAP[error.code] ?? `PROJECT.${error.code}`; - return new CliStructuredError(code as `${string}.${string}`, error.summary, { - why: error.why ?? undefined, - meta: Object.keys(error.meta).length > 0 ? error.meta : undefined, - nextActions: nextActionsFor(error), - }); -} - -export function rethrowMapped(error: unknown): never { - const mapped = mapProjectOperationError(error); - if (mapped) { - throw mapped; - } - throw error; -} diff --git a/packages/cli/src/commands/project/link.ts b/packages/cli/src/commands/project/link.ts index f377337e..f62bc6ba 100644 --- a/packages/cli/src/commands/project/link.ts +++ b/packages/cli/src/commands/project/link.ts @@ -1,8 +1,7 @@ /** The `project link` command. */ import { defineCommand, positional } from "@prisma/cli-engine"; -import { notOk, ok } from "@prisma/cli-engine/protocol"; +import { CliStructuredError, ok } from "@prisma/cli-engine/protocol"; import { formatCommandArgument } from "../../command-arguments"; -import { usageError } from "../../errors"; import { createAppProvider } from "../../lib/app/app-provider"; import { inferTargetName, @@ -24,19 +23,35 @@ import { listWorkspaceProjects, type ProjectCommandContext, } from "./context"; -import { mapProjectOperationError } from "./errors"; import { setupPresentations } from "./presentation"; const CREATE_CHOICE = "__create__"; const CANCEL_CHOICE = "__cancel__"; -function setupCanceledError() { - return usageError( +function setupCanceledError(): CliStructuredError { + return new CliStructuredError( + "PROJECT.USAGE_ERROR", "Project setup canceled", - "Project link needs a Project before it can continue.", - "Choose an existing Project or create a new one, then rerun project link.", - ["prisma project link ", "prisma project create "], - "project", + { + why: "Project link needs a Project before it can continue.", + nextActions: [ + { + kind: "user-choice", + label: + "Choose an existing Project or create a new one, then rerun project link.", + }, + { + kind: "run-command", + label: "prisma project link ", + command: "prisma project link ", + }, + { + kind: "run-command", + label: "prisma project create ", + command: "prisma project create ", + }, + ], + }, ); } @@ -174,19 +189,8 @@ export const projectLinkCommand = defineCommand({ }, needs: { credentials: true }, handler: async (args, ctx) => { - try { - const result = await linkDirectoryToProject( - ctx, - args.positionals.project, - ); + const result = await linkDirectoryToProject(ctx, args.positionals.project); - return ok(ctx.present({ data: result }, setupPresentations(result))); - } catch (error) { - const mapped = mapProjectOperationError(error); - if (mapped) { - return notOk(mapped); - } - throw error; - } + return ok(ctx.present({ data: result }, setupPresentations(result))); }, }); diff --git a/packages/cli/src/commands/project/list.ts b/packages/cli/src/commands/project/list.ts index c2ec001b..a66a9a2b 100644 --- a/packages/cli/src/commands/project/list.ts +++ b/packages/cli/src/commands/project/list.ts @@ -1,6 +1,6 @@ /** The `project list` command. */ import { defineCommand, type Presentations } from "@prisma/cli-engine"; -import { notOk, ok } from "@prisma/cli-engine/protocol"; +import { ok } from "@prisma/cli-engine/protocol"; import { CLI_NAME } from "../../cli-name"; import { readProjectListLocalBinding } from "../../controllers/project"; import { @@ -12,8 +12,6 @@ import { serializeProjectList } from "../../presenters/project"; import type { ProjectListResult } from "../../types/project"; import { resolveActiveWorkspace } from "../resources-shared/workspace"; import { listWorkspaceProjects } from "./context"; -import { mapProjectOperationError } from "./errors"; -import { toNextActions } from "./presentation"; const TITLE = "Listing projects for the authenticated workspace."; @@ -41,15 +39,13 @@ function nextActionsFor(result: ProjectListResult) { if (result.localBinding?.status === "linked") { return []; } - return toNextActions( - buildProjectSetupNextActions({ - createCommand: `${CLI_NAME} project create `, - reason: - result.localBinding?.status === "invalid" - ? "This directory has an invalid local Project binding. Ask the user which Prisma Project to link before running Project-scoped commands." - : "This directory is not linked to a Prisma Project. Project list shows available Projects, but none is selected for this directory.", - }), - ); + return buildProjectSetupNextActions({ + createCommand: `${CLI_NAME} project create `, + reason: + result.localBinding?.status === "invalid" + ? "This directory has an invalid local Project binding. Ask the user which Prisma Project to link before running Project-scoped commands." + : "This directory is not linked to a Prisma Project. Project list shows available Projects, but none is selected for this directory.", + }); } function listPresentations(result: ProjectListResult): Presentations { @@ -91,26 +87,18 @@ export const projectListCommand = defineCommand({ }, needs: { credentials: true }, handler: async (_args, ctx) => { - try { - const workspace = await resolveActiveWorkspace(ctx); - const projects = sortProjects(await listWorkspaceProjects(ctx)); - const localBinding = await readProjectListLocalBinding( - ctx.cwd, - projects, - ctx.signal, - ); - const result: ProjectListResult = { - workspace, - projects: projects.map(toProjectSummary), - localBinding, - }; - return ok(ctx.present({ data: result }, listPresentations(result))); - } catch (error) { - const mapped = mapProjectOperationError(error); - if (mapped) { - return notOk(mapped); - } - throw error; - } + const workspace = await resolveActiveWorkspace(ctx); + const projects = sortProjects(await listWorkspaceProjects(ctx)); + const localBinding = await readProjectListLocalBinding( + ctx.cwd, + projects, + ctx.signal, + ); + const result: ProjectListResult = { + workspace, + projects: projects.map(toProjectSummary), + localBinding, + }; + return ok(ctx.present({ data: result }, listPresentations(result))); }, }); diff --git a/packages/cli/src/commands/project/presentation.ts b/packages/cli/src/commands/project/presentation.ts index 7cf07626..c79b04a9 100644 --- a/packages/cli/src/commands/project/presentation.ts +++ b/packages/cli/src/commands/project/presentation.ts @@ -2,10 +2,8 @@ import type { Presentations } from "@prisma/cli-engine"; import type { Diagnostic, NextAction } from "@prisma/cli-engine/protocol"; import { CLI_NAME } from "../../cli-name"; -import type { NextAction as LegacyNextAction } from "../../next-actions"; import { serializeProjectSetup } from "../../presenters/project"; import type { ProjectSetupResult } from "../../types/project"; -import { portCommandString } from "./errors"; /** Deploys come from pushing a connected repository, so the step after * creating or linking a Project is connecting one. */ @@ -27,22 +25,6 @@ export function localPinDiagnostics(warnings: readonly string[]): Diagnostic[] { })); } -/** The legacy NextAction shape minus its `journey` field, which the engine - * protocol does not carry. */ -export function toNextActions( - actions: readonly LegacyNextAction[], -): NextAction[] { - return actions.map((action) => ({ - kind: action.kind, - label: action.label, - ...(action.command ? { command: portCommandString(action.command) } : {}), - ...(action.commands - ? { commands: action.commands.map(portCommandString) } - : {}), - ...(action.reason ? { reason: action.reason } : {}), - })); -} - export function setupPresentations(result: ProjectSetupResult): Presentations { return { stdout: () => [], diff --git a/packages/cli/src/commands/project/rename.ts b/packages/cli/src/commands/project/rename.ts index 31936821..2505a0a5 100644 --- a/packages/cli/src/commands/project/rename.ts +++ b/packages/cli/src/commands/project/rename.ts @@ -5,7 +5,7 @@ import { type Presentations, positional, } from "@prisma/cli-engine"; -import { notOk, ok } from "@prisma/cli-engine/protocol"; +import { ok } from "@prisma/cli-engine/protocol"; import { createManagementProjectProvider } from "../../lib/project/provider"; import { isValidProjectSetupName, @@ -14,7 +14,6 @@ import { import type { ProjectRenameResult } from "../../types/project"; import { resolveActiveWorkspace } from "../resources-shared/workspace"; import { resolvePinnedProject } from "./context"; -import { mapProjectOperationError } from "./errors"; function renamePresentations(result: ProjectRenameResult): Presentations { return { @@ -65,39 +64,31 @@ export const projectRenameCommand = defineCommand({ }, needs: { credentials: true }, handler: async (args, ctx) => { - try { - const workspace = await resolveActiveWorkspace(ctx); - const name = args.positionals.name.trim(); - if (!isValidProjectSetupName(name)) { - throw projectSetupNameRequiredError("project rename"); - } + const workspace = await resolveActiveWorkspace(ctx); + const name = args.positionals.name.trim(); + if (!isValidProjectSetupName(name)) { + throw projectSetupNameRequiredError("project rename"); + } - const target = await resolvePinnedProject( - ctx, - workspace, - args.flags.project, - "project rename", - ); - const renamed = await createManagementProjectProvider( - ctx.api, - ).renameProject({ - projectId: target.project.id, - name, - signal: ctx.signal, - }); + const target = await resolvePinnedProject( + ctx, + workspace, + args.flags.project, + "project rename", + ); + const renamed = await createManagementProjectProvider( + ctx.api, + ).renameProject({ + projectId: target.project.id, + name, + signal: ctx.signal, + }); - const result: ProjectRenameResult = { - workspace, - project: renamed, - previousName: target.project.name, - }; - return ok(ctx.present({ data: result }, renamePresentations(result))); - } catch (error) { - const mapped = mapProjectOperationError(error); - if (mapped) { - return notOk(mapped); - } - throw error; - } + const result: ProjectRenameResult = { + workspace, + project: renamed, + previousName: target.project.name, + }; + return ok(ctx.present({ data: result }, renamePresentations(result))); }, }); diff --git a/packages/cli/src/commands/project/show.ts b/packages/cli/src/commands/project/show.ts index fb3e0c91..08b02abf 100644 --- a/packages/cli/src/commands/project/show.ts +++ b/packages/cli/src/commands/project/show.ts @@ -4,18 +4,16 @@ import { type Presentations, positional, } from "@prisma/cli-engine"; -import { notOk, ok } from "@prisma/cli-engine/protocol"; +import { ok } from "@prisma/cli-engine/protocol"; import { shortenHomePath } from "../../lib/fs/home-path"; import { buildProjectSetupNextActions, inspectProjectBinding, - projectResolutionErrorToCliError, + projectResolutionErrorToStructured, } from "../../lib/project/resolution"; import type { ProjectShowResult } from "../../types/project"; import { resolveActiveWorkspace } from "../resources-shared/workspace"; import { legacyOperationContext, listWorkspaceProjects } from "./context"; -import { mapProjectOperationError } from "./errors"; -import { toNextActions } from "./presentation"; interface FieldRow { readonly label: string; @@ -98,15 +96,13 @@ function showPresentations( stdoutFieldRows(result, cwd).map((row) => `${row.label}: ${row.value}`), next: () => result.project === null - ? toNextActions( - buildProjectSetupNextActions({ - commandName: "project show", - retryCommand: "prisma project show ", - suggestedProjectName: result.suggestedProjectName, - reason: - "This directory is not linked to a Prisma Project. Package and directory names can suggest setup defaults, but they do not select a Project.", - }), - ) + ? buildProjectSetupNextActions({ + commandName: "project show", + retryCommand: "prisma project show ", + suggestedProjectName: result.suggestedProjectName, + reason: + "This directory is not linked to a Prisma Project. Package and directory names can suggest setup defaults, but they do not select a Project.", + }) : [], }; } @@ -126,31 +122,23 @@ export const projectShowCommand = defineCommand({ }, needs: { credentials: true }, handler: async (args, ctx) => { - try { - const workspace = await resolveActiveWorkspace(ctx); - const inspected = await inspectProjectBinding({ - context: legacyOperationContext(ctx), - workspace, - explicitProject: args.positionals.project, - listProjects: () => listWorkspaceProjects(ctx), - commandName: "project show", - }); - if (inspected.isErr()) { - throw projectResolutionErrorToCliError(inspected.error); - } - const result = inspected.value; - return ok( - ctx.present( - { data: result }, - showPresentations(result, ctx.cwd, ctx.env), - ), - ); - } catch (error) { - const mapped = mapProjectOperationError(error); - if (mapped) { - return notOk(mapped); - } - throw error; + const workspace = await resolveActiveWorkspace(ctx); + const inspected = await inspectProjectBinding({ + context: legacyOperationContext(ctx), + workspace, + explicitProject: args.positionals.project, + listProjects: () => listWorkspaceProjects(ctx), + commandName: "project show", + }); + if (inspected.isErr()) { + throw projectResolutionErrorToStructured(inspected.error); } + const result = inspected.value; + return ok( + ctx.present( + { data: result }, + showPresentations(result, ctx.cwd, ctx.env), + ), + ); }, }); diff --git a/packages/cli/src/commands/project/transfer.ts b/packages/cli/src/commands/project/transfer.ts index 68025859..a3954d13 100644 --- a/packages/cli/src/commands/project/transfer.ts +++ b/packages/cli/src/commands/project/transfer.ts @@ -8,7 +8,7 @@ import { SERVICE_TOKEN_ENV_VAR, } from "@prisma/cli-engine"; import type { Diagnostic } from "@prisma/cli-engine/protocol"; -import { notOk, ok } from "@prisma/cli-engine/protocol"; +import { CliStructuredError, ok } from "@prisma/cli-engine/protocol"; import { workspaceAmbiguousError, workspaceNotAuthenticatedError, @@ -25,7 +25,6 @@ import { transferRecipientRequiredError, transferRecipientUnavailableError, } from "../../controllers/project"; -import { usageError } from "../../errors"; import type { PrismaCliPackageCommandFormatter } from "../../lib/agent/cli-command"; import { createManagementProjectProvider } from "../../lib/project/provider"; import { @@ -39,7 +38,6 @@ import { listWorkspaceProjects, type ProjectCommandContext, } from "./context"; -import { mapProjectOperationError } from "./errors"; import { localPinDiagnostics } from "./presentation"; const CONSENT_QUESTION = @@ -198,88 +196,87 @@ export const projectTransferCommand = defineCommand({ }, needs: { credentials: true }, handler: async (args, ctx) => { - try { - const workspace = await resolveActiveWorkspace(ctx); - // Normalized once: an all-whitespace flag value must not read as - // supplied to one check and absent to the next. - const toWorkspace = args.flags.toWorkspace?.trim() || undefined; - const recipientToken = args.flags.recipientToken?.trim() || undefined; + const workspace = await resolveActiveWorkspace(ctx); + // Normalized once: an all-whitespace flag value must not read as + // supplied to one check and absent to the next. + const toWorkspace = args.flags.toWorkspace?.trim() || undefined; + const recipientToken = args.flags.recipientToken?.trim() || undefined; - if (toWorkspace && recipientToken) { - throw usageError( - "Choose one transfer recipient source", - "--to-workspace and --recipient-token are mutually exclusive.", - "Pass either --to-workspace or --recipient-token .", - [ - formatCommand([ - "project", - "transfer", - "", - "--to-workspace", - "", - "--confirm", - "", - ]), + if (toWorkspace && recipientToken) { + const retry = formatCommand([ + "project", + "transfer", + "", + "--to-workspace", + "", + "--confirm", + "", + ]); + throw new CliStructuredError( + "PROJECT.USAGE_ERROR", + "Choose one transfer recipient source", + { + why: "--to-workspace and --recipient-token are mutually exclusive.", + nextActions: [ + { + kind: "user-choice", + label: + "Pass either --to-workspace or --recipient-token .", + }, + { kind: "run-command", label: retry, command: retry }, ], - "project", - ); - } - if (!toWorkspace && !recipientToken) { - throw transferRecipientRequiredError(formatCommand); - } - - const projects = await listWorkspaceProjects(ctx); - const project = toProjectSummary( - resolveProjectForSetup( - args.positionals.project.trim(), - projects, - workspace, - ), + }, ); + } + if (!toWorkspace && !recipientToken) { + throw transferRecipientRequiredError(formatCommand); + } - await ctx.prompt.consent(CONSENT_QUESTION, { token: project.id }); + const projects = await listWorkspaceProjects(ctx); + const project = toProjectSummary( + resolveProjectForSetup( + args.positionals.project.trim(), + projects, + workspace, + ), + ); - const recipient = await resolveRecipient(ctx, { - toWorkspace, - recipientToken, - }); - await createManagementProjectProvider(ctx.api).transferProject({ - projectId: project.id, - recipientAccessToken: recipient.accessToken, - signal: ctx.signal, - }); + await ctx.prompt.consent(CONSENT_QUESTION, { token: project.id }); - const warnings: string[] = []; - const action = await rewriteOrClearLocalPinForProject( - legacyOperationContext(ctx), - project.id, - recipient.workspaceId, - { onError: (message) => warnings.push(message) }, - ); + const recipient = await resolveRecipient(ctx, { + toWorkspace, + recipientToken, + }); + await createManagementProjectProvider(ctx.api).transferProject({ + projectId: project.id, + recipientAccessToken: recipient.accessToken, + signal: ctx.signal, + }); - const result: ProjectTransferResult = { - workspace, - project, - recipient: { - workspaceId: recipient.workspaceId, - workspaceName: recipient.workspaceName, - source: recipient.source, - }, - localPin: { action }, - }; - const diagnostics: Diagnostic[] = localPinDiagnostics(warnings); - return ok( - ctx.present( - { data: result, diagnostics }, - transferPresentations(result, toWorkspace), - ), - ); - } catch (error) { - const mapped = mapProjectOperationError(error); - if (mapped) { - return notOk(mapped); - } - throw error; - } + const warnings: string[] = []; + const action = await rewriteOrClearLocalPinForProject( + legacyOperationContext(ctx), + project.id, + recipient.workspaceId, + { onError: (message) => warnings.push(message) }, + ); + + const result: ProjectTransferResult = { + workspace, + project, + recipient: { + workspaceId: recipient.workspaceId, + workspaceName: recipient.workspaceName, + source: recipient.source, + }, + localPin: { action }, + }; + const diagnostics: Diagnostic[] = localPinDiagnostics(warnings); + return ok( + ctx.present( + { data: result, diagnostics }, + transferPresentations(result, toWorkspace), + ), + ); }, }); diff --git a/packages/cli/src/commands/service/errors.ts b/packages/cli/src/commands/service/errors.ts index b16a9d56..48d6b671 100644 --- a/packages/cli/src/commands/service/errors.ts +++ b/packages/cli/src/commands/service/errors.ts @@ -1,10 +1,8 @@ import type { NextAction } from "@prisma/cli-engine/protocol"; import { CliStructuredError } from "@prisma/cli-engine/protocol"; import { CLI_NAME } from "../../cli-name"; -import type { CliError } from "../../errors"; import { DomainApiError, type DomainRecord } from "../../lib/app/app-provider"; import { formatDomainFailureFix } from "../../lib/app/domain-guidance"; -import type { NextAction as LegacyNextAction } from "../../next-actions"; type DomainCommand = "add" | "show" | "delete" | "retry" | "wait"; @@ -16,48 +14,9 @@ export function adviceAction(label: string): NextAction { return { kind: "user-choice", label }; } -function toEngineNextAction(action: LegacyNextAction): NextAction { - return { - kind: action.kind, - label: action.label, - ...(action.command !== undefined ? { command: action.command } : {}), - ...(action.commands !== undefined ? { commands: action.commands } : {}), - ...(action.reason !== undefined ? { reason: action.reason } : {}), - }; -} - const CNAME_HINT = /\bcname(?:s)?\s+to\b/; const PRISMA_BUILD_HOST = /\b((?:[a-z0-9-]+\.)+prisma\.build)\b/i; -/** - * Maps a legacy CliError onto the engine error protocol: the flat code - * becomes `SERVICE.`, the free-text fix becomes a user-choice - * action carried alongside any typed legacy actions, and each nextSteps - * command line becomes a run-command action. Copy passes through - * unchanged: the producers write the commands a user types today. - */ -export function fromLegacyCliError(error: CliError): CliStructuredError { - const fixAction = error.fix ? [adviceAction(error.fix)] : []; - const nextActions: NextAction[] = - error.nextActions.length > 0 - ? [...error.nextActions.map(toEngineNextAction), ...fixAction] - : [ - ...fixAction, - ...error.nextSteps.map((step) => ({ - kind: "run-command" as const, - label: "Run", - command: step, - })), - ]; - return new CliStructuredError(`SERVICE.${error.code}`, error.summary, { - ...(error.why ? { why: error.why } : {}), - nextActions, - ...(error.where ? { where: { path: error.where } } : {}), - ...(Object.keys(error.meta).length > 0 ? { meta: error.meta } : {}), - ...(error.docsUrl ? { docsUrl: error.docsUrl } : {}), - }); -} - /** * Consent declined interactively. The engine settles this code as a * user cancellation (exit 3). diff --git a/packages/cli/src/commands/service/target.ts b/packages/cli/src/commands/service/target.ts index d2e1a665..ac16c9c4 100644 --- a/packages/cli/src/commands/service/target.ts +++ b/packages/cli/src/commands/service/target.ts @@ -11,7 +11,7 @@ import { projectApiError } from "../../lib/project/provider"; import { type ProjectCandidate, type ProjectResolutionContext, - projectResolutionErrorToCliError, + projectResolutionErrorToStructured, resolveProjectTarget, sortProjects, } from "../../lib/project/resolution"; @@ -25,7 +25,6 @@ import { domainCommandError, domainHostnameInvalidError, domainNotFoundError, - fromLegacyCliError, projectNotFoundError, runCommandAction, serviceSelectionInvalidError, @@ -126,9 +125,7 @@ async function listWorkspaceProjects( signal: ctx.signal, }); if (error || !data) { - throw fromLegacyCliError( - projectApiError("Failed to list projects", response, error), - ); + throw projectApiError("Failed to list projects", response, error); } return sortProjects( (data.data ?? []).map((project) => ({ @@ -183,9 +180,7 @@ export async function resolveServiceProjectContext( commandName: options.commandName, }); if (resolvedResult.isErr()) { - throw fromLegacyCliError( - projectResolutionErrorToCliError(resolvedResult.error), - ); + throw projectResolutionErrorToStructured(resolvedResult.error); } const resolved = resolvedResult.value; const requested = options.branchName diff --git a/packages/cli/src/controllers/app-env-api.ts b/packages/cli/src/controllers/app-env-api.ts index a8c04a16..ad131940 100644 --- a/packages/cli/src/controllers/app-env-api.ts +++ b/packages/cli/src/controllers/app-env-api.ts @@ -1,6 +1,7 @@ +import { CliStructuredError } from "@prisma/cli-engine/protocol"; import type { ManagementApiClient } from "@prisma/management-api-sdk"; -import { authRequiredError, CliError } from "../errors"; import type { EnvVarRole } from "../lib/app/env-config"; +import { runCommand, userChoice } from "../lib/app/env-errors"; import type { EnvScopeDescriptor, EnvVariableMetadata } from "../types/app-env"; export interface ResolvedEnvApiScope { @@ -92,27 +93,38 @@ export function apiCallError( summary: string, response: Response | undefined, error: ApiErrorBody | undefined, -): CliError { +): CliStructuredError { const status = response?.status ?? 0; const apiCode = error?.error?.code; const apiMessage = error?.error?.message; const apiHint = error?.error?.hint; if (status === 401 || status === 403) { - return authRequiredError(["prisma auth login"]); + return new CliStructuredError("PROJECT.ENV_API_ERROR", summary, { + why: "The Management API rejected the request as unauthorized or forbidden.", + meta: { status }, + nextActions: [runCommand("prisma auth login")], + }); } - return new CliError({ - code: apiCode ?? "ENV_API_ERROR", - domain: "app", - summary, + return new CliStructuredError("PROJECT.ENV_API_ERROR", summary, { why: apiMessage ?? `The Management API returned status ${status || "unknown"}.`, - fix: - apiHint ?? "Re-run with --trace for the underlying API response details.", - exitCode: 1, - nextSteps: [], + ...(status || apiCode !== undefined + ? { + meta: { + ...(status ? { status } : {}), + ...(apiCode !== undefined ? { apiCode } : {}), + }, + } + : {}), + nextActions: [ + userChoice( + apiHint ?? + "Re-run with --log-level verbose for the underlying API response details.", + ), + ], }); } diff --git a/packages/cli/src/controllers/app-env-file.ts b/packages/cli/src/controllers/app-env-file.ts index 6c509d4a..68fd55e0 100644 --- a/packages/cli/src/controllers/app-env-file.ts +++ b/packages/cli/src/controllers/app-env-file.ts @@ -1,10 +1,14 @@ // biome-ignore-all lint/performance/noAwaitInLoops: Environment variable mutations and lookups are intentionally sequential. -// biome-ignore-all lint/style/noNestedTernary: Existing error formatting expression is intentionally compact. + +import { + CliStructuredError, + type NextAction, +} from "@prisma/cli-engine/protocol"; import type { ManagementApiClient } from "@prisma/management-api-sdk"; -import { CliError } from "../errors"; import type { CommandSuccess } from "../legacy/output"; import type { CommandContext } from "../legacy/runtime"; import { type EnvScope, formatScopeLabel } from "../lib/app/env-config"; +import { runCommand, userChoice } from "../lib/app/env-errors"; import type { EnvFileAssignment } from "../lib/app/env-file"; import type { EnvAddResult, @@ -45,19 +49,23 @@ export async function runEnvAddFile( .filter((key) => existing.has(key)); if (existingKeys.length > 0) { - throw new CliError({ - code: "ENV_VARIABLE_ALREADY_EXISTS", - domain: "app", - summary: `${existingKeys.length} environment variable(s) already exist in ${formatScopeLabel(resolved.scope)}`, - why: `Existing keys: ${formatKeyList(existingKeys)}.`, - fix: "Split the input file by key state: update existing keys and add new keys separately.", - exitCode: 1, - nextSteps: splitFileNextSteps(filePath, resolved.scope, { - existingKeys, - first: "update-existing", - }), - meta: { keys: existingKeys }, - }); + throw new CliStructuredError( + "PROJECT.ENV_VARIABLE_ALREADY_EXISTS", + `${existingKeys.length} environment variable(s) already exist in ${formatScopeLabel(resolved.scope)}`, + { + why: `Existing keys: ${formatKeyList(existingKeys)}.`, + meta: { keys: existingKeys }, + nextActions: [ + userChoice( + "Split the input file by key state: update existing keys and add new keys separately.", + ), + ...splitFileActions(filePath, resolved.scope, { + existingKeys, + first: "update-existing", + }), + ], + }, + ); } const warnings = await missingPreviewDefaultWarnings( @@ -117,7 +125,6 @@ export async function runEnvAddFile( }, }, warnings, - nextSteps: [], }; } @@ -142,19 +149,23 @@ export async function runEnvUpdateFile( .filter((key) => !existing.has(key)); if (missingKeys.length > 0) { - throw new CliError({ - code: "ENV_VARIABLE_NOT_FOUND", - domain: "app", - summary: `${missingKeys.length} environment variable(s) not found in ${formatScopeLabel(resolved.scope)}`, - why: `Missing keys: ${formatKeyList(missingKeys)}.`, - fix: "Split the input file by key state: add missing keys and update existing keys separately.", - exitCode: 1, - nextSteps: splitFileNextSteps(filePath, resolved.scope, { - missingKeys, - first: "add-missing", - }), - meta: { keys: missingKeys }, - }); + throw new CliStructuredError( + "PROJECT.ENV_VARIABLE_NOT_FOUND", + `${missingKeys.length} environment variable(s) not found in ${formatScopeLabel(resolved.scope)}`, + { + why: `Missing keys: ${formatKeyList(missingKeys)}.`, + meta: { keys: missingKeys }, + nextActions: [ + userChoice( + "Split the input file by key state: add missing keys and update existing keys separately.", + ), + ...splitFileActions(filePath, resolved.scope, { + missingKeys, + first: "add-missing", + }), + ], + }, + ); } const variables: EnvVariableMetadata[] = []; @@ -208,7 +219,6 @@ export async function runEnvUpdateFile( }, }, warnings: [], - nextSteps: [], }; } @@ -291,35 +301,35 @@ function envFileApplyFailedError( failedKey: string, writtenVariables: EnvVariableMetadata[], error: unknown, -): CliError { +): CliStructuredError { const writtenKeys = writtenVariables.map((variable) => variable.key); - const cause = - error instanceof CliError - ? error.summary - : error instanceof Error - ? error.message - : "Unknown error."; + const cause = error instanceof Error ? error.message : "Unknown error."; - return new CliError({ - code: "ENV_FILE_APPLY_FAILED", - domain: "app", - summary: `Failed to ${command} "${failedKey}" from "${filePath}"`, - why: - writtenKeys.length === 0 - ? `No variables were written before ${failedKey} failed. Cause: ${cause}` - : `Written keys before failure: ${formatKeyList(writtenKeys)}. Cause: ${cause}`, - fix: "Inspect the target scope, then retry the remaining keys once the API issue is resolved.", - exitCode: 1, - nextSteps: [ - `prisma project env list ${formatScopeFlag(scope)}`, - retryStepForApplyFailure(command, filePath, scope, writtenKeys), - ], - meta: { - file: filePath, - failedKey, - writtenKeys, + return new CliStructuredError( + "PROJECT.ENV_FILE_APPLY_FAILED", + `Failed to ${command} "${failedKey}" from "${filePath}"`, + { + why: + writtenKeys.length === 0 + ? `No variables were written before ${failedKey} failed. Cause: ${cause}` + : `Written keys before failure: ${formatKeyList(writtenKeys)}. Cause: ${cause}`, + meta: { + file: filePath, + failedKey, + writtenKeys, + }, + cause: error, + nextActions: [ + userChoice( + "Inspect the target scope, then retry the remaining keys once the API issue is resolved.", + ), + runCommand(`prisma project env list ${formatScopeFlag(scope)}`), + runCommand( + retryStepForApplyFailure(command, filePath, scope, writtenKeys), + ), + ], }, - }); + ); } function retryStepForApplyFailure( @@ -339,31 +349,40 @@ function retryStepForApplyFailure( return `prisma project env add --file ${formatScopeFlag(scope)}`; } -function splitFileNextSteps( +/** Each command carries the key list it applies to as its reason. */ +function splitFileActions( filePath: string, scope: EnvScope, options: | { first: "update-existing"; existingKeys: string[] } | { first: "add-missing"; missingKeys: string[] }, -): string[] { +): NextAction[] { const scopeFlag = formatScopeFlag(scope); const existingFile = `${filePath}.existing`; const newFile = `${filePath}.new`; if (options.first === "update-existing") { return [ - `# existing keys: ${formatKeyList(options.existingKeys)}`, - `prisma project env update --file ${existingFile} ${scopeFlag}`, - "# new keys only", - `prisma project env add --file ${newFile} ${scopeFlag}`, + runCommand( + `prisma project env update --file ${existingFile} ${scopeFlag}`, + `existing keys: ${formatKeyList(options.existingKeys)}`, + ), + runCommand( + `prisma project env add --file ${newFile} ${scopeFlag}`, + "new keys only", + ), ]; } return [ - `# missing keys: ${formatKeyList(options.missingKeys)}`, - `prisma project env add --file ${newFile} ${scopeFlag}`, - "# existing keys only", - `prisma project env update --file ${existingFile} ${scopeFlag}`, + runCommand( + `prisma project env add --file ${newFile} ${scopeFlag}`, + `missing keys: ${formatKeyList(options.missingKeys)}`, + ), + runCommand( + `prisma project env update --file ${existingFile} ${scopeFlag}`, + "existing keys only", + ), ]; } diff --git a/packages/cli/src/controllers/app-env.ts b/packages/cli/src/controllers/app-env.ts index 731dc873..55ba2f6f 100644 --- a/packages/cli/src/controllers/app-env.ts +++ b/packages/cli/src/controllers/app-env.ts @@ -1,12 +1,14 @@ // biome-ignore-all lint/performance/noAwaitInLoops: API pagination loops are intentionally sequential. + +import { CliStructuredError } from "@prisma/cli-engine/protocol"; import type { ManagementApiClient } from "@prisma/management-api-sdk"; -import { CliError, usageError } from "../errors"; import type { CommandContext } from "../legacy/runtime"; import { type EnvScope, type EnvVarRole, parseKeyValuePositional, } from "../lib/app/env-config"; +import { envUsageError, runCommand, userChoice } from "../lib/app/env-errors"; import { type EnvFileAssignment, readEnvFileAssignments, @@ -58,7 +60,7 @@ export function resolveEnvWriteSource( command: "add" | "update", ): EnvWriteSource { if (filePath !== undefined && rawAssignment !== undefined) { - throw usageError( + throw envUsageError( `prisma project env ${command} accepts either KEY=VALUE or --file`, "The command received both a positional assignment and a dotenv file path.", "Pass one input source.", @@ -66,25 +68,23 @@ export function resolveEnvWriteSource( `prisma project env ${command} KEY=value --role preview`, `prisma project env ${command} --file .env --role preview`, ], - "app", ); } if (filePath !== undefined) { if (filePath.length === 0) { - throw usageError( + throw envUsageError( `prisma project env ${command} --file requires a path`, "The --file flag was passed without a file path.", "Pass a readable dotenv file path.", [`prisma project env ${command} --file .env --role preview`], - "app", ); } return { kind: "file", filePath }; } if (rawAssignment === undefined) { - throw usageError( + throw envUsageError( `prisma project env ${command} requires KEY=VALUE or --file`, "No environment variable input was supplied.", "Pass a single KEY=VALUE assignment or a dotenv file path.", @@ -92,7 +92,6 @@ export function resolveEnvWriteSource( `prisma project env ${command} KEY=value --role preview`, `prisma project env ${command} --file .env --role preview`, ], - "app", ); } @@ -155,15 +154,17 @@ export async function resolveScopeToApi( ); if (branch.role === "production") { - throw new CliError({ - code: "ENV_BRANCH_SCOPE_IS_PRODUCTION", - domain: "app", - summary: `Branch "${scope.branchName}" is the production branch`, - why: "Production variables are project-level only; branch overrides apply to preview branches.", - fix: "Use --role production for the production branch.", - exitCode: 1, - nextSteps: ["prisma project env list --role production"], - }); + throw new CliStructuredError( + "PROJECT.ENV_BRANCH_SCOPE_IS_PRODUCTION", + `Branch "${scope.branchName}" is the production branch`, + { + why: "Production variables are project-level only; branch overrides apply to preview branches.", + nextActions: [ + userChoice("Use --role production for the production branch."), + runCommand("prisma project env list --role production"), + ], + }, + ); } return { @@ -279,15 +280,19 @@ async function resolveExistingBranch( await listBranchesByName(client, projectId, branchName, signal) )[0]; if (!branch) { - throw new CliError({ - code: "ENV_BRANCH_NOT_FOUND", - domain: "app", - summary: `Branch "${branchName}" not found`, - why: "Branch update, list, and delete commands only target existing preview branches.", - fix: "Create the branch by deploying it, or use `project env add --branch` to create its first override.", - exitCode: 1, - nextSteps: [`prisma project env add KEY=value --branch ${branchName}`], - }); + throw new CliStructuredError( + "PROJECT.ENV_BRANCH_NOT_FOUND", + `Branch "${branchName}" not found`, + { + why: "Branch update, list, and delete commands only target existing preview branches.", + nextActions: [ + userChoice( + "Create the branch by deploying it, or use `project env add --branch` to create its first override.", + ), + runCommand(`prisma project env add KEY=value --branch ${branchName}`), + ], + }, + ); } return branch; } @@ -306,15 +311,19 @@ async function resolveOrCreateBranch( } if (!(await projectHasDefaultBranch(client, projectId, signal))) { - throw new CliError({ - code: "ENV_BRANCH_CREATE_REQUIRES_DEFAULT_BRANCH", - domain: "app", - summary: `Cannot create branch "${branchName}" from project env`, - why: "Creating the first branch would make it the project default, but branch overrides are preview-only.", - fix: "Create or deploy the default branch first, then add the branch override.", - exitCode: 1, - nextSteps: ["prisma git connect "], - }); + throw new CliStructuredError( + "PROJECT.ENV_BRANCH_CREATE_REQUIRES_DEFAULT_BRANCH", + `Cannot create branch "${branchName}" from project env`, + { + why: "Creating the first branch would make it the project default, but branch overrides are preview-only.", + nextActions: [ + userChoice( + "Create or deploy the default branch first, then add the branch override.", + ), + runCommand("prisma git connect "), + ], + }, + ); } const { data, error, response } = await client.POST( diff --git a/packages/cli/src/controllers/branch.ts b/packages/cli/src/controllers/branch.ts index fb743eee..eefa143d 100644 --- a/packages/cli/src/controllers/branch.ts +++ b/packages/cli/src/controllers/branch.ts @@ -1,6 +1,7 @@ // biome-ignore-all lint/performance/noAwaitInLoops: Branch pagination requests must run sequentially. + +import { CliStructuredError } from "@prisma/cli-engine/protocol"; import type { ManagementApiClient } from "@prisma/management-api-sdk"; -import { CliError } from "../errors"; import type { BranchRole, BranchSummary } from "../types/branch"; export interface RawBranchRecord { @@ -84,19 +85,28 @@ function branchApiError( summary: string, response: Response | undefined, error: ApiErrorBody | undefined, -): CliError { +): CliStructuredError { const status = response?.status ?? 0; - return new CliError({ - code: error?.error?.code ?? "BRANCH_API_ERROR", - domain: "branch", - summary, + const apiCode = error?.error?.code; + return new CliStructuredError("BRANCH.API_ERROR", summary, { why: error?.error?.message ?? `The Management API returned status ${status || "unknown"}.`, - fix: - error?.error?.hint ?? - "Re-run with --trace for the underlying API response details.", - exitCode: 1, - nextSteps: [], + ...(status || apiCode !== undefined + ? { + meta: { + ...(status ? { status } : {}), + ...(apiCode !== undefined ? { apiCode } : {}), + }, + } + : {}), + nextActions: [ + { + kind: "user-choice", + label: + error?.error?.hint ?? + "Re-run with --log-level verbose for the underlying API response details.", + }, + ], }); } diff --git a/packages/cli/src/controllers/database.ts b/packages/cli/src/controllers/database.ts index 92648b58..cec6837f 100644 --- a/packages/cli/src/controllers/database.ts +++ b/packages/cli/src/controllers/database.ts @@ -1,6 +1,9 @@ import { randomBytes } from "node:crypto"; -import { CliError, usageError } from "../errors"; -import type { PrismaCliPackageCommandFormatter } from "../lib/agent/cli-command"; +import { + CliStructuredError, + type NextAction, +} from "@prisma/cli-engine/protocol"; +import { CLI_NAME } from "../cli-name"; import type { DatabaseProvider } from "../lib/database/provider"; import type { ResolvedProjectTarget } from "../lib/project/resolution"; import type { DatabaseSummary } from "../types/database"; @@ -8,11 +11,23 @@ import type { DatabaseSummary } from "../types/database"; const USAGE_DATE_ONLY_PATTERN = /^\d{4}-\d{2}-\d{2}$/; const USAGE_DATETIME_PATTERN = /^\d{4}-\d{2}-\d{2}T/; +const LIST_DATABASES_COMMAND = `${CLI_NAME} postgres list`; + +/** The corrected `postgres usage` form both period errors point at. */ +export const USAGE_PERIOD_EXAMPLE_COMMAND = `${CLI_NAME} postgres usage --from 2026-06-01 --to 2026-06-30`; + +function userChoice(label: string): NextAction { + return { kind: "user-choice", label }; +} + +function runCommand(command: string): NextAction { + return { kind: "run-command", label: command, command }; +} + export function parseUsageDate( value: string | undefined, flagName: string, dayBoundary: "start" | "end", - formatCommand: PrismaCliPackageCommandFormatter, ): string | undefined { if (value === undefined) { return undefined; @@ -39,23 +54,13 @@ export function parseUsageDate( return trimmed; } - throw usageError( - "Invalid usage period", - `${flagName} must be an ISO date such as 2026-06-01 or an ISO datetime such as 2026-06-01T12:00:00Z.`, - `Pass an ISO date or datetime to ${flagName}.`, - [ - formatCommand([ - "database", - "usage", - "", - "--from", - "2026-06-01", - "--to", - "2026-06-30", - ]), + throw new CliStructuredError("POSTGRES.USAGE_ERROR", "Invalid usage period", { + why: `${flagName} must be an ISO date such as 2026-06-01 or an ISO datetime such as 2026-06-01T12:00:00Z.`, + nextActions: [ + userChoice(`Pass an ISO date or datetime to ${flagName}.`), + runCommand(USAGE_PERIOD_EXAMPLE_COMMAND), ], - "database", - ); + }); } function isValidCalendarDate(datePart: string): boolean { @@ -68,7 +73,6 @@ function isValidCalendarDate(datePart: string): boolean { export function parseBackupLimit( value: string | undefined, - formatCommand: PrismaCliPackageCommandFormatter, ): number | undefined { if (value === undefined) { return undefined; @@ -76,21 +80,16 @@ export function parseBackupLimit( const limit = Number(value.trim()); if (!Number.isInteger(limit) || limit < 1 || limit > 100) { - throw usageError( + throw new CliStructuredError( + "POSTGRES.USAGE_ERROR", "Invalid backup limit", - "--limit must be an integer between 1 and 100.", - "Pass a --limit between 1 and 100.", - [ - formatCommand([ - "database", - "backup", - "list", - "", - "--limit", - "50", - ]), - ], - "database", + { + why: "--limit must be an integer between 1 and 100.", + nextActions: [ + userChoice("Pass a --limit between 1 and 100."), + runCommand(`${CLI_NAME} postgres backup list --limit 50`), + ], + }, ); } @@ -106,12 +105,16 @@ export async function resolveDatabase( ): Promise { const ref = databaseRef.trim(); if (!ref) { - throw usageError( + throw new CliStructuredError( + "POSTGRES.USAGE_ERROR", "Database id or name required", - "This command needs a database id or name.", - "Pass a database id or name.", - ["prisma database list"], - "database", + { + why: "This command needs a database id or name.", + nextActions: [ + userChoice("Pass a database id or name."), + runCommand(LIST_DATABASES_COMMAND), + ], + }, ); } @@ -157,15 +160,15 @@ export async function resolveDatabase( function databaseRemovedDuringResolutionError( database: DatabaseSummary, projectName: string, -): CliError { - return new CliError({ - code: "DATABASE_NOT_FOUND", - domain: "database", - summary: "Database not found", +): CliStructuredError { + return new CliStructuredError("POSTGRES.NOT_FOUND", "Database not found", { why: `"${database.name}" (${database.id}) was listed for project "${projectName}", but reading it returned 404. It was most likely removed while this command was running.`, - fix: "Re-run the command, or list the project's databases to see what is there now.", - exitCode: 1, - nextSteps: ["prisma database list"], + nextActions: [ + userChoice( + "Re-run the command, or list the project's databases to see what is there now.", + ), + runCommand(LIST_DATABASES_COMMAND), + ], }); } @@ -203,18 +206,16 @@ function databaseNotFoundError( databaseRef: string, projectName?: string, branchName?: string, -): CliError { +): CliStructuredError { const scope = projectName ? ` in project "${projectName}"${branchName ? ` on branch "${branchName}"` : ""}` : ""; - return new CliError({ - code: "DATABASE_NOT_FOUND", - domain: "database", - summary: "Database not found", + return new CliStructuredError("POSTGRES.NOT_FOUND", "Database not found", { why: `No database matched "${databaseRef}"${scope}.`, - fix: "Pass a database id or name from prisma database list.", - exitCode: 1, - nextSteps: ["prisma database list"], + nextActions: [ + userChoice(`Pass a database id or name from ${LIST_DATABASES_COMMAND}.`), + runCommand(LIST_DATABASES_COMMAND), + ], }); } @@ -222,23 +223,27 @@ function databaseAmbiguousError( databaseRef: string, matches: DatabaseSummary[], branchName: string | undefined, -): CliError { - return new CliError({ - code: "DATABASE_AMBIGUOUS", - domain: "database", - summary: "Database resolution is ambiguous", - why: branchName - ? `Multiple databases matched "${databaseRef}" on branch "${branchName}".` - : `Multiple databases matched "${databaseRef}".`, - fix: "Pass the database id, or pass --branch to narrow the match.", - exitCode: 1, - nextSteps: ["prisma database list"], - meta: { - matches: matches.map((database) => ({ - id: database.id, - name: database.name, - branchName: database.branchName, - })), +): CliStructuredError { + return new CliStructuredError( + "POSTGRES.AMBIGUOUS", + "Database resolution is ambiguous", + { + why: branchName + ? `Multiple databases matched "${databaseRef}" on branch "${branchName}".` + : `Multiple databases matched "${databaseRef}".`, + meta: { + matches: matches.map((database) => ({ + id: database.id, + name: database.name, + branchName: database.branchName, + })), + }, + nextActions: [ + userChoice( + "Pass the database id, or pass --branch to narrow the match.", + ), + runCommand(LIST_DATABASES_COMMAND), + ], }, - }); + ); } diff --git a/packages/cli/src/controllers/project.ts b/packages/cli/src/controllers/project.ts index 70e95aac..69b6bcdc 100644 --- a/packages/cli/src/controllers/project.ts +++ b/packages/cli/src/controllers/project.ts @@ -2,11 +2,14 @@ import { unlink } from "node:fs/promises"; import path from "node:path"; import { SERVICE_TOKEN_ENV_VAR } from "@prisma/cli-engine"; +import { + CliStructuredError, + type NextAction, +} from "@prisma/cli-engine/protocol"; import type { ManagementApiClient } from "@prisma/management-api-sdk"; import { matchError } from "better-result"; import type { GitHubRepositoryReference } from "../adapters/git"; -import { authRequiredError, CliError } from "../errors"; import type { CommandContext } from "../legacy/runtime"; import type { PrismaCliPackageCommandFormatter } from "../lib/agent/cli-command"; import { @@ -25,6 +28,20 @@ import type { export const GITHUB_INSTALL_POLL_INTERVAL_MS = 2_000; export const GITHUB_INSTALL_POLL_TIMEOUT_MS = 120_000; +function runCommand(command: string): NextAction { + return { kind: "run-command", label: command, command }; +} + +function userChoice(label: string): NextAction { + return { kind: "user-choice", label }; +} + +/** A URL is not a command: putting one in `command` tells a consumer to + * execute it. */ +function openUrl(url: string): NextAction { + return { kind: "open-url", label: url, url }; +} + export async function readProjectListLocalBinding( cwd: string, projects: Array>, @@ -67,51 +84,59 @@ function localPinReadErrorToInvalidLocalBinding( export function transferRecipientRequiredError( formatCommand: PrismaCliPackageCommandFormatter, -): CliError { - return new CliError({ - code: "TRANSFER_RECIPIENT_REQUIRED", - domain: "project", - summary: "Transfer recipient required", - why: "Project transfer needs the receiving workspace.", - fix: "Pass --to-workspace for a locally authenticated workspace, or --recipient-token for a cross-account transfer.", - exitCode: 2, - nextSteps: [ - formatCommand(["auth", "workspace", "list"]), - formatCommand([ - "project", - "transfer", - "", - "--to-workspace", - "", - "--confirm", - "", - ]), - ], - }); +): CliStructuredError { + return new CliStructuredError( + "PROJECT.TRANSFER_RECIPIENT_REQUIRED", + "Transfer recipient required", + { + why: "Project transfer needs the receiving workspace.", + nextActions: [ + userChoice( + "Pass --to-workspace for a locally authenticated workspace, or --recipient-token for a cross-account transfer.", + ), + runCommand(formatCommand(["auth", "workspace", "list"])), + runCommand( + formatCommand([ + "project", + "transfer", + "", + "--to-workspace", + "", + "--confirm", + "", + ]), + ), + ], + }, + ); } export function transferRecipientUnavailableError( formatCommand: PrismaCliPackageCommandFormatter, -): CliError { - return new CliError({ - code: "TRANSFER_RECIPIENT_UNAVAILABLE", - domain: "project", - summary: "Local workspace sessions are unavailable", - why: `--to-workspace resolves locally stored OAuth sessions, but ${SERVICE_TOKEN_ENV_VAR} is set and service-token mode does not read them.`, - fix: "Pass --recipient-token with an access token for the receiving workspace, or unset the service token.", - exitCode: 1, - nextSteps: [ - formatCommand([ - "project", - "transfer", - "", - "--recipient-token", - "", - "--confirm", - "", - ]), - ], - }); +): CliStructuredError { + return new CliStructuredError( + "PROJECT.TRANSFER_RECIPIENT_UNAVAILABLE", + "Local workspace sessions are unavailable", + { + why: `--to-workspace resolves locally stored OAuth sessions, but ${SERVICE_TOKEN_ENV_VAR} is set and service-token mode does not read them.`, + nextActions: [ + userChoice( + "Pass --recipient-token with an access token for the receiving workspace, or unset the service token.", + ), + runCommand( + formatCommand([ + "project", + "transfer", + "", + "--recipient-token", + "", + "--confirm", + "", + ]), + ), + ], + }, + ); } export async function cleanupLocalPinForProject( @@ -489,11 +514,14 @@ async function findRepositoryInInstallationIfAvailable( } function isUnavailableScmInstallationError(error: unknown): boolean { - if (!(error instanceof CliError) || error.code !== "REPO_CONNECTION_FAILED") { + if ( + !CliStructuredError.is(error) || + error.code !== "GIT.REPO_CONNECTION_FAILED" + ) { return false; } - return error.meta.status === 404 || error.meta.status === 422; + return error.meta?.status === 404 || error.meta?.status === 422; } export async function createGitHubInstallIntent( @@ -581,89 +609,103 @@ export function toRepositoryConnection( }; } -export function unsupportedRepositoryProviderError(): CliError { - return new CliError({ - code: "REPO_PROVIDER_UNSUPPORTED", - domain: "project", - summary: "Repository provider is not supported", - why: "Repository connection supports GitHub repository URLs only.", - fix: "Pass a GitHub repository URL such as git@github.com:prisma/prisma-cli.git.", - exitCode: 2, - nextSteps: ["prisma git connect git@github.com:owner/repo.git"], - }); +export function unsupportedRepositoryProviderError(): CliStructuredError { + return new CliStructuredError( + "GIT.REPO_PROVIDER_UNSUPPORTED", + "Repository provider is not supported", + { + why: "Repository connection supports GitHub repository URLs only.", + nextActions: [ + userChoice( + "Pass a GitHub repository URL such as git@github.com:prisma/prisma-cli.git.", + ), + runCommand("prisma git connect git@github.com:owner/repo.git"), + ], + }, + ); } -export function repoNotConnectedError(): CliError { - return new CliError({ - code: "REPO_NOT_CONNECTED", - domain: "project", - summary: "No GitHub repository connected", - why: "The resolved project does not have an active GitHub repository connection.", - fix: "Run prisma git connect before disconnecting.", - exitCode: 1, - nextSteps: ["prisma git connect"], - }); +export function repoNotConnectedError(): CliStructuredError { + return new CliStructuredError( + "GIT.REPO_NOT_CONNECTED", + "No GitHub repository connected", + { + why: "The resolved project does not have an active GitHub repository connection.", + nextActions: [ + userChoice("Run prisma git connect before disconnecting."), + runCommand("prisma git connect"), + ], + }, + ); } export function repoInstallationRequiredError( repository: GitHubRepositoryReference, installUrl: string, - opened: boolean, -): CliError { - return new CliError({ - code: "REPO_INSTALLATION_REQUIRED", - domain: "project", - summary: "GitHub App installation required", - why: `The selected workspace does not have a GitHub App installation that can be used to link ${repository.fullName}.`, - fix: opened - ? "Finish installing the GitHub App in the browser, then rerun prisma git connect." - : "Open the GitHub App installation URL, approve access, then rerun prisma git connect.", - meta: { - repository: repository.fullName, - installUrl, - opened, +): CliStructuredError { + return new CliStructuredError( + "GIT.REPO_INSTALLATION_REQUIRED", + "GitHub App installation required", + { + why: `The selected workspace does not have a GitHub App installation that can be used to link ${repository.fullName}.`, + meta: { + repository: repository.fullName, + installUrl, + }, + nextActions: [ + userChoice( + "Finish installing the GitHub App in the browser, then rerun prisma git connect.", + ), + openUrl(installUrl), + runCommand(`prisma git connect ${repository.url}`), + ], }, - exitCode: 1, - nextSteps: [installUrl, `prisma git connect ${repository.url}`], - }); + ); } export function repoNotAccessibleError( repository: GitHubRepositoryReference, installUrl: string, - opened: boolean, -): CliError { - return new CliError({ - code: "REPO_NOT_ACCESSIBLE", - domain: "project", - summary: "GitHub repository is not accessible", - why: `The GitHub App installations connected to this workspace do not expose ${repository.fullName}.`, - fix: "Open the GitHub App installation URL, grant access to this repository, then rerun prisma git connect.", - meta: { - repository: repository.fullName, - installUrl, - opened, +): CliStructuredError { + return new CliStructuredError( + "GIT.REPO_NOT_ACCESSIBLE", + "GitHub repository is not accessible", + { + why: `The GitHub App installations connected to this workspace do not expose ${repository.fullName}.`, + meta: { + repository: repository.fullName, + installUrl, + }, + nextActions: [ + userChoice( + "Open the GitHub App installation URL, grant access to this repository, then rerun prisma git connect.", + ), + openUrl(installUrl), + runCommand(`prisma git connect ${repository.url}`), + ], }, - exitCode: 1, - nextSteps: [installUrl, `prisma git connect ${repository.url}`], - }); + ); } export function repoAlreadyConnectedError( repositoryFullName: string, -): CliError { - return new CliError({ - code: "REPO_ALREADY_CONNECTED", - domain: "project", - summary: "Project already has a GitHub repository connected", - why: `The resolved project is already connected to ${repositoryFullName}.`, - fix: "Disconnect the existing repository before connecting a different one.", - meta: { - repository: repositoryFullName, +): CliStructuredError { + return new CliStructuredError( + "GIT.REPO_ALREADY_CONNECTED", + "Project already has a GitHub repository connected", + { + why: `The resolved project is already connected to ${repositoryFullName}.`, + meta: { + repository: repositoryFullName, + }, + nextActions: [ + userChoice( + "Disconnect the existing repository before connecting a different one.", + ), + runCommand("prisma git disconnect"), + ], }, - exitCode: 1, - nextSteps: ["prisma git disconnect"], - }); + ); } export function repositoryFullNamesMatch(left: string, right: string): boolean { @@ -674,34 +716,35 @@ export function repoConnectionApiError( summary: string, response: Response | undefined, error: SourceRepositoryApiError | undefined, -): CliError { +): CliStructuredError { const status = response?.status ?? 0; const apiCode = error?.error?.code; const apiMessage = error?.error?.message; const apiHint = error?.error?.hint; + const unauthorized = status === 401 || status === 403; - if (status === 401 || status === 403) { - return authRequiredError(["prisma auth login"]); - } - - return new CliError({ - code: "REPO_CONNECTION_FAILED", - domain: "project", - summary, + return new CliStructuredError("GIT.REPO_CONNECTION_FAILED", summary, { why: apiMessage ?? - `The Management API returned status ${status || "unknown"}.`, - fix: apiHint ?? repoConnectionFixForStatus(status), + (unauthorized + ? `The Management API rejected the request as unauthorized (HTTP ${status}).` + : `The Management API returned status ${status || "unknown"}.`), meta: { status, ...(apiCode ? { apiCode } : {}), }, - exitCode: 1, - nextSteps: ["prisma project show"], + nextActions: [ + userChoice(apiHint ?? repoConnectionFixForStatus(status)), + runCommand(unauthorized ? "prisma auth login" : "prisma project show"), + ], }); } function repoConnectionFixForStatus(status: number): string { + if (status === 401 || status === 403) { + return "Sign in again with prisma auth login, then rerun the command."; + } + if (status === 404) { return "Install the GitHub App for this workspace, then rerun prisma git connect."; } @@ -714,5 +757,5 @@ function repoConnectionFixForStatus(status: number): string { return "Make sure the GitHub App installation has access to this repository."; } - return "Re-run with --trace for the underlying API response details."; + return "Re-run with --log-level verbose for the underlying API response details."; } diff --git a/packages/cli/src/errors.ts b/packages/cli/src/errors.ts deleted file mode 100644 index b4c90eba..00000000 --- a/packages/cli/src/errors.ts +++ /dev/null @@ -1,167 +0,0 @@ -import type { NextAction } from "./next-actions"; - -export type ErrorDomain = - | "cli" - | "auth" - | "project" - | "branch" - | "app" - | "database" - | "bucket"; -export type ErrorSeverity = "error"; - -export interface CliErrorOptions { - code: string; - domain: ErrorDomain; - summary: string; - why: string | null; - fix: string | null; - debug?: string | null; - where?: string | null; - meta?: Record; - docsUrl?: string | null; - exitCode?: number; - nextSteps?: string[]; - nextActions?: NextAction[]; - humanLines?: string[]; -} - -export class CliError extends Error { - readonly code: string; - readonly domain: ErrorDomain; - readonly severity: ErrorSeverity; - readonly summary: string; - readonly why: string | null; - readonly fix: string | null; - readonly debug: string | null; - readonly where: string | null; - readonly meta: Record; - readonly docsUrl: string | null; - readonly exitCode: number; - readonly nextSteps: string[]; - readonly nextActions: NextAction[]; - readonly humanLines: string[] | null; - - constructor(options: CliErrorOptions) { - super(options.summary); - this.name = "CliError"; - this.code = options.code; - this.domain = options.domain; - this.severity = "error"; - this.summary = options.summary; - this.why = options.why; - this.fix = options.fix; - this.debug = options.debug ?? null; - this.where = options.where ?? null; - this.meta = options.meta ?? {}; - this.docsUrl = options.docsUrl ?? null; - this.exitCode = options.exitCode ?? 1; - this.nextSteps = options.nextSteps ?? []; - this.nextActions = options.nextActions ?? []; - this.humanLines = - options.humanLines && options.humanLines.length > 0 - ? [...options.humanLines] - : null; - } -} - -export function usageError( - summary: string, - why: string, - fix: string, - nextSteps: string[] = [], - domain: ErrorDomain = "cli", -): CliError { - return new CliError({ - code: "USAGE_ERROR", - domain, - summary, - why, - fix, - exitCode: 2, - nextSteps, - }); -} - -export function isUsageError( - error: unknown, - summary?: string, -): error is CliError { - return ( - isErrorRecord(error) && - error.code === "USAGE_ERROR" && - (summary === undefined || error.summary === summary) - ); -} - -function isErrorRecord(error: unknown): error is Record { - return typeof error === "object" && error !== null; -} - -export function authRequiredError( - nextSteps: string[] = ["prisma auth login"], - options: { debug?: string | null } = {}, -): CliError { - return new CliError({ - code: "AUTH_REQUIRED", - domain: "auth", - summary: "Authentication required", - why: "This command needs an authenticated session.", - fix: "Run prisma auth login, or rerun the command in a TTY to sign in interactively.", - debug: options.debug, - exitCode: 1, - nextSteps, - }); -} - -export function authConfigInvalidError(message: string): CliError { - return new CliError({ - code: "AUTH_CONFIG_INVALID", - domain: "auth", - summary: "Authentication configuration is invalid", - why: message, - fix: "Provide a valid PRISMA_SERVICE_TOKEN value, or unset the variable to use local OAuth login.", - exitCode: 1, - nextSteps: ["prisma auth login"], - }); -} - -export function commandCanceledError(): CliError { - return new CliError({ - code: "COMMAND_CANCELED", - domain: "cli", - summary: "Command canceled", - why: null, - fix: null, - exitCode: 130, - humanLines: ["Command canceled [COMMAND_CANCELED]"], - }); -} - -export function workspaceRequiredError(): CliError { - return usageError( - "Workspace required", - "This command needs an active workspace, but the authenticated session does not have one.", - "Run prisma auth login and choose a workspace.", - ["prisma auth login"], - "auth", - ); -} - -export function featureUnavailableError( - summary: string, - why: string, - fix: string, - nextSteps: string[] = [], - domain: ErrorDomain = "cli", -): CliError { - return new CliError({ - code: "FEATURE_UNAVAILABLE", - domain, - summary, - why, - fix, - exitCode: 1, - nextSteps, - }); -} diff --git a/packages/cli/src/legacy/output.ts b/packages/cli/src/legacy/output.ts index 9d75b86d..96a8345c 100644 --- a/packages/cli/src/legacy/output.ts +++ b/packages/cli/src/legacy/output.ts @@ -1,13 +1,11 @@ import type { Writable } from "node:stream"; -import type { NextAction } from "../next-actions"; - +/** What an env-file controller returns: the result plus the findings it + * collected along the way, which the command turns into diagnostics. */ export interface CommandSuccess { command: string; result: T; warnings: string[]; - nextSteps: string[]; - nextActions?: NextAction[]; } export interface CliOutput { diff --git a/packages/cli/src/lib/app/env-config.ts b/packages/cli/src/lib/app/env-config.ts index fd68e14a..bb660f44 100644 --- a/packages/cli/src/lib/app/env-config.ts +++ b/packages/cli/src/lib/app/env-config.ts @@ -1,4 +1,4 @@ -import { usageError } from "../../errors"; +import { envUsageError } from "./env-errors"; export type EnvVarRole = "production" | "preview"; @@ -29,7 +29,7 @@ export function resolveEnvScope( options: ScopeOptions, ): EnvScope | null { if (flags.roleName && flags.branchName) { - throw usageError( + throw envUsageError( `prisma project env ${options.command} accepts either --role or --branch`, "--role targets a project-level config map; --branch targets a preview branch override.", "Pass exactly one scope flag.", @@ -37,13 +37,12 @@ export function resolveEnvScope( `prisma project env ${options.command} ${positionalHint(options.command)}--role preview`, `prisma project env ${options.command} ${positionalHint(options.command)}--branch feature/foo`, ], - "app", ); } if (flags.roleName) { if (!VALID_ROLES.has(flags.roleName)) { - throw usageError( + throw envUsageError( `Unknown role "${flags.roleName}"`, "--role accepts production or preview.", "Pass --role production or --role preview.", @@ -51,7 +50,6 @@ export function resolveEnvScope( `prisma project env ${options.command} --role production`, `prisma project env ${options.command} --role preview`, ], - "app", ); } @@ -64,7 +62,7 @@ export function resolveEnvScope( if (options.requireExplicit) { const positional = positionalHint(options.command); - throw usageError( + throw envUsageError( `prisma project env ${options.command} requires --role or --branch`, "Writing without an explicit scope is rejected so the command never silently targets production.", "Pass --role production, --role preview, or --branch .", @@ -73,7 +71,6 @@ export function resolveEnvScope( `prisma project env ${options.command} ${positional}--role preview`, `prisma project env ${options.command} ${positional}--branch feature/foo`, ], - "app", ); } @@ -86,14 +83,13 @@ export function parseKeyValuePositional( env: NodeJS.ProcessEnv = process.env, ): { key: string; value: string } { if (!raw) { - throw usageError( + throw envUsageError( `prisma project env ${command} requires KEY=VALUE`, "No KEY=VALUE positional argument was supplied.", "Pass the variable as KEY=VALUE, e.g. STRIPE_KEY=sk_test_xxx.", [ `prisma project env ${command} STRIPE_KEY=sk_test_xxx --role production`, ], - "app", ); } @@ -106,7 +102,7 @@ export function parseKeyValuePositional( return { key: raw, value }; } - throw usageError( + throw envUsageError( `Value for "${raw}" was not provided`, `No KEY=VALUE assignment was supplied, and ${raw} is not set in the current environment.`, "Pass KEY=VALUE or export the variable before running the command.", @@ -114,18 +110,16 @@ export function parseKeyValuePositional( `prisma project env ${command} ${raw}=value --role production`, `${raw}=value prisma project env ${command} ${raw} --role production`, ], - "app", ); } - throw usageError( + throw envUsageError( `KEY=VALUE argument is missing the = separator`, `"${raw}" does not contain an = character.`, "Pass the variable as KEY=VALUE, e.g. STRIPE_KEY=sk_test_xxx.", [ `prisma project env ${command} STRIPE_KEY=sk_test_xxx --role production`, ], - "app", ); } @@ -135,12 +129,11 @@ export function parseKeyValuePositional( validateKey(key, command); if (value.length === 0) { - throw usageError( + throw envUsageError( `KEY=VALUE argument has an empty value`, `"${raw}" has an empty value after the = separator.`, `Pass a non-empty value, or use prisma project env delete to delete a variable.`, [`prisma project env ${command} ${key}=value --role production`], - "app", ); } @@ -151,32 +144,28 @@ const KEY_SHAPE = /^[A-Z_][A-Z0-9_]*$/; export function validateKey(key: string, command: "add" | "update"): void { if (key.length === 0) { - throw usageError( + throw envUsageError( `Variable key cannot be empty`, "An empty key was passed.", "Pass an env-var key, e.g. STRIPE_KEY.", [`prisma project env ${command} STRIPE_KEY=value --role production`], - "app", ); } if (key.length > 256) { - throw usageError( + throw envUsageError( `Variable key "${key}" exceeds the 256-character limit`, "Env-var keys are capped at 256 characters by the platform.", "Use a shorter key.", - [], - "app", ); } if (!KEY_SHAPE.test(key)) { - throw usageError( + throw envUsageError( `Variable key "${key}" must match the POSIX env-var shape`, "Keys must start with an uppercase letter or underscore and contain only uppercase letters, digits, and underscores.", "Rename the key to match [A-Z_][A-Z0-9_]*.", [`prisma project env ${command} STRIPE_KEY=value --role production`], - "app", ); } } diff --git a/packages/cli/src/lib/app/env-errors.ts b/packages/cli/src/lib/app/env-errors.ts new file mode 100644 index 00000000..8c404159 --- /dev/null +++ b/packages/cli/src/lib/app/env-errors.ts @@ -0,0 +1,35 @@ +/** + * The structured errors the `project env` code paths raise, with the + * registered PROJECT.* codes assigned at origin. This is the lowest + * layer the env commands, controllers and parsers share, so the + * parsers can raise without depending on the controllers. + */ +import { + CliStructuredError, + type NextAction, +} from "@prisma/cli-engine/protocol"; + +export function userChoice(label: string): NextAction { + return { kind: "user-choice", label }; +} + +export function runCommand(command: string, reason?: string): NextAction { + return { + kind: "run-command", + label: command, + command, + ...(reason === undefined ? {} : { reason }), + }; +} + +export function envUsageError( + summary: string, + why: string, + fix: string, + commands: readonly string[] = [], +): CliStructuredError { + return new CliStructuredError("PROJECT.USAGE_ERROR", summary, { + why, + nextActions: [userChoice(fix), ...commands.map((step) => runCommand(step))], + }); +} diff --git a/packages/cli/src/lib/app/env-file.ts b/packages/cli/src/lib/app/env-file.ts index 021b9f37..bb96221e 100644 --- a/packages/cli/src/lib/app/env-file.ts +++ b/packages/cli/src/lib/app/env-file.ts @@ -3,8 +3,8 @@ import { readFile } from "node:fs/promises"; import path from "node:path"; import { parse as parseDotenv } from "dotenv"; -import { usageError } from "../../errors"; import { validateKey } from "./env-config"; +import { envUsageError } from "./env-errors"; export interface EnvFileAssignment { key: string; @@ -30,12 +30,11 @@ export async function readEnvFileAssignments( try { contents = await readFile(resolvedPath, "utf8"); } catch (error) { - throw usageError( + throw envUsageError( `Failed to read env file "${filePath}"`, error instanceof Error ? error.message : "The file could not be read.", "Pass a readable dotenv file path.", [`prisma project env ${command} --file .env --role preview`], - "app", ); } @@ -49,12 +48,10 @@ export function parseEnvFileContents( ): EnvFileAssignment[] { const parsedKeys = extractParsedKeys(contents); if (parsedKeys.length === 0) { - throw usageError( + throw envUsageError( `No environment variables found in "${filePath}"`, "The file does not contain any KEY=VALUE assignments.", "Pass a dotenv file with at least one non-empty variable.", - [], - "app", ); } @@ -63,12 +60,10 @@ export function parseEnvFileContents( validateEnvFileKey(entry.key, entry.line, filePath, command); const firstLine = seen.get(entry.key); if (firstLine !== undefined) { - throw usageError( + throw envUsageError( `Duplicate environment variable "${entry.key}" in "${filePath}"`, `Lines ${firstLine} and ${entry.line} both define ${entry.key}.`, "Keep one assignment for each key before importing the file.", - [], - "app", ); } seen.set(entry.key, entry.line); @@ -79,14 +74,12 @@ export function parseEnvFileContents( const value = parsedValues[key]; if (typeof value !== "string" || value.length === 0) { const line = seen.get(key); - throw usageError( + throw envUsageError( `Environment variable "${key}" in "${filePath}" has an empty value`, line === undefined ? `${key} has an empty value.` : `Line ${line} defines ${key} with an empty value.`, "Pass a non-empty value, or omit the key from the file.", - [], - "app", ); } @@ -147,12 +140,10 @@ function validateEnvFileKey( error instanceof Error && error.message.length > 0 ? error.message : "Invalid environment variable key."; - throw usageError( + throw envUsageError( `Invalid environment variable "${key}" in "${filePath}"`, `Line ${line}: ${reason}`, "Use a valid env-var key and retry the import.", - [], - "app", ); } } diff --git a/packages/cli/src/lib/bucket/provider.ts b/packages/cli/src/lib/bucket/provider.ts index 74ea2bde..8f933754 100644 --- a/packages/cli/src/lib/bucket/provider.ts +++ b/packages/cli/src/lib/bucket/provider.ts @@ -1,7 +1,11 @@ // biome-ignore-all lint/performance/noAwaitInLoops: Bucket pagination requests must run sequentially. +import { + CliStructuredError, + type NextAction, +} from "@prisma/cli-engine/protocol"; import type { ManagementApiClient } from "@prisma/management-api-sdk"; -import { CliError } from "../../errors"; +import { CLI_NAME } from "../../cli-name"; import type { BucketKeySummary, BucketSummary } from "../../types/bucket"; export interface BucketCreateInput { @@ -226,15 +230,7 @@ export function createManagementBucketProvider( const bucketName = raw.bucketName; if (!secretAccessKey || !accessKeyId || !endpoint || !bucketName) { - throw new CliError({ - code: "BUCKET_KEY_SECRET_MISSING", - domain: "bucket", - summary: "Created bucket key did not return credentials", - why: "Bucket key credentials are one-time-view secrets, but the Management API did not include them in this create response.", - fix: "Create another bucket key and store the returned credentials immediately.", - exitCode: 1, - nextSteps: [`prisma bucket key create ${options.bucketId}`], - }); + throw bucketKeySecretMissingError(options.bucketId); } return { @@ -287,23 +283,94 @@ export function normalizeKey(raw: RawBucketKeyRecord): BucketKeySummary { }; } +const VERBOSE_LOG_FIX = + "Re-run with --log-level verbose for the underlying API response details."; + +function userChoice(label: string): NextAction { + return { kind: "user-choice", label }; +} + +function runCommand(command: string): NextAction { + return { kind: "run-command", label: command, command }; +} + +function bucketKeySecretMissingError(bucketId: string): CliStructuredError { + return new CliStructuredError( + "BUCKET.KEY_SECRET_MISSING", + "Created bucket key did not return credentials", + { + why: "Bucket key credentials are one-time-view secrets, but the Management API did not include them in this create response.", + nextActions: [ + userChoice( + "Create another bucket key and store the returned credentials immediately.", + ), + runCommand(`${CLI_NAME} bucket key create ${bucketId}`), + ], + }, + ); +} + +/** A 401 or 403 is the API refusing the caller, not a bucket problem. */ +function isRejectedCaller(status: number): boolean { + return status === 401 || status === 403; +} + +function apiErrorWhy(status: number, message: string | undefined): string { + if (!isRejectedCaller(status)) { + return ( + message ?? `The Management API returned status ${status || "unknown"}.` + ); + } + const rejection = `The Management API rejected the request as ${status === 401 ? "unauthorized" : "forbidden"}.`; + return message ? `${rejection} ${message}` : rejection; +} + +function apiErrorMeta( + status: number, + apiCode: string | undefined, +): Record | undefined { + if (!status && apiCode === undefined) { + return undefined; + } + return { + ...(status ? { status } : {}), + ...(apiCode === undefined ? {} : { apiCode }), + }; +} + +function apiErrorActions( + status: number, + hint: string | undefined, +): NextAction[] { + if (!isRejectedCaller(status)) { + return [userChoice(hint ?? VERBOSE_LOG_FIX)]; + } + return [ + userChoice( + hint ?? + `Sign in again with ${CLI_NAME} auth login, then retry the command.`, + ), + runCommand(`${CLI_NAME} auth login`), + ]; +} + +/** + * Every bucket Management API failure lands on the one registered code. + * The response's own error code is data, not an identity: it travels in + * `meta.apiCode` beside `meta.status` so a consumer can still branch on + * it without the CLI minting a code it never registered. + */ function bucketApiError( summary: string, response: Response | undefined, error: RawApiErrorBody | undefined, -): CliError { +): CliStructuredError { const status = response?.status ?? 0; - return new CliError({ - code: error?.error?.code ?? "BUCKET_API_ERROR", - domain: "bucket", - summary, - why: - error?.error?.message ?? - `The Management API returned status ${status || "unknown"}.`, - fix: - error?.error?.hint ?? - "Re-run with --trace for the underlying API response details.", - exitCode: 1, - nextSteps: [], + const meta = apiErrorMeta(status, error?.error?.code); + + return new CliStructuredError("BUCKET.API_ERROR", summary, { + why: apiErrorWhy(status, error?.error?.message), + ...(meta === undefined ? {} : { meta }), + nextActions: apiErrorActions(status, error?.error?.hint), }); } diff --git a/packages/cli/src/lib/database/provider.ts b/packages/cli/src/lib/database/provider.ts index 65cf7e72..b87d6d54 100644 --- a/packages/cli/src/lib/database/provider.ts +++ b/packages/cli/src/lib/database/provider.ts @@ -1,8 +1,11 @@ // biome-ignore-all lint/performance/noAwaitInLoops: Database pagination requests must run sequentially. +import { + CliStructuredError, + type NextAction, +} from "@prisma/cli-engine/protocol"; import type { ManagementApiClient, paths } from "@prisma/management-api-sdk"; -import { formatPrismaCliCommand } from "../../cli-command"; -import { CliError } from "../../errors"; +import { CLI_NAME } from "../../cli-name"; import type { DatabaseConnectionSummary, DatabaseSummary, @@ -10,7 +13,6 @@ import type { DatabaseUsageMetrics, DatabaseUsagePeriod, } from "../../types/database"; -import type { PrismaCliPackageCommandFormatter } from "../agent/cli-command"; export interface DatabaseCreateInput { projectId: string; @@ -208,15 +210,23 @@ interface RawDatabaseRecord { connections?: RawDatabaseConnectionRecord[] | null; } +const VERBOSE_LOG_FIX = + "Re-run with --log-level verbose for the underlying API response details."; + +function userChoice(label: string): NextAction { + return { kind: "user-choice", label }; +} + +function runCommand(command: string): NextAction { + return { kind: "run-command", label: command, command }; +} + export function createManagementDatabaseProvider( client: ManagementApiClient, options?: { - formatCommand?: PrismaCliPackageCommandFormatter; workspaceId?: string; }, ): DatabaseProvider { - const formatCommand = - options?.formatCommand ?? ((args) => formatPrismaCliCommand(args)); const toDatabaseApiError = ( summary: string, response: Response | undefined, @@ -487,11 +497,7 @@ export function createManagementDatabaseProvider( result.response?.status === 409 && !isPlanLimitApiError(result.error) ) { - throw restoreConflictError( - options.targetDatabaseId, - result.error, - formatCommand, - ); + throw restoreConflictError(options.targetDatabaseId, result.error); } // Target and source databases are resolved before this call, so a 404 // here identifies the backup. @@ -499,7 +505,7 @@ export function createManagementDatabaseProvider( result.response?.status === 404 && !isPlanLimitApiError(result.error) ) { - throw restoreBackupNotFoundError(options, result.error, formatCommand); + throw restoreBackupNotFoundError(options, result.error); } if (result.error || !result.data) { throw await toDatabaseApiError( @@ -579,15 +585,19 @@ export function normalizeCreatedDatabase( ): DatabaseCreateRecord { const rawConnection = database.connections?.[0]; if (!rawConnection) { - throw new CliError({ - code: "DATABASE_CONNECTION_MISSING", - domain: "database", - summary: "Created database did not return a connection string", - why: "The Management API created the database but did not include the one-time connection payload.", - fix: "Create a connection explicitly with prisma database connection create .", - exitCode: 1, - nextSteps: [`prisma database connection create ${database.id}`], - }); + throw new CliStructuredError( + "POSTGRES.CONNECTION_MISSING", + "Created database did not return a connection string", + { + why: "The Management API created the database but did not include the one-time connection payload.", + nextActions: [ + userChoice( + `Create a connection explicitly with ${CLI_NAME} postgres connection create .`, + ), + runCommand(`${CLI_NAME} postgres connection create ${database.id}`), + ], + }, + ); } return { @@ -602,15 +612,21 @@ export function normalizeCreatedConnection( ): DatabaseConnectionCreateRecord { const connectionString = extractConnectionString(connection); if (!connectionString) { - throw new CliError({ - code: "DATABASE_CONNECTION_STRING_MISSING", - domain: "database", - summary: "Created connection did not return a connection string", - why: "Database connection strings are one-time-view secrets, but the Management API did not include one in this create response.", - fix: "Create another database connection and store the returned URL immediately.", - exitCode: 1, - nextSteps: [`prisma database connection create ${fallbackDatabaseId}`], - }); + throw new CliStructuredError( + "POSTGRES.CONNECTION_STRING_MISSING", + "Created connection did not return a connection string", + { + why: "Database connection strings are one-time-view secrets, but the Management API did not include one in this create response.", + nextActions: [ + userChoice( + "Create another database connection and store the returned URL immediately.", + ), + runCommand( + `${CLI_NAME} postgres connection create ${fallbackDatabaseId}`, + ), + ], + }, + ); } return { @@ -635,15 +651,14 @@ function requireDatabaseProjectId( return projectId; } - throw new CliError({ - code: "DATABASE_API_ERROR", - domain: "database", - summary: "Database response did not include a project id", - why: "The Management API returned database metadata without project context.", - fix: "Re-run with --trace for the underlying API response details.", - exitCode: 1, - nextSteps: [], - }); + throw new CliStructuredError( + "POSTGRES.API_ERROR", + "Database response did not include a project id", + { + why: "The Management API returned database metadata without project context.", + nextActions: [userChoice(VERBOSE_LOG_FIX)], + }, + ); } function extractConnectionString( @@ -709,15 +724,18 @@ export function normalizeRotatedConnection( ): DatabaseConnectionRotateRecord { const connectionString = extractConnectionString(connection); if (!connectionString) { - throw new CliError({ - code: "DATABASE_CONNECTION_STRING_MISSING", - domain: "database", - summary: "Rotated connection did not return a connection string", - why: "Rotated connection strings are one-time-view secrets, but the Management API did not include one in this rotate response.", - fix: "Re-run the rotation, or create a replacement connection and store the returned URL immediately.", - exitCode: 1, - nextSteps: [], - }); + throw new CliStructuredError( + "POSTGRES.CONNECTION_STRING_MISSING", + "Rotated connection did not return a connection string", + { + why: "Rotated connection strings are one-time-view secrets, but the Management API did not include one in this rotate response.", + nextActions: [ + userChoice( + "Re-run the rotation, or create a replacement connection and store the returned URL immediately.", + ), + ], + }, + ); } const database = @@ -738,62 +756,115 @@ export function normalizeRotatedConnection( function backupsUnsupportedError( databaseId: string, error: RawApiErrorBody | undefined, -): CliError { - return new CliError({ - code: "DATABASE_BACKUPS_UNSUPPORTED", - domain: "database", - summary: "Backups are not available for this database", - why: - error?.error?.message ?? - `The platform does not manage backups for database "${databaseId}", for example because it is a remote/BYO database.`, - fix: "Use your own backup tooling for externally managed databases.", - exitCode: 1, - nextSteps: [], - }); +): CliStructuredError { + return new CliStructuredError( + "POSTGRES.BACKUPS_UNSUPPORTED", + "Backups are not available for this database", + { + why: + error?.error?.message ?? + `The platform does not manage backups for database "${databaseId}", for example because it is a remote/BYO database.`, + nextActions: [ + userChoice( + "Use your own backup tooling for externally managed databases.", + ), + ], + }, + ); } function restoreBackupNotFoundError( options: { backupId: string; sourceDatabaseId: string }, error: RawApiErrorBody | undefined, - formatCommand: PrismaCliPackageCommandFormatter, -): CliError { - const listCommand = formatCommand([ - "database", - "backup", - "list", - options.sourceDatabaseId, - ]); - return new CliError({ - code: "DATABASE_BACKUP_NOT_FOUND", - domain: "database", - summary: "Database backup not found", - why: - error?.error?.message ?? - `No backup matched "${options.backupId}" for database "${options.sourceDatabaseId}".`, - fix: `Pass a backup id from ${listCommand}.`, - exitCode: 1, - nextSteps: [listCommand], - }); +): CliStructuredError { + const listCommand = `${CLI_NAME} postgres backup list ${options.sourceDatabaseId}`; + return new CliStructuredError( + "POSTGRES.BACKUP_NOT_FOUND", + "Database backup not found", + { + why: + error?.error?.message ?? + `No backup matched "${options.backupId}" for database "${options.sourceDatabaseId}".`, + nextActions: [ + userChoice(`Pass a backup id from ${listCommand}.`), + runCommand(listCommand), + ], + }, + ); } function restoreConflictError( targetDatabaseId: string, error: RawApiErrorBody | undefined, - formatCommand: PrismaCliPackageCommandFormatter, -): CliError { - return new CliError({ - code: "DATABASE_RESTORE_CONFLICT", - domain: "database", - summary: "Database cannot be restored right now", - why: - error?.error?.message ?? - `Database "${targetDatabaseId}" is provisioning or already recovering.`, - fix: "Wait for the database to become ready, then retry the restore.", - exitCode: 1, - nextSteps: [formatCommand(["database", "show", targetDatabaseId])], - }); +): CliStructuredError { + return new CliStructuredError( + "POSTGRES.RESTORE_CONFLICT", + "Database cannot be restored right now", + { + why: + error?.error?.message ?? + `Database "${targetDatabaseId}" is provisioning or already recovering.`, + nextActions: [ + userChoice( + "Wait for the database to become ready, then retry the restore.", + ), + runCommand(`${CLI_NAME} postgres show ${targetDatabaseId}`), + ], + }, + ); } +/** A 401 or 403 is the API refusing the caller, not a database problem. */ +function isRejectedCaller(status: number): boolean { + return status === 401 || status === 403; +} + +function apiErrorWhy(status: number, message: string | undefined): string { + if (!isRejectedCaller(status)) { + return ( + message ?? `The Management API returned status ${status || "unknown"}.` + ); + } + const rejection = `The Management API rejected the request as ${status === 401 ? "unauthorized" : "forbidden"}.`; + return message ? `${rejection} ${message}` : rejection; +} + +function apiErrorMeta( + status: number, + apiCode: string | undefined, +): Record | undefined { + if (!status && apiCode === undefined) { + return undefined; + } + return { + ...(status ? { status } : {}), + ...(apiCode === undefined ? {} : { apiCode }), + }; +} + +function apiErrorActions( + status: number, + hint: string | undefined, +): NextAction[] { + if (!isRejectedCaller(status)) { + return [userChoice(hint ?? VERBOSE_LOG_FIX)]; + } + return [ + userChoice( + hint ?? + `Sign in again with ${CLI_NAME} auth login, then retry the command.`, + ), + runCommand(`${CLI_NAME} auth login`), + ]; +} + +/** + * Every database Management API failure that is not a plan limit lands + * on the one registered code. The response's own error code is data, not + * an identity: it travels in `meta.apiCode` beside `meta.status` so a + * consumer can still branch on it without the CLI minting a code it + * never registered. + */ async function databaseApiError(options: { client: ManagementApiClient; workspaceId?: string; @@ -801,24 +872,18 @@ async function databaseApiError(options: { response: Response | undefined; error: RawApiErrorBody | undefined; signal?: AbortSignal; -}): Promise { +}): Promise { if (isPlanLimitApiError(options.error)) { return planLimitReachedError(options); } const status = options.response?.status ?? 0; - return new CliError({ - code: options.error?.error?.code ?? "DATABASE_API_ERROR", - domain: "database", - summary: options.summary, - why: - options.error?.error?.message ?? - `The Management API returned status ${status || "unknown"}.`, - fix: - options.error?.error?.hint ?? - "Re-run with --trace for the underlying API response details.", - exitCode: 1, - nextSteps: [], + const meta = apiErrorMeta(status, options.error?.error?.code); + + return new CliStructuredError("POSTGRES.API_ERROR", options.summary, { + why: apiErrorWhy(status, options.error?.error?.message), + ...(meta === undefined ? {} : { meta }), + nextActions: apiErrorActions(status, options.error?.error?.hint), }); } @@ -826,7 +891,7 @@ async function planLimitReachedError(options: { client: ManagementApiClient; workspaceId?: string; signal?: AbortSignal; -}): Promise { +}): Promise { const subscription = options.workspaceId ? await readWorkspaceSubscription( options.client, @@ -834,45 +899,33 @@ async function planLimitReachedError(options: { options.signal, ) : null; - const workspaceLine = options.workspaceId - ? `Workspace: ${options.workspaceId}` - : "Workspace: unavailable"; const planName = subscription?.planName || null; const usageBlocked = subscription?.usageBlocked ?? null; const upgradeUrl = subscription?.upgradeUrl || null; - const recoveryLines = [ - ...(planName ? [`Current plan: ${planName}`] : []), - upgradeUrl - ? `Upgrade: ${upgradeUrl}` - : "Upgrade: Open Prisma Console and upgrade the affected workspace plan.", - ]; - return new CliError({ - code: "PLAN_LIMIT_REACHED", - domain: "database", - summary: "Workspace plan limit reached", - why: "Database operations are blocked because this workspace has used the operations included in its plan. This is a workspace plan limit, not a Prisma outage.", - fix: upgradeUrl - ? `Upgrade the workspace plan at ${upgradeUrl}.` - : "Open Prisma Console and upgrade the affected workspace plan.", - meta: { - workspaceId: options.workspaceId ?? null, - blockedFeature: null, - planName, - usageBlocked, - upgradeUrl, + return new CliStructuredError( + "POSTGRES.PLAN_LIMIT_REACHED", + "Workspace plan limit reached", + { + why: "Database operations are blocked because this workspace has used the operations included in its plan. This is a workspace plan limit, not a Prisma outage.", + meta: { + workspaceId: options.workspaceId ?? null, + blockedFeature: null, + planName, + usageBlocked, + upgradeUrl, + }, + nextActions: [ + { + kind: "user-choice", + label: "Upgrade the workspace plan", + reason: upgradeUrl + ? `Upgrade at ${upgradeUrl}${planName ? ` (current plan: ${planName})` : ""}.` + : "Open Prisma Console and upgrade the affected workspace plan.", + }, + ], }, - exitCode: 1, - nextSteps: [], - humanLines: [ - "Workspace plan limit reached [PLAN_LIMIT_REACHED]", - "", - "Database operations are blocked because this workspace has used the operations included in its plan. This is a workspace plan limit, not a Prisma outage.", - "", - workspaceLine, - ...recoveryLines, - ], - }); + ); } function isPlanLimitApiError(error: RawApiErrorBody | undefined): boolean { diff --git a/packages/cli/src/lib/project/provider.ts b/packages/cli/src/lib/project/provider.ts index 44e71b76..1f3740d2 100644 --- a/packages/cli/src/lib/project/provider.ts +++ b/packages/cli/src/lib/project/provider.ts @@ -1,7 +1,9 @@ +import { + CliStructuredError, + type NextAction, +} from "@prisma/cli-engine/protocol"; import type { ManagementApiClient } from "@prisma/management-api-sdk"; - import { formatPrismaCliCommand } from "../../cli-command"; -import { CliError } from "../../errors"; import type { ProjectSummary } from "../../types/project"; export interface ProjectProvider { @@ -112,76 +114,104 @@ export function createManagementProjectProvider( }; } +function userChoice(label: string): NextAction { + return { kind: "user-choice", label }; +} + export function projectRenameFailedError( name: string, error: RawApiErrorBody | undefined, -): CliError { - return new CliError({ - code: "PROJECT_RENAME_FAILED", - domain: "project", - summary: "Project rename failed", - why: error?.error?.message ?? `The platform rejected the name "${name}".`, - fix: - error?.error?.hint ?? - "Pass a different project name and retry the rename.", - exitCode: 1, - nextSteps: [], - }); +): CliStructuredError { + return new CliStructuredError( + "PROJECT.RENAME_FAILED", + "Project rename failed", + { + why: error?.error?.message ?? `The platform rejected the name "${name}".`, + nextActions: [ + userChoice( + error?.error?.hint ?? + "Pass a different project name and retry the rename.", + ), + ], + }, + ); } export function projectDeleteBlockedError( projectId: string, error: RawApiErrorBody | undefined, -): CliError { - return new CliError({ - code: "PROJECT_DELETE_BLOCKED", - domain: "project", - summary: "Project cannot be deleted yet", - why: - error?.error?.message ?? - `Project "${projectId}" still has active deployments.`, - fix: "Delete the project's services first, then retry the deletion.", - exitCode: 1, - nextSteps: [ - formatPrismaCliCommand(["service", "delete", "--service", ""]), - ], - }); +): CliStructuredError { + const deleteServicesCommand = formatPrismaCliCommand([ + "service", + "delete", + "--service", + "", + ]); + return new CliStructuredError( + "PROJECT.DELETE_BLOCKED", + "Project cannot be deleted yet", + { + why: + error?.error?.message ?? + `Project "${projectId}" still has active deployments.`, + nextActions: [ + userChoice( + "Delete the project's services first, then retry the deletion.", + ), + { + kind: "run-command", + label: deleteServicesCommand, + command: deleteServicesCommand, + }, + ], + }, + ); } export function projectTransferRejectedError( projectId: string, error: RawApiErrorBody | undefined, -): CliError { - return new CliError({ - code: "PROJECT_TRANSFER_REJECTED", - domain: "project", - summary: "Project transfer was rejected", - why: - error?.error?.message ?? - `The platform rejected the transfer of project "${projectId}", for example because the recipient token is invalid or expired.`, - fix: "Check the recipient workspace session or token and retry the transfer.", - exitCode: 1, - nextSteps: [], - }); +): CliStructuredError { + return new CliStructuredError( + "PROJECT.TRANSFER_REJECTED", + "Project transfer was rejected", + { + why: + error?.error?.message ?? + `The platform rejected the transfer of project "${projectId}", for example because the recipient token is invalid or expired.`, + nextActions: [ + userChoice( + "Check the recipient workspace session or token and retry the transfer.", + ), + ], + }, + ); } export function projectApiError( summary: string, response: Response | undefined, error: RawApiErrorBody | undefined, -): CliError { +): CliStructuredError { const status = response?.status ?? 0; - return new CliError({ - code: error?.error?.code ?? "PROJECT_API_ERROR", - domain: "project", - summary, + const apiCode = error?.error?.code; + return new CliStructuredError("PROJECT.API_ERROR", summary, { why: error?.error?.message ?? `The Management API returned status ${status || "unknown"}.`, - fix: - error?.error?.hint ?? - "Re-run with --trace for the underlying API response details.", - exitCode: 1, - nextSteps: [], + ...(apiCode !== undefined || status + ? { + meta: { + ...(status ? { status } : {}), + ...(apiCode !== undefined ? { apiCode } : {}), + }, + } + : {}), + nextActions: [ + userChoice( + error?.error?.hint ?? + "Re-run with --log-level verbose for the underlying API response details.", + ), + ], }); } diff --git a/packages/cli/src/lib/project/resolution.ts b/packages/cli/src/lib/project/resolution.ts index ac428c62..a63860f3 100644 --- a/packages/cli/src/lib/project/resolution.ts +++ b/packages/cli/src/lib/project/resolution.ts @@ -1,16 +1,16 @@ import { readFile } from "node:fs/promises"; import path from "node:path"; - +import { + CliStructuredError, + type NextAction as EngineNextAction, +} from "@prisma/cli-engine/protocol"; import { matchError, Result, TaggedError, type UnhandledException, } from "better-result"; - import { formatCommandArgument } from "../../command-arguments"; -import { CliError } from "../../errors"; -import type { NextAction } from "../../next-actions"; import type { AuthWorkspace } from "../../types/auth"; import type { BoundProjectShowResult, @@ -218,130 +218,153 @@ export async function inspectProjectBinding( }); } +function runCommand(command: string, reason?: string): EngineNextAction { + return { + kind: "run-command", + label: command, + command, + ...(reason === undefined ? {} : { reason }), + }; +} + +function userChoice(label: string): EngineNextAction { + return { kind: "user-choice", label }; +} + export function projectNotFoundError( projectRef: string, workspace: AuthWorkspace, -): CliError { - return projectResolutionErrorToCliError( +): CliStructuredError { + return projectResolutionErrorToStructured( new ProjectNotFoundError(projectRef, workspace), ); } -function projectNotFoundCliError( +function projectNotFoundStructuredError( projectRef: string, workspace: AuthWorkspace, -): CliError { - return new CliError({ - code: "PROJECT_NOT_FOUND", - domain: "project", - summary: "Project not found", +): CliStructuredError { + return new CliStructuredError("PROJECT.NOT_FOUND", "Project not found", { why: `The project "${projectRef}" does not exist in workspace "${workspace.name}" or is not accessible.`, - fix: "Pass a project id or name from prisma project list.", - exitCode: 1, - nextSteps: ["prisma project list"], + nextActions: [ + userChoice("Pass a project id or name from prisma project list."), + runCommand("prisma project list"), + ], }); } export function projectAmbiguousError( projectRef: string | null, matches: ProjectCandidate[], -): CliError { - return projectResolutionErrorToCliError( +): CliStructuredError { + return projectResolutionErrorToStructured( new ProjectAmbiguousError(projectRef, matches), ); } -function projectAmbiguousCliError( +function projectAmbiguousStructuredError( projectRef: string | null, matches: ProjectCandidate[], -): CliError { +): CliStructuredError { const firstMatch = matches[0]; - const nextSteps = ["prisma project list"]; + const nextActions = [ + userChoice("Pass --project to choose the project explicitly."), + runCommand("prisma project list"), + ]; if (firstMatch) { // Surface the matched id verbatim so the user can copy the exact // shape of a disambiguating reference instead of guessing. - nextSteps.push(`prisma project link ${firstMatch.id}`); + nextActions.push(runCommand(`prisma project link ${firstMatch.id}`)); } - return new CliError({ - code: "PROJECT_AMBIGUOUS", - domain: "project", - summary: "Project resolution is ambiguous", - why: projectRef - ? `Multiple projects matched "${projectRef}".` - : "Multiple projects matched the current directory context.", - fix: "Pass --project to choose the project explicitly.", - meta: { - matches: matches.map((project) => ({ - id: project.id, - name: project.name, - })), + return new CliStructuredError( + "PROJECT.AMBIGUOUS", + "Project resolution is ambiguous", + { + why: projectRef + ? `Multiple projects matched "${projectRef}".` + : "Multiple projects matched the current directory context.", + meta: { + matches: matches.map((project) => ({ + id: project.id, + name: project.name, + })), + }, + nextActions, }, - exitCode: 1, - nextSteps, - }); + ); } -function localStateStaleCliError(): CliError { - return new CliError({ - code: "LOCAL_STATE_STALE", - domain: "project", - summary: "Local project binding is stale", - why: `The target recorded in ${LOCAL_RESOLUTION_PIN_RELATIVE_PATH} is no longer available in the selected workspace.`, - fix: `Delete ${LOCAL_RESOLUTION_PIN_RELATIVE_PATH}, then choose a Project explicitly.`, - meta: { - pinPath: LOCAL_RESOLUTION_PIN_RELATIVE_PATH, +function localStateStaleStructuredError(): CliStructuredError { + return new CliStructuredError( + "PROJECT.LOCAL_STATE_STALE", + "Local project binding is stale", + { + why: `The target recorded in ${LOCAL_RESOLUTION_PIN_RELATIVE_PATH} is no longer available in the selected workspace.`, + meta: { + pinPath: LOCAL_RESOLUTION_PIN_RELATIVE_PATH, + }, + nextActions: [ + userChoice( + `Delete ${LOCAL_RESOLUTION_PIN_RELATIVE_PATH}, then choose a Project explicitly.`, + ), + runCommand("prisma project list"), + runCommand("prisma project link "), + ], }, - exitCode: 1, - nextSteps: ["prisma project list", "prisma project link "], - }); + ); } -function localProjectWorkspaceMismatchCliError(options: { +function localProjectWorkspaceMismatchStructuredError(options: { pinnedWorkspaceId: string; pinnedProjectId: string; activeWorkspace: AuthWorkspace; -}): CliError { - return new CliError({ - code: "LOCAL_PROJECT_WORKSPACE_MISMATCH", - domain: "project", - summary: "Project link uses another workspace", - why: `${LOCAL_RESOLUTION_PIN_RELATIVE_PATH} links this directory to project ${options.pinnedProjectId} in workspace ${options.pinnedWorkspaceId}, but your current CLI session is workspace "${options.activeWorkspace.name}" (${options.activeWorkspace.id}).`, - fix: "Switch to the linked workspace, or relink this directory to a project in the current workspace.", - meta: { - pinPath: LOCAL_RESOLUTION_PIN_RELATIVE_PATH, - pinnedWorkspaceId: options.pinnedWorkspaceId, - pinnedProjectId: options.pinnedProjectId, - activeWorkspaceId: options.activeWorkspace.id, - activeWorkspaceName: options.activeWorkspace.name, +}): CliStructuredError { + return new CliStructuredError( + "PROJECT.LOCAL_WORKSPACE_MISMATCH", + "Project link uses another workspace", + { + why: `${LOCAL_RESOLUTION_PIN_RELATIVE_PATH} links this directory to project ${options.pinnedProjectId} in workspace ${options.pinnedWorkspaceId}, but your current CLI session is workspace "${options.activeWorkspace.name}" (${options.activeWorkspace.id}).`, + meta: { + pinPath: LOCAL_RESOLUTION_PIN_RELATIVE_PATH, + pinnedWorkspaceId: options.pinnedWorkspaceId, + pinnedProjectId: options.pinnedProjectId, + activeWorkspaceId: options.activeWorkspace.id, + activeWorkspaceName: options.activeWorkspace.name, + }, + nextActions: [ + userChoice( + "Switch to the linked workspace, or relink this directory to a project in the current workspace.", + ), + runCommand(`prisma auth workspace use ${options.pinnedWorkspaceId}`), + runCommand("prisma project list"), + runCommand("prisma project link "), + ], }, - exitCode: 1, - nextSteps: [ - `prisma auth workspace use ${options.pinnedWorkspaceId}`, - "prisma project list", - "prisma project link ", - ], - }); + ); } /** - * Converts expected project-resolution variants to command-boundary CliErrors. + * Converts expected project-resolution variants to the structured errors + * a command boundary raises — the codes here are the registered PROJECT.* + * codes, assigned at origin. * `LocalResolutionPinReadAbortedError` and `UnhandledException` intentionally * propagate as exceptions; callers such as `resolveProjectShowInRealMode` * throw this helper's result, so passthrough variants should keep bubbling. */ -export function projectResolutionErrorToCliError( +export function projectResolutionErrorToStructured( error: ProjectResolutionError, -): CliError { +): CliStructuredError { return matchError(error, { ProjectNotFoundError: (error) => - projectNotFoundCliError(error.projectRef, error.workspace), + projectNotFoundStructuredError(error.projectRef, error.workspace), ProjectAmbiguousError: (error) => - projectAmbiguousCliError(error.projectRef, error.matches), - ProjectSetupRequiredError: (error) => projectSetupRequiredCliError(error), - LocalStateStaleError: () => localStateStaleCliError(), + projectAmbiguousStructuredError(error.projectRef, error.matches), + ProjectSetupRequiredError: (error) => + projectSetupRequiredStructuredError(error), + LocalStateStaleError: () => localStateStaleStructuredError(), LocalProjectWorkspaceMismatchError: (error) => - localProjectWorkspaceMismatchCliError({ + localProjectWorkspaceMismatchStructuredError({ pinnedWorkspaceId: error.pinnedWorkspaceId, pinnedProjectId: error.pinnedProjectId, activeWorkspace: error.activeWorkspace, @@ -389,24 +412,22 @@ export async function projectSetupRequiredError(options: { }); } -function projectSetupRequiredCliError( +function projectSetupRequiredStructuredError( error: ProjectSetupRequiredError, -): CliError { +): CliStructuredError { const suggestion = error.suggestion; - return new CliError({ - code: "PROJECT_SETUP_REQUIRED", - domain: "project", - summary: "Choose a Project before running this command", - why: error.message, - fix: "Link the directory to an existing Project, or pass --project for this command.", - meta: { ...suggestion }, - exitCode: 1, - nextSteps: ["prisma project list", ...suggestion.recoveryCommands], - nextActions: buildProjectSetupNextActions({ - commandName: error.commandName, - suggestedProjectName: suggestion.suggestedProjectName, - }), - }); + return new CliStructuredError( + "PROJECT.SETUP_REQUIRED", + "Choose a Project before running this command", + { + why: error.message, + meta: { ...suggestion }, + nextActions: buildProjectSetupNextActions({ + commandName: error.commandName, + suggestedProjectName: suggestion.suggestedProjectName, + }), + }, + ); } export function buildProjectSetupNextActions( @@ -419,7 +440,7 @@ export function buildProjectSetupNextActions( * generic `--project ` template does not fit. */ retryCommand?: string; } = {}, -): NextAction[] { +): EngineNextAction[] { const recoveryCommands = buildProjectRecoveryCommands(options.commandName); const linkCommand = recoveryCommands[0] ?? "prisma project link "; const retryCommand = options.retryCommand ?? recoveryCommands[1]; @@ -429,10 +450,9 @@ export function buildProjectSetupNextActions( ...(retryCommand ? [retryCommand] : []), ]; - const actions: NextAction[] = [ + const actions: EngineNextAction[] = [ { kind: "user-choice", - journey: "project-setup", label: "Ask the user whether to link an existing Project or create a new one", commands, @@ -442,7 +462,6 @@ export function buildProjectSetupNextActions( }, { kind: "run-command", - journey: "project-setup", label: "Link the chosen Project", command: linkCommand, reason: @@ -458,7 +477,6 @@ export function buildProjectSetupNextActions( if (createCommand) { actions.push({ kind: "run-command", - journey: "project-setup", label: "Create and link a new Project", command: createCommand, reason: @@ -469,7 +487,6 @@ export function buildProjectSetupNextActions( if (options.commandName) { actions.push({ kind: "run-command", - journey: "recover", label: "Retry with an explicit Project", command: retryCommand ?? `prisma ${options.commandName} --project `, diff --git a/packages/cli/src/lib/project/setup.ts b/packages/cli/src/lib/project/setup.ts index 7a439604..e5912e96 100644 --- a/packages/cli/src/lib/project/setup.ts +++ b/packages/cli/src/lib/project/setup.ts @@ -1,5 +1,8 @@ +import { + CliStructuredError, + type NextAction, +} from "@prisma/cli-engine/protocol"; import { matchError } from "better-result"; -import { CliError, usageError } from "../../errors"; import type { AuthWorkspace } from "../../types/auth"; import type { ProjectSummary } from "../../types/project"; import { @@ -39,10 +42,9 @@ export function resolveProjectForSetup( throw projectNotFoundError(projectRef, workspace); } -export function projectDirectoryBindingErrorToCliError( +export function projectDirectoryBindingErrorToStructured( error: ProjectDirectoryBindingError, -): CliError { - // Temporary during the migration to better-result: remove when command boundaries convert Result errors directly. +): CliStructuredError { return matchError(error, { LocalResolutionPinSerializationError: (error) => { throw error; @@ -75,18 +77,28 @@ export function projectDirectoryBindingErrorToCliError( function localStateWriteFailedError( error: ProjectDirectoryBindingError, options: { why: string; meta: Record }, -): CliError { - return new CliError({ - code: "LOCAL_STATE_WRITE_FAILED", - domain: "project", - summary: "Could not save local Project binding", - why: options.why, - fix: "Check that this directory is writable and that .prisma/local.json and .gitignore are not blocked by directories or permissions, then retry.", - debug: formatDebugDetails(error.cause), - meta: options.meta, - exitCode: 1, - nextSteps: ["prisma project link "], - }); +): CliStructuredError { + return new CliStructuredError( + "PROJECT.LOCAL_STATE_WRITE_FAILED", + "Could not save local Project binding", + { + why: options.why, + meta: options.meta, + cause: error.cause, + nextActions: [ + { + kind: "user-choice", + label: + "Check that this directory is writable and that .prisma/local.json and .gitignore are not blocked by directories or permissions, then retry.", + }, + { + kind: "run-command", + label: "prisma project link ", + command: "prisma project link ", + }, + ], + }, + ); } export function toProjectSummary( @@ -102,13 +114,20 @@ export function toProjectSummary( }; } -export function projectSetupNameRequiredError(command: string): CliError { - return usageError( +export function projectSetupNameRequiredError( + command: string, +): CliStructuredError { + const example = `prisma ${command} my-app`; + return new CliStructuredError( + "PROJECT.USAGE_ERROR", "Project create requires a name", - "The project name must be a non-empty value.", - "Pass a Project name explicitly.", - [`prisma ${command} my-app`], - "project", + { + why: "The project name must be a non-empty value.", + nextActions: [ + { kind: "user-choice", label: "Pass a Project name explicitly." }, + { kind: "run-command", label: example, command: example }, + ], + }, ); } @@ -121,32 +140,34 @@ export function projectCreateFailedError( permissionFix: string; fallbackFix: string; }, -): CliError { +): CliStructuredError { const status = extractHttpStatus(error); + const permissionRejection = status === 401 || status === 403; + const message = error instanceof Error ? error.message : String(error); - if (status === 401 || status === 403) { - return new CliError({ - code: "PROJECT_CREATE_FAILED", - domain: "project", - summary: `Could not create Project "${projectName}"`, - why: `The platform rejected the Project create in workspace "${workspace.name}" (HTTP ${status}).`, - fix: options.permissionFix, - debug: formatDebugDetails(error), - exitCode: 1, - nextSteps: options.nextSteps, - }); - } - - return new CliError({ - code: "PROJECT_CREATE_FAILED", - domain: "project", - summary: `Could not create Project "${projectName}"`, - why: error instanceof Error ? error.message : String(error), - fix: options.fallbackFix, - debug: formatDebugDetails(error), - exitCode: 1, - nextSteps: options.nextSteps, - }); + const nextActions: NextAction[] = [ + { + kind: "user-choice", + label: permissionRejection ? options.permissionFix : options.fallbackFix, + }, + ...options.nextSteps.map((step) => ({ + kind: "run-command" as const, + label: step, + command: step, + })), + ]; + + return new CliStructuredError( + "PROJECT.CREATE_FAILED", + `Could not create Project "${projectName}"`, + { + why: permissionRejection + ? `The platform rejected the Project create in workspace "${workspace.name}" (HTTP ${status}).` + : message, + cause: error, + nextActions, + }, + ); } const HTTP_STATUS_IN_MESSAGE = /\(HTTP (\d{3})\)/; @@ -177,11 +198,3 @@ function extractHttpStatus(error: unknown): number | null { return null; } - -function formatDebugDetails(error: unknown): string | null { - if (error instanceof Error) { - return error.stack ?? error.message; - } - - return typeof error === "string" ? error : null; -} diff --git a/packages/cli/src/next-actions.ts b/packages/cli/src/next-actions.ts deleted file mode 100644 index 7b1a9b1f..00000000 --- a/packages/cli/src/next-actions.ts +++ /dev/null @@ -1,20 +0,0 @@ -export type NextActionKind = - | "run-command" - | "user-choice" - | "edit-file" - | "done"; - -export type NextActionJourney = - | "project-setup" - | "deploy-app" - | "inspect" - | "recover"; - -export interface NextAction { - kind: NextActionKind; - journey: NextActionJourney; - label: string; - command?: string; - commands?: string[]; - reason?: string; -} diff --git a/packages/cli/tests/branch.test.ts b/packages/cli/tests/branch.test.ts index 428b7300..38d4abae 100644 --- a/packages/cli/tests/branch.test.ts +++ b/packages/cli/tests/branch.test.ts @@ -218,7 +218,7 @@ describe("prisma branch list", () => { expect(result.presented?.presentation.stdout).toEqual([]); }); - it("maps an API failure to the passthrough code", async () => { + it("reports an API failure as BRANCH.API_ERROR carrying the API code in meta", async () => { const result = await makeCli( branchClient({ routes: { @@ -234,9 +234,10 @@ describe("prisma branch list", () => { expect(resultFrame(result.json).envelope).toMatchObject({ ok: false, error: { - code: "BRANCH.internalError", + code: "BRANCH.API_ERROR", summary: "Failed to list branches", why: "Backend exploded.", + meta: { status: 500, apiCode: "internalError" }, nextActions: [ { kind: "user-choice", diff --git a/packages/cli/tests/bucket.test.ts b/packages/cli/tests/bucket.test.ts index f77e84d0..e75f4411 100644 --- a/packages/cli/tests/bucket.test.ts +++ b/packages/cli/tests/bucket.test.ts @@ -361,7 +361,7 @@ describe("prisma bucket create", () => { ]); }); - it("maps an API failure to the passthrough code", async () => { + it("reports an API failure as BUCKET.API_ERROR with the API code in meta", async () => { const result = await makeCli( bucketClient({ routes: { @@ -377,9 +377,10 @@ describe("prisma bucket create", () => { expect(resultFrame(result.json).envelope).toMatchObject({ ok: false, error: { - code: "BUCKET.internalError", + code: "BUCKET.API_ERROR", summary: "Failed to create bucket", why: "Backend exploded.", + meta: { status: 500, apiCode: "internalError" }, nextActions: [ { kind: "user-choice", @@ -534,7 +535,7 @@ describe("prisma bucket delete", () => { }); }); - it("maps an API failure to the passthrough code", async () => { + it("reports an API failure as BUCKET.API_ERROR with the API code in meta", async () => { const result = await makeCli( bucketClient({ routes: { @@ -547,7 +548,11 @@ describe("prisma bucket delete", () => { expect(result.exitCode).toBe(2); expect(resultFrame(result.json).envelope).toMatchObject({ ok: false, - error: { code: "BUCKET.notFound", summary: "Failed to delete bucket" }, + error: { + code: "BUCKET.API_ERROR", + summary: "Failed to delete bucket", + meta: { status: 404, apiCode: "notFound" }, + }, }); }); @@ -912,7 +917,7 @@ describe("prisma bucket key delete", () => { }); }); - it("maps an API failure to the passthrough code", async () => { + it("reports an API failure as BUCKET.API_ERROR with the API code in meta", async () => { const result = await makeCli( bucketClient({ routes: { @@ -926,8 +931,9 @@ describe("prisma bucket key delete", () => { expect(resultFrame(result.json).envelope).toMatchObject({ ok: false, error: { - code: "BUCKET.notFound", + code: "BUCKET.API_ERROR", summary: "Failed to delete bucket key", + meta: { status: 404, apiCode: "notFound" }, }, }); }); diff --git a/packages/cli/tests/database-plan-limit.test.ts b/packages/cli/tests/database-plan-limit.test.ts index dfdedab5..743c7253 100644 --- a/packages/cli/tests/database-plan-limit.test.ts +++ b/packages/cli/tests/database-plan-limit.test.ts @@ -1,6 +1,6 @@ +import { CliStructuredError } from "@prisma/cli-engine/protocol"; import type { ManagementApiClient } from "@prisma/management-api-sdk"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { CliError } from "../src/errors"; import { createManagementDatabaseProvider, SUBSCRIPTION_LOOKUP_TIMEOUT_MS, @@ -58,8 +58,10 @@ describe("database plan-limit classification", () => { .showDatabase("db_synthetic") .catch((caught: unknown) => caught); - expect(error).toBeInstanceOf(CliError); - expect((error as CliError).code).toBe("PLAN_LIMIT_REACHED"); + expect(error).toBeInstanceOf(CliStructuredError); + expect((error as CliStructuredError).code).toBe( + "POSTGRES.PLAN_LIMIT_REACHED", + ); expect(client.GET).toHaveBeenCalledTimes(2); }); @@ -95,21 +97,25 @@ describe("database plan-limit classification", () => { }) ).catch((caught: unknown) => caught); - expect(error).toBeInstanceOf(CliError); - expect((error as CliError).code).toBe("PLAN_LIMIT_REACHED"); + expect(error).toBeInstanceOf(CliStructuredError); + expect((error as CliStructuredError).code).toBe( + "POSTGRES.PLAN_LIMIT_REACHED", + ); expect(client.GET).toHaveBeenCalledWith( "/v1/workspaces/{id}/subscription", expect.anything(), ); }); + // Every non-plan-limit API failure now carries the one registered code. + // What stays specific to the response is `meta.apiCode`. it.each([ - ["503", undefined, 503, "DATABASE_API_ERROR"], - ["429", "rateLimitReached", 429, "rateLimitReached"], - ["auth", "AUTH_REQUIRED", 401, "AUTH_REQUIRED"], - ["spend limit", "spendLimitReached", 400, "spendLimitReached"], - ["generic API error", "DATABASE_API_ERROR", 400, "DATABASE_API_ERROR"], - ])("does not classify a %s response as a plan limit", async (_name, code, status, expectedStableCode) => { + ["503", undefined, 503], + ["429", "rateLimitReached", 429], + ["auth", "AUTH_REQUIRED", 401], + ["spend limit", "spendLimitReached", 400], + ["generic API error", "DATABASE_API_ERROR", 400], + ])("does not classify a %s response as a plan limit", async (_name, code, status) => { const client = { GET: vi.fn().mockResolvedValue({ error: { @@ -131,12 +137,49 @@ describe("database plan-limit classification", () => { .showDatabase("db_synthetic") .catch((caught: unknown) => caught); - expect(error).toBeInstanceOf(CliError); - expect((error as CliError).code).toBe(expectedStableCode); - expect((error as CliError).code).not.toBe("PLAN_LIMIT_REACHED"); + expect(error).toBeInstanceOf(CliStructuredError); + expect(error).toMatchObject({ + code: "POSTGRES.API_ERROR", + meta: { status, ...(code ? { apiCode: code } : {}) }, + }); expect(client.GET).toHaveBeenCalledTimes(1); }); + it("offers a sign-in for a rejected request instead of minting an auth code", async () => { + const client = { + GET: vi.fn().mockResolvedValue({ + error: { error: { code: "AUTH_REQUIRED" } }, + response: new Response(null, { status: 403 }), + }), + }; + const provider = createManagementDatabaseProvider( + client as unknown as ManagementApiClient, + { workspaceId }, + ); + + const error = await provider + .showDatabase("db_synthetic") + .catch((caught: unknown) => caught); + + expect(error).toMatchObject({ + code: "POSTGRES.API_ERROR", + why: "The Management API rejected the request as forbidden.", + meta: { status: 403, apiCode: "AUTH_REQUIRED" }, + nextActions: [ + { + kind: "user-choice", + label: + "Sign in again with prisma auth login, then retry the command.", + }, + { + kind: "run-command", + label: "prisma auth login", + command: "prisma auth login", + }, + ], + }); + }); + it("leaves a network timeout outside plan-limit classification", async () => { const timeout = new Error("Synthetic timeout"); const client = { GET: vi.fn().mockRejectedValue(timeout) }; @@ -178,9 +221,9 @@ describe("database plan-limit classification", () => { await vi.advanceTimersByTimeAsync(SUBSCRIPTION_LOOKUP_TIMEOUT_MS); const error = await errorPromise; - expect(error).toBeInstanceOf(CliError); + expect(error).toBeInstanceOf(CliStructuredError); expect(error).toMatchObject({ - code: "PLAN_LIMIT_REACHED", + code: "POSTGRES.PLAN_LIMIT_REACHED", meta: { workspaceId, planName: null, diff --git a/packages/cli/tests/git.test.ts b/packages/cli/tests/git.test.ts index 9b2ef623..9c4d3ad2 100644 --- a/packages/cli/tests/git.test.ts +++ b/packages/cli/tests/git.test.ts @@ -826,7 +826,7 @@ describe("prisma git disconnect", () => { }); }); - it("maps a forbidden delete to GIT.AUTH_REQUIRED without the TTY offer", async () => { + it("reports a forbidden delete as a connection failure carrying the status", async () => { const result = await makeCli( gitClient({ sourceRepositories: [SOURCE_REPOSITORY], @@ -840,11 +840,16 @@ describe("prisma git disconnect", () => { expect(resultFrame(result.json).envelope).toMatchObject({ ok: false, error: { - code: "GIT.AUTH_REQUIRED", - summary: "Authentication required", - why: "This command needs an authenticated session.", + code: "GIT.REPO_CONNECTION_FAILED", + summary: "Failed to disconnect GitHub repository", + why: "The Management API rejected the request as unauthorized (HTTP 403).", + meta: { status: 403 }, nextActions: [ - { kind: "user-choice", label: "Run prisma auth login." }, + { + kind: "user-choice", + label: + "Sign in again with prisma auth login, then rerun the command.", + }, { kind: "run-command", label: "prisma auth login", diff --git a/packages/cli/tests/postgres.test.ts b/packages/cli/tests/postgres.test.ts index 14219cfe..d4aa9b98 100644 --- a/packages/cli/tests/postgres.test.ts +++ b/packages/cli/tests/postgres.test.ts @@ -180,6 +180,10 @@ async function pinnedCwd() { return cwd; } +function unpinnedCwd() { + return mkdtemp(path.join(os.tmpdir(), "postgres-unpinned-")); +} + function resultFrame(frames: ReadonlyArray<{ kind: string }>) { const frame = frames.at(-1); if (frame === undefined || frame.kind !== "result") { @@ -360,7 +364,25 @@ describe("prisma postgres list", () => { }); }); - it("maps an API failure to the passthrough code", async () => { + // The project resolver raises its own PROJECT.* structured error, and + // the postgres commands let it through untouched. + it("reports an unbound directory as PROJECT.SETUP_REQUIRED", async () => { + const result = await makeCli(postgresClient()).run( + ["postgres", "list", "--json"], + { cwd: await unpinnedCwd() }, + ); + + expect(result.exitCode).toBe(2); + expect(resultFrame(result.json).envelope).toMatchObject({ + ok: false, + error: { + code: "PROJECT.SETUP_REQUIRED", + summary: "Choose a Project before running this command", + }, + }); + }); + + it("reports an API failure as POSTGRES.API_ERROR with the API code in meta", async () => { const result = await makeCli( postgresClient({ routes: { @@ -376,9 +398,10 @@ describe("prisma postgres list", () => { expect(resultFrame(result.json).envelope).toMatchObject({ ok: false, error: { - code: "POSTGRES.internalError", + code: "POSTGRES.API_ERROR", summary: "Failed to list databases", why: "Backend exploded.", + meta: { status: 500, apiCode: "internalError" }, nextActions: [ { kind: "user-choice", @@ -2373,7 +2396,7 @@ describe("prisma postgres connection rotate", () => { }); }); - it("passes an unknown connection through as an API error", async () => { + it("reports an unknown connection as POSTGRES.API_ERROR", async () => { const result = await makeCli( postgresClient({ routes: { @@ -2400,9 +2423,10 @@ describe("prisma postgres connection rotate", () => { expect(resultFrame(result.json).envelope).toMatchObject({ ok: false, error: { - code: "POSTGRES.notFound", + code: "POSTGRES.API_ERROR", summary: "Failed to rotate database connection", why: "Connection not found.", + meta: { status: 404, apiCode: "notFound" }, }, }); }); diff --git a/packages/cli/tests/project-resolution.test.ts b/packages/cli/tests/project-resolution.test.ts index b6f265e1..f333d16f 100644 --- a/packages/cli/tests/project-resolution.test.ts +++ b/packages/cli/tests/project-resolution.test.ts @@ -4,7 +4,7 @@ import type { Result } from "better-result"; import { describe, expect, it, vi } from "vitest"; import type { ProjectCandidate } from "../src/lib/project/resolution"; import { - projectResolutionErrorToCliError, + projectResolutionErrorToStructured, resolveProjectTarget, } from "../src/lib/project/resolution"; import { createTempCwd, createTestCommandContext } from "./helpers"; @@ -64,9 +64,8 @@ describe("project resolution", () => { }); const error = expectErr(result, "LocalProjectWorkspaceMismatchError"); - expect(projectResolutionErrorToCliError(error)).toMatchObject({ - code: "LOCAL_PROJECT_WORKSPACE_MISMATCH", - domain: "project", + expect(projectResolutionErrorToStructured(error)).toMatchObject({ + code: "PROJECT.LOCAL_WORKSPACE_MISMATCH", meta: { pinPath: ".prisma/local.json", pinnedWorkspaceId: "ws_other", @@ -132,9 +131,8 @@ describe("project resolution", () => { }); const error = expectErr(result, "LocalStateStaleError"); - expect(projectResolutionErrorToCliError(error)).toMatchObject({ - code: "LOCAL_STATE_STALE", - domain: "project", + expect(projectResolutionErrorToStructured(error)).toMatchObject({ + code: "PROJECT.LOCAL_STATE_STALE", meta: { pinPath: ".prisma/local.json", }, diff --git a/packages/cli/tests/project.test.ts b/packages/cli/tests/project.test.ts index a4419840..618b57c0 100644 --- a/packages/cli/tests/project.test.ts +++ b/packages/cli/tests/project.test.ts @@ -296,7 +296,13 @@ describe("prisma project list", () => { expect(result.exitCode).toBe(2); expect(resultFrame(result.json).envelope).toMatchObject({ ok: false, - error: { code: "PROJECT.forbidden", summary: "Failed to list projects" }, + error: { + code: "PROJECT.API_ERROR", + summary: "Failed to list projects", + // The API's own code is data, never the error code: a code the + // registry does not list is a code no caller can branch on. + meta: { status: 403, apiCode: "forbidden" }, + }, }); }); @@ -1571,7 +1577,7 @@ describe("prisma project env add", () => { }); }); - it("maps a forbidden API write to PROJECT.AUTH_REQUIRED", async () => { + it("reports a forbidden API write as PROJECT.ENV_API_ERROR pointing at sign-in", async () => { const result = await makeCli( envClient({ failWriteFor: "STRIPE_KEY", failWriteStatus: 403 }), ).run( @@ -1591,14 +1597,11 @@ describe("prisma project env add", () => { expect(resultFrame(result.json).envelope).toMatchObject({ ok: false, error: { - code: "PROJECT.AUTH_REQUIRED", - summary: "Authentication required", - why: "This command needs an authenticated session.", + code: "PROJECT.ENV_API_ERROR", + summary: "Failed to add STRIPE_KEY", + why: "The Management API rejected the request as unauthorized or forbidden.", + meta: { status: 403 }, nextActions: [ - { - kind: "user-choice", - label: "Run prisma auth login.", - }, { kind: "run-command", label: "prisma auth login", @@ -2147,6 +2150,14 @@ describe("prisma project env list", () => { code: "PROJECT.ENV_API_ERROR", summary: "Failed to list environment variables", why: "boom", + meta: { status: 500 }, + nextActions: [ + { + kind: "user-choice", + label: + "Re-run with --log-level verbose for the underlying API response details.", + }, + ], }, }); }); diff --git a/packages/cli/tests/service-session.test.ts b/packages/cli/tests/service-session.test.ts index 73c61174..22a86f10 100644 --- a/packages/cli/tests/service-session.test.ts +++ b/packages/cli/tests/service-session.test.ts @@ -161,7 +161,9 @@ describe("prisma service — the workspace comes from the engine session", () => if (frame?.kind !== "result" || frame.envelope.ok) { throw new Error("expected an errored envelope"); } - expect(frame.envelope.error.code).toBe("SERVICE.PROJECT_NOT_FOUND"); + // An unmatched --project is the project group's failure whichever + // command met it, so it carries that group's code here too. + expect(frame.envelope.error.code).toBe("PROJECT.NOT_FOUND"); // The workspace the refusal names still comes from the session. expect(frame.envelope.error.why).toContain('workspace "Acme Inc"'); }); diff --git a/scripts/list-error-codes.mjs b/scripts/list-error-codes.mjs index 2f2afc81..5c5794fd 100644 --- a/scripts/list-error-codes.mjs +++ b/scripts/list-error-codes.mjs @@ -20,7 +20,7 @@ */ import { execFileSync } from "node:child_process"; -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { argv, exit, stderr, stdout } from "node:process"; import { fileURLToPath } from "node:url"; @@ -55,7 +55,11 @@ export function extractCodes(root) { .filter( (f) => f.includes("/src/") && !TEST_FILE_RE.test(f) && !f.includes("/test/"), - ); + ) + // `git ls-files` lists what the index tracks, which includes a file + // deleted in the working tree but not yet staged. Reading one throws, + // so a branch mid-delete would crash the check instead of running it. + .filter((f) => existsSync(join(root, f))); const codes = new Map(); for (const file of files) { From 1d2e5c7a2701d8202439122368860f8cb16add60 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 25 Aug 2026 17:25:45 +0200 Subject: [PATCH 3/3] docs: use US spelling and plain wording in the error registry The docs site publishes this page and spell-checks it in US English, so four British spellings (recognised, unrecognised, recognises) are fixed at the source rather than dictionaried downstream. CLI.CONSENT_REQUIRED called consent "structurally undefaultable", an invented word for a simple fact: consent has no default answer and --yes does not grant it. Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- docs/reference/error-reference.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index f3eba4c8..5ef29bbf 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -90,7 +90,7 @@ A bucket subcommand was called without its required id argument: `bucket delete` ### CLI.ABORTED -The run's abort signal fired before the command completed — a thrown abort error is recognised in settlement and reported as this code rather than as a bug. When the abort came from a delivered SIGINT/SIGTERM the run exits 130/143; an abort with no recorded signal (an engine-internal abort) exits 3. Meta: none. +The run's abort signal fired before the command completed — a thrown abort error is recognized in settlement and reported as this code rather than as a bug. When the abort came from a delivered SIGINT/SIGTERM the run exits 130/143; an abort with no recorded signal (an engine-internal abort) exits 3. Meta: none. ### CLI.AUTH_SERVICE_ERROR @@ -122,7 +122,7 @@ The config section a command declared in `needs.config` failed its validator; th ### CLI.CONFIG_UNKNOWN_SECTION -The config file has a top-level key that is not a section any mounted command or command family declares; the set of section names is closed, so an unrecognised key is a typo or leftover the CLI refuses to silently ignore. The `why` lists the recognised section names, and the file path is in `where.path`. Raised by the engine's needs check, deliberately outside the host-replaceable loader. Meta: none. +The config file has a top-level key that is not a section any mounted command or command family declares; the set of section names is closed, so an unrecognized key is a typo or leftover the CLI refuses to silently ignore. The `why` lists the recognized section names, and the file path is in `where.path`. Raised by the engine's needs check, deliberately outside the host-replaceable loader. Meta: none. ### CLI.CONFIG_UNREADABLE @@ -134,7 +134,7 @@ The config file's `$prismaConfig` marker declares a version other than the one t ### CLI.CONSENT_REQUIRED -A consent prompt was reached under `--yes` or in a non-interactive session, and consent is structurally undefaultable — `--yes` never grants it. When the consent declares a token, the message and next action say to pass `--confirm `, and the token travels in meta; without a token, the only path is running the command interactively. Meta: `consentToken` (only when the consent declares a token). +A consent prompt was reached under `--yes` or in a non-interactive session. Consent has no default answer and `--yes` does not grant it, so there is nothing for the run to assume. When the consent declares a token, the message and next action say to pass `--confirm `, and the token travels in meta; without a token, the only path is running the command interactively. Meta: `consentToken` (only when the consent declares a token). ### CLI.CREDENTIALS_LOCKED @@ -434,7 +434,7 @@ The general "Management API call failed" wrapper for the `service` command famil ### SERVICE.DOMAIN_DNS_NOT_CONFIGURED -`service domain add` got HTTP 400 or 422 whose message the CLI recognises as a DNS problem (no CNAME, DNS verification failed, and similar). When the API's text names a `*.prisma.build` target, the CLI composes the exact CNAME record to add and carries it in `meta.dnsRecord` and in the advice action; without a target it advises rerunning with `--log-level verbose` to see the API response. Meta: `status`, `apiCode`, `hint`, `dnsRecord` (when the DNS target could be extracted). +`service domain add` got HTTP 400 or 422 whose message the CLI recognizes as a DNS problem (no CNAME, DNS verification failed, and similar). When the API's text names a `*.prisma.build` target, the CLI composes the exact CNAME record to add and carries it in `meta.dnsRecord` and in the advice action; without a target it advises rerunning with `--log-level verbose` to see the API response. Meta: `status`, `apiCode`, `hint`, `dnsRecord` (when the DNS target could be extracted). ### SERVICE.DOMAIN_HOSTNAME_INVALID