From 99ba862728aada8e85aeb22be2ba57d96998235d Mon Sep 17 00:00:00 2001 From: Tyler Benfield Date: Mon, 8 Jun 2026 14:04:08 -0400 Subject: [PATCH 1/2] docs: plan better-result error handling adoption --- .../better-result-error-handling.plan.md | 512 ++++++++++++++++++ .../better-result-error-handling.spec.md | 72 +++ AGENTS.md | 17 + 3 files changed, 601 insertions(+) create mode 100644 .agents/projects/better-result-error-handling.plan.md create mode 100644 .agents/projects/better-result-error-handling.spec.md diff --git a/.agents/projects/better-result-error-handling.plan.md b/.agents/projects/better-result-error-handling.plan.md new file mode 100644 index 0000000..b8a0c39 --- /dev/null +++ b/.agents/projects/better-result-error-handling.plan.md @@ -0,0 +1,512 @@ +# Better Result Error Handling Adoption Plan + +## Assumptions + +- **A1** The plan keeps `CliError` as the public rendering envelope and treats local project pin read/write behavior as the first migration target because it is the narrowest command-relevant slice identified during planning. +- **A2** The first implementation change adds `better-result` to `packages/cli/package.json` because owned fallible application code lives under `packages/cli`. +- **A3** Type checking is currently available through the package TypeScript config rather than a dedicated package script. Use `pnpm --filter @prisma/cli exec tsc -p tsconfig.json` as the type-level verification command unless a script is added during implementation. +- **A4** Existing `CliError` helpers may remain temporarily while a call stack has not migrated. New or migrated expected failures should use `TaggedError` constructors and exhaustive conversion at the CLI-facing boundary. +- **A5** The plan treats missing local state and optional display fields as non-failure states when absence is valid. Best-effort fallible operations still return typed results; the owning boundary explicitly discards or captures those errors. + +## Open Questions + +None. + +## Phases + +### Phase 1: Foundation And Local Pin Read + +**Status:** ☐ Not started + +**Goal:** Add the dependency and prove typed expected failures on the smallest read-only project context slice. + +**Requirements:** FR1, FR2, FR3, FR4, FR5, FR7, FR8, FR9, FR10, FR12, NFR1, NFR2, NFR3, NFR4, NFR5, NFR6 + +**Estimated Files Touched:** 6-9 files + +**Changes:** + +- Add `better-result` as a runtime dependency of `packages/cli`. +- Update `packages/cli/src/lib/project/local-pin.ts` read behavior so parsing and shape failures are represented with local `TaggedError` variants and `Result` return values. +- Preserve non-failure absence semantics for a missing `.prisma/local.json`; absence remains a successful state, not an error. +- Update local-pin read callers in `packages/cli/src/lib/project/resolution.ts`, `packages/cli/src/controllers/project.ts`, and the local-pin read path in `packages/cli/src/controllers/app.ts`. +- Introduce exhaustive `matchError` conversion for migrated local-pin read failures, preserving existing `LOCAL_STATE_STALE` and `LOCAL_PROJECT_WORKSPACE_MISMATCH` output. +- Add or update targeted tests in `packages/cli/tests/project-resolution.test.ts`, `packages/cli/tests/project.test.ts`, and the local-pin read cases in `packages/cli/tests/app-controller.test.ts`. + +**Acceptance Criteria:** + +- Local pin parsing no longer relies on thrown `SyntaxError` or an untyped invalid sentinel in migrated read paths. +- Existing JSON and human outputs for `LOCAL_STATE_STALE` and `LOCAL_PROJECT_WORKSPACE_MISMATCH` remain stable. +- Adding a new local-pin read error variant requires updating exhaustive conversion before type checking passes. +- `pnpm --filter @prisma/cli exec tsc -p tsconfig.json` passes. +- Targeted project resolution, project command, and app deploy local-pin read tests pass. + +### Phase 2: Local Pin Write And Directory Binding + +**Status:** ☐ Not started + +**Goal:** Complete the local-pin call stack by typing write and gitignore update failures used by project binding. + +**Requirements:** FR1, FR2, FR3, FR4, FR5, FR7, FR8, FR9, FR10, NFR1, NFR2, NFR3, NFR4, NFR5, NFR6 + +**Estimated Files Touched:** 5-8 files + +**Changes:** + +- Update `writeLocalResolutionPin` and `ensureLocalResolutionPinGitignore` in `packages/cli/src/lib/project/local-pin.ts` to return typed results for filesystem and serialization failures. +- Update `bindProjectToDirectory` in `packages/cli/src/lib/project/setup.ts` to compose the typed write results and propagate them upward. +- Update project setup and app deploy callers that bind a project to the directory so failures convert at CLI-facing boundaries. +- Preserve the existing success output that reports `Saved .prisma/local.json`. +- Add or update tests in `packages/cli/tests/project-controller.test.ts`, `packages/cli/tests/project.test.ts`, and app deploy binding tests in `packages/cli/tests/app-controller.test.ts`. + +**Acceptance Criteria:** + +- Local pin write and gitignore update failures are typed before they reach project/app controllers. +- Existing project link/create and first deploy binding success output remains stable. +- Command-facing conversion for local-pin write failures is exhaustive. +- `pnpm --filter @prisma/cli exec tsc -p tsconfig.json` passes. +- Targeted project binding tests pass. + +### Phase 3: Project Resolution Domain Errors + +**Status:** ☐ Not started + +**Goal:** Convert project target resolution failures from thrown `CliError` helpers to typed project-domain failures. + +**Requirements:** FR1, FR2, FR3, FR5, FR6, FR7, FR8, FR9, FR10, NFR1, NFR2, NFR3, NFR4, NFR5, NFR6 + +**Estimated Files Touched:** 5-7 files + +**Changes:** + +- Replace project-resolution `CliError` factory paths in `packages/cli/src/lib/project/resolution.ts` with project-domain `TaggedError` variants for project not found, ambiguous project, local state stale, and local workspace mismatch. +- Convert sequential fallible project resolution operations to result composition, using `Result.gen` where multiple result-returning steps currently require early throws or manual propagation. +- Update `packages/cli/src/controllers/project.ts` and app deployment project-context resolution in `packages/cli/src/controllers/app.ts` to exhaustively map migrated project-domain errors into existing `CliError` envelopes. +- Keep stable product error codes from `docs/product/error-conventions.md`; if implementation reveals a missing expected project error code, update product docs before adding a new code. +- Update project show/list and app project-context tests to assert public command behavior. + +**Acceptance Criteria:** + +- Migrated project resolution APIs expose typed results rather than throwing expected `CliError` instances. +- Existing project-related structured error codes and recovery guidance remain stable. +- `pnpm --filter @prisma/cli exec tsc -p tsconfig.json` passes. +- Project resolution and project show/list tests pass. + +### Phase 4: Project Setup Validation And Creation Errors + +**Status:** ☐ Not started + +**Goal:** Migrate project setup validation and project creation failure mapping without bundling it with target resolution. + +**Requirements:** FR1, FR2, FR3, FR4, FR5, FR7, FR8, FR9, FR10, NFR1, NFR2, NFR3, NFR4, NFR5, NFR6 + +**Estimated Files Touched:** 5-8 files + +**Changes:** + +- Update project setup failure helpers in `packages/cli/src/lib/project/setup.ts` to use `TaggedError` variants for expected setup and create failures. +- Keep prompt-only validation helpers as plain values when they only produce inline prompt text and do not represent command failure. +- Update project create/link flows in `packages/cli/src/controllers/project.ts` and app first-deploy project creation flows in `packages/cli/src/controllers/app.ts` to convert setup-domain errors exhaustively. +- Preserve `PROJECT_CREATE_FAILED`, `PROJECT_NOT_FOUND`, `PROJECT_AMBIGUOUS`, and setup-related usage output. +- Update project create/link tests and app first-deploy project setup tests. + +**Acceptance Criteria:** + +- Project setup expected failures are typed and converted only at CLI-facing boundaries. +- Existing project setup error codes, summaries, fixes, and next steps remain stable. +- `pnpm --filter @prisma/cli exec tsc -p tsconfig.json` passes. +- Targeted project setup tests pass. + +### Phase 5: Local State Store Boundary + +**Status:** ☐ Not started + +**Goal:** Type local state file failures independently from auth token storage and use-case contracts. + +**Requirements:** FR1, FR2, FR3, FR4, FR5, FR7, FR8, FR10, FR11, NFR1, NFR2, NFR3, NFR4, NFR5, NFR6 + +**Estimated Files Touched:** 4-7 files + +**Changes:** + +- Update `packages/cli/src/adapters/local-state.ts` so state file read, JSON parse, and write failures are modeled as typed results where they affect auth, branch, project, app selection, or deployment state. +- Preserve valid absence semantics for a missing state file. +- Update direct local state consumers that need only mechanical signature changes, without broad use-case contract redesign. +- Convert local-state adapter failures at existing controller or command-runner boundaries with exhaustive `matchError`. +- Update `packages/cli/tests/app-state.test.ts` coverage and command tests affected by state read/write failure behavior. + +**Acceptance Criteria:** + +- Command-relevant local state failures are visible in typed results at the adapter boundary. +- Missing state remains a successful default state. +- `pnpm --filter @prisma/cli exec tsc -p tsconfig.json` passes. +- Local state and affected command tests pass. + +### Phase 6: Auth Token Storage Boundary + +**Status:** ☐ Not started + +**Goal:** Type credential-store and refresh-lock failures without changing unrelated auth command behavior. + +**Requirements:** FR1, FR2, FR3, FR4, FR5, FR7, FR8, FR10, FR11, NFR1, NFR2, NFR3, NFR4, NFR5, NFR6 + +**Estimated Files Touched:** 4-7 files + +**Changes:** + +- Update `packages/cli/src/adapters/token-storage.ts` so credential-store calls and refresh-lock operations distinguish expected storage/lock failures from cancellation and unexpected defects. +- Keep third-party credential-store internals untouched; wrap only from owned adapter code. +- Update auth provider/client paths that consume `FileTokenStorage` only as needed for typed result propagation. +- Convert auth storage failures at auth-facing controller or command-runner boundaries while preserving `AUTH_REQUIRED` and `COMMAND_CANCELED` behavior. +- Update token-storage and auth command tests. + +**Acceptance Criteria:** + +- Token read/write/clear and refresh-lock failures are typed at the adapter boundary. +- Cancellation still maps to `COMMAND_CANCELED` and exit code `130` where currently expected. +- `pnpm --filter @prisma/cli exec tsc -p tsconfig.json` passes. +- Token-storage and auth tests pass. + +### Phase 7: Use-Case Gateway Contracts + +**Status:** ☐ Not started + +**Goal:** Migrate gateway contracts after their storage dependencies are typed, keeping the PR focused on interface propagation. + +**Requirements:** FR1, FR2, FR3, FR5, FR6, FR7, FR8, FR10, NFR1, NFR2, NFR3, NFR4, NFR5, NFR6 + +**Estimated Files Touched:** 5-8 files + +**Changes:** + +- Update `packages/cli/src/use-cases/contracts.ts` and `packages/cli/src/use-cases/create-cli-gateways.ts` so gateway interfaces propagate typed results instead of converting command-relevant failures to `null` or raw throws. +- Migrate affected auth, project, and branch use cases only as far as required by changed gateway contracts. +- Use `Result.gen` for sequential use-case composition where multiple migrated gateway calls must short-circuit. +- Convert migrated use-case failures at controllers or command runners with exhaustive `matchError`. +- Update auth, project, branch, and use-case helper tests. + +**Acceptance Criteria:** + +- Gateway interfaces make command-relevant failure propagation visible in types. +- Use-case tests exercise public use-case contracts rather than adapter internals. +- `pnpm --filter @prisma/cli exec tsc -p tsconfig.json` passes. +- Auth, project, branch, and use-case tests pass. + +### Phase 8: Git And Repository Connection Boundary + +**Status:** ☐ Not started + +**Goal:** Migrate git process and repository parsing/connection failures as one cohesive repository workflow slice. + +**Requirements:** FR1, FR2, FR3, FR4, FR5, FR6, FR7, FR8, FR9, FR10, FR11, NFR1, NFR2, NFR3, NFR4, NFR5, NFR6 + +**Estimated Files Touched:** 4-6 files + +**Changes:** + +- Update `packages/cli/src/adapters/git.ts` so `git` process execution exposes typed results when repository discovery affects command behavior. +- Keep GitHub URL parser functions plain only where invalid input is a non-failure branch; return typed results where invalid input maps to structured command failure. +- Update project repository connection flows in `packages/cli/src/controllers/project.ts` to consume typed git/repository results and map them exhaustively to existing repository-related error codes. +- Preserve repository-related codes such as `REPO_PROVIDER_UNSUPPORTED`, `REPO_INSTALLATION_REQUIRED`, `REPO_NOT_ACCESSIBLE`, `REPO_NOT_CONNECTED`, `REPO_ALREADY_CONNECTED`, and `REPO_CONNECTION_FAILED`. +- Update git adapter and project repository connect/disconnect tests. + +**Acceptance Criteria:** + +- Git process failures no longer disappear as `null` when the command needs a recoverable reason. +- Repository connection structured errors remain stable. +- `pnpm --filter @prisma/cli exec tsc -p tsconfig.json` passes. +- Git adapter and project repository tests pass. + +### Phase 9: Version Metadata Boundary + +**Status:** ☐ Not started + +**Goal:** Migrate the small package metadata boundary independently from repository and app work. + +**Requirements:** FR1, FR2, FR3, FR4, FR5, FR7, FR8, FR9, FR10, NFR1, NFR2, NFR3, NFR4, NFR5, NFR6 + +**Estimated Files Touched:** 3-5 files + +**Changes:** + +- Update `packages/cli/src/lib/version.ts` so package metadata read failures are represented as a typed expected `VERSION_UNAVAILABLE` failure at the lowest owned boundary. +- Update `packages/cli/src/controllers/version.ts` to exhaustively convert the version-domain failure to the existing CLI envelope. +- Preserve current version success output and `VERSION_UNAVAILABLE` failure behavior. +- Update version tests. + +**Acceptance Criteria:** + +- Version metadata failures are typed below the controller. +- `VERSION_UNAVAILABLE` output remains stable. +- `pnpm --filter @prisma/cli exec tsc -p tsconfig.json` passes. +- Version tests pass. + +### Phase 10: Best-Effort Shell Boundaries + +**Status:** ☐ Not started + +**Goal:** Make best-effort update checks and diagnostics return typed results before explicit discard or capture. + +**Requirements:** FR1, FR2, FR3, FR4, FR5, FR6, FR10, FR11, NFR1, NFR2, NFR3, NFR5, NFR6 + +**Estimated Files Touched:** 5-8 files + +**Changes:** + +- Update `packages/cli/src/shell/update-check.ts` so fallible update-check work returns typed results instead of hidden `null` or swallowed exceptions. +- Update command diagnostics paths around `packages/cli/src/shell/command-runner.ts`, `packages/cli/src/lib/diagnostics.ts`, and `packages/cli/src/shell/diagnostics-output.ts` so failures are typed before the command runner explicitly discards or captures them. +- Do not convert best-effort failures into public CLI envelopes unless product behavior changes first. +- Update shell/update-check/diagnostics tests or add narrow coverage for explicit discard/capture behavior. + +**Acceptance Criteria:** + +- Best-effort command diagnostics and update checks have explicit typed-result discard or capture points rather than hidden swallowed exceptions. +- Best-effort failures do not change command success/failure behavior. +- `pnpm --filter @prisma/cli exec tsc -p tsconfig.json` passes. +- Targeted shell/update-check/diagnostics tests pass. + +### Phase 11: App Config And Env Parsing Boundaries + +**Status:** ☐ Not started + +**Goal:** Migrate app configuration and environment parsing failures before platform/provider work. + +**Requirements:** FR1, FR2, FR3, FR4, FR5, FR6, FR7, FR8, FR9, FR10, FR11, NFR1, NFR2, NFR3, NFR4, NFR5, NFR6 + +**Estimated Files Touched:** 7-11 files + +**Changes:** + +- Update app config and env modules under `packages/cli/src/lib/app`, including env config/file parsing, env vars, build settings, and framework/build setting validation. +- Return typed expected failures for invalid config, invalid env files, unsupported framework/build settings, and parsing failures that currently throw usage or app config errors. +- Update app env controllers and deploy config resolution in `packages/cli/src/controllers/app.ts` to exhaustively map the migrated errors. +- Preserve `APP_CONFIG_INVALID`, `FRAMEWORK_NOT_DETECTED`, usage errors, and env command output behavior. +- Update app env, app build settings, and deploy config tests. + +**Acceptance Criteria:** + +- App config/env parsing failures are typed below controllers. +- Existing config/env structured errors and human output remain stable. +- `pnpm --filter @prisma/cli exec tsc -p tsconfig.json` passes. +- App env and config-related tests pass. + +### Phase 12: App Local Build And Run Boundaries + +**Status:** ☐ Not started + +**Goal:** Type local process/build/run failures independently from remote deployment/provider failures. + +**Requirements:** FR1, FR2, FR3, FR4, FR5, FR6, FR7, FR8, FR9, FR10, FR11, NFR1, NFR2, NFR3, NFR4, NFR5, NFR6 + +**Estimated Files Touched:** 4-7 files + +**Changes:** + +- Update local app build and run helpers under `packages/cli/src/lib/app` so process execution and framework invocation failures return typed errors. +- Update `runAppBuild`, `runAppRun`, and deploy pre-build paths in `packages/cli/src/controllers/app.ts` to use result composition and exhaustive conversion. +- Preserve `BUILD_FAILED`, `RUN_FAILED`, framework validation, trace/debug, and streaming behavior. +- Update app build, app local dev/run, and deploy pre-build tests. + +**Acceptance Criteria:** + +- Local build/run expected failures are typed before reaching app controllers. +- Existing build/run structured errors and output remain stable. +- `pnpm --filter @prisma/cli exec tsc -p tsconfig.json` passes. +- App build and local run tests pass. + +### Phase 13: Branch Database And Production Gate Boundaries + +**Status:** ☐ Not started + +**Goal:** Migrate deploy safety and database setup failures as a cohesive deploy-preparation slice. + +**Requirements:** FR1, FR2, FR3, FR4, FR5, FR6, FR7, FR8, FR9, FR10, FR11, NFR1, NFR2, NFR3, NFR4, NFR5, NFR6 + +**Estimated Files Touched:** 5-8 files + +**Changes:** + +- Update `packages/cli/src/lib/app/production-deploy-gate.ts` to return typed expected failures for production deploy gating and deployment-list inspection. +- Update `packages/cli/src/lib/app/branch-database-deploy.ts` and related preview branch database setup helpers to return typed expected failures. +- Update app deploy controller paths that compose production gate and branch database setup to use result composition and exhaustive conversion. +- Preserve `PROD_DEPLOY_REQUIRES_FLAG`, `BRANCH_DATABASE_SETUP_FAILED`, `SCHEMA_SETUP_FAILED`, and production safety output. +- Update production deploy gate and branch database tests. + +**Acceptance Criteria:** + +- Production gate and branch database setup failures are typed before deployment starts. +- Production safety behavior remains fail-closed. +- `pnpm --filter @prisma/cli exec tsc -p tsconfig.json` passes. +- Production deploy gate and branch database tests pass. + +### Phase 14: Preview Provider Project And App Boundary + +**Status:** ☐ Not started + +**Goal:** Replace generic SDK error wrappers for preview provider project, branch, and app-resolution operations. + +**Requirements:** FR1, FR2, FR3, FR4, FR5, FR6, FR7, FR8, FR9, FR10, FR11, NFR1, NFR2, NFR3, NFR4, NFR5, NFR6 + +**Estimated Files Touched:** 3-5 files + +**Changes:** + +- Update `packages/cli/src/lib/app/preview-provider.ts` project creation, branch resolution, app listing, app lookup, and app selection operations to return typed provider errors instead of generic SDK error wrappers or raw API errors. +- Preserve original status, response, project/branch/app identifiers, and safe debug context in typed errors. +- Keep third-party SDK internals untouched; wrap only provider calls in owned code. +- Update app provider tests for project, branch, and app-resolution operations. + +**Acceptance Criteria:** + +- Project, branch, and app-resolution provider failures are typed at the provider boundary. +- Unexpected provider defects are not hidden behind expected user-facing envelopes. +- `pnpm --filter @prisma/cli exec tsc -p tsconfig.json` passes. +- App provider tests for project, branch, and app-resolution operations pass. + +### Phase 15: Preview Provider Deployment Boundary + +**Status:** ☐ Not started + +**Goal:** Migrate preview provider deployment lifecycle operations without bundling env or domain provider work. + +**Requirements:** FR1, FR2, FR3, FR4, FR5, FR6, FR7, FR8, FR9, FR10, FR11, NFR1, NFR2, NFR3, NFR4, NFR5, NFR6 + +**Estimated Files Touched:** 3-5 files + +**Changes:** + +- Update preview provider deployment operations for deploy, list, show, logs, promote, rollback, and remove to return typed provider errors. +- Preserve original status, response, service/deployment identifiers, and safe debug context in typed errors. +- Keep third-party SDK internals untouched; wrap only provider calls in owned code. +- Update app provider tests for deployment lifecycle operations. + +**Acceptance Criteria:** + +- Deployment lifecycle provider failures are typed at the provider boundary. +- Unexpected provider defects are not hidden behind expected user-facing envelopes. +- `pnpm --filter @prisma/cli exec tsc -p tsconfig.json` passes. +- App provider deployment lifecycle tests pass. + +### Phase 16: Preview Provider Env And Domain Boundary + +**Status:** ☐ Not started + +**Goal:** Migrate environment variable and custom-domain provider operations separately from core deployment operations. + +**Requirements:** FR1, FR2, FR3, FR4, FR5, FR6, FR7, FR8, FR9, FR10, FR11, NFR1, NFR2, NFR3, NFR4, NFR5, NFR6 + +**Estimated Files Touched:** 4-7 files + +**Changes:** + +- Update preview provider environment variable operations to return typed provider errors with safe context. +- Update preview provider custom-domain operations to return typed domain/provider errors for add, show, remove, retry, and wait flows. +- Preserve domain-related codes such as `DOMAIN_HOSTNAME_INVALID`, `DOMAIN_DNS_NOT_CONFIGURED`, `DOMAIN_ALREADY_REGISTERED`, `DOMAIN_QUOTA_EXCEEDED`, `DOMAIN_NOT_FOUND`, `DOMAIN_RETRY_NOT_ELIGIBLE`, `DOMAIN_VERIFICATION_FAILED`, and `DOMAIN_VERIFICATION_TIMEOUT`. +- Update app env provider and domain tests. + +**Acceptance Criteria:** + +- Env-var and domain provider failures are typed at the provider boundary. +- Existing env and domain structured errors remain stable. +- `pnpm --filter @prisma/cli exec tsc -p tsconfig.json` passes. +- App env provider and domain tests pass. + +### Phase 17: App Deploy Controller Composition + +**Status:** ☐ Not started + +**Goal:** Convert the main deploy orchestration path after its local and provider dependencies return typed results. + +**Requirements:** FR1, FR2, FR3, FR4, FR5, FR6, FR7, FR8, FR9, FR10, FR11, NFR1, NFR2, NFR3, NFR4, NFR5, NFR6 + +**Estimated Files Touched:** 4-6 files + +**Changes:** + +- Update `runAppDeploy` and directly related deploy helpers in `packages/cli/src/controllers/app.ts` to compose migrated project, config, build, database, provider, and deployment results with `Result.gen` where appropriate. +- Replace catch-all deploy wrappers with exhaustive mapping from typed deploy/app/provider errors to existing CLI envelopes. +- Preserve deploy progress, streaming, build, branch database, production safety, and deploy result output behavior. +- Update app deploy controller tests. + +**Acceptance Criteria:** + +- The main deploy path performs exhaustive expected-error conversion rather than relying on catch-all promise handlers. +- Existing deploy success and failure output remains stable. +- `pnpm --filter @prisma/cli exec tsc -p tsconfig.json` passes. +- App deploy controller tests pass. + +### Phase 18: App Management Controller Composition + +**Status:** ☐ Not started + +**Goal:** Convert non-deploy app management command families after provider operations are typed. + +**Requirements:** FR1, FR2, FR3, FR4, FR5, FR6, FR7, FR8, FR9, FR10, FR11, NFR1, NFR2, NFR3, NFR4, NFR5, NFR6 + +**Estimated Files Touched:** 4-6 files + +**Changes:** + +- Update app list/show/open/list-deploys/show-deploy/logs/promote/rollback/remove controllers to consume typed provider results and convert errors exhaustively. +- Preserve `DEPLOYMENT_NOT_FOUND`, `NO_DEPLOYMENTS`, `NO_PREVIOUS_DEPLOYMENT`, `PROMOTE_SOURCE_INVALID`, `ROLLBACK_UNAVAILABLE`, `REMOVE_FAILED`, and deployment inspection output. +- Update app management command tests. + +**Acceptance Criteria:** + +- App management controllers no longer rely on catch-all provider promise handlers for expected failures. +- Existing management command output and structured errors remain stable. +- `pnpm --filter @prisma/cli exec tsc -p tsconfig.json` passes. +- App management tests pass. + +### Phase 19: App Env And Domain Controller Composition + +**Status:** ☐ Not started + +**Goal:** Convert env-var and domain command controllers after their provider operations are typed. + +**Requirements:** FR1, FR2, FR3, FR4, FR5, FR6, FR7, FR8, FR9, FR10, FR11, NFR1, NFR2, NFR3, NFR4, NFR5, NFR6 + +**Estimated Files Touched:** 4-7 files + +**Changes:** + +- Update app env command controllers to consume typed config/provider results and convert errors exhaustively. +- Update app domain command controllers to consume typed domain/provider results and convert errors exhaustively. +- Preserve human and JSON output for env add/update/rm/list and domain add/show/remove/retry/wait flows. +- Update app env and domain controller tests. + +**Acceptance Criteria:** + +- App env and domain controllers perform exhaustive expected-error conversion. +- Existing env/domain command output remains stable. +- `pnpm --filter @prisma/cli exec tsc -p tsconfig.json` passes. +- App env and domain controller tests pass. + +### Phase 20: Command Boundary Cleanup And Enforcement + +**Status:** ☐ Not started + +**Goal:** Consolidate CLI-facing conversion after migrated call stacks return typed results and make future regressions harder. + +**Requirements:** FR1, FR2, FR3, FR4, FR5, FR6, FR7, FR8, FR9, FR10, FR11, NFR1, NFR2, NFR3, NFR4, NFR5, NFR6 + +**Estimated Files Touched:** 5-9 files + +**Changes:** + +- Review `packages/cli/src/shell/command-runner.ts`, `packages/cli/src/shell/errors.ts`, and output helpers so migrated expected failures are converted consistently into the existing human and JSON envelopes. +- Remove forwarding-only `CliError` factory helpers whose only remaining purpose is wrapping constructor arguments already represented in `TaggedError` constructors. +- Keep outer crash behavior for unknown errors and `UnhandledException` cases that should remain defects, preserving stack traces and trace/debug behavior. +- Add lightweight lint, type-level, or test guardrails if needed to prevent migrated lower layers from reintroducing thrown `CliError`, catch-all error mapping, or broad app-wide error unions. +- Update contributor docs only if implementation introduces new repo-local conventions beyond the rules already in `AGENTS.md`. + +**Acceptance Criteria:** + +- No migrated lower-layer module throws `CliError` for expected failures. +- Command boundaries are the only places where migrated expected failures become public CLI envelopes. +- Exhaustive matching is the verification path for migrated expected error unions. +- `pnpm --filter @prisma/cli exec tsc -p tsconfig.json` passes. +- `pnpm test` passes. +- `pnpm build:cli` passes if command runner, package metadata, or build-facing code changed in the phase. + +## Revision Log diff --git a/.agents/projects/better-result-error-handling.spec.md b/.agents/projects/better-result-error-handling.spec.md new file mode 100644 index 0000000..cf4dc95 --- /dev/null +++ b/.agents/projects/better-result-error-handling.spec.md @@ -0,0 +1,72 @@ +# Better Result Error Handling Adoption Spec + +## Problem + +The CLI currently mixes thrown `CliError` instances, generic `Error` wrappers, nullable sentinel returns, local `try`/`catch` blocks, and SDK-specific result handling across adapters, lib modules, use cases, controllers, and shell boundaries. This makes expected failures harder to audit because callers cannot reliably see, from the type signature alone, which failures must be handled before user output is produced. + +This work matters because CLI errors are part of the product surface. The product error conventions require stable structured codes, actionable recovery guidance, and agent-friendly branching fields. The implementation needs stronger type-level guarantees that expected failures are carried from the lowest fallible boundary to the command boundary without becoming untyped exceptions, `null`, booleans, or generic messages. + +Success means owned fallible application code exposes typed `better-result` outcomes, expected failures are modeled as `TaggedError` variants, command-facing error conversion is exhaustive, and agents can verify by type checking that every propagated expected failure has a user-facing mapping before it reaches CLI output. + +The case against this work is migration cost. The codebase is small and the current shell boundary already handles `CliError` formatting. A broad rewrite could add churn without improving user behavior. The adoption must therefore be incremental, bottom-up, and focused on call stacks where typed expected failures materially improve correctness. + +## Stakeholders + +- **S1** CLI users need errors that explain what failed, why it failed, and what useful recovery step is available. +- **S2** Automation users and agents need stable structured error codes and exhaustive mappings so they can branch on error data rather than prose or process exit codes. +- **S3** CLI maintainers need fallible code paths whose expected failures are visible in types and cannot be accidentally dropped while wiring controllers, use cases, and adapters. +- **S4** Future contributors need adoption rules that allow migration one call stack at a time without requiring a whole-repository rewrite. + +## Functional Requirements + +- **FR1** Owned application code that can fail must be able to return `better-result` results whose error type includes only locally produced expected failures plus failures propagated from callees. +- **FR2** Expected failures must be represented as `TaggedError` variants with enough structured context to produce the documented CLI error envelope fields: code, domain, summary, why, fix, where, metadata, next steps, and exit code where applicable. +- **FR3** Unexpected failures caught by `Result.try` or `Result.tryPromise` must remain represented as `UnhandledException` unless a boundary can classify the failure as a specific expected `TaggedError` at the lowest throwing expression. +- **FR4** Throwing and rejecting external boundaries must be wrapped at the lowest owned boundary that can classify the result. This includes filesystem, SDK, parsing, process, runtime, prompt, and network-facing calls owned by the CLI implementation. +- **FR5** Lower layers must propagate typed results upward instead of converting failures into generic `Error`, `CliError`, `null`, booleans, or swallowed exceptions. If a failure is intentionally best-effort, the discard or capture decision must happen at the boundary that owns that best-effort policy. +- **FR6** Composition across multiple result-returning operations must preserve the inferred error union and must use generator-based composition where sequential fallible operations would otherwise require manual `isErr` propagation. +- **FR7** Command-facing boundaries must convert typed expected failures into the existing human and JSON CLI error output shapes without weakening the product error conventions in `docs/product/error-conventions.md`. +- **FR8** Error conversion at command-facing boundaries must use exhaustive `matchError` handling for every expected `TaggedError` variant that can reach that boundary. Catch-all or partial matching is not acceptable for expected user-facing failures. +- **FR9** Existing user-visible error codes from `docs/product/error-conventions.md` must remain stable unless the product docs are updated first. +- **FR10** Adoption must proceed bottom-up by call stack: boundary adapters and low-level lib modules first, then use cases, then controllers, then shell or command-boundary conversion. +- **FR11** The implementation must not refactor generated code, third-party SDK internals, or framework internals for result handling. If generated, SDK-owned, or framework-owned code throws, only owned wrapper boundaries may adopt `better-result`. +- **FR12** The dependency on `better-result` must be added to the package that owns the fallible CLI application code before code starts importing it. + +## Non-Functional Requirements + +- **NFR1** Type checking must be a primary verification mechanism. A newly introduced expected error variant in a migrated call stack must require updates to exhaustive command-facing mapping before the project type check can pass. +- **NFR2** Runtime behavior must remain automation-friendly. JSON error envelopes must keep stable machine-readable fields, and human-readable errors must continue writing to stderr through existing output helpers. +- **NFR3** Migration slices must be reviewable independently. Each slice should target a coherent call stack or boundary area and avoid unrelated command behavior changes. +- **NFR4** User-facing error quality must not regress. For migrated expected failures, the rendered output must include at least the same actionable context as the current `CliError` path or improve it according to the product error conventions. +- **NFR5** Unexpected bugs must remain debuggable. Adoption must not hide invariant failures behind expected user-facing envelopes, and stack-preserving outer crash behavior must remain available for defects. +- **NFR6** Tests for migrated paths must exercise public boundaries used by callers rather than private helpers introduced only for test access. + +## Assumptions + +- **A1** The first implementation plan should focus on `packages/cli`, because that is where owned fallible CLI application code, adapters, use cases, controllers, and shell boundaries currently live. +- **A2** The existing `CliError` output model remains the public CLI error envelope. It may be tightened internally, but `better-result` should not be exposed to CLI users. `TaggedError` variants become the typed internal source that command-facing boundaries convert into that envelope. +- **A3** Nullable returns that model absence without failure may remain nullable. Nullable returns that currently hide parsing, I/O, SDK, auth, or state failures should migrate to typed results. +- **A4** Best-effort diagnostics and update checks should still return typed results from fallible operations. The boundary that owns the best-effort policy may then explicitly discard the error or capture it in the background. +- **A5** The dependency version available from npm at spec time is `better-result@2.9.2`, and the implementation plan should verify the current version before installation. +- **A6** Result conversion should happen only at CLI-facing boundaries present in this repository, including command runners, controllers that produce command output, auth providers, API/client adapters, storage adapters, and startup assembly. + +## Downstream Effects + +- **D1** Function signatures in low-level adapters and lib modules will become more explicit, which may require staged updates through callers before a full call stack compiles. +- **D2** Tests that currently assert thrown `CliError` behavior from internals may need to move up to command-facing public surfaces or assert typed results at the module's public API. +- **D3** Existing helper functions that only construct `CliError` instances may become obsolete as `TaggedError` constructors take over message and context construction. +- **D4** Contributors will need to understand `Result.gen`, `TaggedError`, `UnhandledException`, and exhaustive `matchError` before changing migrated call stacks. +- **D5** The migration may reveal product error cases that are not yet represented in `docs/product/error-conventions.md`; those cases require product-doc updates before implementation assigns stable codes. + +## Out Of Scope + +- **OOS1** This spec does not require a whole-repository migration in one change. +- **OOS2** This spec does not change command names, command behavior, or the documented resource model. +- **OOS3** This spec does not replace the public human or JSON CLI error envelope with serialized `better-result` output. +- **OOS4** This spec does not require converting non-failure absence states, such as optional display fields, into results. +- **OOS5** This spec does not require refactoring generated code, framework internals, or third-party SDK internals. +- **OOS6** This spec does not mandate installing `better-result` skills for agents, though future planning may decide that is useful. + +## Open Questions + +None. diff --git a/AGENTS.md b/AGENTS.md index 1ff11e5..4105899 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,6 +75,23 @@ Important themes: - non-TTY and non-interactive behavior must stay automation-friendly - structured error codes are the branching surface for agents and CI +## Error Handling + +- Use `better-result` for owned application code that can fail. +- Model expected failures as `TaggedError` types from `better-result`. +- Put tagged error message construction in the constructor. Instantiate tagged errors directly; delete factory functions that only wrap constructors. Use static constructors only when they encode distinct domain variants. +- Represent unexpected failures as `UnhandledException` from `better-result`. +- Return only errors produced by the function plus errors propagated from callees. Do not create app-wide error unions. +- Wrap throwing or rejecting boundaries with `Result.try` or `Result.tryPromise`. This includes SDK calls, I/O, parsing, and async framework calls. +- Wrap expected throwing failures at the lowest throwing expression and map them to the local tagged error type. +- Do not wrap a boundary only to match `UnhandledException` and rethrow it. Let unexpected failures throw directly when no expected error is modeled or propagated. +- Use `Result.gen` to compose multiple results. Do not manually chain `isErr` propagation when `yield*` can express the flow. +- Propagate typed results through lower layers. Do not convert results to plain values, `null`, booleans, or thrown exceptions below the boundary. +- Convert results only at CLI-facing boundaries: command runners, controllers that produce command output, auth providers, API/client adapters, storage adapters, and startup assembly. +- Match errors exhaustively with `matchError`. Do not use catch-all handlers or partial matches. +- Throw tagged errors directly when their message and context are already correct. Do not wrap them in generic `Error` instances. +- Do not refactor generated code, third-party SDK internals, or framework internals for result handling. + ## When Making Changes - Prefer tightening existing docs over adding new surface area. From 59f72f639ce9df51b59d2d40c2b73ab8c2c864a8 Mon Sep 17 00:00:00 2001 From: Tyler Benfield Date: Mon, 8 Jun 2026 17:17:14 -0400 Subject: [PATCH 2/2] feat: adopt result errors for local pin reads --- .../better-result-error-handling.plan.md | 2 +- AGENTS.md | 2 + packages/cli/package.json | 1 + packages/cli/src/controllers/app.ts | 25 ++- packages/cli/src/controllers/project.ts | 27 ++- packages/cli/src/lib/project/local-pin.ts | 164 +++++++++++++++--- packages/cli/src/lib/project/resolution.ts | 27 ++- packages/cli/tests/project-resolution.test.ts | 29 ++++ packages/cli/tests/project.test.ts | 18 ++ pnpm-lock.yaml | 3 + 10 files changed, 259 insertions(+), 39 deletions(-) diff --git a/.agents/projects/better-result-error-handling.plan.md b/.agents/projects/better-result-error-handling.plan.md index b8a0c39..edc4d3f 100644 --- a/.agents/projects/better-result-error-handling.plan.md +++ b/.agents/projects/better-result-error-handling.plan.md @@ -16,7 +16,7 @@ None. ### Phase 1: Foundation And Local Pin Read -**Status:** ☐ Not started +**Status:** ◐ Implemented; targeted tests pass, repo typecheck blocked by unrelated existing errors **Goal:** Add the dependency and prove typed expected failures on the smallest read-only project context slice. diff --git a/AGENTS.md b/AGENTS.md index 4105899..f305150 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -84,6 +84,8 @@ Important themes: - Return only errors produced by the function plus errors propagated from callees. Do not create app-wide error unions. - Wrap throwing or rejecting boundaries with `Result.try` or `Result.tryPromise`. This includes SDK calls, I/O, parsing, and async framework calls. - Wrap expected throwing failures at the lowest throwing expression and map them to the local tagged error type. +- Once a function returns `Result`, do not throw inside its body for modeled boundaries. Return expected errors, abort errors, and propagated `UnhandledException` values in the error union; throw only at temporary or final CLI-facing boundaries. +- When mapping abortable boundary failures, prefer `signal.aborted` over matching error names or messages to detect cancellation. - Do not wrap a boundary only to match `UnhandledException` and rethrow it. Let unexpected failures throw directly when no expected error is modeled or propagated. - Use `Result.gen` to compose multiple results. Do not manually chain `isErr` propagation when `yield*` can express the flow. - Propagate typed results through lower layers. Do not convert results to plain values, `null`, booleans, or thrown exceptions below the boundary. diff --git a/packages/cli/package.json b/packages/cli/package.json index 3ddca2a..bacbb5a 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -44,6 +44,7 @@ "@prisma/compute-sdk": "^0.22.0", "@prisma/credentials-store": "^7.8.0", "@prisma/management-api-sdk": "^1.37.0", + "better-result": "^2.9.2", "c12": "4.0.0-beta.5", "colorette": "^2.0.20", "commander": "^14.0.3", diff --git a/packages/cli/src/controllers/app.ts b/packages/cli/src/controllers/app.ts index f7920a6..7940609 100644 --- a/packages/cli/src/controllers/app.ts +++ b/packages/cli/src/controllers/app.ts @@ -4,6 +4,7 @@ import path from "node:path"; import open from "open"; import type { PortMapping, StreamRecord } from "@prisma/compute-sdk"; import type { ManagementApiClient } from "@prisma/management-api-sdk"; +import { Result, matchError } from "better-result"; import { FileTokenStorage } from "../adapters/token-storage"; import { authRequiredError, CliError, featureUnavailableError, usageError, workspaceRequiredError } from "../shell/errors"; @@ -69,6 +70,7 @@ import { import { LOCAL_RESOLUTION_PIN_RELATIVE_PATH, readLocalResolutionPin, + type LocalResolutionPinReadError, type LocalResolutionPinReadResult, } from "../lib/project/local-pin"; import { readLocalGitBranch } from "../lib/git/local-branch"; @@ -243,12 +245,13 @@ export async function runAppDeploy( }); const skipLocalPin = Boolean(envProjectId || options?.projectRef || options?.createProjectName); - const localPin = skipLocalPin - ? ({ kind: "missing" } satisfies LocalResolutionPinReadResult) + const localPinReadResult = skipLocalPin + ? Result.ok({ kind: "missing" } satisfies LocalResolutionPinReadResult) : await readLocalResolutionPin(context.runtime.cwd, context.runtime.signal); - if (!skipLocalPin && localPin.kind === "invalid") { - throw localResolutionPinStaleError(); + if (localPinReadResult.isErr()) { + throw localPinReadErrorToDeployError(localPinReadResult.error); } + const localPin = localPinReadResult.value; const branch = await resolveDeployBranch(context, options?.branchName); if (options?.httpPort) { @@ -3364,6 +3367,20 @@ function localResolutionPinStaleError(): CliError { }); } +function localPinReadErrorToDeployError(error: LocalResolutionPinReadError): CliError { + // Migration bridge: remove in Phase 20 when app controllers compose Result errors instead of throwing CliError. + return matchError(error, { + LocalResolutionPinInvalidJsonError: () => localResolutionPinStaleError(), + LocalResolutionPinInvalidShapeError: () => localResolutionPinStaleError(), + LocalResolutionPinReadAbortedError: (error) => { + throw error; + }, + UnhandledException: (error) => { + throw error; + }, + }); +} + function readDeployEnvOverride(context: CommandContext, name: string): string | undefined { const value = context.runtime.env[name]?.trim(); return value ? value : undefined; diff --git a/packages/cli/src/controllers/project.ts b/packages/cli/src/controllers/project.ts index 8c2cb0f..48e3631 100644 --- a/packages/cli/src/controllers/project.ts +++ b/packages/cli/src/controllers/project.ts @@ -1,4 +1,5 @@ import type { ManagementApiClient } from "@prisma/management-api-sdk"; +import { matchError } from "better-result"; import open from "open"; import { @@ -17,7 +18,7 @@ import { type ResolvedProjectTarget, } from "../lib/project/resolution"; import { promptForProjectSetupChoice } from "../lib/project/interactive-setup"; -import { readLocalResolutionPin } from "../lib/project/local-pin"; +import { readLocalResolutionPin, type LocalResolutionPinReadError } from "../lib/project/local-pin"; import { bindProjectToDirectory, formatCommandArgument, @@ -65,18 +66,34 @@ async function readProjectListLocalBinding( projects: Array>, signal: AbortSignal, ): Promise { - const pin = await readLocalResolutionPin(cwd, signal); + const pinResult = await readLocalResolutionPin(cwd, signal); + if (pinResult.isErr()) { + return localPinReadErrorToInvalidLocalBinding(pinResult.error); + } + + const pin = pinResult.value; if (pin.kind === "present") { return pin.pin.workspaceId === workspace.id && projects.some((project) => project.id === pin.pin.projectId) ? { status: "linked" } : { status: "invalid" }; } - if (pin.kind === "invalid") { - return { status: "invalid" }; - } return { status: "not-linked" }; } +function localPinReadErrorToInvalidLocalBinding(error: LocalResolutionPinReadError): ProjectListResult["localBinding"] { + // Migration bridge: remove in Phase 20 when local-pin read errors are composed before controller output shaping. + return matchError(error, { + LocalResolutionPinInvalidJsonError: () => ({ status: "invalid" }), + LocalResolutionPinInvalidShapeError: () => ({ status: "invalid" }), + LocalResolutionPinReadAbortedError: (error) => { + throw error; + }, + UnhandledException: (error) => { + throw error; + }, + }); +} + export async function runProjectList(context: CommandContext): Promise> { const authState = await requireAuthenticatedAuthState(context); const workspace = authState.workspace; diff --git a/packages/cli/src/lib/project/local-pin.ts b/packages/cli/src/lib/project/local-pin.ts index 95f6833..2982907 100644 --- a/packages/cli/src/lib/project/local-pin.ts +++ b/packages/cli/src/lib/project/local-pin.ts @@ -1,6 +1,8 @@ import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; import path from "node:path"; +import { Result, TaggedError, UnhandledException } from "better-result"; + export const LOCAL_RESOLUTION_PIN_RELATIVE_PATH = ".prisma/local.json"; export interface LocalResolutionPin { @@ -10,31 +12,126 @@ export interface LocalResolutionPin { export type LocalResolutionPinReadResult = | { kind: "missing" } - | { kind: "invalid" } | { kind: "present"; pin: LocalResolutionPin }; -export async function readLocalResolutionPin(cwd: string, signal?: AbortSignal): Promise { - signal?.throwIfAborted(); - try { - const raw = await readFile(path.join(cwd, LOCAL_RESOLUTION_PIN_RELATIVE_PATH), { encoding: "utf8", signal }); - const parsed = JSON.parse(raw) as unknown; +export class LocalResolutionPinInvalidJsonError extends TaggedError( + "LocalResolutionPinInvalidJsonError", +)<{ + message: string; + cause: unknown; + pinPath: string; +}>() { + constructor(cause: unknown) { + super({ + message: `${LOCAL_RESOLUTION_PIN_RELATIVE_PATH} contains invalid JSON.`, + cause, + pinPath: LOCAL_RESOLUTION_PIN_RELATIVE_PATH, + }); + } +} + +export class LocalResolutionPinInvalidShapeError extends TaggedError( + "LocalResolutionPinInvalidShapeError", +)<{ + message: string; + pinPath: string; +}>() { + constructor() { + super({ + message: `${LOCAL_RESOLUTION_PIN_RELATIVE_PATH} must contain workspaceId and projectId string fields only.`, + pinPath: LOCAL_RESOLUTION_PIN_RELATIVE_PATH, + }); + } +} + +export class LocalResolutionPinReadAbortedError extends TaggedError( + "LocalResolutionPinReadAbortedError", +)<{ + message: string; + cause: unknown; + pinPath: string; +}>() { + constructor(cause: unknown) { + super({ + message: `Reading ${LOCAL_RESOLUTION_PIN_RELATIVE_PATH} was aborted.`, + cause, + pinPath: LOCAL_RESOLUTION_PIN_RELATIVE_PATH, + }); + } +} + +export type LocalResolutionPinReadError = + | LocalResolutionPinInvalidJsonError + | LocalResolutionPinInvalidShapeError + | LocalResolutionPinReadAbortedError + | UnhandledException; + +export async function readLocalResolutionPin( + cwd: string, + signal?: AbortSignal, +): Promise> { + return Result.gen(async function* () { + yield* ensureLocalResolutionPinReadNotAborted(signal); + + const file = yield* Result.await(readLocalResolutionPinFile(cwd, signal)); + if (file.kind === "missing") { + return Result.ok({ kind: "missing" } satisfies LocalResolutionPinReadResult); + } + + const parsed = yield* parseLocalResolutionPin(file.raw); if (!isLocalResolutionPin(parsed)) { - return { kind: "invalid" }; + return Result.err(new LocalResolutionPinInvalidShapeError()); } - return { + return Result.ok({ kind: "present", pin: parsed, - }; - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - return { kind: "missing" }; - } - if (error instanceof SyntaxError) { - return { kind: "invalid" }; + } satisfies LocalResolutionPinReadResult); + }); +} + +function ensureLocalResolutionPinReadNotAborted(signal: AbortSignal | undefined): Result { + return Result.try({ + try: () => signal?.throwIfAborted(), + catch: (cause) => new LocalResolutionPinReadAbortedError(cause), + }); +} + +type LocalResolutionPinFileReadResult = + | { kind: "missing" } + | { kind: "present"; raw: string }; + +async function readLocalResolutionPinFile( + cwd: string, + signal: AbortSignal | undefined, +): Promise> { + const readResult = await Result.tryPromise({ + try: () => + readFile(path.join(cwd, LOCAL_RESOLUTION_PIN_RELATIVE_PATH), { + encoding: "utf8", + signal, + }), + catch: (cause) => signal?.aborted + ? new LocalResolutionPinReadAbortedError(cause) + : new UnhandledException({ cause }), + }); + if (readResult.isErr()) { + if (readResult.error instanceof UnhandledException && (readResult.error.cause as NodeJS.ErrnoException).code === "ENOENT") { + return Result.ok({ kind: "missing" }); } - throw error; + return Result.err(readResult.error); } + + return Result.ok({ kind: "present", raw: readResult.value }); +} + +function parseLocalResolutionPin(raw: string): Result { + return Result.try({ + try: () => JSON.parse(raw) as unknown, + catch: (cause) => cause instanceof SyntaxError + ? new LocalResolutionPinInvalidJsonError(cause) + : new UnhandledException({ cause }), + }); } export async function writeLocalResolutionPin( @@ -47,14 +144,23 @@ export async function writeLocalResolutionPin( // mkdir does not accept AbortSignal; check before the filesystem boundary. await mkdir(prismaDir, { recursive: true }); const pinPath = path.join(cwd, LOCAL_RESOLUTION_PIN_RELATIVE_PATH); - const tmpPath = path.join(prismaDir, `local.${process.pid}.${Date.now()}.tmp`); - await writeFile(tmpPath, `${JSON.stringify(pin, null, 2)}\n`, { encoding: "utf8", signal }); + const tmpPath = path.join( + prismaDir, + `local.${process.pid}.${Date.now()}.tmp`, + ); + await writeFile(tmpPath, `${JSON.stringify(pin, null, 2)}\n`, { + encoding: "utf8", + signal, + }); signal?.throwIfAborted(); // rename does not accept AbortSignal; check before the filesystem boundary. await rename(tmpPath, pinPath); } -export async function ensureLocalResolutionPinGitignore(cwd: string, signal?: AbortSignal): Promise { +export async function ensureLocalResolutionPinGitignore( + cwd: string, + signal?: AbortSignal, +): Promise { const gitignorePath = path.join(cwd, ".gitignore"); let existing: string | null = null; @@ -80,7 +186,9 @@ export async function ensureLocalResolutionPinGitignore(cwd: string, signal?: Ab return; } - const next = existing.endsWith("\n") ? `${existing}.prisma/\n` : `${existing}\n.prisma/\n`; + const next = existing.endsWith("\n") + ? `${existing}.prisma/\n` + : `${existing}\n.prisma/\n`; await writeFile(gitignorePath, next, { encoding: "utf8", signal }); } @@ -90,13 +198,19 @@ function isLocalResolutionPin(value: unknown): value is LocalResolutionPin { } const keys = Object.keys(value); - if (keys.length !== 2 || !keys.includes("workspaceId") || !keys.includes("projectId")) { + if ( + keys.length !== 2 || + !keys.includes("workspaceId") || + !keys.includes("projectId") + ) { return false; } const candidate = value as Partial>; - return typeof candidate.workspaceId === "string" - && candidate.workspaceId.trim().length > 0 - && typeof candidate.projectId === "string" - && candidate.projectId.trim().length > 0; + return ( + typeof candidate.workspaceId === "string" && + candidate.workspaceId.trim().length > 0 && + typeof candidate.projectId === "string" && + candidate.projectId.trim().length > 0 + ); } diff --git a/packages/cli/src/lib/project/resolution.ts b/packages/cli/src/lib/project/resolution.ts index 679da63..3a56187 100644 --- a/packages/cli/src/lib/project/resolution.ts +++ b/packages/cli/src/lib/project/resolution.ts @@ -1,6 +1,8 @@ import { readFile } from "node:fs/promises"; import path from "node:path"; +import { matchError } from "better-result"; + import { formatCommandArgument } from "../../shell/command-arguments"; import { CliError } from "../../shell/errors"; import type { NextAction } from "../../shell/next-actions"; @@ -16,6 +18,7 @@ import type { } from "../../types/project"; import { LOCAL_RESOLUTION_PIN_RELATIVE_PATH, + type LocalResolutionPinReadError, type LocalResolutionPinReadResult, readLocalResolutionPin, } from "./local-pin"; @@ -359,9 +362,6 @@ async function resolveBoundProjectTarget( if (!localPin) { return null; } - if (localPin.kind === "invalid") { - throw localStateStaleError(); - } if (localPin.kind === "present") { if (localPin.pin.workspaceId !== options.workspace.id) { throw localProjectWorkspaceMismatchError({ @@ -403,7 +403,12 @@ async function readImplicitLocalPin( return null; } - const localPin = await readLocalResolutionPin(options.context.runtime.cwd, options.context.runtime.signal); + const localPinResult = await readLocalResolutionPin(options.context.runtime.cwd, options.context.runtime.signal); + if (localPinResult.isErr()) { + throw localPinReadErrorToProjectError(localPinResult.error); + } + + const localPin = localPinResult.value; if (localPin.kind === "present" && localPin.pin.workspaceId !== options.workspace.id) { throw localProjectWorkspaceMismatchError({ pinnedWorkspaceId: localPin.pin.workspaceId, @@ -415,6 +420,20 @@ async function readImplicitLocalPin( return localPin; } +function localPinReadErrorToProjectError(error: LocalResolutionPinReadError): CliError { + // Migration bridge: remove in Phase 20 when command boundaries convert Result errors directly. + return matchError(error, { + LocalResolutionPinInvalidJsonError: () => localStateStaleError(), + LocalResolutionPinInvalidShapeError: () => localStateStaleError(), + LocalResolutionPinReadAbortedError: (error) => { + throw error; + }, + UnhandledException: (error) => { + throw error; + }, + }); +} + function resolvedTarget( workspace: AuthWorkspace, project: ProjectCandidate, diff --git a/packages/cli/tests/project-resolution.test.ts b/packages/cli/tests/project-resolution.test.ts index 8b5c968..fa8d2a8 100644 --- a/packages/cli/tests/project-resolution.test.ts +++ b/packages/cli/tests/project-resolution.test.ts @@ -11,6 +11,11 @@ async function writeLocalPin(cwd: string, pin: unknown) { await writeFile(path.join(cwd, ".prisma/local.json"), `${JSON.stringify(pin, null, 2)}\n`, "utf8"); } +async function writeLocalPinContent(cwd: string, content: string) { + await mkdir(path.join(cwd, ".prisma"), { recursive: true }); + await writeFile(path.join(cwd, ".prisma/local.json"), content, "utf8"); +} + describe("project resolution", () => { it("returns LOCAL_PROJECT_WORKSPACE_MISMATCH before listing projects", async () => { const cwd = await createTempCwd(); @@ -106,4 +111,28 @@ describe("project resolution", () => { expect(result.project.id).toBe("proj_env"); expect(listProjects).toHaveBeenCalledTimes(1); }); + + it("returns LOCAL_STATE_STALE for invalid local pin JSON before listing projects", async () => { + const cwd = await createTempCwd(); + await writeLocalPinContent(cwd, "{ nope"); + const { context } = await createTestCommandContext({ cwd }); + const listProjects = vi.fn<() => Promise>(); + + await expect(resolveProjectTarget({ + context, + workspace: { + id: "ws_123", + name: "Acme Inc", + }, + listProjects, + commandName: "app deploy", + })).rejects.toMatchObject({ + code: "LOCAL_STATE_STALE", + domain: "project", + meta: { + pinPath: ".prisma/local.json", + }, + }); + expect(listProjects).not.toHaveBeenCalled(); + }); }); diff --git a/packages/cli/tests/project.test.ts b/packages/cli/tests/project.test.ts index 4cdde42..f50dcbb 100644 --- a/packages/cli/tests/project.test.ts +++ b/packages/cli/tests/project.test.ts @@ -155,6 +155,24 @@ describe("project commands", () => { ]); }); + it("marks project list local binding invalid when the local pin cannot be parsed", async () => { + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + await writeLocalPin(cwd, "{ nope"); + await login(cwd, stateDir); + + const result = await executeCli({ + argv: ["project", "list", "--json"], + cwd, + stateDir, + fixturePath, + }); + const payload = JSON.parse(result.stdout); + + expect(result.exitCode).toBe(0); + expect(payload.result.localBinding).toEqual({ status: "invalid" }); + }); + it("prompts for a Project when bare project link runs interactively", async () => { const cwd = await createTempCwd(); const stateDir = path.join(cwd, ".state"); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1d23abc..fdf208c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: '@prisma/management-api-sdk': specifier: ^1.37.0 version: 1.37.0 + better-result: + specifier: ^2.9.2 + version: 2.9.2 c12: specifier: 4.0.0-beta.5 version: 4.0.0-beta.5(dotenv@17.4.2)(jiti@2.7.0)(magicast@0.5.3)