Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
afd73d0
chore(cli): define error actionability taxonomy with co-located decla…
jgoux Jul 8, 2026
68442e6
chore(cli): harden error actionability coverage and classifications
jgoux Jul 8, 2026
11232cb
chore(cli): address actionability review findings
jgoux Jul 8, 2026
8b677d6
chore(cli): classify wrapped causes, 403s, and api-client config fail…
jgoux Jul 8, 2026
c7e8441
Merge remote-tracking branch 'origin/develop' into julien/cli-1560-er…
jgoux Jul 8, 2026
c9af260
chore(cli): classify error types added on develop
jgoux Jul 8, 2026
5b22e0c
chore(cli): harden classifier recursion and dedupe review findings
jgoux Jul 9, 2026
dcc2a91
chore(repo): format packages/stack and require the monorepo-wide chec…
jgoux Jul 9, 2026
c30bd0f
chore(cli): classify functions API statuses, bootstrap health, and do…
jgoux Jul 9, 2026
a6d69ca
chore(cli): split bootstrap transport failures and scope download cau…
jgoux Jul 9, 2026
0309ab2
chore(cli): classify transport, decode, and daemon failure paths prec…
jgoux Jul 9, 2026
cc03e81
chore(cli): classify branch-lookup 404s as input and gateway auth as …
jgoux Jul 9, 2026
47664b6
chore(cli): implement the deferred classification refinements
jgoux Jul 9, 2026
4e888b3
chore(cli): finish decode, 404, and login-source classification strag…
jgoux Jul 9, 2026
8da9504
chore(cli): classify remaining 404, user-SQL, and wrapped-panic paths
jgoux Jul 9, 2026
8f9ae5b
chore(cli): close api-key 404, deploy body-order, and daemon decode gaps
jgoux Jul 9, 2026
510042c
chore(cli): promote ungated 404s to invalid input in the status policy
jgoux Jul 9, 2026
8b4d757
chore(cli): finish api_response fingerprints and wrapper discriminants
jgoux Jul 9, 2026
18e62e8
chore(cli): classify response-shape and flag-input consistency stragg…
jgoux Jul 9, 2026
055ebcb
chore(cli): classify input-phase schema failures, 404 parity, and wra…
jgoux Jul 9, 2026
db50a8e
chore(cli): pass through input errors and mark remaining decode sites
jgoux Jul 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
3 changes: 2 additions & 1 deletion apps/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,8 @@
"@anthropic-ai/claude-agent-sdk",
"@anthropic-ai/sdk",
"@modelcontextprotocol/sdk"
]
],
"ignoreExportsUsedInFile": true
},
"nx": {
"targets": {
Expand Down
6 changes: 5 additions & 1 deletion apps/cli/src/legacy/auth/legacy-access-token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,13 @@ const LEGACY_INVALID_ACCESS_TOKEN_MESSAGE =
*/
export const validateLegacyAccessToken = (
token: string,
source?: "env" | "stored",
): Effect.Effect<string, LegacyInvalidAccessTokenError> =>
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,
}),
);
6 changes: 3 additions & 3 deletions apps/cli/src/legacy/auth/legacy-credentials.layer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -369,22 +369,22 @@ 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));
}

// Filesystem fallback in the Supabase home directory.
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));
}

Expand Down
43 changes: 38 additions & 5 deletions apps/cli/src/legacy/auth/legacy-errors.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,37 @@
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;
}> {}
/**
* 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 this.source === "env" ? actionability.authToken : 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
Expand All @@ -21,7 +42,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.
Expand All @@ -33,7 +58,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
Expand All @@ -44,4 +73,8 @@ export class LegacyNotLoggedInError extends Data.TaggedError("LegacyNotLoggedInE
*/
export class LegacyDeleteTokenError extends Data.TaggedError("LegacyDeleteTokenError")<{
readonly message: string;
}> {}
}> {
get [ErrorActionabilityId](): CliErrorActionabilityDeclaration {
return actionability.permission;
}
}
2 changes: 1 addition & 1 deletion apps/cli/src/legacy/auth/legacy-platform-api.layer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
36 changes: 32 additions & 4 deletions apps/cli/src/legacy/commands/backups/backups.errors.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,55 @@
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;
}> {}
readonly decode?: boolean;
}> {
get [ErrorActionabilityId](): CliErrorActionabilityDeclaration {
return this.decode === true
? { ...actionability.apiStatus, fingerprint_suffix: "api_response" }
: actionability.externalNetwork;
}
}

export class LegacyBackupListUnexpectedStatusError extends Data.TaggedError(
"LegacyBackupListUnexpectedStatusError",
)<{
readonly status: number;
readonly body: string;
readonly message: string;
}> {}
}> {
get [ErrorActionabilityId](): CliErrorActionabilityDeclaration {
return statusCodeActionability(this.status);
Comment thread
jgoux marked this conversation as resolved.
}
}

export class LegacyBackupRestoreNetworkError extends Data.TaggedError(
"LegacyBackupRestoreNetworkError",
)<{
readonly message: string;
}> {}
readonly decode?: boolean;
}> {
get [ErrorActionabilityId](): CliErrorActionabilityDeclaration {
return this.decode === true
? { ...actionability.apiStatus, fingerprint_suffix: "api_response" }
: actionability.externalNetwork;
}
}

export class LegacyBackupRestoreUnexpectedStatusError extends Data.TaggedError(
"LegacyBackupRestoreUnexpectedStatusError",
)<{
readonly status: number;
readonly body: string;
readonly message: string;
}> {}
}> {
get [ErrorActionabilityId](): CliErrorActionabilityDeclaration {
return statusCodeActionability(this.status);
}
}
59 changes: 53 additions & 6 deletions apps/cli/src/legacy/commands/bootstrap/bootstrap.errors.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { Data } from "effect";
import {
actionability,
type CliErrorActionabilityDeclaration,
ErrorActionabilityId,
statusCodeActionability,
} from "../../../shared/telemetry/error-actionability.ts";

// ---------------------------------------------------------------------------
// Bootstrap-specific tagged errors. Each maps to a Go `errors.New` / failure
Expand All @@ -12,21 +18,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)`
Expand All @@ -36,19 +54,48 @@ 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
* `Service not healthy: %s (%s)` (`bootstrap.go:checkProjectHealth`).
*/
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;
/** 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" };
}
return actionability.apiStatus;
}
}
20 changes: 18 additions & 2 deletions apps/cli/src/legacy/commands/bootstrap/bootstrap.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<never, LegacyBootstrapHealthError> => {
if (HttpClientError.isHttpClientError(cause) && cause.response !== undefined) {
Expand All @@ -326,9 +336,15 @@ const mapHealthError = (cause: unknown): Effect.Effect<never, LegacyBootstrapHea
Effect.orElseSucceed(() => ""),
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 }),
),
),
);
}
return Effect.fail(new LegacyBootstrapHealthError({ message: `Error status 0: ${cause}` }));
return Effect.fail(
isDecodeFailureCause(cause)
? new LegacyBootstrapHealthError({ message: `Error status 0: ${cause}`, decode: true })
: new LegacyBootstrapHealthError({ message: `Error status 0: ${cause}`, transport: true }),
);
};
Loading
Loading