diff --git a/apps/cli/src/commands/secrets/secrets.errors.ts b/apps/cli/src/commands/secrets/secrets.errors.ts index ea1e120e2b..3c2f951b98 100644 --- a/apps/cli/src/commands/secrets/secrets.errors.ts +++ b/apps/cli/src/commands/secrets/secrets.errors.ts @@ -118,6 +118,26 @@ export class InvalidSecretPairError extends Data.TaggedError("InvalidSecretPairE export class SecretsNoArgumentsError extends Data.TaggedError("SecretsNoArgumentsError")<{ readonly message: string; + readonly suggestion?: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} + +export class SecretsUnsetInputError extends Data.TaggedError("SecretsUnsetInputError")<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} + +export class SecretsUnsetConfirmationRequiredError extends Data.TaggedError( + "SecretsUnsetConfirmationRequiredError", +)<{ + readonly message: string; + readonly suggestion: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { return actionability.provideFlags; diff --git a/apps/cli/src/commands/secrets/unset/SIDE_EFFECTS.md b/apps/cli/src/commands/secrets/unset/SIDE_EFFECTS.md index 68023dc210..6c8bcbbf59 100644 --- a/apps/cli/src/commands/secrets/unset/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/secrets/unset/SIDE_EFFECTS.md @@ -12,18 +12,19 @@ ## Files Written -| Path | Format | When | -| ------------------------------------------------ | ------ | ------------------------------------------------------------ | -| `~/.supabase//linked-project.json` | JSON | always (in `Effect.ensuring`) after `--project-ref` resolves | -| `~/.supabase/telemetry.json` | JSON | always (in `Effect.ensuring`) at end of command | +| Path | Format | When | +| ---------------------------------------------- | ------ | -------------------------------------------------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | in `Effect.ensuring` after the ref resolves, on success and failure, only when the file is missing | +| `~/.supabase/telemetry.json` | JSON | always (in `Effect.ensuring`) at end of command | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| -------- | ---------------------------- | ------------ | --------------- | ------------------------------------------------------------- | -| `GET` | `/v1/projects/{ref}/secrets` | Bearer token | none | `[{name}]` — empty-args path only, filters `SUPABASE_` prefix | -| `DELETE` | `/v1/projects/{ref}/secrets` | Bearer token | `["NAME", ...]` | none (200 expected) | -| `GET` | `/v1/projects` | Bearer token | none | TTY-prompt fallback only | +| Method | Path | Auth | Request body | Response (used fields) | +| -------- | ---------------------------- | ------------ | --------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/v1/projects/{ref}/secrets` | Bearer token | none | `[{name}]` — `--all` path only, filters `SUPABASE_` prefix | +| `DELETE` | `/v1/projects/{ref}/secrets` | Bearer token | `["NAME", ...]` | none (200 expected; not called with `--dry-run`) | +| `GET` | `/v1/projects` | Bearer token | none | TTY-prompt fallback only | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | linked-project cache fill in `Effect.ensuring`, on success and failure, only when `linked-project.json` is missing; best-effort | ## Environment Variables @@ -31,24 +32,29 @@ | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | | `SUPABASE_PROFILE` | selects API base URL: `supabase` → `api.supabase.com`, `supabase-staging` → `api.supabase.green`, `supabase-local` → `http://localhost:8080`. May alternatively be a filesystem path to a YAML profile with at least `api_url:` and optional `name:` (used by the cli-e2e test harness). | no (defaults to `supabase`) | +| `SUPABASE_YES` | confirms without a prompt, same as `--yes` | no | | `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (also reads `/supabase/.temp/project-ref` then prompts on TTY) | | `SUPABASE_WORKDIR` | base directory for the `.temp/project-ref` lookup | no (walks up from CWD looking for `supabase/config.toml` or `supabase/config.json`) | ## Exit Codes -| Code | Condition | -| ---- | --------------------------------------------------------------------------------------- | -| `0` | success — secrets unset from the linked project | -| `0` | empty-args path resolved to zero non-`SUPABASE_` secrets (stderr no-op, no DELETE call) | -| `1` | `AccessTokenRequiredError` — no token in env/keyring/file | -| `1` | `InvalidAccessTokenError` — token violates `^sbp_(oauth_\|v0_)?[a-f0-9]{40}$` | -| `1` | `ProjectRefNotLinkedError` — `--project-ref` unset, env/file empty, and stdin not a TTY | -| `1` | `InvalidProjectRefError` — resolved ref violates `^[a-z]{20}$` | -| `1` | `SecretsListUnexpectedStatusError` — non-2xx response from GET (empty-args path) | -| `1` | `SecretsListNetworkError` — GET transport failure (empty-args path) | -| `1` | `SecretsUnsetCancelledError` — user declined the confirmation prompt | -| `1` | `SecretsUnsetUnexpectedStatusError` — non-2xx response from DELETE | -| `1` | `SecretsUnsetNetworkError` — DELETE transport failure | +| Code | Condition | +| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success — secrets unset from the linked project | +| `0` | `--dry-run` — the secrets that would be unset were printed, no DELETE call | +| `0` | `--all` resolved to zero non-`SUPABASE_` secrets (no DELETE call; text writes the stderr no-op, machine formats an empty result) | +| `1` | `SecretsNoArgumentsError` — no names and no `--all`; fails before the project ref resolves and before any API call | +| `1` | `SecretsUnsetInputError` — names given together with `--all`; fails before the project ref resolves and before any API call | +| `1` | `AccessTokenRequiredError` — no token in env/keyring/file | +| `1` | `InvalidAccessTokenError` — token violates `^sbp_(oauth_\|v0_)?[a-f0-9]{40}$` | +| `1` | `ProjectRefNotLinkedError` — `--project-ref` unset, env/file empty, and stdin not a TTY | +| `1` | `InvalidProjectRefError` — resolved ref violates `^[a-z]{20}$` | +| `1` | `SecretsUnsetConfirmationRequiredError` — no `--yes`/`SUPABASE_YES` and stdin is not a TTY or output is not interactive text; no secrets request (the linked-project cache fill may still run) | +| `1` | `SecretsListUnexpectedStatusError` — non-2xx response from GET (`--all` path) | +| `1` | `SecretsListNetworkError` — GET transport failure (`--all` path) | +| `1` | `SecretsUnsetCancelledError` — user declined the confirmation prompt | +| `1` | `SecretsUnsetUnexpectedStatusError` — non-2xx response from DELETE | +| `1` | `SecretsUnsetNetworkError` — DELETE transport failure | ## Telemetry Events Fired @@ -60,21 +66,35 @@ ### `--output pretty` (default) / `--output-format text` -Stdout: `Finished supabase secrets unset.\n`. Stderr: confirmation prompt label (when TTY without `--yes`) or `[Y/n] y` echo (with `--yes`) or the no-op message when empty-args resolves to no secrets. +Stdout: `Finished supabase secrets unset.\n`. Stderr: the confirmation prompt (`Do you want to unset these function secrets from project ?`, the bulleted names, then `[y/N]`; default No) on an interactive TTY without `--yes`, the same label with `[y/N] y` echoed with `--yes`, or the no-op message when `--all` resolves to no secrets. -The `--output {json,yaml,toml,env}` flags all collapse to the same text-mode `Finished` message. +`--dry-run` stdout, with no `Finished` line and no prompt: + +``` +Would unset these function secrets from project : + • NAME_A + • NAME_B +``` + +The `--output {json,yaml,toml,env}` flags all collapse to the same text-mode output. ### `--output-format json` -Single JSON object emitted via `Output.success` with `{project_ref, count}` as the `data` field. +Single JSON object emitted via `Output.success` with `{project_ref, count, names, dry_run: false}` as the `data` field. + +With `--dry-run`, the message is `Dry run: no secrets were unset.` and `data` is `{project_ref, count, names, dry_run: true}`. + +When `--all` resolves to no secrets, the message is `You have not set any function secrets, nothing to do.` and `data` is `{project_ref, count: 0, names: [], dry_run}`. ### `--output-format stream-json` -One `result` NDJSON event on success containing `{project_ref, count}`. +One `result` NDJSON event on success containing `{project_ref, count, names, dry_run}`, with the same `--dry-run` and empty-`--all` messages as `json`. ## Notes -- When called without arguments, fetches the full secret list and unsets all entries that do not have a `SUPABASE_` prefix. -- `--yes` bypasses the confirmation prompt with a stderr label echo. -- Non-TTY without `--yes` auto-confirms silently, after a 100ms non-TTY read timeout. +- Names and `--all` are mutually exclusive; with neither, the command fails. `--all` unsets every secret that does not have a `SUPABASE_` prefix. +- Argument validation runs before the project ref resolves, so a usage error never opens the project picker and makes no API call. +- The prompt defaults to No and names the project ref. `--yes` (or `SUPABASE_YES`) skips the prompt with a stderr label echo. +- Without `--yes`, a run whose stdin is not a TTY, or whose output is not interactive text (`json`, `stream-json`), fails before any secrets request. Piped stdin answers are not read. +- `--dry-run` never prompts, never calls DELETE, and works without `--yes` in every output mode. With `--all` it still calls GET to list the secrets. - Sends `User-Agent: SupabaseCLI/` and Bearer auth. No `X-Supabase-Command` headers. diff --git a/apps/cli/src/commands/secrets/unset/unset.command.ts b/apps/cli/src/commands/secrets/unset/unset.command.ts index 1b7ee38301..0c7bc8513a 100644 --- a/apps/cli/src/commands/secrets/unset/unset.command.ts +++ b/apps/cli/src/commands/secrets/unset/unset.command.ts @@ -17,6 +17,16 @@ const config = { Argument.withDescription("Secret names to unset."), Argument.variadic(), ), + all: Flag.Boolean("all").pipe( + Flag.withDescription( + "Unset every secret except SUPABASE_ ones. Required when no names are given.", + ), + Flag.withDefault(false), + ), + dryRun: Flag.Boolean("dry-run").pipe( + Flag.withDescription("Print the secrets that would be unset without unsetting them."), + Flag.withDefault(false), + ), } as const; export type SecretsUnsetFlags = CliCommand.Command.Config.Infer; @@ -33,10 +43,18 @@ export const secretsUnsetCommand = Command.make("unset", config).pipe( command: "supabase secrets unset MY_SECRET OTHER_SECRET", description: "Unset multiple secrets", }, + { + command: "supabase secrets unset --all", + description: "Unset every custom secret after confirming", + }, + { + command: "supabase secrets unset --all --dry-run", + description: "Preview which secrets --all would unset", + }, ]), Command.withHandler((flags) => secretsUnset(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), - // `stdinLayer`: the confirmation prompt reads piped stdin via `promptYesNo` on a non-TTY stdin. + // `promptYesNo` requires `Stdin` even though the handler fails before its non-TTY branch. Command.provide(Layer.mergeAll(managementApiRuntimeLayer(["secrets", "unset"]), stdinLayer)), ); diff --git a/apps/cli/src/commands/secrets/unset/unset.handler.ts b/apps/cli/src/commands/secrets/unset/unset.handler.ts index ccc1f462ac..14b0321d5b 100644 --- a/apps/cli/src/commands/secrets/unset/unset.handler.ts +++ b/apps/cli/src/commands/secrets/unset/unset.handler.ts @@ -9,11 +9,15 @@ import { resolveYes } from "../../../command-internal/global-flags.ts"; import { promptYesNo } from "../../../command-internal/prompt-yes-no.ts"; import { CONTEXT_CANCELED_MESSAGE } from "../../../shared/output/errors.ts"; import { Output } from "../../../shared/output/output.service.ts"; +import { Tty } from "../../../shared/runtime/tty.service.ts"; import { mapHttpError } from "../../../command-internal/http-errors.ts"; import { SecretsListNetworkError, SecretsListUnexpectedStatusError, + SecretsNoArgumentsError, SecretsUnsetCancelledError, + SecretsUnsetConfirmationRequiredError, + SecretsUnsetInputError, SecretsUnsetNetworkError, SecretsUnsetUnexpectedStatusError, } from "../secrets.errors.ts"; @@ -21,7 +25,7 @@ import type { SecretsUnsetFlags } from "./unset.command.ts"; type Secrets = typeof V1ListAllSecretsOutput.Type; -// The empty-args path lists secrets first, so it shares the LIST error pair +// The `--all` path lists secrets first, so it shares the LIST error pair // with the `list` handler. const mapListErrorForUnset = mapHttpError({ networkError: SecretsListNetworkError, @@ -43,15 +47,38 @@ export const secretsUnset = Effect.fn("secrets.unset")(function* (flags: Secrets const resolver = yield* ProjectRefResolver; const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; + const tty = yield* Tty; // `resolveYes` also honors `SUPABASE_YES`, not just the `--yes` flag. const yes = yield* resolveYes; + if (flags.all && flags.names.length > 0) { + return yield* new SecretsUnsetInputError({ + message: "Pass secret names or --all, not both. Nothing was unset.", + }); + } + if (!flags.all && flags.names.length === 0) { + return yield* new SecretsNoArgumentsError({ + message: "No secret names given. Nothing was unset.", + suggestion: + "Pass the names to unset, or --all to unset every secret (add --dry-run to preview).", + }); + } + const ref = yield* resolver.resolve(flags.projectRef); yield* Effect.gen(function* () { + const canPrompt = tty.stdinIsTty && output.interactive && output.format === "text"; + if (!flags.dryRun && !yes && !canPrompt) { + return yield* new SecretsUnsetConfirmationRequiredError({ + message: `Unsetting secrets on project ${ref} needs confirmation, and this run can't prompt. Nothing was unset.`, + suggestion: + "Pass --yes (or set SUPABASE_YES=1) to confirm without a prompt, or --dry-run to preview.", + }); + } + let names: ReadonlyArray = flags.names; - if (names.length === 0) { + if (flags.all) { const all: Secrets = yield* api.v1 .listAllSecrets({ ref }) .pipe(Effect.catch(mapListErrorForUnset)); @@ -59,23 +86,49 @@ export const secretsUnset = Effect.fn("secrets.unset")(function* (flags: Secrets } if (names.length === 0) { - yield* output.raw("You have not set any function secrets, nothing to do.\n", "stderr"); + const message = "You have not set any function secrets, nothing to do."; + if (output.format === "text") { + yield* output.raw(`${message}\n`, "stderr"); + return; + } + yield* output.success(message, { + project_ref: ref, + count: 0, + names, + dry_run: flags.dryRun, + }); return; } - const label = `Do you want to unset these function secrets?\n • ${names.join("\n • ")}\n\n`; + yield* Effect.annotateCurrentSpan({ + "secret.count": names.length, + "secret.dry_run": flags.dryRun, + }); + + if (flags.dryRun) { + if (output.format === "text") { + yield* output.raw( + `Would unset these function secrets from project ${ref}:\n • ${names.join("\n • ")}\n`, + ); + return; + } + yield* output.success("Dry run: no secrets were unset.", { + project_ref: ref, + count: names.length, + names, + dry_run: true, + }); + return; + } + + const label = `Do you want to unset these function secrets from project ${ref}?\n • ${names.join("\n • ")}\n\n`; - // `promptYesNo` echoes `