From afd73d0c8f0b142bf8fd4500cc303390dc9e8596 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 8 Jul 2026 14:21:43 +0200 Subject: [PATCH 01/20] chore(cli): define error actionability taxonomy with co-located declarations Defines the CLI error actionability taxonomy for KPI reporting (CLI-1560): error kinds, categories, suggestion types, and the Q2 strict-recovery / repeat-error / internal-unknown-rate metric definitions. Classification is declared where each error is defined: every error class in apps/cli exposes a CliErrorActionabilityDeclaration under the ErrorActionabilityId symbol, enforced by an exhaustiveness test that fails when any error class lacks a declaration. Errors from workspace packages (stack, config, process-compose) and effect cli/http are classified by a small structural adapter, exhaustiveness-checked against those packages' sources. Broad wrapper errors gain structured discriminants instead of message sniffing: StackBuildError.reason, DockerPullError.daemonDown, LegacyDockerRunError.reason/daemonDown, and a typed upgradeSuggested prop threaded from legacySuggestUpgrade through the plan-gated sso, branches, and vanity-subdomains errors. No telemetry capture changes: cli_command_executed still emits only existing fields until CLI-1561 wires classification into failure events. Co-Authored-By: Claude Fable 5 --- apps/cli/package.json | 3 +- apps/cli/src/legacy/auth/legacy-errors.ts | 35 +- .../legacy/commands/backups/backups.errors.ts | 30 +- .../commands/bootstrap/bootstrap.errors.ts | 41 +- .../commands/branches/branches.errors.ts | 176 +++++- .../branches/create/create.handler.ts | 15 +- .../branches/update/update.handler.ts | 15 +- .../commands/config/push/push.errors.ts | 180 +++++- .../commands/db/advisors/advisors.errors.ts | 72 ++- .../legacy/commands/db/diff/diff.errors.ts | 35 +- .../legacy/commands/db/dump/dump.errors.ts | 29 +- .../commands/db/dump/dump.integration.test.ts | 12 +- .../legacy/commands/db/lint/lint.errors.ts | 48 +- .../legacy/commands/db/pull/pull.errors.ts | 42 +- .../legacy/commands/db/push/push.errors.ts | 41 +- .../legacy/commands/db/query/query.errors.ts | 47 +- .../legacy/commands/db/reset/reset.errors.ts | 60 +- .../schema/declarative/declarative.errors.ts | 48 +- .../db/shared/legacy-db-bootstrap.errors.ts | 13 +- .../commands/db/shared/legacy-migra.errors.ts | 18 +- .../db/shared/legacy-pgdelta.errors.ts | 36 +- .../legacy/commands/domains/domains.errors.ts | 28 +- .../commands/encryption/encryption.errors.ts | 22 +- .../commands/functions/list/list.errors.ts | 24 +- .../commands/functions/new/new.errors.ts | 23 +- .../gen/signing-key/signing-key.errors.ts | 41 +- .../legacy/commands/gen/types/types.errors.ts | 30 +- .../inspect/db/legacy-inspect-query.ts | 11 +- .../commands/inspect/report/report.errors.ts | 17 +- .../src/legacy/commands/link/link.errors.ts | 42 +- .../src/legacy/commands/login/login.errors.ts | 42 +- .../legacy/commands/logout/logout.errors.ts | 11 +- .../commands/migration/down/down.errors.ts | 17 +- .../commands/migration/fetch/fetch.errors.ts | 12 +- .../commands/migration/migration.errors.ts | 36 +- .../commands/migration/new/new.errors.ts | 11 +- .../migration/repair/repair.errors.ts | 11 +- .../legacy/commands/migration/up/up.errors.ts | 17 +- .../network-bans/network-bans.errors.ts | 41 +- .../network-restrictions.errors.ts | 38 +- .../src/legacy/commands/orgs/orgs.errors.ts | 36 +- .../postgres-config/postgres-config.errors.ts | 76 ++- .../commands/projects/projects.errors.ts | 102 +++- .../legacy/commands/secrets/secrets.errors.ts | 79 ++- .../commands/seed/buckets/buckets.errors.ts | 17 +- .../commands/services/services.errors.ts | 11 +- .../commands/snippets/snippets.errors.ts | 42 +- .../ssl-enforcement/ssl-enforcement.errors.ts | 38 +- .../legacy/commands/sso/add/add.handler.ts | 5 +- .../legacy/commands/sso/list/list.handler.ts | 12 +- .../commands/sso/remove/remove.handler.ts | 11 +- .../cli/src/legacy/commands/sso/sso.errors.ts | 184 +++++- .../commands/sso/update/update.handler.ts | 14 +- .../legacy/commands/storage/storage.errors.ts | 55 +- .../src/legacy/commands/test/db/db.errors.ts | 24 +- .../commands/test/db/db.integration.test.ts | 24 +- .../legacy/commands/test/new/new.errors.ts | 18 +- .../legacy/commands/unlink/unlink.errors.ts | 18 +- .../activate/activate.handler.ts | 10 +- .../check-availability.handler.ts | 10 +- .../vanity-subdomains.errors.ts | 56 +- .../src/legacy/config/legacy-profile-file.ts | 11 +- .../config/legacy-project-ref.errors.ts | 24 +- .../legacy/shared/legacy-db-config.errors.ts | 72 ++- .../shared/legacy-db-connection.errors.ts | 23 +- .../legacy/shared/legacy-docker-run.errors.ts | 26 +- .../legacy/shared/legacy-docker-run.layer.ts | 22 + .../src/legacy/shared/legacy-drop-objects.ts | 11 +- .../legacy-edge-runtime-script.errors.ts | 11 +- .../legacy/shared/legacy-experimental-gate.ts | 9 + .../legacy/shared/legacy-go-output-flag.ts | 11 +- .../legacy/shared/legacy-migration-apply.ts | 11 +- .../legacy-migration-apply.unit.test.ts | 5 +- .../legacy/shared/legacy-migration.errors.ts | 11 +- .../legacy-pgdelta-ssl-probe.service.ts | 11 +- apps/cli/src/legacy/shared/legacy-seed.ts | 11 +- .../legacy-storage-credentials.errors.ts | 30 +- .../shared/legacy-storage-gateway.errors.ts | 18 +- .../src/legacy/shared/legacy-temp-paths.ts | 11 +- .../legacy/shared/legacy-upgrade-suggest.ts | 21 +- apps/cli/src/legacy/shared/legacy-vault.ts | 11 +- apps/cli/src/next/auth/errors.ts | 24 +- apps/cli/src/next/commands/branches/errors.ts | 23 +- .../dev/functions-dev-edge-runtime-config.ts | 9 + .../next/commands/functions/new/new.errors.ts | 23 +- .../cli/src/next/commands/link/link.errors.ts | 17 +- .../src/next/commands/login/login.handler.ts | 13 +- .../cli/src/next/commands/logs/logs.errors.ts | 9 + .../next/commands/platform/platform.errors.ts | 30 +- .../next/config/project-link-remote.layer.ts | 21 +- .../next/config/project-link-state.service.ts | 17 +- .../project-local-service-versions.service.ts | 11 +- .../next/config/service-version-resolution.ts | 13 +- .../cli/src/shared/functions/delete.errors.ts | 29 +- .../cli/src/shared/functions/deploy.errors.ts | 29 +- .../src/shared/functions/download.errors.ts | 35 +- .../src/shared/init/project-init.errors.ts | 17 + apps/cli/src/shared/output/errors.ts | 9 + .../shared/runtime/file-watcher.service.ts | 12 +- .../src/shared/services/services.shared.ts | 13 +- .../error-actionability-coverage.unit.test.ts | 162 ++++++ .../shared/telemetry/error-actionability.ts | 543 ++++++++++++++++++ .../error-actionability.unit.test.ts | 200 +++++++ packages/stack/src/StackBuilder.ts | 4 + .../stack/src/StackLifecycleCoordinator.ts | 2 + packages/stack/src/StackPreparation.ts | 16 + packages/stack/src/errors.ts | 14 + 107 files changed, 3558 insertions(+), 409 deletions(-) create mode 100644 apps/cli/src/shared/telemetry/error-actionability-coverage.unit.test.ts create mode 100644 apps/cli/src/shared/telemetry/error-actionability.ts create mode 100644 apps/cli/src/shared/telemetry/error-actionability.unit.test.ts diff --git a/apps/cli/package.json b/apps/cli/package.json index 345a397df7..6ef03a1c05 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -142,7 +142,8 @@ "@anthropic-ai/claude-agent-sdk", "@anthropic-ai/sdk", "@modelcontextprotocol/sdk" - ] + ], + "ignoreExportsUsedInFile": true }, "nx": { "targets": { diff --git a/apps/cli/src/legacy/auth/legacy-errors.ts b/apps/cli/src/legacy/auth/legacy-errors.ts index 30314cb4ec..f9317216a5 100644 --- a/apps/cli/src/legacy/auth/legacy-errors.ts +++ b/apps/cli/src/legacy/auth/legacy-errors.ts @@ -1,16 +1,29 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; export class LegacyInvalidAccessTokenError extends Data.TaggedError( "LegacyInvalidAccessTokenError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.authLogin; + } +} export class LegacyPlatformAuthRequiredError extends Data.TaggedError( "LegacyPlatformAuthRequiredError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.authLogin; + } +} /** * Raised by `deleteProjectCredential` when removing a stored database-password @@ -21,7 +34,11 @@ export class LegacyPlatformAuthRequiredError extends Data.TaggedError( */ export class LegacyCredentialDeleteError extends Data.TaggedError("LegacyCredentialDeleteError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.permission; + } +} /** * Raised by `deleteAccessToken` when there is no access token to delete, i.e. @@ -33,7 +50,11 @@ export class LegacyCredentialDeleteError extends Data.TaggedError("LegacyCredent */ export class LegacyNotLoggedInError extends Data.TaggedError("LegacyNotLoggedInError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.authLogin; + } +} /** * Raised by `deleteAccessToken` when removing the token fails for a real reason @@ -44,4 +65,8 @@ export class LegacyNotLoggedInError extends Data.TaggedError("LegacyNotLoggedInE */ export class LegacyDeleteTokenError extends Data.TaggedError("LegacyDeleteTokenError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.permission; + } +} diff --git a/apps/cli/src/legacy/commands/backups/backups.errors.ts b/apps/cli/src/legacy/commands/backups/backups.errors.ts index b439a7210c..67fd9e3dea 100644 --- a/apps/cli/src/legacy/commands/backups/backups.errors.ts +++ b/apps/cli/src/legacy/commands/backups/backups.errors.ts @@ -1,8 +1,18 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../../../shared/telemetry/error-actionability.ts"; export class LegacyBackupListNetworkError extends Data.TaggedError("LegacyBackupListNetworkError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} export class LegacyBackupListUnexpectedStatusError extends Data.TaggedError( "LegacyBackupListUnexpectedStatusError", @@ -10,13 +20,21 @@ export class LegacyBackupListUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} export class LegacyBackupRestoreNetworkError extends Data.TaggedError( "LegacyBackupRestoreNetworkError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} export class LegacyBackupRestoreUnexpectedStatusError extends Data.TaggedError( "LegacyBackupRestoreUnexpectedStatusError", @@ -24,4 +42,8 @@ export class LegacyBackupRestoreUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} diff --git a/apps/cli/src/legacy/commands/bootstrap/bootstrap.errors.ts b/apps/cli/src/legacy/commands/bootstrap/bootstrap.errors.ts index 95896f9b6f..613cc301d4 100644 --- a/apps/cli/src/legacy/commands/bootstrap/bootstrap.errors.ts +++ b/apps/cli/src/legacy/commands/bootstrap/bootstrap.errors.ts @@ -1,4 +1,9 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../shared/telemetry/error-actionability.ts"; // --------------------------------------------------------------------------- // Bootstrap-specific tagged errors. Each maps to a Go `errors.New` / failure @@ -12,21 +17,33 @@ export class LegacyBootstrapInvalidTemplateError extends Data.TaggedError( "LegacyBootstrapInvalidTemplateError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** GitHub samples listing failure — Go's `failed to list samples` (`bootstrap.go:ListSamples`). */ export class LegacyBootstrapTemplateListError extends Data.TaggedError( "LegacyBootstrapTemplateListError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} /** Reading the target workdir failed — Go's `failed to read workdir: %w` (`bootstrap.go:44`). */ export class LegacyBootstrapWorkdirReadError extends Data.TaggedError( "LegacyBootstrapWorkdirReadError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.permission; + } +} /** * User declined the overwrite prompt — Go returns `errors.New(context.Canceled)` @@ -36,14 +53,22 @@ export class LegacyBootstrapOverwriteDeclinedError extends Data.TaggedError( "LegacyBootstrapOverwriteDeclinedError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.cancelled; + } +} /** Template download failure — Go's `failed to download template: %w` (`bootstrap.go:downloadSample`). */ export class LegacyBootstrapTemplateDownloadError extends Data.TaggedError( "LegacyBootstrapTemplateDownloadError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} /** * Project health probe failed — Go's `Error status %d: %s` (non-200) or @@ -51,4 +76,8 @@ export class LegacyBootstrapTemplateDownloadError extends Data.TaggedError( */ export class LegacyBootstrapHealthError extends Data.TaggedError("LegacyBootstrapHealthError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.apiStatus; + } +} diff --git a/apps/cli/src/legacy/commands/branches/branches.errors.ts b/apps/cli/src/legacy/commands/branches/branches.errors.ts index 0092742e88..af3a7298d5 100644 --- a/apps/cli/src/legacy/commands/branches/branches.errors.ts +++ b/apps/cli/src/legacy/commands/branches/branches.errors.ts @@ -1,4 +1,11 @@ import { Data } from "effect"; +import { + actionability, + CliSuggestionType, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../../../shared/telemetry/error-actionability.ts"; // --------------------------------------------------------------------------- // HTTP-bound errors — one (Network + UnexpectedStatus) pair per Go errorf site. @@ -10,7 +17,11 @@ export class LegacyBranchesListNetworkError extends Data.TaggedError( "LegacyBranchesListNetworkError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} export class LegacyBranchesListUnexpectedStatusError extends Data.TaggedError( "LegacyBranchesListUnexpectedStatusError", @@ -18,13 +29,21 @@ export class LegacyBranchesListUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} export class LegacyBranchesCreateNetworkError extends Data.TaggedError( "LegacyBranchesCreateNetworkError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} export class LegacyBranchesCreateUnexpectedStatusError extends Data.TaggedError( "LegacyBranchesCreateUnexpectedStatusError", @@ -32,14 +51,23 @@ export class LegacyBranchesCreateUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} + readonly upgradeSuggested?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { upgradeSuggested: this.upgradeSuggested }); + } +} // Lookup phase of `branches get` (only runs when input is not UUID / not ref). export class LegacyBranchesFindNetworkError extends Data.TaggedError( "LegacyBranchesFindNetworkError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} export class LegacyBranchesFindUnexpectedStatusError extends Data.TaggedError( "LegacyBranchesFindUnexpectedStatusError", @@ -47,7 +75,11 @@ export class LegacyBranchesFindUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} // `branches get` detail phase + the resolver's UUID branch (both use // V1GetABranchConfig; Go shares the same error template). @@ -55,7 +87,11 @@ export class LegacyBranchesGetNetworkError extends Data.TaggedError( "LegacyBranchesGetNetworkError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} export class LegacyBranchesGetUnexpectedStatusError extends Data.TaggedError( "LegacyBranchesGetUnexpectedStatusError", @@ -63,13 +99,21 @@ export class LegacyBranchesGetUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} export class LegacyBranchesApiKeysNetworkError extends Data.TaggedError( "LegacyBranchesApiKeysNetworkError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} export class LegacyBranchesApiKeysUnexpectedStatusError extends Data.TaggedError( "LegacyBranchesApiKeysUnexpectedStatusError", @@ -77,13 +121,21 @@ export class LegacyBranchesApiKeysUnexpectedStatusError extends Data.TaggedError readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} export class LegacyBranchesPoolerNetworkError extends Data.TaggedError( "LegacyBranchesPoolerNetworkError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} export class LegacyBranchesPoolerUnexpectedStatusError extends Data.TaggedError( "LegacyBranchesPoolerUnexpectedStatusError", @@ -91,19 +143,31 @@ export class LegacyBranchesPoolerUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} export class LegacyBranchesPrimaryNotFoundError extends Data.TaggedError( "LegacyBranchesPrimaryNotFoundError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.apiStatus; + } +} export class LegacyBranchesUpdateNetworkError extends Data.TaggedError( "LegacyBranchesUpdateNetworkError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} export class LegacyBranchesUpdateUnexpectedStatusError extends Data.TaggedError( "LegacyBranchesUpdateUnexpectedStatusError", @@ -111,13 +175,22 @@ export class LegacyBranchesUpdateUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} + readonly upgradeSuggested?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { upgradeSuggested: this.upgradeSuggested }); + } +} export class LegacyBranchesPauseNetworkError extends Data.TaggedError( "LegacyBranchesPauseNetworkError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} export class LegacyBranchesPauseUnexpectedStatusError extends Data.TaggedError( "LegacyBranchesPauseUnexpectedStatusError", @@ -125,13 +198,21 @@ export class LegacyBranchesPauseUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} export class LegacyBranchesUnpauseNetworkError extends Data.TaggedError( "LegacyBranchesUnpauseNetworkError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} export class LegacyBranchesUnpauseUnexpectedStatusError extends Data.TaggedError( "LegacyBranchesUnpauseUnexpectedStatusError", @@ -139,13 +220,21 @@ export class LegacyBranchesUnpauseUnexpectedStatusError extends Data.TaggedError readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} export class LegacyBranchesDeleteNetworkError extends Data.TaggedError( "LegacyBranchesDeleteNetworkError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} export class LegacyBranchesDeleteUnexpectedStatusError extends Data.TaggedError( "LegacyBranchesDeleteUnexpectedStatusError", @@ -153,13 +242,21 @@ export class LegacyBranchesDeleteUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} export class LegacyBranchesDisableNetworkError extends Data.TaggedError( "LegacyBranchesDisableNetworkError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} export class LegacyBranchesDisableUnexpectedStatusError extends Data.TaggedError( "LegacyBranchesDisableUnexpectedStatusError", @@ -167,7 +264,11 @@ export class LegacyBranchesDisableUnexpectedStatusError extends Data.TaggedError readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} // --------------------------------------------------------------------------- // Pure-path errors (validation, prompt-time semantics, user cancellation). @@ -177,19 +278,31 @@ export class LegacyBranchesEnvNotSupportedError extends Data.TaggedError( "LegacyBranchesEnvNotSupportedError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} export class LegacyBranchesCreateCancelledError extends Data.TaggedError( "LegacyBranchesCreateCancelledError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.cancelled; + } +} export class LegacyBranchesBranchNameEmptyError extends Data.TaggedError( "LegacyBranchesBranchNameEmptyError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} export class LegacyBranchesBranchingDisabledError extends Data.TaggedError( "LegacyBranchesBranchingDisabledError", @@ -201,4 +314,13 @@ export class LegacyBranchesBranchingDisabledError extends Data.TaggedError( * `normalizeCliError` and printed after the error message in text mode. */ readonly suggestion: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return { + ...actionability.invalidInput, + has_suggestion: true, + suggestion_type: CliSuggestionType.UpdateConfig, + suggested_command: "supabase branches create", + }; + } +} diff --git a/apps/cli/src/legacy/commands/branches/create/create.handler.ts b/apps/cli/src/legacy/commands/branches/create/create.handler.ts index 9cf9352b1f..2e16ae20f9 100644 --- a/apps/cli/src/legacy/commands/branches/create/create.handler.ts +++ b/apps/cli/src/legacy/commands/branches/create/create.handler.ts @@ -106,12 +106,23 @@ export const legacyBranchesCreate = Effect.fn("legacy.branches.create")(function HttpClientError.isHttpClientError(cause) && cause.response !== undefined ? cause.response.status : 0; - yield* legacySuggestUpgrade({ + const upgradeSuggested = yield* legacySuggestUpgrade({ projectRef: ref, featureKey: "branching_limit", statusCode: status, }); - return yield* mapCreateErrorRaw(cause); + const mapped = yield* Effect.flip(mapCreateErrorRaw(cause)); + if (mapped._tag === "LegacyBranchesCreateUnexpectedStatusError") { + return yield* Effect.fail( + new LegacyBranchesCreateUnexpectedStatusError({ + status: mapped.status, + body: mapped.body, + message: mapped.message, + upgradeSuggested, + }), + ); + } + return yield* Effect.fail(mapped); }), ), ); diff --git a/apps/cli/src/legacy/commands/branches/update/update.handler.ts b/apps/cli/src/legacy/commands/branches/update/update.handler.ts index 5dc3085c74..b0e4eff594 100644 --- a/apps/cli/src/legacy/commands/branches/update/update.handler.ts +++ b/apps/cli/src/legacy/commands/branches/update/update.handler.ts @@ -78,12 +78,23 @@ export const legacyBranchesUpdate = Effect.fn("legacy.branches.update")(function : 0; // Mirrors Go's `update.go:26` — pass the resolved branch's project // ref so the entitlements check is scoped to the branch's org. - yield* legacySuggestUpgrade({ + const upgradeSuggested = yield* legacySuggestUpgrade({ projectRef: branchRef, featureKey: "branching_persistent", statusCode: status, }); - return yield* mapUpdateError(cause); + const mapped = yield* Effect.flip(mapUpdateError(cause)); + if (mapped._tag === "LegacyBranchesUpdateUnexpectedStatusError") { + return yield* Effect.fail( + new LegacyBranchesUpdateUnexpectedStatusError({ + status: mapped.status, + body: mapped.body, + message: mapped.message, + upgradeSuggested, + }), + ); + } + return yield* Effect.fail(mapped); }), ), ); diff --git a/apps/cli/src/legacy/commands/config/push/push.errors.ts b/apps/cli/src/legacy/commands/config/push/push.errors.ts index f0921dfdda..a26f4fcbb7 100644 --- a/apps/cli/src/legacy/commands/config/push/push.errors.ts +++ b/apps/cli/src/legacy/commands/config/push/push.errors.ts @@ -1,4 +1,10 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../../../../shared/telemetry/error-actionability.ts"; /** * Tagged errors for `supabase config push`, one per Go error path @@ -29,113 +35,229 @@ interface StatusErrorArgs { /** TOML parse failure (rewraps the packages/config parse error). Aborts before any network call. */ export class LegacyConfigPushLoadConfigError extends Data.TaggedError( "LegacyConfigPushLoadConfigError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} // --- cost matrix (list addons) --------------------------------------------- export class LegacyConfigPushListAddonsNetworkError extends Data.TaggedError( "LegacyConfigPushListAddonsNetworkError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} export class LegacyConfigPushListAddonsStatusError extends Data.TaggedError( "LegacyConfigPushListAddonsStatusError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} // --- api -------------------------------------------------------------------- export class LegacyConfigPushApiReadNetworkError extends Data.TaggedError( "LegacyConfigPushApiReadNetworkError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} export class LegacyConfigPushApiReadStatusError extends Data.TaggedError( "LegacyConfigPushApiReadStatusError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} export class LegacyConfigPushApiUpdateNetworkError extends Data.TaggedError( "LegacyConfigPushApiUpdateNetworkError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} export class LegacyConfigPushApiUpdateStatusError extends Data.TaggedError( "LegacyConfigPushApiUpdateStatusError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} // --- db.settings ------------------------------------------------------------ export class LegacyConfigPushDbReadNetworkError extends Data.TaggedError( "LegacyConfigPushDbReadNetworkError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} export class LegacyConfigPushDbReadStatusError extends Data.TaggedError( "LegacyConfigPushDbReadStatusError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} export class LegacyConfigPushDbUpdateNetworkError extends Data.TaggedError( "LegacyConfigPushDbUpdateNetworkError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} export class LegacyConfigPushDbUpdateStatusError extends Data.TaggedError( "LegacyConfigPushDbUpdateStatusError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} // --- db.network_restrictions ------------------------------------------------ export class LegacyConfigPushNetworkRestrictionsReadNetworkError extends Data.TaggedError( "LegacyConfigPushNetworkRestrictionsReadNetworkError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} export class LegacyConfigPushNetworkRestrictionsReadStatusError extends Data.TaggedError( "LegacyConfigPushNetworkRestrictionsReadStatusError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} export class LegacyConfigPushNetworkRestrictionsUpdateNetworkError extends Data.TaggedError( "LegacyConfigPushNetworkRestrictionsUpdateNetworkError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} export class LegacyConfigPushNetworkRestrictionsUpdateStatusError extends Data.TaggedError( "LegacyConfigPushNetworkRestrictionsUpdateStatusError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} // --- db.ssl_enforcement ----------------------------------------------------- export class LegacyConfigPushSslEnforcementReadNetworkError extends Data.TaggedError( "LegacyConfigPushSslEnforcementReadNetworkError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} export class LegacyConfigPushSslEnforcementReadStatusError extends Data.TaggedError( "LegacyConfigPushSslEnforcementReadStatusError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} export class LegacyConfigPushSslEnforcementUpdateNetworkError extends Data.TaggedError( "LegacyConfigPushSslEnforcementUpdateNetworkError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} export class LegacyConfigPushSslEnforcementUpdateStatusError extends Data.TaggedError( "LegacyConfigPushSslEnforcementUpdateStatusError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} // --- auth ------------------------------------------------------------------- export class LegacyConfigPushAuthReadNetworkError extends Data.TaggedError( "LegacyConfigPushAuthReadNetworkError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} export class LegacyConfigPushAuthReadStatusError extends Data.TaggedError( "LegacyConfigPushAuthReadStatusError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} export class LegacyConfigPushAuthUpdateNetworkError extends Data.TaggedError( "LegacyConfigPushAuthUpdateNetworkError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} export class LegacyConfigPushAuthUpdateStatusError extends Data.TaggedError( "LegacyConfigPushAuthUpdateStatusError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} // --- storage ---------------------------------------------------------------- export class LegacyConfigPushStorageReadNetworkError extends Data.TaggedError( "LegacyConfigPushStorageReadNetworkError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} export class LegacyConfigPushStorageReadStatusError extends Data.TaggedError( "LegacyConfigPushStorageReadStatusError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} export class LegacyConfigPushStorageUpdateNetworkError extends Data.TaggedError( "LegacyConfigPushStorageUpdateNetworkError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} export class LegacyConfigPushStorageUpdateStatusError extends Data.TaggedError( "LegacyConfigPushStorageUpdateStatusError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} // --- experimental.webhooks -------------------------------------------------- export class LegacyConfigPushEnableWebhookNetworkError extends Data.TaggedError( "LegacyConfigPushEnableWebhookNetworkError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} export class LegacyConfigPushEnableWebhookStatusError extends Data.TaggedError( "LegacyConfigPushEnableWebhookStatusError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} diff --git a/apps/cli/src/legacy/commands/db/advisors/advisors.errors.ts b/apps/cli/src/legacy/commands/db/advisors/advisors.errors.ts index bd073206d8..39fc9dcc4a 100644 --- a/apps/cli/src/legacy/commands/db/advisors/advisors.errors.ts +++ b/apps/cli/src/legacy/commands/db/advisors/advisors.errors.ts @@ -1,4 +1,10 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../../../../shared/telemetry/error-actionability.ts"; /** * Tagged errors for `db advisors`, one per Go failure path @@ -13,7 +19,11 @@ import { Data } from "effect"; /** cobra `MarkFlagsMutuallyExclusive("db-url", "linked", "local")` (`db.go`). */ export class LegacyDbAdvisorsMutuallyExclusiveFlagsError extends Data.TaggedError( "LegacyDbAdvisorsMutuallyExclusiveFlagsError", -)<{ readonly message: string }> {} +)<{ readonly message: string }> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * `--linked` PreRunE: no access token. Message is Go's `utils.ErrMissingToken`; @@ -22,7 +32,11 @@ export class LegacyDbAdvisorsMutuallyExclusiveFlagsError extends Data.TaggedErro */ export class LegacyDbAdvisorsNotLoggedInError extends Data.TaggedError( "LegacyDbAdvisorsNotLoggedInError", -)<{ readonly message: string; readonly suggestion: string }> {} +)<{ readonly message: string; readonly suggestion: string }> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.authLogin; + } +} /** * `--linked` PreRunE: the resolved access token is malformed. Message is Go's @@ -33,44 +47,80 @@ export class LegacyDbAdvisorsNotLoggedInError extends Data.TaggedError( */ export class LegacyDbAdvisorsInvalidTokenError extends Data.TaggedError( "LegacyDbAdvisorsInvalidTokenError", -)<{ readonly message: string; readonly suggestion: string }> {} +)<{ readonly message: string; readonly suggestion: string }> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.authLogin; + } +} /** `failed to begin transaction: %w` (`advisors.go:105`). */ export class LegacyDbAdvisorsBeginTxError extends Data.TaggedError("LegacyDbAdvisorsBeginTxError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbConnection; + } +} /** `failed to prepare lint session: %w` (`advisors.go:115`). */ export class LegacyDbAdvisorsSetupError extends Data.TaggedError("LegacyDbAdvisorsSetupError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} /** `failed to query lints: %w` (`advisors.go:120`). */ export class LegacyDbAdvisorsQueryError extends Data.TaggedError("LegacyDbAdvisorsQueryError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} /** `failed to fetch security advisors: %w` (`advisors.go:165`). */ export class LegacyDbAdvisorsSecurityNetworkError extends Data.TaggedError( "LegacyDbAdvisorsSecurityNetworkError", -)<{ readonly message: string }> {} +)<{ readonly message: string }> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} /** `unexpected security advisors status %d: %s` (`advisors.go:168`). */ export class LegacyDbAdvisorsSecurityStatusError extends Data.TaggedError( "LegacyDbAdvisorsSecurityStatusError", -)<{ readonly status: number; readonly body: string; readonly message: string }> {} +)<{ readonly status: number; readonly body: string; readonly message: string }> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} /** `failed to fetch performance advisors: %w` (`advisors.go:176`). */ export class LegacyDbAdvisorsPerformanceNetworkError extends Data.TaggedError( "LegacyDbAdvisorsPerformanceNetworkError", -)<{ readonly message: string }> {} +)<{ readonly message: string }> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} /** `unexpected performance advisors status %d: %s` (`advisors.go:179`). */ export class LegacyDbAdvisorsPerformanceStatusError extends Data.TaggedError( "LegacyDbAdvisorsPerformanceStatusError", -)<{ readonly status: number; readonly body: string; readonly message: string }> {} +)<{ readonly status: number; readonly body: string; readonly message: string }> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} /** `fail-on is set to %s, non-zero exit` (`advisors.go:257`). */ export class LegacyDbAdvisorsFailOnError extends Data.TaggedError("LegacyDbAdvisorsFailOnError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} diff --git a/apps/cli/src/legacy/commands/db/diff/diff.errors.ts b/apps/cli/src/legacy/commands/db/diff/diff.errors.ts index b7c6dff954..e901e94dad 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.errors.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.errors.ts @@ -1,4 +1,9 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; /** * Conflicting database-target flags. Reproduces cobra's @@ -7,7 +12,11 @@ import { Data } from "effect"; */ export class LegacyDbDiffTargetFlagsError extends Data.TaggedError("LegacyDbDiffTargetFlagsError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * Conflicting diff-engine flags. Reproduces cobra's @@ -18,7 +27,11 @@ export class LegacyDbDiffEngineConflictError extends Data.TaggedError( "LegacyDbDiffEngineConflictError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * Only one of `--from` / `--to` was set in explicit diff mode. Byte-matches Go's @@ -29,7 +42,11 @@ export class LegacyDbDiffExplicitFlagsError extends Data.TaggedError( "LegacyDbDiffExplicitFlagsError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * An explicit `--from`/`--to` ref was neither `local`/`linked`/`migrations` nor a @@ -41,7 +58,11 @@ export class LegacyDbDiffUnknownTargetError extends Data.TaggedError( "LegacyDbDiffUnknownTargetError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * Writing the diff output failed — a `--file` migration, or an explicit-mode @@ -49,4 +70,8 @@ export class LegacyDbDiffUnknownTargetError extends Data.TaggedError( */ export class LegacyDbDiffWriteError extends Data.TaggedError("LegacyDbDiffWriteError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} diff --git a/apps/cli/src/legacy/commands/db/dump/dump.errors.ts b/apps/cli/src/legacy/commands/db/dump/dump.errors.ts index d7de51c62d..542aa21d40 100644 --- a/apps/cli/src/legacy/commands/db/dump/dump.errors.ts +++ b/apps/cli/src/legacy/commands/db/dump/dump.errors.ts @@ -1,4 +1,9 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; /** * `--use-copy` / `--exclude` were passed without `--data-only`. Reproduces @@ -9,7 +14,11 @@ export class LegacyDbDumpRequiresDataOnlyError extends Data.TaggedError( "LegacyDbDumpRequiresDataOnlyError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * Two mutually exclusive flags were set together. Reproduces cobra's @@ -20,7 +29,11 @@ export class LegacyDbDumpMutuallyExclusiveFlagsError extends Data.TaggedError( "LegacyDbDumpMutuallyExclusiveFlagsError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * Failed to open the `--file` output path. Byte-matches Go's @@ -28,7 +41,11 @@ export class LegacyDbDumpMutuallyExclusiveFlagsError extends Data.TaggedError( */ export class LegacyDbDumpOpenFileError extends Data.TaggedError("LegacyDbDumpOpenFileError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * The pg_dump container exited non-zero. Byte-matches Go's @@ -41,4 +58,8 @@ export class LegacyDbDumpRunError extends Data.TaggedError("LegacyDbDumpRunError // transaction-pooler guidance. `Output.fail` prints it bare on stderr after the // error message, mirroring Go's `recoverAndExit`. readonly suggestion?: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbConnection; + } +} diff --git a/apps/cli/src/legacy/commands/db/dump/dump.integration.test.ts b/apps/cli/src/legacy/commands/db/dump/dump.integration.test.ts index 3c16d07f08..ef76a95883 100644 --- a/apps/cli/src/legacy/commands/db/dump/dump.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/dump/dump.integration.test.ts @@ -112,7 +112,11 @@ function mockDockerRun(opts: { allOpts.push(runOpts); if (opts.runFails === true) { return Effect.fail( - new LegacyDockerRunError({ message: "failed to run docker: not found" }), + new LegacyDockerRunError({ + message: "failed to run docker: not found", + reason: "spawn", + daemonDown: false, + }), ); } const next = queue.shift(); @@ -130,7 +134,11 @@ function mockDockerRun(opts: { allOpts.push(runOpts); if (opts.runFails === true) { return yield* Effect.fail( - new LegacyDockerRunError({ message: "failed to run docker: not found" }), + new LegacyDockerRunError({ + message: "failed to run docker: not found", + reason: "spawn", + daemonDown: false, + }), ); } const next = queue.shift(); diff --git a/apps/cli/src/legacy/commands/db/lint/lint.errors.ts b/apps/cli/src/legacy/commands/db/lint/lint.errors.ts index 73c295a688..de21f7b547 100644 --- a/apps/cli/src/legacy/commands/db/lint/lint.errors.ts +++ b/apps/cli/src/legacy/commands/db/lint/lint.errors.ts @@ -1,5 +1,11 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; + /** * Tagged errors for `db lint`, one per Go failure path * (`internal/db/lint/lint.go`). The `message` byte-matches Go's `errors.Errorf` @@ -12,34 +18,62 @@ import { Data } from "effect"; /** cobra `MarkFlagsMutuallyExclusive("db-url", "linked", "local")` (`db.go`). */ export class LegacyDbLintMutuallyExclusiveFlagsError extends Data.TaggedError( "LegacyDbLintMutuallyExclusiveFlagsError", -)<{ readonly message: string }> {} +)<{ readonly message: string }> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** `failed to begin transaction: %w` (`lint.go:111`). */ export class LegacyDbLintBeginTxError extends Data.TaggedError("LegacyDbLintBeginTxError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbConnection; + } +} /** `failed to list schemas: %w` (`drop.go:46`, via `ListUserSchemas`). */ export class LegacyDbLintListSchemasError extends Data.TaggedError("LegacyDbLintListSchemasError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} /** `failed to enable pgsql_check: %w` (`lint.go:126`). */ export class LegacyDbLintEnableCheckError extends Data.TaggedError("LegacyDbLintEnableCheckError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} /** `failed to query rows: %w` (`lint.go:140`). */ export class LegacyDbLintQueryError extends Data.TaggedError("LegacyDbLintQueryError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} /** `failed to marshal json: %w` (`lint.go:151`). */ export class LegacyDbLintMalformedJsonError extends Data.TaggedError( "LegacyDbLintMalformedJsonError", -)<{ readonly message: string }> {} +)<{ readonly message: string }> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} /** `fail-on is set to %s, non-zero exit` (`lint.go:72`). */ export class LegacyDbLintFailOnError extends Data.TaggedError("LegacyDbLintFailOnError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} diff --git a/apps/cli/src/legacy/commands/db/pull/pull.errors.ts b/apps/cli/src/legacy/commands/db/pull/pull.errors.ts index 25c3c36300..2b2f038b89 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.errors.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.errors.ts @@ -1,5 +1,11 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; + /** * Conflicting database-target flags. Reproduces cobra's * `MarkFlagsMutuallyExclusive("db-url", "linked", "local")` error byte-for-byte @@ -7,7 +13,11 @@ import { Data } from "effect"; */ export class LegacyDbPullTargetFlagsError extends Data.TaggedError("LegacyDbPullTargetFlagsError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * `--declarative` / `--use-pg-delta` combined with `--diff-engine`. Reproduces @@ -18,7 +28,11 @@ export class LegacyDbPullEngineConflictError extends Data.TaggedError( "LegacyDbPullEngineConflictError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * The remote migration history does not match local files. Byte-matches Go's @@ -30,7 +44,11 @@ export class LegacyDbPullMigrationConflictError extends Data.TaggedError( )<{ readonly message: string; readonly suggestion: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.migrationDrift; + } +} /** * The diff produced no schema changes. Byte-matches Go's `errInSync` @@ -40,7 +58,11 @@ export class LegacyDbPullMigrationConflictError extends Data.TaggedError( */ export class LegacyDbPullInSyncError extends Data.TaggedError("LegacyDbPullInSyncError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} /** * Writing the migration file / updating the remote migration-history table failed. @@ -48,7 +70,11 @@ export class LegacyDbPullInSyncError extends Data.TaggedError("LegacyDbPullInSyn */ export class LegacyDbPullWriteError extends Data.TaggedError("LegacyDbPullWriteError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.permission; + } +} /** * The initial-pull pg_dump container exited non-zero. Go's `dumpRemoteSchema` @@ -60,4 +86,8 @@ export class LegacyDbPullWriteError extends Data.TaggedError("LegacyDbPullWriteE export class LegacyDbPullDumpError extends Data.TaggedError("LegacyDbPullDumpError")<{ readonly message: string; readonly suggestion?: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbConnection; + } +} diff --git a/apps/cli/src/legacy/commands/db/push/push.errors.ts b/apps/cli/src/legacy/commands/db/push/push.errors.ts index 3849d55add..a22977b18d 100644 --- a/apps/cli/src/legacy/commands/db/push/push.errors.ts +++ b/apps/cli/src/legacy/commands/db/push/push.errors.ts @@ -1,4 +1,9 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; /** * Conflicting database-target flags. Reproduces cobra's @@ -7,7 +12,11 @@ import { Data } from "effect"; */ export class LegacyDbPushTargetFlagsError extends Data.TaggedError("LegacyDbPushTargetFlagsError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * Remote migration versions are missing from the local directory. Byte-matches @@ -19,7 +28,11 @@ export class LegacyDbPushMissingLocalError extends Data.TaggedError( )<{ readonly message: string; readonly suggestion: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.migrationDrift; + } +} /** * Local migration files are ordered before the remote head and `--include-all` @@ -31,7 +44,11 @@ export class LegacyDbPushMissingRemoteError extends Data.TaggedError( )<{ readonly message: string; readonly suggestion: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.migrationDrift; + } +} /** * The user declined a confirmation prompt. Go returns `errors.New(context.Canceled)` @@ -39,12 +56,20 @@ export class LegacyDbPushMissingRemoteError extends Data.TaggedError( */ export class LegacyDbPushCancelledError extends Data.TaggedError("LegacyDbPushCancelledError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.cancelled; + } +} /** Locating `supabase/roles.sql` failed (Go's `failed to find custom roles: %w`). */ export class LegacyDbPushRolesError extends Data.TaggedError("LegacyDbPushRolesError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} /** * A migration / seed / globals / vault statement failed while applying. Carries @@ -53,4 +78,8 @@ export class LegacyDbPushRolesError extends Data.TaggedError("LegacyDbPushRolesE */ export class LegacyDbPushApplyError extends Data.TaggedError("LegacyDbPushApplyError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} diff --git a/apps/cli/src/legacy/commands/db/query/query.errors.ts b/apps/cli/src/legacy/commands/db/query/query.errors.ts index ac7f3f53e3..617461c3f8 100644 --- a/apps/cli/src/legacy/commands/db/query/query.errors.ts +++ b/apps/cli/src/legacy/commands/db/query/query.errors.ts @@ -1,4 +1,9 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; /** * No SQL was provided by any source. Byte-matches Go's @@ -7,17 +12,29 @@ import { Data } from "effect"; */ export class LegacyDbQueryNoSqlError extends Data.TaggedError("LegacyDbQueryNoSqlError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** Stdin was piped but empty. Byte-matches Go's `"no SQL provided via stdin"`. */ export class LegacyDbQueryNoStdinSqlError extends Data.TaggedError("LegacyDbQueryNoStdinSqlError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** `--file` could not be read. Byte-matches Go's `"failed to read SQL file: " + err`. */ export class LegacyDbQueryReadFileError extends Data.TaggedError("LegacyDbQueryReadFileError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * `--linked` was used without an access token. Mirrors Go's PreRunE, which @@ -29,12 +46,20 @@ export class LegacyDbQueryLoginRequiredError extends Data.TaggedError( )<{ readonly message: string; readonly suggestion: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.authLogin; + } +} /** Query execution failed. Byte-matches Go's `"failed to execute query: " + err`. */ export class LegacyDbQueryExecError extends Data.TaggedError("LegacyDbQueryExecError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} /** * More than one of `--db-url` / `--linked` / `--local` was set. Reproduces @@ -46,7 +71,11 @@ export class LegacyDbQueryMutuallyExclusiveFlagsError extends Data.TaggedError( "LegacyDbQueryMutuallyExclusiveFlagsError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * The linked Management API returned a non-201 status. Byte-matches Go's @@ -56,4 +85,8 @@ export class LegacyDbQueryUnexpectedStatusError extends Data.TaggedError( "LegacyDbQueryUnexpectedStatusError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.apiStatus; + } +} diff --git a/apps/cli/src/legacy/commands/db/reset/reset.errors.ts b/apps/cli/src/legacy/commands/db/reset/reset.errors.ts index bfb4c3e539..86dc6b1188 100644 --- a/apps/cli/src/legacy/commands/db/reset/reset.errors.ts +++ b/apps/cli/src/legacy/commands/db/reset/reset.errors.ts @@ -1,5 +1,11 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; + /** * Conflicting database-target flags. Reproduces cobra's * `MarkFlagsMutuallyExclusive("db-url", "linked", "local")` (`cmd/db.go:573`). @@ -8,7 +14,11 @@ export class LegacyDbResetTargetFlagsError extends Data.TaggedError( "LegacyDbResetTargetFlagsError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * `--version` and `--last` together. Reproduces cobra's @@ -18,7 +28,11 @@ export class LegacyDbResetVersionFlagsError extends Data.TaggedError( "LegacyDbResetVersionFlagsError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * `--version` is not a valid integer. Byte-matches Go's @@ -28,7 +42,11 @@ export class LegacyDbResetInvalidVersionError extends Data.TaggedError( "LegacyDbResetInvalidVersionError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * No migration file matches `--version`. Byte-matches Go's @@ -39,7 +57,11 @@ export class LegacyDbResetMigrationFileError extends Data.TaggedError( "LegacyDbResetMigrationFileError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.permission; + } +} /** * The user declined the reset confirmation. Go returns @@ -47,12 +69,20 @@ export class LegacyDbResetMigrationFileError extends Data.TaggedError( */ export class LegacyDbResetCancelledError extends Data.TaggedError("LegacyDbResetCancelledError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.cancelled; + } +} /** A drop / migrate / seed / vault statement failed during the remote reset. */ export class LegacyDbResetApplyError extends Data.TaggedError("LegacyDbResetApplyError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} /** * The local database container is not running. Byte-matches Go's @@ -62,7 +92,11 @@ export class LegacyDbResetApplyError extends Data.TaggedError("LegacyDbResetAppl */ export class LegacyDbResetNotRunningError extends Data.TaggedError("LegacyDbResetNotRunningError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.startStack; + } +} /** * `--last` was given a negative value. Go declares `--last` as an unsigned flag @@ -71,7 +105,11 @@ export class LegacyDbResetNotRunningError extends Data.TaggedError("LegacyDbRese */ export class LegacyDbResetLastFlagError extends Data.TaggedError("LegacyDbResetLastFlagError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * Invalid `--sql-paths` usage. Byte-matches Go's `validateDbResetSeedFlags` @@ -85,4 +123,8 @@ export class LegacyDbResetSeedFlagsError extends Data.TaggedError("LegacyDbReset * `validateDbResetSeedFlags` `utils.CmdSuggestion` (`cmd/db.go`). */ readonly suggestion?: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.errors.ts b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.errors.ts index a97de5224d..9eace27317 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.errors.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.errors.ts @@ -1,5 +1,11 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../../shared/telemetry/error-actionability.ts"; + /** * Declarative commands were invoked without `--experimental` and without * `[experimental.pgdelta] enabled = true`. Byte-matches Go's gate error @@ -12,7 +18,11 @@ export class LegacyDeclarativeNotEnabledError extends Data.TaggedError( )<{ readonly message: string; readonly suggestion: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * A target could not be resolved in non-interactive mode. Byte-matches Go's @@ -24,7 +34,11 @@ export class LegacyDeclarativeNonInteractiveError extends Data.TaggedError( "LegacyDeclarativeNonInteractiveError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * A mutually-exclusive flag group was violated. Reproduces cobra's @@ -37,7 +51,11 @@ export class LegacyDeclarativeMutuallyExclusiveFlagsError extends Data.TaggedErr "LegacyDeclarativeMutuallyExclusiveFlagsError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * The interactive custom-database-URL prompt was empty or unparseable. Byte-matches @@ -48,7 +66,11 @@ export class LegacyDeclarativeInvalidDbUrlError extends Data.TaggedError( "LegacyDeclarativeInvalidDbUrlError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * `db schema declarative generate` ran but produced no declarative files (sync's @@ -59,7 +81,11 @@ export class LegacyDeclarativeNoFilesGeneratedError extends Data.TaggedError( "LegacyDeclarativeNoFilesGeneratedError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} /** * Diffing declarative schema to migrations failed. Wraps @@ -69,7 +95,11 @@ export class LegacyDeclarativeNoFilesGeneratedError extends Data.TaggedError( */ export class LegacyDeclarativeDiffError extends Data.TaggedError("LegacyDeclarativeDiffError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} /** * Applying the generated migration to the local database failed. Wraps Go's @@ -79,7 +109,11 @@ export class LegacyDeclarativeDiffError extends Data.TaggedError("LegacyDeclarat */ export class LegacyDeclarativeApplyError extends Data.TaggedError("LegacyDeclarativeApplyError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} /** * Materializing the declarative export on disk failed. Byte-matches Go's diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.errors.ts b/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.errors.ts index 673e78a466..ea9db1b988 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.errors.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.errors.ts @@ -1,4 +1,9 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; /** * Driving the bundled Go binary's hidden `db __db-bootstrap` seam failed — the @@ -17,4 +22,10 @@ export class LegacyDbBootstrapError extends Data.TaggedError("LegacyDbBootstrapE * runtime's daemon is unreachable (`AssertServiceIsRunning`, `misc.go:148-154`). */ readonly suggestion?: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.suggestion !== undefined + ? { ...actionability.dockerNotRunning, fingerprint_suffix: "docker_not_running" } + : { ...actionability.unknown, fingerprint_suffix: "seam" }; + } +} diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-migra.errors.ts b/apps/cli/src/legacy/commands/db/shared/legacy-migra.errors.ts index 642909c665..c80f93bf1e 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-migra.errors.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-migra.errors.ts @@ -1,5 +1,11 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; + /** * The migra diff failed (edge-runtime run, or the OOM bash fallback in the * `supabase/migra` Docker image). Byte-matches Go's @@ -8,7 +14,11 @@ import { Data } from "effect"; */ export class LegacyMigraDiffError extends Data.TaggedError("LegacyMigraDiffError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} /** * Loading the target's user-defined schemas for the migra bash fallback failed. @@ -18,4 +28,8 @@ export class LegacyMigraDiffError extends Data.TaggedError("LegacyMigraDiffError */ export class LegacyMigraSchemaLoadError extends Data.TaggedError("LegacyMigraSchemaLoadError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.errors.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.errors.ts index b52d173fbe..f662015c4b 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.errors.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.errors.ts @@ -1,5 +1,11 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; + /** * The pg-delta edge-runtime script failed. Byte-matches Go's * `": :\n"` wrapping in `RunEdgeRuntimeScript` @@ -11,7 +17,11 @@ export class LegacyDeclarativeEdgeRuntimeError extends Data.TaggedError( "LegacyDeclarativeEdgeRuntimeError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} /** * Setting up / connecting to / migrating the throwaway shadow database failed. @@ -23,7 +33,11 @@ export class LegacyDeclarativeShadowDbError extends Data.TaggedError( "LegacyDeclarativeShadowDbError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbConnection; + } +} /** * Exporting declarative schema produced no output. Byte-matches Go's @@ -35,7 +49,11 @@ export class LegacyDeclarativeEmptyOutputError extends Data.TaggedError( "LegacyDeclarativeEmptyOutputError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} /** * Parsing the declarative export envelope failed. Byte-matches Go's @@ -46,7 +64,11 @@ export class LegacyDeclarativeParseOutputError extends Data.TaggedError( "LegacyDeclarativeParseOutputError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} /** * Materializing the declarative export on disk failed. Byte-matches Go's @@ -57,4 +79,8 @@ export class LegacyDeclarativeParseOutputError extends Data.TaggedError( */ export class LegacyDeclarativeWriteError extends Data.TaggedError("LegacyDeclarativeWriteError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.permission; + } +} diff --git a/apps/cli/src/legacy/commands/domains/domains.errors.ts b/apps/cli/src/legacy/commands/domains/domains.errors.ts index 11b68b3b65..448e29839a 100644 --- a/apps/cli/src/legacy/commands/domains/domains.errors.ts +++ b/apps/cli/src/legacy/commands/domains/domains.errors.ts @@ -1,28 +1,42 @@ import { Data } from "effect"; import { mapLegacyHttpError } from "../../shared/legacy-http-errors.ts"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../../../shared/telemetry/error-actionability.ts"; /** * Transport-level failure talking to the Management API custom-hostname * endpoints. Mirrors Go's `errors.Errorf("failed to custom hostname: %w", err)` * (`apps/cli-go/internal/hostnames/*`). */ -class LegacyDomainsNetworkError extends Data.TaggedError("LegacyDomainsNetworkError")<{ +export class LegacyDomainsNetworkError extends Data.TaggedError("LegacyDomainsNetworkError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} /** * The custom-hostname endpoint returned a status the Go CLI does not treat as * success (201 for create/reverify/activate, 200 for get/delete). Mirrors Go's * `errors.Errorf("unexpected hostname status %d: %s", code, body)`. */ -class LegacyDomainsUnexpectedStatusError extends Data.TaggedError( +export class LegacyDomainsUnexpectedStatusError extends Data.TaggedError( "LegacyDomainsUnexpectedStatusError", )<{ readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} /** * The CNAME pre-check in `domains create` failed — either the DNS lookup did @@ -31,7 +45,11 @@ class LegacyDomainsUnexpectedStatusError extends Data.TaggedError( */ export class LegacyDomainsCnameError extends Data.TaggedError("LegacyDomainsCnameError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} /** * Build the network/status error mapper for a custom-hostname subcommand. The diff --git a/apps/cli/src/legacy/commands/encryption/encryption.errors.ts b/apps/cli/src/legacy/commands/encryption/encryption.errors.ts index cf57170f97..7a1fbfc699 100644 --- a/apps/cli/src/legacy/commands/encryption/encryption.errors.ts +++ b/apps/cli/src/legacy/commands/encryption/encryption.errors.ts @@ -1,5 +1,11 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../../../shared/telemetry/error-actionability.ts"; import { mapLegacyHttpError } from "../../shared/legacy-http-errors.ts"; /** @@ -7,22 +13,30 @@ import { mapLegacyHttpError } from "../../shared/legacy-http-errors.ts"; * Mirrors Go's `errors.Errorf("failed to pgsodium config: %w", err)` * (`apps/cli-go/internal/encryption/{get,update}`). */ -class LegacyEncryptionNetworkError extends Data.TaggedError("LegacyEncryptionNetworkError")<{ +export class LegacyEncryptionNetworkError extends Data.TaggedError("LegacyEncryptionNetworkError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} /** * The pgsodium endpoint returned a status the Go CLI does not treat as success * (it only accepts `JSON200`). Mirrors Go's * `errors.Errorf("unexpected pgsodium config status %d: %s", code, body)`. */ -class LegacyEncryptionUnexpectedStatusError extends Data.TaggedError( +export class LegacyEncryptionUnexpectedStatusError extends Data.TaggedError( "LegacyEncryptionUnexpectedStatusError", )<{ readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} /** * Build the network/status error mapper for an encryption subcommand. Go uses diff --git a/apps/cli/src/legacy/commands/functions/list/list.errors.ts b/apps/cli/src/legacy/commands/functions/list/list.errors.ts index b4348baf5c..66ee3560cd 100644 --- a/apps/cli/src/legacy/commands/functions/list/list.errors.ts +++ b/apps/cli/src/legacy/commands/functions/list/list.errors.ts @@ -1,10 +1,20 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../../../../shared/telemetry/error-actionability.ts"; export class LegacyFunctionsListNetworkError extends Data.TaggedError( "LegacyFunctionsListNetworkError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} export class LegacyFunctionsListUnexpectedStatusError extends Data.TaggedError( "LegacyFunctionsListUnexpectedStatusError", @@ -12,10 +22,18 @@ export class LegacyFunctionsListUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} export class LegacyFunctionsEnvNotSupportedError extends Data.TaggedError( "LegacyFunctionsEnvNotSupportedError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} diff --git a/apps/cli/src/legacy/commands/functions/new/new.errors.ts b/apps/cli/src/legacy/commands/functions/new/new.errors.ts index ac34daf516..2d5916b53c 100644 --- a/apps/cli/src/legacy/commands/functions/new/new.errors.ts +++ b/apps/cli/src/legacy/commands/functions/new/new.errors.ts @@ -1,11 +1,20 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; export class LegacyFunctionsNewInvalidSlugError extends Data.TaggedError( "LegacyFunctionsNewInvalidSlugError", )<{ readonly message: string; readonly detail: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} export class LegacyFunctionsNewFileExistsError extends Data.TaggedError( "LegacyFunctionsNewFileExistsError", @@ -13,12 +22,20 @@ export class LegacyFunctionsNewFileExistsError extends Data.TaggedError( readonly path: string; readonly message: string; readonly suggestion: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} export class LegacyFunctionsNewWriteError extends Data.TaggedError("LegacyFunctionsNewWriteError")<{ readonly path: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.permission; + } +} /** * Maps an arbitrary thrown cause from a filesystem write to a typed diff --git a/apps/cli/src/legacy/commands/gen/signing-key/signing-key.errors.ts b/apps/cli/src/legacy/commands/gen/signing-key/signing-key.errors.ts index cf53a22656..10e806e398 100644 --- a/apps/cli/src/legacy/commands/gen/signing-key/signing-key.errors.ts +++ b/apps/cli/src/legacy/commands/gen/signing-key/signing-key.errors.ts @@ -1,35 +1,64 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; export class LegacyGenSigningKeyConfigParseError extends Data.TaggedError( "LegacyGenSigningKeyConfigParseError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} export class LegacyGenSigningKeyGenerateError extends Data.TaggedError( "LegacyGenSigningKeyGenerateError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.internalPanic; + } +} export class LegacyGenSigningKeyReadError extends Data.TaggedError("LegacyGenSigningKeyReadError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.permission; + } +} export class LegacyGenSigningKeyDecodeError extends Data.TaggedError( "LegacyGenSigningKeyDecodeError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} export class LegacyGenSigningKeyWriteError extends Data.TaggedError( "LegacyGenSigningKeyWriteError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.permission; + } +} export class LegacyGenSigningKeyCancelledError extends Data.TaggedError( "LegacyGenSigningKeyCancelledError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.cancelled; + } +} diff --git a/apps/cli/src/legacy/commands/gen/types/types.errors.ts b/apps/cli/src/legacy/commands/gen/types/types.errors.ts index 5dde3f3fef..8772eca809 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.errors.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.errors.ts @@ -1,8 +1,18 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../../../../shared/telemetry/error-actionability.ts"; export class LegacyGenTypesNetworkError extends Data.TaggedError("LegacyGenTypesNetworkError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} export class LegacyGenTypesUnexpectedStatusError extends Data.TaggedError( "LegacyGenTypesUnexpectedStatusError", @@ -10,16 +20,28 @@ export class LegacyGenTypesUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} export class LegacyInvalidGenTypesDurationError extends Data.TaggedError( "LegacyInvalidGenTypesDurationError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} export class LegacyInvalidGenTypesDatabaseUrlError extends Data.TaggedError( "LegacyInvalidGenTypesDatabaseUrlError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} diff --git a/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-query.ts b/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-query.ts index 345ad9185a..665fe22b13 100644 --- a/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-query.ts +++ b/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-query.ts @@ -3,6 +3,11 @@ import { Data, Effect, Option } from "effect"; import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; import { LegacyDnsResolverFlag } from "../../../../shared/legacy/global-flags.ts"; import { Output } from "../../../../shared/output/output.service.ts"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; import { renderGlamourTable } from "../../../output/legacy-glamour-table.ts"; import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; import type { LegacyResolvedDbConfig } from "../../../shared/legacy-db-config.types.ts"; @@ -60,7 +65,11 @@ export interface LegacyInspectQuerySpec { */ export class LegacyInspectMutuallyExclusiveFlagsError extends Data.TaggedError( "LegacyInspectMutuallyExclusiveFlagsError", -)<{ readonly message: string }> {} +)<{ readonly message: string }> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} // --------------------------------------------------------------------------- // Cell formatters — pure, exported, unit-tested. Each reproduces a Go `fmt` diff --git a/apps/cli/src/legacy/commands/inspect/report/report.errors.ts b/apps/cli/src/legacy/commands/inspect/report/report.errors.ts index 57ef7a82e4..32f043e77c 100644 --- a/apps/cli/src/legacy/commands/inspect/report/report.errors.ts +++ b/apps/cli/src/legacy/commands/inspect/report/report.errors.ts @@ -1,4 +1,9 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; /** * Creating the dated `//` directory failed. Mirrors Go's @@ -7,7 +12,11 @@ import { Data } from "effect"; */ export class LegacyInspectReportMkdirError extends Data.TaggedError( "LegacyInspectReportMkdirError", -)<{ readonly message: string }> {} +)<{ readonly message: string }> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.permission; + } +} /** * Writing one of the report CSV files failed. Mirrors Go's `copyToCSV` @@ -18,4 +27,8 @@ export class LegacyInspectReportMkdirError extends Data.TaggedError( */ export class LegacyInspectReportWriteError extends Data.TaggedError( "LegacyInspectReportWriteError", -)<{ readonly message: string }> {} +)<{ readonly message: string }> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.permission; + } +} diff --git a/apps/cli/src/legacy/commands/link/link.errors.ts b/apps/cli/src/legacy/commands/link/link.errors.ts index 504965d46a..2a283e88e5 100644 --- a/apps/cli/src/legacy/commands/link/link.errors.ts +++ b/apps/cli/src/legacy/commands/link/link.errors.ts @@ -1,11 +1,21 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../../../shared/telemetry/error-actionability.ts"; /** Transport failure while fetching `GET /v1/projects/{ref}`. */ export class LegacyLinkProjectStatusNetworkError extends Data.TaggedError( "LegacyLinkProjectStatusNetworkError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} /** * `GET /v1/projects/{ref}` returned a non-200, non-404 status. Byte-matches Go's @@ -15,7 +25,11 @@ export class LegacyLinkProjectStatusError extends Data.TaggedError("LegacyLinkPr readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} /** * The remote project is paused (`status == INACTIVE`). Message `"project is paused"` @@ -25,14 +39,22 @@ export class LegacyLinkProjectStatusError extends Data.TaggedError("LegacyLinkPr export class LegacyProjectPausedError extends Data.TaggedError("LegacyProjectPausedError")<{ readonly message: string; readonly suggestion: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} /** Transport failure while fetching `GET /v1/projects/{ref}/api-keys`. */ export class LegacyLinkApiKeysNetworkError extends Data.TaggedError( "LegacyLinkApiKeysNetworkError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} /** * `GET /v1/projects/{ref}/api-keys` returned a non-200 status. Byte-matches Go's @@ -43,7 +65,11 @@ export class LegacyLinkAuthTokenError extends Data.TaggedError("LegacyLinkAuthTo readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.authLogin; + } +} /** * The api-keys response contained no usable anon/service-role key. Byte-matches @@ -51,4 +77,8 @@ export class LegacyLinkAuthTokenError extends Data.TaggedError("LegacyLinkAuthTo */ export class LegacyLinkMissingKeyError extends Data.TaggedError("LegacyLinkMissingKeyError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.permission; + } +} diff --git a/apps/cli/src/legacy/commands/login/login.errors.ts b/apps/cli/src/legacy/commands/login/login.errors.ts index 65b9c5aab1..f8fb6e54f1 100644 --- a/apps/cli/src/legacy/commands/login/login.errors.ts +++ b/apps/cli/src/legacy/commands/login/login.errors.ts @@ -1,5 +1,11 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../shared/telemetry/error-actionability.ts"; + /** * Go's `ErrMissingToken` (`apps/cli-go/cmd/login.go:16`). Go Aqua-styles the * `--token` / `SUPABASE_ACCESS_TOKEN` substrings, but the legacy port renders @@ -12,12 +18,20 @@ export const LEGACY_LOGIN_MISSING_TOKEN_MESSAGE = /** Token-path save failure — Go's `cannot save provided token: %w` (`login.go:171`). */ export class LegacyLoginSaveTokenError extends Data.TaggedError("LegacyLoginSaveTokenError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.authLogin; + } +} /** Non-TTY environment with no token supplied (`login.go:34-35`). */ export class LegacyLoginMissingTokenError extends Data.TaggedError("LegacyLoginMissingTokenError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.authToken; + } +} /** * A single login-session poll/parse failure. Carries the underlying message so @@ -27,19 +41,35 @@ export class LegacyLoginMissingTokenError extends Data.TaggedError("LegacyLoginM */ export class LegacyLoginVerificationError extends Data.TaggedError("LegacyLoginVerificationError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.authLogin; + } +} /** All verification retries exhausted (`login.go:214-216`). */ export class LegacyLoginFailedError extends Data.TaggedError("LegacyLoginFailedError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.authLogin; + } +} /** ECDH / AES-GCM decryption failure — Go's `cannot decrypt access token` (`login.go:47`). */ export class LegacyLoginDecryptError extends Data.TaggedError("LegacyLoginDecryptError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.authLogin; + } +} /** ECDH keypair generation failure — Go's `cannot generate crypto keys` (`login.go:66`). */ export class LegacyLoginCryptoError extends Data.TaggedError("LegacyLoginCryptoError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.internalPanic; + } +} diff --git a/apps/cli/src/legacy/commands/logout/logout.errors.ts b/apps/cli/src/legacy/commands/logout/logout.errors.ts index 1f3dd768f6..acedafcd1b 100644 --- a/apps/cli/src/legacy/commands/logout/logout.errors.ts +++ b/apps/cli/src/legacy/commands/logout/logout.errors.ts @@ -1,4 +1,9 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../shared/telemetry/error-actionability.ts"; /** * Raised when the user declines the logout confirmation prompt. Go returns @@ -9,6 +14,10 @@ import { Data } from "effect"; */ export class LegacyLogoutCancelledError extends Data.TaggedError("LegacyLogoutCancelledError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.cancelled; + } +} export const LEGACY_LOGOUT_CANCELLED_MESSAGE = "context canceled"; diff --git a/apps/cli/src/legacy/commands/migration/down/down.errors.ts b/apps/cli/src/legacy/commands/migration/down/down.errors.ts index a243205914..d17e86a0b6 100644 --- a/apps/cli/src/legacy/commands/migration/down/down.errors.ts +++ b/apps/cli/src/legacy/commands/migration/down/down.errors.ts @@ -1,9 +1,18 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; /** `--last 0`. Byte-matches Go's `--last must be greater than 0` (`down.go:21`). */ export class LegacyMigrationLastZeroError extends Data.TaggedError("LegacyMigrationLastZeroError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * `--last` >= the number of applied migrations. Byte-matches Go's @@ -15,4 +24,8 @@ export class LegacyMigrationLastTooLargeError extends Data.TaggedError( )<{ readonly message: string; readonly suggestion: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} diff --git a/apps/cli/src/legacy/commands/migration/fetch/fetch.errors.ts b/apps/cli/src/legacy/commands/migration/fetch/fetch.errors.ts index 3cc8289327..6418ad4af0 100644 --- a/apps/cli/src/legacy/commands/migration/fetch/fetch.errors.ts +++ b/apps/cli/src/legacy/commands/migration/fetch/fetch.errors.ts @@ -1,5 +1,11 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; + /** * Writing a fetched migration file failed. Byte-matches Go's * `failed to write migration: %w` (`internal/migration/fetch/fetch.go:38`). @@ -8,4 +14,8 @@ export class LegacyMigrationFetchWriteError extends Data.TaggedError( "LegacyMigrationFetchWriteError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.permission; + } +} diff --git a/apps/cli/src/legacy/commands/migration/migration.errors.ts b/apps/cli/src/legacy/commands/migration/migration.errors.ts index 09d3f96bb8..af8d0ad977 100644 --- a/apps/cli/src/legacy/commands/migration/migration.errors.ts +++ b/apps/cli/src/legacy/commands/migration/migration.errors.ts @@ -1,5 +1,11 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../shared/telemetry/error-actionability.ts"; + /** * Conflicting database-target flags. Reproduces cobra's * `MarkFlagsMutuallyExclusive("db-url", "linked", "local")` error byte-for-byte @@ -10,7 +16,11 @@ export class LegacyMigrationTargetFlagsError extends Data.TaggedError( "LegacyMigrationTargetFlagsError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * `--db-url` combined with `--password`/`-p`. Reproduces cobra's @@ -20,7 +30,11 @@ export class LegacyMigrationPasswordFlagsError extends Data.TaggedError( "LegacyMigrationPasswordFlagsError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * A positional version argument is not a valid integer. Byte-matches Go's @@ -30,7 +44,11 @@ export class LegacyMigrationInvalidVersionError extends Data.TaggedError( "LegacyMigrationInvalidVersionError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * No local migration file matched the requested version glob. Byte-matches Go's @@ -41,7 +59,11 @@ export class LegacyMigrationFileNotFoundError extends Data.TaggedError( "LegacyMigrationFileNotFoundError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * The user declined a confirmation prompt (overwrite / repair-all / revert). @@ -50,4 +72,8 @@ export class LegacyMigrationFileNotFoundError extends Data.TaggedError( */ export class LegacyOperationCanceledError extends Data.TaggedError("LegacyOperationCanceledError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.cancelled; + } +} diff --git a/apps/cli/src/legacy/commands/migration/new/new.errors.ts b/apps/cli/src/legacy/commands/migration/new/new.errors.ts index a6f8d07cc1..fb866e0e45 100644 --- a/apps/cli/src/legacy/commands/migration/new/new.errors.ts +++ b/apps/cli/src/legacy/commands/migration/new/new.errors.ts @@ -1,4 +1,9 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; /** * Creating the migrations directory or writing the new migration file failed. @@ -7,4 +12,8 @@ import { Data } from "effect"; */ export class LegacyMigrationNewWriteError extends Data.TaggedError("LegacyMigrationNewWriteError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.permission; + } +} diff --git a/apps/cli/src/legacy/commands/migration/repair/repair.errors.ts b/apps/cli/src/legacy/commands/migration/repair/repair.errors.ts index 7b85614e33..ec9c2294b4 100644 --- a/apps/cli/src/legacy/commands/migration/repair/repair.errors.ts +++ b/apps/cli/src/legacy/commands/migration/repair/repair.errors.ts @@ -1,4 +1,9 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; /** * Applying the repair batch (TRUNCATE / UPSERT / DELETE) failed. Byte-matches @@ -9,4 +14,8 @@ export class LegacyMigrationRepairUpdateError extends Data.TaggedError( "LegacyMigrationRepairUpdateError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} diff --git a/apps/cli/src/legacy/commands/migration/up/up.errors.ts b/apps/cli/src/legacy/commands/migration/up/up.errors.ts index 683c2c8c25..84f37b2297 100644 --- a/apps/cli/src/legacy/commands/migration/up/up.errors.ts +++ b/apps/cli/src/legacy/commands/migration/up/up.errors.ts @@ -1,4 +1,9 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; /** * A remote migration version is not present in the local migrations directory. @@ -10,7 +15,11 @@ export class LegacyMigrationMissingLocalError extends Data.TaggedError( )<{ readonly message: string; readonly suggestion: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.migrationDrift; + } +} /** * Out-of-order local migrations exist before the last remote migration, and @@ -23,4 +32,8 @@ export class LegacyMigrationMissingRemoteError extends Data.TaggedError( )<{ readonly message: string; readonly suggestion: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.migrationDrift; + } +} diff --git a/apps/cli/src/legacy/commands/network-bans/network-bans.errors.ts b/apps/cli/src/legacy/commands/network-bans/network-bans.errors.ts index 6e9459a1fb..1d98722024 100644 --- a/apps/cli/src/legacy/commands/network-bans/network-bans.errors.ts +++ b/apps/cli/src/legacy/commands/network-bans/network-bans.errors.ts @@ -1,10 +1,21 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../../../shared/telemetry/error-actionability.ts"; + export class LegacyNetworkBansGetNetworkError extends Data.TaggedError( "LegacyNetworkBansGetNetworkError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} export class LegacyNetworkBansGetUnexpectedStatusError extends Data.TaggedError( "LegacyNetworkBansGetUnexpectedStatusError", @@ -12,13 +23,21 @@ export class LegacyNetworkBansGetUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} export class LegacyNetworkBansRemoveNetworkError extends Data.TaggedError( "LegacyNetworkBansRemoveNetworkError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} export class LegacyNetworkBansRemoveUnexpectedStatusError extends Data.TaggedError( "LegacyNetworkBansRemoveUnexpectedStatusError", @@ -26,13 +45,21 @@ export class LegacyNetworkBansRemoveUnexpectedStatusError extends Data.TaggedErr readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} export class LegacyNetworkBansEnvNotSupportedError extends Data.TaggedError( "LegacyNetworkBansEnvNotSupportedError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} export class LegacyNetworkBansInvalidIpError extends Data.TaggedError( "LegacyNetworkBansInvalidIpError", @@ -43,4 +70,8 @@ export class LegacyNetworkBansInvalidIpError extends Data.TaggedError( constructor(args: { readonly input: string }) { super({ input: args.input, message: `invalid IP address: ${args.input}` }); } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } } diff --git a/apps/cli/src/legacy/commands/network-restrictions/network-restrictions.errors.ts b/apps/cli/src/legacy/commands/network-restrictions/network-restrictions.errors.ts index 8e7b0b2bf0..6f619976b8 100644 --- a/apps/cli/src/legacy/commands/network-restrictions/network-restrictions.errors.ts +++ b/apps/cli/src/legacy/commands/network-restrictions/network-restrictions.errors.ts @@ -1,10 +1,20 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../../../shared/telemetry/error-actionability.ts"; export class LegacyNetworkRestrictionsGetNetworkError extends Data.TaggedError( "LegacyNetworkRestrictionsGetNetworkError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} export class LegacyNetworkRestrictionsGetUnexpectedStatusError extends Data.TaggedError( "LegacyNetworkRestrictionsGetUnexpectedStatusError", @@ -12,13 +22,21 @@ export class LegacyNetworkRestrictionsGetUnexpectedStatusError extends Data.Tagg readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} export class LegacyNetworkRestrictionsUpdateNetworkError extends Data.TaggedError( "LegacyNetworkRestrictionsUpdateNetworkError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} export class LegacyNetworkRestrictionsUpdateUnexpectedStatusError extends Data.TaggedError( "LegacyNetworkRestrictionsUpdateUnexpectedStatusError", @@ -26,7 +44,11 @@ export class LegacyNetworkRestrictionsUpdateUnexpectedStatusError extends Data.T readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} export class LegacyNetworkRestrictionsInvalidCidrError extends Data.TaggedError( "LegacyNetworkRestrictionsInvalidCidrError", @@ -38,6 +60,10 @@ export class LegacyNetworkRestrictionsInvalidCidrError extends Data.TaggedError( // Verbatim Go string from `apps/cli-go/internal/restrictions/update/update.go:23`. super({ input: args.input, message: `failed to parse IP: ${args.input}` }); } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } } export class LegacyNetworkRestrictionsPrivateIpError extends Data.TaggedError( @@ -50,4 +76,8 @@ export class LegacyNetworkRestrictionsPrivateIpError extends Data.TaggedError( // Verbatim Go string from `apps/cli-go/internal/restrictions/update/update.go:26`. super({ input: args.input, message: `private IP provided: ${args.input}` }); } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } } diff --git a/apps/cli/src/legacy/commands/orgs/orgs.errors.ts b/apps/cli/src/legacy/commands/orgs/orgs.errors.ts index 494060d003..76863e0025 100644 --- a/apps/cli/src/legacy/commands/orgs/orgs.errors.ts +++ b/apps/cli/src/legacy/commands/orgs/orgs.errors.ts @@ -1,4 +1,10 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../../../shared/telemetry/error-actionability.ts"; // --------------------------------------------------------------------------- // HTTP-bound errors — one (Network + UnexpectedStatus) pair per Go errorf site @@ -7,7 +13,11 @@ import { Data } from "effect"; export class LegacyOrgsListNetworkError extends Data.TaggedError("LegacyOrgsListNetworkError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} export class LegacyOrgsListUnexpectedStatusError extends Data.TaggedError( "LegacyOrgsListUnexpectedStatusError", @@ -15,11 +25,19 @@ export class LegacyOrgsListUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} export class LegacyOrgsCreateNetworkError extends Data.TaggedError("LegacyOrgsCreateNetworkError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} export class LegacyOrgsCreateUnexpectedStatusError extends Data.TaggedError( "LegacyOrgsCreateUnexpectedStatusError", @@ -27,7 +45,11 @@ export class LegacyOrgsCreateUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} // --------------------------------------------------------------------------- // Pure-path error — `orgs list --output env` is explicitly rejected by the Go @@ -40,4 +62,8 @@ export class LegacyOrgsEnvNotSupportedError extends Data.TaggedError( "LegacyOrgsEnvNotSupportedError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} diff --git a/apps/cli/src/legacy/commands/postgres-config/postgres-config.errors.ts b/apps/cli/src/legacy/commands/postgres-config/postgres-config.errors.ts index a15a8c4c5f..27f4431c16 100644 --- a/apps/cli/src/legacy/commands/postgres-config/postgres-config.errors.ts +++ b/apps/cli/src/legacy/commands/postgres-config/postgres-config.errors.ts @@ -1,10 +1,20 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../../../shared/telemetry/error-actionability.ts"; export class LegacyPostgresConfigGetNetworkError extends Data.TaggedError( "LegacyPostgresConfigGetNetworkError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} export class LegacyPostgresConfigGetUnexpectedStatusError extends Data.TaggedError( "LegacyPostgresConfigGetUnexpectedStatusError", @@ -12,19 +22,31 @@ export class LegacyPostgresConfigGetUnexpectedStatusError extends Data.TaggedErr readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} export class LegacyPostgresConfigGetUnmarshalError extends Data.TaggedError( "LegacyPostgresConfigGetUnmarshalError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.apiStatus; + } +} export class LegacyPostgresConfigUpdateNetworkError extends Data.TaggedError( "LegacyPostgresConfigUpdateNetworkError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} export class LegacyPostgresConfigUpdateUnexpectedStatusError extends Data.TaggedError( "LegacyPostgresConfigUpdateUnexpectedStatusError", @@ -32,25 +54,41 @@ export class LegacyPostgresConfigUpdateUnexpectedStatusError extends Data.Tagged readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} export class LegacyPostgresConfigUpdateUnmarshalError extends Data.TaggedError( "LegacyPostgresConfigUpdateUnmarshalError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.apiStatus; + } +} export class LegacyPostgresConfigUpdateSerializeError extends Data.TaggedError( "LegacyPostgresConfigUpdateSerializeError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} export class LegacyPostgresConfigDeleteNetworkError extends Data.TaggedError( "LegacyPostgresConfigDeleteNetworkError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} export class LegacyPostgresConfigDeleteUnexpectedStatusError extends Data.TaggedError( "LegacyPostgresConfigDeleteUnexpectedStatusError", @@ -58,19 +96,31 @@ export class LegacyPostgresConfigDeleteUnexpectedStatusError extends Data.Tagged readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} export class LegacyPostgresConfigDeleteUnmarshalError extends Data.TaggedError( "LegacyPostgresConfigDeleteUnmarshalError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.apiStatus; + } +} export class LegacyPostgresConfigDeleteSerializeError extends Data.TaggedError( "LegacyPostgresConfigDeleteSerializeError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} export class LegacyPostgresConfigInvalidConfigValueError extends Data.TaggedError( "LegacyPostgresConfigInvalidConfigValueError", @@ -84,4 +134,8 @@ export class LegacyPostgresConfigInvalidConfigValueError extends Data.TaggedErro message: `expected config value in key:value format, received: '${args.input}'`, }); } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } } diff --git a/apps/cli/src/legacy/commands/projects/projects.errors.ts b/apps/cli/src/legacy/commands/projects/projects.errors.ts index 791e931075..c0e9501cd4 100644 --- a/apps/cli/src/legacy/commands/projects/projects.errors.ts +++ b/apps/cli/src/legacy/commands/projects/projects.errors.ts @@ -1,4 +1,10 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../../../shared/telemetry/error-actionability.ts"; // --------------------------------------------------------------------------- // HTTP-bound errors — one (Network + UnexpectedStatus) pair per Go errorf site. @@ -10,7 +16,11 @@ export class LegacyProjectsListNetworkError extends Data.TaggedError( "LegacyProjectsListNetworkError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} export class LegacyProjectsListUnexpectedStatusError extends Data.TaggedError( "LegacyProjectsListUnexpectedStatusError", @@ -18,13 +28,21 @@ export class LegacyProjectsListUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} export class LegacyProjectsCreateNetworkError extends Data.TaggedError( "LegacyProjectsCreateNetworkError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} export class LegacyProjectsCreateUnexpectedStatusError extends Data.TaggedError( "LegacyProjectsCreateUnexpectedStatusError", @@ -32,7 +50,11 @@ export class LegacyProjectsCreateUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} // Interactive org list fetched by `create` when `--org-id` is omitted // (`create.go:97-105`). @@ -40,7 +62,11 @@ export class LegacyProjectsOrgsListNetworkError extends Data.TaggedError( "LegacyProjectsOrgsListNetworkError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} export class LegacyProjectsOrgsListUnexpectedStatusError extends Data.TaggedError( "LegacyProjectsOrgsListUnexpectedStatusError", @@ -48,13 +74,21 @@ export class LegacyProjectsOrgsListUnexpectedStatusError extends Data.TaggedErro readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} export class LegacyProjectsDeleteNetworkError extends Data.TaggedError( "LegacyProjectsDeleteNetworkError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} export class LegacyProjectsDeleteUnexpectedStatusError extends Data.TaggedError( "LegacyProjectsDeleteUnexpectedStatusError", @@ -62,20 +96,32 @@ export class LegacyProjectsDeleteUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} // 404 branch of `delete.Run` (`delete.go:37-38`): "Project does not exist:". export class LegacyProjectsDeleteNotFoundError extends Data.TaggedError( "LegacyProjectsDeleteNotFoundError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} export class LegacyProjectsApiKeysNetworkError extends Data.TaggedError( "LegacyProjectsApiKeysNetworkError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} export class LegacyProjectsApiKeysUnexpectedStatusError extends Data.TaggedError( "LegacyProjectsApiKeysUnexpectedStatusError", @@ -83,7 +129,11 @@ export class LegacyProjectsApiKeysUnexpectedStatusError extends Data.TaggedError readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} // --------------------------------------------------------------------------- // Pure-path errors (validation, prompt-time semantics, user cancellation). @@ -94,7 +144,11 @@ export class LegacyProjectsEnvNotSupportedError extends Data.TaggedError( "LegacyProjectsEnvNotSupportedError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} // Non-interactive `create` missing required params — mirrors Go's PreRunE // marking `--org-id`, `--db-password`, `--region` required + ExactArgs(1) @@ -103,14 +157,22 @@ export class LegacyProjectsCreateMissingArgError extends Data.TaggedError( "LegacyProjectsCreateMissingArgError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} // Interactive `create` name prompt returned blank (`create.go:94`). export class LegacyProjectsCreateNameEmptyError extends Data.TaggedError( "LegacyProjectsCreateNameEmptyError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} // `delete` non-interactive with no positional ref — mirrors Go's // `cobra.ExactArgs(1)` on a non-TTY (`projects.go:109-113`). @@ -118,7 +180,11 @@ export class LegacyProjectsDeleteRefRequiredError extends Data.TaggedError( "LegacyProjectsDeleteRefRequiredError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} // User declined the delete confirmation prompt (`delete.go:24-25`, // `errors.New(context.Canceled)`). @@ -126,4 +192,8 @@ export class LegacyProjectsDeleteCancelledError extends Data.TaggedError( "LegacyProjectsDeleteCancelledError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.cancelled; + } +} diff --git a/apps/cli/src/legacy/commands/secrets/secrets.errors.ts b/apps/cli/src/legacy/commands/secrets/secrets.errors.ts index 385c0911fe..4f6215286c 100644 --- a/apps/cli/src/legacy/commands/secrets/secrets.errors.ts +++ b/apps/cli/src/legacy/commands/secrets/secrets.errors.ts @@ -1,5 +1,12 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../../../shared/telemetry/error-actionability.ts"; + // --------------------------------------------------------------------------- // HTTP-bound errors (network + unexpected-status pairs) // --------------------------------------------------------------------------- @@ -8,7 +15,11 @@ export class LegacySecretsListNetworkError extends Data.TaggedError( "LegacySecretsListNetworkError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} export class LegacySecretsListUnexpectedStatusError extends Data.TaggedError( "LegacySecretsListUnexpectedStatusError", @@ -16,11 +27,19 @@ export class LegacySecretsListUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} export class LegacySecretsSetNetworkError extends Data.TaggedError("LegacySecretsSetNetworkError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} export class LegacySecretsSetUnexpectedStatusError extends Data.TaggedError( "LegacySecretsSetUnexpectedStatusError", @@ -28,13 +47,21 @@ export class LegacySecretsSetUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} export class LegacySecretsUnsetNetworkError extends Data.TaggedError( "LegacySecretsUnsetNetworkError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} export class LegacySecretsUnsetUnexpectedStatusError extends Data.TaggedError( "LegacySecretsUnsetUnexpectedStatusError", @@ -42,7 +69,11 @@ export class LegacySecretsUnsetUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} // --------------------------------------------------------------------------- // Pure-path errors (validation, file I/O, user cancellation) @@ -52,33 +83,57 @@ export class LegacySecretsEnvFileOpenError extends Data.TaggedError( "LegacySecretsEnvFileOpenError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} export class LegacySecretsEnvFileParseError extends Data.TaggedError( "LegacySecretsEnvFileParseError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} export class LegacyInvalidSecretPairError extends Data.TaggedError("LegacyInvalidSecretPairError")<{ readonly pair: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} export class LegacySecretsNoArgumentsError extends Data.TaggedError( "LegacySecretsNoArgumentsError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} export class LegacySecretsEnvNotSupportedError extends Data.TaggedError( "LegacySecretsEnvNotSupportedError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} export class LegacySecretsUnsetCancelledError extends Data.TaggedError( "LegacySecretsUnsetCancelledError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.cancelled; + } +} diff --git a/apps/cli/src/legacy/commands/seed/buckets/buckets.errors.ts b/apps/cli/src/legacy/commands/seed/buckets/buckets.errors.ts index 2f3e9efa6f..cc3e438670 100644 --- a/apps/cli/src/legacy/commands/seed/buckets/buckets.errors.ts +++ b/apps/cli/src/legacy/commands/seed/buckets/buckets.errors.ts @@ -1,4 +1,9 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; /** * Domain errors specific to `supabase seed buckets`. @@ -18,7 +23,11 @@ import { Data } from "effect"; */ export class LegacySeedConfigLoadError extends Data.TaggedError("LegacySeedConfigLoadError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} /** * Raised when `--local` and `--linked` are both passed, reproducing cobra's @@ -28,4 +37,8 @@ export class LegacySeedMutuallyExclusiveFlagsError extends Data.TaggedError( "LegacySeedMutuallyExclusiveFlagsError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} diff --git a/apps/cli/src/legacy/commands/services/services.errors.ts b/apps/cli/src/legacy/commands/services/services.errors.ts index 7450caafb2..1eb85785f0 100644 --- a/apps/cli/src/legacy/commands/services/services.errors.ts +++ b/apps/cli/src/legacy/commands/services/services.errors.ts @@ -1,7 +1,16 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../shared/telemetry/error-actionability.ts"; export class LegacyServicesEnvNotSupportedError extends Data.TaggedError( "LegacyServicesEnvNotSupportedError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} diff --git a/apps/cli/src/legacy/commands/snippets/snippets.errors.ts b/apps/cli/src/legacy/commands/snippets/snippets.errors.ts index e030544b4b..43f24601e0 100644 --- a/apps/cli/src/legacy/commands/snippets/snippets.errors.ts +++ b/apps/cli/src/legacy/commands/snippets/snippets.errors.ts @@ -1,10 +1,20 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../../../shared/telemetry/error-actionability.ts"; export class LegacySnippetsListNetworkError extends Data.TaggedError( "LegacySnippetsListNetworkError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} export class LegacySnippetsListUnexpectedStatusError extends Data.TaggedError( "LegacySnippetsListUnexpectedStatusError", @@ -12,7 +22,11 @@ export class LegacySnippetsListUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} // Mirrors Go's `utils.ErrEnvNotSupported` ("--output env is not supported"), // returned from `list.Run` when `OutputFormat.Value == OutputEnv`. @@ -20,19 +34,31 @@ export class LegacySnippetsEnvNotSupportedError extends Data.TaggedError( "LegacySnippetsEnvNotSupportedError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} // Wraps `uuid.Parse` failure in `download.Run`; message preserves Go's // `invalid snippet ID: ` prefix so callers see the same string. export class LegacySnippetsInvalidIdError extends Data.TaggedError("LegacySnippetsInvalidIdError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} export class LegacySnippetsDownloadNetworkError extends Data.TaggedError( "LegacySnippetsDownloadNetworkError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} export class LegacySnippetsDownloadUnexpectedStatusError extends Data.TaggedError( "LegacySnippetsDownloadUnexpectedStatusError", @@ -40,4 +66,8 @@ export class LegacySnippetsDownloadUnexpectedStatusError extends Data.TaggedErro readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} diff --git a/apps/cli/src/legacy/commands/ssl-enforcement/ssl-enforcement.errors.ts b/apps/cli/src/legacy/commands/ssl-enforcement/ssl-enforcement.errors.ts index 0ff460b5a1..d7044067c6 100644 --- a/apps/cli/src/legacy/commands/ssl-enforcement/ssl-enforcement.errors.ts +++ b/apps/cli/src/legacy/commands/ssl-enforcement/ssl-enforcement.errors.ts @@ -1,10 +1,20 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../../../shared/telemetry/error-actionability.ts"; export class LegacySslEnforcementGetNetworkError extends Data.TaggedError( "LegacySslEnforcementGetNetworkError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} export class LegacySslEnforcementGetUnexpectedStatusError extends Data.TaggedError( "LegacySslEnforcementGetUnexpectedStatusError", @@ -12,13 +22,21 @@ export class LegacySslEnforcementGetUnexpectedStatusError extends Data.TaggedErr readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} export class LegacySslEnforcementUpdateNetworkError extends Data.TaggedError( "LegacySslEnforcementUpdateNetworkError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} export class LegacySslEnforcementUpdateUnexpectedStatusError extends Data.TaggedError( "LegacySslEnforcementUpdateUnexpectedStatusError", @@ -26,7 +44,11 @@ export class LegacySslEnforcementUpdateUnexpectedStatusError extends Data.Tagged readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} // Verbatim Go string from `apps/cli-go/internal/ssl_enforcement/update/update.go:27`. export class LegacySslEnforcementNoEnableDisableFlagError extends Data.TaggedError( @@ -37,6 +59,10 @@ export class LegacySslEnforcementNoEnableDisableFlagError extends Data.TaggedErr constructor() { super({ message: "enable/disable not specified" }); } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } } // Verbatim cobra string for parity with Go's `MarkFlagsMutuallyExclusive` @@ -53,4 +79,8 @@ export class LegacySslEnforcementMutuallyExclusiveFlagsError extends Data.Tagged "if any flags in the group [enable-db-ssl-enforcement disable-db-ssl-enforcement] are set none of the others can be", }); } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } } diff --git a/apps/cli/src/legacy/commands/sso/add/add.handler.ts b/apps/cli/src/legacy/commands/sso/add/add.handler.ts index e1951e7b6c..a881873c5b 100644 --- a/apps/cli/src/legacy/commands/sso/add/add.handler.ts +++ b/apps/cli/src/legacy/commands/sso/add/add.handler.ts @@ -137,7 +137,7 @@ export const legacySsoAdd = Effect.fn("legacy.sso.add")(function* (flags: Legacy // mapper uses (`mapLegacyHttpError`) so error output stays bounded and // shell-safe — the raw-HTTP path must not skip these defences. const bodyText = sanitizeLegacyErrorBody(rawBody); - yield* legacySuggestUpgrade({ + const upgradeSuggested = yield* legacySuggestUpgrade({ projectRef: ref, featureKey: "auth.saml_2", statusCode: response.status, @@ -145,7 +145,7 @@ export const legacySsoAdd = Effect.fn("legacy.sso.add")(function* (flags: Legacy yield* creating?.fail() ?? Effect.void; if (response.status === 404) { return yield* Effect.fail( - new LegacySsoAddSamlDisabledError({ message: SAML_DISABLED_MESSAGE }), + new LegacySsoAddSamlDisabledError({ message: SAML_DISABLED_MESSAGE, upgradeSuggested }), ); } return yield* Effect.fail( @@ -153,6 +153,7 @@ export const legacySsoAdd = Effect.fn("legacy.sso.add")(function* (flags: Legacy status: response.status, body: bodyText, message: `Unexpected error adding identity provider: ${bodyText}`, + upgradeSuggested, }), ); } diff --git a/apps/cli/src/legacy/commands/sso/list/list.handler.ts b/apps/cli/src/legacy/commands/sso/list/list.handler.ts index 408e43d23b..803a73eafd 100644 --- a/apps/cli/src/legacy/commands/sso/list/list.handler.ts +++ b/apps/cli/src/legacy/commands/sso/list/list.handler.ts @@ -37,16 +37,24 @@ const handleListError = (ref: string, cause: SupabaseApiError) => Effect.gen(function* () { const mapped = yield* Effect.flip(mapStatusOrNetwork(cause)); if (mapped._tag === "LegacySsoListUnexpectedStatusError") { - yield* legacySuggestUpgrade({ + const upgradeSuggested = yield* legacySuggestUpgrade({ projectRef: ref, featureKey: "auth.saml_2", statusCode: mapped.status, }); if (mapped.status === 404) { return yield* Effect.fail( - new LegacySsoListSamlDisabledError({ message: SAML_DISABLED_MESSAGE }), + new LegacySsoListSamlDisabledError({ message: SAML_DISABLED_MESSAGE, upgradeSuggested }), ); } + return yield* Effect.fail( + new LegacySsoListUnexpectedStatusError({ + status: mapped.status, + body: mapped.body, + message: mapped.message, + upgradeSuggested, + }), + ); } return yield* Effect.fail(mapped); }); diff --git a/apps/cli/src/legacy/commands/sso/remove/remove.handler.ts b/apps/cli/src/legacy/commands/sso/remove/remove.handler.ts index c3a35f0ef3..f6caf08eed 100644 --- a/apps/cli/src/legacy/commands/sso/remove/remove.handler.ts +++ b/apps/cli/src/legacy/commands/sso/remove/remove.handler.ts @@ -29,7 +29,7 @@ const handleRemoveError = (ref: string, providerId: string, cause: SupabaseApiEr Effect.gen(function* () { const mapped = yield* Effect.flip(mapStatusOrNetwork(cause)); if (mapped._tag === "LegacySsoRemoveUnexpectedStatusError") { - yield* legacySuggestUpgrade({ + const upgradeSuggested = yield* legacySuggestUpgrade({ projectRef: ref, featureKey: "auth.saml_2", statusCode: mapped.status, @@ -38,9 +38,18 @@ const handleRemoveError = (ref: string, providerId: string, cause: SupabaseApiEr return yield* Effect.fail( new LegacySsoRemoveNotFoundError({ message: `An identity provider with ID ${JSON.stringify(providerId)} could not be found.`, + upgradeSuggested, }), ); } + return yield* Effect.fail( + new LegacySsoRemoveUnexpectedStatusError({ + status: mapped.status, + body: mapped.body, + message: mapped.message, + upgradeSuggested, + }), + ); } return yield* Effect.fail(mapped); }); diff --git a/apps/cli/src/legacy/commands/sso/sso.errors.ts b/apps/cli/src/legacy/commands/sso/sso.errors.ts index 55630760c0..103dcdae2c 100644 --- a/apps/cli/src/legacy/commands/sso/sso.errors.ts +++ b/apps/cli/src/legacy/commands/sso/sso.errors.ts @@ -1,4 +1,30 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../../../shared/telemetry/error-actionability.ts"; + +/** + * The SAML feature is entitlement-gated: handlers thread the typed result of + * `legacySuggestUpgrade` (`upgradeSuggested`) into these errors so telemetry + * can distinguish plan-gated failures from ordinary API failures without + * sniffing message text. + */ +const samlDisabledActionability = ( + upgradeSuggested: boolean | undefined, +): CliErrorActionabilityDeclaration => + upgradeSuggested === true + ? { ...actionability.planLimit, fingerprint_suffix: "plan_limit" } + : { ...actionability.invalidConfig, fingerprint_suffix: "saml_disabled" }; + +const gatedNotFoundActionability = ( + upgradeSuggested: boolean | undefined, +): CliErrorActionabilityDeclaration => + upgradeSuggested === true + ? { ...actionability.planLimit, fingerprint_suffix: "plan_limit" } + : actionability.invalidInput; // Shared across show / update / remove: Go's `uuid.Parse` failure. // Message intentionally diverges from Go's verbose `failed to parse provider ID: invalid UUID …` @@ -7,18 +33,31 @@ import { Data } from "effect"; export class LegacySsoInvalidUuidError extends Data.TaggedError("LegacySsoInvalidUuidError")<{ readonly providerId: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} // `sso list` export class LegacySsoListNetworkError extends Data.TaggedError("LegacySsoListNetworkError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} export class LegacySsoListSamlDisabledError extends Data.TaggedError( "LegacySsoListSamlDisabledError", )<{ readonly message: string; -}> {} + readonly upgradeSuggested?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return samlDisabledActionability(this.upgradeSuggested); + } +} export class LegacySsoListUnexpectedStatusError extends Data.TaggedError( "LegacySsoListUnexpectedStatusError", @@ -26,18 +65,32 @@ export class LegacySsoListUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} + readonly upgradeSuggested?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { upgradeSuggested: this.upgradeSuggested }); + } +} // `sso add` export class LegacySsoAddNetworkError extends Data.TaggedError("LegacySsoAddNetworkError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} export class LegacySsoAddSamlDisabledError extends Data.TaggedError( "LegacySsoAddSamlDisabledError", )<{ readonly message: string; -}> {} + readonly upgradeSuggested?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return samlDisabledActionability(this.upgradeSuggested); + } +} export class LegacySsoAddUnexpectedStatusError extends Data.TaggedError( "LegacySsoAddUnexpectedStatusError", @@ -45,51 +98,88 @@ export class LegacySsoAddUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} + readonly upgradeSuggested?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { upgradeSuggested: this.upgradeSuggested }); + } +} export class LegacySsoAddMetadataFileError extends Data.TaggedError( "LegacySsoAddMetadataFileError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} export class LegacySsoAddAttributeMappingFileError extends Data.TaggedError( "LegacySsoAddAttributeMappingFileError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} export class LegacySsoMutexFlagError extends Data.TaggedError("LegacySsoMutexFlagError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} // Shared across add + update — metadata URL validation. export class LegacySsoMetadataUrlInvalidError extends Data.TaggedError( "LegacySsoMetadataUrlInvalidError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} export class LegacySsoMetadataUrlNetworkError extends Data.TaggedError( "LegacySsoMetadataUrlNetworkError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} export class LegacySsoMetadataUrlNonUtf8Error extends Data.TaggedError( "LegacySsoMetadataUrlNonUtf8Error", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} // `sso show` export class LegacySsoShowNetworkError extends Data.TaggedError("LegacySsoShowNetworkError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} export class LegacySsoShowNotFoundError extends Data.TaggedError("LegacySsoShowNotFoundError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} export class LegacySsoShowUnexpectedStatusError extends Data.TaggedError( "LegacySsoShowUnexpectedStatusError", @@ -97,22 +187,39 @@ export class LegacySsoShowUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} export class LegacySsoShowEnvNotSupportedError extends Data.TaggedError( "LegacySsoShowEnvNotSupportedError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} // `sso update` export class LegacySsoUpdateNetworkError extends Data.TaggedError("LegacySsoUpdateNetworkError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} export class LegacySsoUpdateNotFoundError extends Data.TaggedError("LegacySsoUpdateNotFoundError")<{ readonly message: string; -}> {} + readonly upgradeSuggested?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return gatedNotFoundActionability(this.upgradeSuggested); + } +} export class LegacySsoUpdateUnexpectedStatusError extends Data.TaggedError( "LegacySsoUpdateUnexpectedStatusError", @@ -120,28 +227,50 @@ export class LegacySsoUpdateUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} + readonly upgradeSuggested?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { upgradeSuggested: this.upgradeSuggested }); + } +} export class LegacySsoUpdateMetadataFileError extends Data.TaggedError( "LegacySsoUpdateMetadataFileError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} export class LegacySsoUpdateAttributeMappingFileError extends Data.TaggedError( "LegacySsoUpdateAttributeMappingFileError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} // `sso remove` export class LegacySsoRemoveNetworkError extends Data.TaggedError("LegacySsoRemoveNetworkError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} export class LegacySsoRemoveNotFoundError extends Data.TaggedError("LegacySsoRemoveNotFoundError")<{ readonly message: string; -}> {} + readonly upgradeSuggested?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return gatedNotFoundActionability(this.upgradeSuggested); + } +} export class LegacySsoRemoveUnexpectedStatusError extends Data.TaggedError( "LegacySsoRemoveUnexpectedStatusError", @@ -149,4 +278,9 @@ export class LegacySsoRemoveUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} + readonly upgradeSuggested?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { upgradeSuggested: this.upgradeSuggested }); + } +} diff --git a/apps/cli/src/legacy/commands/sso/update/update.handler.ts b/apps/cli/src/legacy/commands/sso/update/update.handler.ts index ca86765455..d753a558dd 100644 --- a/apps/cli/src/legacy/commands/sso/update/update.handler.ts +++ b/apps/cli/src/legacy/commands/sso/update/update.handler.ts @@ -52,7 +52,7 @@ const handleGetError = (ref: string, providerId: string, cause: SupabaseApiError Effect.gen(function* () { const mapped = yield* Effect.flip(mapGetStatusOrNetwork(cause)); if (mapped._tag === "LegacySsoUpdateUnexpectedStatusError") { - yield* legacySuggestUpgrade({ + const upgradeSuggested = yield* legacySuggestUpgrade({ projectRef: ref, featureKey: "auth.saml_2", statusCode: mapped.status, @@ -61,9 +61,18 @@ const handleGetError = (ref: string, providerId: string, cause: SupabaseApiError return yield* Effect.fail( new LegacySsoUpdateNotFoundError({ message: `An identity provider with ID ${JSON.stringify(providerId)} could not be found.`, + upgradeSuggested, }), ); } + return yield* Effect.fail( + new LegacySsoUpdateUnexpectedStatusError({ + status: mapped.status, + body: mapped.body, + message: mapped.message, + upgradeSuggested, + }), + ); } return yield* Effect.fail(mapped); }); @@ -209,7 +218,7 @@ export const legacySsoUpdate = Effect.fn("legacy.sso.update")(function* ( // Cap + sanitise to match `mapLegacyHttpError`'s defences — see add handler // for the rationale; the raw-HTTP path must not bypass these. const bodyText = sanitizeLegacyErrorBody(rawBody); - yield* legacySuggestUpgrade({ + const upgradeSuggested = yield* legacySuggestUpgrade({ projectRef: ref, featureKey: "auth.saml_2", statusCode: response.status, @@ -221,6 +230,7 @@ export const legacySsoUpdate = Effect.fn("legacy.sso.update")(function* ( status: response.status, body: bodyText, message: `unexpected error fetching identity provider: ${bodyText}`, + upgradeSuggested, }), ); } diff --git a/apps/cli/src/legacy/commands/storage/storage.errors.ts b/apps/cli/src/legacy/commands/storage/storage.errors.ts index af87bd9059..e95f80bb2c 100644 --- a/apps/cli/src/legacy/commands/storage/storage.errors.ts +++ b/apps/cli/src/legacy/commands/storage/storage.errors.ts @@ -1,5 +1,10 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../shared/telemetry/error-actionability.ts"; import { legacyAqua } from "../../shared/legacy-colors.ts"; /** @@ -21,6 +26,10 @@ export class LegacyStorageInvalidUrlError extends Data.TaggedError("LegacyStorag constructor() { super({ message: "URL must match pattern ss:///bucket/[prefix]" }); } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } } /** @@ -30,7 +39,11 @@ export class LegacyStorageInvalidUrlError extends Data.TaggedError("LegacyStorag */ export class LegacyStorageUrlParseError extends Data.TaggedError("LegacyStorageUrlParseError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * `cp`'s local→local branch (`internal/storage/cp/cp.go:59-60`). Go sets @@ -49,6 +62,10 @@ export class LegacyStorageUnsupportedOperationError extends Data.TaggedError( suggestion: `Run ${legacyAqua("cp -r ")} to copy between local directories.`, }); } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } } /** `cp`'s remote→remote branch (`internal/storage/cp/cp.go:57`). */ @@ -60,6 +77,10 @@ export class LegacyStorageCopyBetweenBucketsError extends Data.TaggedError( constructor() { super({ message: "Copying between buckets is not supported" }); } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } } /** `mv`'s cross-bucket branch (`internal/storage/mv/mv.go:19,38`). */ @@ -71,6 +92,10 @@ export class LegacyStorageUnsupportedMoveError extends Data.TaggedError( constructor() { super({ message: "Moving between buckets is unsupported" }); } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } } /** `mv`'s both-root branch (`internal/storage/mv/mv.go:20,35`). */ @@ -82,6 +107,10 @@ export class LegacyStorageMissingPathError extends Data.TaggedError( constructor() { super({ message: "You must specify an object path" }); } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } } /** `rm`'s root-arg branch (`internal/storage/rm/rm.go:21,41`). */ @@ -93,6 +122,10 @@ export class LegacyStorageMissingBucketError extends Data.TaggedError( constructor() { super({ message: "You must specify a bucket to delete." }); } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } } /** `rm`'s directory-without-`-r` branch (`internal/storage/rm/rm.go:22,44,53`). */ @@ -104,6 +137,10 @@ export class LegacyStorageMissingFlagError extends Data.TaggedError( constructor() { super({ message: "You must specify -r flag to delete directories." }); } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } } /** @@ -119,12 +156,20 @@ export class LegacyStorageObjectNotFoundError extends Data.TaggedError( constructor(path: string) { super({ message: `Object not found: ${path}` }); } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } } /** `failed to read file:` / `failed to create file:` (`pkg/storage/objects.go`). */ export class LegacyStorageFileError extends Data.TaggedError("LegacyStorageFileError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} /** * Both `--linked` and `--local` set, reproducing cobra's @@ -134,4 +179,8 @@ export class LegacyStorageMutuallyExclusiveFlagsError extends Data.TaggedError( "LegacyStorageMutuallyExclusiveFlagsError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} diff --git a/apps/cli/src/legacy/commands/test/db/db.errors.ts b/apps/cli/src/legacy/commands/test/db/db.errors.ts index 63e3c87d7b..57c6eb1e80 100644 --- a/apps/cli/src/legacy/commands/test/db/db.errors.ts +++ b/apps/cli/src/legacy/commands/test/db/db.errors.ts @@ -1,12 +1,22 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; + /** * `create extension if not exists pgtap` failed. Byte-matches Go's * `"failed to enable pgTAP: " + err` (`apps/cli-go/internal/db/test/test.go:70`). */ export class LegacyTestDbEnablePgtapError extends Data.TaggedError("LegacyTestDbEnablePgtapError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbConnection; + } +} /** * `pg_prove` exited non-zero (test failures or a container error). Byte-matches @@ -15,7 +25,11 @@ export class LegacyTestDbEnablePgtapError extends Data.TaggedError("LegacyTestDb */ export class LegacyTestDbRunError extends Data.TaggedError("LegacyTestDbRunError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} /** * More than one of `--db-url` / `--linked` / `--local` was set. Reproduces @@ -26,4 +40,8 @@ export class LegacyTestDbMutuallyExclusiveFlagsError extends Data.TaggedError( "LegacyTestDbMutuallyExclusiveFlagsError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} diff --git a/apps/cli/src/legacy/commands/test/db/db.integration.test.ts b/apps/cli/src/legacy/commands/test/db/db.integration.test.ts index 0340f54baa..41c452bc01 100644 --- a/apps/cli/src/legacy/commands/test/db/db.integration.test.ts +++ b/apps/cli/src/legacy/commands/test/db/db.integration.test.ts @@ -111,19 +111,37 @@ function mockDockerRun(opts: { exitCode?: number; runFails?: boolean }) { run: (runOpts) => { lastOpts = runOpts; return opts.runFails === true - ? Effect.fail(new LegacyDockerRunError({ message: "failed to run docker: not found" })) + ? Effect.fail( + new LegacyDockerRunError({ + message: "failed to run docker: not found", + reason: "spawn", + daemonDown: false, + }), + ) : Effect.succeed(opts.exitCode ?? 0); }, runCapture: (runOpts) => { lastOpts = runOpts; return opts.runFails === true - ? Effect.fail(new LegacyDockerRunError({ message: "failed to run docker: not found" })) + ? Effect.fail( + new LegacyDockerRunError({ + message: "failed to run docker: not found", + reason: "spawn", + daemonDown: false, + }), + ) : Effect.succeed({ exitCode: opts.exitCode ?? 0, stdout: new Uint8Array(0), stderr: "" }); }, runStream: (runOpts) => { lastOpts = runOpts; return opts.runFails === true - ? Effect.fail(new LegacyDockerRunError({ message: "failed to run docker: not found" })) + ? Effect.fail( + new LegacyDockerRunError({ + message: "failed to run docker: not found", + reason: "spawn", + daemonDown: false, + }), + ) : Effect.succeed({ exitCode: opts.exitCode ?? 0, stderr: "" }); }, }); diff --git a/apps/cli/src/legacy/commands/test/new/new.errors.ts b/apps/cli/src/legacy/commands/test/new/new.errors.ts index 8cfad19a4a..0edcc72f78 100644 --- a/apps/cli/src/legacy/commands/test/new/new.errors.ts +++ b/apps/cli/src/legacy/commands/test/new/new.errors.ts @@ -1,5 +1,11 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; + /** * The target test file already exists. Byte-matches Go's * `errors.New(path + " already exists.")` (`apps/cli-go/internal/test/new/new.go:26`). @@ -7,7 +13,11 @@ import { Data } from "effect"; export class LegacyTestNewFileExistsError extends Data.TaggedError("LegacyTestNewFileExistsError")<{ readonly path: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * Writing the test file failed (e.g. permission denied). Mirrors Go's @@ -16,4 +26,8 @@ export class LegacyTestNewFileExistsError extends Data.TaggedError("LegacyTestNe export class LegacyTestNewWriteError extends Data.TaggedError("LegacyTestNewWriteError")<{ readonly path: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.permission; + } +} diff --git a/apps/cli/src/legacy/commands/unlink/unlink.errors.ts b/apps/cli/src/legacy/commands/unlink/unlink.errors.ts index 8c1e9155f1..1296ff3861 100644 --- a/apps/cli/src/legacy/commands/unlink/unlink.errors.ts +++ b/apps/cli/src/legacy/commands/unlink/unlink.errors.ts @@ -1,5 +1,11 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../shared/telemetry/error-actionability.ts"; + /** * Reading `supabase/.temp/project-ref` failed for a reason other than the file * being absent (which maps to `LegacyProjectNotLinkedError`). Byte-matches Go's @@ -7,7 +13,11 @@ import { Data } from "effect"; */ export class LegacyUnlinkRefReadError extends Data.TaggedError("LegacyUnlinkRefReadError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} /** * Removing the `supabase/.temp` directory failed. Byte-matches Go's @@ -15,4 +25,8 @@ export class LegacyUnlinkRefReadError extends Data.TaggedError("LegacyUnlinkRefR */ export class LegacyUnlinkTempRemovalError extends Data.TaggedError("LegacyUnlinkTempRemovalError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.permission; + } +} diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/activate/activate.handler.ts b/apps/cli/src/legacy/commands/vanity-subdomains/activate/activate.handler.ts index 27877ce2c5..86b0c692dd 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/activate/activate.handler.ts +++ b/apps/cli/src/legacy/commands/vanity-subdomains/activate/activate.handler.ts @@ -57,11 +57,19 @@ export const legacyVanitySubdomainsActivate = Effect.fn("legacy.vanity-subdomain // tagged error before deciding whether to suggest an upgrade, then re-fail. const mapped = yield* Effect.flip(mapActivateError(cause)); if (mapped._tag === "LegacyVanitySubdomainsActivateUnexpectedStatusError") { - yield* legacySuggestUpgrade({ + const upgradeSuggested = yield* legacySuggestUpgrade({ projectRef: ref, featureKey: "vanity_subdomain", statusCode: mapped.status, }); + return yield* Effect.fail( + new LegacyVanitySubdomainsActivateUnexpectedStatusError({ + status: mapped.status, + body: mapped.body, + message: mapped.message, + upgradeSuggested, + }), + ); } return yield* Effect.fail(mapped); }), diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/check-availability.handler.ts b/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/check-availability.handler.ts index d17836e693..5dbf711b0c 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/check-availability.handler.ts +++ b/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/check-availability.handler.ts @@ -60,12 +60,20 @@ export const legacyVanitySubdomainsCheckAvailability = Effect.fn( if (mapped._tag === "LegacyVanitySubdomainsCheckUnexpectedStatusError") { // Go's check command calls SuggestUpgradeOnError without a following // TrackUpgradeSuggested, so suppress the analytics event for parity. - yield* legacySuggestUpgrade({ + const upgradeSuggested = yield* legacySuggestUpgrade({ projectRef: ref, featureKey: "vanity_subdomain", statusCode: mapped.status, trackAnalytics: false, }); + return yield* Effect.fail( + new LegacyVanitySubdomainsCheckUnexpectedStatusError({ + status: mapped.status, + body: mapped.body, + message: mapped.message, + upgradeSuggested, + }), + ); } return yield* Effect.fail(mapped); }), diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/vanity-subdomains.errors.ts b/apps/cli/src/legacy/commands/vanity-subdomains/vanity-subdomains.errors.ts index aa849f89ef..8b54285d7e 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/vanity-subdomains.errors.ts +++ b/apps/cli/src/legacy/commands/vanity-subdomains/vanity-subdomains.errors.ts @@ -1,10 +1,20 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../../../shared/telemetry/error-actionability.ts"; export class LegacyVanitySubdomainsGetNetworkError extends Data.TaggedError( "LegacyVanitySubdomainsGetNetworkError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} export class LegacyVanitySubdomainsGetUnexpectedStatusError extends Data.TaggedError( "LegacyVanitySubdomainsGetUnexpectedStatusError", @@ -12,13 +22,21 @@ export class LegacyVanitySubdomainsGetUnexpectedStatusError extends Data.TaggedE readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} export class LegacyVanitySubdomainsCheckNetworkError extends Data.TaggedError( "LegacyVanitySubdomainsCheckNetworkError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} export class LegacyVanitySubdomainsCheckUnexpectedStatusError extends Data.TaggedError( "LegacyVanitySubdomainsCheckUnexpectedStatusError", @@ -26,13 +44,22 @@ export class LegacyVanitySubdomainsCheckUnexpectedStatusError extends Data.Tagge readonly status: number; readonly body: string; readonly message: string; -}> {} + readonly upgradeSuggested?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { upgradeSuggested: this.upgradeSuggested }); + } +} export class LegacyVanitySubdomainsActivateNetworkError extends Data.TaggedError( "LegacyVanitySubdomainsActivateNetworkError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} export class LegacyVanitySubdomainsActivateUnexpectedStatusError extends Data.TaggedError( "LegacyVanitySubdomainsActivateUnexpectedStatusError", @@ -40,13 +67,22 @@ export class LegacyVanitySubdomainsActivateUnexpectedStatusError extends Data.Ta readonly status: number; readonly body: string; readonly message: string; -}> {} + readonly upgradeSuggested?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { upgradeSuggested: this.upgradeSuggested }); + } +} export class LegacyVanitySubdomainsDeleteNetworkError extends Data.TaggedError( "LegacyVanitySubdomainsDeleteNetworkError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} export class LegacyVanitySubdomainsDeleteUnexpectedStatusError extends Data.TaggedError( "LegacyVanitySubdomainsDeleteUnexpectedStatusError", @@ -54,4 +90,8 @@ export class LegacyVanitySubdomainsDeleteUnexpectedStatusError extends Data.Tagg readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} diff --git a/apps/cli/src/legacy/config/legacy-profile-file.ts b/apps/cli/src/legacy/config/legacy-profile-file.ts index 680beb4213..fc1485e235 100644 --- a/apps/cli/src/legacy/config/legacy-profile-file.ts +++ b/apps/cli/src/legacy/config/legacy-profile-file.ts @@ -1,5 +1,10 @@ import { Data, Effect, FileSystem, Path } from "effect"; import { resolveSupabaseHome } from "../../shared/config/supabase-home.ts"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; /** * Helpers for the persisted profile-name file under the global Supabase home, @@ -29,7 +34,11 @@ export function legacySupabaseHome( * (`apps/cli-go/cmd/login.go:42-46`). */ export class LegacyProfileSaveError extends Data.TaggedError("LegacyProfileSaveError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.permission; + } +} export function legacyProfileFilePath( path: Path.Path, diff --git a/apps/cli/src/legacy/config/legacy-project-ref.errors.ts b/apps/cli/src/legacy/config/legacy-project-ref.errors.ts index a8dab2f7dc..81943235df 100644 --- a/apps/cli/src/legacy/config/legacy-project-ref.errors.ts +++ b/apps/cli/src/legacy/config/legacy-project-ref.errors.ts @@ -1,13 +1,27 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; + export class LegacyProjectNotLinkedError extends Data.TaggedError("LegacyProjectNotLinkedError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.projectNotLinked; + } +} export class LegacyInvalidProjectRefError extends Data.TaggedError("LegacyInvalidProjectRefError")<{ readonly ref: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} /** * Raised by `resolveForLink` on a non-TTY when neither `--project-ref` nor @@ -19,4 +33,8 @@ export class LegacyProjectRefRequiredError extends Data.TaggedError( "LegacyProjectRefRequiredError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.missingProjectRef; + } +} diff --git a/apps/cli/src/legacy/shared/legacy-db-config.errors.ts b/apps/cli/src/legacy/shared/legacy-db-config.errors.ts index d725cbc4c2..9263eb47f2 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.errors.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.errors.ts @@ -1,4 +1,10 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../../shared/telemetry/error-actionability.ts"; /** * `--db-url` could not be parsed as a Postgres connection string. Mirrors Go's @@ -7,7 +13,11 @@ import { Data } from "effect"; */ export class LegacyDbConfigParseUrlError extends Data.TaggedError("LegacyDbConfigParseUrlError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} /** * `supabase/config.toml` exists but could not be read or parsed. Mirrors Go's @@ -19,12 +29,20 @@ export class LegacyDbConfigParseUrlError extends Data.TaggedError("LegacyDbConfi */ export class LegacyDbConfigLoadError extends Data.TaggedError("LegacyDbConfigLoadError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} /** Transport failure creating a temporary login role (`V1CreateLoginRole`). */ export class LegacyDbConfigLoginRoleNetworkError extends Data.TaggedError( "LegacyDbConfigLoginRoleNetworkError", -)<{ readonly message: string }> {} +)<{ readonly message: string }> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} /** Non-201 status creating a temporary login role (`V1CreateLoginRole`). */ export class LegacyDbConfigLoginRoleStatusError extends Data.TaggedError( @@ -33,12 +51,20 @@ export class LegacyDbConfigLoginRoleStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} /** Transport failure listing network bans (`V1ListAllNetworkBans`). */ export class LegacyDbConfigListBansNetworkError extends Data.TaggedError( "LegacyDbConfigListBansNetworkError", -)<{ readonly message: string }> {} +)<{ readonly message: string }> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} /** Non-2xx status listing network bans (`V1ListAllNetworkBans`). */ export class LegacyDbConfigListBansStatusError extends Data.TaggedError( @@ -47,12 +73,20 @@ export class LegacyDbConfigListBansStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} /** Transport failure removing network bans (`V1DeleteNetworkBans`). */ export class LegacyDbConfigUnbanNetworkError extends Data.TaggedError( "LegacyDbConfigUnbanNetworkError", -)<{ readonly message: string }> {} +)<{ readonly message: string }> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} /** Non-2xx status removing network bans (`V1DeleteNetworkBans`). */ export class LegacyDbConfigUnbanStatusError extends Data.TaggedError( @@ -61,7 +95,11 @@ export class LegacyDbConfigUnbanStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} /** * The linked project's direct database host is unreachable (IPv6-only) and no @@ -72,7 +110,11 @@ export class LegacyDbConfigUnbanStatusError extends Data.TaggedError( export class LegacyDbConfigIpv6Error extends Data.TaggedError("LegacyDbConfigIpv6Error")<{ readonly message: string; readonly suggestion?: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbConnection; + } +} /** * Failed to connect to the linked project as the temporary login role after the @@ -84,7 +126,11 @@ export class LegacyDbConfigConnectTempRoleError extends Data.TaggedError( )<{ readonly message: string; readonly suggestion?: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbConnection; + } +} /** * The configured pooler connection string does not match the linked project ref @@ -97,4 +143,8 @@ export class LegacyDbConfigPoolerLoginError extends Data.TaggedError( )<{ readonly message: string; readonly suggestion?: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbConnection; + } +} diff --git a/apps/cli/src/legacy/shared/legacy-db-connection.errors.ts b/apps/cli/src/legacy/shared/legacy-db-connection.errors.ts index 49b68bbbce..118c38573a 100644 --- a/apps/cli/src/legacy/shared/legacy-db-connection.errors.ts +++ b/apps/cli/src/legacy/shared/legacy-db-connection.errors.ts @@ -1,4 +1,9 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; /** * Opening a Postgres connection failed. Mirrors Go's `pgx`/`pgconn` connect @@ -9,7 +14,11 @@ import { Data } from "effect"; export class LegacyDbConnectError extends Data.TaggedError("LegacyDbConnectError")<{ readonly message: string; readonly suggestion?: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbConnection; + } +} /** * Executing a SQL statement against an open connection failed. Mirrors the Go @@ -24,7 +33,11 @@ export class LegacyDbExecError extends Data.TaggedError("LegacyDbExecError")<{ * missing migration-history table, not an undefined column. */ readonly code?: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} /** * A server-side `COPY (...) TO STDOUT` stream failed. Mirrors Go's @@ -37,4 +50,8 @@ export class LegacyDbExecError extends Data.TaggedError("LegacyDbExecError")<{ */ export class LegacyDbCopyError extends Data.TaggedError("LegacyDbCopyError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} diff --git a/apps/cli/src/legacy/shared/legacy-docker-run.errors.ts b/apps/cli/src/legacy/shared/legacy-docker-run.errors.ts index a2e6ce2a71..6ad69eda21 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-run.errors.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-run.errors.ts @@ -1,6 +1,30 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; /** Spawning or running the `docker` CLI failed (binary missing, daemon down, non-spawn failure). */ export class LegacyDockerRunError extends Data.TaggedError("LegacyDockerRunError")<{ readonly message: string; -}> {} + /** + * Structured discriminant set at the docker boundary: `spawn` when the + * container runtime could not be executed at all, `pull` when the runtime + * ran but every registry candidate failed to pull. + */ + readonly reason: "spawn" | "pull"; + /** + * For `pull` failures, whether the registry output indicates the daemon + * itself is unreachable — detected where docker's output is produced, so + * consumers never sniff `message` text. + */ + readonly daemonDown: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + if (this.reason === "spawn" || this.daemonDown) { + return { ...actionability.dockerNotRunning, fingerprint_suffix: "docker_not_running" }; + } + return { ...actionability.externalNetwork, fingerprint_suffix: "registry_pull" }; + } +} diff --git a/apps/cli/src/legacy/shared/legacy-docker-run.layer.ts b/apps/cli/src/legacy/shared/legacy-docker-run.layer.ts index 915268b704..ea09e3e344 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-run.layer.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-run.layer.ts @@ -32,6 +32,22 @@ const RETRYABLE_PULL_PATTERNS = [ /i\/o timeout/i, ] as const; +/** + * Whether the container runtime's output indicates the daemon itself is not + * running. This is the boundary where docker's text output is produced, so it + * is the one place allowed to interpret it (feeds + * `LegacyDockerRunError.daemonDown`). + */ +const legacyIsDockerDaemonDownMessage = (message: string): boolean => { + const normalized = message.toLowerCase(); + return ( + normalized.includes("cannot connect to the docker daemon") || + normalized.includes("docker daemon is not running") || + normalized.includes("docker desktop is not running") || + normalized.includes("is the docker daemon running") + ); +}; + export const legacyDockerRunLayer: Layer.Layer< LegacyDockerRun, never, @@ -48,6 +64,8 @@ export const legacyDockerRunLayer: Layer.Layer< // credential-free message that still points at the likely cause. new LegacyDockerRunError({ message: `failed to run docker. ${LEGACY_SUGGEST_DOCKER_INSTALL}`, + reason: "spawn", + daemonDown: false, }); const concat = (chunks: ReadonlyArray): Uint8Array => { @@ -169,6 +187,8 @@ export const legacyDockerRunLayer: Layer.Layer< return yield* Effect.fail( new LegacyDockerRunError({ message: `failed to pull docker image from all registries: ${failures.join("; ")}`, + reason: "pull", + daemonDown: failures.some(legacyIsDockerDaemonDownMessage), }), ); }); @@ -309,6 +329,8 @@ export const legacyDockerRunLayer: Layer.Layer< () => new LegacyDockerRunError({ message: `failed to run docker. ${LEGACY_SUGGEST_DOCKER_INSTALL}`, + reason: "spawn", + daemonDown: false, }), ), ); diff --git a/apps/cli/src/legacy/shared/legacy-drop-objects.ts b/apps/cli/src/legacy/shared/legacy-drop-objects.ts index 523c193a12..7d7de97a93 100644 --- a/apps/cli/src/legacy/shared/legacy-drop-objects.ts +++ b/apps/cli/src/legacy/shared/legacy-drop-objects.ts @@ -1,11 +1,20 @@ import { Data, Effect } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; import type { LegacyDbSession } from "./legacy-db-connection.service.ts"; /** Dropping the user schemas failed (Go's `DropUserSchemas` error). */ export class LegacyMigrationDropError extends Data.TaggedError("LegacyMigrationDropError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} /** * The embedded `DO $$ ... $$` block from Go's `pkg/migration/queries/drop.sql`, diff --git a/apps/cli/src/legacy/shared/legacy-edge-runtime-script.errors.ts b/apps/cli/src/legacy/shared/legacy-edge-runtime-script.errors.ts index 009d602462..bea984e285 100644 --- a/apps/cli/src/legacy/shared/legacy-edge-runtime-script.errors.ts +++ b/apps/cli/src/legacy/shared/legacy-edge-runtime-script.errors.ts @@ -1,4 +1,9 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; /** * Running a TypeScript program inside the edge-runtime container failed (non-zero @@ -10,4 +15,8 @@ import { Data } from "effect"; */ export class LegacyEdgeRuntimeScriptError extends Data.TaggedError("LegacyEdgeRuntimeScriptError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} diff --git a/apps/cli/src/legacy/shared/legacy-experimental-gate.ts b/apps/cli/src/legacy/shared/legacy-experimental-gate.ts index 325f2e96bb..27ea08fe5c 100644 --- a/apps/cli/src/legacy/shared/legacy-experimental-gate.ts +++ b/apps/cli/src/legacy/shared/legacy-experimental-gate.ts @@ -1,5 +1,10 @@ import { Data, Effect } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; import { legacyResolveExperimental } from "../../shared/legacy/global-flags.ts"; /** @@ -35,6 +40,10 @@ export class LegacyExperimentalRequiredError extends Data.TaggedError( constructor() { super({ message: "must set the --experimental flag to run this command" }); } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } } /** Fails with {@link LegacyExperimentalRequiredError} unless experimental is enabled. */ diff --git a/apps/cli/src/legacy/shared/legacy-go-output-flag.ts b/apps/cli/src/legacy/shared/legacy-go-output-flag.ts index fc943b7884..48cea50187 100644 --- a/apps/cli/src/legacy/shared/legacy-go-output-flag.ts +++ b/apps/cli/src/legacy/shared/legacy-go-output-flag.ts @@ -1,4 +1,9 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; /** * Per-command `--output`/`-o` enums, mirroring Go. Go registers `--output` per @@ -25,7 +30,11 @@ export const LEGACY_QUERY_OUTPUT_FORMATS = ["json", "table", "csv"] as const; */ export class LegacyInvalidOutputFormatError extends Data.TaggedError( "LegacyInvalidOutputFormatError", -)<{ readonly message: string }> {} +)<{ readonly message: string }> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} /** Go's `must be one of [ a | b | c ]` (`enum.go:23`, joined with `" | "`). */ export function legacyOutputFormatEnumMessage(allowed: ReadonlyArray): string { diff --git a/apps/cli/src/legacy/shared/legacy-migration-apply.ts b/apps/cli/src/legacy/shared/legacy-migration-apply.ts index 3ca0812bdd..d50bda1fbf 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-apply.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-apply.ts @@ -1,6 +1,11 @@ import { Data, Effect, type FileSystem, type Path } from "effect"; import { Output } from "../../shared/output/output.service.ts"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; import type { LegacyDbSession } from "./legacy-db-connection.service.ts"; import { INSERT_MIGRATION_VERSION, @@ -16,7 +21,11 @@ import { legacySplitAndTrim } from "./legacy-sql-split.ts"; */ export class LegacyMigrationApplyError extends Data.TaggedError("LegacyMigrationApplyError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} // Byte order mark (U+FEFF) — stripped from the head of a statement like Go does. const BOM_CODE_POINT = 0xfeff; diff --git a/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts b/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts index a5039843de..923ecf7dfb 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts @@ -7,6 +7,7 @@ import { Data, Effect, Exit, FileSystem, Path } from "effect"; import { mockOutput } from "../../../tests/helpers/mocks.ts"; import type { LegacyDbSession } from "./legacy-db-connection.service.ts"; +import { LegacyDbExecError } from "./legacy-db-connection.errors.ts"; import { legacyApplyMigrationFile, legacyIsPipelineIncompatible, @@ -15,15 +16,13 @@ import { class TestError extends Data.TaggedError("TestError")<{ readonly message: string }> {} -class FakeExecError extends Data.TaggedError("LegacyDbExecError")<{ readonly message: string }> {} - function fakeSession(opts: { failOn?: string } = {}) { const calls: Array<{ kind: "exec" | "query"; sql: string; params?: ReadonlyArray }> = []; const session: LegacyDbSession = { exec: (sql) => { calls.push({ kind: "exec", sql }); return opts.failOn !== undefined && sql.includes(opts.failOn) - ? Effect.fail(new FakeExecError({ message: "exec failed" })) + ? Effect.fail(new LegacyDbExecError({ message: "exec failed" })) : Effect.void; }, query: (sql, params) => { diff --git a/apps/cli/src/legacy/shared/legacy-migration.errors.ts b/apps/cli/src/legacy/shared/legacy-migration.errors.ts index 1cf0340928..64248c821d 100644 --- a/apps/cli/src/legacy/shared/legacy-migration.errors.ts +++ b/apps/cli/src/legacy/shared/legacy-migration.errors.ts @@ -1,4 +1,9 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; /** * Listing or reading migrations failed for a reason other than the directory @@ -13,4 +18,8 @@ import { Data } from "effect"; */ export class LegacyMigrationsReadError extends Data.TaggedError("LegacyMigrationsReadError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.permission; + } +} diff --git a/apps/cli/src/legacy/shared/legacy-pgdelta-ssl-probe.service.ts b/apps/cli/src/legacy/shared/legacy-pgdelta-ssl-probe.service.ts index d5dca259f5..175f0fcc80 100644 --- a/apps/cli/src/legacy/shared/legacy-pgdelta-ssl-probe.service.ts +++ b/apps/cli/src/legacy/shared/legacy-pgdelta-ssl-probe.service.ts @@ -1,4 +1,9 @@ import { Context, Data, type Effect } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; /** * A live TLS-capability probe for pg-delta SOURCE/TARGET endpoints, mirroring Go's @@ -33,7 +38,11 @@ export interface LegacyPgDeltaSslProbeShape { export class LegacyPgDeltaSslProbeError extends Data.TaggedError("LegacyPgDeltaSslProbeError")<{ readonly message: string; readonly cause?: unknown; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbConnection; + } +} export class LegacyPgDeltaSslProbe extends Context.Service< LegacyPgDeltaSslProbe, diff --git a/apps/cli/src/legacy/shared/legacy-seed.ts b/apps/cli/src/legacy/shared/legacy-seed.ts index dc67437c48..0e4049f6dd 100644 --- a/apps/cli/src/legacy/shared/legacy-seed.ts +++ b/apps/cli/src/legacy/shared/legacy-seed.ts @@ -2,6 +2,11 @@ import { createHash } from "node:crypto"; import { Data, Effect, FileSystem, Path } from "effect"; import { Output } from "../../shared/output/output.service.ts"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; import type { LegacyDbSession } from "./legacy-db-connection.service.ts"; import { legacyCreateSeedTable, @@ -14,7 +19,11 @@ import { legacySplitAndTrim } from "./legacy-sql-split.ts"; /** Applying a seed file failed (Go's `SeedData` / `ExecBatchWithCache` errors). */ export class LegacyMigrationSeedError extends Data.TaggedError("LegacyMigrationSeedError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} /** `[db.seed]` config: `enabled` + the (supabase-prefixed) `sql_paths` glob list. */ export interface LegacySeedConfig { diff --git a/apps/cli/src/legacy/shared/legacy-storage-credentials.errors.ts b/apps/cli/src/legacy/shared/legacy-storage-credentials.errors.ts index f6f36d126c..86c0f60ca2 100644 --- a/apps/cli/src/legacy/shared/legacy-storage-credentials.errors.ts +++ b/apps/cli/src/legacy/shared/legacy-storage-credentials.errors.ts @@ -1,5 +1,11 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; + /** * Errors raised while deriving Storage connection credentials, shared by * `seed buckets` and `storage ls/cp/mv/rm`. @@ -11,7 +17,11 @@ import { Data } from "effect"; */ export class LegacyStorageConfigError extends Data.TaggedError("LegacyStorageConfigError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} /** * Raised on `--linked` when the project's api-keys response yields no keys, @@ -23,14 +33,22 @@ export class LegacyStorageMissingApiKeyError extends Data.TaggedError( "LegacyStorageMissingApiKeyError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.permission; + } +} /** Transport failure fetching the project's api-keys (`failed to get api keys: `). */ export class LegacyStorageApiKeysNetworkError extends Data.TaggedError( "LegacyStorageApiKeysNetworkError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} /** * `GET /v1/projects/{ref}/api-keys?reveal=true` returned a non-200 on a @@ -42,4 +60,8 @@ export class LegacyStorageAuthTokenError extends Data.TaggedError("LegacyStorage readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.authLogin; + } +} diff --git a/apps/cli/src/legacy/shared/legacy-storage-gateway.errors.ts b/apps/cli/src/legacy/shared/legacy-storage-gateway.errors.ts index 1c1d041319..8ff768baca 100644 --- a/apps/cli/src/legacy/shared/legacy-storage-gateway.errors.ts +++ b/apps/cli/src/legacy/shared/legacy-storage-gateway.errors.ts @@ -1,4 +1,10 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../../shared/telemetry/error-actionability.ts"; /** * Errors for the Supabase Storage **service gateway** (Kong), shared by every @@ -17,7 +23,11 @@ export class LegacyStorageGatewayNetworkError extends Data.TaggedError( "LegacyStorageGatewayNetworkError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} export class LegacyStorageGatewayStatusError extends Data.TaggedError( "LegacyStorageGatewayStatusError", @@ -25,7 +35,11 @@ export class LegacyStorageGatewayStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} export type LegacyStorageGatewayError = | LegacyStorageGatewayNetworkError diff --git a/apps/cli/src/legacy/shared/legacy-temp-paths.ts b/apps/cli/src/legacy/shared/legacy-temp-paths.ts index aaa14167e9..3fd8ad49ca 100644 --- a/apps/cli/src/legacy/shared/legacy-temp-paths.ts +++ b/apps/cli/src/legacy/shared/legacy-temp-paths.ts @@ -1,4 +1,9 @@ import { Data, Effect, FileSystem, Option, type Path } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; /** * A real failure reading `/supabase/.temp/project-ref` (e.g. the path is a @@ -9,7 +14,11 @@ import { Data, Effect, FileSystem, Option, type Path } from "effect"; */ export class LegacyProjectRefReadError extends Data.TaggedError("LegacyProjectRefReadError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.relinkProject; + } +} /** * Absolute paths to the files the Go CLI writes under `/supabase/.temp/`. diff --git a/apps/cli/src/legacy/shared/legacy-upgrade-suggest.ts b/apps/cli/src/legacy/shared/legacy-upgrade-suggest.ts index 0466e726ec..eb405a049d 100644 --- a/apps/cli/src/legacy/shared/legacy-upgrade-suggest.ts +++ b/apps/cli/src/legacy/shared/legacy-upgrade-suggest.ts @@ -35,6 +35,9 @@ function readString(obj: unknown, key: string): string { * telemetry event with `{feature_key, org_slug}` properties. * * Never fails the caller; lookup errors swallow into a no-op suggestion. + * Returns whether the feature was confirmed plan-gated, so callers can thread + * the typed result into their error (`upgradeSuggested`) for telemetry + * classification. * * Bypasses the typed Management API client to GET `/v1/projects/{ref}` and * `/v1/organizations/{slug}/entitlements` directly via `HttpClient`. The @@ -58,7 +61,7 @@ export const legacySuggestUpgrade = Effect.fnUntraced(function* (opts: { readonly trackAnalytics?: boolean; }) { if (opts.statusCode < 400 || opts.statusCode >= 500) { - return; + return false; } const output = yield* Output; @@ -78,15 +81,15 @@ export const legacySuggestUpgrade = Effect.fnUntraced(function* (opts: { ).pipe(authHeader, HttpClientRequest.setHeader("User-Agent", cliConfig.userAgent)); const projectResp = yield* httpClient.execute(projectReq).pipe(Effect.option); if (projectResp._tag === "None" || projectResp.value.status !== 200) { - return; + return false; } const projectBody = yield* projectResp.value.json.pipe(Effect.option); if (projectBody._tag === "None") { - return; + return false; } const orgSlug = readString(projectBody.value, "organization_slug"); if (orgSlug.length === 0) { - return; + return false; } const entReq = HttpClientRequest.get( @@ -94,15 +97,15 @@ export const legacySuggestUpgrade = Effect.fnUntraced(function* (opts: { ).pipe(authHeader, HttpClientRequest.setHeader("User-Agent", cliConfig.userAgent)); const entResp = yield* httpClient.execute(entReq).pipe(Effect.option); if (entResp._tag === "None" || entResp.value.status !== 200) { - return; + return false; } const entBody = yield* entResp.value.json.pipe(Effect.option); if (entBody._tag === "None") { - return; + return false; } const entitlements = (entBody.value as { entitlements?: unknown }).entitlements; if (!Array.isArray(entitlements)) { - return; + return false; } const gated = entitlements.some((entry: unknown) => { @@ -114,7 +117,7 @@ export const legacySuggestUpgrade = Effect.fnUntraced(function* (opts: { return key === opts.featureKey && hasAccess === false; }); if (!gated) { - return; + return false; } const url = legacyBillingUrl(cliConfig.profile, orgSlug); @@ -130,4 +133,6 @@ export const legacySuggestUpgrade = Effect.fnUntraced(function* (opts: { [PropOrgSlug]: orgSlug, }); } + + return true; }); diff --git a/apps/cli/src/legacy/shared/legacy-vault.ts b/apps/cli/src/legacy/shared/legacy-vault.ts index 16534f8227..b45128e7f3 100644 --- a/apps/cli/src/legacy/shared/legacy-vault.ts +++ b/apps/cli/src/legacy/shared/legacy-vault.ts @@ -1,12 +1,21 @@ import { Data, Effect } from "effect"; import { Output } from "../../shared/output/output.service.ts"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; import type { LegacyDbSession } from "./legacy-db-connection.service.ts"; /** Reading or updating `vault.secrets` failed (Go's `UpsertVaultSecrets` errors). */ export class LegacyMigrationVaultError extends Data.TaggedError("LegacyMigrationVaultError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} /** A resolved `[db.vault]` secret. `resolved` mirrors Go's `len(SHA256) > 0` gate. */ export interface LegacyVaultSecret { diff --git a/apps/cli/src/next/auth/errors.ts b/apps/cli/src/next/auth/errors.ts index 246cf1368f..6d55a2ee7f 100644 --- a/apps/cli/src/next/auth/errors.ts +++ b/apps/cli/src/next/auth/errors.ts @@ -1,4 +1,10 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../../shared/telemetry/error-actionability.ts"; function CliError(tag: Tag) { return class extends Data.TaggedError(tag)<{ @@ -11,15 +17,27 @@ function CliError(tag: Tag) { }; } -export class InvalidTokenError extends CliError("InvalidTokenError") {} +export class InvalidTokenError extends CliError("InvalidTokenError") { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.authLogin; + } +} export class ApiError extends Data.TaggedError("ApiError")<{ readonly statusCode?: number; readonly detail: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.statusCode); + } +} export class PlatformAuthRequiredError extends Data.TaggedError("PlatformAuthRequiredError")<{ readonly message: string; readonly detail?: string; readonly suggestion?: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.authLogin; + } +} diff --git a/apps/cli/src/next/commands/branches/errors.ts b/apps/cli/src/next/commands/branches/errors.ts index 0c0dada1aa..f9ca32c23f 100644 --- a/apps/cli/src/next/commands/branches/errors.ts +++ b/apps/cli/src/next/commands/branches/errors.ts @@ -1,16 +1,33 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../shared/telemetry/error-actionability.ts"; export class BranchNotFoundError extends Data.TaggedError("BranchNotFoundError")<{ readonly detail: string; readonly suggestion: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} export class NoBranchNameError extends Data.TaggedError("NoBranchNameError")<{ readonly detail: string; readonly suggestion: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} export class BranchAlreadyExistsError extends Data.TaggedError("BranchAlreadyExistsError")<{ readonly detail: string; readonly suggestion: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} diff --git a/apps/cli/src/next/commands/functions/dev/functions-dev-edge-runtime-config.ts b/apps/cli/src/next/commands/functions/dev/functions-dev-edge-runtime-config.ts index b13a63f0fc..8b19629c01 100644 --- a/apps/cli/src/next/commands/functions/dev/functions-dev-edge-runtime-config.ts +++ b/apps/cli/src/next/commands/functions/dev/functions-dev-edge-runtime-config.ts @@ -7,6 +7,11 @@ import { import type { EdgeRuntimeConfig } from "@supabase/stack/effect"; import { Data, Effect, Redacted } from "effect"; import { ProjectHome } from "../../../config/project-home.service.ts"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; type ResolvedSecretValue = string | Redacted.Redacted; type EdgeRuntimePolicy = "oneshot" | "per_worker"; @@ -27,6 +32,10 @@ export class FunctionsDevEdgeRuntimeDisabledError extends Data.TaggedError( override get message() { return `${this.detail}\n Suggestion: ${this.suggestion}`; } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } } export interface ResolvedFunctionsDevEdgeRuntimeConfig { diff --git a/apps/cli/src/next/commands/functions/new/new.errors.ts b/apps/cli/src/next/commands/functions/new/new.errors.ts index 4da86bf457..060f83bd15 100644 --- a/apps/cli/src/next/commands/functions/new/new.errors.ts +++ b/apps/cli/src/next/commands/functions/new/new.errors.ts @@ -1,18 +1,35 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; export class InvalidFunctionSlugError extends Data.TaggedError("InvalidFunctionSlugError")<{ readonly detail: string; readonly suggestion: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} export class MissingFunctionSlugError extends Data.TaggedError("MissingFunctionSlugError")<{ readonly detail: string; readonly suggestion: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} export class FunctionEntrypointExistsError extends Data.TaggedError( "FunctionEntrypointExistsError", )<{ readonly detail: string; readonly suggestion: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} diff --git a/apps/cli/src/next/commands/link/link.errors.ts b/apps/cli/src/next/commands/link/link.errors.ts index 06e8ff24d2..21d3f9c3ff 100644 --- a/apps/cli/src/next/commands/link/link.errors.ts +++ b/apps/cli/src/next/commands/link/link.errors.ts @@ -1,11 +1,24 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../shared/telemetry/error-actionability.ts"; export class ProjectRefRequiredError extends Data.TaggedError("ProjectRefRequiredError")<{ readonly detail: string; readonly suggestion: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.missingProjectRef; + } +} export class NoAccessibleProjectsError extends Data.TaggedError("NoAccessibleProjectsError")<{ readonly detail: string; readonly suggestion: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.accountAccess; + } +} diff --git a/apps/cli/src/next/commands/login/login.handler.ts b/apps/cli/src/next/commands/login/login.handler.ts index a0006a25cd..15f270b8ea 100644 --- a/apps/cli/src/next/commands/login/login.handler.ts +++ b/apps/cli/src/next/commands/login/login.handler.ts @@ -18,14 +18,23 @@ import { } from "../../../shared/telemetry/identity.ts"; import { Analytics } from "../../../shared/telemetry/analytics.service.ts"; import { withAnalyticsContext } from "../../../shared/telemetry/analytics-context.ts"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../shared/telemetry/error-actionability.ts"; import { TelemetryRuntime } from "../../../shared/telemetry/runtime.service.ts"; import type { NonInteractiveError } from "../../../shared/output/errors.ts"; import { LoginFailedError, NoTtyError } from "./login.errors.ts"; import type { LoginFlags } from "./login.command.ts"; -class LoginVerificationError extends Data.TaggedError("LoginVerificationError")<{ +export class LoginVerificationError extends Data.TaggedError("LoginVerificationError")<{ cause: ApiError; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.authLogin; + } +} const MAX_LOGIN_VERIFICATION_RETRIES = 2; diff --git a/apps/cli/src/next/commands/logs/logs.errors.ts b/apps/cli/src/next/commands/logs/logs.errors.ts index 2fc9866f60..f2e4ea812c 100644 --- a/apps/cli/src/next/commands/logs/logs.errors.ts +++ b/apps/cli/src/next/commands/logs/logs.errors.ts @@ -1,4 +1,9 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../shared/telemetry/error-actionability.ts"; export class UnsupportedLogsOutputFormatError extends Data.TaggedError( "UnsupportedLogsOutputFormatError", @@ -9,4 +14,8 @@ export class UnsupportedLogsOutputFormatError extends Data.TaggedError( override get message() { return `${this.detail}\n Suggestion: ${this.suggestion}`; } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } } diff --git a/apps/cli/src/next/commands/platform/platform.errors.ts b/apps/cli/src/next/commands/platform/platform.errors.ts index cd049e7987..d0372c4604 100644 --- a/apps/cli/src/next/commands/platform/platform.errors.ts +++ b/apps/cli/src/next/commands/platform/platform.errors.ts @@ -1,24 +1,46 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../shared/telemetry/error-actionability.ts"; + export class PlatformInputError extends Data.TaggedError("PlatformInputError")<{ readonly message: string; readonly detail?: string; readonly suggestion?: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} export class PlatformMetadataError extends Data.TaggedError("PlatformMetadataError")<{ readonly message: string; readonly detail?: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.impossibleState; + } +} export class PlatformRouteNotFoundError extends Data.TaggedError("PlatformRouteNotFoundError")<{ readonly message: string; readonly detail?: string; readonly suggestion?: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} export class PlatformMethodSelectionError extends Data.TaggedError("PlatformMethodSelectionError")<{ readonly message: string; readonly detail?: string; readonly suggestion?: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} diff --git a/apps/cli/src/next/config/project-link-remote.layer.ts b/apps/cli/src/next/config/project-link-remote.layer.ts index cd444e93cd..f697973fd5 100644 --- a/apps/cli/src/next/config/project-link-remote.layer.ts +++ b/apps/cli/src/next/config/project-link-remote.layer.ts @@ -1,5 +1,10 @@ import { Data, Duration, Effect, Exit, Layer } from "effect"; import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; import { PlatformApi } from "../auth/platform-api.service.ts"; import { CliConfig } from "./cli-config.service.ts"; import { @@ -10,13 +15,21 @@ import { } from "./project-link-remote.service.ts"; import type { LinkedServiceVersions } from "./project-link-state.service.ts"; -class ServiceVersionNotFoundError extends Data.TaggedError("ServiceVersionNotFoundError")<{ +export class ServiceVersionNotFoundError extends Data.TaggedError("ServiceVersionNotFoundError")<{ readonly service: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} -class NoProjectApiKeyError extends Data.TaggedError("NoProjectApiKeyError")<{ +export class NoProjectApiKeyError extends Data.TaggedError("NoProjectApiKeyError")<{ readonly projectRef: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.permission; + } +} type ProjectApiKey = { readonly name: string; diff --git a/apps/cli/src/next/config/project-link-state.service.ts b/apps/cli/src/next/config/project-link-state.service.ts index 5e5ea316a5..04cb75fa81 100644 --- a/apps/cli/src/next/config/project-link-state.service.ts +++ b/apps/cli/src/next/config/project-link-state.service.ts @@ -1,5 +1,10 @@ import type { Effect, Option } from "effect"; import { Data, Schema, Context } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; const LinkedServiceVersionsSchema = Schema.Struct({ postgres: Schema.optionalKey(Schema.String), @@ -37,12 +42,20 @@ export type ProjectLinkStateValue = Schema.Schema.Type {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.relinkProject; + } +} export class ProjectNotLinkedError extends Data.TaggedError("ProjectNotLinkedError")<{ readonly detail: string; readonly suggestion: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.projectNotLinked; + } +} interface ProjectLinkStateShape { readonly load: Effect.Effect, InvalidProjectLinkStateError>; diff --git a/apps/cli/src/next/config/project-local-service-versions.service.ts b/apps/cli/src/next/config/project-local-service-versions.service.ts index 77168189df..59b7dd2083 100644 --- a/apps/cli/src/next/config/project-local-service-versions.service.ts +++ b/apps/cli/src/next/config/project-local-service-versions.service.ts @@ -1,5 +1,10 @@ import type { Effect, Option } from "effect"; import { Data, Schema, Context } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; const LocalServiceVersionsSchema = Schema.Struct({ postgres: Schema.optionalKey(Schema.String), @@ -28,7 +33,11 @@ export class InvalidLocalServiceVersionsStateError extends Data.TaggedError( )<{ readonly detail: string; readonly suggestion: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} interface ProjectLocalServiceVersionsShape { readonly load: Effect.Effect< diff --git a/apps/cli/src/next/config/service-version-resolution.ts b/apps/cli/src/next/config/service-version-resolution.ts index 62f9aedb82..dccd3321c3 100644 --- a/apps/cli/src/next/config/service-version-resolution.ts +++ b/apps/cli/src/next/config/service-version-resolution.ts @@ -6,17 +6,26 @@ import { SERVICE_NAMES, } from "@supabase/stack/effect"; import { Data, Effect, Option } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; import { ProjectLocalServiceVersions } from "./project-local-service-versions.service.ts"; import { ProjectLinkState } from "./project-link-state.service.ts"; export type ResolvedServiceVersionContext = StackVersionPlan; -class InvalidServiceVersionOverrideError extends Data.TaggedError( +export class InvalidServiceVersionOverrideError extends Data.TaggedError( "InvalidServiceVersionOverrideError", )<{ readonly detail: string; readonly suggestion: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} function isServiceName(value: string): value is ServiceName { return (SERVICE_NAMES as ReadonlyArray).includes(value); diff --git a/apps/cli/src/shared/functions/delete.errors.ts b/apps/cli/src/shared/functions/delete.errors.ts index ba0ee0a761..6c05504beb 100644 --- a/apps/cli/src/shared/functions/delete.errors.ts +++ b/apps/cli/src/shared/functions/delete.errors.ts @@ -1,19 +1,40 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../telemetry/error-actionability.ts"; export class InvalidFunctionSlugError extends Data.TaggedError("InvalidFunctionSlugError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} export class FunctionNotFoundError extends Data.TaggedError("FunctionNotFoundError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} export class DeleteFunctionNetworkError extends Data.TaggedError("DeleteFunctionNetworkError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} export class DeleteFunctionUnexpectedStatusError extends Data.TaggedError( "DeleteFunctionUnexpectedStatusError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.apiStatus; + } +} diff --git a/apps/cli/src/shared/functions/deploy.errors.ts b/apps/cli/src/shared/functions/deploy.errors.ts index b1ac44b74e..4a4cbc13b3 100644 --- a/apps/cli/src/shared/functions/deploy.errors.ts +++ b/apps/cli/src/shared/functions/deploy.errors.ts @@ -1,21 +1,42 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../telemetry/error-actionability.ts"; export class ConflictingFunctionDeployFlagsError extends Data.TaggedError( "ConflictingFunctionDeployFlagsError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} export class InvalidFunctionDeploySlugError extends Data.TaggedError( "InvalidFunctionDeploySlugError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} export class NoFunctionsToDeployError extends Data.TaggedError("NoFunctionsToDeployError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} export class FunctionDeployCancelledError extends Data.TaggedError("FunctionDeployCancelledError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.cancelled; + } +} diff --git a/apps/cli/src/shared/functions/download.errors.ts b/apps/cli/src/shared/functions/download.errors.ts index b12aeca7af..f25d42409a 100644 --- a/apps/cli/src/shared/functions/download.errors.ts +++ b/apps/cli/src/shared/functions/download.errors.ts @@ -1,29 +1,54 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../telemetry/error-actionability.ts"; export class InvalidFunctionSlugError extends Data.TaggedError("InvalidFunctionSlugError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} export class ConflictingFunctionDownloadFlagsError extends Data.TaggedError( "ConflictingFunctionDownloadFlagsError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} export class FunctionDownloadNotFoundError extends Data.TaggedError( "FunctionDownloadNotFoundError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} export class InvalidFunctionDownloadResponseError extends Data.TaggedError( "InvalidFunctionDownloadResponseError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.apiStatus; + } +} export class UnsafeFunctionDownloadPathError extends Data.TaggedError( "UnsafeFunctionDownloadPathError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.apiStatus; + } +} diff --git a/apps/cli/src/shared/init/project-init.errors.ts b/apps/cli/src/shared/init/project-init.errors.ts index edcd4e069c..6d685a76f3 100644 --- a/apps/cli/src/shared/init/project-init.errors.ts +++ b/apps/cli/src/shared/init/project-init.errors.ts @@ -1,4 +1,9 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../telemetry/error-actionability.ts"; export class InitAlreadyExistsError extends Data.TaggedError("InitAlreadyExistsError")<{ readonly detail: string; @@ -7,6 +12,10 @@ export class InitAlreadyExistsError extends Data.TaggedError("InitAlreadyExistsE override get message() { return "A Supabase project is already initialized in this directory."; } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } } export class InitExperimentalRequiredError extends Data.TaggedError( @@ -18,6 +27,10 @@ export class InitExperimentalRequiredError extends Data.TaggedError( override get message() { return "The --use-orioledb flag requires --experimental."; } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } } export class InitParseSettingsError extends Data.TaggedError("InitParseSettingsError")<{ @@ -27,4 +40,8 @@ export class InitParseSettingsError extends Data.TaggedError("InitParseSettingsE override get message() { return "Failed to parse existing IDE settings file."; } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } } diff --git a/apps/cli/src/shared/output/errors.ts b/apps/cli/src/shared/output/errors.ts index 02e93c8143..588108e0a6 100644 --- a/apps/cli/src/shared/output/errors.ts +++ b/apps/cli/src/shared/output/errors.ts @@ -1,4 +1,9 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../telemetry/error-actionability.ts"; export class NonInteractiveError extends Data.TaggedError("NonInteractiveError")<{ readonly detail: string; @@ -7,4 +12,8 @@ export class NonInteractiveError extends Data.TaggedError("NonInteractiveError") override get message() { return `${this.detail}\n Suggestion: ${this.suggestion}`; } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } } diff --git a/apps/cli/src/shared/runtime/file-watcher.service.ts b/apps/cli/src/shared/runtime/file-watcher.service.ts index b7dfb6c05a..70f0c5f047 100644 --- a/apps/cli/src/shared/runtime/file-watcher.service.ts +++ b/apps/cli/src/shared/runtime/file-watcher.service.ts @@ -1,6 +1,12 @@ import type { Stream } from "effect"; import { Data, Context } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../telemetry/error-actionability.ts"; + type FileWatchEventType = "create" | "update" | "delete"; export interface FileWatchEvent { @@ -15,7 +21,11 @@ export interface FileWatchOptions { export class FileWatcherError extends Data.TaggedError("FileWatcherError")<{ readonly path: string; readonly cause: unknown; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.permission; + } +} interface FileWatcherShape { readonly watch: ( diff --git a/apps/cli/src/shared/services/services.shared.ts b/apps/cli/src/shared/services/services.shared.ts index 05840cb893..38273b72f5 100644 --- a/apps/cli/src/shared/services/services.shared.ts +++ b/apps/cli/src/shared/services/services.shared.ts @@ -4,6 +4,11 @@ import { Data, Duration, Effect, Exit, Redacted } from "effect"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; import { renderGlamourTable } from "../../legacy/output/legacy-glamour-table.ts"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../telemetry/error-actionability.ts"; import { dockerfileServiceImages, parseDockerfileServiceImages, @@ -197,9 +202,13 @@ export interface ServiceFetchConfig { readonly tenantBaseUrlOverride?: string; } -class ServiceVersionNotFoundError extends Data.TaggedError("ServiceVersionNotFoundError")<{ +export class ServiceVersionNotFoundError extends Data.TaggedError("ServiceVersionNotFoundError")<{ readonly service: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} function fieldValue(value: unknown, key: string): unknown { if (typeof value !== "object" || value === null) { diff --git a/apps/cli/src/shared/telemetry/error-actionability-coverage.unit.test.ts b/apps/cli/src/shared/telemetry/error-actionability-coverage.unit.test.ts new file mode 100644 index 0000000000..72b57fb499 --- /dev/null +++ b/apps/cli/src/shared/telemetry/error-actionability-coverage.unit.test.ts @@ -0,0 +1,162 @@ +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +// Vitest (via Vite) provides `import.meta.glob` at runtime; the workspace +// tsconfig does not load `vite/client`, so declare the one member we use. +declare global { + interface ImportMeta { + readonly glob: (patterns: ReadonlyArray) => Record Promise>; + } +} +import { + CliErrorCategory, + CliErrorKind, + CliSuggestionType, + ErrorActionabilityId, + isClassifiedExternalErrorTag, +} from "./error-actionability.ts"; + +/** + * Drift guard for the error actionability taxonomy: every error class defined + * in `apps/cli/src` must declare its own classification under + * {@link ErrorActionabilityId}, and every error tag defined in the workspace + * packages that can surface through CLI commands must have an external + * adapter. A new error type failing here is the feature — `unknown` in + * production telemetry must mean "genuinely unforeseen failure", never "we + * forgot to classify". + */ + +const ERROR_DEFINITION_PATTERN = /(?:TaggedError|CliError)\(\s*"([A-Za-z0-9_]+)"/gs; + +function scanErrorTags(root: string): Map> { + const tagsByFile = new Map>(); + const walk = (dir: string) => { + for (const entry of readdirSync(dir)) { + const path = join(dir, entry); + if (statSync(path).isDirectory()) { + walk(path); + continue; + } + if (!path.endsWith(".ts") || path.endsWith(".test.ts")) continue; + const tags = [...readFileSync(path, "utf8").matchAll(ERROR_DEFINITION_PATTERN)].map( + (match) => match[1] ?? "", + ); + if (tags.length > 0) tagsByFile.set(path, tags); + } + }; + walk(root); + return tagsByFile; +} + +const kindValues = new Set(Object.values(CliErrorKind)); +const categoryValues = new Set(Object.values(CliErrorCategory)); +const suggestionValues = new Set(Object.values(CliSuggestionType)); + +interface DeclaredErrorClass { + readonly exportName: string; + readonly tag: string; + readonly prototype: object; +} + +function collectErrorClasses(module: Record): Array { + const classes: Array = []; + for (const [exportName, value] of Object.entries(module)) { + if (typeof value !== "function") continue; + const prototype: unknown = value.prototype; + if (typeof prototype !== "object" || prototype === null) continue; + if (!(prototype instanceof Error)) continue; + // effect V4 assigns `_tag` per instance, so probe with an empty props bag. + let tag: unknown; + try { + tag = Reflect.get(Reflect.construct(value, [{}]), "_tag"); + } catch { + tag = undefined; + } + if (typeof tag !== "string") continue; + classes.push({ exportName, tag, prototype }); + } + return classes; +} + +const srcRoot = resolve(import.meta.dirname, "../.."); +const repoRoot = resolve(import.meta.dirname, "../../../../.."); + +const moduleLoaders = new Map( + Object.entries(import.meta.glob(["../../**/*.ts", "!**/*.test.ts"])).map(([key, loader]) => [ + resolve(import.meta.dirname, key), + loader, + ]), +); + +describe("apps/cli error classes declare their actionability", () => { + const tagsByFile = scanErrorTags(srcRoot); + + it("finds the error definition surface", () => { + expect(tagsByFile.size).toBeGreaterThan(50); + }); + + for (const [file, tags] of tagsByFile) { + const relativePath = file.slice(srcRoot.length + 1); + it(relativePath, async () => { + const loader = moduleLoaders.get(file); + expect(loader, `no module loader for ${relativePath}`).toBeDefined(); + const module = await loader?.(); + expect(typeof module).toBe("object"); + const classes = collectErrorClasses(Object(module)); + + const exportedTags = new Set(classes.map((cls) => cls.tag)); + for (const tag of tags) { + expect( + exportedTags.has(tag), + `error "${tag}" is defined in ${relativePath} but not exported — export it so its actionability declaration is verifiable`, + ).toBe(true); + } + + for (const { exportName, tag, prototype } of classes) { + expect( + ErrorActionabilityId in prototype, + `${exportName} ("${tag}") does not declare [ErrorActionabilityId] — add a getter returning its CliErrorActionabilityDeclaration`, + ).toBe(true); + + // Evaluate the getter against a field-less probe: instance-dependent + // declarations must degrade to a valid declaration when fields are + // absent, and static ones are checked directly. + const probe: object = Object.create(prototype); + const declaration: unknown = Reflect.get(probe, ErrorActionabilityId); + expect( + typeof declaration === "object" && declaration !== null, + `${exportName} ("${tag}") declaration is not an object`, + ).toBe(true); + const record: Record = Object(declaration); + expect(kindValues.has(String(record["error_kind"]))).toBe(true); + expect(categoryValues.has(String(record["error_category"]))).toBe(true); + expect(typeof record["has_suggestion"]).toBe("boolean"); + expect(suggestionValues.has(String(record["suggestion_type"]))).toBe(true); + } + }); + } +}); + +describe("workspace package error tags have external adapters", () => { + const packageRoots = [ + "packages/stack/src", + "packages/config/src", + "packages/process-compose/src", + ]; + + for (const packageRoot of packageRoots) { + it(packageRoot, () => { + const tagsByFile = scanErrorTags(resolve(repoRoot, packageRoot)); + expect(tagsByFile.size).toBeGreaterThan(0); + for (const [file, tags] of tagsByFile) { + for (const tag of tags) { + expect( + isClassifiedExternalErrorTag(tag), + `"${tag}" (${file.slice(repoRoot.length + 1)}) has no external adapter in error-actionability.ts`, + ).toBe(true); + } + } + }); + } +}); diff --git a/apps/cli/src/shared/telemetry/error-actionability.ts b/apps/cli/src/shared/telemetry/error-actionability.ts new file mode 100644 index 0000000000..1746237f7c --- /dev/null +++ b/apps/cli/src/shared/telemetry/error-actionability.ts @@ -0,0 +1,543 @@ +import { Cause, Option } from "effect"; + +/** + * CLI error actionability taxonomy for KPI reporting (CLI-1560). + * + * Classification is declared where each error is defined: every error class in + * `apps/cli/src` exposes a {@link CliErrorActionabilityDeclaration} under the + * {@link ErrorActionabilityId} symbol (enforced by + * `error-actionability-coverage.unit.test.ts`). Errors originating outside the + * CLI workspace (`@supabase/stack`, `@supabase/config`, + * `@supabase/process-compose`, `effect` cli/http) are classified by the + * structural adapters at the bottom of this module, which are themselves + * exhaustiveness-checked against those packages' sources. + * + * Everything emitted from here is sanitized by construction: kinds, categories, + * suggestion types, and fingerprints are closed enums or `tag:`-prefixed safe + * identifiers — never raw error text or user-specific data. + */ + +export const CliErrorKind = { + UserActionable: "user_actionable", + InternalBug: "internal_bug", + ExternalService: "external_service", + UserCancelled: "user_cancelled", + Unknown: "unknown", +} as const; + +export type CliErrorKind = (typeof CliErrorKind)[keyof typeof CliErrorKind]; + +export const CliErrorCategory = { + Auth: "auth", + MissingProjectRef: "missing_project_ref", + ProjectNotLinked: "project_not_linked", + DockerNotRunning: "docker_not_running", + InvalidConfig: "invalid_config", + DbConnection: "db_connection", + MigrationDrift: "migration_drift", + Permission: "permission", + PlanLimit: "plan_limit", + InvalidInput: "invalid_input", + Network: "network", + ApiStatus: "api_status", + Cancelled: "cancelled", + Panic: "panic", + ImpossibleState: "impossible_state", + Unknown: "unknown", +} as const; + +export type CliErrorCategory = (typeof CliErrorCategory)[keyof typeof CliErrorCategory]; + +export const CliSuggestionType = { + Login: "login", + LinkProject: "link_project", + StartDocker: "start_docker", + ProvideFlags: "provide_flags", + SetEnvVar: "set_env_var", + RepairMigration: "repair_migration", + UpdateConfig: "update_config", + UpgradePlan: "upgrade_plan", + RerunDebug: "rerun_debug", + None: "none", +} as const; + +export type CliSuggestionType = (typeof CliSuggestionType)[keyof typeof CliSuggestionType]; + +/** Q2 KPI metric definitions, kept next to the taxonomy they consume. */ +export const CliErrorActionabilityMetricDefinitions = { + strictRecovery: { + id: "same_command_success_same_session", + description: + "A user-actionable error is recovered when the same command later succeeds in the same PostHog session.", + }, + repeatError: { + id: "same_command_same_error_same_session_before_success", + description: + "A user-actionable error repeats when the same command fails again with the same error fingerprint in the same PostHog session before success.", + }, + internalUnknownBugRate: { + id: "failed_commands_internal_bug_or_unknown", + description: + "Internal/unknown bug failure rate is the share of failed commands classified as internal_bug or unknown.", + }, +} as const; + +/** + * Symbol under which CLI error classes declare their own actionability. + * + * Declare it as a getter so it lives on the prototype (visible to the coverage + * test without instantiating the class) and can branch on instance fields: + * + * ```ts + * class MyStatusError extends Data.TaggedError(tag)<{ status: number }> { + * get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + * return statusCodeActionability(this.status); + * } + * } + * ``` + */ +export const ErrorActionabilityId: unique symbol = Symbol.for( + "@supabase/cli/telemetry/ErrorActionability", +); + +export interface CliErrorActionabilityDeclaration { + readonly error_kind: CliErrorKind; + readonly error_category: CliErrorCategory; + readonly has_suggestion: boolean; + readonly suggestion_type: CliSuggestionType; + readonly suggested_command?: string; + /** + * Distinguishes branches of instance-dependent declarations in the repeat + * fingerprint, so e.g. a registry pull failure and a daemon-down failure of + * the same wrapper tag never count as repeats of one another. Must be a + * static safe identifier, never derived from error text. + */ + readonly fingerprint_suffix?: string; +} + +/** Sanitized classification emitted with failed `cli_command_executed` events. */ +export interface CliErrorActionability { + readonly error_kind: CliErrorKind; + readonly error_category: CliErrorCategory; + readonly error_fingerprint: string; + readonly has_suggestion: boolean; + readonly suggestion_type: CliSuggestionType; + readonly suggested_command?: string; +} + +/** Shared declarations for the recurring classification shapes. */ +export const actionability = { + authLogin: { + error_kind: CliErrorKind.UserActionable, + error_category: CliErrorCategory.Auth, + has_suggestion: true, + suggestion_type: CliSuggestionType.Login, + suggested_command: "supabase login", + }, + authToken: { + error_kind: CliErrorKind.UserActionable, + error_category: CliErrorCategory.Auth, + has_suggestion: true, + suggestion_type: CliSuggestionType.SetEnvVar, + }, + provideFlags: { + error_kind: CliErrorKind.UserActionable, + error_category: CliErrorCategory.InvalidInput, + has_suggestion: true, + suggestion_type: CliSuggestionType.ProvideFlags, + }, + invalidInput: { + error_kind: CliErrorKind.UserActionable, + error_category: CliErrorCategory.InvalidInput, + has_suggestion: false, + suggestion_type: CliSuggestionType.None, + }, + invalidConfig: { + error_kind: CliErrorKind.UserActionable, + error_category: CliErrorCategory.InvalidConfig, + has_suggestion: true, + suggestion_type: CliSuggestionType.UpdateConfig, + }, + /** + * A database operation failed because of the user's own SQL, schema, or + * data — actionable, but the CLI has no generic remediation to suggest. + */ + dbFinding: { + error_kind: CliErrorKind.UserActionable, + error_category: CliErrorCategory.InvalidConfig, + has_suggestion: false, + suggestion_type: CliSuggestionType.None, + }, + dbConnection: { + error_kind: CliErrorKind.UserActionable, + error_category: CliErrorCategory.DbConnection, + has_suggestion: true, + suggestion_type: CliSuggestionType.UpdateConfig, + }, + migrationDrift: { + error_kind: CliErrorKind.UserActionable, + error_category: CliErrorCategory.MigrationDrift, + has_suggestion: true, + suggestion_type: CliSuggestionType.RepairMigration, + }, + permission: { + error_kind: CliErrorKind.UserActionable, + error_category: CliErrorCategory.Permission, + has_suggestion: true, + suggestion_type: CliSuggestionType.UpdateConfig, + }, + accountAccess: { + error_kind: CliErrorKind.UserActionable, + error_category: CliErrorCategory.Permission, + has_suggestion: true, + suggestion_type: CliSuggestionType.Login, + suggested_command: "supabase login", + }, + planLimit: { + error_kind: CliErrorKind.UserActionable, + error_category: CliErrorCategory.PlanLimit, + has_suggestion: true, + suggestion_type: CliSuggestionType.UpgradePlan, + }, + projectNotLinked: { + error_kind: CliErrorKind.UserActionable, + error_category: CliErrorCategory.ProjectNotLinked, + has_suggestion: true, + suggestion_type: CliSuggestionType.LinkProject, + suggested_command: "supabase link", + }, + missingProjectRef: { + error_kind: CliErrorKind.UserActionable, + error_category: CliErrorCategory.MissingProjectRef, + has_suggestion: true, + suggestion_type: CliSuggestionType.LinkProject, + suggested_command: "supabase link", + }, + /** Local link state exists but is unusable — re-linking repairs it. */ + relinkProject: { + error_kind: CliErrorKind.UserActionable, + error_category: CliErrorCategory.InvalidConfig, + has_suggestion: true, + suggestion_type: CliSuggestionType.LinkProject, + suggested_command: "supabase link", + }, + dockerNotRunning: { + error_kind: CliErrorKind.UserActionable, + error_category: CliErrorCategory.DockerNotRunning, + has_suggestion: true, + suggestion_type: CliSuggestionType.StartDocker, + }, + startStack: { + error_kind: CliErrorKind.UserActionable, + error_category: CliErrorCategory.InvalidConfig, + has_suggestion: true, + suggestion_type: CliSuggestionType.UpdateConfig, + suggested_command: "supabase start", + }, + stopStack: { + error_kind: CliErrorKind.UserActionable, + error_category: CliErrorCategory.InvalidConfig, + has_suggestion: true, + suggestion_type: CliSuggestionType.UpdateConfig, + suggested_command: "supabase stop", + }, + externalNetwork: { + error_kind: CliErrorKind.ExternalService, + error_category: CliErrorCategory.Network, + has_suggestion: true, + suggestion_type: CliSuggestionType.RerunDebug, + }, + apiStatus: { + error_kind: CliErrorKind.ExternalService, + error_category: CliErrorCategory.ApiStatus, + has_suggestion: false, + suggestion_type: CliSuggestionType.None, + }, + cancelled: { + error_kind: CliErrorKind.UserCancelled, + error_category: CliErrorCategory.Cancelled, + has_suggestion: false, + suggestion_type: CliSuggestionType.None, + }, + internalPanic: { + error_kind: CliErrorKind.InternalBug, + error_category: CliErrorCategory.Panic, + has_suggestion: true, + suggestion_type: CliSuggestionType.RerunDebug, + }, + impossibleState: { + error_kind: CliErrorKind.InternalBug, + error_category: CliErrorCategory.ImpossibleState, + has_suggestion: true, + suggestion_type: CliSuggestionType.RerunDebug, + }, + unknown: { + error_kind: CliErrorKind.Unknown, + error_category: CliErrorCategory.Unknown, + has_suggestion: false, + suggestion_type: CliSuggestionType.None, + }, +} as const satisfies Record; + +/** + * Classification policy for errors that carry a Management API status code. + * `upgradeSuggested` is the typed result of the entitlement gate + * (`legacySuggestUpgrade`) threaded through the error constructor — never + * inferred from message text. + */ +export function statusCodeActionability( + status: number | undefined, + opts: { readonly upgradeSuggested?: boolean } = {}, +): CliErrorActionabilityDeclaration { + if (status === 401) { + return { ...actionability.authLogin, fingerprint_suffix: "auth" }; + } + if (opts.upgradeSuggested === true && status !== undefined && status >= 400 && status < 500) { + return { ...actionability.planLimit, fingerprint_suffix: "plan_limit" }; + } + if (status === undefined) { + return { ...actionability.externalNetwork, fingerprint_suffix: "network" }; + } + return { ...actionability.apiStatus, fingerprint_suffix: "api_status" }; +} + +type ErrorRecord = Record; + +function isErrorRecord(value: unknown): value is ErrorRecord { + return typeof value === "object" && value !== null; +} + +function readString(value: ErrorRecord, key: string): string | undefined { + const field = value[key]; + return typeof field === "string" && field.trim().length > 0 ? field.trim() : undefined; +} + +function readNumber(value: ErrorRecord, key: string): number | undefined { + const field = value[key]; + return typeof field === "number" && Number.isFinite(field) ? field : undefined; +} + +const kindValues = new Set(Object.values(CliErrorKind)); +const categoryValues = new Set(Object.values(CliErrorCategory)); +const suggestionValues = new Set(Object.values(CliSuggestionType)); + +function isDeclaration(value: unknown): value is CliErrorActionabilityDeclaration { + if (!isErrorRecord(value)) return false; + const kind = value["error_kind"]; + const category = value["error_category"]; + const suggestion = value["suggestion_type"]; + return ( + typeof kind === "string" && + kindValues.has(kind) && + typeof category === "string" && + categoryValues.has(category) && + typeof value["has_suggestion"] === "boolean" && + typeof suggestion === "string" && + suggestionValues.has(suggestion) + ); +} + +function readDeclaration(error: unknown): CliErrorActionabilityDeclaration | undefined { + if (!isErrorRecord(error)) return undefined; + if (!(ErrorActionabilityId in error)) return undefined; + const declared = error[ErrorActionabilityId]; + return isDeclaration(declared) ? declared : undefined; +} + +function safeIdentifier(value: string | undefined): string | undefined { + if (value === undefined) return undefined; + return /^[A-Za-z][A-Za-z0-9_]*$/.test(value) ? value : undefined; +} + +function readErrorTag(error: unknown): string | undefined { + if (!isErrorRecord(error)) return undefined; + return safeIdentifier(readString(error, "_tag")); +} + +function readErrorName(error: unknown): string | undefined { + if (error instanceof Error) return safeIdentifier(error.name); + if (!isErrorRecord(error)) return undefined; + return safeIdentifier(readString(error, "name")); +} + +function fingerprint(prefix: string, identifier: string | undefined, suffix?: string): string { + const base = identifier === undefined ? `${prefix}:unknown` : `${prefix}:${identifier}`; + return suffix === undefined ? base : `${base}:${suffix}`; +} + +function toActionability( + declaration: CliErrorActionabilityDeclaration, + fingerprintPrefix: string, + identifier: string | undefined, +): CliErrorActionability { + const { fingerprint_suffix, ...rest } = declaration; + return { + ...rest, + error_fingerprint: fingerprint(fingerprintPrefix, identifier, fingerprint_suffix), + }; +} + +/** + * Adapters for errors defined outside `apps/cli`, keyed by `_tag`. Kept + * exhaustive against those packages' sources by + * `error-actionability-coverage.unit.test.ts`. Everything defined inside + * `apps/cli` must declare {@link ErrorActionabilityId} instead of being + * added here. + */ +const externalActionabilityByTag: Record< + string, + (error: ErrorRecord) => CliErrorActionabilityDeclaration +> = { + // effect/unstable/cli parser failures + MissingOption: () => actionability.invalidInput, + UnknownSubcommand: () => actionability.invalidInput, + UnrecognizedOption: () => actionability.invalidInput, + + // @supabase/config + ProjectConfigParseError: () => actionability.invalidConfig, + ProjectEnvParseError: () => actionability.invalidConfig, + MissingProjectConfigValueError: () => actionability.invalidConfig, + DuplicateRemoteProjectIdError: () => actionability.invalidConfig, + + // effect/unstable/http — generated Management API client transport/decoding + HttpClientError: (error) => { + const reason = error["reason"]; + const reasonTag = isErrorRecord(reason) ? readString(reason, "_tag") : undefined; + const response = error["response"]; + const status = isErrorRecord(response) ? readNumber(response, "status") : undefined; + if (status === 401) return { ...actionability.authLogin, fingerprint_suffix: "auth" }; + if (reasonTag === "StatusCodeError" || isErrorRecord(response)) { + return { ...actionability.apiStatus, fingerprint_suffix: "api_status" }; + } + return { ...actionability.externalNetwork, fingerprint_suffix: "network" }; + }, + HttpBodyError: () => ({ ...actionability.apiStatus, fingerprint_suffix: "api_response" }), + SchemaError: () => ({ ...actionability.apiStatus, fingerprint_suffix: "api_response" }), + + // @supabase/stack + BinaryNotFoundError: () => actionability.invalidConfig, + DownloadError: () => actionability.externalNetwork, + ChecksumMismatchError: () => ({ + ...actionability.externalNetwork, + fingerprint_suffix: "asset_checksum", + }), + DockerPullError: (error) => + error["daemonDown"] === true + ? { ...actionability.dockerNotRunning, fingerprint_suffix: "docker_not_running" } + : { ...actionability.externalNetwork, fingerprint_suffix: "registry_pull" }, + StackBuildError: (error) => { + const reason = readString(error, "reason"); + if (reason === "invalid_config") { + return { ...actionability.invalidConfig, fingerprint_suffix: "invalid_config" }; + } + if (reason === "asset_preparation") { + return { ...actionability.externalNetwork, fingerprint_suffix: "asset_preparation" }; + } + return { ...actionability.impossibleState, fingerprint_suffix: "internal_build" }; + }, + PortConflictError: () => ({ + ...actionability.invalidConfig, + fingerprint_suffix: "port_conflict", + }), + PortAllocationError: () => ({ + ...actionability.invalidConfig, + fingerprint_suffix: "port_allocation", + }), + StateNotFoundError: () => actionability.startStack, + StackMetadataNotFoundError: () => actionability.startStack, + InvalidStackStateError: () => actionability.invalidConfig, + InvalidStackMetadataError: () => actionability.invalidConfig, + UnsupportedStackMetadataVersionError: () => actionability.invalidConfig, + NoRunningStackError: () => actionability.startStack, + StackAlreadyRunningError: () => actionability.stopStack, + DaemonStartError: () => actionability.startStack, + DaemonStillRunningError: () => actionability.stopStack, + UnixHttpClientError: () => actionability.stopStack, + + // @supabase/process-compose — the CLI generates the process graph, so graph + // invariants are internal bugs; runtime service failures are stack-state + // problems the user resolves by restarting the stack. + CyclicDependencyError: () => actionability.impossibleState, + MissingDependencyError: () => actionability.impossibleState, + ServiceNotFoundError: () => actionability.impossibleState, + SpawnError: () => actionability.startStack, + ShutdownTimeoutError: () => actionability.stopStack, + ServiceReadyError: () => actionability.startStack, +}; + +/** + * Whether a tag defined outside `apps/cli` has an external adapter. Used by + * the coverage test to keep {@link externalActionabilityByTag} exhaustive + * against the workspace packages. + */ +export function isClassifiedExternalErrorTag(tag: string): boolean { + return tag in externalActionabilityByTag; +} + +function classifyShowHelp(error: ErrorRecord): CliErrorActionability | undefined { + const errors = error["errors"]; + if (!Array.isArray(errors)) return undefined; + if (errors.length === 1) return classifyCliErrorActionability(errors[0]); + return toActionability(actionability.invalidInput, "tag", "ShowHelp"); +} + +function isNativeJsExceptionName(name: string | undefined): boolean { + return ( + name === "TypeError" || + name === "ReferenceError" || + name === "RangeError" || + name === "SyntaxError" || + name === "EvalError" || + name === "URIError" || + name === "AggregateError" + ); +} + +export function classifyCliErrorActionability(error: unknown): CliErrorActionability { + const tag = readErrorTag(error); + + const declared = readDeclaration(error); + if (declared !== undefined) { + return toActionability(declared, "tag", tag ?? readErrorName(error)); + } + + if (tag === "ShowHelp" && isErrorRecord(error)) { + const classified = classifyShowHelp(error); + if (classified !== undefined) return classified; + } + + if (tag !== undefined && isErrorRecord(error)) { + const external = externalActionabilityByTag[tag]; + if (external !== undefined) { + return toActionability(external(error), "tag", tag); + } + return toActionability(actionability.unknown, "tag", tag); + } + + if (isErrorRecord(error) && readErrorName(error) === "StackError") { + if (readString(error, "code") === "PORT_ALLOCATION") { + return toActionability( + { ...actionability.invalidConfig, fingerprint_suffix: "port_allocation" }, + "error", + "StackError", + ); + } + return toActionability(actionability.unknown, "error", "StackError"); + } + + if (typeof error === "string") { + return toActionability(actionability.unknown, "string", undefined); + } + + const name = readErrorName(error); + if (isNativeJsExceptionName(name)) { + return toActionability(actionability.internalPanic, "error", name); + } + + return toActionability(actionability.unknown, "error", name); +} + +export function classifyCliCauseActionability(cause: Cause.Cause): CliErrorActionability { + const error = Option.getOrElse(Cause.findErrorOption(cause), () => Cause.squash(cause)); + return classifyCliErrorActionability(error); +} diff --git a/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts b/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts new file mode 100644 index 0000000000..2c2ce2f301 --- /dev/null +++ b/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts @@ -0,0 +1,200 @@ +import { Cause, Data } from "effect"; +import { describe, expect, it } from "vitest"; +import { + actionability, + type CliErrorActionabilityDeclaration, + classifyCliCauseActionability, + classifyCliErrorActionability, + CliErrorActionabilityMetricDefinitions, + ErrorActionabilityId, + statusCodeActionability, +} from "./error-actionability.ts"; + +class DeclaredError extends Data.TaggedError("DeclaredError")<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.authLogin; + } +} + +class DeclaredStatusError extends Data.TaggedError("DeclaredStatusError")<{ + readonly status: number; + readonly upgradeSuggested?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { upgradeSuggested: this.upgradeSuggested }); + } +} + +class UndeclaredError extends Data.TaggedError("UndeclaredError")<{ + readonly message: string; +}> {} + +describe("classifyCliErrorActionability", () => { + it("uses the declaration co-located on the error class", () => { + expect( + classifyCliErrorActionability(new DeclaredError({ message: "raw secret text" })), + ).toEqual({ + error_kind: "user_actionable", + error_category: "auth", + error_fingerprint: "tag:DeclaredError", + has_suggestion: true, + suggestion_type: "login", + suggested_command: "supabase login", + }); + }); + + it("lets instance-dependent declarations branch on typed fields", () => { + const auth = classifyCliErrorActionability(new DeclaredStatusError({ status: 401 })); + expect(auth.error_category).toBe("auth"); + expect(auth.error_fingerprint).toBe("tag:DeclaredStatusError:auth"); + + const gated = classifyCliErrorActionability( + new DeclaredStatusError({ status: 404, upgradeSuggested: true }), + ); + expect(gated.error_category).toBe("plan_limit"); + expect(gated.suggestion_type).toBe("upgrade_plan"); + expect(gated.error_fingerprint).toBe("tag:DeclaredStatusError:plan_limit"); + + const status = classifyCliErrorActionability(new DeclaredStatusError({ status: 500 })); + expect(status.error_kind).toBe("external_service"); + expect(status.error_category).toBe("api_status"); + expect(status.error_fingerprint).toBe("tag:DeclaredStatusError:api_status"); + }); + + it("classifies undeclared tagged errors as unknown with a sanitized fingerprint", () => { + const result = classifyCliErrorActionability(new UndeclaredError({ message: "boom" })); + expect(result.error_kind).toBe("unknown"); + expect(result.error_fingerprint).toBe("tag:UndeclaredError"); + }); + + it("classifies external stack build errors by structured reason", () => { + const invalidConfig = classifyCliErrorActionability({ + _tag: "StackBuildError", + detail: "imgproxy requires storage to be enabled", + reason: "invalid_config", + }); + expect(invalidConfig.error_category).toBe("invalid_config"); + expect(invalidConfig.error_fingerprint).toBe("tag:StackBuildError:invalid_config"); + + const assetPreparation = classifyCliErrorActionability({ + _tag: "StackBuildError", + detail: "Failed to prepare stack assets", + reason: "asset_preparation", + }); + expect(assetPreparation.error_kind).toBe("external_service"); + expect(assetPreparation.error_fingerprint).toBe("tag:StackBuildError:asset_preparation"); + + const internal = classifyCliErrorActionability({ _tag: "StackBuildError", detail: "bug" }); + expect(internal.error_kind).toBe("internal_bug"); + expect(internal.error_category).toBe("impossible_state"); + expect(internal.error_fingerprint).toBe("tag:StackBuildError:internal_build"); + }); + + it("splits docker pull failures from a stopped docker daemon", () => { + const daemonDown = classifyCliErrorActionability({ + _tag: "DockerPullError", + image: "postgres", + daemonDown: true, + }); + expect(daemonDown.error_category).toBe("docker_not_running"); + expect(daemonDown.suggestion_type).toBe("start_docker"); + + const pull = classifyCliErrorActionability({ _tag: "DockerPullError", image: "postgres" }); + expect(pull.error_kind).toBe("external_service"); + expect(pull.error_fingerprint).toBe("tag:DockerPullError:registry_pull"); + }); + + it("classifies http client errors by response presence and status", () => { + const auth = classifyCliErrorActionability({ + _tag: "HttpClientError", + response: { status: 401 }, + }); + expect(auth.error_category).toBe("auth"); + + const status = classifyCliErrorActionability({ + _tag: "HttpClientError", + response: { status: 503 }, + }); + expect(status.error_category).toBe("api_status"); + + const transport = classifyCliErrorActionability({ + _tag: "HttpClientError", + reason: { _tag: "TransportError" }, + }); + expect(transport.error_category).toBe("network"); + }); + + it("recurses into single-error ShowHelp wrappers", () => { + const single = classifyCliErrorActionability({ + _tag: "ShowHelp", + errors: [new DeclaredError({ message: "inner" })], + }); + expect(single.error_fingerprint).toBe("tag:DeclaredError"); + + const multiple = classifyCliErrorActionability({ + _tag: "ShowHelp", + errors: [{ _tag: "MissingOption" }, { _tag: "MissingOption" }], + }); + expect(multiple.error_category).toBe("invalid_input"); + expect(multiple.error_fingerprint).toBe("tag:ShowHelp"); + }); + + it("classifies StackError port allocation failures", () => { + const error = new Error("no free port"); + error.name = "StackError"; + Object.defineProperty(error, "code", { value: "PORT_ALLOCATION" }); + const result = classifyCliErrorActionability(error); + expect(result.error_category).toBe("invalid_config"); + expect(result.error_fingerprint).toBe("error:StackError:port_allocation"); + + const other = new Error("other"); + other.name = "StackError"; + expect(classifyCliErrorActionability(other).error_kind).toBe("unknown"); + }); + + it("buckets native JS exceptions as internal panics", () => { + const result = classifyCliErrorActionability(new TypeError("x is not a function")); + expect(result.error_kind).toBe("internal_bug"); + expect(result.error_category).toBe("panic"); + expect(result.error_fingerprint).toBe("error:TypeError"); + }); + + it("never leaks raw text into fingerprints for unknown failures", () => { + expect(classifyCliErrorActionability("raw failure text").error_fingerprint).toBe( + "string:unknown", + ); + const named = new Error("boom"); + named.name = "Weird Name With Spaces"; + expect(classifyCliErrorActionability(named).error_fingerprint).toBe("error:unknown"); + }); +}); + +describe("metric definitions", () => { + it("keeps the Q2 baseline definitions stable for reporting queries", () => { + // These ids are referenced by the PostHog reporting built in CLI-1562; + // changing them invalidates the Q2 baseline and must be deliberate. + expect(CliErrorActionabilityMetricDefinitions.strictRecovery.id).toBe( + "same_command_success_same_session", + ); + expect(CliErrorActionabilityMetricDefinitions.repeatError.id).toBe( + "same_command_same_error_same_session_before_success", + ); + expect(CliErrorActionabilityMetricDefinitions.internalUnknownBugRate.id).toBe( + "failed_commands_internal_bug_or_unknown", + ); + }); +}); + +describe("classifyCliCauseActionability", () => { + it("classifies the typed failure inside a cause", () => { + const cause = Cause.fail(new DeclaredError({ message: "inner" })); + expect(classifyCliCauseActionability(cause).error_category).toBe("auth"); + }); + + it("classifies defects", () => { + const cause = Cause.die(new TypeError("boom")); + expect(classifyCliCauseActionability(cause).error_category).toBe("panic"); + }); +}); diff --git a/packages/stack/src/StackBuilder.ts b/packages/stack/src/StackBuilder.ts index 9975ca4fb0..b064b0808f 100644 --- a/packages/stack/src/StackBuilder.ts +++ b/packages/stack/src/StackBuilder.ts @@ -372,6 +372,7 @@ export const validateResolvedConfig = ( return yield* Effect.fail( new StackBuildError({ detail: `mode "native" only supports postgres, auth, and postgrest. Disable ${enabledDockerOnly.join(", ")} or switch to "auto" or "docker".`, + reason: "invalid_config", }), ); } @@ -381,6 +382,7 @@ export const validateResolvedConfig = ( return yield* Effect.fail( new StackBuildError({ detail: "imgproxy requires storage to be enabled", + reason: "invalid_config", }), ); } @@ -389,6 +391,7 @@ export const validateResolvedConfig = ( return yield* Effect.fail( new StackBuildError({ detail: "vector requires analytics to be enabled", + reason: "invalid_config", }), ); } @@ -397,6 +400,7 @@ export const validateResolvedConfig = ( return yield* Effect.fail( new StackBuildError({ detail: "studio requires pgmeta to be enabled", + reason: "invalid_config", }), ); } diff --git a/packages/stack/src/StackLifecycleCoordinator.ts b/packages/stack/src/StackLifecycleCoordinator.ts index 0a8ba5bee1..9f6385408a 100644 --- a/packages/stack/src/StackLifecycleCoordinator.ts +++ b/packages/stack/src/StackLifecycleCoordinator.ts @@ -254,6 +254,7 @@ export class StackLifecycleCoordinator extends Context.Service< new StackBuildError({ detail: "Failed to prepare stack assets", cause, + reason: "asset_preparation", }), ), ) @@ -346,6 +347,7 @@ export class StackLifecycleCoordinator extends Context.Service< new StackBuildError({ detail: "Failed to persist stack cleanup metadata", cause, + reason: "invalid_config", }), ), ); diff --git a/packages/stack/src/StackPreparation.ts b/packages/stack/src/StackPreparation.ts index 1c3a856bee..a18ee6316e 100644 --- a/packages/stack/src/StackPreparation.ts +++ b/packages/stack/src/StackPreparation.ts @@ -258,10 +258,26 @@ const pullImage = ( image: images[0] ?? "unknown", detail: `Failed to pull Docker image from all registries. ${detail}`, cause: new Error(detail), + daemonDown: failures.some((failure) => isDockerDaemonDownMessage(failure.message)), }), ); }); +/** + * Whether the container runtime's output indicates the daemon itself is not + * running. This is the boundary where docker's text output is produced, so it + * is the one place allowed to interpret it. + */ +const isDockerDaemonDownMessage = (message: string): boolean => { + const normalized = message.toLowerCase(); + return ( + normalized.includes("cannot connect to the docker daemon") || + normalized.includes("docker daemon is not running") || + normalized.includes("docker desktop is not running") || + normalized.includes("is the docker daemon running") + ); +}; + const resolveServiceWithMetadata = ( resolver: BinaryResolver["Service"], spawner: ChildProcessSpawner.ChildProcessSpawner["Service"], diff --git a/packages/stack/src/errors.ts b/packages/stack/src/errors.ts index 71bafcb46f..53ff7b7cce 100644 --- a/packages/stack/src/errors.ts +++ b/packages/stack/src/errors.ts @@ -20,11 +20,25 @@ export class DockerPullError extends Data.TaggedError("DockerPullError")<{ readonly image: string; readonly detail: string; readonly cause: unknown; + /** + * Whether the pull failed because the Docker daemon itself is unreachable, + * detected from the runtime's output at the boundary where it is produced. + * Consumers must branch on this instead of sniffing `detail` text. + */ + readonly daemonDown: boolean; }> {} export class StackBuildError extends Data.TaggedError("StackBuildError")<{ readonly detail: string; readonly cause?: unknown; + /** + * Structured discriminant for consumers that need to distinguish failure + * classes without parsing `detail`: `invalid_config` for user-fixable + * configuration problems, `asset_preparation` for download/registry + * failures while preparing stack assets. Absent for internal invariant + * violations. + */ + readonly reason?: "invalid_config" | "asset_preparation"; }> {} export class PortConflictError extends Data.TaggedError("PortConflictError")<{ From 68442e667162fb9c3fd4a365f6e3cb5eb1b9451b Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 8 Jul 2026 16:27:22 +0200 Subject: [PATCH 02/20] chore(cli): harden error actionability coverage and classifications Coverage: the drift guard now also enforces declarations on plain `extends Error` classes, and the external adapters cover the full effect cli parser error set (MissingArgument, DuplicateOption, InvalidValue), UserError unwrapping, and PlatformError/BadArgument with reason-based classification. Classification fixes from a construction-site audit of all ~420 classes: local filesystem failures mislabeled as config/input errors now classify as permission; CLI-internal declarative script failures move to internal_bug; transport failures conflated with user errors in LegacyDbQueryExecError, LegacyDeclarativeApplyError, and LegacyDomainsCnameError are split via typed discriminants; paused projects get the new open_dashboard suggestion type; and LegacyDbConfigIpv6Error's suggestion now matches the link command the CLI actually prints. Co-Authored-By: Claude Fable 5 --- .../commands/db/advisors/advisors.errors.ts | 2 +- .../legacy/commands/db/diff/diff.errors.ts | 2 +- .../legacy/commands/db/dump/dump.errors.ts | 2 +- .../legacy/commands/db/query/query.errors.ts | 9 +++ .../legacy/commands/db/query/query.handler.ts | 6 +- .../legacy/commands/db/reset/reset.errors.ts | 2 +- .../schema/declarative/declarative.errors.ts | 8 +++ .../schema/declarative/sync/sync.handler.ts | 4 +- .../db/shared/legacy-pgdelta.errors.ts | 6 +- .../legacy/commands/domains/domains.cname.ts | 61 ++++++++++++++----- .../domains/domains.cname.unit.test.ts | 7 ++- .../legacy/commands/domains/domains.errors.ts | 9 +++ .../commands/inspect/report/report.csvq.ts | 9 +++ .../src/legacy/commands/link/link.errors.ts | 8 ++- .../migration/repair/repair.errors.ts | 2 +- .../commands/projects/projects.errors.ts | 2 +- .../legacy/commands/secrets/secrets.errors.ts | 2 +- .../legacy/commands/storage/storage.errors.ts | 2 +- .../legacy/commands/unlink/unlink.errors.ts | 2 +- .../legacy/shared/legacy-db-config.errors.ts | 10 ++- .../legacy-storage-credentials.errors.ts | 2 +- .../src/legacy/shared/legacy-storage-url.ts | 14 +++++ .../legacy/shared/legacy-string-slice-flag.ts | 10 +++ .../src/shared/functions/download.errors.ts | 2 +- .../error-actionability-coverage.unit.test.ts | 13 ++-- .../shared/telemetry/error-actionability.ts | 57 ++++++++++++++--- 26 files changed, 203 insertions(+), 50 deletions(-) diff --git a/apps/cli/src/legacy/commands/db/advisors/advisors.errors.ts b/apps/cli/src/legacy/commands/db/advisors/advisors.errors.ts index 39fc9dcc4a..1b77171a35 100644 --- a/apps/cli/src/legacy/commands/db/advisors/advisors.errors.ts +++ b/apps/cli/src/legacy/commands/db/advisors/advisors.errors.ts @@ -67,7 +67,7 @@ export class LegacyDbAdvisorsSetupError extends Data.TaggedError("LegacyDbAdviso readonly message: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.dbFinding; + return actionability.dbConnection; } } diff --git a/apps/cli/src/legacy/commands/db/diff/diff.errors.ts b/apps/cli/src/legacy/commands/db/diff/diff.errors.ts index e901e94dad..6da5113849 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.errors.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.errors.ts @@ -72,6 +72,6 @@ export class LegacyDbDiffWriteError extends Data.TaggedError("LegacyDbDiffWriteE readonly message: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.invalidConfig; + return actionability.permission; } } diff --git a/apps/cli/src/legacy/commands/db/dump/dump.errors.ts b/apps/cli/src/legacy/commands/db/dump/dump.errors.ts index 542aa21d40..4c97617bc9 100644 --- a/apps/cli/src/legacy/commands/db/dump/dump.errors.ts +++ b/apps/cli/src/legacy/commands/db/dump/dump.errors.ts @@ -43,7 +43,7 @@ export class LegacyDbDumpOpenFileError extends Data.TaggedError("LegacyDbDumpOpe readonly message: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.provideFlags; + return actionability.permission; } } diff --git a/apps/cli/src/legacy/commands/db/query/query.errors.ts b/apps/cli/src/legacy/commands/db/query/query.errors.ts index 617461c3f8..33228d1903 100644 --- a/apps/cli/src/legacy/commands/db/query/query.errors.ts +++ b/apps/cli/src/legacy/commands/db/query/query.errors.ts @@ -55,8 +55,17 @@ export class LegacyDbQueryLoginRequiredError extends Data.TaggedError( /** Query execution failed. Byte-matches Go's `"failed to execute query: " + err`. */ export class LegacyDbQueryExecError extends Data.TaggedError("LegacyDbQueryExecError")<{ readonly message: string; + /** + * Set when this failure came from the linked path's HTTP transport + * (`httpClient.execute`/body read against `/v1/projects/{ref}/database/query`) + * rather than the user's SQL failing to execute. + */ + readonly transport?: boolean; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + if (this.transport === true) { + return { ...actionability.externalNetwork, fingerprint_suffix: "network" }; + } return actionability.invalidInput; } } diff --git a/apps/cli/src/legacy/commands/db/query/query.handler.ts b/apps/cli/src/legacy/commands/db/query/query.handler.ts index 4a51f9e301..40ef6c4bc2 100644 --- a/apps/cli/src/legacy/commands/db/query/query.handler.ts +++ b/apps/cli/src/legacy/commands/db/query/query.handler.ts @@ -200,7 +200,11 @@ export const legacyDbQuery = Effect.fn("legacy.db.query")(function* (flags: Lega return { status: response.status, body: text }; }).pipe( Effect.mapError( - (cause) => new LegacyDbQueryExecError({ message: `failed to execute query: ${cause}` }), + (cause) => + new LegacyDbQueryExecError({ + message: `failed to execute query: ${cause}`, + transport: true, + }), ), ); if (status !== 201) { diff --git a/apps/cli/src/legacy/commands/db/reset/reset.errors.ts b/apps/cli/src/legacy/commands/db/reset/reset.errors.ts index 86dc6b1188..fba5290c7e 100644 --- a/apps/cli/src/legacy/commands/db/reset/reset.errors.ts +++ b/apps/cli/src/legacy/commands/db/reset/reset.errors.ts @@ -59,7 +59,7 @@ export class LegacyDbResetMigrationFileError extends Data.TaggedError( readonly message: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.permission; + return actionability.provideFlags; } } diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.errors.ts b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.errors.ts index 9eace27317..f0ba472a32 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.errors.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.errors.ts @@ -109,8 +109,16 @@ export class LegacyDeclarativeDiffError extends Data.TaggedError("LegacyDeclarat */ export class LegacyDeclarativeApplyError extends Data.TaggedError("LegacyDeclarativeApplyError")<{ readonly message: string; + /** + * Set when this failure came from connecting to the local Postgres instance + * (`dbConnection.connect`) rather than the migration SQL failing to apply. + */ + readonly connect?: boolean; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + if (this.connect === true) { + return { ...actionability.dbConnection, fingerprint_suffix: "connect" }; + } return actionability.dbFinding; } } diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.handler.ts b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.handler.ts index 61f276e4c0..87ce149546 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.handler.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.handler.ts @@ -451,7 +451,9 @@ const applyMigrationToLocal = ( { isLocal: true, dnsResolver: local.dnsResolver }, ) .pipe( - Effect.mapError((error) => new LegacyDeclarativeApplyError({ message: error.message })), + Effect.mapError( + (error) => new LegacyDeclarativeApplyError({ message: error.message, connect: true }), + ), ); yield* legacyApplyMigrationFile( session, diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.errors.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.errors.ts index f662015c4b..5750a60488 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.errors.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.errors.ts @@ -35,7 +35,7 @@ export class LegacyDeclarativeShadowDbError extends Data.TaggedError( readonly message: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.dbConnection; + return actionability.startStack; } } @@ -51,7 +51,7 @@ export class LegacyDeclarativeEmptyOutputError extends Data.TaggedError( readonly message: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.dbFinding; + return actionability.impossibleState; } } @@ -66,7 +66,7 @@ export class LegacyDeclarativeParseOutputError extends Data.TaggedError( readonly message: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.dbFinding; + return actionability.impossibleState; } } diff --git a/apps/cli/src/legacy/commands/domains/domains.cname.ts b/apps/cli/src/legacy/commands/domains/domains.cname.ts index 07d01833e8..f1e37a7f01 100644 --- a/apps/cli/src/legacy/commands/domains/domains.cname.ts +++ b/apps/cli/src/legacy/commands/domains/domains.cname.ts @@ -11,6 +11,19 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } +/** + * Internal discriminated failure for the CNAME verification pipeline: + * `transport: true` for resolver failures (fetch error, non-200, timeout), + * `transport: false` for a genuine finding about the user's DNS records + * (no CNAME answer). Consumed exclusively by {@link verifyLegacyCname}, which + * folds it into `LegacyDomainsCnameError` so telemetry can tell a Cloudflare + * DoH outage apart from a misconfigured record. + */ +export interface LegacyCnameFailure { + readonly transport: boolean; + readonly detail: string; +} + /** * Extract the first CNAME answer's `data` from a Cloudflare DNS-over-HTTPS JSON * response. Mirrors Go's `utils.ResolveCNAME` @@ -18,7 +31,10 @@ function isRecord(value: unknown): value is Record { * with `type === 5` and return its `data`; otherwise fail with the same * "failed to locate" message Go embeds (4-space-indented JSON of the answers). */ -export function parseFirstCname(payload: unknown, host: string): Effect.Effect { +export function parseFirstCname( + payload: unknown, + host: string, +): Effect.Effect { const answers = isRecord(payload) && Array.isArray(payload["Answer"]) ? payload["Answer"] : []; for (const answer of answers) { if (isRecord(answer) && answer["type"] === CNAME_TYPE && typeof answer["data"] === "string") { @@ -29,15 +45,16 @@ export function parseFirstCname(payload: unknown, host: string): Effect.Effect 1024 ? `${dump.slice(0, 1024)}…` : dump; - return Effect.fail( - new Error(`failed to locate appropriate CNAME record for ${host}; resolves to ${capped}`), - ); + return Effect.fail({ + transport: false, + detail: `failed to locate appropriate CNAME record for ${host}; resolves to ${capped}`, + }); } /** * Render the `%w`-wrapped cause string for the "failed to resolve" CNAME error. - * Transport / timeout / parse failures and the locate error all flow through - * here so the outer message stays Go-shaped without leaking object internals. + * Transport / timeout / parse failures all flow through here so the outer + * message stays Go-shaped without leaking object internals. */ export function formatCnameCause(cause: unknown): string { if (cause instanceof Error) return cause.message; @@ -45,6 +62,11 @@ export function formatCnameCause(cause: unknown): string { return String(cause); } +const transportFailure = (cause: unknown): LegacyCnameFailure => ({ + transport: true, + detail: formatCnameCause(cause), +}); + /** * Verify that `customHostname` has a CNAME record pointing at the project's * Supabase subdomain before initializing a custom hostname. Mirrors @@ -68,20 +90,29 @@ export const verifyLegacyCname = Effect.fnUntraced(function* (args: { ); const resolved = yield* Effect.gen(function* () { - const response = yield* args.httpClient.execute(request); + const response = yield* args.httpClient + .execute(request) + .pipe(Effect.mapError(transportFailure)); if (response.status !== 200) { - return yield* Effect.fail(new Error(`unexpected DNS query status ${response.status}`)); + return yield* Effect.fail({ + transport: true, + detail: `unexpected DNS query status ${response.status}`, + }); } - const payload = yield* response.json; + const payload = yield* response.json.pipe(Effect.mapError(transportFailure)); return yield* parseFirstCname(payload, args.customHostname); }).pipe( Effect.timeout("10 seconds"), - Effect.mapError( - (cause) => - new LegacyDomainsCnameError({ - message: `expected custom hostname '${args.customHostname}' to have a CNAME record pointing to your project at '${expected}', but it failed to resolve: ${formatCnameCause(cause)}`, - }), - ), + Effect.mapError((cause) => { + const failure: LegacyCnameFailure = + typeof cause === "object" && "transport" in cause && "detail" in cause + ? cause + : transportFailure(cause); + return new LegacyDomainsCnameError({ + message: `expected custom hostname '${args.customHostname}' to have a CNAME record pointing to your project at '${expected}', but it failed to resolve: ${failure.detail}`, + transport: failure.transport, + }); + }), ); if (resolved !== expected) { diff --git a/apps/cli/src/legacy/commands/domains/domains.cname.unit.test.ts b/apps/cli/src/legacy/commands/domains/domains.cname.unit.test.ts index c6304af928..fdbc439de6 100644 --- a/apps/cli/src/legacy/commands/domains/domains.cname.unit.test.ts +++ b/apps/cli/src/legacy/commands/domains/domains.cname.unit.test.ts @@ -33,11 +33,12 @@ describe("parseFirstCname", () => { expect(Exit.isFailure(exit)).toBe(true); }); - it("fails with a locate error when no CNAME answer is present", () => { - const error = Effect.runSync( + it("fails with a non-transport locate failure when no CNAME answer is present", () => { + const failure = Effect.runSync( Effect.flip(parseFirstCname({ Answer: [{ type: 1, data: "1.2.3.4" }] }, "host.example.com")), ); - expect(error.message).toContain( + expect(failure.transport).toBe(false); + expect(failure.detail).toContain( "failed to locate appropriate CNAME record for host.example.com", ); }); diff --git a/apps/cli/src/legacy/commands/domains/domains.errors.ts b/apps/cli/src/legacy/commands/domains/domains.errors.ts index 448e29839a..5a3476b03a 100644 --- a/apps/cli/src/legacy/commands/domains/domains.errors.ts +++ b/apps/cli/src/legacy/commands/domains/domains.errors.ts @@ -45,8 +45,17 @@ export class LegacyDomainsUnexpectedStatusError extends Data.TaggedError( */ export class LegacyDomainsCnameError extends Data.TaggedError("LegacyDomainsCnameError")<{ readonly message: string; + /** + * Set when the DNS-over-HTTPS resolver call itself failed (timeout, + * non-200, or fetch failure against the 1.1.1.1 resolver) rather than the + * CNAME being missing or pointing at the wrong host. + */ + readonly transport?: boolean; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + if (this.transport === true) { + return { ...actionability.externalNetwork, fingerprint_suffix: "network" }; + } return actionability.invalidConfig; } } diff --git a/apps/cli/src/legacy/commands/inspect/report/report.csvq.ts b/apps/cli/src/legacy/commands/inspect/report/report.csvq.ts index 38eece5203..7cc7dc37d4 100644 --- a/apps/cli/src/legacy/commands/inspect/report/report.csvq.ts +++ b/apps/cli/src/legacy/commands/inspect/report/report.csvq.ts @@ -1,4 +1,9 @@ import { Option } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; /** * A bounded, hand-written evaluator for the subset of the csvq SQL dialect that @@ -48,6 +53,10 @@ import { Option } from "effect"; /** Thrown for grammar or evaluation outside the supported csvq subset. */ export class LegacyInspectCsvqError extends Error { override readonly name = "LegacyInspectCsvqError"; + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.impossibleState; + } } // --------------------------------------------------------------------------- diff --git a/apps/cli/src/legacy/commands/link/link.errors.ts b/apps/cli/src/legacy/commands/link/link.errors.ts index 2a283e88e5..823e47f201 100644 --- a/apps/cli/src/legacy/commands/link/link.errors.ts +++ b/apps/cli/src/legacy/commands/link/link.errors.ts @@ -2,6 +2,7 @@ import { Data } from "effect"; import { actionability, type CliErrorActionabilityDeclaration, + CliSuggestionType, ErrorActionabilityId, statusCodeActionability, } from "../../../shared/telemetry/error-actionability.ts"; @@ -41,7 +42,12 @@ export class LegacyProjectPausedError extends Data.TaggedError("LegacyProjectPau readonly suggestion: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.invalidConfig; + // The rendered remediation is "unpause it from the Supabase dashboard" — + // remote project state, not local config. + return { + ...actionability.planLimit, + suggestion_type: CliSuggestionType.OpenDashboard, + }; } } diff --git a/apps/cli/src/legacy/commands/migration/repair/repair.errors.ts b/apps/cli/src/legacy/commands/migration/repair/repair.errors.ts index ec9c2294b4..55e050110d 100644 --- a/apps/cli/src/legacy/commands/migration/repair/repair.errors.ts +++ b/apps/cli/src/legacy/commands/migration/repair/repair.errors.ts @@ -16,6 +16,6 @@ export class LegacyMigrationRepairUpdateError extends Data.TaggedError( readonly message: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.dbFinding; + return actionability.dbConnection; } } diff --git a/apps/cli/src/legacy/commands/projects/projects.errors.ts b/apps/cli/src/legacy/commands/projects/projects.errors.ts index c0e9501cd4..d85702bfc3 100644 --- a/apps/cli/src/legacy/commands/projects/projects.errors.ts +++ b/apps/cli/src/legacy/commands/projects/projects.errors.ts @@ -109,7 +109,7 @@ export class LegacyProjectsDeleteNotFoundError extends Data.TaggedError( readonly message: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.provideFlags; + return actionability.invalidInput; } } diff --git a/apps/cli/src/legacy/commands/secrets/secrets.errors.ts b/apps/cli/src/legacy/commands/secrets/secrets.errors.ts index 4f6215286c..18cf25175e 100644 --- a/apps/cli/src/legacy/commands/secrets/secrets.errors.ts +++ b/apps/cli/src/legacy/commands/secrets/secrets.errors.ts @@ -85,7 +85,7 @@ export class LegacySecretsEnvFileOpenError extends Data.TaggedError( readonly message: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.invalidInput; + return actionability.provideFlags; } } diff --git a/apps/cli/src/legacy/commands/storage/storage.errors.ts b/apps/cli/src/legacy/commands/storage/storage.errors.ts index e95f80bb2c..aa8871cd9b 100644 --- a/apps/cli/src/legacy/commands/storage/storage.errors.ts +++ b/apps/cli/src/legacy/commands/storage/storage.errors.ts @@ -167,7 +167,7 @@ export class LegacyStorageFileError extends Data.TaggedError("LegacyStorageFileE readonly message: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.invalidInput; + return actionability.permission; } } diff --git a/apps/cli/src/legacy/commands/unlink/unlink.errors.ts b/apps/cli/src/legacy/commands/unlink/unlink.errors.ts index 1296ff3861..94e9e94279 100644 --- a/apps/cli/src/legacy/commands/unlink/unlink.errors.ts +++ b/apps/cli/src/legacy/commands/unlink/unlink.errors.ts @@ -15,7 +15,7 @@ export class LegacyUnlinkRefReadError extends Data.TaggedError("LegacyUnlinkRefR readonly message: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.invalidConfig; + return actionability.permission; } } diff --git a/apps/cli/src/legacy/shared/legacy-db-config.errors.ts b/apps/cli/src/legacy/shared/legacy-db-config.errors.ts index 9263eb47f2..f6ece4c7fa 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.errors.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.errors.ts @@ -2,6 +2,7 @@ import { Data } from "effect"; import { actionability, type CliErrorActionabilityDeclaration, + CliSuggestionType, ErrorActionabilityId, statusCodeActionability, } from "../../shared/telemetry/error-actionability.ts"; @@ -112,7 +113,14 @@ export class LegacyDbConfigIpv6Error extends Data.TaggedError("LegacyDbConfigIpv readonly suggestion?: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.dbConnection; + // The rendered remediation is "Run supabase link --project-ref to + // setup IPv4 connection", so the suggestion is link-shaped even though the + // category stays db_connection. + return { + ...actionability.dbConnection, + suggestion_type: CliSuggestionType.LinkProject, + suggested_command: "supabase link", + }; } } diff --git a/apps/cli/src/legacy/shared/legacy-storage-credentials.errors.ts b/apps/cli/src/legacy/shared/legacy-storage-credentials.errors.ts index 86c0f60ca2..10548540aa 100644 --- a/apps/cli/src/legacy/shared/legacy-storage-credentials.errors.ts +++ b/apps/cli/src/legacy/shared/legacy-storage-credentials.errors.ts @@ -35,7 +35,7 @@ export class LegacyStorageMissingApiKeyError extends Data.TaggedError( readonly message: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.permission; + return actionability.apiStatus; } } diff --git a/apps/cli/src/legacy/shared/legacy-storage-url.ts b/apps/cli/src/legacy/shared/legacy-storage-url.ts index 4401c56387..a3be047416 100644 --- a/apps/cli/src/legacy/shared/legacy-storage-url.ts +++ b/apps/cli/src/legacy/shared/legacy-storage-url.ts @@ -1,3 +1,9 @@ +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; + /** * Storage URL parsing, ported 1:1 from Go's `internal/storage/client/scheme.go` * plus the slices of `net/url` that `url.Parse` exercises for the `ss://` scheme. @@ -32,6 +38,10 @@ export class LegacyGoUrlParseError extends Error { super(`parse "${rawURL}": ${inner}`); this.name = "LegacyGoUrlParseError"; } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } } /** @@ -45,6 +55,10 @@ export class LegacyStorageUrlPatternError extends Error { super(LEGACY_STORAGE_INVALID_URL_MESSAGE); this.name = "LegacyStorageUrlPatternError"; } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } } export interface LegacyGoUrl { diff --git a/apps/cli/src/legacy/shared/legacy-string-slice-flag.ts b/apps/cli/src/legacy/shared/legacy-string-slice-flag.ts index dafb3df022..844e8518d5 100644 --- a/apps/cli/src/legacy/shared/legacy-string-slice-flag.ts +++ b/apps/cli/src/legacy/shared/legacy-string-slice-flag.ts @@ -1,3 +1,9 @@ +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; + /** * Parses a pflag `StringSliceVar` flag: CSV-splits each occurrence via * `encoding/csv` and accumulates across repeats, matching `readAsCSV` in @@ -20,6 +26,10 @@ export class LegacyStringSliceFlagParseError extends Error { this.value = value; this.detail = detail; } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } } /** diff --git a/apps/cli/src/shared/functions/download.errors.ts b/apps/cli/src/shared/functions/download.errors.ts index f25d42409a..dc6aaadda5 100644 --- a/apps/cli/src/shared/functions/download.errors.ts +++ b/apps/cli/src/shared/functions/download.errors.ts @@ -49,6 +49,6 @@ export class UnsafeFunctionDownloadPathError extends Data.TaggedError( readonly message: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.apiStatus; + return actionability.permission; } } diff --git a/apps/cli/src/shared/telemetry/error-actionability-coverage.unit.test.ts b/apps/cli/src/shared/telemetry/error-actionability-coverage.unit.test.ts index 72b57fb499..869605105d 100644 --- a/apps/cli/src/shared/telemetry/error-actionability-coverage.unit.test.ts +++ b/apps/cli/src/shared/telemetry/error-actionability-coverage.unit.test.ts @@ -27,7 +27,8 @@ import { * forgot to classify". */ -const ERROR_DEFINITION_PATTERN = /(?:TaggedError|CliError)\(\s*"([A-Za-z0-9_]+)"/gs; +const ERROR_DEFINITION_PATTERN = + /(?:TaggedError|CliError)\(\s*"([A-Za-z0-9_]+)"|class\s+([A-Za-z0-9_]+)\s+extends\s+Error\b/gs; function scanErrorTags(root: string): Map> { const tagsByFile = new Map>(); @@ -40,7 +41,7 @@ function scanErrorTags(root: string): Map> { } if (!path.endsWith(".ts") || path.endsWith(".test.ts")) continue; const tags = [...readFileSync(path, "utf8").matchAll(ERROR_DEFINITION_PATTERN)].map( - (match) => match[1] ?? "", + (match) => match[1] ?? match[2] ?? "", ); if (tags.length > 0) tagsByFile.set(path, tags); } @@ -67,14 +68,14 @@ function collectErrorClasses(module: Record): Array { for (const [file, tags] of tagsByFile) { const relativePath = file.slice(srcRoot.length + 1); - it(relativePath, async () => { + // Importing a command module can pull in a large transitive graph on first + // load; give these dynamic-import tests more headroom than the default 5s. + it(relativePath, { timeout: 30_000 }, async () => { const loader = moduleLoaders.get(file); expect(loader, `no module loader for ${relativePath}`).toBeDefined(); const module = await loader?.(); diff --git a/apps/cli/src/shared/telemetry/error-actionability.ts b/apps/cli/src/shared/telemetry/error-actionability.ts index 1746237f7c..d646301fdb 100644 --- a/apps/cli/src/shared/telemetry/error-actionability.ts +++ b/apps/cli/src/shared/telemetry/error-actionability.ts @@ -58,6 +58,7 @@ export const CliSuggestionType = { UpdateConfig: "update_config", UpgradePlan: "upgrade_plan", RerunDebug: "rerun_debug", + OpenDashboard: "open_dashboard", None: "none", } as const; @@ -103,6 +104,14 @@ export const ErrorActionabilityId: unique symbol = Symbol.for( export interface CliErrorActionabilityDeclaration { readonly error_kind: CliErrorKind; readonly error_category: CliErrorCategory; + /** + * Whether this failure class has a canonical remediation. This is the + * taxonomy-level claim, not a guarantee that the CLI rendered a + * `Suggestion:` line for a given instance — some errors convey the fix in + * the message itself. When CLI-1561 wires capture, the emitted + * `has_suggestion` should be reconciled with what the output layer actually + * rendered so telemetry can never disagree with what the user saw. + */ readonly has_suggestion: boolean; readonly suggestion_type: CliSuggestionType; readonly suggested_command?: string; @@ -388,11 +397,34 @@ const externalActionabilityByTag: Record< string, (error: ErrorRecord) => CliErrorActionabilityDeclaration > = { - // effect/unstable/cli parser failures + // effect/unstable/cli parser failures (ShowHelp and UserError recurse in + // classifyCliErrorActionability instead of mapping here) MissingOption: () => actionability.invalidInput, + MissingArgument: () => actionability.invalidInput, + DuplicateOption: () => actionability.invalidInput, + InvalidValue: () => actionability.invalidInput, UnknownSubcommand: () => actionability.invalidInput, UnrecognizedOption: () => actionability.invalidInput, + // effect PlatformError — OS/filesystem operations. `reason` is + // `BadArgument | SystemError`; BadArgument means the CLI itself passed a + // rejected argument (internal bug), SystemError reasons are local + // environment problems the user resolves. + PlatformError: (error) => { + const reason = error["reason"]; + const reasonTag = isErrorRecord(reason) + ? safeIdentifier(readString(reason, "_tag")) + : undefined; + if (reasonTag === "BadArgument") { + return { ...actionability.impossibleState, fingerprint_suffix: "bad_argument" }; + } + return { + ...actionability.permission, + ...(reasonTag !== undefined ? { fingerprint_suffix: reasonTag } : {}), + }; + }, + BadArgument: () => ({ ...actionability.impossibleState, fingerprint_suffix: "bad_argument" }), + // @supabase/config ProjectConfigParseError: () => actionability.invalidConfig, ProjectEnvParseError: () => actionability.invalidConfig, @@ -414,7 +446,12 @@ const externalActionabilityByTag: Record< HttpBodyError: () => ({ ...actionability.apiStatus, fingerprint_suffix: "api_response" }), SchemaError: () => ({ ...actionability.apiStatus, fingerprint_suffix: "api_response" }), - // @supabase/stack + // @supabase/stack — StackError is a plain Error subclass matched by `name` + // in classifyCliErrorActionability, with a structured `code` field. + StackError: (error) => + readString(error, "code") === "PORT_ALLOCATION" + ? { ...actionability.invalidConfig, fingerprint_suffix: "port_allocation" } + : actionability.unknown, BinaryNotFoundError: () => actionability.invalidConfig, DownloadError: () => actionability.externalNetwork, ChecksumMismatchError: () => ({ @@ -506,6 +543,12 @@ export function classifyCliErrorActionability(error: unknown): CliErrorActionabi if (classified !== undefined) return classified; } + // effect cli wraps handler failures in UserError({ cause }) — classify the + // actual failure instead of the wrapper. + if (tag === "UserError" && isErrorRecord(error) && error["cause"] !== undefined) { + return classifyCliErrorActionability(error["cause"]); + } + if (tag !== undefined && isErrorRecord(error)) { const external = externalActionabilityByTag[tag]; if (external !== undefined) { @@ -515,14 +558,10 @@ export function classifyCliErrorActionability(error: unknown): CliErrorActionabi } if (isErrorRecord(error) && readErrorName(error) === "StackError") { - if (readString(error, "code") === "PORT_ALLOCATION") { - return toActionability( - { ...actionability.invalidConfig, fingerprint_suffix: "port_allocation" }, - "error", - "StackError", - ); + const classify = externalActionabilityByTag["StackError"]; + if (classify !== undefined) { + return toActionability(classify(error), "error", "StackError"); } - return toActionability(actionability.unknown, "error", "StackError"); } if (typeof error === "string") { From 11232cb803af3d99becc577d67b309ca1f932280 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 8 Jul 2026 17:21:49 +0200 Subject: [PATCH 03/20] chore(cli): address actionability review findings - Coverage scan now recognizes any local `*Error("Tag")` factory (e.g. the next login shell's LoginError), closing the drift-guard gap for NoTtyError and LoginFailedError, which now declare auth classifications. - classifyCliErrorActionability recurses into a StackError's preserved tagged cause before falling back, and uses own-property adapter lookups so sanitized tags like "constructor" cannot hit Object.prototype. - DockerPullError.daemonDown now also covers docker spawn failures (binary missing), not just daemon-down output. - DeleteFunctionUnexpectedStatusError, LegacyDbQueryUnexpectedStatusError, and LegacyLinkAuthTokenError thread the response status and classify via statusCodeActionability, so 401s land in auth and 5xx in api_status. - Paused projects get a dedicated project_paused category instead of polluting plan_limit. Co-Authored-By: Claude Fable 5 --- .../legacy/commands/db/query/query.errors.ts | 4 +++- .../legacy/commands/db/query/query.handler.ts | 1 + .../src/legacy/commands/link/link.errors.ts | 12 ++++++++--- .../src/next/commands/login/login.errors.ts | 17 ++++++++++++++-- .../cli/src/shared/functions/delete.errors.ts | 4 +++- apps/cli/src/shared/functions/delete.ts | 1 + .../error-actionability-coverage.unit.test.ts | 10 ++++++++-- .../shared/telemetry/error-actionability.ts | 20 +++++++++++++++---- .../error-actionability.unit.test.ts | 18 +++++++++++++++++ packages/stack/src/StackPreparation.ts | 8 +++++++- packages/stack/src/errors.ts | 8 +++++--- 11 files changed, 86 insertions(+), 17 deletions(-) diff --git a/apps/cli/src/legacy/commands/db/query/query.errors.ts b/apps/cli/src/legacy/commands/db/query/query.errors.ts index 33228d1903..6e17f12267 100644 --- a/apps/cli/src/legacy/commands/db/query/query.errors.ts +++ b/apps/cli/src/legacy/commands/db/query/query.errors.ts @@ -3,6 +3,7 @@ import { actionability, type CliErrorActionabilityDeclaration, ErrorActionabilityId, + statusCodeActionability, } from "../../../../shared/telemetry/error-actionability.ts"; /** @@ -93,9 +94,10 @@ export class LegacyDbQueryMutuallyExclusiveFlagsError extends Data.TaggedError( export class LegacyDbQueryUnexpectedStatusError extends Data.TaggedError( "LegacyDbQueryUnexpectedStatusError", )<{ + readonly status: number; readonly message: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.apiStatus; + return statusCodeActionability(this.status); } } diff --git a/apps/cli/src/legacy/commands/db/query/query.handler.ts b/apps/cli/src/legacy/commands/db/query/query.handler.ts index 40ef6c4bc2..1e8f9bc0df 100644 --- a/apps/cli/src/legacy/commands/db/query/query.handler.ts +++ b/apps/cli/src/legacy/commands/db/query/query.handler.ts @@ -210,6 +210,7 @@ export const legacyDbQuery = Effect.fn("legacy.db.query")(function* (flags: Lega if (status !== 201) { return yield* Effect.fail( new LegacyDbQueryUnexpectedStatusError({ + status, message: `unexpected status ${status}: ${body}`, }), ); diff --git a/apps/cli/src/legacy/commands/link/link.errors.ts b/apps/cli/src/legacy/commands/link/link.errors.ts index 823e47f201..c07852d440 100644 --- a/apps/cli/src/legacy/commands/link/link.errors.ts +++ b/apps/cli/src/legacy/commands/link/link.errors.ts @@ -2,6 +2,8 @@ import { Data } from "effect"; import { actionability, type CliErrorActionabilityDeclaration, + CliErrorCategory, + CliErrorKind, CliSuggestionType, ErrorActionabilityId, statusCodeActionability, @@ -43,9 +45,11 @@ export class LegacyProjectPausedError extends Data.TaggedError("LegacyProjectPau }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { // The rendered remediation is "unpause it from the Supabase dashboard" — - // remote project state, not local config. + // remote project state, not local config and not an entitlement failure. return { - ...actionability.planLimit, + error_kind: CliErrorKind.UserActionable, + error_category: CliErrorCategory.ProjectPaused, + has_suggestion: true, suggestion_type: CliSuggestionType.OpenDashboard, }; } @@ -73,7 +77,9 @@ export class LegacyLinkAuthTokenError extends Data.TaggedError("LegacyLinkAuthTo readonly message: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.authLogin; + // The shared mapper wraps any non-200 in this tag; only a 401 is an auth + // failure the user fixes by re-logging in. + return statusCodeActionability(this.status); } } diff --git a/apps/cli/src/next/commands/login/login.errors.ts b/apps/cli/src/next/commands/login/login.errors.ts index a1bd067583..0c09c95e69 100644 --- a/apps/cli/src/next/commands/login/login.errors.ts +++ b/apps/cli/src/next/commands/login/login.errors.ts @@ -1,4 +1,9 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../shared/telemetry/error-actionability.ts"; function LoginError(tag: Tag) { return class extends Data.TaggedError(tag)<{ @@ -11,5 +16,13 @@ function LoginError(tag: Tag) { }; } -export class NoTtyError extends LoginError("NoTtyError") {} -export class LoginFailedError extends LoginError("LoginFailedError") {} +export class NoTtyError extends LoginError("NoTtyError") { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.authToken; + } +} +export class LoginFailedError extends LoginError("LoginFailedError") { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.authLogin; + } +} diff --git a/apps/cli/src/shared/functions/delete.errors.ts b/apps/cli/src/shared/functions/delete.errors.ts index 6c05504beb..b7357ee9de 100644 --- a/apps/cli/src/shared/functions/delete.errors.ts +++ b/apps/cli/src/shared/functions/delete.errors.ts @@ -3,6 +3,7 @@ import { actionability, type CliErrorActionabilityDeclaration, ErrorActionabilityId, + statusCodeActionability, } from "../telemetry/error-actionability.ts"; export class InvalidFunctionSlugError extends Data.TaggedError("InvalidFunctionSlugError")<{ @@ -32,9 +33,10 @@ export class DeleteFunctionNetworkError extends Data.TaggedError("DeleteFunction export class DeleteFunctionUnexpectedStatusError extends Data.TaggedError( "DeleteFunctionUnexpectedStatusError", )<{ + readonly status: number; readonly message: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.apiStatus; + return statusCodeActionability(this.status); } } diff --git a/apps/cli/src/shared/functions/delete.ts b/apps/cli/src/shared/functions/delete.ts index 4e766c2952..0c8758461d 100644 --- a/apps/cli/src/shared/functions/delete.ts +++ b/apps/cli/src/shared/functions/delete.ts @@ -72,6 +72,7 @@ export function deleteFunction( const body = yield* response.text.pipe(Effect.orElseSucceed(() => "")); return yield* Effect.fail( new DeleteFunctionUnexpectedStatusError({ + status: response.status, message: `unexpected delete function status ${response.status}: ${body}`, }), ); diff --git a/apps/cli/src/shared/telemetry/error-actionability-coverage.unit.test.ts b/apps/cli/src/shared/telemetry/error-actionability-coverage.unit.test.ts index 869605105d..7992bf9f60 100644 --- a/apps/cli/src/shared/telemetry/error-actionability-coverage.unit.test.ts +++ b/apps/cli/src/shared/telemetry/error-actionability-coverage.unit.test.ts @@ -27,8 +27,14 @@ import { * forgot to classify". */ +// Matches every way an error class is defined in this workspace: direct +// `Data.TaggedError("Tag")`, any local `*Error(...)` factory whose heritage +// call carries the tag literal (`CliError("Tag")`, `LoginError("Tag")`, ...), +// and plain `extends Error` classes (identified by class name). Error +// factories must therefore be named `Error` to stay guarded — +// which also keeps `Data.TaggedClass` event types out of the scan. const ERROR_DEFINITION_PATTERN = - /(?:TaggedError|CliError)\(\s*"([A-Za-z0-9_]+)"|class\s+([A-Za-z0-9_]+)\s+extends\s+Error\b/gs; + /TaggedError\(\s*"([A-Za-z0-9_]+)"|class\s+[A-Za-z0-9_]+\s+extends\s+[A-Za-z0-9_.]*Error\(\s*"([A-Za-z0-9_]+)"|class\s+([A-Za-z0-9_]+)\s+extends\s+Error\b/gs; function scanErrorTags(root: string): Map> { const tagsByFile = new Map>(); @@ -41,7 +47,7 @@ function scanErrorTags(root: string): Map> { } if (!path.endsWith(".ts") || path.endsWith(".test.ts")) continue; const tags = [...readFileSync(path, "utf8").matchAll(ERROR_DEFINITION_PATTERN)].map( - (match) => match[1] ?? match[2] ?? "", + (match) => match[1] ?? match[2] ?? match[3] ?? "", ); if (tags.length > 0) tagsByFile.set(path, tags); } diff --git a/apps/cli/src/shared/telemetry/error-actionability.ts b/apps/cli/src/shared/telemetry/error-actionability.ts index d646301fdb..58652d5a76 100644 --- a/apps/cli/src/shared/telemetry/error-actionability.ts +++ b/apps/cli/src/shared/telemetry/error-actionability.ts @@ -37,6 +37,7 @@ export const CliErrorCategory = { MigrationDrift: "migration_drift", Permission: "permission", PlanLimit: "plan_limit", + ProjectPaused: "project_paused", InvalidInput: "invalid_input", Network: "network", ApiStatus: "api_status", @@ -508,7 +509,7 @@ const externalActionabilityByTag: Record< * against the workspace packages. */ export function isClassifiedExternalErrorTag(tag: string): boolean { - return tag in externalActionabilityByTag; + return Object.hasOwn(externalActionabilityByTag, tag); } function classifyShowHelp(error: ErrorRecord): CliErrorActionability | undefined { @@ -550,14 +551,25 @@ export function classifyCliErrorActionability(error: unknown): CliErrorActionabi } if (tag !== undefined && isErrorRecord(error)) { - const external = externalActionabilityByTag[tag]; - if (external !== undefined) { - return toActionability(external(error), "tag", tag); + // Own-property lookup: a sanitized tag like "constructor" must not pick + // up Object.prototype members as adapters. + if (Object.hasOwn(externalActionabilityByTag, tag)) { + const external = externalActionabilityByTag[tag]; + if (external !== undefined) { + return toActionability(external(error), "tag", tag); + } } return toActionability(actionability.unknown, "tag", tag); } if (isErrorRecord(error) && readErrorName(error) === "StackError") { + // The public Stack promise API wraps tagged failures via `toStackError`, + // preserving the original in `cause` — classify that instead of the + // wrapper whenever it is itself classifiable. + const cause = error["cause"]; + if (readErrorTag(cause) !== undefined || readDeclaration(cause) !== undefined) { + return classifyCliErrorActionability(cause); + } const classify = externalActionabilityByTag["StackError"]; if (classify !== undefined) { return toActionability(classify(error), "error", "StackError"); diff --git a/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts b/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts index 2c2ce2f301..65feb69764 100644 --- a/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts +++ b/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts @@ -154,6 +154,24 @@ describe("classifyCliErrorActionability", () => { expect(classifyCliErrorActionability(other).error_kind).toBe("unknown"); }); + it("classifies the preserved tagged cause of a StackError wrapper", () => { + const wrapped = new Error("stack failure"); + wrapped.name = "StackError"; + Object.defineProperty(wrapped, "code", { value: "BUILD_ERROR" }); + Object.defineProperty(wrapped, "cause", { + value: { _tag: "StackBuildError", detail: "x", reason: "invalid_config" }, + }); + const result = classifyCliErrorActionability(wrapped); + expect(result.error_category).toBe("invalid_config"); + expect(result.error_fingerprint).toBe("tag:StackBuildError:invalid_config"); + }); + + it("does not treat Object.prototype members as external adapters", () => { + const result = classifyCliErrorActionability({ _tag: "constructor" }); + expect(result.error_kind).toBe("unknown"); + expect(result.error_fingerprint).toBe("tag:constructor"); + }); + it("buckets native JS exceptions as internal panics", () => { const result = classifyCliErrorActionability(new TypeError("x is not a function")); expect(result.error_kind).toBe("internal_bug"); diff --git a/packages/stack/src/StackPreparation.ts b/packages/stack/src/StackPreparation.ts index a18ee6316e..64a3912e31 100644 --- a/packages/stack/src/StackPreparation.ts +++ b/packages/stack/src/StackPreparation.ts @@ -209,6 +209,7 @@ const pullImage = ( yield* callbacks?.onDownloadStart ?? Effect.void; const failures: PullAttemptFailure[] = []; + let spawnFailed = false; for (const image of images) { for ( @@ -233,6 +234,10 @@ const pullImage = ( break; } } else { + // A failed effect (rather than a non-zero exit) means the container + // runtime could not be spawned at all — a local Docker setup + // problem, not a registry failure. + spawnFailed = true; const cause = Cause.squash(result.cause); const message = cause instanceof Error ? cause.message : String(cause); failures.push({ image, attempt, message }); @@ -258,7 +263,8 @@ const pullImage = ( image: images[0] ?? "unknown", detail: `Failed to pull Docker image from all registries. ${detail}`, cause: new Error(detail), - daemonDown: failures.some((failure) => isDockerDaemonDownMessage(failure.message)), + daemonDown: + spawnFailed || failures.some((failure) => isDockerDaemonDownMessage(failure.message)), }), ); }); diff --git a/packages/stack/src/errors.ts b/packages/stack/src/errors.ts index 53ff7b7cce..91412fd709 100644 --- a/packages/stack/src/errors.ts +++ b/packages/stack/src/errors.ts @@ -21,9 +21,11 @@ export class DockerPullError extends Data.TaggedError("DockerPullError")<{ readonly detail: string; readonly cause: unknown; /** - * Whether the pull failed because the Docker daemon itself is unreachable, - * detected from the runtime's output at the boundary where it is produced. - * Consumers must branch on this instead of sniffing `detail` text. + * Whether the pull failed because the container runtime itself is unusable + * locally — the daemon is unreachable (detected from the runtime's output + * at the boundary where it is produced) or the docker binary could not be + * spawned at all. Consumers must branch on this instead of sniffing + * `detail` text. */ readonly daemonDown: boolean; }> {} From 8b677d64d93cd91825618c90ad2908491ecabc8f Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 8 Jul 2026 17:40:58 +0200 Subject: [PATCH 04/20] chore(cli): classify wrapped causes, 403s, and api-client config failures - classifyCliErrorActionability recurses into the classifiable preserved cause of stack wrapper errors (asset-preparation StackBuildError, DownloadError, StackError), so a daemon-down DockerPullError or a local filesystem PlatformError is no longer reported as a registry/network failure. Recursion is guarded: an unclassifiable cause keeps the wrapper's own bucket. - statusCodeActionability and the HttpClientError adapter classify 403 as user-actionable account permission (after the entitlement-gate branch, so confirmed plan gates stay plan_limit). - packages/api joins the adapter coverage scan; SupabaseApiConfigError classifies as auth/set_env_var. - LegacyStorageAuthTokenError branches on its stored status like its link twin, so api-keys 5xx responses stop counting as user login problems. Co-Authored-By: Claude Fable 5 --- .../legacy-storage-credentials.errors.ts | 5 +- .../error-actionability-coverage.unit.test.ts | 1 + .../shared/telemetry/error-actionability.ts | 41 +++++++++++++- .../error-actionability.unit.test.ts | 55 +++++++++++++++++++ 4 files changed, 99 insertions(+), 3 deletions(-) diff --git a/apps/cli/src/legacy/shared/legacy-storage-credentials.errors.ts b/apps/cli/src/legacy/shared/legacy-storage-credentials.errors.ts index 10548540aa..62b634530c 100644 --- a/apps/cli/src/legacy/shared/legacy-storage-credentials.errors.ts +++ b/apps/cli/src/legacy/shared/legacy-storage-credentials.errors.ts @@ -4,6 +4,7 @@ import { actionability, type CliErrorActionabilityDeclaration, ErrorActionabilityId, + statusCodeActionability, } from "../../shared/telemetry/error-actionability.ts"; /** @@ -62,6 +63,8 @@ export class LegacyStorageAuthTokenError extends Data.TaggedError("LegacyStorage readonly message: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.authLogin; + // The shared mapper wraps any non-200 in this tag; only a 401 is an auth + // failure the user fixes by re-logging in. + return statusCodeActionability(this.status); } } diff --git a/apps/cli/src/shared/telemetry/error-actionability-coverage.unit.test.ts b/apps/cli/src/shared/telemetry/error-actionability-coverage.unit.test.ts index 7992bf9f60..2f66b2103b 100644 --- a/apps/cli/src/shared/telemetry/error-actionability-coverage.unit.test.ts +++ b/apps/cli/src/shared/telemetry/error-actionability-coverage.unit.test.ts @@ -149,6 +149,7 @@ describe("apps/cli error classes declare their actionability", () => { describe("workspace package error tags have external adapters", () => { const packageRoots = [ + "packages/api/src", "packages/stack/src", "packages/config/src", "packages/process-compose/src", diff --git a/apps/cli/src/shared/telemetry/error-actionability.ts b/apps/cli/src/shared/telemetry/error-actionability.ts index 58652d5a76..51dbc6619a 100644 --- a/apps/cli/src/shared/telemetry/error-actionability.ts +++ b/apps/cli/src/shared/telemetry/error-actionability.ts @@ -305,6 +305,9 @@ export function statusCodeActionability( if (opts.upgradeSuggested === true && status !== undefined && status >= 400 && status < 500) { return { ...actionability.planLimit, fingerprint_suffix: "plan_limit" }; } + if (status === 403) { + return { ...actionability.accountAccess, fingerprint_suffix: "forbidden" }; + } if (status === undefined) { return { ...actionability.externalNetwork, fingerprint_suffix: "network" }; } @@ -432,6 +435,10 @@ const externalActionabilityByTag: Record< MissingProjectConfigValueError: () => actionability.invalidConfig, DuplicateRemoteProjectIdError: () => actionability.invalidConfig, + // @supabase/api — client construction failed before any request (missing + // access token / bad configuration); remediation is the token env var. + SupabaseApiConfigError: () => actionability.authToken, + // effect/unstable/http — generated Management API client transport/decoding HttpClientError: (error) => { const reason = error["reason"]; @@ -439,6 +446,7 @@ const externalActionabilityByTag: Record< const response = error["response"]; const status = isErrorRecord(response) ? readNumber(response, "status") : undefined; if (status === 401) return { ...actionability.authLogin, fingerprint_suffix: "auth" }; + if (status === 403) return { ...actionability.accountAccess, fingerprint_suffix: "forbidden" }; if (reasonTag === "StatusCodeError" || isErrorRecord(response)) { return { ...actionability.apiStatus, fingerprint_suffix: "api_status" }; } @@ -512,6 +520,20 @@ export function isClassifiedExternalErrorTag(tag: string): boolean { return Object.hasOwn(externalActionabilityByTag, tag); } +/** + * A wrapper's preserved `cause`, but only when classifying it cannot degrade + * the result: the cause must carry its own declaration or a known external + * adapter tag, otherwise the wrapper's own classification is more truthful. + */ +function classifiableCause(error: ErrorRecord): ErrorRecord | undefined { + const cause = error["cause"]; + if (!isErrorRecord(cause)) return undefined; + if (readDeclaration(cause) !== undefined) return cause; + const causeTag = readErrorTag(cause); + if (causeTag !== undefined && Object.hasOwn(externalActionabilityByTag, causeTag)) return cause; + return undefined; +} + function classifyShowHelp(error: ErrorRecord): CliErrorActionability | undefined { const errors = error["errors"]; if (!Array.isArray(errors)) return undefined; @@ -550,6 +572,21 @@ export function classifyCliErrorActionability(error: unknown): CliErrorActionabi return classifyCliErrorActionability(error["cause"]); } + // @supabase/stack wrapper errors preserve the underlying tagged failure in + // `cause`; classify it when it is more specific than the wrapper (e.g. a + // daemon-down DockerPullError inside an asset-preparation StackBuildError, + // or a local filesystem PlatformError inside a DownloadError). + if ( + isErrorRecord(error) && + (tag === "DownloadError" || + (tag === "StackBuildError" && readString(error, "reason") === "asset_preparation")) + ) { + const cause = classifiableCause(error); + if (cause !== undefined) { + return classifyCliErrorActionability(cause); + } + } + if (tag !== undefined && isErrorRecord(error)) { // Own-property lookup: a sanitized tag like "constructor" must not pick // up Object.prototype members as adapters. @@ -566,8 +603,8 @@ export function classifyCliErrorActionability(error: unknown): CliErrorActionabi // The public Stack promise API wraps tagged failures via `toStackError`, // preserving the original in `cause` — classify that instead of the // wrapper whenever it is itself classifiable. - const cause = error["cause"]; - if (readErrorTag(cause) !== undefined || readDeclaration(cause) !== undefined) { + const cause = classifiableCause(error); + if (cause !== undefined) { return classifyCliErrorActionability(cause); } const classify = externalActionabilityByTag["StackError"]; diff --git a/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts b/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts index 65feb69764..539abcc3a8 100644 --- a/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts +++ b/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts @@ -166,6 +166,61 @@ describe("classifyCliErrorActionability", () => { expect(result.error_fingerprint).toBe("tag:StackBuildError:invalid_config"); }); + it("treats forbidden API statuses as account permission failures", () => { + const forbidden = classifyCliErrorActionability(new DeclaredStatusError({ status: 403 })); + expect(forbidden.error_kind).toBe("user_actionable"); + expect(forbidden.error_category).toBe("permission"); + expect(forbidden.error_fingerprint).toBe("tag:DeclaredStatusError:forbidden"); + + const gated = classifyCliErrorActionability( + new DeclaredStatusError({ status: 403, upgradeSuggested: true }), + ); + expect(gated.error_category).toBe("plan_limit"); + + const http = classifyCliErrorActionability({ + _tag: "HttpClientError", + response: { status: 403 }, + }); + expect(http.error_category).toBe("permission"); + }); + + it("classifies the preserved cause of stack wrapper errors", () => { + const daemonDown = classifyCliErrorActionability({ + _tag: "StackBuildError", + detail: "Failed to prepare stack assets", + reason: "asset_preparation", + cause: { _tag: "DockerPullError", image: "postgres", daemonDown: true }, + }); + expect(daemonDown.error_category).toBe("docker_not_running"); + expect(daemonDown.error_fingerprint).toBe("tag:DockerPullError:docker_not_running"); + + const localFs = classifyCliErrorActionability({ + _tag: "DownloadError", + url: "filesystem error for /cache", + cause: { _tag: "PlatformError", reason: { _tag: "PermissionDenied" } }, + }); + expect(localFs.error_kind).toBe("user_actionable"); + expect(localFs.error_category).toBe("permission"); + + // An unclassifiable cause keeps the wrapper's own bucket. + const opaque = classifyCliErrorActionability({ + _tag: "DownloadError", + url: "https://example.com", + cause: new Error("boom"), + }); + expect(opaque.error_kind).toBe("external_service"); + expect(opaque.error_category).toBe("network"); + }); + + it("classifies API client configuration failures as token problems", () => { + const result = classifyCliErrorActionability({ + _tag: "SupabaseApiConfigError", + message: "Missing access token.", + }); + expect(result.error_category).toBe("auth"); + expect(result.suggestion_type).toBe("set_env_var"); + }); + it("does not treat Object.prototype members as external adapters", () => { const result = classifyCliErrorActionability({ _tag: "constructor" }); expect(result.error_kind).toBe("unknown"); From c9af260751871ee82f3854e0e53894931ddad892 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 8 Jul 2026 18:00:22 +0200 Subject: [PATCH 05/20] chore(cli): classify error types added on develop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The coverage guard correctly failed on the develop merge: the stop/status port and config-validation refactor introduced new error classes. Each now declares its actionability from its construction sites (workdir flags → provide_flags, config.toml content → invalid_config, docker CLI wrapper failures → docker_not_running, missing/unhealthy containers → start-stack remediation), LegacyContainerRuntimeNotFoundError is exported for guard verification, and packages/config's InvalidRemoteProjectIdError joins the external adapters as invalid_config. Co-Authored-By: Claude Fable 5 --- .../legacy/commands/status/status.errors.ts | 62 ++++++++++++++++--- .../src/legacy/commands/stop/stop.errors.ts | 53 +++++++++++++--- .../legacy/shared/legacy-config-validate.ts | 11 +++- .../src/legacy/shared/legacy-container-cli.ts | 23 +++++-- .../legacy/shared/legacy-docker-lifecycle.ts | 31 +++++++++- .../shared/legacy-local-config-values.ts | 17 +++++ .../shared/legacy-workdir-validation.ts | 18 +++++- .../shared/telemetry/error-actionability.ts | 1 + 8 files changed, 187 insertions(+), 29 deletions(-) diff --git a/apps/cli/src/legacy/commands/status/status.errors.ts b/apps/cli/src/legacy/commands/status/status.errors.ts index 9e72e14fbb..38a8c34727 100644 --- a/apps/cli/src/legacy/commands/status/status.errors.ts +++ b/apps/cli/src/legacy/commands/status/status.errors.ts @@ -1,4 +1,9 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../shared/telemetry/error-actionability.ts"; /** * An explicit `--workdir`/`SUPABASE_WORKDIR` path doesn't exist or isn't a @@ -10,41 +15,76 @@ import { Data } from "effect"; */ export class LegacyStatusWorkdirError extends Data.TaggedError("LegacyStatusWorkdirError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** `loadProjectConfig` rejected `supabase/config.toml` (malformed TOML/JSON). */ export class LegacyStatusConfigLoadError extends Data.TaggedError("LegacyStatusConfigLoadError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} /** A `--override-name KEY=VALUE` entry did not parse, mirroring `env.EnvironToEnvSet`. */ export class LegacyStatusOverrideParseError extends Data.TaggedError( "LegacyStatusOverrideParseError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} -/** Inspecting the db container failed for a reason other than "not found". */ +/** + * Inspecting the db container failed for a reason other than "not found" — + * except Go's `assertContainerHealthy` never special-cases a missing + * container (see `status.handler.ts`'s step-5 comment): an absent container + * is just another non-zero inspect exit, so the dominant real trigger of this + * error is "the local stack was never started", same fix as + * {@link LegacyStatusDbNotRunningError}. + */ export class LegacyStatusDbInspectError extends Data.TaggedError("LegacyStatusDbInspectError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.startStack; + } +} /** The db container is absent or present but not in the `running` state. */ export class LegacyStatusDbNotRunningError extends Data.TaggedError( "LegacyStatusDbNotRunningError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.startStack; + } +} /** The db container is running but its Docker health check is not `healthy`. */ export class LegacyStatusDbNotReadyError extends Data.TaggedError("LegacyStatusDbNotReadyError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.startStack; + } +} /** Listing running containers by label failed. */ export class LegacyStatusListError extends Data.TaggedError("LegacyStatusListError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dockerNotRunning; + } +} /** * `config.toml` resolved to a value `Config.Validate` would reject before status @@ -55,4 +95,8 @@ export class LegacyStatusInvalidConfigError extends Data.TaggedError( "LegacyStatusInvalidConfigError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} diff --git a/apps/cli/src/legacy/commands/stop/stop.errors.ts b/apps/cli/src/legacy/commands/stop/stop.errors.ts index ac8d49db97..6b1f78943d 100644 --- a/apps/cli/src/legacy/commands/stop/stop.errors.ts +++ b/apps/cli/src/legacy/commands/stop/stop.errors.ts @@ -1,4 +1,9 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../shared/telemetry/error-actionability.ts"; /** * An explicit `--workdir`/`SUPABASE_WORKDIR` path doesn't exist or isn't a @@ -10,7 +15,11 @@ import { Data } from "effect"; */ export class LegacyStopWorkdirError extends Data.TaggedError("LegacyStopWorkdirError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * `--project-id` and `--all` were both set. Best-effort match of cobra's @@ -23,12 +32,20 @@ export class LegacyStopMutuallyExclusiveError extends Data.TaggedError( "LegacyStopMutuallyExclusiveError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** Loading `config.toml` failed for a reason other than the file being absent (malformed TOML). */ export class LegacyStopConfigLoadError extends Data.TaggedError("LegacyStopConfigLoadError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} /** * Listing containers to stop failed. `stop`-specific wrapper over @@ -37,26 +54,46 @@ export class LegacyStopConfigLoadError extends Data.TaggedError("LegacyStopConfi */ export class LegacyStopListError extends Data.TaggedError("LegacyStopListError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dockerNotRunning; + } +} /** Stopping one or more containers failed (`DockerRemoveAll`'s `WaitAll` step). */ export class LegacyStopContainerError extends Data.TaggedError("LegacyStopContainerError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dockerNotRunning; + } +} /** `docker container prune` failed. */ export class LegacyStopContainerPruneError extends Data.TaggedError( "LegacyStopContainerPruneError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dockerNotRunning; + } +} /** `docker volume prune` failed (only run when `--no-backup`/`--backup=false`). */ export class LegacyStopVolumePruneError extends Data.TaggedError("LegacyStopVolumePruneError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dockerNotRunning; + } +} /** `docker network prune` failed. */ export class LegacyStopNetworkPruneError extends Data.TaggedError("LegacyStopNetworkPruneError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dockerNotRunning; + } +} diff --git a/apps/cli/src/legacy/shared/legacy-config-validate.ts b/apps/cli/src/legacy/shared/legacy-config-validate.ts index f833f95300..7bb299f18a 100644 --- a/apps/cli/src/legacy/shared/legacy-config-validate.ts +++ b/apps/cli/src/legacy/shared/legacy-config-validate.ts @@ -1,5 +1,10 @@ import { isAbsolute, join } from "node:path"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; import { legacyGoUrlParse } from "./legacy-storage-url.ts"; /** @@ -161,7 +166,11 @@ export function legacyParseGoBool(value: string): boolean | undefined { * `.toThrow("substring")`), so swapping their inline `throw new Error(...)` calls for this class * is a byte-identical, purely internal refactor. */ -export class LegacyConfigValidateError extends Error {} +export class LegacyConfigValidateError extends Error { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} /** One `[api.tls]` section, post-env-override. See {@link LegacyConfigValidationInput}. */ export interface LegacyApiInput { diff --git a/apps/cli/src/legacy/shared/legacy-container-cli.ts b/apps/cli/src/legacy/shared/legacy-container-cli.ts index 53bfb6e947..a1cc751d08 100644 --- a/apps/cli/src/legacy/shared/legacy-container-cli.ts +++ b/apps/cli/src/legacy/shared/legacy-container-cli.ts @@ -2,6 +2,12 @@ import { Data, Effect, Stream } from "effect"; import * as ChildProcess from "effect/unstable/process/ChildProcess"; import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; + /** * Container CLIs tried in order: Docker is preferred, Podman is the fallback * for Docker-less hosts (e.g. Podman-only Linux setups). @@ -17,16 +23,21 @@ type Spawner = ChildProcessSpawner["Service"]; /** * Raised when neither `docker` nor `podman` can be spawned at all (e.g. neither * is installed or on `PATH`) — distinct from a spawned process exiting non-zero. - * Not exported: callers never need to match on this type directly, they fold it - * into their own tagged error via {@link legacyDescribeContainerCliFailure} so - * the "no runtime found" root cause survives instead of collapsing into a - * generic "failed to ..." message. + * Callers never need to match on this type directly, they fold it into their + * own tagged error via {@link legacyDescribeContainerCliFailure} so the "no + * runtime found" root cause survives instead of collapsing into a generic + * "failed to ..." message. Exported only so the coverage test can verify its + * own actionability declaration. */ -class LegacyContainerRuntimeNotFoundError extends Data.TaggedError( +export class LegacyContainerRuntimeNotFoundError extends Data.TaggedError( "LegacyContainerRuntimeNotFoundError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dockerNotRunning; + } +} const RUNTIME_NOT_FOUND_MESSAGE = "docker: command not found (podman also not found) — install Docker Desktop or Podman and ensure it is on PATH"; diff --git a/apps/cli/src/legacy/shared/legacy-docker-lifecycle.ts b/apps/cli/src/legacy/shared/legacy-docker-lifecycle.ts index f8884a700a..e555ed721a 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-lifecycle.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-lifecycle.ts @@ -1,6 +1,11 @@ import { Data, Effect, Stream } from "effect"; import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; import { legacyDescribeContainerCliFailure, spawnContainerCli } from "./legacy-container-cli.ts"; type Spawner = ChildProcessSpawner["Service"]; @@ -15,14 +20,34 @@ export class LegacyDockerLifecycleListError extends Data.TaggedError( "LegacyDockerLifecycleListError", )<{ readonly message: string; -}> {} +}> { + // `docker ps`/`docker volume ls` never fail because nothing matches the + // label filter — an empty match is a successful, empty result. Every real + // failure here is therefore a container-runtime problem: neither + // docker/podman could be spawned, or the daemon itself rejected the call. + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dockerNotRunning; + } +} -/** Inspecting a single container's state failed for a reason other than "not found". */ +/** + * Inspecting a single container's state failed for a reason other than "not + * found" — except Go's `assertContainerHealthy` (and this port, matching it, + * see `status.handler.ts`) never special-cases a missing container either: an + * absent container is just another non-zero `docker container inspect` exit, + * which is by far the dominant real trigger of this error (the user hasn't + * run `supabase start` yet) — same fix as the other "stack isn't running" + * errors elsewhere in this codebase. + */ export class LegacyDockerLifecycleInspectError extends Data.TaggedError( "LegacyDockerLifecycleInspectError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.startStack; + } +} function collectByteStream(stream: Stream.Stream) { const decoder = new TextDecoder(); diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.ts index a851d5a670..86b1efa656 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.ts @@ -5,6 +5,11 @@ import { ENV_CAPTURE_REGEX, type ProjectConfig } from "@supabase/config"; import { defaultJwtSecret, defaultPublishableKey, defaultSecretKey } from "@supabase/stack/effect"; import { Schema } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; import { legacyResolveApiExternalUrl } from "./legacy-api-url.ts"; import { legacySanitizeProjectId } from "./legacy-docker-ids.ts"; import { @@ -116,6 +121,9 @@ export class LegacyInvalidJwtSecretError extends Error { super("Invalid config for auth.jwt_secret. Must be at least 16 characters"); this.name = "LegacyInvalidJwtSecretError"; } + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } } /** Go's minimum `auth.jwt_secret` length (`pkg/config/apikeys.go:46`). */ @@ -138,6 +146,9 @@ export class LegacyInvalidPortEnvOverrideError extends Error { super(`Invalid config for ${dottedFieldPath}: cannot parse "${value}" as a port`); this.name = "LegacyInvalidPortEnvOverrideError"; } + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } } /** Go's `uint16` port fields' valid range (`pkg/config/db.go:84`, `pkg/config/api.go:29`, etc). */ @@ -234,6 +245,9 @@ export class LegacyInvalidBoolEnvOverrideError extends Error { super(`Invalid config for ${dottedFieldPath}: cannot parse "${value}" as a bool`); this.name = "LegacyInvalidBoolEnvOverrideError"; } + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } } /** @@ -289,6 +303,9 @@ export class LegacyInvalidAnalyticsBackendEnvOverrideError extends Error { ); this.name = "LegacyInvalidAnalyticsBackendEnvOverrideError"; } + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } } /** diff --git a/apps/cli/src/legacy/shared/legacy-workdir-validation.ts b/apps/cli/src/legacy/shared/legacy-workdir-validation.ts index 40e8a5976f..6731c06923 100644 --- a/apps/cli/src/legacy/shared/legacy-workdir-validation.ts +++ b/apps/cli/src/legacy/shared/legacy-workdir-validation.ts @@ -1,13 +1,27 @@ import { Data, Effect, FileSystem } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; + /** * Raised by {@link legacyValidateWorkdirIsDirectory} when the target path * doesn't exist or isn't a directory. Callers map this into their own - * command-specific error type. + * command-specific error type. Only reachable when the user explicitly set + * `--workdir`/`SUPABASE_WORKDIR` to a bad path — the default walk-up + * resolution can never fail this check (see the doc comment on + * {@link legacyValidateWorkdirIsDirectory} below) — so the fix is always + * "pass a different `--workdir`/`SUPABASE_WORKDIR`". */ export class LegacyWorkdirValidationError extends Data.TaggedError("LegacyWorkdirValidationError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * Validates that `workdir` exists and is a directory, the way Go's diff --git a/apps/cli/src/shared/telemetry/error-actionability.ts b/apps/cli/src/shared/telemetry/error-actionability.ts index 51dbc6619a..c48ab600e1 100644 --- a/apps/cli/src/shared/telemetry/error-actionability.ts +++ b/apps/cli/src/shared/telemetry/error-actionability.ts @@ -434,6 +434,7 @@ const externalActionabilityByTag: Record< ProjectEnvParseError: () => actionability.invalidConfig, MissingProjectConfigValueError: () => actionability.invalidConfig, DuplicateRemoteProjectIdError: () => actionability.invalidConfig, + InvalidRemoteProjectIdError: () => actionability.invalidConfig, // @supabase/api — client construction failed before any request (missing // access token / bad configuration); remediation is the token env var. From 5b22e0cdcef64b528da57513ed54c198bcf8c4ed Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 9 Jul 2026 08:45:49 +0200 Subject: [PATCH 06/20] chore(cli): harden classifier recursion and dedupe review findings From a final multi-angle review pass: - Cause-chain recursion (ShowHelp, UserError, stack wrapper causes) is capped at depth 8, so a cyclic cause chain can never stack-overflow the failure-telemetry path; regression tests cover mutual and self cycles. - Reason-less StackBuildErrors now classify their preserved cause, so a user's ProjectConfigParseError inside "Failed to configure Edge Functions" reports invalid_config instead of an internal bug; explicit invalid_config wrappers are never overridden. The cleanup-metadata persistence site drops its invalid_config reason (it is a filesystem failure, classified from its cause). - toStackError's ServiceNotFoundError case preserves `cause` like every sibling case, so wrapped stack failures classify uniformly. - DockerPullError's spawnFailed flag resets once a later attempt spawns successfully, so a transient spawn hiccup no longer reports a genuine registry failure as docker-not-running. - The docker daemon-down matcher lives once in @supabase/stack and is imported by the legacy docker-run layer; the plan-limit-gated declaration is shared (planLimitGatedActionability) instead of triplicated. - LegacyDbQueryExecError's non-transport branch classifies as dbFinding, matching every sibling "user's own SQL failed" error. - The cname failure discriminant is null-safe. Co-Authored-By: Claude Fable 5 --- .../legacy/commands/db/query/query.errors.ts | 3 +- .../legacy/commands/domains/domains.cname.ts | 2 +- .../cli/src/legacy/commands/sso/sso.errors.ts | 7 ++- .../legacy/shared/legacy-docker-run.layer.ts | 19 +------- .../shared/telemetry/error-actionability.ts | 46 +++++++++++++++---- .../error-actionability.unit.test.ts | 28 +++++++++++ .../stack/src/StackLifecycleCoordinator.ts | 1 - packages/stack/src/StackPreparation.ts | 19 ++------ packages/stack/src/errors.ts | 17 +++++++ packages/stack/src/index.ts | 1 + 10 files changed, 95 insertions(+), 48 deletions(-) diff --git a/apps/cli/src/legacy/commands/db/query/query.errors.ts b/apps/cli/src/legacy/commands/db/query/query.errors.ts index 6e17f12267..3bf4112a8d 100644 --- a/apps/cli/src/legacy/commands/db/query/query.errors.ts +++ b/apps/cli/src/legacy/commands/db/query/query.errors.ts @@ -67,7 +67,8 @@ export class LegacyDbQueryExecError extends Data.TaggedError("LegacyDbQueryExecE if (this.transport === true) { return { ...actionability.externalNetwork, fingerprint_suffix: "network" }; } - return actionability.invalidInput; + // The user's own SQL failed — same bucket as every sibling exec error. + return actionability.dbFinding; } } diff --git a/apps/cli/src/legacy/commands/domains/domains.cname.ts b/apps/cli/src/legacy/commands/domains/domains.cname.ts index f1e37a7f01..f8ad264083 100644 --- a/apps/cli/src/legacy/commands/domains/domains.cname.ts +++ b/apps/cli/src/legacy/commands/domains/domains.cname.ts @@ -105,7 +105,7 @@ export const verifyLegacyCname = Effect.fnUntraced(function* (args: { Effect.timeout("10 seconds"), Effect.mapError((cause) => { const failure: LegacyCnameFailure = - typeof cause === "object" && "transport" in cause && "detail" in cause + typeof cause === "object" && cause !== null && "transport" in cause ? cause : transportFailure(cause); return new LegacyDomainsCnameError({ diff --git a/apps/cli/src/legacy/commands/sso/sso.errors.ts b/apps/cli/src/legacy/commands/sso/sso.errors.ts index 103dcdae2c..656a8b4b61 100644 --- a/apps/cli/src/legacy/commands/sso/sso.errors.ts +++ b/apps/cli/src/legacy/commands/sso/sso.errors.ts @@ -3,6 +3,7 @@ import { actionability, type CliErrorActionabilityDeclaration, ErrorActionabilityId, + planLimitGatedActionability, statusCodeActionability, } from "../../../shared/telemetry/error-actionability.ts"; @@ -16,15 +17,13 @@ const samlDisabledActionability = ( upgradeSuggested: boolean | undefined, ): CliErrorActionabilityDeclaration => upgradeSuggested === true - ? { ...actionability.planLimit, fingerprint_suffix: "plan_limit" } + ? planLimitGatedActionability : { ...actionability.invalidConfig, fingerprint_suffix: "saml_disabled" }; const gatedNotFoundActionability = ( upgradeSuggested: boolean | undefined, ): CliErrorActionabilityDeclaration => - upgradeSuggested === true - ? { ...actionability.planLimit, fingerprint_suffix: "plan_limit" } - : actionability.invalidInput; + upgradeSuggested === true ? planLimitGatedActionability : actionability.invalidInput; // Shared across show / update / remove: Go's `uuid.Parse` failure. // Message intentionally diverges from Go's verbose `failed to parse provider ID: invalid UUID …` diff --git a/apps/cli/src/legacy/shared/legacy-docker-run.layer.ts b/apps/cli/src/legacy/shared/legacy-docker-run.layer.ts index ea09e3e344..58f7641210 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-run.layer.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-run.layer.ts @@ -1,3 +1,4 @@ +import { isDockerDaemonDownMessage } from "@supabase/stack"; import { Effect, Exit, Layer, Stream } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; import { ProcessControl } from "../../shared/runtime/process-control.service.ts"; @@ -32,22 +33,6 @@ const RETRYABLE_PULL_PATTERNS = [ /i\/o timeout/i, ] as const; -/** - * Whether the container runtime's output indicates the daemon itself is not - * running. This is the boundary where docker's text output is produced, so it - * is the one place allowed to interpret it (feeds - * `LegacyDockerRunError.daemonDown`). - */ -const legacyIsDockerDaemonDownMessage = (message: string): boolean => { - const normalized = message.toLowerCase(); - return ( - normalized.includes("cannot connect to the docker daemon") || - normalized.includes("docker daemon is not running") || - normalized.includes("docker desktop is not running") || - normalized.includes("is the docker daemon running") - ); -}; - export const legacyDockerRunLayer: Layer.Layer< LegacyDockerRun, never, @@ -188,7 +173,7 @@ export const legacyDockerRunLayer: Layer.Layer< new LegacyDockerRunError({ message: `failed to pull docker image from all registries: ${failures.join("; ")}`, reason: "pull", - daemonDown: failures.some(legacyIsDockerDaemonDownMessage), + daemonDown: failures.some(isDockerDaemonDownMessage), }), ); }); diff --git a/apps/cli/src/shared/telemetry/error-actionability.ts b/apps/cli/src/shared/telemetry/error-actionability.ts index c48ab600e1..18c2189b63 100644 --- a/apps/cli/src/shared/telemetry/error-actionability.ts +++ b/apps/cli/src/shared/telemetry/error-actionability.ts @@ -289,6 +289,16 @@ export const actionability = { }, } as const satisfies Record; +/** + * The declaration for a failure confirmed plan-gated by the entitlement + * check (`legacySuggestUpgrade`). Shared so every gated surface groups under + * the same fingerprint family. + */ +export const planLimitGatedActionability: CliErrorActionabilityDeclaration = { + ...actionability.planLimit, + fingerprint_suffix: "plan_limit", +}; + /** * Classification policy for errors that carry a Management API status code. * `upgradeSuggested` is the typed result of the entitlement gate @@ -303,7 +313,7 @@ export function statusCodeActionability( return { ...actionability.authLogin, fingerprint_suffix: "auth" }; } if (opts.upgradeSuggested === true && status !== undefined && status >= 400 && status < 500) { - return { ...actionability.planLimit, fingerprint_suffix: "plan_limit" }; + return planLimitGatedActionability; } if (status === 403) { return { ...actionability.accountAccess, fingerprint_suffix: "forbidden" }; @@ -535,10 +545,10 @@ function classifiableCause(error: ErrorRecord): ErrorRecord | undefined { return undefined; } -function classifyShowHelp(error: ErrorRecord): CliErrorActionability | undefined { +function classifyShowHelp(error: ErrorRecord, depth: number): CliErrorActionability | undefined { const errors = error["errors"]; if (!Array.isArray(errors)) return undefined; - if (errors.length === 1) return classifyCliErrorActionability(errors[0]); + if (errors.length === 1) return classifyAtDepth(errors[0], depth + 1); return toActionability(actionability.invalidInput, "tag", "ShowHelp"); } @@ -554,7 +564,22 @@ function isNativeJsExceptionName(name: string | undefined): boolean { ); } +/** + * Hard cap on cause-chain recursion (ShowHelp, UserError, and stack wrapper + * causes). Real chains are 1-2 deep; the cap exists so a cyclic or + * adversarial cause chain can never stack-overflow the failure-telemetry + * path itself. + */ +const MAX_CAUSE_DEPTH = 8; + export function classifyCliErrorActionability(error: unknown): CliErrorActionability { + return classifyAtDepth(error, 0); +} + +function classifyAtDepth(error: unknown, depth: number): CliErrorActionability { + if (depth >= MAX_CAUSE_DEPTH) { + return toActionability(actionability.unknown, "error", "CauseChainLimit"); + } const tag = readErrorTag(error); const declared = readDeclaration(error); @@ -563,28 +588,31 @@ export function classifyCliErrorActionability(error: unknown): CliErrorActionabi } if (tag === "ShowHelp" && isErrorRecord(error)) { - const classified = classifyShowHelp(error); + const classified = classifyShowHelp(error, depth); if (classified !== undefined) return classified; } // effect cli wraps handler failures in UserError({ cause }) — classify the // actual failure instead of the wrapper. if (tag === "UserError" && isErrorRecord(error) && error["cause"] !== undefined) { - return classifyCliErrorActionability(error["cause"]); + return classifyAtDepth(error["cause"], depth + 1); } // @supabase/stack wrapper errors preserve the underlying tagged failure in // `cause`; classify it when it is more specific than the wrapper (e.g. a // daemon-down DockerPullError inside an asset-preparation StackBuildError, - // or a local filesystem PlatformError inside a DownloadError). + // a user's ProjectConfigParseError inside a reason-less StackBuildError, or + // a local filesystem PlatformError inside a DownloadError). Explicit + // `invalid_config` StackBuildErrors are deliberate user-facing config + // verdicts and are never overridden by their cause. if ( isErrorRecord(error) && (tag === "DownloadError" || - (tag === "StackBuildError" && readString(error, "reason") === "asset_preparation")) + (tag === "StackBuildError" && readString(error, "reason") !== "invalid_config")) ) { const cause = classifiableCause(error); if (cause !== undefined) { - return classifyCliErrorActionability(cause); + return classifyAtDepth(cause, depth + 1); } } @@ -606,7 +634,7 @@ export function classifyCliErrorActionability(error: unknown): CliErrorActionabi // wrapper whenever it is itself classifiable. const cause = classifiableCause(error); if (cause !== undefined) { - return classifyCliErrorActionability(cause); + return classifyAtDepth(cause, depth + 1); } const classify = externalActionabilityByTag["StackError"]; if (classify !== undefined) { diff --git a/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts b/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts index 539abcc3a8..9958eb680e 100644 --- a/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts +++ b/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts @@ -221,6 +221,34 @@ describe("classifyCliErrorActionability", () => { expect(result.suggestion_type).toBe("set_env_var"); }); + it("caps cause-chain recursion instead of overflowing on cycles", () => { + const a: Record = { _tag: "DownloadError", url: "x" }; + const b: Record = { + _tag: "StackBuildError", + detail: "y", + reason: "asset_preparation", + cause: a, + }; + a["cause"] = b; + const result = classifyCliErrorActionability(a); + expect(result.error_kind).toBe("unknown"); + expect(result.error_fingerprint).toBe("error:CauseChainLimit"); + + const self: Record = { _tag: "UserError" }; + self["cause"] = self; + expect(classifyCliErrorActionability(self).error_fingerprint).toBe("error:CauseChainLimit"); + }); + + it("classifies the user config cause of a reason-less StackBuildError", () => { + const result = classifyCliErrorActionability({ + _tag: "StackBuildError", + detail: "Failed to configure Edge Functions", + cause: { _tag: "ProjectConfigParseError", path: "supabase/config.toml" }, + }); + expect(result.error_category).toBe("invalid_config"); + expect(result.error_fingerprint).toBe("tag:ProjectConfigParseError"); + }); + it("does not treat Object.prototype members as external adapters", () => { const result = classifyCliErrorActionability({ _tag: "constructor" }); expect(result.error_kind).toBe("unknown"); diff --git a/packages/stack/src/StackLifecycleCoordinator.ts b/packages/stack/src/StackLifecycleCoordinator.ts index 9f6385408a..cfeacc0897 100644 --- a/packages/stack/src/StackLifecycleCoordinator.ts +++ b/packages/stack/src/StackLifecycleCoordinator.ts @@ -347,7 +347,6 @@ export class StackLifecycleCoordinator extends Context.Service< new StackBuildError({ detail: "Failed to persist stack cleanup metadata", cause, - reason: "invalid_config", }), ), ); diff --git a/packages/stack/src/StackPreparation.ts b/packages/stack/src/StackPreparation.ts index 64a3912e31..ae3568de69 100644 --- a/packages/stack/src/StackPreparation.ts +++ b/packages/stack/src/StackPreparation.ts @@ -2,7 +2,7 @@ import { Cause, Data, Effect, Exit, Layer, Queue, Context, Stream } from "effect import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { BinaryResolver } from "./BinaryResolver.ts"; import type { ChecksumMismatchError } from "./errors.ts"; -import { DockerPullError } from "./errors.ts"; +import { DockerPullError, isDockerDaemonDownMessage } from "./errors.ts"; import type { ServiceResolution } from "./resolve.ts"; import { DEFAULT_VERSIONS, @@ -220,6 +220,9 @@ const pullImage = ( const attempt = attemptIndex + 1; const result = yield* Effect.exit(runPullCommand(spawner, image)); if (Exit.isSuccess(result)) { + // A successful spawn proves the runtime is usable; an earlier + // transient spawn failure must not taint the final classification. + spawnFailed = false; if (result.value.exitCode === 0) { return image; } @@ -269,20 +272,6 @@ const pullImage = ( ); }); -/** - * Whether the container runtime's output indicates the daemon itself is not - * running. This is the boundary where docker's text output is produced, so it - * is the one place allowed to interpret it. - */ -const isDockerDaemonDownMessage = (message: string): boolean => { - const normalized = message.toLowerCase(); - return ( - normalized.includes("cannot connect to the docker daemon") || - normalized.includes("docker daemon is not running") || - normalized.includes("docker desktop is not running") || - normalized.includes("is the docker daemon running") - ); -}; const resolveServiceWithMetadata = ( resolver: BinaryResolver["Service"], diff --git a/packages/stack/src/errors.ts b/packages/stack/src/errors.ts index 91412fd709..28e15223c8 100644 --- a/packages/stack/src/errors.ts +++ b/packages/stack/src/errors.ts @@ -30,6 +30,22 @@ export class DockerPullError extends Data.TaggedError("DockerPullError")<{ readonly daemonDown: boolean; }> {} +/** + * Whether a container runtime's output indicates the daemon itself is not + * running. This is the boundary vocabulary for `DockerPullError.daemonDown` + * and shared with the CLI's legacy docker-run layer so both paths agree on + * what "daemon down" looks like. + */ +export const isDockerDaemonDownMessage = (message: string): boolean => { + const normalized = message.toLowerCase(); + return ( + normalized.includes("cannot connect to the docker daemon") || + normalized.includes("docker daemon is not running") || + normalized.includes("docker desktop is not running") || + normalized.includes("is the docker daemon running") + ); +}; + export class StackBuildError extends Data.TaggedError("StackBuildError")<{ readonly detail: string; readonly cause?: unknown; @@ -70,6 +86,7 @@ export function toStackError(err: unknown): StackError { return new StackError({ code: "SERVICE_NOT_FOUND", message: taggedMessage, + cause: err, }); case "StackBuildError": return new StackError({ diff --git a/packages/stack/src/index.ts b/packages/stack/src/index.ts index 087b40503e..3645f939ac 100644 --- a/packages/stack/src/index.ts +++ b/packages/stack/src/index.ts @@ -26,3 +26,4 @@ export type { PrefetchOptions, PrefetchResult } from "./prefetch.ts"; export type { ReadyOptions, StackHandle } from "./createStack.ts"; export type { FunctionsConfig, FunctionsRuntimeConfig } from "./functions.ts"; export { UnixHttpClient, UnixHttpClientError } from "./UnixHttpClient.ts"; +export { isDockerDaemonDownMessage } from "./errors.ts"; From dcc2a916fe5573a7842bccddf0ecee49dec694c0 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 9 Jul 2026 09:02:18 +0200 Subject: [PATCH 07/20] chore(repo): format packages/stack and require the monorepo-wide check gate The last commit left an unformatted line in packages/stack, which failed CI's Check code quality job (it runs the check targets across all projects) while the focused apps/cli checks were green. Fix the formatting and record the rule in AGENTS.md: multi-workspace changes must pass `npx nx run-many -t types:check lint:check fmt:check knip:check` from the repo root before the task is done, judged by the Nx Failed tasks list rather than filtered output. Co-Authored-By: Claude Fable 5 --- AGENTS.md | 8 +++++++- packages/stack/src/StackPreparation.ts | 1 - 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7207168209..105f21762a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,7 +76,13 @@ pnpm test If a workspace exposes a different script set, use that workspace's `package.json` as the source of truth. -## Nx +Before finishing any task that touches more than one workspace — or any `packages/*` workspace that other workspaces depend on — also run the monorepo-wide gate that CI's `Check code quality` job runs, from the repo root, and confirm it passes: + +```sh +npx nx run-many -t types:check lint:check fmt:check knip:check +``` + +Per-workspace `check:all` runs are not a substitute: CI runs these targets across all projects, so a formatting or lint failure in any touched workspace fails the PR even when the workspace you focused on is green. Read the Nx `Failed tasks` list at the end of the output verbatim — do not judge success by grepping for passing lines, and beware that piping a check command through `tail`/`grep` masks its exit code. This repo uses Nx for task orchestration. Prefer Nx commands over running scripts directly when working across projects or when you need to understand project structure. diff --git a/packages/stack/src/StackPreparation.ts b/packages/stack/src/StackPreparation.ts index ae3568de69..5b75083e28 100644 --- a/packages/stack/src/StackPreparation.ts +++ b/packages/stack/src/StackPreparation.ts @@ -272,7 +272,6 @@ const pullImage = ( ); }); - const resolveServiceWithMetadata = ( resolver: BinaryResolver["Service"], spawner: ChildProcessSpawner.ChildProcessSpawner["Service"], From c30bd0f9777d3f0f9f6e548677905a6037606bf0 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 9 Jul 2026 09:30:59 +0200 Subject: [PATCH 08/20] chore(cli): classify functions API statuses, bootstrap health, and docker socket denials - Plain new Error(...) failures at the functions deploy/download Management-API status checks become FunctionsApiStatusError with statusCodeActionability, so common deploy/download failures stop reporting as unknown; messages are byte-identical. - LegacyBootstrapHealthError threads the health-poll status and classifies through the shared status helper (service-unhealthy stays api_status). - The shared docker daemon-down matcher recognizes socket permission-denied output, so a Linux user missing docker group membership classifies as a local runtime problem instead of a registry failure. Co-Authored-By: Claude Fable 5 --- .../commands/bootstrap/bootstrap.errors.ts | 8 ++++- .../commands/bootstrap/bootstrap.handler.ts | 4 ++- apps/cli/src/shared/functions/deploy.ts | 34 +++++++++++++------ apps/cli/src/shared/functions/download.ts | 18 ++++++++-- .../shared/functions/functions-api.errors.ts | 23 +++++++++++++ packages/stack/src/errors.ts | 6 +++- 6 files changed, 77 insertions(+), 16 deletions(-) create mode 100644 apps/cli/src/shared/functions/functions-api.errors.ts diff --git a/apps/cli/src/legacy/commands/bootstrap/bootstrap.errors.ts b/apps/cli/src/legacy/commands/bootstrap/bootstrap.errors.ts index 613cc301d4..dcea63d7d4 100644 --- a/apps/cli/src/legacy/commands/bootstrap/bootstrap.errors.ts +++ b/apps/cli/src/legacy/commands/bootstrap/bootstrap.errors.ts @@ -3,6 +3,7 @@ import { actionability, type CliErrorActionabilityDeclaration, ErrorActionabilityId, + statusCodeActionability, } from "../../../shared/telemetry/error-actionability.ts"; // --------------------------------------------------------------------------- @@ -76,8 +77,13 @@ export class LegacyBootstrapTemplateDownloadError extends Data.TaggedError( */ export class LegacyBootstrapHealthError extends Data.TaggedError("LegacyBootstrapHealthError")<{ readonly message: string; + /** Set when the health poll itself failed with a non-200; absent when the + * service reported unhealthy. */ + readonly status?: number; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.apiStatus; + return this.status !== undefined + ? statusCodeActionability(this.status) + : actionability.apiStatus; } } diff --git a/apps/cli/src/legacy/commands/bootstrap/bootstrap.handler.ts b/apps/cli/src/legacy/commands/bootstrap/bootstrap.handler.ts index 0ed9b20a8a..6171959738 100644 --- a/apps/cli/src/legacy/commands/bootstrap/bootstrap.handler.ts +++ b/apps/cli/src/legacy/commands/bootstrap/bootstrap.handler.ts @@ -326,7 +326,9 @@ const mapHealthError = (cause: unknown): Effect.Effect ""), Effect.map(sanitizeLegacyErrorBody), Effect.flatMap((body) => - Effect.fail(new LegacyBootstrapHealthError({ message: `Error status ${status}: ${body}` })), + Effect.fail( + new LegacyBootstrapHealthError({ message: `Error status ${status}: ${body}`, status }), + ), ), ); } diff --git a/apps/cli/src/shared/functions/deploy.ts b/apps/cli/src/shared/functions/deploy.ts index 51f0d9a9a8..335f9fecd8 100644 --- a/apps/cli/src/shared/functions/deploy.ts +++ b/apps/cli/src/shared/functions/deploy.ts @@ -31,6 +31,7 @@ import { InvalidFunctionDeploySlugError, NoFunctionsToDeployError, } from "./deploy.errors.ts"; +import { FunctionsApiStatusError } from "./functions-api.errors.ts"; const COMPRESSED_ESZIP_MAGIC = "EZBR"; const DENO1_EDGE_RUNTIME_VERSION = "1.68.4"; @@ -1427,7 +1428,7 @@ const bundleFunctionWithDocker = Effect.fnUntraced(function* ( }); const listRemoteFunctions = Effect.fnUntraced(function* (api: ApiClient, projectRef: string) { - let lastError: Error | undefined; + let lastError: Error | FunctionsApiStatusError | undefined; for (let attempt = 0; attempt <= 3; attempt += 1) { const result = yield* api .executeRaw(operationDefinitions.v1ListAllFunctions, { ref: projectRef }) @@ -1452,7 +1453,10 @@ const listRemoteFunctions = Effect.fnUntraced(function* (api: ApiClient, project ), }); } - lastError = new Error(`unexpected list functions status ${result.response.status}: ${body}`); + lastError = new FunctionsApiStatusError({ + status: result.response.status, + message: `unexpected list functions status ${result.response.status}: ${body}`, + }); if (result.response.status < 500 && result.response.status !== 429) { return yield* Effect.fail(lastError); } @@ -1570,7 +1574,10 @@ const uploadFunctionSource = Effect.fnUntraced(function* ( const body = yield* response.body; if (response.status !== 201) { return yield* Effect.fail( - new Error(`unexpected deploy status ${response.status}: ${JSON.stringify(body)}`), + new FunctionsApiStatusError({ + status: response.status, + message: `unexpected deploy status ${response.status}: ${JSON.stringify(body)}`, + }), ); } return yield* Effect.try({ @@ -1603,7 +1610,7 @@ const bulkUpdateRemoteFunctions = Effect.fnUntraced(function* ( projectRef: string, functions: ReadonlyArray, ) { - let lastError: Error | undefined; + let lastError: Error | FunctionsApiStatusError | undefined; for (let attempt = 0; attempt <= 3; attempt += 1) { const result = yield* rateLimitedRequest("bulk updating functions", () => api @@ -1636,7 +1643,10 @@ const bulkUpdateRemoteFunctions = Effect.fnUntraced(function* ( if (result.response.status === 200) { return; } - lastError = new Error(`unexpected bulk update status ${result.response.status}: ${body}`); + lastError = new FunctionsApiStatusError({ + status: result.response.status, + message: `unexpected bulk update status ${result.response.status}: ${body}`, + }); if (result.response.status < 500) { return yield* Effect.fail(lastError); } @@ -1658,7 +1668,7 @@ const upsertBundledFunction = Effect.fnUntraced(function* ( exists: boolean, ) { let shouldUpdate = exists; - let lastError: Error | undefined; + let lastError: Error | FunctionsApiStatusError | undefined; for (let attempt = 0; attempt <= 3; attempt += 1) { const action = shouldUpdate ? "update" : "create"; @@ -1708,9 +1718,10 @@ const upsertBundledFunction = Effect.fnUntraced(function* ( if (!shouldUpdate && body.includes("Duplicated function slug")) { shouldUpdate = true; } - lastError = new Error( - `unexpected ${action} function status ${response.value.status}: ${body}`, - ); + lastError = new FunctionsApiStatusError({ + status: response.value.status, + message: `unexpected ${action} function status ${response.value.status}: ${body}`, + }); } else { lastError = response.error; } @@ -1740,7 +1751,10 @@ const deleteRemoteFunction = Effect.fnUntraced(function* ( } const body = yield* response.text.pipe(Effect.orElseSucceed(() => "")); return yield* Effect.fail( - new Error(`unexpected delete function status ${response.status}: ${body}`), + new FunctionsApiStatusError({ + status: response.status, + message: `unexpected delete function status ${response.status}: ${body}`, + }), ); }); diff --git a/apps/cli/src/shared/functions/download.ts b/apps/cli/src/shared/functions/download.ts index 7b9882f6dd..e27fecca0b 100644 --- a/apps/cli/src/shared/functions/download.ts +++ b/apps/cli/src/shared/functions/download.ts @@ -23,6 +23,7 @@ import { InvalidFunctionSlugError, UnsafeFunctionDownloadPathError, } from "./download.errors.ts"; +import { FunctionsApiStatusError } from "./functions-api.errors.ts"; const legacyEntrypointPath = "file:///src/index.ts"; @@ -589,7 +590,10 @@ const listRemoteFunctionSlugs = Effect.fnUntraced(function* (api: ApiClient, pro const body = yield* response.text.pipe(Effect.orElseSucceed(() => "")); if (response.status !== 200) { return yield* Effect.fail( - new Error(`unexpected list functions status ${response.status}: ${body}`), + new FunctionsApiStatusError({ + status: response.status, + message: `unexpected list functions status ${response.status}: ${body}`, + }), ); } @@ -635,7 +639,10 @@ const getRemoteFunction = Effect.fnUntraced(function* ( ); default: return yield* Effect.fail( - new Error(`Failed to download Function ${slug} on the Supabase project: ${body}`), + new FunctionsApiStatusError({ + status: response.status, + message: `Failed to download Function ${slug} on the Supabase project: ${body}`, + }), ); } @@ -675,7 +682,12 @@ const downloadBody = Effect.fnUntraced(function* ( } const body = yield* response.text.pipe(Effect.orElseSucceed(() => "")); - return yield* Effect.fail(new Error(`Error status ${response.status}: ${body}`)); + return yield* Effect.fail( + new FunctionsApiStatusError({ + status: response.status, + message: `Error status ${response.status}: ${body}`, + }), + ); }); const downloadSingle = Effect.fnUntraced(function* ( diff --git a/apps/cli/src/shared/functions/functions-api.errors.ts b/apps/cli/src/shared/functions/functions-api.errors.ts new file mode 100644 index 0000000000..08f0baea65 --- /dev/null +++ b/apps/cli/src/shared/functions/functions-api.errors.ts @@ -0,0 +1,23 @@ +import { Data } from "effect"; +import { + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../telemetry/error-actionability.ts"; + +/** + * Shared error for a non-OK Management API response where an HTTP status + * code is available, used by both `deploy.ts` and `download.ts`. Keeping one + * class here (rather than duplicating it per file) lets both call sites + * classify identically via {@link statusCodeActionability} instead of + * falling back to a plain `Error` (which reports as `unknown` in the error + * actionability KPI). + */ +export class FunctionsApiStatusError extends Data.TaggedError("FunctionsApiStatusError")<{ + readonly status: number; + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} diff --git a/packages/stack/src/errors.ts b/packages/stack/src/errors.ts index 28e15223c8..94ae4c115e 100644 --- a/packages/stack/src/errors.ts +++ b/packages/stack/src/errors.ts @@ -42,7 +42,11 @@ export const isDockerDaemonDownMessage = (message: string): boolean => { normalized.includes("cannot connect to the docker daemon") || normalized.includes("docker daemon is not running") || normalized.includes("docker desktop is not running") || - normalized.includes("is the docker daemon running") + normalized.includes("is the docker daemon running") || + // Spawn succeeds but the socket is not accessible (e.g. a Linux user + // missing docker group membership) — a local setup problem, not a + // registry failure. + normalized.includes("permission denied while trying to connect to the docker daemon") ); }; From a6d69ca20f2608afce87a700990f565d0a04a164 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 9 Jul 2026 10:05:08 +0200 Subject: [PATCH 09/20] chore(cli): split bootstrap transport failures and scope download cause recursion - LegacyBootstrapHealthError carries a transport flag for polls that failed without an HTTP response (DNS/TLS/timeout), classifying them as network instead of api_status; service-unhealthy responses keep api_status. - DownloadError cause recursion is restricted to local filesystem PlatformError causes. HTTP causes stay on the wrapper: the HttpClientError adapter's 401/403 auth/permission policy is Management-API-specific and must not label GitHub/CDN asset-download failures as login problems. Co-Authored-By: Claude Fable 5 --- .../commands/bootstrap/bootstrap.errors.ts | 11 +++++--- .../commands/bootstrap/bootstrap.handler.ts | 4 ++- .../shared/telemetry/error-actionability.ts | 22 +++++++++++----- .../error-actionability.unit.test.ts | 26 ++++++++++++++++++- 4 files changed, 52 insertions(+), 11 deletions(-) diff --git a/apps/cli/src/legacy/commands/bootstrap/bootstrap.errors.ts b/apps/cli/src/legacy/commands/bootstrap/bootstrap.errors.ts index dcea63d7d4..5292d65307 100644 --- a/apps/cli/src/legacy/commands/bootstrap/bootstrap.errors.ts +++ b/apps/cli/src/legacy/commands/bootstrap/bootstrap.errors.ts @@ -80,10 +80,15 @@ export class LegacyBootstrapHealthError extends Data.TaggedError("LegacyBootstra /** Set when the health poll itself failed with a non-200; absent when the * service reported unhealthy. */ readonly status?: number; + /** Set when the health poll failed without any HTTP response (DNS, TLS, + * timeout) — a network failure, not an API status. */ + readonly transport?: boolean; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return this.status !== undefined - ? statusCodeActionability(this.status) - : actionability.apiStatus; + if (this.status !== undefined) return statusCodeActionability(this.status); + if (this.transport === true) { + return { ...actionability.externalNetwork, fingerprint_suffix: "network" }; + } + return actionability.apiStatus; } } diff --git a/apps/cli/src/legacy/commands/bootstrap/bootstrap.handler.ts b/apps/cli/src/legacy/commands/bootstrap/bootstrap.handler.ts index 6171959738..a6ec88f6a2 100644 --- a/apps/cli/src/legacy/commands/bootstrap/bootstrap.handler.ts +++ b/apps/cli/src/legacy/commands/bootstrap/bootstrap.handler.ts @@ -332,5 +332,7 @@ const mapHealthError = (cause: unknown): Effect.Effect { }); it("caps cause-chain recursion instead of overflowing on cycles", () => { - const a: Record = { _tag: "DownloadError", url: "x" }; + const a: Record = { + _tag: "StackBuildError", + detail: "x", + reason: "asset_preparation", + }; const b: Record = { _tag: "StackBuildError", detail: "y", @@ -249,6 +253,26 @@ describe("classifyCliErrorActionability", () => { expect(result.error_fingerprint).toBe("tag:ProjectConfigParseError"); }); + it("keeps HTTP download causes in the download bucket", () => { + // GitHub/CDN 401/403 during asset download must NOT hit the + // Management-API auth/permission policy of the HttpClientError adapter. + const forbidden = classifyCliErrorActionability({ + _tag: "DownloadError", + url: "https://github.com/releases/x", + cause: { _tag: "HttpClientError", response: { status: 403 } }, + }); + expect(forbidden.error_kind).toBe("external_service"); + expect(forbidden.error_category).toBe("network"); + expect(forbidden.error_fingerprint).toBe("tag:DownloadError"); + + const localFs = classifyCliErrorActionability({ + _tag: "DownloadError", + url: "filesystem error for /cache", + cause: { _tag: "PlatformError", reason: { _tag: "PermissionDenied" } }, + }); + expect(localFs.error_category).toBe("permission"); + }); + it("does not treat Object.prototype members as external adapters", () => { const result = classifyCliErrorActionability({ _tag: "constructor" }); expect(result.error_kind).toBe("unknown"); From 0309ab2c389479fd9313ecbda0246f18bf93cd96 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 9 Jul 2026 11:15:33 +0200 Subject: [PATCH 10/20] chore(cli): classify transport, decode, and daemon failure paths precisely - mapTransportError in functions deploy/download returns FunctionsApiTransportError (network bucket) instead of plain Error, so Management API outages during --use-api flows stop counting as unknown. - ApiError and the advisors NetworkError classes carry a decode flag set only by the response-decode catch sites, classifying 200-with-bad-body as api_status/api_response instead of network. - RemoteStack maps non-OK daemon responses into UnixHttpClientError before Effect.orDie, so a local daemon 500 classifies as a stack problem instead of a Management API status. Co-Authored-By: Claude Fable 5 --- .../commands/db/advisors/advisors.errors.ts | 26 +++- .../commands/db/advisors/advisors.linked.ts | 19 ++- apps/cli/src/next/auth/api.layer.ts | 1 + apps/cli/src/next/auth/errors.ts | 15 ++- apps/cli/src/shared/functions/deploy.ts | 10 +- apps/cli/src/shared/functions/download.ts | 10 +- .../shared/functions/functions-api.errors.ts | 17 +++ packages/stack/src/RemoteStack.ts | 114 +++++++++++++----- 8 files changed, 163 insertions(+), 49 deletions(-) diff --git a/apps/cli/src/legacy/commands/db/advisors/advisors.errors.ts b/apps/cli/src/legacy/commands/db/advisors/advisors.errors.ts index 1b77171a35..081dfa4104 100644 --- a/apps/cli/src/legacy/commands/db/advisors/advisors.errors.ts +++ b/apps/cli/src/legacy/commands/db/advisors/advisors.errors.ts @@ -80,12 +80,19 @@ export class LegacyDbAdvisorsQueryError extends Data.TaggedError("LegacyDbAdviso } } -/** `failed to fetch security advisors: %w` (`advisors.go:165`). */ +/** + * `failed to fetch security advisors: %w` (`advisors.go:165`). Go folds a + * decode error into the same message path as a transport failure — `decode` + * distinguishes them for actionability so a 200-response decode failure + * classifies as an API response problem instead of a network problem. + */ export class LegacyDbAdvisorsSecurityNetworkError extends Data.TaggedError( "LegacyDbAdvisorsSecurityNetworkError", -)<{ readonly message: string }> { +)<{ readonly message: string; readonly decode?: boolean }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.externalNetwork; + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; } } @@ -98,12 +105,19 @@ export class LegacyDbAdvisorsSecurityStatusError extends Data.TaggedError( } } -/** `failed to fetch performance advisors: %w` (`advisors.go:176`). */ +/** + * `failed to fetch performance advisors: %w` (`advisors.go:176`). Go folds a + * decode error into the same message path as a transport failure — `decode` + * distinguishes them for actionability so a 200-response decode failure + * classifies as an API response problem instead of a network problem. + */ export class LegacyDbAdvisorsPerformanceNetworkError extends Data.TaggedError( "LegacyDbAdvisorsPerformanceNetworkError", -)<{ readonly message: string }> { +)<{ readonly message: string; readonly decode?: boolean }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.externalNetwork; + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; } } diff --git a/apps/cli/src/legacy/commands/db/advisors/advisors.linked.ts b/apps/cli/src/legacy/commands/db/advisors/advisors.linked.ts index 6ef5d5aa35..0425245942 100644 --- a/apps/cli/src/legacy/commands/db/advisors/advisors.linked.ts +++ b/apps/cli/src/legacy/commands/db/advisors/advisors.linked.ts @@ -18,8 +18,15 @@ import { apiResponseToLegacyAdvisorLints } from "./advisors.format.ts"; interface AdvisorEndpoint { readonly path: "security" | "performance"; - /** Builds the network/parse failure (Go's `failed to fetch … advisors: %w`). */ - readonly network: (message: string) => LegacyAdvisorNetworkError; + /** + * Builds the network/parse failure (Go's `failed to fetch … advisors: %w`). + * `decode: true` marks a 200-response body decode failure rather than a + * transport failure, even though Go folds both into the same message path. + */ + readonly network: ( + message: string, + opts?: { readonly decode?: boolean }, + ) => LegacyAdvisorNetworkError; /** Builds the non-200 failure (Go's `unexpected … advisors status %d: %s`). */ readonly status: (status: number, body: string) => LegacyAdvisorStatusError; } @@ -92,7 +99,7 @@ const fetchAdvisors = Effect.fnUntraced(function* ( // `apiResponseToLegacyAdvisorLints`) to the endpoint's network error. return yield* Effect.try({ try: () => apiResponseToLegacyAdvisorLints(JSON.parse(rawBody) as unknown), - catch: (cause) => endpoint.network(String(cause)), + catch: (cause) => endpoint.network(String(cause), { decode: true }), }); }); @@ -101,9 +108,10 @@ export const legacyFetchSecurityAdvisors = (ref: string, stitch: LegacyStitchFn) ref, { path: "security", - network: (message) => + network: (message, opts) => new LegacyDbAdvisorsSecurityNetworkError({ message: `failed to fetch security advisors: ${message}`, + decode: opts?.decode, }), status: (status, body) => new LegacyDbAdvisorsSecurityStatusError({ @@ -120,9 +128,10 @@ export const legacyFetchPerformanceAdvisors = (ref: string, stitch: LegacyStitch ref, { path: "performance", - network: (message) => + network: (message, opts) => new LegacyDbAdvisorsPerformanceNetworkError({ message: `failed to fetch performance advisors: ${message}`, + decode: opts?.decode, }), status: (status, body) => new LegacyDbAdvisorsPerformanceStatusError({ diff --git a/apps/cli/src/next/auth/api.layer.ts b/apps/cli/src/next/auth/api.layer.ts index 5e37fa2bc1..a526ab408a 100644 --- a/apps/cli/src/next/auth/api.layer.ts +++ b/apps/cli/src/next/auth/api.layer.ts @@ -56,6 +56,7 @@ export const makeApi = Effect.gen(function* () { return Effect.fail( new ApiError({ detail: error instanceof Error ? error.message : String(error), + decode: true, }), ); }), diff --git a/apps/cli/src/next/auth/errors.ts b/apps/cli/src/next/auth/errors.ts index 6d55a2ee7f..5af7c2ba26 100644 --- a/apps/cli/src/next/auth/errors.ts +++ b/apps/cli/src/next/auth/errors.ts @@ -26,9 +26,22 @@ export class InvalidTokenError extends CliError("InvalidTokenError") { export class ApiError extends Data.TaggedError("ApiError")<{ readonly statusCode?: number; readonly detail: string; + /** + * Set when this error represents a body/schema decode failure on an + * otherwise-successful response (no status code to classify by), rather + * than a transport failure — so it classifies as an API response problem + * instead of a network problem. + */ + readonly decode?: boolean; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return statusCodeActionability(this.statusCode); + if (this.statusCode !== undefined) { + return statusCodeActionability(this.statusCode); + } + if (this.decode === true) { + return { ...actionability.apiStatus, fingerprint_suffix: "api_response" }; + } + return statusCodeActionability(undefined); } } diff --git a/apps/cli/src/shared/functions/deploy.ts b/apps/cli/src/shared/functions/deploy.ts index 335f9fecd8..ab9f9e7e65 100644 --- a/apps/cli/src/shared/functions/deploy.ts +++ b/apps/cli/src/shared/functions/deploy.ts @@ -31,7 +31,7 @@ import { InvalidFunctionDeploySlugError, NoFunctionsToDeployError, } from "./deploy.errors.ts"; -import { FunctionsApiStatusError } from "./functions-api.errors.ts"; +import { FunctionsApiStatusError, FunctionsApiTransportError } from "./functions-api.errors.ts"; const COMPRESSED_ESZIP_MAGIC = "EZBR"; const DENO1_EDGE_RUNTIME_VERSION = "1.68.4"; @@ -156,17 +156,17 @@ function decodeFunctionListResponse(value: unknown): ReadonlyArray> { diff --git a/apps/cli/src/shared/functions/download.ts b/apps/cli/src/shared/functions/download.ts index e27fecca0b..e57fde578a 100644 --- a/apps/cli/src/shared/functions/download.ts +++ b/apps/cli/src/shared/functions/download.ts @@ -23,7 +23,7 @@ import { InvalidFunctionSlugError, UnsafeFunctionDownloadPathError, } from "./download.errors.ts"; -import { FunctionsApiStatusError } from "./functions-api.errors.ts"; +import { FunctionsApiStatusError, FunctionsApiTransportError } from "./functions-api.errors.ts"; const legacyEntrypointPath = "file:///src/index.ts"; @@ -150,17 +150,17 @@ function validateDownloadFlags( ); } -function mapTransportError(prefix: string, error: unknown): Error { +function mapTransportError(prefix: string, error: unknown): FunctionsApiTransportError { if (HttpClientError.isHttpClientError(error)) { const description = error.reason.description ?? error.reason._tag; - return new Error(`${prefix}: ${description}`); + return new FunctionsApiTransportError({ message: `${prefix}: ${description}` }); } if (error instanceof Error) { - return new Error(`${prefix}: ${error.message}`); + return new FunctionsApiTransportError({ message: `${prefix}: ${error.message}` }); } - return new Error(`${prefix}: ${String(error)}`); + return new FunctionsApiTransportError({ message: `${prefix}: ${String(error)}` }); } function hasEntrypointPath(metadata: DownloadMetadata | undefined): metadata is { diff --git a/apps/cli/src/shared/functions/functions-api.errors.ts b/apps/cli/src/shared/functions/functions-api.errors.ts index 08f0baea65..63280c85d3 100644 --- a/apps/cli/src/shared/functions/functions-api.errors.ts +++ b/apps/cli/src/shared/functions/functions-api.errors.ts @@ -1,5 +1,6 @@ import { Data } from "effect"; import { + actionability, type CliErrorActionabilityDeclaration, ErrorActionabilityId, statusCodeActionability, @@ -21,3 +22,19 @@ export class FunctionsApiStatusError extends Data.TaggedError("FunctionsApiStatu return statusCodeActionability(this.status); } } + +/** + * Shared error for a Management API request that failed before a response + * was received (DNS, connection reset, timeout, ...), used by both + * `deploy.ts` and `download.ts`'s `mapTransportError`. Keeping one class here + * (rather than duplicating it per file) lets both call sites classify + * identically as a network failure instead of falling back to a plain + * `Error` (which reports as `unknown` in the error actionability KPI). + */ +export class FunctionsApiTransportError extends Data.TaggedError("FunctionsApiTransportError")<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return { ...actionability.externalNetwork, fingerprint_suffix: "network" }; + } +} diff --git a/packages/stack/src/RemoteStack.ts b/packages/stack/src/RemoteStack.ts index 00803d7444..e8a613f273 100644 --- a/packages/stack/src/RemoteStack.ts +++ b/packages/stack/src/RemoteStack.ts @@ -1,7 +1,7 @@ import { ServiceNotFoundError, ServiceReadyError, type LogEntry } from "@supabase/process-compose"; import { Effect, Layer, Schema, Stream } from "effect"; import * as Sse from "effect/unstable/encoding/Sse"; -import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; +import { HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; import { Stack, StackInfoSchema } from "./Stack.ts"; import { StackServiceState, StackServiceStatusSchema } from "./StackServiceState.ts"; import { UnixHttpClient, UnixHttpClientError } from "./UnixHttpClient.ts"; @@ -86,11 +86,31 @@ function unixResponse(socketPath: string, path: string, init?: RequestInit) { ); } +/** + * Maps a `filterStatusOk` failure (the daemon responded, but with a non-2xx + * status) into a `UnixHttpClientError` so the CLI's error classifier sees a + * daemon-RPC failure instead of a raw Management-API-shaped `HttpClientError`. + */ +function dieOnNonOkStatus( + socketPath: string, + path: string, + effect: Effect.Effect, +) { + return effect.pipe( + Effect.mapError((cause) => new UnixHttpClientError({ socketPath, path, cause })), + Effect.orDie, + ); +} + /** Fetch JSON from the daemon, dying on HTTP errors. */ function fetchStatus(socketPath: string, path: string, method = "GET") { return Effect.gen(function* () { const response = yield* unixResponse(socketPath, path, { method }); - const okResponse = yield* HttpClientResponse.filterStatusOk(response).pipe(Effect.orDie); + const okResponse = yield* dieOnNonOkStatus( + socketPath, + path, + HttpClientResponse.filterStatusOk(response), + ); return yield* HttpClientResponse.schemaBodyJson(StatusResponseSchema)(okResponse).pipe( Effect.orDie, ); @@ -100,7 +120,11 @@ function fetchStatus(socketPath: string, path: string, method = "GET") { function fetchLogEntries(socketPath: string, path: string) { return Effect.gen(function* () { const response = yield* unixResponse(socketPath, path); - const okResponse = yield* HttpClientResponse.filterStatusOk(response).pipe(Effect.orDie); + const okResponse = yield* dieOnNonOkStatus( + socketPath, + path, + HttpClientResponse.filterStatusOk(response), + ); return yield* HttpClientResponse.schemaBodyJson(Schema.Array(LogEntrySchema))(okResponse).pipe( Effect.orDie, ); @@ -212,31 +236,47 @@ export const RemoteStack = { start: () => withUnixHttpClient( Effect.gen(function* () { - const response = yield* unixResponse(socketPath, "/start", { method: "POST" }); - yield* HttpClientResponse.filterStatusOk(response).pipe(Effect.orDie); + const path = "/start"; + const response = yield* unixResponse(socketPath, path, { method: "POST" }); + yield* dieOnNonOkStatus( + socketPath, + path, + HttpClientResponse.filterStatusOk(response), + ); }), ), stop: () => withUnixHttpClient( Effect.gen(function* () { - const response = yield* unixResponse(socketPath, "/stop", { method: "POST" }); - yield* HttpClientResponse.filterStatusOk(response).pipe(Effect.orDie); + const path = "/stop"; + const response = yield* unixResponse(socketPath, path, { method: "POST" }); + yield* dieOnNonOkStatus( + socketPath, + path, + HttpClientResponse.filterStatusOk(response), + ); }), ), dispose: () => withUnixHttpClient( Effect.gen(function* () { - const response = yield* unixResponse(socketPath, "/stop", { method: "POST" }); - yield* HttpClientResponse.filterStatusOk(response).pipe(Effect.orDie); + const path = "/stop"; + const response = yield* unixResponse(socketPath, path, { method: "POST" }); + yield* dieOnNonOkStatus( + socketPath, + path, + HttpClientResponse.filterStatusOk(response), + ); }), ), startService: (name: string) => withUnixHttpClient( Effect.gen(function* () { - const response = yield* unixResponse(socketPath, `/services/${name}/start`, { + const path = `/services/${name}/start`; + const response = yield* unixResponse(socketPath, path, { method: "POST", }); if (response.status === 404) { @@ -248,48 +288,59 @@ export const RemoteStack = { ).pipe(Effect.orDie); return yield* new ServiceReadyError({ name, reason: body.error }); } - yield* HttpClientResponse.filterStatusOk(response).pipe(Effect.orDie); + yield* dieOnNonOkStatus( + socketPath, + path, + HttpClientResponse.filterStatusOk(response), + ); }), ), stopService: (name: string) => withUnixHttpClient( Effect.gen(function* () { - const response = yield* unixResponse(socketPath, `/services/${name}/stop`, { + const path = `/services/${name}/stop`; + const response = yield* unixResponse(socketPath, path, { method: "POST", }); if (response.status === 404) { return yield* new ServiceNotFoundError({ name }); } - yield* HttpClientResponse.filterStatusOk(response).pipe(Effect.orDie); + yield* dieOnNonOkStatus( + socketPath, + path, + HttpClientResponse.filterStatusOk(response), + ); }), ), restartService: (name: string) => withUnixHttpClient( Effect.gen(function* () { - const response = yield* unixResponse(socketPath, `/services/${name}/restart`, { + const path = `/services/${name}/restart`; + const response = yield* unixResponse(socketPath, path, { method: "POST", }); if (response.status === 404) { return yield* new ServiceNotFoundError({ name }); } - yield* HttpClientResponse.filterStatusOk(response).pipe(Effect.orDie); + yield* dieOnNonOkStatus( + socketPath, + path, + HttpClientResponse.filterStatusOk(response), + ); }), ), reloadFunctions: (opts) => withUnixHttpClient( Effect.gen(function* () { - const response = yield* unixResponse( - socketPath, - `/functions/reload${encodeSearchParams({ - envFile: opts?.envFile, - noVerifyJwt: - opts?.noVerifyJwt === undefined ? undefined : String(opts.noVerifyJwt), - })}`, - { method: "POST" }, - ); + const path = `/functions/reload${encodeSearchParams({ + envFile: opts?.envFile, + noVerifyJwt: + opts?.noVerifyJwt === undefined ? undefined : String(opts.noVerifyJwt), + })}`; + const response = yield* unixResponse(socketPath, path, { method: "POST" }); if (response.status === 404) { return yield* new ServiceNotFoundError({ name: "edge-runtime" }); } @@ -302,14 +353,19 @@ export const RemoteStack = { reason: body.error, }); } - yield* HttpClientResponse.filterStatusOk(response).pipe(Effect.orDie); + yield* dieOnNonOkStatus( + socketPath, + path, + HttpClientResponse.filterStatusOk(response), + ); }), ), reloadEdgeRuntime: (opts) => withUnixHttpClient( Effect.gen(function* () { - const response = yield* unixResponse(socketPath, "/edge-runtime/reload", { + const path = "/edge-runtime/reload"; + const response = yield* unixResponse(socketPath, path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(opts), @@ -326,7 +382,11 @@ export const RemoteStack = { reason: body.error, }); } - yield* HttpClientResponse.filterStatusOk(response).pipe(Effect.orDie); + yield* dieOnNonOkStatus( + socketPath, + path, + HttpClientResponse.filterStatusOk(response), + ); }), ), From cc03e8146e5371ea7b50db04ec74211331dd5ecc Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 9 Jul 2026 11:34:02 +0200 Subject: [PATCH 11/20] chore(cli): classify branch-lookup 404s as input and gateway auth as external - LegacyBranchesFindUnexpectedStatusError treats 404 as invalid_input (not_found): a user-supplied branch name that matches nothing is a user error and belongs in the recovery funnel, not the external bucket. - LegacyStorageGatewayStatusError stops routing 401/403 through the Management-API auth policy: a tenant Storage gateway auth failure means stale local service keys, which supabase login cannot fix. Co-Authored-By: Claude Fable 5 --- apps/cli/src/legacy/commands/branches/branches.errors.ts | 5 +++++ apps/cli/src/legacy/shared/legacy-storage-gateway.errors.ts | 6 ++++++ 2 files changed, 11 insertions(+) diff --git a/apps/cli/src/legacy/commands/branches/branches.errors.ts b/apps/cli/src/legacy/commands/branches/branches.errors.ts index af3a7298d5..d67c732f69 100644 --- a/apps/cli/src/legacy/commands/branches/branches.errors.ts +++ b/apps/cli/src/legacy/commands/branches/branches.errors.ts @@ -77,6 +77,11 @@ export class LegacyBranchesFindUnexpectedStatusError extends Data.TaggedError( readonly message: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + // A 404 on branch lookup means the user-supplied branch name/ref did not + // match — user input, not an API failure. + if (this.status === 404) { + return { ...actionability.invalidInput, fingerprint_suffix: "not_found" }; + } return statusCodeActionability(this.status); } } diff --git a/apps/cli/src/legacy/shared/legacy-storage-gateway.errors.ts b/apps/cli/src/legacy/shared/legacy-storage-gateway.errors.ts index 8ff768baca..61bf09bbda 100644 --- a/apps/cli/src/legacy/shared/legacy-storage-gateway.errors.ts +++ b/apps/cli/src/legacy/shared/legacy-storage-gateway.errors.ts @@ -37,6 +37,12 @@ export class LegacyStorageGatewayStatusError extends Data.TaggedError( readonly message: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + // The tenant Storage gateway is not the Management API: a 401/403 here + // means stale local service keys, which `supabase login` cannot fix, so + // the Management-API auth/permission policy must not apply. + if (this.status === 401 || this.status === 403) { + return { ...actionability.apiStatus, fingerprint_suffix: "gateway_auth" }; + } return statusCodeActionability(this.status); } } From 47664b6e42493fc16a4c20c73517ee3c992e45f4 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 9 Jul 2026 12:22:57 +0200 Subject: [PATCH 12/20] chore(cli): implement the deferred classification refinements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un-defers the remaining review items and covers the latest round: - mapLegacyHttpError sets decode on generated-client response-decode failures, and all 59 mapper-backed *NetworkError classes (plus the snippets and config-push direct decode sites) classify decode as api_status/api_response instead of network. - Branch detail and snippets download 404s classify as invalid_input (not_found) — user-supplied identifiers, back in the recovery funnel. - UnixHttpClientError branches on the daemon RPC path: /start failures suggest starting the stack, others keep the stop remediation. - has_suggestion is reconciled at classify time with the instance's rendered suggestion field (false flips to true, never the reverse). - Invalid access tokens carry their source: env/flag-provided tokens suggest fixing the token (set_env_var) instead of supabase login, in both shells. - fetchLoginSession decode failures get the api_response fingerprint, matching fetchProfile. - Login polling exhaustion threads the last poll failure into the terminal error in both shells: 5xx classifies api_status, transport or timeout classifies network, pending 4xx keeps the auth bucket (verified against Go's uniform non-200 retry in login.go:139-157). Messages and retry behavior are byte-identical. Co-Authored-By: Claude Fable 5 --- .../src/legacy/auth/legacy-access-token.ts | 6 +- .../legacy/auth/legacy-credentials.layer.ts | 6 +- apps/cli/src/legacy/auth/legacy-errors.ts | 10 +- .../legacy/auth/legacy-platform-api.layer.ts | 2 +- .../legacy/commands/backups/backups.errors.ts | 10 +- .../commands/branches/branches.errors.ts | 62 +++++++++--- .../commands/config/push/push.cost-matrix.ts | 1 + .../commands/config/push/push.errors.ts | 94 +++++++++++++------ .../legacy/commands/domains/domains.errors.ts | 5 +- .../commands/encryption/encryption.errors.ts | 5 +- .../commands/functions/list/list.errors.ts | 5 +- .../legacy/commands/gen/types/types.errors.ts | 5 +- .../src/legacy/commands/link/link.errors.ts | 5 +- .../src/legacy/commands/login/login.errors.ts | 21 ++++- .../network-bans/network-bans.errors.ts | 10 +- .../network-restrictions.errors.ts | 10 +- .../src/legacy/commands/orgs/orgs.errors.ts | 10 +- .../commands/projects/projects.errors.ts | 15 ++- .../legacy/commands/secrets/secrets.errors.ts | 15 ++- .../snippets/download/download.handler.ts | 3 + .../commands/snippets/list/list.handler.ts | 3 + .../commands/snippets/snippets.errors.ts | 15 ++- .../ssl-enforcement/ssl-enforcement.errors.ts | 10 +- .../cli/src/legacy/commands/sso/sso.errors.ts | 20 +++- .../vanity-subdomains.errors.ts | 20 +++- .../legacy/shared/legacy-db-config.errors.ts | 18 ++-- .../src/legacy/shared/legacy-ensure-login.ts | 8 +- .../src/legacy/shared/legacy-http-errors.ts | 12 ++- .../legacy/shared/legacy-login-api.layer.ts | 3 + .../legacy-storage-credentials.errors.ts | 5 +- apps/cli/src/next/auth/api.layer.ts | 63 ++++++++----- apps/cli/src/next/auth/errors.ts | 28 +++--- apps/cli/src/next/auth/token.ts | 6 +- .../src/next/commands/login/login.errors.ts | 22 ++++- .../src/next/commands/login/login.handler.ts | 38 ++++++-- .../shared/telemetry/error-actionability.ts | 49 +++++++--- .../error-actionability.unit.test.ts | 46 +++++++++ 37 files changed, 518 insertions(+), 148 deletions(-) diff --git a/apps/cli/src/legacy/auth/legacy-access-token.ts b/apps/cli/src/legacy/auth/legacy-access-token.ts index 0aadb70bfd..2c3ed1b942 100644 --- a/apps/cli/src/legacy/auth/legacy-access-token.ts +++ b/apps/cli/src/legacy/auth/legacy-access-token.ts @@ -17,9 +17,13 @@ const LEGACY_INVALID_ACCESS_TOKEN_MESSAGE = */ export const validateLegacyAccessToken = ( token: string, + source?: "env" | "stored", ): Effect.Effect => LEGACY_ACCESS_TOKEN_PATTERN.test(token) ? Effect.succeed(token) : Effect.fail( - new LegacyInvalidAccessTokenError({ message: LEGACY_INVALID_ACCESS_TOKEN_MESSAGE }), + new LegacyInvalidAccessTokenError({ + message: LEGACY_INVALID_ACCESS_TOKEN_MESSAGE, + source, + }), ); diff --git a/apps/cli/src/legacy/auth/legacy-credentials.layer.ts b/apps/cli/src/legacy/auth/legacy-credentials.layer.ts index 2d78ea2ec8..4ca379c960 100644 --- a/apps/cli/src/legacy/auth/legacy-credentials.layer.ts +++ b/apps/cli/src/legacy/auth/legacy-credentials.layer.ts @@ -369,14 +369,14 @@ const makeLegacyCredentials = Effect.gen(function* () { // Env takes precedence (matches access_token.go:38). if (Option.isSome(cliConfig.accessToken)) { yield* debugLogger.debug("Using access token from env var..."); - yield* validateLegacyAccessToken(Redacted.value(cliConfig.accessToken.value)); + yield* validateLegacyAccessToken(Redacted.value(cliConfig.accessToken.value), "env"); return Option.some(cliConfig.accessToken.value); } // Keyring (profile key, then legacy key). Skipped on WSL. const keyringValue = yield* readKeyring; if (Option.isSome(keyringValue)) { - yield* validateLegacyAccessToken(keyringValue.value); + yield* validateLegacyAccessToken(keyringValue.value, "stored"); return Option.some(Redacted.make(keyringValue.value)); } @@ -384,7 +384,7 @@ const makeLegacyCredentials = Effect.gen(function* () { const fileValue = yield* readFile; if (Option.isSome(fileValue)) { yield* debugLogger.debug(`Using access token from file: ${fallbackPath}`); - yield* validateLegacyAccessToken(fileValue.value); + yield* validateLegacyAccessToken(fileValue.value, "stored"); return Option.some(Redacted.make(fileValue.value)); } diff --git a/apps/cli/src/legacy/auth/legacy-errors.ts b/apps/cli/src/legacy/auth/legacy-errors.ts index f9317216a5..f99d230b40 100644 --- a/apps/cli/src/legacy/auth/legacy-errors.ts +++ b/apps/cli/src/legacy/auth/legacy-errors.ts @@ -9,9 +9,17 @@ export class LegacyInvalidAccessTokenError extends Data.TaggedError( "LegacyInvalidAccessTokenError", )<{ readonly message: string; + /** + * Where the malformed token was read from. An env-var token + * (`SUPABASE_ACCESS_TOKEN`) takes precedence over stored credentials, so + * `supabase login` cannot fix it — the remediation is to correct the env + * var. A stored (keyring/file) token, or an unknown source, is fixable by + * logging in again. + */ + readonly source?: "env" | "stored"; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.authLogin; + return this.source === "env" ? actionability.authToken : actionability.authLogin; } } diff --git a/apps/cli/src/legacy/auth/legacy-platform-api.layer.ts b/apps/cli/src/legacy/auth/legacy-platform-api.layer.ts index 77eb406486..2093c73564 100644 --- a/apps/cli/src/legacy/auth/legacy-platform-api.layer.ts +++ b/apps/cli/src/legacy/auth/legacy-platform-api.layer.ts @@ -47,7 +47,7 @@ export const legacyMakePlatformApi = Effect.gen(function* () { // already validates the keyring/file paths; validate the env token here too so // a malformed SUPABASE_ACCESS_TOKEN fails with the invalid-token error rather // than being sent to the API. - yield* validateLegacyAccessToken(Redacted.value(configuredToken.value)); + yield* validateLegacyAccessToken(Redacted.value(configuredToken.value), "env"); return configuredToken; } return yield* credentials.getAccessToken; diff --git a/apps/cli/src/legacy/commands/backups/backups.errors.ts b/apps/cli/src/legacy/commands/backups/backups.errors.ts index 67fd9e3dea..859f97753c 100644 --- a/apps/cli/src/legacy/commands/backups/backups.errors.ts +++ b/apps/cli/src/legacy/commands/backups/backups.errors.ts @@ -8,9 +8,12 @@ import { export class LegacyBackupListNetworkError extends Data.TaggedError("LegacyBackupListNetworkError")<{ readonly message: string; + readonly decode?: boolean; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.externalNetwork; + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; } } @@ -30,9 +33,12 @@ export class LegacyBackupRestoreNetworkError extends Data.TaggedError( "LegacyBackupRestoreNetworkError", )<{ readonly message: string; + readonly decode?: boolean; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.externalNetwork; + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; } } diff --git a/apps/cli/src/legacy/commands/branches/branches.errors.ts b/apps/cli/src/legacy/commands/branches/branches.errors.ts index d67c732f69..5402ac34b4 100644 --- a/apps/cli/src/legacy/commands/branches/branches.errors.ts +++ b/apps/cli/src/legacy/commands/branches/branches.errors.ts @@ -17,9 +17,12 @@ export class LegacyBranchesListNetworkError extends Data.TaggedError( "LegacyBranchesListNetworkError", )<{ readonly message: string; + readonly decode?: boolean; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.externalNetwork; + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; } } @@ -39,9 +42,12 @@ export class LegacyBranchesCreateNetworkError extends Data.TaggedError( "LegacyBranchesCreateNetworkError", )<{ readonly message: string; + readonly decode?: boolean; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.externalNetwork; + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; } } @@ -63,9 +69,12 @@ export class LegacyBranchesFindNetworkError extends Data.TaggedError( "LegacyBranchesFindNetworkError", )<{ readonly message: string; + readonly decode?: boolean; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.externalNetwork; + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; } } @@ -92,9 +101,12 @@ export class LegacyBranchesGetNetworkError extends Data.TaggedError( "LegacyBranchesGetNetworkError", )<{ readonly message: string; + readonly decode?: boolean; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.externalNetwork; + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; } } @@ -106,6 +118,13 @@ export class LegacyBranchesGetUnexpectedStatusError extends Data.TaggedError( readonly message: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + // A 404 from `V1GetABranchConfig` means the user-supplied branch id/ref did + // not match any branch — user input, not an API failure. The resolver and + // `get` handler only call this endpoint with a user-provided UUID/ref, or + // the project_ref resolved from a user-provided branch name. + if (this.status === 404) { + return { ...actionability.invalidInput, fingerprint_suffix: "not_found" }; + } return statusCodeActionability(this.status); } } @@ -114,9 +133,12 @@ export class LegacyBranchesApiKeysNetworkError extends Data.TaggedError( "LegacyBranchesApiKeysNetworkError", )<{ readonly message: string; + readonly decode?: boolean; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.externalNetwork; + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; } } @@ -136,9 +158,12 @@ export class LegacyBranchesPoolerNetworkError extends Data.TaggedError( "LegacyBranchesPoolerNetworkError", )<{ readonly message: string; + readonly decode?: boolean; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.externalNetwork; + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; } } @@ -168,9 +193,12 @@ export class LegacyBranchesUpdateNetworkError extends Data.TaggedError( "LegacyBranchesUpdateNetworkError", )<{ readonly message: string; + readonly decode?: boolean; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.externalNetwork; + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; } } @@ -191,9 +219,12 @@ export class LegacyBranchesPauseNetworkError extends Data.TaggedError( "LegacyBranchesPauseNetworkError", )<{ readonly message: string; + readonly decode?: boolean; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.externalNetwork; + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; } } @@ -213,9 +244,12 @@ export class LegacyBranchesUnpauseNetworkError extends Data.TaggedError( "LegacyBranchesUnpauseNetworkError", )<{ readonly message: string; + readonly decode?: boolean; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.externalNetwork; + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; } } @@ -235,9 +269,12 @@ export class LegacyBranchesDeleteNetworkError extends Data.TaggedError( "LegacyBranchesDeleteNetworkError", )<{ readonly message: string; + readonly decode?: boolean; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.externalNetwork; + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; } } @@ -257,9 +294,12 @@ export class LegacyBranchesDisableNetworkError extends Data.TaggedError( "LegacyBranchesDisableNetworkError", )<{ readonly message: string; + readonly decode?: boolean; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.externalNetwork; + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; } } diff --git a/apps/cli/src/legacy/commands/config/push/push.cost-matrix.ts b/apps/cli/src/legacy/commands/config/push/push.cost-matrix.ts index 8bdfbb4416..2b3c166837 100644 --- a/apps/cli/src/legacy/commands/config/push/push.cost-matrix.ts +++ b/apps/cli/src/legacy/commands/config/push/push.cost-matrix.ts @@ -72,6 +72,7 @@ export const getCostMatrix = Effect.fn("legacy.config.push.cost-matrix")(functio catch: (cause) => new LegacyConfigPushListAddonsNetworkError({ message: `failed to list addons: ${String(cause)}`, + decode: true, }), }); diff --git a/apps/cli/src/legacy/commands/config/push/push.errors.ts b/apps/cli/src/legacy/commands/config/push/push.errors.ts index a26f4fcbb7..b9c873bc3a 100644 --- a/apps/cli/src/legacy/commands/config/push/push.errors.ts +++ b/apps/cli/src/legacy/commands/config/push/push.errors.ts @@ -26,6 +26,17 @@ interface NetworkErrorArgs { readonly message: string; } +/** + * A network-error shape that may instead represent a 200-response body decode + * failure (`SchemaError` / `HttpBodyError` folded in by `mapLegacyHttpError`). + * `decode: true` reclassifies the failure as an API-response problem rather + * than a transport/network problem. + */ +interface DecodableNetworkErrorArgs { + readonly message: string; + readonly decode?: boolean; +} + interface StatusErrorArgs { readonly status: number; readonly body: string; @@ -45,8 +56,11 @@ export class LegacyConfigPushLoadConfigError extends Data.TaggedError( export class LegacyConfigPushListAddonsNetworkError extends Data.TaggedError( "LegacyConfigPushListAddonsNetworkError", -) { +) { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + if (this.decode === true) { + return { ...actionability.apiStatus, fingerprint_suffix: "api_response" }; + } return actionability.externalNetwork; } } @@ -63,9 +77,11 @@ export class LegacyConfigPushListAddonsStatusError extends Data.TaggedError( export class LegacyConfigPushApiReadNetworkError extends Data.TaggedError( "LegacyConfigPushApiReadNetworkError", -) { +) { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.externalNetwork; + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; } } export class LegacyConfigPushApiReadStatusError extends Data.TaggedError( @@ -77,9 +93,11 @@ export class LegacyConfigPushApiReadStatusError extends Data.TaggedError( } export class LegacyConfigPushApiUpdateNetworkError extends Data.TaggedError( "LegacyConfigPushApiUpdateNetworkError", -) { +) { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.externalNetwork; + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; } } export class LegacyConfigPushApiUpdateStatusError extends Data.TaggedError( @@ -94,9 +112,11 @@ export class LegacyConfigPushApiUpdateStatusError extends Data.TaggedError( export class LegacyConfigPushDbReadNetworkError extends Data.TaggedError( "LegacyConfigPushDbReadNetworkError", -) { +) { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.externalNetwork; + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; } } export class LegacyConfigPushDbReadStatusError extends Data.TaggedError( @@ -108,9 +128,11 @@ export class LegacyConfigPushDbReadStatusError extends Data.TaggedError( } export class LegacyConfigPushDbUpdateNetworkError extends Data.TaggedError( "LegacyConfigPushDbUpdateNetworkError", -) { +) { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.externalNetwork; + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; } } export class LegacyConfigPushDbUpdateStatusError extends Data.TaggedError( @@ -125,9 +147,11 @@ export class LegacyConfigPushDbUpdateStatusError extends Data.TaggedError( export class LegacyConfigPushNetworkRestrictionsReadNetworkError extends Data.TaggedError( "LegacyConfigPushNetworkRestrictionsReadNetworkError", -) { +) { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.externalNetwork; + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; } } export class LegacyConfigPushNetworkRestrictionsReadStatusError extends Data.TaggedError( @@ -139,9 +163,11 @@ export class LegacyConfigPushNetworkRestrictionsReadStatusError extends Data.Tag } export class LegacyConfigPushNetworkRestrictionsUpdateNetworkError extends Data.TaggedError( "LegacyConfigPushNetworkRestrictionsUpdateNetworkError", -) { +) { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.externalNetwork; + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; } } export class LegacyConfigPushNetworkRestrictionsUpdateStatusError extends Data.TaggedError( @@ -156,9 +182,11 @@ export class LegacyConfigPushNetworkRestrictionsUpdateStatusError extends Data.T export class LegacyConfigPushSslEnforcementReadNetworkError extends Data.TaggedError( "LegacyConfigPushSslEnforcementReadNetworkError", -) { +) { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.externalNetwork; + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; } } export class LegacyConfigPushSslEnforcementReadStatusError extends Data.TaggedError( @@ -170,9 +198,11 @@ export class LegacyConfigPushSslEnforcementReadStatusError extends Data.TaggedEr } export class LegacyConfigPushSslEnforcementUpdateNetworkError extends Data.TaggedError( "LegacyConfigPushSslEnforcementUpdateNetworkError", -) { +) { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.externalNetwork; + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; } } export class LegacyConfigPushSslEnforcementUpdateStatusError extends Data.TaggedError( @@ -187,9 +217,11 @@ export class LegacyConfigPushSslEnforcementUpdateStatusError extends Data.Tagged export class LegacyConfigPushAuthReadNetworkError extends Data.TaggedError( "LegacyConfigPushAuthReadNetworkError", -) { +) { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.externalNetwork; + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; } } export class LegacyConfigPushAuthReadStatusError extends Data.TaggedError( @@ -201,9 +233,11 @@ export class LegacyConfigPushAuthReadStatusError extends Data.TaggedError( } export class LegacyConfigPushAuthUpdateNetworkError extends Data.TaggedError( "LegacyConfigPushAuthUpdateNetworkError", -) { +) { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.externalNetwork; + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; } } export class LegacyConfigPushAuthUpdateStatusError extends Data.TaggedError( @@ -218,9 +252,11 @@ export class LegacyConfigPushAuthUpdateStatusError extends Data.TaggedError( export class LegacyConfigPushStorageReadNetworkError extends Data.TaggedError( "LegacyConfigPushStorageReadNetworkError", -) { +) { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.externalNetwork; + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; } } export class LegacyConfigPushStorageReadStatusError extends Data.TaggedError( @@ -232,9 +268,11 @@ export class LegacyConfigPushStorageReadStatusError extends Data.TaggedError( } export class LegacyConfigPushStorageUpdateNetworkError extends Data.TaggedError( "LegacyConfigPushStorageUpdateNetworkError", -) { +) { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.externalNetwork; + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; } } export class LegacyConfigPushStorageUpdateStatusError extends Data.TaggedError( @@ -249,9 +287,11 @@ export class LegacyConfigPushStorageUpdateStatusError extends Data.TaggedError( export class LegacyConfigPushEnableWebhookNetworkError extends Data.TaggedError( "LegacyConfigPushEnableWebhookNetworkError", -) { +) { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.externalNetwork; + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; } } export class LegacyConfigPushEnableWebhookStatusError extends Data.TaggedError( diff --git a/apps/cli/src/legacy/commands/domains/domains.errors.ts b/apps/cli/src/legacy/commands/domains/domains.errors.ts index 5a3476b03a..d67526fd78 100644 --- a/apps/cli/src/legacy/commands/domains/domains.errors.ts +++ b/apps/cli/src/legacy/commands/domains/domains.errors.ts @@ -15,9 +15,12 @@ import { */ export class LegacyDomainsNetworkError extends Data.TaggedError("LegacyDomainsNetworkError")<{ readonly message: string; + readonly decode?: boolean; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.externalNetwork; + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; } } diff --git a/apps/cli/src/legacy/commands/encryption/encryption.errors.ts b/apps/cli/src/legacy/commands/encryption/encryption.errors.ts index 7a1fbfc699..db959f75f8 100644 --- a/apps/cli/src/legacy/commands/encryption/encryption.errors.ts +++ b/apps/cli/src/legacy/commands/encryption/encryption.errors.ts @@ -15,9 +15,12 @@ import { mapLegacyHttpError } from "../../shared/legacy-http-errors.ts"; */ export class LegacyEncryptionNetworkError extends Data.TaggedError("LegacyEncryptionNetworkError")<{ readonly message: string; + readonly decode?: boolean; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.externalNetwork; + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; } } diff --git a/apps/cli/src/legacy/commands/functions/list/list.errors.ts b/apps/cli/src/legacy/commands/functions/list/list.errors.ts index 66ee3560cd..47c7799fa4 100644 --- a/apps/cli/src/legacy/commands/functions/list/list.errors.ts +++ b/apps/cli/src/legacy/commands/functions/list/list.errors.ts @@ -10,9 +10,12 @@ export class LegacyFunctionsListNetworkError extends Data.TaggedError( "LegacyFunctionsListNetworkError", )<{ readonly message: string; + readonly decode?: boolean; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.externalNetwork; + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; } } diff --git a/apps/cli/src/legacy/commands/gen/types/types.errors.ts b/apps/cli/src/legacy/commands/gen/types/types.errors.ts index 8772eca809..c2976d01eb 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.errors.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.errors.ts @@ -8,9 +8,12 @@ import { export class LegacyGenTypesNetworkError extends Data.TaggedError("LegacyGenTypesNetworkError")<{ readonly message: string; + readonly decode?: boolean; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.externalNetwork; + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; } } diff --git a/apps/cli/src/legacy/commands/link/link.errors.ts b/apps/cli/src/legacy/commands/link/link.errors.ts index c07852d440..e856124f0f 100644 --- a/apps/cli/src/legacy/commands/link/link.errors.ts +++ b/apps/cli/src/legacy/commands/link/link.errors.ts @@ -60,9 +60,12 @@ export class LegacyLinkApiKeysNetworkError extends Data.TaggedError( "LegacyLinkApiKeysNetworkError", )<{ readonly message: string; + readonly decode?: boolean; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.externalNetwork; + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; } } diff --git a/apps/cli/src/legacy/commands/login/login.errors.ts b/apps/cli/src/legacy/commands/login/login.errors.ts index f8fb6e54f1..e964d6bc9f 100644 --- a/apps/cli/src/legacy/commands/login/login.errors.ts +++ b/apps/cli/src/legacy/commands/login/login.errors.ts @@ -41,17 +41,36 @@ export class LegacyLoginMissingTokenError extends Data.TaggedError("LegacyLoginM */ export class LegacyLoginVerificationError extends Data.TaggedError("LegacyLoginVerificationError")<{ readonly message: string; + /** HTTP status of a non-200 poll response, when one was received. */ + readonly statusCode?: number; + /** Set when the poll failed at the transport layer (connection/timeout). */ + readonly network?: boolean; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { return actionability.authLogin; } } -/** All verification retries exhausted (`login.go:214-216`). */ +/** + * All verification retries exhausted (`login.go:214-216`). Carries the LAST + * poll failure's discriminant so classification distinguishes "the user never + * completed the browser flow" (the endpoint keeps returning a pending 4xx, or + * no signal) from a genuine platform problem (5xx / transport). See the Go + * poll protocol: `pollForAccessToken` treats every non-200 as a retryable + * error (`login.go:132-157`, `pkg/fetcher/http.go:102-113`). + */ export class LegacyLoginFailedError extends Data.TaggedError("LegacyLoginFailedError")<{ readonly message: string; + readonly statusCode?: number; + readonly network?: boolean; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + if (this.network === true) { + return { ...actionability.externalNetwork, fingerprint_suffix: "network" }; + } + if (this.statusCode !== undefined && this.statusCode >= 500) { + return { ...actionability.apiStatus, fingerprint_suffix: "api_status" }; + } return actionability.authLogin; } } diff --git a/apps/cli/src/legacy/commands/network-bans/network-bans.errors.ts b/apps/cli/src/legacy/commands/network-bans/network-bans.errors.ts index 1d98722024..55e4dd86f0 100644 --- a/apps/cli/src/legacy/commands/network-bans/network-bans.errors.ts +++ b/apps/cli/src/legacy/commands/network-bans/network-bans.errors.ts @@ -11,9 +11,12 @@ export class LegacyNetworkBansGetNetworkError extends Data.TaggedError( "LegacyNetworkBansGetNetworkError", )<{ readonly message: string; + readonly decode?: boolean; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.externalNetwork; + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; } } @@ -33,9 +36,12 @@ export class LegacyNetworkBansRemoveNetworkError extends Data.TaggedError( "LegacyNetworkBansRemoveNetworkError", )<{ readonly message: string; + readonly decode?: boolean; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.externalNetwork; + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; } } diff --git a/apps/cli/src/legacy/commands/network-restrictions/network-restrictions.errors.ts b/apps/cli/src/legacy/commands/network-restrictions/network-restrictions.errors.ts index 6f619976b8..344bf9aa57 100644 --- a/apps/cli/src/legacy/commands/network-restrictions/network-restrictions.errors.ts +++ b/apps/cli/src/legacy/commands/network-restrictions/network-restrictions.errors.ts @@ -10,9 +10,12 @@ export class LegacyNetworkRestrictionsGetNetworkError extends Data.TaggedError( "LegacyNetworkRestrictionsGetNetworkError", )<{ readonly message: string; + readonly decode?: boolean; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.externalNetwork; + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; } } @@ -32,9 +35,12 @@ export class LegacyNetworkRestrictionsUpdateNetworkError extends Data.TaggedErro "LegacyNetworkRestrictionsUpdateNetworkError", )<{ readonly message: string; + readonly decode?: boolean; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.externalNetwork; + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; } } diff --git a/apps/cli/src/legacy/commands/orgs/orgs.errors.ts b/apps/cli/src/legacy/commands/orgs/orgs.errors.ts index 76863e0025..a175e9c6ba 100644 --- a/apps/cli/src/legacy/commands/orgs/orgs.errors.ts +++ b/apps/cli/src/legacy/commands/orgs/orgs.errors.ts @@ -13,9 +13,12 @@ import { export class LegacyOrgsListNetworkError extends Data.TaggedError("LegacyOrgsListNetworkError")<{ readonly message: string; + readonly decode?: boolean; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.externalNetwork; + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; } } @@ -33,9 +36,12 @@ export class LegacyOrgsListUnexpectedStatusError extends Data.TaggedError( export class LegacyOrgsCreateNetworkError extends Data.TaggedError("LegacyOrgsCreateNetworkError")<{ readonly message: string; + readonly decode?: boolean; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.externalNetwork; + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; } } diff --git a/apps/cli/src/legacy/commands/projects/projects.errors.ts b/apps/cli/src/legacy/commands/projects/projects.errors.ts index d85702bfc3..54b520954d 100644 --- a/apps/cli/src/legacy/commands/projects/projects.errors.ts +++ b/apps/cli/src/legacy/commands/projects/projects.errors.ts @@ -62,9 +62,12 @@ export class LegacyProjectsOrgsListNetworkError extends Data.TaggedError( "LegacyProjectsOrgsListNetworkError", )<{ readonly message: string; + readonly decode?: boolean; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.externalNetwork; + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; } } @@ -84,9 +87,12 @@ export class LegacyProjectsDeleteNetworkError extends Data.TaggedError( "LegacyProjectsDeleteNetworkError", )<{ readonly message: string; + readonly decode?: boolean; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.externalNetwork; + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; } } @@ -117,9 +123,12 @@ export class LegacyProjectsApiKeysNetworkError extends Data.TaggedError( "LegacyProjectsApiKeysNetworkError", )<{ readonly message: string; + readonly decode?: boolean; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.externalNetwork; + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; } } diff --git a/apps/cli/src/legacy/commands/secrets/secrets.errors.ts b/apps/cli/src/legacy/commands/secrets/secrets.errors.ts index 18cf25175e..a75fc5588e 100644 --- a/apps/cli/src/legacy/commands/secrets/secrets.errors.ts +++ b/apps/cli/src/legacy/commands/secrets/secrets.errors.ts @@ -15,9 +15,12 @@ export class LegacySecretsListNetworkError extends Data.TaggedError( "LegacySecretsListNetworkError", )<{ readonly message: string; + readonly decode?: boolean; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.externalNetwork; + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; } } @@ -35,9 +38,12 @@ export class LegacySecretsListUnexpectedStatusError extends Data.TaggedError( export class LegacySecretsSetNetworkError extends Data.TaggedError("LegacySecretsSetNetworkError")<{ readonly message: string; + readonly decode?: boolean; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.externalNetwork; + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; } } @@ -57,9 +63,12 @@ export class LegacySecretsUnsetNetworkError extends Data.TaggedError( "LegacySecretsUnsetNetworkError", )<{ readonly message: string; + readonly decode?: boolean; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.externalNetwork; + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; } } diff --git a/apps/cli/src/legacy/commands/snippets/download/download.handler.ts b/apps/cli/src/legacy/commands/snippets/download/download.handler.ts index 086709b41e..baf35be3e2 100644 --- a/apps/cli/src/legacy/commands/snippets/download/download.handler.ts +++ b/apps/cli/src/legacy/commands/snippets/download/download.handler.ts @@ -110,6 +110,9 @@ export const legacySnippetsDownload = Effect.fn("legacy.snippets.download")(func (cause) => new LegacySnippetsDownloadNetworkError({ message: `failed to download snippet: ${String(cause)}`, + // 200-response body decode failure — an API-response problem, not + // a transport/network failure. + decode: true, }), ), ); diff --git a/apps/cli/src/legacy/commands/snippets/list/list.handler.ts b/apps/cli/src/legacy/commands/snippets/list/list.handler.ts index fbbf5212dd..6764f3a7b3 100644 --- a/apps/cli/src/legacy/commands/snippets/list/list.handler.ts +++ b/apps/cli/src/legacy/commands/snippets/list/list.handler.ts @@ -128,6 +128,9 @@ export const legacySnippetsList = Effect.fn("legacy.snippets.list")(function* ( (cause) => new LegacySnippetsListNetworkError({ message: `failed to list snippets: ${String(cause)}`, + // 200-response body decode failure — an API-response problem, not + // a transport/network failure. + decode: true, }), ), ); diff --git a/apps/cli/src/legacy/commands/snippets/snippets.errors.ts b/apps/cli/src/legacy/commands/snippets/snippets.errors.ts index 43f24601e0..c5cb6a65fc 100644 --- a/apps/cli/src/legacy/commands/snippets/snippets.errors.ts +++ b/apps/cli/src/legacy/commands/snippets/snippets.errors.ts @@ -10,9 +10,12 @@ export class LegacySnippetsListNetworkError extends Data.TaggedError( "LegacySnippetsListNetworkError", )<{ readonly message: string; + readonly decode?: boolean; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.externalNetwork; + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; } } @@ -54,9 +57,12 @@ export class LegacySnippetsDownloadNetworkError extends Data.TaggedError( "LegacySnippetsDownloadNetworkError", )<{ readonly message: string; + readonly decode?: boolean; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.externalNetwork; + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; } } @@ -68,6 +74,11 @@ export class LegacySnippetsDownloadUnexpectedStatusError extends Data.TaggedErro readonly message: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + // A 404 from `GET /v1/snippets/{id}` means the user-supplied snippet id did + // not match any snippet — user input, not an API failure. + if (this.status === 404) { + return { ...actionability.invalidInput, fingerprint_suffix: "not_found" }; + } return statusCodeActionability(this.status); } } diff --git a/apps/cli/src/legacy/commands/ssl-enforcement/ssl-enforcement.errors.ts b/apps/cli/src/legacy/commands/ssl-enforcement/ssl-enforcement.errors.ts index d7044067c6..f8897bc285 100644 --- a/apps/cli/src/legacy/commands/ssl-enforcement/ssl-enforcement.errors.ts +++ b/apps/cli/src/legacy/commands/ssl-enforcement/ssl-enforcement.errors.ts @@ -10,9 +10,12 @@ export class LegacySslEnforcementGetNetworkError extends Data.TaggedError( "LegacySslEnforcementGetNetworkError", )<{ readonly message: string; + readonly decode?: boolean; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.externalNetwork; + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; } } @@ -32,9 +35,12 @@ export class LegacySslEnforcementUpdateNetworkError extends Data.TaggedError( "LegacySslEnforcementUpdateNetworkError", )<{ readonly message: string; + readonly decode?: boolean; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.externalNetwork; + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; } } diff --git a/apps/cli/src/legacy/commands/sso/sso.errors.ts b/apps/cli/src/legacy/commands/sso/sso.errors.ts index 656a8b4b61..d4d6a64d13 100644 --- a/apps/cli/src/legacy/commands/sso/sso.errors.ts +++ b/apps/cli/src/legacy/commands/sso/sso.errors.ts @@ -41,9 +41,12 @@ export class LegacySsoInvalidUuidError extends Data.TaggedError("LegacySsoInvali // `sso list` export class LegacySsoListNetworkError extends Data.TaggedError("LegacySsoListNetworkError")<{ readonly message: string; + readonly decode?: boolean; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.externalNetwork; + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; } } @@ -166,9 +169,12 @@ export class LegacySsoMetadataUrlNonUtf8Error extends Data.TaggedError( // `sso show` export class LegacySsoShowNetworkError extends Data.TaggedError("LegacySsoShowNetworkError")<{ readonly message: string; + readonly decode?: boolean; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.externalNetwork; + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; } } @@ -205,9 +211,12 @@ export class LegacySsoShowEnvNotSupportedError extends Data.TaggedError( // `sso update` export class LegacySsoUpdateNetworkError extends Data.TaggedError("LegacySsoUpdateNetworkError")<{ readonly message: string; + readonly decode?: boolean; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.externalNetwork; + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; } } @@ -256,9 +265,12 @@ export class LegacySsoUpdateAttributeMappingFileError extends Data.TaggedError( // `sso remove` export class LegacySsoRemoveNetworkError extends Data.TaggedError("LegacySsoRemoveNetworkError")<{ readonly message: string; + readonly decode?: boolean; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.externalNetwork; + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; } } diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/vanity-subdomains.errors.ts b/apps/cli/src/legacy/commands/vanity-subdomains/vanity-subdomains.errors.ts index 8b54285d7e..9285c24018 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/vanity-subdomains.errors.ts +++ b/apps/cli/src/legacy/commands/vanity-subdomains/vanity-subdomains.errors.ts @@ -10,9 +10,12 @@ export class LegacyVanitySubdomainsGetNetworkError extends Data.TaggedError( "LegacyVanitySubdomainsGetNetworkError", )<{ readonly message: string; + readonly decode?: boolean; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.externalNetwork; + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; } } @@ -32,9 +35,12 @@ export class LegacyVanitySubdomainsCheckNetworkError extends Data.TaggedError( "LegacyVanitySubdomainsCheckNetworkError", )<{ readonly message: string; + readonly decode?: boolean; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.externalNetwork; + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; } } @@ -55,9 +61,12 @@ export class LegacyVanitySubdomainsActivateNetworkError extends Data.TaggedError "LegacyVanitySubdomainsActivateNetworkError", )<{ readonly message: string; + readonly decode?: boolean; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.externalNetwork; + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; } } @@ -78,9 +87,12 @@ export class LegacyVanitySubdomainsDeleteNetworkError extends Data.TaggedError( "LegacyVanitySubdomainsDeleteNetworkError", )<{ readonly message: string; + readonly decode?: boolean; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.externalNetwork; + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; } } diff --git a/apps/cli/src/legacy/shared/legacy-db-config.errors.ts b/apps/cli/src/legacy/shared/legacy-db-config.errors.ts index f6ece4c7fa..93979c11ae 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.errors.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.errors.ts @@ -39,9 +39,11 @@ export class LegacyDbConfigLoadError extends Data.TaggedError("LegacyDbConfigLoa /** Transport failure creating a temporary login role (`V1CreateLoginRole`). */ export class LegacyDbConfigLoginRoleNetworkError extends Data.TaggedError( "LegacyDbConfigLoginRoleNetworkError", -)<{ readonly message: string }> { +)<{ readonly message: string; readonly decode?: boolean }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.externalNetwork; + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; } } @@ -61,9 +63,11 @@ export class LegacyDbConfigLoginRoleStatusError extends Data.TaggedError( /** Transport failure listing network bans (`V1ListAllNetworkBans`). */ export class LegacyDbConfigListBansNetworkError extends Data.TaggedError( "LegacyDbConfigListBansNetworkError", -)<{ readonly message: string }> { +)<{ readonly message: string; readonly decode?: boolean }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.externalNetwork; + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; } } @@ -83,9 +87,11 @@ export class LegacyDbConfigListBansStatusError extends Data.TaggedError( /** Transport failure removing network bans (`V1DeleteNetworkBans`). */ export class LegacyDbConfigUnbanNetworkError extends Data.TaggedError( "LegacyDbConfigUnbanNetworkError", -)<{ readonly message: string }> { +)<{ readonly message: string; readonly decode?: boolean }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.externalNetwork; + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; } } diff --git a/apps/cli/src/legacy/shared/legacy-ensure-login.ts b/apps/cli/src/legacy/shared/legacy-ensure-login.ts index e94aa87a20..999a3d0767 100644 --- a/apps/cli/src/legacy/shared/legacy-ensure-login.ts +++ b/apps/cli/src/legacy/shared/legacy-ensure-login.ts @@ -137,7 +137,13 @@ export const legacyBrowserLogin = Effect.fnUntraced(function* (opts: LegacyBrows Effect.gen(function* () { const failures = failuresSoFar + 1; if (failures > MAX_LOGIN_RETRIES) { - return yield* Effect.fail(new LegacyLoginFailedError({ message: err.message })); + return yield* Effect.fail( + new LegacyLoginFailedError({ + message: err.message, + statusCode: err.statusCode, + network: err.network, + }), + ); } yield* output.raw(`${err.message}\nRetry (${failures}/${MAX_LOGIN_RETRIES}): `, "stderr"); return yield* verifyWithRetries(failures); diff --git a/apps/cli/src/legacy/shared/legacy-http-errors.ts b/apps/cli/src/legacy/shared/legacy-http-errors.ts index 7639e83f99..760cd3cef6 100644 --- a/apps/cli/src/legacy/shared/legacy-http-errors.ts +++ b/apps/cli/src/legacy/shared/legacy-http-errors.ts @@ -47,7 +47,10 @@ function sanitizeErrorBody(input: string): string { return out; } -export type NetworkErrorFactory = new (args: { readonly message: string }) => E; +export type NetworkErrorFactory = new (args: { + readonly message: string; + readonly decode?: boolean; +}) => E; export type StatusErrorFactory = new (args: { readonly status: number; @@ -92,9 +95,12 @@ export function mapLegacyHttpError(opts: { new opts.networkError({ message: opts.networkMessage(description) }), ); } - // SchemaError or HttpBodyError — treat as transport-level network error. + // SchemaError or HttpBodyError — the server returned a response whose + // body failed schema decoding (a 200 the generated client could not + // parse). This is not a transport failure, so flag `decode` to classify + // it as an API-response problem rather than a network problem. return yield* Effect.fail( - new opts.networkError({ message: opts.networkMessage(String(cause)) }), + new opts.networkError({ message: opts.networkMessage(String(cause)), decode: true }), ); }); } diff --git a/apps/cli/src/legacy/shared/legacy-login-api.layer.ts b/apps/cli/src/legacy/shared/legacy-login-api.layer.ts index d40f47524b..3113c4c506 100644 --- a/apps/cli/src/legacy/shared/legacy-login-api.layer.ts +++ b/apps/cli/src/legacy/shared/legacy-login-api.layer.ts @@ -38,6 +38,7 @@ export const legacyLoginApiLayer = Layer.effect( return yield* Effect.fail( new LegacyLoginVerificationError({ message: `Error status ${response.status}: ${body}`, + statusCode: response.status, }), ); } @@ -56,6 +57,7 @@ export const legacyLoginApiLayer = Layer.effect( Effect.fail( new LegacyLoginVerificationError({ message: `failed to execute http request: ${cause.message}`, + network: true, }), ), ), @@ -65,6 +67,7 @@ export const legacyLoginApiLayer = Layer.effect( Effect.fail( new LegacyLoginVerificationError({ message: "failed to execute http request: request timed out", + network: true, }), ), }), diff --git a/apps/cli/src/legacy/shared/legacy-storage-credentials.errors.ts b/apps/cli/src/legacy/shared/legacy-storage-credentials.errors.ts index 62b634530c..563e352ccf 100644 --- a/apps/cli/src/legacy/shared/legacy-storage-credentials.errors.ts +++ b/apps/cli/src/legacy/shared/legacy-storage-credentials.errors.ts @@ -45,9 +45,12 @@ export class LegacyStorageApiKeysNetworkError extends Data.TaggedError( "LegacyStorageApiKeysNetworkError", )<{ readonly message: string; + readonly decode?: boolean; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.externalNetwork; + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; } } diff --git a/apps/cli/src/next/auth/api.layer.ts b/apps/cli/src/next/auth/api.layer.ts index a526ab408a..fc2eb1627f 100644 --- a/apps/cli/src/next/auth/api.layer.ts +++ b/apps/cli/src/next/auth/api.layer.ts @@ -10,18 +10,42 @@ import { import { ApiError } from "./errors.ts"; import { Api, type LoginSessionResponse } from "./api.service.ts"; -function mapHttpClientError( - error: HttpClientError.HttpClientError, -): Effect.Effect { - if (error.response !== undefined) { - return Effect.fail( - new ApiError({ - statusCode: error.response.status, - detail: `${error.response.status} ${error.message}`, - }), - ); +// HttpClientError reasons that mean the response arrived but its body could not +// be decoded (including a 2xx whose body isn't valid JSON). These are API +// response problems, not transport ones, so they classify by `decode` rather +// than a status code. +const BODY_DECODE_REASONS = new Set(["DecodeError", "EmptyBodyError"]); + +/** + * Maps any fetcher failure to an {@link ApiError}, preserving the classification + * signal: + * - a received status → `statusCode` (transport error → status-less, classified + * as network); + * - a body/schema decode failure — whether an `HttpClientError` decode reason or + * a non-`HttpClientError` thrown while decoding — → `decode: true`, classified + * as an API response problem instead of network. + */ +function mapToApiError(error: unknown): Effect.Effect { + if (HttpClientError.isHttpClientError(error)) { + if (BODY_DECODE_REASONS.has(error.reason._tag)) { + return Effect.fail(new ApiError({ detail: error.message, decode: true })); + } + if (error.response !== undefined) { + return Effect.fail( + new ApiError({ + statusCode: error.response.status, + detail: `${error.response.status} ${error.message}`, + }), + ); + } + return Effect.fail(new ApiError({ detail: error.message })); } - return Effect.fail(new ApiError({ detail: error.message })); + return Effect.fail( + new ApiError({ + detail: error instanceof Error ? error.message : String(error), + decode: true, + }), + ); } export const makeApi = Effect.gen(function* () { @@ -36,7 +60,7 @@ export const makeApi = Effect.gen(function* () { const response = yield* httpClient.execute(HttpClientRequest.get(url)); return (yield* response.json) as LoginSessionResponse; }, - (effect) => effect.pipe(Effect.catch(mapHttpClientError)), + (effect) => effect.pipe(Effect.catch(mapToApiError)), ), fetchProfile: Effect.fnUntraced( function* (apiUrl, accessToken) { @@ -47,20 +71,7 @@ export const makeApi = Effect.gen(function* () { }).pipe(Effect.provide(httpClientLayer)); return yield* api.v1.getProfile(); }, - (effect) => - effect.pipe( - Effect.catch((error) => { - if (HttpClientError.isHttpClientError(error)) { - return mapHttpClientError(error); - } - return Effect.fail( - new ApiError({ - detail: error instanceof Error ? error.message : String(error), - decode: true, - }), - ); - }), - ), + (effect) => effect.pipe(Effect.catch(mapToApiError)), ), }); }); diff --git a/apps/cli/src/next/auth/errors.ts b/apps/cli/src/next/auth/errors.ts index 5af7c2ba26..34625dac5c 100644 --- a/apps/cli/src/next/auth/errors.ts +++ b/apps/cli/src/next/auth/errors.ts @@ -6,20 +6,22 @@ import { statusCodeActionability, } from "../../shared/telemetry/error-actionability.ts"; -function CliError(tag: Tag) { - return class extends Data.TaggedError(tag)<{ - readonly detail: string; - readonly suggestion: string; - }> { - override get message() { - return `${this.detail}\n Suggestion: ${this.suggestion}`; - } - }; -} - -export class InvalidTokenError extends CliError("InvalidTokenError") { +export class InvalidTokenError extends Data.TaggedError("InvalidTokenError")<{ + readonly detail: string; + readonly suggestion: string; + /** + * Where the malformed token came from. Direct-input tokens (`--token` flag, + * `SUPABASE_ACCESS_TOKEN`, piped stdin) cannot be fixed by `supabase login`, + * so their remediation is to correct that input. A token from the browser + * flow (no source) is fixable by logging in again. + */ + readonly source?: "env" | "flag" | "stdin"; +}> { + override get message() { + return `${this.detail}\n Suggestion: ${this.suggestion}`; + } get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.authLogin; + return this.source === undefined ? actionability.authLogin : actionability.authToken; } } diff --git a/apps/cli/src/next/auth/token.ts b/apps/cli/src/next/auth/token.ts index 631a8aef08..00f959149d 100644 --- a/apps/cli/src/next/auth/token.ts +++ b/apps/cli/src/next/auth/token.ts @@ -3,11 +3,15 @@ import { InvalidTokenError } from "./errors.ts"; const TOKEN_PATTERN = /^sbp_(oauth_)?[a-f0-9]{40}$/; -export const validateToken = Effect.fnUntraced(function* (token: string) { +export const validateToken = Effect.fnUntraced(function* ( + token: string, + source?: "env" | "flag" | "stdin", +) { if (!TOKEN_PATTERN.test(token)) { return yield* new InvalidTokenError({ detail: "Invalid access token format", suggestion: "Generate a token at https://supabase.com/dashboard/account/tokens", + source, }); } }); diff --git a/apps/cli/src/next/commands/login/login.errors.ts b/apps/cli/src/next/commands/login/login.errors.ts index 0c09c95e69..3c58f5708c 100644 --- a/apps/cli/src/next/commands/login/login.errors.ts +++ b/apps/cli/src/next/commands/login/login.errors.ts @@ -21,8 +21,28 @@ export class NoTtyError extends LoginError("NoTtyError") { return actionability.authToken; } } -export class LoginFailedError extends LoginError("LoginFailedError") { +/** + * All browser-login verification retries exhausted. Carries the LAST poll + * failure's discriminant so classification distinguishes "the user never + * completed the browser flow" (a pending 4xx, or no signal) from a genuine + * platform problem (5xx / transport). + */ +export class LoginFailedError extends Data.TaggedError("LoginFailedError")<{ + readonly detail: string; + readonly suggestion: string; + readonly statusCode?: number; + readonly network?: boolean; +}> { + override get message() { + return `${this.detail}\n Suggestion: ${this.suggestion}`; + } get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + if (this.network === true) { + return { ...actionability.externalNetwork, fingerprint_suffix: "network" }; + } + if (this.statusCode !== undefined && this.statusCode >= 500) { + return { ...actionability.apiStatus, fingerprint_suffix: "api_status" }; + } return actionability.authLogin; } } diff --git a/apps/cli/src/next/commands/login/login.handler.ts b/apps/cli/src/next/commands/login/login.handler.ts index 15f270b8ea..c6deff6bb1 100644 --- a/apps/cli/src/next/commands/login/login.handler.ts +++ b/apps/cli/src/next/commands/login/login.handler.ts @@ -98,34 +98,48 @@ const captureLoginCompleted = Effect.fnUntraced(function* ( ); }); -const saveDirectToken = Effect.fnUntraced(function* (token: Redacted.Redacted) { +const saveDirectToken = Effect.fnUntraced(function* ( + token: Redacted.Redacted, + source: "env" | "flag" | "stdin", +) { const credentials = yield* Credentials; const output = yield* Output; - yield* validateToken(revealToken(token)); + yield* validateToken(revealToken(token), source); yield* credentials.saveAccessToken(token); const distinctId = yield* resolveAuthenticatedDistinctId(token); yield* output.success("Logged in successfully.", { command: "login" }); yield* captureLoginCompleted({ login_method: "token" }, distinctId); }); +interface ResolvedToken { + readonly token: Redacted.Redacted; + readonly source: "env" | "flag" | "stdin"; +} + // Token resolution priority: --token flag > SUPABASE_ACCESS_TOKEN env > piped stdin > interactive browser flow const resolveToken = Effect.fnUntraced(function* (tokenFlag: Option.Option) { - if (Option.isSome(tokenFlag)) return Option.some(Redacted.make(tokenFlag.value)); + if (Option.isSome(tokenFlag)) { + return Option.some({ token: Redacted.make(tokenFlag.value), source: "flag" }); + } const cliConfig = yield* CliConfig; - if (Option.isSome(cliConfig.accessToken)) return cliConfig.accessToken; + if (Option.isSome(cliConfig.accessToken)) { + return Option.some({ token: cliConfig.accessToken.value, source: "env" }); + } const stdin = yield* Stdin; if (!stdin.isTTY) { const piped = yield* stdin.readPipedText; - if (Option.isSome(piped)) return Option.some(Redacted.make(piped.value)); + if (Option.isSome(piped)) { + return Option.some({ token: Redacted.make(piped.value), source: "stdin" }); + } return yield* new NoTtyError({ detail: "Cannot prompt for token in non-interactive mode", suggestion: "Pass --token or set SUPABASE_ACCESS_TOKEN", }); } - return Option.none(); + return Option.none(); }); // --------------------------------------------------------------------------- @@ -196,14 +210,22 @@ const browserOAuthFlow = Effect.fnUntraced(function* (flags: LoginFlags) { remainingRetries: number, ): Effect.Effect => verifyCode.pipe( - Effect.catchTag("LoginVerificationError", () => + Effect.catchTag("LoginVerificationError", (err) => Effect.gen(function* () { yield* output.error("Verification failed"); if (remainingRetries <= 0) { + // Thread the last poll failure's discriminant: a received status + // classifies by code (5xx → platform outage), a status-less/decode + // failure means transport (network) unless it carried a decoded + // body, and a pending 4xx / no signal stays "run supabase login". + const { statusCode, decode } = err.cause; + const network = statusCode === undefined && decode !== true; return yield* Effect.fail( new LoginFailedError({ detail: "Login failed after maximum retries", suggestion: "Try running `supabase login` again", + statusCode, + network, }), ); } @@ -248,7 +270,7 @@ export const login = Effect.fnUntraced(function* (flags: LoginFlags) { const resolved = yield* resolveToken(flags.token); if (Option.isSome(resolved)) { - return yield* saveDirectToken(resolved.value); + return yield* saveDirectToken(resolved.value.token, resolved.value.source); } return yield* browserOAuthFlow(flags); }); diff --git a/apps/cli/src/shared/telemetry/error-actionability.ts b/apps/cli/src/shared/telemetry/error-actionability.ts index 830ae04522..3d07fe4eb3 100644 --- a/apps/cli/src/shared/telemetry/error-actionability.ts +++ b/apps/cli/src/shared/telemetry/error-actionability.ts @@ -109,9 +109,14 @@ export interface CliErrorActionabilityDeclaration { * Whether this failure class has a canonical remediation. This is the * taxonomy-level claim, not a guarantee that the CLI rendered a * `Suggestion:` line for a given instance — some errors convey the fix in - * the message itself. When CLI-1561 wires capture, the emitted - * `has_suggestion` should be reconciled with what the output layer actually - * rendered so telemetry can never disagree with what the user saw. + * the message itself. Instance-level rendered suggestions (a non-empty + * `suggestion` field on the error, the same field `normalize-error.ts` + * renders as the `Suggestion:` line) are already reconciled with this + * declaration at classify time in `toActionability`: a false declaration + * flips to true when the instance carries one, but a true declaration is + * never flipped to false. Capture-time reconciliation remains necessary + * only for errors that convey remediation in the message text itself, + * which classify-time has no way to see. */ readonly has_suggestion: boolean; readonly suggestion_type: CliSuggestionType; @@ -388,14 +393,26 @@ function fingerprint(prefix: string, identifier: string | undefined, suffix?: st return suffix === undefined ? base : `${base}:${suffix}`; } +/** + * Whether the classified error instance carries a non-empty `suggestion` + * field — the same field `shared/output/normalize-error.ts` renders as the + * `Suggestion:` line. Used to reconcile a declaration's `has_suggestion` + * with what the user actually saw, without ever downgrading a declared true. + */ +function hasInstanceSuggestion(error: unknown): boolean { + return isErrorRecord(error) && readString(error, "suggestion") !== undefined; +} + function toActionability( declaration: CliErrorActionabilityDeclaration, fingerprintPrefix: string, identifier: string | undefined, + error?: unknown, ): CliErrorActionability { const { fingerprint_suffix, ...rest } = declaration; return { ...rest, + has_suggestion: rest.has_suggestion || hasInstanceSuggestion(error), error_fingerprint: fingerprint(fingerprintPrefix, identifier, fingerprint_suffix), }; } @@ -509,7 +526,15 @@ const externalActionabilityByTag: Record< StackAlreadyRunningError: () => actionability.stopStack, DaemonStartError: () => actionability.startStack, DaemonStillRunningError: () => actionability.stopStack, - UnixHttpClientError: () => actionability.stopStack, + // A `/start` RPC failure means the daemon never came up; every other path + // (status/logs/stop/dispose) means a running-but-broken daemon. + UnixHttpClientError: (error) => { + const path = readString(error, "path"); + if (path !== undefined && path.startsWith("/start")) { + return { ...actionability.startStack, fingerprint_suffix: "daemon_start" }; + } + return actionability.stopStack; + }, // @supabase/process-compose — the CLI generates the process graph, so graph // invariants are internal bugs; runtime service failures are stack-state @@ -549,7 +574,7 @@ function classifyShowHelp(error: ErrorRecord, depth: number): CliErrorActionabil const errors = error["errors"]; if (!Array.isArray(errors)) return undefined; if (errors.length === 1) return classifyAtDepth(errors[0], depth + 1); - return toActionability(actionability.invalidInput, "tag", "ShowHelp"); + return toActionability(actionability.invalidInput, "tag", "ShowHelp", error); } function isNativeJsExceptionName(name: string | undefined): boolean { @@ -578,13 +603,13 @@ export function classifyCliErrorActionability(error: unknown): CliErrorActionabi function classifyAtDepth(error: unknown, depth: number): CliErrorActionability { if (depth >= MAX_CAUSE_DEPTH) { - return toActionability(actionability.unknown, "error", "CauseChainLimit"); + return toActionability(actionability.unknown, "error", "CauseChainLimit", error); } const tag = readErrorTag(error); const declared = readDeclaration(error); if (declared !== undefined) { - return toActionability(declared, "tag", tag ?? readErrorName(error)); + return toActionability(declared, "tag", tag ?? readErrorName(error), error); } if (tag === "ShowHelp" && isErrorRecord(error)) { @@ -632,10 +657,10 @@ function classifyAtDepth(error: unknown, depth: number): CliErrorActionability { if (Object.hasOwn(externalActionabilityByTag, tag)) { const external = externalActionabilityByTag[tag]; if (external !== undefined) { - return toActionability(external(error), "tag", tag); + return toActionability(external(error), "tag", tag, error); } } - return toActionability(actionability.unknown, "tag", tag); + return toActionability(actionability.unknown, "tag", tag, error); } if (isErrorRecord(error) && readErrorName(error) === "StackError") { @@ -648,7 +673,7 @@ function classifyAtDepth(error: unknown, depth: number): CliErrorActionability { } const classify = externalActionabilityByTag["StackError"]; if (classify !== undefined) { - return toActionability(classify(error), "error", "StackError"); + return toActionability(classify(error), "error", "StackError", error); } } @@ -658,10 +683,10 @@ function classifyAtDepth(error: unknown, depth: number): CliErrorActionability { const name = readErrorName(error); if (isNativeJsExceptionName(name)) { - return toActionability(actionability.internalPanic, "error", name); + return toActionability(actionability.internalPanic, "error", name, error); } - return toActionability(actionability.unknown, "error", name); + return toActionability(actionability.unknown, "error", name, error); } export function classifyCliCauseActionability(cause: Cause.Cause): CliErrorActionability { diff --git a/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts b/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts index 2f43019cc1..1fd966e1ed 100644 --- a/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts +++ b/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts @@ -31,6 +31,15 @@ class UndeclaredError extends Data.TaggedError("UndeclaredError")<{ readonly message: string; }> {} +class DeclaredNoSuggestionError extends Data.TaggedError("DeclaredNoSuggestionError")<{ + readonly message: string; + readonly suggestion?: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} + describe("classifyCliErrorActionability", () => { it("uses the declaration co-located on the error class", () => { expect( @@ -212,6 +221,26 @@ describe("classifyCliErrorActionability", () => { expect(opaque.error_category).toBe("network"); }); + it("splits daemon start failures from other daemon RPC failures", () => { + const start = classifyCliErrorActionability({ + _tag: "UnixHttpClientError", + socketPath: "/tmp/daemon.sock", + path: "/start", + }); + expect(start.error_category).toBe("invalid_config"); + expect(start.suggested_command).toBe("supabase start"); + expect(start.error_fingerprint).toBe("tag:UnixHttpClientError:daemon_start"); + + const status = classifyCliErrorActionability({ + _tag: "UnixHttpClientError", + socketPath: "/tmp/daemon.sock", + path: "/status", + }); + expect(status.error_category).toBe("invalid_config"); + expect(status.suggested_command).toBe("supabase stop"); + expect(status.error_fingerprint).toBe("tag:UnixHttpClientError"); + }); + it("classifies API client configuration failures as token problems", () => { const result = classifyCliErrorActionability({ _tag: "SupabaseApiConfigError", @@ -286,6 +315,23 @@ describe("classifyCliErrorActionability", () => { expect(result.error_fingerprint).toBe("error:TypeError"); }); + it("reconciles has_suggestion with an instance-level rendered suggestion", () => { + const withSuggestion = classifyCliErrorActionability( + new DeclaredNoSuggestionError({ message: "bad row", suggestion: "do X" }), + ); + expect(withSuggestion.has_suggestion).toBe(true); + + const withoutSuggestion = classifyCliErrorActionability( + new DeclaredNoSuggestionError({ message: "bad row" }), + ); + expect(withoutSuggestion.has_suggestion).toBe(false); + + // A declaration-level true is never downgraded, even with no instance + // suggestion field. + const declaredTrue = classifyCliErrorActionability(new DeclaredError({ message: "x" })); + expect(declaredTrue.has_suggestion).toBe(true); + }); + it("never leaks raw text into fingerprints for unknown failures", () => { expect(classifyCliErrorActionability("raw failure text").error_fingerprint).toBe( "string:unknown", From 4e888b324db339195785e067ae17caa2d467ec22 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 9 Jul 2026 13:02:52 +0200 Subject: [PATCH 13/20] chore(cli): finish decode, 404, and login-source classification stragglers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Login polling in both shells threads a decode discriminant through the retry loop: a persistent 200-with-malformed-JSON classifies as api_response instead of auth (next) or network (legacy). - Branch operation 404s (pause/unpause/delete, and update/create when not plan-gated) classify as invalid_input/not_found — ref-shaped inputs bypass the lookup, so the operation endpoint sees user-supplied refs. - LegacyLoginSaveTokenError classifies authToken: its only construction path is a provided token (--token/env/stdin), which supabase login cannot fix. - Direct decode catch sites in functions list and link pass decode (link discriminates transport from generated-client decode causes). - LegacySsoMetadataUrlNetworkError classifies provideFlags: all five sites preflight the user-supplied --metadata-url, a third-party URL whose failures do not belong in the Supabase external-service bucket. - FunctionsApiStatusError treats 404 as invalid_input/not_found — every construction site is keyed by user-supplied slugs or refs. Co-Authored-By: Claude Fable 5 --- .../commands/branches/branches.errors.ts | 32 +++++++ .../branches/branches.errors.unit.test.ts | 91 +++++++++++++++++++ .../commands/functions/list/list.handler.ts | 1 + .../src/legacy/commands/link/link.errors.ts | 12 ++- .../commands/link/link.errors.unit.test.ts | 21 +++++ .../src/legacy/commands/link/link.handler.ts | 4 + .../src/legacy/commands/login/login.errors.ts | 24 ++++- .../commands/login/login.errors.unit.test.ts | 58 ++++++++++++ .../cli/src/legacy/commands/sso/sso.errors.ts | 6 +- .../commands/sso/sso.errors.unit.test.ts | 14 +++ .../src/legacy/shared/legacy-ensure-login.ts | 1 + .../legacy/shared/legacy-login-api.layer.ts | 23 ++++- .../src/next/commands/login/login.errors.ts | 9 ++ .../commands/login/login.errors.unit.test.ts | 42 +++++++++ .../src/next/commands/login/login.handler.ts | 1 + .../shared/functions/functions-api.errors.ts | 6 ++ .../functions-api.errors.unit.test.ts | 28 ++++++ 17 files changed, 363 insertions(+), 10 deletions(-) create mode 100644 apps/cli/src/legacy/commands/branches/branches.errors.unit.test.ts create mode 100644 apps/cli/src/legacy/commands/link/link.errors.unit.test.ts create mode 100644 apps/cli/src/legacy/commands/login/login.errors.unit.test.ts create mode 100644 apps/cli/src/legacy/commands/sso/sso.errors.unit.test.ts create mode 100644 apps/cli/src/next/commands/login/login.errors.unit.test.ts create mode 100644 apps/cli/src/shared/functions/functions-api.errors.unit.test.ts diff --git a/apps/cli/src/legacy/commands/branches/branches.errors.ts b/apps/cli/src/legacy/commands/branches/branches.errors.ts index 5402ac34b4..92361dcef3 100644 --- a/apps/cli/src/legacy/commands/branches/branches.errors.ts +++ b/apps/cli/src/legacy/commands/branches/branches.errors.ts @@ -60,6 +60,13 @@ export class LegacyBranchesCreateUnexpectedStatusError extends Data.TaggedError( readonly upgradeSuggested?: boolean; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + // A 404 means the user-supplied project ref (resolved from `--project-ref` + // / env / linked-project file) did not match a project — user input, not + // an API failure. Guard against shadowing a confirmed plan gate, since a + // gated 4xx is a plan limit rather than a bad ref. + if (this.upgradeSuggested !== true && this.status === 404) { + return { ...actionability.invalidInput, fingerprint_suffix: "not_found" }; + } return statusCodeActionability(this.status, { upgradeSuggested: this.upgradeSuggested }); } } @@ -211,6 +218,13 @@ export class LegacyBranchesUpdateUnexpectedStatusError extends Data.TaggedError( readonly upgradeSuggested?: boolean; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + // A 404 means the user-supplied branch id/ref (or ref-shaped input, which + // bypasses the resolver lookup) did not match — user input, not an API + // failure. Guard against shadowing a confirmed plan gate, since a gated + // 4xx is a plan limit rather than a bad ref. + if (this.upgradeSuggested !== true && this.status === 404) { + return { ...actionability.invalidInput, fingerprint_suffix: "not_found" }; + } return statusCodeActionability(this.status, { upgradeSuggested: this.upgradeSuggested }); } } @@ -236,6 +250,12 @@ export class LegacyBranchesPauseUnexpectedStatusError extends Data.TaggedError( readonly message: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + // A 404 means the user-supplied branch id/ref (or ref-shaped input, which + // bypasses the resolver lookup) did not match — user input, not an API + // failure. + if (this.status === 404) { + return { ...actionability.invalidInput, fingerprint_suffix: "not_found" }; + } return statusCodeActionability(this.status); } } @@ -261,6 +281,12 @@ export class LegacyBranchesUnpauseUnexpectedStatusError extends Data.TaggedError readonly message: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + // A 404 means the user-supplied branch id/ref (or ref-shaped input, which + // bypasses the resolver lookup) did not match — user input, not an API + // failure. + if (this.status === 404) { + return { ...actionability.invalidInput, fingerprint_suffix: "not_found" }; + } return statusCodeActionability(this.status); } } @@ -286,6 +312,12 @@ export class LegacyBranchesDeleteUnexpectedStatusError extends Data.TaggedError( readonly message: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + // A 404 means the user-supplied branch id/ref (or ref-shaped input, which + // bypasses the resolver lookup) did not match — user input, not an API + // failure. + if (this.status === 404) { + return { ...actionability.invalidInput, fingerprint_suffix: "not_found" }; + } return statusCodeActionability(this.status); } } diff --git a/apps/cli/src/legacy/commands/branches/branches.errors.unit.test.ts b/apps/cli/src/legacy/commands/branches/branches.errors.unit.test.ts new file mode 100644 index 0000000000..96f2bcd19a --- /dev/null +++ b/apps/cli/src/legacy/commands/branches/branches.errors.unit.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; +import { classifyCliErrorActionability } from "../../../shared/telemetry/error-actionability.ts"; +import { + LegacyBranchesCreateUnexpectedStatusError, + LegacyBranchesDeleteUnexpectedStatusError, + LegacyBranchesPauseUnexpectedStatusError, + LegacyBranchesUnpauseUnexpectedStatusError, + LegacyBranchesUpdateUnexpectedStatusError, +} from "./branches.errors.ts"; + +const body = { body: "not found", message: "boom" }; + +describe("branch operation 404s classify as invalid input", () => { + it("pause 404 → invalid input", () => { + const result = classifyCliErrorActionability( + new LegacyBranchesPauseUnexpectedStatusError({ status: 404, ...body }), + ); + expect(result.error_kind).toBe("user_actionable"); + expect(result.error_category).toBe("invalid_input"); + expect(result.error_fingerprint).toBe("tag:LegacyBranchesPauseUnexpectedStatusError:not_found"); + }); + + it("unpause 404 → invalid input", () => { + const result = classifyCliErrorActionability( + new LegacyBranchesUnpauseUnexpectedStatusError({ status: 404, ...body }), + ); + expect(result.error_category).toBe("invalid_input"); + expect(result.error_fingerprint).toBe( + "tag:LegacyBranchesUnpauseUnexpectedStatusError:not_found", + ); + }); + + it("delete 404 → invalid input", () => { + const result = classifyCliErrorActionability( + new LegacyBranchesDeleteUnexpectedStatusError({ status: 404, ...body }), + ); + expect(result.error_category).toBe("invalid_input"); + expect(result.error_fingerprint).toBe( + "tag:LegacyBranchesDeleteUnexpectedStatusError:not_found", + ); + }); + + it("a non-404 status stays on the status policy", () => { + const result = classifyCliErrorActionability( + new LegacyBranchesPauseUnexpectedStatusError({ status: 500, ...body }), + ); + expect(result.error_category).toBe("api_status"); + }); +}); + +describe("gated branch operation 404s", () => { + it("update 404 without an upgrade gate → invalid input", () => { + const result = classifyCliErrorActionability( + new LegacyBranchesUpdateUnexpectedStatusError({ status: 404, ...body }), + ); + expect(result.error_category).toBe("invalid_input"); + expect(result.suggestion_type).toBe("none"); + }); + + it("create 404 without an upgrade gate → invalid input", () => { + const result = classifyCliErrorActionability( + new LegacyBranchesCreateUnexpectedStatusError({ status: 404, ...body }), + ); + expect(result.error_category).toBe("invalid_input"); + expect(result.suggestion_type).toBe("none"); + }); + + it("update: a confirmed plan gate is not shadowed by the 404 branch", () => { + const result = classifyCliErrorActionability( + new LegacyBranchesUpdateUnexpectedStatusError({ + status: 404, + ...body, + upgradeSuggested: true, + }), + ); + expect(result.error_category).toBe("plan_limit"); + expect(result.suggestion_type).toBe("upgrade_plan"); + }); + + it("create: a confirmed plan gate is not shadowed by the 404 branch", () => { + const result = classifyCliErrorActionability( + new LegacyBranchesCreateUnexpectedStatusError({ + status: 404, + ...body, + upgradeSuggested: true, + }), + ); + expect(result.error_category).toBe("plan_limit"); + expect(result.suggestion_type).toBe("upgrade_plan"); + }); +}); diff --git a/apps/cli/src/legacy/commands/functions/list/list.handler.ts b/apps/cli/src/legacy/commands/functions/list/list.handler.ts index 62aaa0b851..dd6399550a 100644 --- a/apps/cli/src/legacy/commands/functions/list/list.handler.ts +++ b/apps/cli/src/legacy/commands/functions/list/list.handler.ts @@ -88,6 +88,7 @@ export const legacyFunctionsList = Effect.fn("legacy.functions.list")(function* yield* fetching?.fail() ?? Effect.void; return yield* new LegacyFunctionsListNetworkError({ message: decodedFunctions.message, + decode: true, }); } yield* fetching?.clear() ?? Effect.void; diff --git a/apps/cli/src/legacy/commands/link/link.errors.ts b/apps/cli/src/legacy/commands/link/link.errors.ts index e856124f0f..4a93b07046 100644 --- a/apps/cli/src/legacy/commands/link/link.errors.ts +++ b/apps/cli/src/legacy/commands/link/link.errors.ts @@ -9,14 +9,22 @@ import { statusCodeActionability, } from "../../../shared/telemetry/error-actionability.ts"; -/** Transport failure while fetching `GET /v1/projects/{ref}`. */ +/** Transport (or body-decode) failure while fetching `GET /v1/projects/{ref}`. */ export class LegacyLinkProjectStatusNetworkError extends Data.TaggedError( "LegacyLinkProjectStatusNetworkError", )<{ readonly message: string; + /** + * Set when the failure was the generated client rejecting the response body + * (`SchemaError` / `HttpBodyError`) rather than a transport failure — an API + * response problem instead of a network one. + */ + readonly decode?: boolean; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.externalNetwork; + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; } } diff --git a/apps/cli/src/legacy/commands/link/link.errors.unit.test.ts b/apps/cli/src/legacy/commands/link/link.errors.unit.test.ts new file mode 100644 index 0000000000..53ed3c4449 --- /dev/null +++ b/apps/cli/src/legacy/commands/link/link.errors.unit.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { classifyCliErrorActionability } from "../../../shared/telemetry/error-actionability.ts"; +import { LegacyLinkProjectStatusNetworkError } from "./link.errors.ts"; + +describe("LegacyLinkProjectStatusNetworkError actionability", () => { + it("classifies a body-decode failure as an API response problem", () => { + const result = classifyCliErrorActionability( + new LegacyLinkProjectStatusNetworkError({ message: "boom", decode: true }), + ); + expect(result.error_kind).toBe("external_service"); + expect(result.error_category).toBe("api_status"); + expect(result.error_fingerprint).toBe("tag:LegacyLinkProjectStatusNetworkError:api_response"); + }); + + it("classifies a transport failure as network", () => { + const result = classifyCliErrorActionability( + new LegacyLinkProjectStatusNetworkError({ message: "boom" }), + ); + expect(result.error_category).toBe("network"); + }); +}); diff --git a/apps/cli/src/legacy/commands/link/link.handler.ts b/apps/cli/src/legacy/commands/link/link.handler.ts index f1cca7c3e5..18f7675b11 100644 --- a/apps/cli/src/legacy/commands/link/link.handler.ts +++ b/apps/cli/src/legacy/commands/link/link.handler.ts @@ -65,9 +65,13 @@ const classifyProjectError = ( ), ); } + // Everything else: a transport `HttpClientError` (no response) is a network + // failure; a non-`HttpClientError` (the generated client's `SchemaError` / + // `HttpBodyError` rejecting the response body) is an API response problem. return Effect.fail( new LegacyLinkProjectStatusNetworkError({ message: `failed to retrieve remote project status: ${String(cause)}`, + decode: !HttpClientError.isHttpClientError(cause), }), ); }; diff --git a/apps/cli/src/legacy/commands/login/login.errors.ts b/apps/cli/src/legacy/commands/login/login.errors.ts index e964d6bc9f..089c0f543c 100644 --- a/apps/cli/src/legacy/commands/login/login.errors.ts +++ b/apps/cli/src/legacy/commands/login/login.errors.ts @@ -15,12 +15,18 @@ export const LEGACY_LOGIN_MISSING_TOKEN_MESSAGE = `Cannot use automatic login flow inside non-TTY environments. ` + `Please provide --token flag or set the SUPABASE_ACCESS_TOKEN environment variable.`; -/** Token-path save failure — Go's `cannot save provided token: %w` (`login.go:171`). */ +/** + * Token-path save failure — Go's `cannot save provided token: %w` + * (`login.go:171`). Only ever constructed on the provided-token paths (`--token` + * / `SUPABASE_ACCESS_TOKEN` / piped stdin); the browser flow saves via the raw + * `credentials.saveAccessToken`. A malformed provided token is not fixable by + * `supabase login`, so the remediation is to correct that input. + */ export class LegacyLoginSaveTokenError extends Data.TaggedError("LegacyLoginSaveTokenError")<{ readonly message: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.authLogin; + return actionability.authToken; } } @@ -45,6 +51,11 @@ export class LegacyLoginVerificationError extends Data.TaggedError("LegacyLoginV readonly statusCode?: number; /** Set when the poll failed at the transport layer (connection/timeout). */ readonly network?: boolean; + /** + * Set when the poll response arrived but its body could not be decoded — an + * API response problem rather than a transport (network) one. + */ + readonly decode?: boolean; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { return actionability.authLogin; @@ -63,8 +74,17 @@ export class LegacyLoginFailedError extends Data.TaggedError("LegacyLoginFailedE readonly message: string; readonly statusCode?: number; readonly network?: boolean; + /** + * Set when the last poll response arrived but its body could not be decoded — + * an API response problem rather than a transport (network) one or an + * incomplete browser flow. + */ + readonly decode?: boolean; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + if (this.decode === true) { + return { ...actionability.apiStatus, fingerprint_suffix: "api_response" }; + } if (this.network === true) { return { ...actionability.externalNetwork, fingerprint_suffix: "network" }; } diff --git a/apps/cli/src/legacy/commands/login/login.errors.unit.test.ts b/apps/cli/src/legacy/commands/login/login.errors.unit.test.ts new file mode 100644 index 0000000000..faef326043 --- /dev/null +++ b/apps/cli/src/legacy/commands/login/login.errors.unit.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { classifyCliErrorActionability } from "../../../shared/telemetry/error-actionability.ts"; +import { LegacyLoginFailedError, LegacyLoginSaveTokenError } from "./login.errors.ts"; + +describe("LegacyLoginFailedError actionability", () => { + it("classifies a decoded-body poll failure as an API response problem", () => { + const result = classifyCliErrorActionability( + new LegacyLoginFailedError({ message: "boom", decode: true }), + ); + expect(result.error_kind).toBe("external_service"); + expect(result.error_category).toBe("api_status"); + expect(result.error_fingerprint).toBe("tag:LegacyLoginFailedError:api_response"); + }); + + it("prefers the decode signal over a transport one", () => { + const result = classifyCliErrorActionability( + new LegacyLoginFailedError({ message: "boom", network: true, decode: true }), + ); + expect(result.error_fingerprint).toBe("tag:LegacyLoginFailedError:api_response"); + }); + + it("classifies a transport failure as network", () => { + const result = classifyCliErrorActionability( + new LegacyLoginFailedError({ message: "boom", network: true }), + ); + expect(result.error_category).toBe("network"); + expect(result.error_fingerprint).toBe("tag:LegacyLoginFailedError:network"); + }); + + it("classifies a 5xx poll status as an API status problem", () => { + const result = classifyCliErrorActionability( + new LegacyLoginFailedError({ message: "boom", statusCode: 503 }), + ); + expect(result.error_fingerprint).toBe("tag:LegacyLoginFailedError:api_status"); + }); + + it("treats an incomplete browser flow (no signal / pending 4xx) as auth login", () => { + expect( + classifyCliErrorActionability(new LegacyLoginFailedError({ message: "boom" })).error_category, + ).toBe("auth"); + expect( + classifyCliErrorActionability( + new LegacyLoginFailedError({ message: "boom", statusCode: 400 }), + ).error_category, + ).toBe("auth"); + }); +}); + +describe("LegacyLoginSaveTokenError actionability", () => { + it("classifies a provided-token save failure as a token problem", () => { + const result = classifyCliErrorActionability( + new LegacyLoginSaveTokenError({ message: "cannot save provided token: bad" }), + ); + expect(result.error_kind).toBe("user_actionable"); + expect(result.error_category).toBe("auth"); + expect(result.suggestion_type).toBe("set_env_var"); + }); +}); diff --git a/apps/cli/src/legacy/commands/sso/sso.errors.ts b/apps/cli/src/legacy/commands/sso/sso.errors.ts index d4d6a64d13..90af9df28b 100644 --- a/apps/cli/src/legacy/commands/sso/sso.errors.ts +++ b/apps/cli/src/legacy/commands/sso/sso.errors.ts @@ -152,7 +152,11 @@ export class LegacySsoMetadataUrlNetworkError extends Data.TaggedError( readonly message: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.externalNetwork; + // Fired only during preflight validation of the USER-SUPPLIED + // `--metadata-url` (a third-party SAML IDP endpoint), never a Supabase + // service — a bad URL that times out / non-200s / is too large is user + // input, like its `MetadataUrlInvalid` / `NonUtf8` siblings. + return actionability.provideFlags; } } diff --git a/apps/cli/src/legacy/commands/sso/sso.errors.unit.test.ts b/apps/cli/src/legacy/commands/sso/sso.errors.unit.test.ts new file mode 100644 index 0000000000..dfec433a95 --- /dev/null +++ b/apps/cli/src/legacy/commands/sso/sso.errors.unit.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { classifyCliErrorActionability } from "../../../shared/telemetry/error-actionability.ts"; +import { LegacySsoMetadataUrlNetworkError } from "./sso.errors.ts"; + +describe("LegacySsoMetadataUrlNetworkError actionability", () => { + it("classifies a failed user-supplied --metadata-url as a flag input problem", () => { + const result = classifyCliErrorActionability( + new LegacySsoMetadataUrlNetworkError({ message: "failed to fetch metadata url: timeout" }), + ); + expect(result.error_kind).toBe("user_actionable"); + expect(result.error_category).toBe("invalid_input"); + expect(result.suggestion_type).toBe("provide_flags"); + }); +}); diff --git a/apps/cli/src/legacy/shared/legacy-ensure-login.ts b/apps/cli/src/legacy/shared/legacy-ensure-login.ts index 999a3d0767..508d41c12c 100644 --- a/apps/cli/src/legacy/shared/legacy-ensure-login.ts +++ b/apps/cli/src/legacy/shared/legacy-ensure-login.ts @@ -142,6 +142,7 @@ export const legacyBrowserLogin = Effect.fnUntraced(function* (opts: LegacyBrows message: err.message, statusCode: err.statusCode, network: err.network, + decode: err.decode, }), ); } diff --git a/apps/cli/src/legacy/shared/legacy-login-api.layer.ts b/apps/cli/src/legacy/shared/legacy-login-api.layer.ts index 3113c4c506..abf75d456d 100644 --- a/apps/cli/src/legacy/shared/legacy-login-api.layer.ts +++ b/apps/cli/src/legacy/shared/legacy-login-api.layer.ts @@ -11,6 +11,12 @@ import { LegacyLoginVerificationError } from "../commands/login/login.errors.ts" const POLL_TIMEOUT = "10 seconds"; +// HttpClientError reasons that mean the response arrived but its body could not +// be decoded (including a 2xx whose body isn't valid JSON). These are API +// response problems, not transport ones, so they classify by `decode` rather +// than as a network failure. Mirrors `next/auth/api.layer.ts`. +const BODY_DECODE_REASONS = new Set(["DecodeError", "EmptyBodyError"]); + function readString(obj: unknown, key: string): string { if (typeof obj === "object" && obj !== null && key in obj) { const value = (obj as Record)[key]; @@ -52,13 +58,20 @@ export const legacyLoginApiLayer = Layer.effect( }).pipe( // Map transport / JSON-decode failures to the retry-driving error. // The explicit non-200 `LegacyLoginVerificationError` above passes - // through untouched (it is not an `HttpClientError`). + // through untouched (it is not an `HttpClientError`). A body-decode + // reason means the response arrived but its body was unparseable — an + // API response problem (`decode`), not a transport (`network`) one. Effect.catchTag("HttpClientError", (cause) => Effect.fail( - new LegacyLoginVerificationError({ - message: `failed to execute http request: ${cause.message}`, - network: true, - }), + BODY_DECODE_REASONS.has(cause.reason._tag) + ? new LegacyLoginVerificationError({ + message: `failed to execute http request: ${cause.message}`, + decode: true, + }) + : new LegacyLoginVerificationError({ + message: `failed to execute http request: ${cause.message}`, + network: true, + }), ), ), Effect.timeoutOrElse({ diff --git a/apps/cli/src/next/commands/login/login.errors.ts b/apps/cli/src/next/commands/login/login.errors.ts index 3c58f5708c..fb129f979c 100644 --- a/apps/cli/src/next/commands/login/login.errors.ts +++ b/apps/cli/src/next/commands/login/login.errors.ts @@ -32,11 +32,20 @@ export class LoginFailedError extends Data.TaggedError("LoginFailedError")<{ readonly suggestion: string; readonly statusCode?: number; readonly network?: boolean; + /** + * Set when the last poll failed decoding an otherwise-received response body + * (no status code to classify by) — an API response problem rather than a + * transport (network) one or an incomplete browser flow. + */ + readonly decode?: boolean; }> { override get message() { return `${this.detail}\n Suggestion: ${this.suggestion}`; } get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + if (this.decode === true) { + return { ...actionability.apiStatus, fingerprint_suffix: "api_response" }; + } if (this.network === true) { return { ...actionability.externalNetwork, fingerprint_suffix: "network" }; } diff --git a/apps/cli/src/next/commands/login/login.errors.unit.test.ts b/apps/cli/src/next/commands/login/login.errors.unit.test.ts new file mode 100644 index 0000000000..2ead78424e --- /dev/null +++ b/apps/cli/src/next/commands/login/login.errors.unit.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { classifyCliErrorActionability } from "../../../shared/telemetry/error-actionability.ts"; +import { LoginFailedError } from "./login.errors.ts"; + +const base = { detail: "Login failed after maximum retries", suggestion: "Try again" }; + +describe("LoginFailedError actionability", () => { + it("classifies a decoded-body poll failure as an API response problem", () => { + const result = classifyCliErrorActionability(new LoginFailedError({ ...base, decode: true })); + expect(result.error_kind).toBe("external_service"); + expect(result.error_category).toBe("api_status"); + expect(result.error_fingerprint).toBe("tag:LoginFailedError:api_response"); + }); + + it("prefers the decode signal over a transport one", () => { + const result = classifyCliErrorActionability( + new LoginFailedError({ ...base, network: true, decode: true }), + ); + expect(result.error_fingerprint).toBe("tag:LoginFailedError:api_response"); + }); + + it("classifies a transport failure as network", () => { + const result = classifyCliErrorActionability(new LoginFailedError({ ...base, network: true })); + expect(result.error_category).toBe("network"); + expect(result.error_fingerprint).toBe("tag:LoginFailedError:network"); + }); + + it("classifies a 5xx poll status as an API status problem", () => { + const result = classifyCliErrorActionability( + new LoginFailedError({ ...base, statusCode: 502 }), + ); + expect(result.error_fingerprint).toBe("tag:LoginFailedError:api_status"); + }); + + it("treats an incomplete browser flow (no signal / pending 4xx) as auth login", () => { + expect(classifyCliErrorActionability(new LoginFailedError(base)).error_category).toBe("auth"); + expect( + classifyCliErrorActionability(new LoginFailedError({ ...base, statusCode: 400 })) + .error_category, + ).toBe("auth"); + }); +}); diff --git a/apps/cli/src/next/commands/login/login.handler.ts b/apps/cli/src/next/commands/login/login.handler.ts index c6deff6bb1..07058958ee 100644 --- a/apps/cli/src/next/commands/login/login.handler.ts +++ b/apps/cli/src/next/commands/login/login.handler.ts @@ -226,6 +226,7 @@ const browserOAuthFlow = Effect.fnUntraced(function* (flags: LoginFlags) { suggestion: "Try running `supabase login` again", statusCode, network, + decode, }), ); } diff --git a/apps/cli/src/shared/functions/functions-api.errors.ts b/apps/cli/src/shared/functions/functions-api.errors.ts index 63280c85d3..7e41c9465d 100644 --- a/apps/cli/src/shared/functions/functions-api.errors.ts +++ b/apps/cli/src/shared/functions/functions-api.errors.ts @@ -19,6 +19,12 @@ export class FunctionsApiStatusError extends Data.TaggedError("FunctionsApiStatu readonly message: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + // Every construction site targets a functions endpoint keyed by a + // user-supplied project ref / function slug, so a 404 means that ref/slug + // did not match — user input, not an API failure. + if (this.status === 404) { + return { ...actionability.invalidInput, fingerprint_suffix: "not_found" }; + } return statusCodeActionability(this.status); } } diff --git a/apps/cli/src/shared/functions/functions-api.errors.unit.test.ts b/apps/cli/src/shared/functions/functions-api.errors.unit.test.ts new file mode 100644 index 0000000000..0621878e3d --- /dev/null +++ b/apps/cli/src/shared/functions/functions-api.errors.unit.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { classifyCliErrorActionability } from "../telemetry/error-actionability.ts"; +import { FunctionsApiStatusError } from "./functions-api.errors.ts"; + +describe("FunctionsApiStatusError actionability", () => { + it("classifies a 404 (unknown project ref / function slug) as invalid input", () => { + const result = classifyCliErrorActionability( + new FunctionsApiStatusError({ status: 404, message: "not found" }), + ); + expect(result.error_kind).toBe("user_actionable"); + expect(result.error_category).toBe("invalid_input"); + expect(result.error_fingerprint).toBe("tag:FunctionsApiStatusError:not_found"); + }); + + it("keeps a 401 on the auth-login policy", () => { + const result = classifyCliErrorActionability( + new FunctionsApiStatusError({ status: 401, message: "unauthorized" }), + ); + expect(result.error_category).toBe("auth"); + }); + + it("keeps a 5xx on the API status policy", () => { + const result = classifyCliErrorActionability( + new FunctionsApiStatusError({ status: 500, message: "server error" }), + ); + expect(result.error_category).toBe("api_status"); + }); +}); From 8da950419a15917e8a369e415362863eca5fc761 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 9 Jul 2026 13:21:39 +0200 Subject: [PATCH 14/20] chore(cli): classify remaining 404, user-SQL, and wrapped-panic paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - functions list 404 classifies as invalid_input/not_found: the ref came from the user or a stale link, consistent with the other functions paths. - db query --linked 400 classifies as the user's own SQL (dbFinding), the remote twin of the local exec error. - Storage gateway 404 classifies as invalid_input/not_found by status — the friendly bucket-not-found bodies are intercepted upstream, and classification stays free of body-text matching. - A native JS exception preserved as the cause of a code-UNKNOWN StackError classifies as internal_bug/panic instead of unknown, matching the top-level native-exception rule. Co-Authored-By: Claude Fable 5 --- apps/cli/src/legacy/commands/db/query/query.errors.ts | 5 +++++ .../src/legacy/commands/functions/list/list.errors.ts | 5 +++++ .../legacy/shared/legacy-storage-gateway.errors.ts | 6 ++++++ apps/cli/src/shared/telemetry/error-actionability.ts | 6 ++++++ .../shared/telemetry/error-actionability.unit.test.ts | 11 +++++++++++ 5 files changed, 33 insertions(+) diff --git a/apps/cli/src/legacy/commands/db/query/query.errors.ts b/apps/cli/src/legacy/commands/db/query/query.errors.ts index 3bf4112a8d..52dcabb278 100644 --- a/apps/cli/src/legacy/commands/db/query/query.errors.ts +++ b/apps/cli/src/legacy/commands/db/query/query.errors.ts @@ -99,6 +99,11 @@ export class LegacyDbQueryUnexpectedStatusError extends Data.TaggedError( readonly message: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + // The endpoint executes the user's SQL: a 400 is the remote twin of the + // local LegacyDbQueryExecError (syntax/constraint failures in user SQL). + if (this.status === 400) { + return { ...actionability.dbFinding, fingerprint_suffix: "query" }; + } return statusCodeActionability(this.status); } } diff --git a/apps/cli/src/legacy/commands/functions/list/list.errors.ts b/apps/cli/src/legacy/commands/functions/list/list.errors.ts index 47c7799fa4..0c48c07cc1 100644 --- a/apps/cli/src/legacy/commands/functions/list/list.errors.ts +++ b/apps/cli/src/legacy/commands/functions/list/list.errors.ts @@ -27,6 +27,11 @@ export class LegacyFunctionsListUnexpectedStatusError extends Data.TaggedError( readonly message: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + // A 404 means the user-supplied or stale linked project ref matched no + // project — user input, consistent with the other functions paths. + if (this.status === 404) { + return { ...actionability.invalidInput, fingerprint_suffix: "not_found" }; + } return statusCodeActionability(this.status); } } diff --git a/apps/cli/src/legacy/shared/legacy-storage-gateway.errors.ts b/apps/cli/src/legacy/shared/legacy-storage-gateway.errors.ts index 61bf09bbda..b013f6cded 100644 --- a/apps/cli/src/legacy/shared/legacy-storage-gateway.errors.ts +++ b/apps/cli/src/legacy/shared/legacy-storage-gateway.errors.ts @@ -37,6 +37,12 @@ export class LegacyStorageGatewayStatusError extends Data.TaggedError( readonly message: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + // A 404 is a user-supplied ss:///bucket/path that matched nothing (the + // friendly bucket-not-found cases are intercepted by the handlers before + // this error propagates). + if (this.status === 404) { + return { ...actionability.invalidInput, fingerprint_suffix: "not_found" }; + } // The tenant Storage gateway is not the Management API: a 401/403 here // means stale local service keys, which `supabase login` cannot fix, so // the Management-API auth/permission policy must not apply. diff --git a/apps/cli/src/shared/telemetry/error-actionability.ts b/apps/cli/src/shared/telemetry/error-actionability.ts index 3d07fe4eb3..83dc7ff486 100644 --- a/apps/cli/src/shared/telemetry/error-actionability.ts +++ b/apps/cli/src/shared/telemetry/error-actionability.ts @@ -671,6 +671,12 @@ function classifyAtDepth(error: unknown, depth: number): CliErrorActionability { if (cause !== undefined) { return classifyAtDepth(cause, depth + 1); } + // toStackError wraps arbitrary thrown errors with code "UNKNOWN"; a + // native JS exception cause is a stack-internal crash and must land in + // the internal-bug bucket, matching the top-level native-exception rule. + if (isNativeJsExceptionName(readErrorName(error["cause"]))) { + return classifyAtDepth(error["cause"], depth + 1); + } const classify = externalActionabilityByTag["StackError"]; if (classify !== undefined) { return toActionability(classify(error), "error", "StackError", error); diff --git a/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts b/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts index 1fd966e1ed..267c5131de 100644 --- a/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts +++ b/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts @@ -175,6 +175,17 @@ describe("classifyCliErrorActionability", () => { expect(result.error_fingerprint).toBe("tag:StackBuildError:invalid_config"); }); + it("classifies a native exception wrapped by StackError as an internal bug", () => { + const wrapped = new Error("boom"); + wrapped.name = "StackError"; + Object.defineProperty(wrapped, "code", { value: "UNKNOWN" }); + Object.defineProperty(wrapped, "cause", { value: new TypeError("x is not a function") }); + const result = classifyCliErrorActionability(wrapped); + expect(result.error_kind).toBe("internal_bug"); + expect(result.error_category).toBe("panic"); + expect(result.error_fingerprint).toBe("error:TypeError"); + }); + it("treats forbidden API statuses as account permission failures", () => { const forbidden = classifyCliErrorActionability(new DeclaredStatusError({ status: 403 })); expect(forbidden.error_kind).toBe("user_actionable"); From 8f9ae5b645d80b8c7ec1e082bb2e16e9c050ed63 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 9 Jul 2026 14:22:31 +0200 Subject: [PATCH 15/20] chore(cli): close api-key 404, deploy body-order, and daemon decode gaps - Link and storage api-key 404s classify as invalid_input/not_found: the ref came from the user or a stale link. - functions deploy reads response text defensively and checks the status before parsing, so non-201 HTML bodies and malformed success bodies classify by status instead of as network transport; JSON-body messages are byte-identical. - RemoteStack wraps daemon body-decode failures in UnixHttpClientError (sibling of the status helper), so daemon JSON corruption classifies as a stack problem instead of a Management API response. Co-Authored-By: Claude Fable 5 --- .../src/legacy/commands/link/link.errors.ts | 6 ++ .../legacy-storage-credentials.errors.ts | 5 ++ apps/cli/src/shared/functions/deploy.ts | 57 ++++++++++++++----- packages/stack/src/RemoteStack.ts | 54 +++++++++++++----- 4 files changed, 94 insertions(+), 28 deletions(-) diff --git a/apps/cli/src/legacy/commands/link/link.errors.ts b/apps/cli/src/legacy/commands/link/link.errors.ts index 4a93b07046..291819ee7e 100644 --- a/apps/cli/src/legacy/commands/link/link.errors.ts +++ b/apps/cli/src/legacy/commands/link/link.errors.ts @@ -88,6 +88,12 @@ export class LegacyLinkAuthTokenError extends Data.TaggedError("LegacyLinkAuthTo readonly message: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + // A 404 here means the api-keys endpoint did not find the user-supplied / + // stale `--project-ref` (the earlier getProject 404 is tolerated as a + // possible branch ref) — user input, not an API failure. + if (this.status === 404) { + return { ...actionability.invalidInput, fingerprint_suffix: "not_found" }; + } // The shared mapper wraps any non-200 in this tag; only a 401 is an auth // failure the user fixes by re-logging in. return statusCodeActionability(this.status); diff --git a/apps/cli/src/legacy/shared/legacy-storage-credentials.errors.ts b/apps/cli/src/legacy/shared/legacy-storage-credentials.errors.ts index 563e352ccf..f4a15884cb 100644 --- a/apps/cli/src/legacy/shared/legacy-storage-credentials.errors.ts +++ b/apps/cli/src/legacy/shared/legacy-storage-credentials.errors.ts @@ -66,6 +66,11 @@ export class LegacyStorageAuthTokenError extends Data.TaggedError("LegacyStorage readonly message: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + // A 404 here means the api-keys endpoint did not find the user-supplied / + // linked project ref — user input, not an API failure. + if (this.status === 404) { + return { ...actionability.invalidInput, fingerprint_suffix: "not_found" }; + } // The shared mapper wraps any non-200 in this tag; only a 401 is an auth // failure the user fixes by re-logging in. return statusCodeActionability(this.status); diff --git a/apps/cli/src/shared/functions/deploy.ts b/apps/cli/src/shared/functions/deploy.ts index ab9f9e7e65..992ae49f6b 100644 --- a/apps/cli/src/shared/functions/deploy.ts +++ b/apps/cli/src/shared/functions/deploy.ts @@ -156,6 +156,19 @@ function decodeFunctionListResponse(value: unknown): ReadonlyArray ({ status: raw.status, headers: raw.headers, - body: raw.json.pipe( - Effect.mapError((error) => mapTransportError("failed to deploy function", error)), - ), + body: raw.text.pipe(Effect.orElseSucceed(() => "")), })), Effect.mapError((error) => mapTransportError("failed to deploy function", error)), ), @@ -1576,16 +1591,20 @@ const uploadFunctionSource = Effect.fnUntraced(function* ( return yield* Effect.fail( new FunctionsApiStatusError({ status: response.status, - message: `unexpected deploy status ${response.status}: ${JSON.stringify(body)}`, + message: `unexpected deploy status ${response.status}: ${formatUnexpectedStatusBody(body)}`, }), ); } + // A 201 whose body is not the expected JSON is an API-response problem, not a + // transport failure — surface it via FunctionsApiStatusError so it classifies + // as api_status rather than network. return yield* Effect.try({ - try: () => decodeDeployFunctionResponse(body), + try: () => decodeDeployFunctionResponse(JSON.parse(body)), catch: (error) => - new Error( - `failed to read deploy response: ${error instanceof Error ? error.message : String(error)}`, - ), + new FunctionsApiStatusError({ + status: response.status, + message: `failed to read deploy response: ${error instanceof Error ? error.message : String(error)}`, + }), }); }); @@ -1619,12 +1638,12 @@ const bulkUpdateRemoteFunctions = Effect.fnUntraced(function* ( body: functions.map(toBulkUpdateItem), }) .pipe( + // Read the body as text (never failing) so the status check wins even + // if the body cannot be read. Effect.map((raw) => ({ status: raw.status, headers: raw.headers, - body: raw.text.pipe( - Effect.mapError((error) => mapTransportError("failed to bulk update", error)), - ), + body: raw.text.pipe(Effect.orElseSucceed(() => "")), })), Effect.mapError((error) => mapTransportError("failed to bulk update", error)), ), @@ -1708,10 +1727,18 @@ const upsertBundledFunction = Effect.fnUntraced(function* ( if (response.success) { const expectedStatus = shouldUpdate ? 200 : 201; if (response.value.status === expectedStatus) { - const body = yield* response.value.json.pipe( - Effect.mapError((error) => mapTransportError("failed to read function response", error)), - ); - return decodeDeployFunctionResponse(body); + // A success status with a malformed / unexpected JSON body is an + // API-response problem, not a transport failure — surface it via + // FunctionsApiStatusError so it classifies as api_status not network. + const body = yield* response.value.text.pipe(Effect.orElseSucceed(() => "")); + return yield* Effect.try({ + try: () => decodeDeployFunctionResponse(JSON.parse(body)), + catch: (error) => + new FunctionsApiStatusError({ + status: response.value.status, + message: `failed to read function response: ${error instanceof Error ? error.message : String(error)}`, + }), + }); } const body = yield* response.value.text.pipe(Effect.orElseSucceed(() => "")); diff --git a/packages/stack/src/RemoteStack.ts b/packages/stack/src/RemoteStack.ts index e8a613f273..459598a91b 100644 --- a/packages/stack/src/RemoteStack.ts +++ b/packages/stack/src/RemoteStack.ts @@ -102,6 +102,24 @@ function dieOnNonOkStatus( ); } +/** + * Reads a schema-decoded JSON body from the daemon, mapping a body-decode + * failure (`SchemaError` / `HttpBodyError` — daemon JSON corruption or + * version skew) into a `UnixHttpClientError` before dying, so the CLI's error + * classifier attributes it to the stack daemon instead of treating it as a raw + * Management-API-shaped decode error. + */ +function dieOnBodyDecodeError( + socketPath: string, + path: string, + effect: Effect.Effect, +) { + return effect.pipe( + Effect.mapError((cause) => new UnixHttpClientError({ socketPath, path, cause })), + Effect.orDie, + ); +} + /** Fetch JSON from the daemon, dying on HTTP errors. */ function fetchStatus(socketPath: string, path: string, method = "GET") { return Effect.gen(function* () { @@ -111,8 +129,10 @@ function fetchStatus(socketPath: string, path: string, method = "GET") { path, HttpClientResponse.filterStatusOk(response), ); - return yield* HttpClientResponse.schemaBodyJson(StatusResponseSchema)(okResponse).pipe( - Effect.orDie, + return yield* dieOnBodyDecodeError( + socketPath, + path, + HttpClientResponse.schemaBodyJson(StatusResponseSchema)(okResponse), ); }); } @@ -125,8 +145,10 @@ function fetchLogEntries(socketPath: string, path: string) { path, HttpClientResponse.filterStatusOk(response), ); - return yield* HttpClientResponse.schemaBodyJson(Schema.Array(LogEntrySchema))(okResponse).pipe( - Effect.orDie, + return yield* dieOnBodyDecodeError( + socketPath, + path, + HttpClientResponse.schemaBodyJson(Schema.Array(LogEntrySchema))(okResponse), ); }); } @@ -283,9 +305,11 @@ export const RemoteStack = { return yield* new ServiceNotFoundError({ name }); } if (response.status === 500) { - const body = yield* HttpClientResponse.schemaBodyJson(ServiceErrorResponseSchema)( - response, - ).pipe(Effect.orDie); + const body = yield* dieOnBodyDecodeError( + socketPath, + path, + HttpClientResponse.schemaBodyJson(ServiceErrorResponseSchema)(response), + ); return yield* new ServiceReadyError({ name, reason: body.error }); } yield* dieOnNonOkStatus( @@ -345,9 +369,11 @@ export const RemoteStack = { return yield* new ServiceNotFoundError({ name: "edge-runtime" }); } if (response.status === 500) { - const body = yield* HttpClientResponse.schemaBodyJson(ServiceErrorResponseSchema)( - response, - ).pipe(Effect.orDie); + const body = yield* dieOnBodyDecodeError( + socketPath, + path, + HttpClientResponse.schemaBodyJson(ServiceErrorResponseSchema)(response), + ); return yield* new ServiceReadyError({ name: "edge-runtime", reason: body.error, @@ -374,9 +400,11 @@ export const RemoteStack = { return yield* new ServiceNotFoundError({ name: "edge-runtime" }); } if (response.status === 500) { - const body = yield* HttpClientResponse.schemaBodyJson(ServiceErrorResponseSchema)( - response, - ).pipe(Effect.orDie); + const body = yield* dieOnBodyDecodeError( + socketPath, + path, + HttpClientResponse.schemaBodyJson(ServiceErrorResponseSchema)(response), + ); return yield* new ServiceReadyError({ name: "edge-runtime", reason: body.error, From 510042ce76ef58fe078caad0130d38999c600864 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 9 Jul 2026 14:57:47 +0200 Subject: [PATCH 16/20] chore(cli): promote ungated 404s to invalid input in the status policy Management API paths are keyed by user-supplied or linked identifiers, so an ungated 404 means the user-named resource was not found. statusCodeActionability now classifies it invalid_input/not_found after the entitlement-gate branch, covering projects api-keys, backups list, secrets list, snippets list, and every future status error uniformly; the twelve per-class 404 branches that duplicated this policy are removed. projects list additionally marks its malformed-200 constructions with decode so they fingerprint api_response. Co-Authored-By: Claude Fable 5 --- .../commands/branches/branches.errors.ts | 44 ------------------- .../commands/functions/list/list.errors.ts | 5 --- .../src/legacy/commands/link/link.errors.ts | 11 ++--- .../commands/projects/list/list.handler.ts | 2 + .../commands/projects/projects.errors.ts | 9 ++++ .../commands/snippets/snippets.errors.ts | 5 --- .../legacy-storage-credentials.errors.ts | 10 ++--- .../shared/legacy-storage-gateway.errors.ts | 11 ++--- .../shared/functions/functions-api.errors.ts | 6 --- .../shared/telemetry/error-actionability.ts | 9 ++++ .../error-actionability.unit.test.ts | 5 +++ 11 files changed, 35 insertions(+), 82 deletions(-) diff --git a/apps/cli/src/legacy/commands/branches/branches.errors.ts b/apps/cli/src/legacy/commands/branches/branches.errors.ts index 92361dcef3..a92cfe90bd 100644 --- a/apps/cli/src/legacy/commands/branches/branches.errors.ts +++ b/apps/cli/src/legacy/commands/branches/branches.errors.ts @@ -60,13 +60,6 @@ export class LegacyBranchesCreateUnexpectedStatusError extends Data.TaggedError( readonly upgradeSuggested?: boolean; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - // A 404 means the user-supplied project ref (resolved from `--project-ref` - // / env / linked-project file) did not match a project — user input, not - // an API failure. Guard against shadowing a confirmed plan gate, since a - // gated 4xx is a plan limit rather than a bad ref. - if (this.upgradeSuggested !== true && this.status === 404) { - return { ...actionability.invalidInput, fingerprint_suffix: "not_found" }; - } return statusCodeActionability(this.status, { upgradeSuggested: this.upgradeSuggested }); } } @@ -93,11 +86,6 @@ export class LegacyBranchesFindUnexpectedStatusError extends Data.TaggedError( readonly message: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - // A 404 on branch lookup means the user-supplied branch name/ref did not - // match — user input, not an API failure. - if (this.status === 404) { - return { ...actionability.invalidInput, fingerprint_suffix: "not_found" }; - } return statusCodeActionability(this.status); } } @@ -125,13 +113,6 @@ export class LegacyBranchesGetUnexpectedStatusError extends Data.TaggedError( readonly message: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - // A 404 from `V1GetABranchConfig` means the user-supplied branch id/ref did - // not match any branch — user input, not an API failure. The resolver and - // `get` handler only call this endpoint with a user-provided UUID/ref, or - // the project_ref resolved from a user-provided branch name. - if (this.status === 404) { - return { ...actionability.invalidInput, fingerprint_suffix: "not_found" }; - } return statusCodeActionability(this.status); } } @@ -218,13 +199,6 @@ export class LegacyBranchesUpdateUnexpectedStatusError extends Data.TaggedError( readonly upgradeSuggested?: boolean; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - // A 404 means the user-supplied branch id/ref (or ref-shaped input, which - // bypasses the resolver lookup) did not match — user input, not an API - // failure. Guard against shadowing a confirmed plan gate, since a gated - // 4xx is a plan limit rather than a bad ref. - if (this.upgradeSuggested !== true && this.status === 404) { - return { ...actionability.invalidInput, fingerprint_suffix: "not_found" }; - } return statusCodeActionability(this.status, { upgradeSuggested: this.upgradeSuggested }); } } @@ -250,12 +224,6 @@ export class LegacyBranchesPauseUnexpectedStatusError extends Data.TaggedError( readonly message: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - // A 404 means the user-supplied branch id/ref (or ref-shaped input, which - // bypasses the resolver lookup) did not match — user input, not an API - // failure. - if (this.status === 404) { - return { ...actionability.invalidInput, fingerprint_suffix: "not_found" }; - } return statusCodeActionability(this.status); } } @@ -281,12 +249,6 @@ export class LegacyBranchesUnpauseUnexpectedStatusError extends Data.TaggedError readonly message: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - // A 404 means the user-supplied branch id/ref (or ref-shaped input, which - // bypasses the resolver lookup) did not match — user input, not an API - // failure. - if (this.status === 404) { - return { ...actionability.invalidInput, fingerprint_suffix: "not_found" }; - } return statusCodeActionability(this.status); } } @@ -312,12 +274,6 @@ export class LegacyBranchesDeleteUnexpectedStatusError extends Data.TaggedError( readonly message: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - // A 404 means the user-supplied branch id/ref (or ref-shaped input, which - // bypasses the resolver lookup) did not match — user input, not an API - // failure. - if (this.status === 404) { - return { ...actionability.invalidInput, fingerprint_suffix: "not_found" }; - } return statusCodeActionability(this.status); } } diff --git a/apps/cli/src/legacy/commands/functions/list/list.errors.ts b/apps/cli/src/legacy/commands/functions/list/list.errors.ts index 0c48c07cc1..47c7799fa4 100644 --- a/apps/cli/src/legacy/commands/functions/list/list.errors.ts +++ b/apps/cli/src/legacy/commands/functions/list/list.errors.ts @@ -27,11 +27,6 @@ export class LegacyFunctionsListUnexpectedStatusError extends Data.TaggedError( readonly message: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - // A 404 means the user-supplied or stale linked project ref matched no - // project — user input, consistent with the other functions paths. - if (this.status === 404) { - return { ...actionability.invalidInput, fingerprint_suffix: "not_found" }; - } return statusCodeActionability(this.status); } } diff --git a/apps/cli/src/legacy/commands/link/link.errors.ts b/apps/cli/src/legacy/commands/link/link.errors.ts index 291819ee7e..54cd5d182c 100644 --- a/apps/cli/src/legacy/commands/link/link.errors.ts +++ b/apps/cli/src/legacy/commands/link/link.errors.ts @@ -88,14 +88,9 @@ export class LegacyLinkAuthTokenError extends Data.TaggedError("LegacyLinkAuthTo readonly message: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - // A 404 here means the api-keys endpoint did not find the user-supplied / - // stale `--project-ref` (the earlier getProject 404 is tolerated as a - // possible branch ref) — user input, not an API failure. - if (this.status === 404) { - return { ...actionability.invalidInput, fingerprint_suffix: "not_found" }; - } - // The shared mapper wraps any non-200 in this tag; only a 401 is an auth - // failure the user fixes by re-logging in. + // The shared mapper wraps any non-200 in this tag; the status policy maps + // 401 → re-login, 404 → user-supplied ref not found, everything else → + // API status. return statusCodeActionability(this.status); } } diff --git a/apps/cli/src/legacy/commands/projects/list/list.handler.ts b/apps/cli/src/legacy/commands/projects/list/list.handler.ts index 3fc4e0b63f..8b7aae2ee5 100644 --- a/apps/cli/src/legacy/commands/projects/list/list.handler.ts +++ b/apps/cli/src/legacy/commands/projects/list/list.handler.ts @@ -71,6 +71,7 @@ export const legacyProjectsList = Effect.fn("legacy.projects.list")(function* ( status: response.status, body: "", message: `Unexpected error retrieving projects: ${cause}`, + decode: true, }), ), ); @@ -80,6 +81,7 @@ export const legacyProjectsList = Effect.fn("legacy.projects.list")(function* ( status: response.status, body: "", message: "Unexpected error retrieving projects: response was not an array", + decode: true, }); } yield* fetching?.clear() ?? Effect.void; diff --git a/apps/cli/src/legacy/commands/projects/projects.errors.ts b/apps/cli/src/legacy/commands/projects/projects.errors.ts index 54b520954d..182d4541b3 100644 --- a/apps/cli/src/legacy/commands/projects/projects.errors.ts +++ b/apps/cli/src/legacy/commands/projects/projects.errors.ts @@ -28,8 +28,17 @@ export class LegacyProjectsListUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; + /** + * Set when the failure is a 200 response whose body could not be decoded + * (unparseable JSON / not an array) rather than a genuine non-200 status — + * an API response problem, not a bad status code. + */ + readonly decode?: boolean; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + if (this.decode === true) { + return { ...actionability.apiStatus, fingerprint_suffix: "api_response" }; + } return statusCodeActionability(this.status); } } diff --git a/apps/cli/src/legacy/commands/snippets/snippets.errors.ts b/apps/cli/src/legacy/commands/snippets/snippets.errors.ts index c5cb6a65fc..f682a7869d 100644 --- a/apps/cli/src/legacy/commands/snippets/snippets.errors.ts +++ b/apps/cli/src/legacy/commands/snippets/snippets.errors.ts @@ -74,11 +74,6 @@ export class LegacySnippetsDownloadUnexpectedStatusError extends Data.TaggedErro readonly message: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - // A 404 from `GET /v1/snippets/{id}` means the user-supplied snippet id did - // not match any snippet — user input, not an API failure. - if (this.status === 404) { - return { ...actionability.invalidInput, fingerprint_suffix: "not_found" }; - } return statusCodeActionability(this.status); } } diff --git a/apps/cli/src/legacy/shared/legacy-storage-credentials.errors.ts b/apps/cli/src/legacy/shared/legacy-storage-credentials.errors.ts index f4a15884cb..9ba8913b7a 100644 --- a/apps/cli/src/legacy/shared/legacy-storage-credentials.errors.ts +++ b/apps/cli/src/legacy/shared/legacy-storage-credentials.errors.ts @@ -66,13 +66,9 @@ export class LegacyStorageAuthTokenError extends Data.TaggedError("LegacyStorage readonly message: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - // A 404 here means the api-keys endpoint did not find the user-supplied / - // linked project ref — user input, not an API failure. - if (this.status === 404) { - return { ...actionability.invalidInput, fingerprint_suffix: "not_found" }; - } - // The shared mapper wraps any non-200 in this tag; only a 401 is an auth - // failure the user fixes by re-logging in. + // The shared mapper wraps any non-200 in this tag; the status policy maps + // 401 → re-login, 404 → user-supplied ref not found, everything else → + // API status. return statusCodeActionability(this.status); } } diff --git a/apps/cli/src/legacy/shared/legacy-storage-gateway.errors.ts b/apps/cli/src/legacy/shared/legacy-storage-gateway.errors.ts index b013f6cded..d953069b53 100644 --- a/apps/cli/src/legacy/shared/legacy-storage-gateway.errors.ts +++ b/apps/cli/src/legacy/shared/legacy-storage-gateway.errors.ts @@ -37,15 +37,12 @@ export class LegacyStorageGatewayStatusError extends Data.TaggedError( readonly message: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - // A 404 is a user-supplied ss:///bucket/path that matched nothing (the - // friendly bucket-not-found cases are intercepted by the handlers before - // this error propagates). - if (this.status === 404) { - return { ...actionability.invalidInput, fingerprint_suffix: "not_found" }; - } // The tenant Storage gateway is not the Management API: a 401/403 here // means stale local service keys, which `supabase login` cannot fix, so - // the Management-API auth/permission policy must not apply. + // the Management-API auth/permission policy must not apply. A 404 is a + // user-supplied ss:///bucket/path that matched nothing (the friendly + // bucket-not-found cases are intercepted by the handlers before this error + // propagates), which the status policy classifies as user input. if (this.status === 401 || this.status === 403) { return { ...actionability.apiStatus, fingerprint_suffix: "gateway_auth" }; } diff --git a/apps/cli/src/shared/functions/functions-api.errors.ts b/apps/cli/src/shared/functions/functions-api.errors.ts index 7e41c9465d..63280c85d3 100644 --- a/apps/cli/src/shared/functions/functions-api.errors.ts +++ b/apps/cli/src/shared/functions/functions-api.errors.ts @@ -19,12 +19,6 @@ export class FunctionsApiStatusError extends Data.TaggedError("FunctionsApiStatu readonly message: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - // Every construction site targets a functions endpoint keyed by a - // user-supplied project ref / function slug, so a 404 means that ref/slug - // did not match — user input, not an API failure. - if (this.status === 404) { - return { ...actionability.invalidInput, fingerprint_suffix: "not_found" }; - } return statusCodeActionability(this.status); } } diff --git a/apps/cli/src/shared/telemetry/error-actionability.ts b/apps/cli/src/shared/telemetry/error-actionability.ts index 83dc7ff486..94939bbc0f 100644 --- a/apps/cli/src/shared/telemetry/error-actionability.ts +++ b/apps/cli/src/shared/telemetry/error-actionability.ts @@ -309,6 +309,12 @@ export const planLimitGatedActionability: CliErrorActionabilityDeclaration = { * `upgradeSuggested` is the typed result of the entitlement gate * (`legacySuggestUpgrade`) threaded through the error constructor — never * inferred from message text. + * + * On Management API endpoints the request path is keyed by user-supplied or + * linked identifiers (project refs, slugs, ids), so an ungated 404 means the + * user-named resource was not found — user input, not an API failure. The + * entitlement-gate branch stays ahead of the 404 branch so a confirmed + * plan-limited 404 still classifies as `plan_limit`. */ export function statusCodeActionability( status: number | undefined, @@ -323,6 +329,9 @@ export function statusCodeActionability( if (status === 403) { return { ...actionability.accountAccess, fingerprint_suffix: "forbidden" }; } + if (status === 404) { + return { ...actionability.invalidInput, fingerprint_suffix: "not_found" }; + } if (status === undefined) { return { ...actionability.externalNetwork, fingerprint_suffix: "network" }; } diff --git a/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts b/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts index 267c5131de..9808b5dcdd 100644 --- a/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts +++ b/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts @@ -66,6 +66,11 @@ describe("classifyCliErrorActionability", () => { expect(gated.suggestion_type).toBe("upgrade_plan"); expect(gated.error_fingerprint).toBe("tag:DeclaredStatusError:plan_limit"); + const notFound = classifyCliErrorActionability(new DeclaredStatusError({ status: 404 })); + expect(notFound.error_kind).toBe("user_actionable"); + expect(notFound.error_category).toBe("invalid_input"); + expect(notFound.error_fingerprint).toBe("tag:DeclaredStatusError:not_found"); + const status = classifyCliErrorActionability(new DeclaredStatusError({ status: 500 })); expect(status.error_kind).toBe("external_service"); expect(status.error_category).toBe("api_status"); From 8b4d757d66b02f8ba334d52a8d26eaf931ffc168 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 9 Jul 2026 15:37:30 +0200 Subject: [PATCH 17/20] chore(cli): finish api_response fingerprints and wrapper discriminants - 200-with-undecodable-body errors fingerprint api_response across functions download, postgres-config unmarshal, storage credentials missing-key, and the storage gateway parse path. - The advisors invalid-token wrapper copies the token source, so a malformed SUPABASE_ACCESS_TOKEN keeps the set_env_var remediation through the re-wrap (query/not-logged-in siblings verified unaffected). - LegacyEdgeRuntimeScriptError threads the docker discriminant from its runCapture wrap site: daemon failures classify docker_not_running, registry pulls classify network, and only genuine script/SQL failures stay dbFinding. Co-Authored-By: Claude Fable 5 --- .../commands/db/advisors/advisors.errors.ts | 15 ++++++-- .../db/advisors/advisors.errors.unit.test.ts | 34 +++++++++++++++++++ .../commands/db/advisors/advisors.handler.ts | 3 ++ .../postgres-config/postgres-config.errors.ts | 12 +++++-- .../legacy-edge-runtime-script.errors.ts | 14 ++++++++ ...cy-edge-runtime-script.errors.unit.test.ts | 34 +++++++++++++++++++ .../legacy-edge-runtime-script.layer.ts | 3 ++ .../legacy-storage-credentials.errors.ts | 4 ++- .../shared/legacy-storage-gateway.errors.ts | 10 +++++- .../legacy/shared/legacy-storage-gateway.ts | 1 + .../src/shared/functions/download.errors.ts | 4 ++- 11 files changed, 126 insertions(+), 8 deletions(-) create mode 100644 apps/cli/src/legacy/commands/db/advisors/advisors.errors.unit.test.ts create mode 100644 apps/cli/src/legacy/shared/legacy-edge-runtime-script.errors.unit.test.ts diff --git a/apps/cli/src/legacy/commands/db/advisors/advisors.errors.ts b/apps/cli/src/legacy/commands/db/advisors/advisors.errors.ts index 081dfa4104..574455575b 100644 --- a/apps/cli/src/legacy/commands/db/advisors/advisors.errors.ts +++ b/apps/cli/src/legacy/commands/db/advisors/advisors.errors.ts @@ -47,9 +47,20 @@ export class LegacyDbAdvisorsNotLoggedInError extends Data.TaggedError( */ export class LegacyDbAdvisorsInvalidTokenError extends Data.TaggedError( "LegacyDbAdvisorsInvalidTokenError", -)<{ readonly message: string; readonly suggestion: string }> { +)<{ + readonly message: string; + readonly suggestion: string; + /** + * Copied from the wrapped `LegacyInvalidAccessTokenError`: an env-var token + * (`SUPABASE_ACCESS_TOKEN`) takes precedence over stored credentials, so + * `supabase login` cannot fix it — the remediation is to correct the env + * var. A stored (keyring/file) token, or an unknown source, is fixable by + * logging in again. + */ + readonly source?: "env" | "stored"; +}> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.authLogin; + return this.source === "env" ? actionability.authToken : actionability.authLogin; } } diff --git a/apps/cli/src/legacy/commands/db/advisors/advisors.errors.unit.test.ts b/apps/cli/src/legacy/commands/db/advisors/advisors.errors.unit.test.ts new file mode 100644 index 0000000000..862549890b --- /dev/null +++ b/apps/cli/src/legacy/commands/db/advisors/advisors.errors.unit.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { classifyCliErrorActionability } from "../../../../shared/telemetry/error-actionability.ts"; +import { LegacyDbAdvisorsInvalidTokenError } from "./advisors.errors.ts"; + +describe("LegacyDbAdvisorsInvalidTokenError actionability", () => { + const build = (source?: "env" | "stored") => + new LegacyDbAdvisorsInvalidTokenError({ + message: "Invalid access token format. Must be like `sbp_0102...1920`.", + suggestion: "Run supabase login first.", + source, + }); + + it("classifies an env-provided malformed token as a set-env-var remediation", () => { + const result = classifyCliErrorActionability(build("env")); + expect(result.error_kind).toBe("user_actionable"); + expect(result.error_category).toBe("auth"); + expect(result.suggestion_type).toBe("set_env_var"); + expect(result.error_fingerprint).toBe("tag:LegacyDbAdvisorsInvalidTokenError"); + }); + + it("classifies a stored malformed token as a re-login remediation", () => { + const result = classifyCliErrorActionability(build("stored")); + expect(result.error_kind).toBe("user_actionable"); + expect(result.error_category).toBe("auth"); + expect(result.suggestion_type).toBe("login"); + expect(result.suggested_command).toBe("supabase login"); + }); + + it("defaults to a re-login remediation when the source is unknown", () => { + const result = classifyCliErrorActionability(build()); + expect(result.suggestion_type).toBe("login"); + expect(result.suggested_command).toBe("supabase login"); + }); +}); diff --git a/apps/cli/src/legacy/commands/db/advisors/advisors.handler.ts b/apps/cli/src/legacy/commands/db/advisors/advisors.handler.ts index b84ca59906..ce1847f106 100644 --- a/apps/cli/src/legacy/commands/db/advisors/advisors.handler.ts +++ b/apps/cli/src/legacy/commands/db/advisors/advisors.handler.ts @@ -157,6 +157,9 @@ const runLinked = Effect.fnUntraced(function* ( new LegacyDbAdvisorsInvalidTokenError({ message: cause.message, suggestion: loginSuggestion(), + // Preserve the token source so an env-provided malformed token keeps + // its `set_env_var` remediation instead of degrading to `supabase login`. + source: cause.source, }), ), ), diff --git a/apps/cli/src/legacy/commands/postgres-config/postgres-config.errors.ts b/apps/cli/src/legacy/commands/postgres-config/postgres-config.errors.ts index 27f4431c16..b030fee06f 100644 --- a/apps/cli/src/legacy/commands/postgres-config/postgres-config.errors.ts +++ b/apps/cli/src/legacy/commands/postgres-config/postgres-config.errors.ts @@ -34,7 +34,9 @@ export class LegacyPostgresConfigGetUnmarshalError extends Data.TaggedError( readonly message: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.apiStatus; + // Constructed only after a 200 status check when `parseJsonObject` fails — + // an API response problem, not a raw status failure. + return { ...actionability.apiStatus, fingerprint_suffix: "api_response" }; } } @@ -66,7 +68,9 @@ export class LegacyPostgresConfigUpdateUnmarshalError extends Data.TaggedError( readonly message: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.apiStatus; + // Constructed only after a 200 status check when `parseJsonObject` fails — + // an API response problem, not a raw status failure. + return { ...actionability.apiStatus, fingerprint_suffix: "api_response" }; } } @@ -108,7 +112,9 @@ export class LegacyPostgresConfigDeleteUnmarshalError extends Data.TaggedError( readonly message: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.apiStatus; + // Constructed only after a 200 status check when `parseJsonObject` fails — + // an API response problem, not a raw status failure. + return { ...actionability.apiStatus, fingerprint_suffix: "api_response" }; } } diff --git a/apps/cli/src/legacy/shared/legacy-edge-runtime-script.errors.ts b/apps/cli/src/legacy/shared/legacy-edge-runtime-script.errors.ts index bea984e285..6b2b6e576e 100644 --- a/apps/cli/src/legacy/shared/legacy-edge-runtime-script.errors.ts +++ b/apps/cli/src/legacy/shared/legacy-edge-runtime-script.errors.ts @@ -15,8 +15,22 @@ import { */ export class LegacyEdgeRuntimeScriptError extends Data.TaggedError("LegacyEdgeRuntimeScriptError")<{ readonly message: string; + /** + * Threaded from a wrapped `LegacyDockerRunError` so a docker-boundary failure + * (docker daemon down or registry pull) does not misclassify as a user-SQL + * (`dbFinding`) failure. `daemon` maps to docker-not-running, `pull` to an + * external network problem. `undefined` for genuine script/config failures, + * which keep the user-SQL classification. + */ + readonly docker?: "daemon" | "pull"; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + if (this.docker === "daemon") { + return { ...actionability.dockerNotRunning, fingerprint_suffix: "docker_not_running" }; + } + if (this.docker === "pull") { + return { ...actionability.externalNetwork, fingerprint_suffix: "registry_pull" }; + } return actionability.dbFinding; } } diff --git a/apps/cli/src/legacy/shared/legacy-edge-runtime-script.errors.unit.test.ts b/apps/cli/src/legacy/shared/legacy-edge-runtime-script.errors.unit.test.ts new file mode 100644 index 0000000000..df43fbdf2b --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-edge-runtime-script.errors.unit.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { classifyCliErrorActionability } from "../../shared/telemetry/error-actionability.ts"; +import { LegacyEdgeRuntimeScriptError } from "./legacy-edge-runtime-script.errors.ts"; + +describe("LegacyEdgeRuntimeScriptError actionability", () => { + it("classifies a docker-daemon failure as docker-not-running", () => { + const result = classifyCliErrorActionability( + new LegacyEdgeRuntimeScriptError({ message: "error diffing schema: ...", docker: "daemon" }), + ); + expect(result.error_kind).toBe("user_actionable"); + expect(result.error_category).toBe("docker_not_running"); + expect(result.suggestion_type).toBe("start_docker"); + expect(result.error_fingerprint).toBe("tag:LegacyEdgeRuntimeScriptError:docker_not_running"); + }); + + it("classifies a registry-pull failure as an external network problem", () => { + const result = classifyCliErrorActionability( + new LegacyEdgeRuntimeScriptError({ message: "error diffing schema: ...", docker: "pull" }), + ); + expect(result.error_kind).toBe("external_service"); + expect(result.error_category).toBe("network"); + expect(result.error_fingerprint).toBe("tag:LegacyEdgeRuntimeScriptError:registry_pull"); + }); + + it("classifies a non-docker script failure as a user db finding", () => { + const result = classifyCliErrorActionability( + new LegacyEdgeRuntimeScriptError({ message: "error diffing schema: exit 1:\n..." }), + ); + expect(result.error_kind).toBe("user_actionable"); + expect(result.error_category).toBe("invalid_config"); + expect(result.has_suggestion).toBe(false); + expect(result.error_fingerprint).toBe("tag:LegacyEdgeRuntimeScriptError"); + }); +}); diff --git a/apps/cli/src/legacy/shared/legacy-edge-runtime-script.layer.ts b/apps/cli/src/legacy/shared/legacy-edge-runtime-script.layer.ts index 5b7d273c78..1fa9aad56d 100644 --- a/apps/cli/src/legacy/shared/legacy-edge-runtime-script.layer.ts +++ b/apps/cli/src/legacy/shared/legacy-edge-runtime-script.layer.ts @@ -112,11 +112,14 @@ export const legacyEdgeRuntimeScriptLayer = Layer.effect( }) // A spawn failure (e.g. Docker not installed) carries no container // stderr; wrap it with the caller's prefix like Go's `%s: %w`. + // Thread the docker discriminant so a daemon-down / registry-pull + // failure at the docker boundary is not misclassified as user SQL. .pipe( Effect.mapError( (cause) => new LegacyEdgeRuntimeScriptError({ message: `${opts.errPrefix}: ${cause.message}`, + docker: cause.reason === "spawn" || cause.daemonDown ? "daemon" : "pull", }), ), ); diff --git a/apps/cli/src/legacy/shared/legacy-storage-credentials.errors.ts b/apps/cli/src/legacy/shared/legacy-storage-credentials.errors.ts index 9ba8913b7a..b4d1cc02fa 100644 --- a/apps/cli/src/legacy/shared/legacy-storage-credentials.errors.ts +++ b/apps/cli/src/legacy/shared/legacy-storage-credentials.errors.ts @@ -36,7 +36,9 @@ export class LegacyStorageMissingApiKeyError extends Data.TaggedError( readonly message: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.apiStatus; + // A 200 api-keys response with no usable key — an API response problem, not + // a raw status failure. + return { ...actionability.apiStatus, fingerprint_suffix: "api_response" }; } } diff --git a/apps/cli/src/legacy/shared/legacy-storage-gateway.errors.ts b/apps/cli/src/legacy/shared/legacy-storage-gateway.errors.ts index d953069b53..d1a1a5a9d3 100644 --- a/apps/cli/src/legacy/shared/legacy-storage-gateway.errors.ts +++ b/apps/cli/src/legacy/shared/legacy-storage-gateway.errors.ts @@ -23,9 +23,17 @@ export class LegacyStorageGatewayNetworkError extends Data.TaggedError( "LegacyStorageGatewayNetworkError", )<{ readonly message: string; + /** + * Set when this failure is a 200-response body that failed to decode + * (`failParse`) rather than a transport failure — so a malformed-body decode + * classifies as an API response problem instead of a network problem. + */ + readonly decode?: boolean; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.externalNetwork; + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; } } diff --git a/apps/cli/src/legacy/shared/legacy-storage-gateway.ts b/apps/cli/src/legacy/shared/legacy-storage-gateway.ts index 96bf8eabfb..4738a57b42 100644 --- a/apps/cli/src/legacy/shared/legacy-storage-gateway.ts +++ b/apps/cli/src/legacy/shared/legacy-storage-gateway.ts @@ -137,6 +137,7 @@ export interface LegacyStorageGateway { function failParse(detail: string): LegacyStorageGatewayNetworkError { return new LegacyStorageGatewayNetworkError({ message: `failed to parse response body: ${detail}`, + decode: true, }); } diff --git a/apps/cli/src/shared/functions/download.errors.ts b/apps/cli/src/shared/functions/download.errors.ts index dc6aaadda5..9baf333b07 100644 --- a/apps/cli/src/shared/functions/download.errors.ts +++ b/apps/cli/src/shared/functions/download.errors.ts @@ -39,7 +39,9 @@ export class InvalidFunctionDownloadResponseError extends Data.TaggedError( readonly message: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.apiStatus; + // Every construction is a 200-response whose multipart/metadata/list body + // failed to decode — an API response problem, not a raw status failure. + return { ...actionability.apiStatus, fingerprint_suffix: "api_response" }; } } From 18e62e84e0abd5b5c1f42b8764c34cb44f4e33e0 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 9 Jul 2026 16:10:59 +0200 Subject: [PATCH 18/20] chore(cli): classify response-shape and flag-input consistency stragglers - NoProjectApiKeyError and LegacyBranchesPrimaryNotFoundError fingerprint api_response: both fire on successful responses with missing expected content, matching their storage/decode siblings. - UnsafeFunctionDownloadPathError discriminates unsafe remote response paths (api_response) from local temp-file write failures (permission). - LegacyDbConfigParseUrlError classifies provideFlags: its only site is a user-supplied --db-url failing to parse, matching the other invalid database URL errors. Co-Authored-By: Claude Fable 5 --- .../commands/branches/branches.errors.ts | 4 +- .../legacy/shared/legacy-db-config.errors.ts | 2 +- .../next/config/project-link-remote.layer.ts | 4 +- .../src/shared/functions/download.errors.ts | 10 +++++ .../functions/download.errors.unit.test.ts | 38 +++++++++++++++++++ apps/cli/src/shared/functions/download.ts | 2 + 6 files changed, 57 insertions(+), 3 deletions(-) create mode 100644 apps/cli/src/shared/functions/download.errors.unit.test.ts diff --git a/apps/cli/src/legacy/commands/branches/branches.errors.ts b/apps/cli/src/legacy/commands/branches/branches.errors.ts index a92cfe90bd..288bcd5605 100644 --- a/apps/cli/src/legacy/commands/branches/branches.errors.ts +++ b/apps/cli/src/legacy/commands/branches/branches.errors.ts @@ -173,7 +173,9 @@ export class LegacyBranchesPrimaryNotFoundError extends Data.TaggedError( readonly message: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.apiStatus; + // A successful pooler-config response with no PRIMARY entry — an API + // response problem, not a raw status failure. + return { ...actionability.apiStatus, fingerprint_suffix: "api_response" }; } } diff --git a/apps/cli/src/legacy/shared/legacy-db-config.errors.ts b/apps/cli/src/legacy/shared/legacy-db-config.errors.ts index 93979c11ae..0bc64051f9 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.errors.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.errors.ts @@ -16,7 +16,7 @@ export class LegacyDbConfigParseUrlError extends Data.TaggedError("LegacyDbConfi readonly message: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.invalidConfig; + return actionability.provideFlags; } } diff --git a/apps/cli/src/next/config/project-link-remote.layer.ts b/apps/cli/src/next/config/project-link-remote.layer.ts index f697973fd5..a054fa984a 100644 --- a/apps/cli/src/next/config/project-link-remote.layer.ts +++ b/apps/cli/src/next/config/project-link-remote.layer.ts @@ -27,7 +27,9 @@ export class NoProjectApiKeyError extends Data.TaggedError("NoProjectApiKeyError readonly projectRef: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.permission; + // A successful api-keys response with no usable key — an API response + // problem, not a raw status failure. + return { ...actionability.apiStatus, fingerprint_suffix: "api_response" }; } } diff --git a/apps/cli/src/shared/functions/download.errors.ts b/apps/cli/src/shared/functions/download.errors.ts index 9baf333b07..2883d0b782 100644 --- a/apps/cli/src/shared/functions/download.errors.ts +++ b/apps/cli/src/shared/functions/download.errors.ts @@ -49,8 +49,18 @@ export class UnsafeFunctionDownloadPathError extends Data.TaggedError( "UnsafeFunctionDownloadPathError", )<{ readonly message: string; + /** + * True when a 200 response's multipart filename/metadata entrypoint + * resolved outside `supabase/functions` — an API response problem, not a + * local write/rename failure (the other construction sites, which keep the + * default `permission` classification). + */ + readonly unsafeResponsePath?: boolean; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + if (this.unsafeResponsePath === true) { + return { ...actionability.apiStatus, fingerprint_suffix: "api_response" }; + } return actionability.permission; } } diff --git a/apps/cli/src/shared/functions/download.errors.unit.test.ts b/apps/cli/src/shared/functions/download.errors.unit.test.ts new file mode 100644 index 0000000000..8b69507070 --- /dev/null +++ b/apps/cli/src/shared/functions/download.errors.unit.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { classifyCliErrorActionability } from "../telemetry/error-actionability.ts"; +import { UnsafeFunctionDownloadPathError } from "./download.errors.ts"; + +describe("UnsafeFunctionDownloadPathError actionability", () => { + it("classifies a response-derived unsafe path as an API response problem", () => { + const result = classifyCliErrorActionability( + new UnsafeFunctionDownloadPathError({ + message: "refusing to extract Function file outside supabase/functions: ../evil", + unsafeResponsePath: true, + }), + ); + expect(result.error_kind).toBe("external_service"); + expect(result.error_category).toBe("api_status"); + expect(result.error_fingerprint).toBe("tag:UnsafeFunctionDownloadPathError:api_response"); + }); + + it("keeps a local temp-file write/rename failure on the permission policy", () => { + const result = classifyCliErrorActionability( + new UnsafeFunctionDownloadPathError({ + message: "failed to write Function file: index.ts: EACCES", + }), + ); + expect(result.error_kind).toBe("user_actionable"); + expect(result.error_category).toBe("permission"); + expect(result.error_fingerprint).toBe("tag:UnsafeFunctionDownloadPathError"); + }); + + it("keeps an explicitly-false unsafeResponsePath on the permission policy", () => { + const result = classifyCliErrorActionability( + new UnsafeFunctionDownloadPathError({ + message: "failed to create temporary Function file while extracting index.ts: EACCES", + unsafeResponsePath: false, + }), + ); + expect(result.error_category).toBe("permission"); + }); +}); diff --git a/apps/cli/src/shared/functions/download.ts b/apps/cli/src/shared/functions/download.ts index e57fde578a..251746b69d 100644 --- a/apps/cli/src/shared/functions/download.ts +++ b/apps/cli/src/shared/functions/download.ts @@ -524,6 +524,7 @@ function resolveDownloadDestination( return Effect.fail( new UnsafeFunctionDownloadPathError({ message: `refusing to extract Function file outside ${functionsRoot}: ${partPath}`, + unsafeResponsePath: true, }), ); } @@ -536,6 +537,7 @@ function ensureContainedPath(root: string, candidate: string, sourcePath: string return Effect.fail( new UnsafeFunctionDownloadPathError({ message: `refusing to extract Function file outside ${root}: ${sourcePath}`, + unsafeResponsePath: true, }), ); } From 055ebcbdca9f63affc36f9b3d53b6eaa9aa2ed81 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 9 Jul 2026 16:52:34 +0200 Subject: [PATCH 19/20] chore(cli): classify input-phase schema failures, 404 parity, and wrapper follow-ups - packages/api raises SupabaseApiInputError when the generated client's input schema rejects user-provided values before any request, and the adapter classifies it invalid_input/request_input; response decode keeps api_response. - The raw HttpClientError adapter gains the 404 -> not_found branch for parity with the shared status policy. - NoBranchNameError's declined-prompt path classifies user_cancelled; legacy branches create 409 classifies invalid_input/conflict when not plan-gated, matching the next shell's BranchAlreadyExistsError. - Docker discriminants: LegacyDockerLifecycleInspectError detects daemon-down output (status/stop), LegacyMigraDiffError threads the docker reason through the migra fallback like the edge-runtime twin. - FunctionsApiStatusError marks successful-status decode failures as api_response; LegacyDbPullDumpError splits local file-open failures (permission) from pg_dump runs (db_connection). Co-Authored-By: Claude Fable 5 --- .../commands/branches/branches.errors.ts | 8 +++++ .../branches/branches.errors.unit.test.ts | 23 +++++++++++++ .../legacy/commands/db/pull/pull.errors.ts | 9 ++++- .../commands/db/pull/pull.errors.unit.test.ts | 26 ++++++++++++++ .../legacy/commands/db/pull/pull.handler.ts | 5 ++- .../commands/db/shared/legacy-migra.errors.ts | 14 ++++++++ .../shared/legacy-migra.errors.unit.test.ts | 34 +++++++++++++++++++ .../legacy/commands/db/shared/legacy-migra.ts | 9 ++++- .../legacy/shared/legacy-docker-lifecycle.ts | 26 ++++++++++---- .../legacy-docker-lifecycle.unit.test.ts | 13 +++++++ .../branches/create/create.handler.ts | 1 + apps/cli/src/next/commands/branches/errors.ts | 9 ++++- .../commands/branches/errors.unit.test.ts | 31 +++++++++++++++++ apps/cli/src/shared/functions/deploy.ts | 2 ++ .../shared/functions/functions-api.errors.ts | 11 ++++++ .../functions-api.errors.unit.test.ts | 13 +++++++ .../shared/telemetry/error-actionability.ts | 10 ++++++ .../error-actionability.unit.test.ts | 18 ++++++++++ packages/api/src/effect.ts | 2 +- packages/api/src/internal/client.ts | 28 +++++++++++++-- 20 files changed, 278 insertions(+), 14 deletions(-) create mode 100644 apps/cli/src/legacy/commands/db/pull/pull.errors.unit.test.ts create mode 100644 apps/cli/src/legacy/commands/db/shared/legacy-migra.errors.unit.test.ts create mode 100644 apps/cli/src/next/commands/branches/errors.unit.test.ts diff --git a/apps/cli/src/legacy/commands/branches/branches.errors.ts b/apps/cli/src/legacy/commands/branches/branches.errors.ts index 288bcd5605..1634a7441b 100644 --- a/apps/cli/src/legacy/commands/branches/branches.errors.ts +++ b/apps/cli/src/legacy/commands/branches/branches.errors.ts @@ -60,6 +60,14 @@ export class LegacyBranchesCreateUnexpectedStatusError extends Data.TaggedError( readonly upgradeSuggested?: boolean; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + // A non-gated 409 on `branches create` means the branch name already + // exists (the next shell maps this endpoint's 409 to + // BranchAlreadyExistsError) — user input, not a raw API status. The gate + // guard stays ahead so a confirmed plan-limited 409 still classifies as + // plan_limit via the shared policy. + if (this.upgradeSuggested !== true && this.status === 409) { + return { ...actionability.invalidInput, fingerprint_suffix: "conflict" }; + } return statusCodeActionability(this.status, { upgradeSuggested: this.upgradeSuggested }); } } diff --git a/apps/cli/src/legacy/commands/branches/branches.errors.unit.test.ts b/apps/cli/src/legacy/commands/branches/branches.errors.unit.test.ts index 96f2bcd19a..a28b49df68 100644 --- a/apps/cli/src/legacy/commands/branches/branches.errors.unit.test.ts +++ b/apps/cli/src/legacy/commands/branches/branches.errors.unit.test.ts @@ -89,3 +89,26 @@ describe("gated branch operation 404s", () => { expect(result.suggestion_type).toBe("upgrade_plan"); }); }); + +describe("branches create 409 = duplicate branch name", () => { + it("create 409 without an upgrade gate → invalid input (conflict)", () => { + const result = classifyCliErrorActionability( + new LegacyBranchesCreateUnexpectedStatusError({ status: 409, ...body }), + ); + expect(result.error_kind).toBe("user_actionable"); + expect(result.error_category).toBe("invalid_input"); + expect(result.error_fingerprint).toBe("tag:LegacyBranchesCreateUnexpectedStatusError:conflict"); + }); + + it("create: a confirmed plan gate is not shadowed by the 409 branch", () => { + const result = classifyCliErrorActionability( + new LegacyBranchesCreateUnexpectedStatusError({ + status: 409, + ...body, + upgradeSuggested: true, + }), + ); + expect(result.error_category).toBe("plan_limit"); + expect(result.suggestion_type).toBe("upgrade_plan"); + }); +}); diff --git a/apps/cli/src/legacy/commands/db/pull/pull.errors.ts b/apps/cli/src/legacy/commands/db/pull/pull.errors.ts index 2b2f038b89..a65421d37e 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.errors.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.errors.ts @@ -86,8 +86,15 @@ export class LegacyDbPullWriteError extends Data.TaggedError("LegacyDbPullWriteE export class LegacyDbPullDumpError extends Data.TaggedError("LegacyDbPullDumpError")<{ readonly message: string; readonly suggestion?: string; + /** + * Set when the failure is opening/truncating the local migration file before + * any pg_dump attempt — a filesystem permission problem, not a database + * connection failure. The actual pg_dump-run failures leave it unset and keep + * the `dbConnection` classification. + */ + readonly fileOpen?: boolean; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.dbConnection; + return this.fileOpen === true ? actionability.permission : actionability.dbConnection; } } diff --git a/apps/cli/src/legacy/commands/db/pull/pull.errors.unit.test.ts b/apps/cli/src/legacy/commands/db/pull/pull.errors.unit.test.ts new file mode 100644 index 0000000000..a5a5a95b15 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/pull/pull.errors.unit.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { classifyCliErrorActionability } from "../../../../shared/telemetry/error-actionability.ts"; +import { LegacyDbPullDumpError } from "./pull.errors.ts"; + +describe("LegacyDbPullDumpError actionability", () => { + it("classifies a local migration-file open failure as a permission problem", () => { + const result = classifyCliErrorActionability( + new LegacyDbPullDumpError({ + message: "failed to open dump file: permission denied", + fileOpen: true, + }), + ); + expect(result.error_kind).toBe("user_actionable"); + expect(result.error_category).toBe("permission"); + expect(result.error_fingerprint).toBe("tag:LegacyDbPullDumpError"); + }); + + it("classifies a pg_dump-run failure as a db-connection problem", () => { + const result = classifyCliErrorActionability( + new LegacyDbPullDumpError({ message: "error running container: exit 1" }), + ); + expect(result.error_kind).toBe("user_actionable"); + expect(result.error_category).toBe("db_connection"); + expect(result.error_fingerprint).toBe("tag:LegacyDbPullDumpError"); + }); +}); diff --git a/apps/cli/src/legacy/commands/db/pull/pull.handler.ts b/apps/cli/src/legacy/commands/db/pull/pull.handler.ts index e95fc934f2..a9723b4f7b 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.handler.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.handler.ts @@ -479,7 +479,10 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy columnInsert: false, }; const toDumpOpenError = (cause: { readonly message: string }) => - new LegacyDbPullDumpError({ message: `failed to open dump file: ${cause.message}` }); + new LegacyDbPullDumpError({ + message: `failed to open dump file: ${cause.message}`, + fileOpen: true, + }); // Stream pg_dump → migration file, (re)truncating per attempt so a pooler // retry leaves only the successful attempt's bytes (Go's `resetOutput`). const runSchemaDump = (target: LegacyPgConnInput) => { diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-migra.errors.ts b/apps/cli/src/legacy/commands/db/shared/legacy-migra.errors.ts index c80f93bf1e..3ad13e2c76 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-migra.errors.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-migra.errors.ts @@ -14,8 +14,22 @@ import { */ export class LegacyMigraDiffError extends Data.TaggedError("LegacyMigraDiffError")<{ readonly message: string; + /** + * Threaded from a wrapped `LegacyDockerRunError` in the OOM bash fallback so a + * docker-boundary failure (docker daemon down or registry pull) does not + * misclassify as a user-SQL (`dbFinding`) failure. `daemon` maps to + * docker-not-running, `pull` to an external network problem. `undefined` for + * genuine diff/script failures, which keep the user-SQL classification. + */ + readonly docker?: "daemon" | "pull"; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + if (this.docker === "daemon") { + return { ...actionability.dockerNotRunning, fingerprint_suffix: "docker_not_running" }; + } + if (this.docker === "pull") { + return { ...actionability.externalNetwork, fingerprint_suffix: "registry_pull" }; + } return actionability.dbFinding; } } diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-migra.errors.unit.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-migra.errors.unit.test.ts new file mode 100644 index 0000000000..80674a14f7 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-migra.errors.unit.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { classifyCliErrorActionability } from "../../../../shared/telemetry/error-actionability.ts"; +import { LegacyMigraDiffError } from "./legacy-migra.errors.ts"; + +describe("LegacyMigraDiffError actionability", () => { + it("classifies a docker-daemon failure as docker-not-running", () => { + const result = classifyCliErrorActionability( + new LegacyMigraDiffError({ message: "error diffing schema: ...", docker: "daemon" }), + ); + expect(result.error_kind).toBe("user_actionable"); + expect(result.error_category).toBe("docker_not_running"); + expect(result.suggestion_type).toBe("start_docker"); + expect(result.error_fingerprint).toBe("tag:LegacyMigraDiffError:docker_not_running"); + }); + + it("classifies a registry-pull failure as an external network problem", () => { + const result = classifyCliErrorActionability( + new LegacyMigraDiffError({ message: "error diffing schema: ...", docker: "pull" }), + ); + expect(result.error_kind).toBe("external_service"); + expect(result.error_category).toBe("network"); + expect(result.error_fingerprint).toBe("tag:LegacyMigraDiffError:registry_pull"); + }); + + it("classifies a non-docker diff failure as a user db finding", () => { + const result = classifyCliErrorActionability( + new LegacyMigraDiffError({ message: "error diffing schema:\n..." }), + ); + expect(result.error_kind).toBe("user_actionable"); + expect(result.error_category).toBe("invalid_config"); + expect(result.has_suggestion).toBe(false); + expect(result.error_fingerprint).toBe("tag:LegacyMigraDiffError"); + }); +}); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-migra.ts b/apps/cli/src/legacy/commands/db/shared/legacy-migra.ts index be5af7dd2d..ae0450be25 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-migra.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-migra.ts @@ -225,7 +225,14 @@ const diffMigraBash = Effect.fnUntraced(function* (params: { }) .pipe( Effect.mapError( - (cause) => new LegacyMigraDiffError({ message: `error diffing schema: ${cause.message}` }), + (cause) => + new LegacyMigraDiffError({ + message: `error diffing schema: ${cause.message}`, + // Thread the docker discriminant so a daemon-down / registry-pull + // failure at the docker boundary is not misclassified as user SQL, + // mirroring the edge-runtime-script fix. + docker: cause.reason === "spawn" || cause.daemonDown ? "daemon" : "pull", + }), ), ); if (result.exitCode !== 0) { diff --git a/apps/cli/src/legacy/shared/legacy-docker-lifecycle.ts b/apps/cli/src/legacy/shared/legacy-docker-lifecycle.ts index e555ed721a..1951d9be33 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-lifecycle.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-lifecycle.ts @@ -1,3 +1,4 @@ +import { isDockerDaemonDownMessage } from "@supabase/stack"; import { Data, Effect, Stream } from "effect"; import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; @@ -43,8 +44,19 @@ export class LegacyDockerLifecycleInspectError extends Data.TaggedError( "LegacyDockerLifecycleInspectError", )<{ readonly message: string; + /** + * Set at the docker boundary when the runtime's own output indicates the + * daemon itself is unreachable (detected via `isDockerDaemonDownMessage`, so + * consumers never sniff `message` text). Daemon-down is docker-not-running; + * every other inspect failure (the dominant "stack isn't running yet" case) + * keeps the `startStack` classification. + */ + readonly daemonDown?: boolean; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + if (this.daemonDown === true) { + return { ...actionability.dockerNotRunning, fingerprint_suffix: "docker_not_running" }; + } return actionability.startStack; } } @@ -155,12 +167,13 @@ export const legacyInspectContainerState = (spawner: Spawner, containerId: strin stderr: "pipe", }, ).pipe( - Effect.mapError( - (cause) => - new LegacyDockerLifecycleInspectError({ - message: `failed to inspect container health: ${legacyDescribeContainerCliFailure(cause)}`, - }), - ), + Effect.mapError((cause) => { + const description = legacyDescribeContainerCliFailure(cause); + return new LegacyDockerLifecycleInspectError({ + message: `failed to inspect container health: ${description}`, + daemonDown: isDockerDaemonDownMessage(description), + }); + }), ); // Concurrency is required, not cosmetic — see the matching comment in // `legacyListContainersByLabel` above. @@ -187,6 +200,7 @@ export const legacyInspectContainerState = (spawner: Spawner, containerId: strin message.length > 0 ? `failed to inspect container health: ${message}` : "failed to inspect container health", + daemonDown: isDockerDaemonDownMessage(message), }), ); } diff --git a/apps/cli/src/legacy/shared/legacy-docker-lifecycle.unit.test.ts b/apps/cli/src/legacy/shared/legacy-docker-lifecycle.unit.test.ts index 0ecc41b372..748c64c88a 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-lifecycle.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-lifecycle.unit.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "@effect/vitest"; import { Deferred, Effect, Sink, Stream } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; +import { classifyCliErrorActionability } from "../../shared/telemetry/error-actionability.ts"; import { LegacyDockerLifecycleInspectError, LegacyDockerLifecycleListError, @@ -237,6 +238,10 @@ describe("legacyInspectContainerState", () => { expect(error.message).toBe( "failed to inspect container health: Error response from daemon: No such container: supabase_db_my-app", ); + // The dominant "stack isn't running yet" case: not daemon-down, so it + // keeps the start-stack classification. + expect(error.daemonDown).toBeFalsy(); + expect(classifyCliErrorActionability(error).error_category).toBe("invalid_config"); }), ); }, @@ -251,6 +256,14 @@ describe("legacyInspectContainerState", () => { expect(error.message).toBe( "failed to inspect container health: Cannot connect to the Docker daemon", ); + // A daemon-down stderr flips the discriminant so the failure classifies + // as docker-not-running instead of a broken running stack. + expect(error.daemonDown).toBe(true); + const result = classifyCliErrorActionability(error); + expect(result.error_category).toBe("docker_not_running"); + expect(result.error_fingerprint).toBe( + "tag:LegacyDockerLifecycleInspectError:docker_not_running", + ); }), ); }); diff --git a/apps/cli/src/next/commands/branches/create/create.handler.ts b/apps/cli/src/next/commands/branches/create/create.handler.ts index 2f42731fd7..d6a4493736 100644 --- a/apps/cli/src/next/commands/branches/create/create.handler.ts +++ b/apps/cli/src/next/commands/branches/create/create.handler.ts @@ -50,6 +50,7 @@ const resolveBranchName = Effect.fnUntraced(function* (nameOpt: Option.Option`", + cancelled: true, }), ); } diff --git a/apps/cli/src/next/commands/branches/errors.ts b/apps/cli/src/next/commands/branches/errors.ts index f9ca32c23f..a64d45b668 100644 --- a/apps/cli/src/next/commands/branches/errors.ts +++ b/apps/cli/src/next/commands/branches/errors.ts @@ -17,9 +17,16 @@ export class BranchNotFoundError extends Data.TaggedError("BranchNotFoundError") export class NoBranchNameError extends Data.TaggedError("NoBranchNameError")<{ readonly detail: string; readonly suggestion: string; + /** + * Set when the user declined the "create branch named …?" prompt: the + * failure is a deliberate cancellation, not missing input. Left unset for + * the genuine "no name and no way to obtain one" paths, which stay + * `provideFlags`. + */ + readonly cancelled?: boolean; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.provideFlags; + return this.cancelled === true ? actionability.cancelled : actionability.provideFlags; } } diff --git a/apps/cli/src/next/commands/branches/errors.unit.test.ts b/apps/cli/src/next/commands/branches/errors.unit.test.ts new file mode 100644 index 0000000000..a2622122fe --- /dev/null +++ b/apps/cli/src/next/commands/branches/errors.unit.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { classifyCliErrorActionability } from "../../../shared/telemetry/error-actionability.ts"; +import { NoBranchNameError } from "./errors.ts"; + +describe("NoBranchNameError actionability", () => { + it("classifies a declined-prompt cancellation as user-cancelled", () => { + const result = classifyCliErrorActionability( + new NoBranchNameError({ + detail: "Branch creation cancelled.", + suggestion: "Provide a branch name: `supabase branches create `", + cancelled: true, + }), + ); + expect(result.error_kind).toBe("user_cancelled"); + expect(result.error_category).toBe("cancelled"); + expect(result.error_fingerprint).toBe("tag:NoBranchNameError"); + }); + + it("classifies a genuinely missing branch name as provide-flags", () => { + const result = classifyCliErrorActionability( + new NoBranchNameError({ + detail: "No branch name provided and no git branch detected.", + suggestion: "Provide a branch name: `supabase branches create `", + }), + ); + expect(result.error_kind).toBe("user_actionable"); + expect(result.error_category).toBe("invalid_input"); + expect(result.suggestion_type).toBe("provide_flags"); + expect(result.error_fingerprint).toBe("tag:NoBranchNameError"); + }); +}); diff --git a/apps/cli/src/shared/functions/deploy.ts b/apps/cli/src/shared/functions/deploy.ts index 992ae49f6b..4f7d6e726d 100644 --- a/apps/cli/src/shared/functions/deploy.ts +++ b/apps/cli/src/shared/functions/deploy.ts @@ -1604,6 +1604,7 @@ const uploadFunctionSource = Effect.fnUntraced(function* ( new FunctionsApiStatusError({ status: response.status, message: `failed to read deploy response: ${error instanceof Error ? error.message : String(error)}`, + decode: true, }), }); }); @@ -1737,6 +1738,7 @@ const upsertBundledFunction = Effect.fnUntraced(function* ( new FunctionsApiStatusError({ status: response.value.status, message: `failed to read function response: ${error instanceof Error ? error.message : String(error)}`, + decode: true, }), }); } diff --git a/apps/cli/src/shared/functions/functions-api.errors.ts b/apps/cli/src/shared/functions/functions-api.errors.ts index 63280c85d3..b000f6524a 100644 --- a/apps/cli/src/shared/functions/functions-api.errors.ts +++ b/apps/cli/src/shared/functions/functions-api.errors.ts @@ -17,8 +17,19 @@ import { export class FunctionsApiStatusError extends Data.TaggedError("FunctionsApiStatusError")<{ readonly status: number; readonly message: string; + /** + * Set when the failure is a successful-status response whose body could not + * be decoded (status is 200/201 but the JSON is malformed / unexpected). + * That is an API-response problem, not a raw status failure, so it classifies + * as `api_status` with the `api_response` fingerprint ahead of the + * status-code policy. + */ + readonly decode?: boolean; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + if (this.decode === true) { + return { ...actionability.apiStatus, fingerprint_suffix: "api_response" }; + } return statusCodeActionability(this.status); } } diff --git a/apps/cli/src/shared/functions/functions-api.errors.unit.test.ts b/apps/cli/src/shared/functions/functions-api.errors.unit.test.ts index 0621878e3d..47e4827d85 100644 --- a/apps/cli/src/shared/functions/functions-api.errors.unit.test.ts +++ b/apps/cli/src/shared/functions/functions-api.errors.unit.test.ts @@ -25,4 +25,17 @@ describe("FunctionsApiStatusError actionability", () => { ); expect(result.error_category).toBe("api_status"); }); + + it("classifies a successful-status decode failure as an api-response problem", () => { + const result = classifyCliErrorActionability( + new FunctionsApiStatusError({ + status: 201, + message: "failed to read deploy response: unexpected token", + decode: true, + }), + ); + expect(result.error_kind).toBe("external_service"); + expect(result.error_category).toBe("api_status"); + expect(result.error_fingerprint).toBe("tag:FunctionsApiStatusError:api_response"); + }); }); diff --git a/apps/cli/src/shared/telemetry/error-actionability.ts b/apps/cli/src/shared/telemetry/error-actionability.ts index 94939bbc0f..950cbc7876 100644 --- a/apps/cli/src/shared/telemetry/error-actionability.ts +++ b/apps/cli/src/shared/telemetry/error-actionability.ts @@ -476,6 +476,15 @@ const externalActionabilityByTag: Record< // access token / bad configuration); remediation is the token env var. SupabaseApiConfigError: () => actionability.authToken, + // @supabase/api — the generated client's input schema rejected the request + // input before any request was sent (e.g. a user-supplied project ref that + // violates the `ref` pattern). The fix is the user's own input, so this is + // invalid input — distinct from the response-decode SchemaError below. + SupabaseApiInputError: () => ({ + ...actionability.invalidInput, + fingerprint_suffix: "request_input", + }), + // effect/unstable/http — generated Management API client transport/decoding HttpClientError: (error) => { const reason = error["reason"]; @@ -484,6 +493,7 @@ const externalActionabilityByTag: Record< const status = isErrorRecord(response) ? readNumber(response, "status") : undefined; if (status === 401) return { ...actionability.authLogin, fingerprint_suffix: "auth" }; if (status === 403) return { ...actionability.accountAccess, fingerprint_suffix: "forbidden" }; + if (status === 404) return { ...actionability.invalidInput, fingerprint_suffix: "not_found" }; if (reasonTag === "StatusCodeError" || isErrorRecord(response)) { return { ...actionability.apiStatus, fingerprint_suffix: "api_status" }; } diff --git a/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts b/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts index 9808b5dcdd..26dc3586b1 100644 --- a/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts +++ b/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts @@ -127,6 +127,14 @@ describe("classifyCliErrorActionability", () => { }); expect(auth.error_category).toBe("auth"); + const notFound = classifyCliErrorActionability({ + _tag: "HttpClientError", + response: { status: 404 }, + }); + expect(notFound.error_kind).toBe("user_actionable"); + expect(notFound.error_category).toBe("invalid_input"); + expect(notFound.error_fingerprint).toBe("tag:HttpClientError:not_found"); + const status = classifyCliErrorActionability({ _tag: "HttpClientError", response: { status: 503 }, @@ -266,6 +274,16 @@ describe("classifyCliErrorActionability", () => { expect(result.suggestion_type).toBe("set_env_var"); }); + it("classifies API client input-schema rejections as invalid user input", () => { + const result = classifyCliErrorActionability({ + _tag: "SupabaseApiInputError", + message: "ref must match pattern", + }); + expect(result.error_kind).toBe("user_actionable"); + expect(result.error_category).toBe("invalid_input"); + expect(result.error_fingerprint).toBe("tag:SupabaseApiInputError:request_input"); + }); + it("caps cause-chain recursion instead of overflowing on cycles", () => { const a: Record = { _tag: "StackBuildError", diff --git a/packages/api/src/effect.ts b/packages/api/src/effect.ts index 0cb0a4d4fe..d3bce6c5d4 100644 --- a/packages/api/src/effect.ts +++ b/packages/api/src/effect.ts @@ -12,7 +12,7 @@ import { } from "./generated/effect-client.ts"; export type { SupabaseApiError, SupabaseApiRetryOptions } from "./internal/client.ts"; -export { SupabaseApiConfigError } from "./internal/client.ts"; +export { SupabaseApiConfigError, SupabaseApiInputError } from "./internal/client.ts"; export type { SupabaseApiClientOptions, SupabaseApiConfig } from "./internal/client.ts"; export { apiConfigLayer, DEFAULT_SUPABASE_API_URL } from "./config/api-config.layer.ts"; export { ApiConfig } from "./config/api-config.service.ts"; diff --git a/packages/api/src/internal/client.ts b/packages/api/src/internal/client.ts index 24dc02ed8b..95cf4f3e00 100644 --- a/packages/api/src/internal/client.ts +++ b/packages/api/src/internal/client.ts @@ -47,7 +47,8 @@ export interface SupabaseApiClientOptions { export type SupabaseApiError = | HttpBody.HttpBodyError | HttpClientError.HttpClientError - | SchemaError; + | SchemaError + | SupabaseApiInputError; export interface SupabaseApiClientShape { readonly execute: ( @@ -82,6 +83,23 @@ export class SupabaseApiConfigError extends Error { } } +/** + * The generated client's input schema rejected the request input before any + * request was sent (e.g. a user-supplied project ref that violates the `ref` + * pattern). Raised only on the request-input encoding/validation phase, so it + * is distinguishable from a response-decode {@link SchemaError}: the former is + * a user-input problem, the latter an API-response problem. The original + * schema failure is preserved as `cause`. + */ +export class SupabaseApiInputError extends Error { + readonly _tag = "SupabaseApiInputError"; + + constructor(message: string, options?: { readonly cause?: unknown }) { + super(message, options); + this.name = "SupabaseApiInputError"; + } +} + function resolveSupabaseApiConfig( config: SupabaseApiConfig = {}, ): Effect.Effect { @@ -516,7 +534,9 @@ export function makeSupabaseApiClient( return { execute: (definition, input) => Effect.gen(function* () { - const validated = yield* Schema.decodeUnknownEffect(definition.inputSchema)(input); + const validated = yield* Schema.decodeUnknownEffect(definition.inputSchema)(input).pipe( + Effect.mapError((error) => new SupabaseApiInputError(error.message, { cause: error })), + ); const response = yield* executeRequest(prepared, definition, validated); if (isJsonOperation(definition)) { return yield* decodeJsonResponse(definition, response); @@ -531,7 +551,9 @@ export function makeSupabaseApiClient( }), executeRaw: (definition, input, headers) => Effect.gen(function* () { - const validated = yield* Schema.decodeUnknownEffect(definition.inputSchema)(input); + const validated = yield* Schema.decodeUnknownEffect(definition.inputSchema)(input).pipe( + Effect.mapError((error) => new SupabaseApiInputError(error.message, { cause: error })), + ); const request = yield* buildRequest(definition, validated).pipe( Effect.map((request) => headers === undefined ? request : HttpClientRequest.setHeaders(request, headers), From db50a8e8218633dd3c5add78bbdb827ce17744ad Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 9 Jul 2026 18:27:25 +0200 Subject: [PATCH 20/20] chore(cli): pass through input errors and mark remaining decode sites - mapTransportError in functions deploy/download passes SupabaseApiInputError through unchanged, so a generated-client input rejection of a user flag keeps its invalid_input/request_input classification instead of network. - listRemoteFunctions' 200-decode failure constructs FunctionsApiStatusError with decode, closing the last plain-Error unknown leak on the deploy path. - Bootstrap's health mapper distinguishes generated-client body-decode causes (api_response) from genuine transport failures (network). Co-Authored-By: Claude Fable 5 --- .../commands/bootstrap/bootstrap.errors.ts | 7 ++++ .../commands/bootstrap/bootstrap.handler.ts | 14 +++++++- apps/cli/src/shared/functions/deploy.ts | 32 ++++++++++++++--- apps/cli/src/shared/functions/download.ts | 16 +++++++-- .../functions-api.errors.unit.test.ts | 13 +++++++ .../error-actionability.unit.test.ts | 35 +++++++++++++++++++ 6 files changed, 109 insertions(+), 8 deletions(-) diff --git a/apps/cli/src/legacy/commands/bootstrap/bootstrap.errors.ts b/apps/cli/src/legacy/commands/bootstrap/bootstrap.errors.ts index 5292d65307..9958af5c64 100644 --- a/apps/cli/src/legacy/commands/bootstrap/bootstrap.errors.ts +++ b/apps/cli/src/legacy/commands/bootstrap/bootstrap.errors.ts @@ -80,12 +80,19 @@ export class LegacyBootstrapHealthError extends Data.TaggedError("LegacyBootstra /** Set when the health poll itself failed with a non-200; absent when the * service reported unhealthy. */ readonly status?: number; + /** Set when the health poll's response came back with a 200 the generated + * client could not decode (`SchemaError`/`HttpBodyError`) — an API-response + * problem, not a transport failure. */ + readonly decode?: boolean; /** Set when the health poll failed without any HTTP response (DNS, TLS, * timeout) — a network failure, not an API status. */ readonly transport?: boolean; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { if (this.status !== undefined) return statusCodeActionability(this.status); + if (this.decode === true) { + return { ...actionability.apiStatus, fingerprint_suffix: "api_response" }; + } if (this.transport === true) { return { ...actionability.externalNetwork, fingerprint_suffix: "network" }; } diff --git a/apps/cli/src/legacy/commands/bootstrap/bootstrap.handler.ts b/apps/cli/src/legacy/commands/bootstrap/bootstrap.handler.ts index a6ec88f6a2..b064296103 100644 --- a/apps/cli/src/legacy/commands/bootstrap/bootstrap.handler.ts +++ b/apps/cli/src/legacy/commands/bootstrap/bootstrap.handler.ts @@ -318,6 +318,16 @@ export const legacyBootstrap = Effect.fn("legacy.bootstrap")(function* ( ); }); +// Whether `cause` is the generated client's `SchemaError`/`HttpBodyError` — a +// 200 response the client could not decode, as opposed to a transport +// failure (DNS, TLS, timeout). +function isDecodeFailureCause(cause: unknown): boolean { + if (typeof cause !== "object" || cause === null || !("_tag" in cause)) { + return false; + } + return cause._tag === "SchemaError" || cause._tag === "HttpBodyError"; +} + // Mirrors Go's `checkProjectHealth` non-200 branch: `Error status %d: %s`. const mapHealthError = (cause: unknown): Effect.Effect => { if (HttpClientError.isHttpClientError(cause) && cause.response !== undefined) { @@ -333,6 +343,8 @@ const mapHealthError = (cause: unknown): Effect.Effect "")); if (result.response.status === 200) { + // A 200 whose body is not the expected JSON is an API-response problem, + // not a transport failure — surface it via FunctionsApiStatusError so it + // classifies as api_status rather than network. return yield* Effect.try({ try: () => decodeFunctionListResponse(JSON.parse(body)), catch: (error) => - new Error( - `failed to read functions list: ${error instanceof Error ? error.message : String(error)}`, - ), + new FunctionsApiStatusError({ + status: result.response.status, + message: `failed to read functions list: ${error instanceof Error ? error.message : String(error)}`, + decode: true, + }), }); } lastError = new FunctionsApiStatusError({ diff --git a/apps/cli/src/shared/functions/download.ts b/apps/cli/src/shared/functions/download.ts index 251746b69d..62e032f152 100644 --- a/apps/cli/src/shared/functions/download.ts +++ b/apps/cli/src/shared/functions/download.ts @@ -1,4 +1,4 @@ -import { operationDefinitions, type ApiClient } from "@supabase/api/effect"; +import { operationDefinitions, SupabaseApiInputError, type ApiClient } from "@supabase/api/effect"; import { randomUUID } from "node:crypto"; import { open, rename, rm } from "node:fs/promises"; import { dirname, isAbsolute, join, posix, relative, resolve, sep } from "node:path"; @@ -150,7 +150,19 @@ function validateDownloadFlags( ); } -function mapTransportError(prefix: string, error: unknown): FunctionsApiTransportError { +function mapTransportError( + prefix: string, + error: unknown, +): FunctionsApiTransportError | SupabaseApiInputError { + // The generated client's input schema rejected the request before any + // request was sent (e.g. a user-supplied ref failing the `ref` pattern). + // That is an input-phase failure, not a transport failure — pass it through + // unchanged so the actionability adapter classifies it as invalid input + // instead of network. + if (error instanceof SupabaseApiInputError) { + return error; + } + if (HttpClientError.isHttpClientError(error)) { const description = error.reason.description ?? error.reason._tag; return new FunctionsApiTransportError({ message: `${prefix}: ${description}` }); diff --git a/apps/cli/src/shared/functions/functions-api.errors.unit.test.ts b/apps/cli/src/shared/functions/functions-api.errors.unit.test.ts index 47e4827d85..a14f0c37cc 100644 --- a/apps/cli/src/shared/functions/functions-api.errors.unit.test.ts +++ b/apps/cli/src/shared/functions/functions-api.errors.unit.test.ts @@ -38,4 +38,17 @@ describe("FunctionsApiStatusError actionability", () => { expect(result.error_category).toBe("api_status"); expect(result.error_fingerprint).toBe("tag:FunctionsApiStatusError:api_response"); }); + + it("classifies a 200 list-functions decode failure as an api-response problem", () => { + const result = classifyCliErrorActionability( + new FunctionsApiStatusError({ + status: 200, + message: "failed to read functions list: unexpected token", + decode: true, + }), + ); + expect(result.error_kind).toBe("external_service"); + expect(result.error_category).toBe("api_status"); + expect(result.error_fingerprint).toBe("tag:FunctionsApiStatusError:api_response"); + }); }); diff --git a/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts b/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts index 26dc3586b1..6f85795fd5 100644 --- a/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts +++ b/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts @@ -1,5 +1,6 @@ import { Cause, Data } from "effect"; import { describe, expect, it } from "vitest"; +import { LegacyBootstrapHealthError } from "../../legacy/commands/bootstrap/bootstrap.errors.ts"; import { actionability, type CliErrorActionabilityDeclaration, @@ -403,3 +404,37 @@ describe("classifyCliCauseActionability", () => { expect(classifyCliCauseActionability(cause).error_category).toBe("panic"); }); }); + +describe("LegacyBootstrapHealthError actionability", () => { + it("classifies a non-200 health poll on the status-code policy", () => { + const result = classifyCliErrorActionability( + new LegacyBootstrapHealthError({ message: "Error status 500: boom", status: 500 }), + ); + expect(result.error_category).toBe("api_status"); + }); + + it("classifies a 200 the generated client could not decode as an api-response problem", () => { + const result = classifyCliErrorActionability( + new LegacyBootstrapHealthError({ message: "Error status 0: boom", decode: true }), + ); + expect(result.error_kind).toBe("external_service"); + expect(result.error_category).toBe("api_status"); + expect(result.error_fingerprint).toBe("tag:LegacyBootstrapHealthError:api_response"); + }); + + it("classifies a responseless health poll failure as network", () => { + const result = classifyCliErrorActionability( + new LegacyBootstrapHealthError({ message: "Error status 0: boom", transport: true }), + ); + expect(result.error_kind).toBe("external_service"); + expect(result.error_category).toBe("network"); + expect(result.error_fingerprint).toBe("tag:LegacyBootstrapHealthError:network"); + }); + + it("falls back to the api-status policy for an unhealthy service report", () => { + const result = classifyCliErrorActionability( + new LegacyBootstrapHealthError({ message: "Service not healthy: db (unhealthy)" }), + ); + expect(result.error_category).toBe("api_status"); + }); +});