From 77a839f7523ec0f4204643658b18ddf2d4e0dc4a Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 21 May 2026 13:56:11 +0100 Subject: [PATCH 1/9] feat(cli): port backups list and restore to native TypeScript Replaces the Phase-0 Go-proxy handlers for `supabase backups list` and `supabase backups restore` with native Effect-based implementations. Adds the supporting legacy infrastructure (auth, config, project-ref resolution, Glamour table renderer) that future ports will reuse. - Strict Go parity on stdout/stderr: byte-identical `--output json` (alphabetical field order, `backups: null` for nil slices), Glamour tables, restore stderr line. - Extends `Output` with a `raw(text, stream)` method so handlers route stdout/stderr through the service. Removes monkey-patching from integration tests. - Hoists shared backups layer composition (`backups.layers.ts`) and HTTP error mapping (`mapLegacyBackupHttpError` factory) so subsequent subcommands stay DRY. - Truncates API error response bodies to 1024 chars in tagged-error fields. - Suppresses the fetching/restoring spinner in non-text output modes. - Regenerates `@supabase/api` contracts from upstream OpenAPI (brings in the missing `id` field on backup items). --- apps/cli/docs/go-cli-porting-status.md | 204 ++-- apps/cli/package.json | 4 +- .../legacy/auth/legacy-credentials.layer.ts | 155 +++ .../legacy-credentials.layer.unit.test.ts | 230 +++++ .../legacy/auth/legacy-credentials.service.ts | 17 + apps/cli/src/legacy/auth/legacy-errors.ts | 13 + .../legacy/auth/legacy-platform-api.layer.ts | 37 + .../legacy-platform-api.layer.unit.test.ts | 138 +++ .../auth/legacy-platform-api.service.ts | 6 + .../commands/backups/backups.encoders.ts | 118 +++ .../backups/backups.encoders.unit.test.ts | 186 ++++ .../legacy/commands/backups/backups.errors.ts | 95 ++ .../legacy/commands/backups/backups.format.ts | 51 + .../backups/backups.format.unit.test.ts | 55 ++ .../legacy/commands/backups/backups.layers.ts | 30 + .../commands/backups/list/SIDE_EFFECTS.md | 75 +- .../commands/backups/list/list.command.ts | 18 +- .../commands/backups/list/list.e2e.test.ts | 15 + .../commands/backups/list/list.handler.ts | 104 +- .../backups/list/list.integration.test.ts | 485 ++++++++++ .../commands/backups/restore/SIDE_EFFECTS.md | 66 +- .../backups/restore/restore.command.ts | 16 +- .../backups/restore/restore.e2e.test.ts | 20 + .../backups/restore/restore.handler.ts | 63 +- .../restore/restore.integration.test.ts | 404 ++++++++ .../legacy/config/legacy-cli-config.layer.ts | 102 ++ .../legacy-cli-config.layer.unit.test.ts | 163 ++++ .../config/legacy-cli-config.service.ts | 17 + .../config/legacy-project-ref.errors.ts | 10 + .../legacy/config/legacy-project-ref.layer.ts | 100 ++ .../legacy-project-ref.layer.unit.test.ts | 228 +++++ .../config/legacy-project-ref.service.ts | 25 + .../src/legacy/output/legacy-glamour-table.ts | 45 + .../output/legacy-glamour-table.unit.test.ts | 43 + .../platform/platform-input.unit.test.ts | 1 + .../platform-schema.integration.test.ts | 3 +- .../output/json-error-handling.unit.test.ts | 1 + apps/cli/src/shared/output/output.layer.ts | 14 + apps/cli/src/shared/output/output.service.ts | 8 + apps/cli/tests/helpers/mocks.ts | 18 + packages/api/src/generated/contracts.ts | 202 +++- packages/api/src/generated/effect-client.ts | 54 ++ packages/api/src/generated/openapi.json | 895 ++++++++++++------ pnpm-lock.yaml | 10 +- 44 files changed, 4043 insertions(+), 501 deletions(-) create mode 100644 apps/cli/src/legacy/auth/legacy-credentials.layer.ts create mode 100644 apps/cli/src/legacy/auth/legacy-credentials.layer.unit.test.ts create mode 100644 apps/cli/src/legacy/auth/legacy-credentials.service.ts create mode 100644 apps/cli/src/legacy/auth/legacy-errors.ts create mode 100644 apps/cli/src/legacy/auth/legacy-platform-api.layer.ts create mode 100644 apps/cli/src/legacy/auth/legacy-platform-api.layer.unit.test.ts create mode 100644 apps/cli/src/legacy/auth/legacy-platform-api.service.ts create mode 100644 apps/cli/src/legacy/commands/backups/backups.encoders.ts create mode 100644 apps/cli/src/legacy/commands/backups/backups.encoders.unit.test.ts create mode 100644 apps/cli/src/legacy/commands/backups/backups.errors.ts create mode 100644 apps/cli/src/legacy/commands/backups/backups.format.ts create mode 100644 apps/cli/src/legacy/commands/backups/backups.format.unit.test.ts create mode 100644 apps/cli/src/legacy/commands/backups/backups.layers.ts create mode 100644 apps/cli/src/legacy/commands/backups/list/list.e2e.test.ts create mode 100644 apps/cli/src/legacy/commands/backups/list/list.integration.test.ts create mode 100644 apps/cli/src/legacy/commands/backups/restore/restore.e2e.test.ts create mode 100644 apps/cli/src/legacy/commands/backups/restore/restore.integration.test.ts create mode 100644 apps/cli/src/legacy/config/legacy-cli-config.layer.ts create mode 100644 apps/cli/src/legacy/config/legacy-cli-config.layer.unit.test.ts create mode 100644 apps/cli/src/legacy/config/legacy-cli-config.service.ts create mode 100644 apps/cli/src/legacy/config/legacy-project-ref.errors.ts create mode 100644 apps/cli/src/legacy/config/legacy-project-ref.layer.ts create mode 100644 apps/cli/src/legacy/config/legacy-project-ref.layer.unit.test.ts create mode 100644 apps/cli/src/legacy/config/legacy-project-ref.service.ts create mode 100644 apps/cli/src/legacy/output/legacy-glamour-table.ts create mode 100644 apps/cli/src/legacy/output/legacy-glamour-table.unit.test.ts diff --git a/apps/cli/docs/go-cli-porting-status.md b/apps/cli/docs/go-cli-porting-status.md index ed5588abf..0178a0335 100644 --- a/apps/cli/docs/go-cli-porting-status.md +++ b/apps/cli/docs/go-cli-porting-status.md @@ -210,105 +210,105 @@ Legend: - `wrapped`: Phase 0 proxy wrapper exists in the legacy shell - `missing`: no legacy shell command yet -| Command | Legacy status | Legacy command path | -| -------------------------------------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `orgs list` | `wrapped` | [`../src/legacy/commands/orgs/list/list.command.ts`](../src/legacy/commands/orgs/list/list.command.ts) | -| `orgs create` | `wrapped` | [`../src/legacy/commands/orgs/create/create.command.ts`](../src/legacy/commands/orgs/create/create.command.ts) | -| `projects list` | `wrapped` | [`../src/legacy/commands/projects/list/list.command.ts`](../src/legacy/commands/projects/list/list.command.ts) | -| `projects create` | `wrapped` | [`../src/legacy/commands/projects/create/create.command.ts`](../src/legacy/commands/projects/create/create.command.ts) | -| `projects delete` | `wrapped` | [`../src/legacy/commands/projects/delete/delete.command.ts`](../src/legacy/commands/projects/delete/delete.command.ts) | -| `projects api-keys` | `wrapped` | [`../src/legacy/commands/projects/api-keys/api-keys.command.ts`](../src/legacy/commands/projects/api-keys/api-keys.command.ts) | -| `branches list` | `wrapped` | [`../src/legacy/commands/branches/list/list.command.ts`](../src/legacy/commands/branches/list/list.command.ts) | -| `branches create` | `wrapped` | [`../src/legacy/commands/branches/create/create.command.ts`](../src/legacy/commands/branches/create/create.command.ts) | -| `branches get` | `wrapped` | [`../src/legacy/commands/branches/get/get.command.ts`](../src/legacy/commands/branches/get/get.command.ts) | -| `branches update` | `wrapped` | [`../src/legacy/commands/branches/update/update.command.ts`](../src/legacy/commands/branches/update/update.command.ts) | -| `branches pause` | `wrapped` | [`../src/legacy/commands/branches/pause/pause.command.ts`](../src/legacy/commands/branches/pause/pause.command.ts) | -| `branches unpause` | `wrapped` | [`../src/legacy/commands/branches/unpause/unpause.command.ts`](../src/legacy/commands/branches/unpause/unpause.command.ts) | -| `branches delete` | `wrapped` | [`../src/legacy/commands/branches/delete/delete.command.ts`](../src/legacy/commands/branches/delete/delete.command.ts) | -| `branches disable` | `wrapped` | [`../src/legacy/commands/branches/disable/disable.command.ts`](../src/legacy/commands/branches/disable/disable.command.ts) | -| `secrets list` | `wrapped` | [`../src/legacy/commands/secrets/list/list.command.ts`](../src/legacy/commands/secrets/list/list.command.ts) | -| `secrets set` | `wrapped` | [`../src/legacy/commands/secrets/set/set.command.ts`](../src/legacy/commands/secrets/set/set.command.ts) | -| `secrets unset` | `wrapped` | [`../src/legacy/commands/secrets/unset/unset.command.ts`](../src/legacy/commands/secrets/unset/unset.command.ts) | -| `config push` | `wrapped` | [`../src/legacy/commands/config/push/push.command.ts`](../src/legacy/commands/config/push/push.command.ts) | -| `backups list` | `wrapped` | [`../src/legacy/commands/backups/list/list.command.ts`](../src/legacy/commands/backups/list/list.command.ts) | -| `backups restore` | `wrapped` | [`../src/legacy/commands/backups/restore/restore.command.ts`](../src/legacy/commands/backups/restore/restore.command.ts) | -| `snippets list` | `wrapped` | [`../src/legacy/commands/snippets/list/list.command.ts`](../src/legacy/commands/snippets/list/list.command.ts) | -| `snippets download` | `wrapped` | [`../src/legacy/commands/snippets/download/download.command.ts`](../src/legacy/commands/snippets/download/download.command.ts) | -| `sso list` | `wrapped` | [`../src/legacy/commands/sso/list/list.command.ts`](../src/legacy/commands/sso/list/list.command.ts) | -| `sso add` | `wrapped` | [`../src/legacy/commands/sso/add/add.command.ts`](../src/legacy/commands/sso/add/add.command.ts) | -| `sso remove` | `wrapped` | [`../src/legacy/commands/sso/remove/remove.command.ts`](../src/legacy/commands/sso/remove/remove.command.ts) | -| `sso update` | `wrapped` | [`../src/legacy/commands/sso/update/update.command.ts`](../src/legacy/commands/sso/update/update.command.ts) | -| `sso show` | `wrapped` | [`../src/legacy/commands/sso/show/show.command.ts`](../src/legacy/commands/sso/show/show.command.ts) | -| `sso info` | `wrapped` | [`../src/legacy/commands/sso/info/info.command.ts`](../src/legacy/commands/sso/info/info.command.ts) | -| `domains create` | `wrapped` | [`../src/legacy/commands/domains/create/create.command.ts`](../src/legacy/commands/domains/create/create.command.ts) | -| `domains get` | `wrapped` | [`../src/legacy/commands/domains/get/get.command.ts`](../src/legacy/commands/domains/get/get.command.ts) | -| `domains reverify` | `wrapped` | [`../src/legacy/commands/domains/reverify/reverify.command.ts`](../src/legacy/commands/domains/reverify/reverify.command.ts) | -| `domains activate` | `wrapped` | [`../src/legacy/commands/domains/activate/activate.command.ts`](../src/legacy/commands/domains/activate/activate.command.ts) | -| `domains delete` | `wrapped` | [`../src/legacy/commands/domains/delete/delete.command.ts`](../src/legacy/commands/domains/delete/delete.command.ts) | -| `vanity-subdomains get` | `wrapped` | [`../src/legacy/commands/vanity-subdomains/get/get.command.ts`](../src/legacy/commands/vanity-subdomains/get/get.command.ts) | -| `vanity-subdomains check-availability` | `wrapped` | [`../src/legacy/commands/vanity-subdomains/check-availability/check-availability.command.ts`](../src/legacy/commands/vanity-subdomains/check-availability/check-availability.command.ts) | -| `vanity-subdomains activate` | `wrapped` | [`../src/legacy/commands/vanity-subdomains/activate/activate.command.ts`](../src/legacy/commands/vanity-subdomains/activate/activate.command.ts) | -| `vanity-subdomains delete` | `wrapped` | [`../src/legacy/commands/vanity-subdomains/delete/delete.command.ts`](../src/legacy/commands/vanity-subdomains/delete/delete.command.ts) | -| `network-bans get` | `wrapped` | [`../src/legacy/commands/network-bans/get/get.command.ts`](../src/legacy/commands/network-bans/get/get.command.ts) | -| `network-bans remove` | `wrapped` | [`../src/legacy/commands/network-bans/remove/remove.command.ts`](../src/legacy/commands/network-bans/remove/remove.command.ts) | -| `network-restrictions get` | `wrapped` | [`../src/legacy/commands/network-restrictions/get/get.command.ts`](../src/legacy/commands/network-restrictions/get/get.command.ts) | -| `network-restrictions update` | `wrapped` | [`../src/legacy/commands/network-restrictions/update/update.command.ts`](../src/legacy/commands/network-restrictions/update/update.command.ts) | -| `encryption get-root-key` | `wrapped` | [`../src/legacy/commands/encryption/get-root-key/get-root-key.command.ts`](../src/legacy/commands/encryption/get-root-key/get-root-key.command.ts) | -| `encryption update-root-key` | `wrapped` | [`../src/legacy/commands/encryption/update-root-key/update-root-key.command.ts`](../src/legacy/commands/encryption/update-root-key/update-root-key.command.ts) | -| `ssl-enforcement get` | `wrapped` | [`../src/legacy/commands/ssl-enforcement/get/get.command.ts`](../src/legacy/commands/ssl-enforcement/get/get.command.ts) | -| `ssl-enforcement update` | `wrapped` | [`../src/legacy/commands/ssl-enforcement/update/update.command.ts`](../src/legacy/commands/ssl-enforcement/update/update.command.ts) | -| `postgres-config get` | `wrapped` | [`../src/legacy/commands/postgres-config/get/get.command.ts`](../src/legacy/commands/postgres-config/get/get.command.ts) | -| `postgres-config update` | `wrapped` | [`../src/legacy/commands/postgres-config/update/update.command.ts`](../src/legacy/commands/postgres-config/update/update.command.ts) | -| `postgres-config delete` | `wrapped` | [`../src/legacy/commands/postgres-config/delete/delete.command.ts`](../src/legacy/commands/postgres-config/delete/delete.command.ts) | -| `login` | `wrapped` | [`../src/legacy/commands/login/login.command.ts`](../src/legacy/commands/login/login.command.ts) | -| `logout` | `wrapped` | [`../src/legacy/commands/logout/logout.command.ts`](../src/legacy/commands/logout/logout.command.ts) | -| `link` | `wrapped` | [`../src/legacy/commands/link/link.command.ts`](../src/legacy/commands/link/link.command.ts) | -| `unlink` | `wrapped` | [`../src/legacy/commands/unlink/unlink.command.ts`](../src/legacy/commands/unlink/unlink.command.ts) | -| `bootstrap` | `wrapped` | [`../src/legacy/commands/bootstrap/bootstrap.command.ts`](../src/legacy/commands/bootstrap/bootstrap.command.ts) | -| `init` | `wrapped` | [`../src/legacy/commands/init/init.command.ts`](../src/legacy/commands/init/init.command.ts) | -| `services` | `wrapped` | [`../src/legacy/commands/services/services.command.ts`](../src/legacy/commands/services/services.command.ts) | -| `start` | `wrapped` | [`../src/legacy/commands/start/start.command.ts`](../src/legacy/commands/start/start.command.ts) | -| `stop` | `wrapped` | [`../src/legacy/commands/stop/stop.command.ts`](../src/legacy/commands/stop/stop.command.ts) | -| `status` | `wrapped` | [`../src/legacy/commands/status/status.command.ts`](../src/legacy/commands/status/status.command.ts) | -| `migration list` | `wrapped` | [`../src/legacy/commands/migration/list/list.command.ts`](../src/legacy/commands/migration/list/list.command.ts) | -| `migration new` | `wrapped` | [`../src/legacy/commands/migration/new/new.command.ts`](../src/legacy/commands/migration/new/new.command.ts) | -| `migration repair` | `wrapped` | [`../src/legacy/commands/migration/repair/repair.command.ts`](../src/legacy/commands/migration/repair/repair.command.ts) | -| `migration squash` | `wrapped` | [`../src/legacy/commands/migration/squash/squash.command.ts`](../src/legacy/commands/migration/squash/squash.command.ts) | -| `migration up` | `wrapped` | [`../src/legacy/commands/migration/up/up.command.ts`](../src/legacy/commands/migration/up/up.command.ts) | -| `migration down` | `wrapped` | [`../src/legacy/commands/migration/down/down.command.ts`](../src/legacy/commands/migration/down/down.command.ts) | -| `migration fetch` | `wrapped` | [`../src/legacy/commands/migration/fetch/fetch.command.ts`](../src/legacy/commands/migration/fetch/fetch.command.ts) | -| `gen types` | `wrapped` | [`../src/legacy/commands/gen/types/types.command.ts`](../src/legacy/commands/gen/types/types.command.ts) | -| `gen signing-key` | `wrapped` | [`../src/legacy/commands/gen/signing-key/signing-key.command.ts`](../src/legacy/commands/gen/signing-key/signing-key.command.ts) | -| `gen bearer-jwt` | `wrapped` | [`../src/legacy/commands/gen/bearer-jwt/bearer-jwt.command.ts`](../src/legacy/commands/gen/bearer-jwt/bearer-jwt.command.ts) | -| `gen keys` | `wrapped` | [`../src/legacy/commands/gen/keys/keys.command.ts`](../src/legacy/commands/gen/keys/keys.command.ts) | -| `functions list` | `wrapped` | [`../src/legacy/commands/functions/list/list.command.ts`](../src/legacy/commands/functions/list/list.command.ts) | -| `functions delete` | `wrapped` | [`../src/legacy/commands/functions/delete/delete.command.ts`](../src/legacy/commands/functions/delete/delete.command.ts) | -| `functions download` | `wrapped` | [`../src/legacy/commands/functions/download/download.command.ts`](../src/legacy/commands/functions/download/download.command.ts) | -| `functions deploy` | `wrapped` | [`../src/legacy/commands/functions/deploy/deploy.command.ts`](../src/legacy/commands/functions/deploy/deploy.command.ts) | -| `functions new` | `wrapped` | [`../src/legacy/commands/functions/new/new.command.ts`](../src/legacy/commands/functions/new/new.command.ts) | -| `functions serve` | `wrapped` | [`../src/legacy/commands/functions/serve/serve.command.ts`](../src/legacy/commands/functions/serve/serve.command.ts) | -| `storage ls` | `wrapped` | [`../src/legacy/commands/storage/ls/ls.command.ts`](../src/legacy/commands/storage/ls/ls.command.ts) | -| `storage cp` | `wrapped` | [`../src/legacy/commands/storage/cp/cp.command.ts`](../src/legacy/commands/storage/cp/cp.command.ts) | -| `storage mv` | `wrapped` | [`../src/legacy/commands/storage/mv/mv.command.ts`](../src/legacy/commands/storage/mv/mv.command.ts) | -| `storage rm` | `wrapped` | [`../src/legacy/commands/storage/rm/rm.command.ts`](../src/legacy/commands/storage/rm/rm.command.ts) | -| `test db` | `wrapped` | [`../src/legacy/commands/test/db/db.command.ts`](../src/legacy/commands/test/db/db.command.ts) | -| `test new` | `wrapped` | [`../src/legacy/commands/test/new/new.command.ts`](../src/legacy/commands/test/new/new.command.ts) | -| `seed buckets` | `wrapped` | [`../src/legacy/commands/seed/buckets/buckets.command.ts`](../src/legacy/commands/seed/buckets/buckets.command.ts) | -| `db diff` | `wrapped` | [`../src/legacy/commands/db/diff/diff.command.ts`](../src/legacy/commands/db/diff/diff.command.ts) | -| `db dump` | `wrapped` | [`../src/legacy/commands/db/dump/dump.command.ts`](../src/legacy/commands/db/dump/dump.command.ts) | -| `db push` | `wrapped` | [`../src/legacy/commands/db/push/push.command.ts`](../src/legacy/commands/db/push/push.command.ts) | -| `db pull` | `wrapped` | [`../src/legacy/commands/db/pull/pull.command.ts`](../src/legacy/commands/db/pull/pull.command.ts) — includes `--diff-engine` (migra\|pg-delta, mutually exclusive with `--use-pg-delta`) | -| `db reset` | `wrapped` | [`../src/legacy/commands/db/reset/reset.command.ts`](../src/legacy/commands/db/reset/reset.command.ts) | -| `db lint` | `wrapped` | [`../src/legacy/commands/db/lint/lint.command.ts`](../src/legacy/commands/db/lint/lint.command.ts) | -| `db start` | `wrapped` | [`../src/legacy/commands/db/start/start.command.ts`](../src/legacy/commands/db/start/start.command.ts) | -| `db query` | `wrapped` | [`../src/legacy/commands/db/query/query.command.ts`](../src/legacy/commands/db/query/query.command.ts) | -| `db advisors` | `wrapped` | [`../src/legacy/commands/db/advisors/advisors.command.ts`](../src/legacy/commands/db/advisors/advisors.command.ts) | -| `db test` | `wrapped` | [`../src/legacy/commands/db/test/test.command.ts`](../src/legacy/commands/db/test/test.command.ts) | -| `db branch create` | `wrapped` | [`../src/legacy/commands/db/branch/create/create.command.ts`](../src/legacy/commands/db/branch/create/create.command.ts) | -| `db branch delete` | `wrapped` | [`../src/legacy/commands/db/branch/delete/delete.command.ts`](../src/legacy/commands/db/branch/delete/delete.command.ts) | -| `db branch list` | `wrapped` | [`../src/legacy/commands/db/branch/list/list.command.ts`](../src/legacy/commands/db/branch/list/list.command.ts) | -| `db branch switch` | `wrapped` | [`../src/legacy/commands/db/branch/switch/switch.command.ts`](../src/legacy/commands/db/branch/switch/switch.command.ts) | -| `db remote changes` | `wrapped` | [`../src/legacy/commands/db/remote/changes/changes.command.ts`](../src/legacy/commands/db/remote/changes/changes.command.ts) | -| `db remote commit` | `wrapped` | [`../src/legacy/commands/db/remote/commit/commit.command.ts`](../src/legacy/commands/db/remote/commit/commit.command.ts) | -| `db schema declarative sync` | `wrapped` | [`../src/legacy/commands/db/schema/declarative/sync/sync.command.ts`](../src/legacy/commands/db/schema/declarative/sync/sync.command.ts) — `--apply` and `--no-apply` are mutually exclusive; `--no-apply` overrides the global `--yes` flag | -| `db schema declarative generate` | `wrapped` | [`../src/legacy/commands/db/schema/declarative/generate/generate.command.ts`](../src/legacy/commands/db/schema/declarative/generate/generate.command.ts) | +| Command | Legacy status | Legacy command path | +| -------------------------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `orgs list` | `wrapped` | [`../src/legacy/commands/orgs/list/list.command.ts`](../src/legacy/commands/orgs/list/list.command.ts) | +| `orgs create` | `wrapped` | [`../src/legacy/commands/orgs/create/create.command.ts`](../src/legacy/commands/orgs/create/create.command.ts) | +| `projects list` | `wrapped` | [`../src/legacy/commands/projects/list/list.command.ts`](../src/legacy/commands/projects/list/list.command.ts) | +| `projects create` | `wrapped` | [`../src/legacy/commands/projects/create/create.command.ts`](../src/legacy/commands/projects/create/create.command.ts) | +| `projects delete` | `wrapped` | [`../src/legacy/commands/projects/delete/delete.command.ts`](../src/legacy/commands/projects/delete/delete.command.ts) | +| `projects api-keys` | `wrapped` | [`../src/legacy/commands/projects/api-keys/api-keys.command.ts`](../src/legacy/commands/projects/api-keys/api-keys.command.ts) | +| `branches list` | `wrapped` | [`../src/legacy/commands/branches/list/list.command.ts`](../src/legacy/commands/branches/list/list.command.ts) | +| `branches create` | `wrapped` | [`../src/legacy/commands/branches/create/create.command.ts`](../src/legacy/commands/branches/create/create.command.ts) | +| `branches get` | `wrapped` | [`../src/legacy/commands/branches/get/get.command.ts`](../src/legacy/commands/branches/get/get.command.ts) | +| `branches update` | `wrapped` | [`../src/legacy/commands/branches/update/update.command.ts`](../src/legacy/commands/branches/update/update.command.ts) | +| `branches pause` | `wrapped` | [`../src/legacy/commands/branches/pause/pause.command.ts`](../src/legacy/commands/branches/pause/pause.command.ts) | +| `branches unpause` | `wrapped` | [`../src/legacy/commands/branches/unpause/unpause.command.ts`](../src/legacy/commands/branches/unpause/unpause.command.ts) | +| `branches delete` | `wrapped` | [`../src/legacy/commands/branches/delete/delete.command.ts`](../src/legacy/commands/branches/delete/delete.command.ts) | +| `branches disable` | `wrapped` | [`../src/legacy/commands/branches/disable/disable.command.ts`](../src/legacy/commands/branches/disable/disable.command.ts) | +| `secrets list` | `wrapped` | [`../src/legacy/commands/secrets/list/list.command.ts`](../src/legacy/commands/secrets/list/list.command.ts) | +| `secrets set` | `wrapped` | [`../src/legacy/commands/secrets/set/set.command.ts`](../src/legacy/commands/secrets/set/set.command.ts) | +| `secrets unset` | `wrapped` | [`../src/legacy/commands/secrets/unset/unset.command.ts`](../src/legacy/commands/secrets/unset/unset.command.ts) | +| `config push` | `wrapped` | [`../src/legacy/commands/config/push/push.command.ts`](../src/legacy/commands/config/push/push.command.ts) | +| `backups list` | `ported` | [`../src/legacy/commands/backups/list/list.command.ts`](../src/legacy/commands/backups/list/list.command.ts) | +| `backups restore` | `ported` | [`../src/legacy/commands/backups/restore/restore.command.ts`](../src/legacy/commands/backups/restore/restore.command.ts) | +| `snippets list` | `wrapped` | [`../src/legacy/commands/snippets/list/list.command.ts`](../src/legacy/commands/snippets/list/list.command.ts) | +| `snippets download` | `wrapped` | [`../src/legacy/commands/snippets/download/download.command.ts`](../src/legacy/commands/snippets/download/download.command.ts) | +| `sso list` | `wrapped` | [`../src/legacy/commands/sso/list/list.command.ts`](../src/legacy/commands/sso/list/list.command.ts) | +| `sso add` | `wrapped` | [`../src/legacy/commands/sso/add/add.command.ts`](../src/legacy/commands/sso/add/add.command.ts) | +| `sso remove` | `wrapped` | [`../src/legacy/commands/sso/remove/remove.command.ts`](../src/legacy/commands/sso/remove/remove.command.ts) | +| `sso update` | `wrapped` | [`../src/legacy/commands/sso/update/update.command.ts`](../src/legacy/commands/sso/update/update.command.ts) | +| `sso show` | `wrapped` | [`../src/legacy/commands/sso/show/show.command.ts`](../src/legacy/commands/sso/show/show.command.ts) | +| `sso info` | `wrapped` | [`../src/legacy/commands/sso/info/info.command.ts`](../src/legacy/commands/sso/info/info.command.ts) | +| `domains create` | `wrapped` | [`../src/legacy/commands/domains/create/create.command.ts`](../src/legacy/commands/domains/create/create.command.ts) | +| `domains get` | `wrapped` | [`../src/legacy/commands/domains/get/get.command.ts`](../src/legacy/commands/domains/get/get.command.ts) | +| `domains reverify` | `wrapped` | [`../src/legacy/commands/domains/reverify/reverify.command.ts`](../src/legacy/commands/domains/reverify/reverify.command.ts) | +| `domains activate` | `wrapped` | [`../src/legacy/commands/domains/activate/activate.command.ts`](../src/legacy/commands/domains/activate/activate.command.ts) | +| `domains delete` | `wrapped` | [`../src/legacy/commands/domains/delete/delete.command.ts`](../src/legacy/commands/domains/delete/delete.command.ts) | +| `vanity-subdomains get` | `wrapped` | [`../src/legacy/commands/vanity-subdomains/get/get.command.ts`](../src/legacy/commands/vanity-subdomains/get/get.command.ts) | +| `vanity-subdomains check-availability` | `wrapped` | [`../src/legacy/commands/vanity-subdomains/check-availability/check-availability.command.ts`](../src/legacy/commands/vanity-subdomains/check-availability/check-availability.command.ts) | +| `vanity-subdomains activate` | `wrapped` | [`../src/legacy/commands/vanity-subdomains/activate/activate.command.ts`](../src/legacy/commands/vanity-subdomains/activate/activate.command.ts) | +| `vanity-subdomains delete` | `wrapped` | [`../src/legacy/commands/vanity-subdomains/delete/delete.command.ts`](../src/legacy/commands/vanity-subdomains/delete/delete.command.ts) | +| `network-bans get` | `wrapped` | [`../src/legacy/commands/network-bans/get/get.command.ts`](../src/legacy/commands/network-bans/get/get.command.ts) | +| `network-bans remove` | `wrapped` | [`../src/legacy/commands/network-bans/remove/remove.command.ts`](../src/legacy/commands/network-bans/remove/remove.command.ts) | +| `network-restrictions get` | `wrapped` | [`../src/legacy/commands/network-restrictions/get/get.command.ts`](../src/legacy/commands/network-restrictions/get/get.command.ts) | +| `network-restrictions update` | `wrapped` | [`../src/legacy/commands/network-restrictions/update/update.command.ts`](../src/legacy/commands/network-restrictions/update/update.command.ts) | +| `encryption get-root-key` | `wrapped` | [`../src/legacy/commands/encryption/get-root-key/get-root-key.command.ts`](../src/legacy/commands/encryption/get-root-key/get-root-key.command.ts) | +| `encryption update-root-key` | `wrapped` | [`../src/legacy/commands/encryption/update-root-key/update-root-key.command.ts`](../src/legacy/commands/encryption/update-root-key/update-root-key.command.ts) | +| `ssl-enforcement get` | `wrapped` | [`../src/legacy/commands/ssl-enforcement/get/get.command.ts`](../src/legacy/commands/ssl-enforcement/get/get.command.ts) | +| `ssl-enforcement update` | `wrapped` | [`../src/legacy/commands/ssl-enforcement/update/update.command.ts`](../src/legacy/commands/ssl-enforcement/update/update.command.ts) | +| `postgres-config get` | `wrapped` | [`../src/legacy/commands/postgres-config/get/get.command.ts`](../src/legacy/commands/postgres-config/get/get.command.ts) | +| `postgres-config update` | `wrapped` | [`../src/legacy/commands/postgres-config/update/update.command.ts`](../src/legacy/commands/postgres-config/update/update.command.ts) | +| `postgres-config delete` | `wrapped` | [`../src/legacy/commands/postgres-config/delete/delete.command.ts`](../src/legacy/commands/postgres-config/delete/delete.command.ts) | +| `login` | `wrapped` | [`../src/legacy/commands/login/login.command.ts`](../src/legacy/commands/login/login.command.ts) | +| `logout` | `wrapped` | [`../src/legacy/commands/logout/logout.command.ts`](../src/legacy/commands/logout/logout.command.ts) | +| `link` | `wrapped` | [`../src/legacy/commands/link/link.command.ts`](../src/legacy/commands/link/link.command.ts) | +| `unlink` | `wrapped` | [`../src/legacy/commands/unlink/unlink.command.ts`](../src/legacy/commands/unlink/unlink.command.ts) | +| `bootstrap` | `wrapped` | [`../src/legacy/commands/bootstrap/bootstrap.command.ts`](../src/legacy/commands/bootstrap/bootstrap.command.ts) | +| `init` | `wrapped` | [`../src/legacy/commands/init/init.command.ts`](../src/legacy/commands/init/init.command.ts) | +| `services` | `wrapped` | [`../src/legacy/commands/services/services.command.ts`](../src/legacy/commands/services/services.command.ts) | +| `start` | `wrapped` | [`../src/legacy/commands/start/start.command.ts`](../src/legacy/commands/start/start.command.ts) | +| `stop` | `wrapped` | [`../src/legacy/commands/stop/stop.command.ts`](../src/legacy/commands/stop/stop.command.ts) | +| `status` | `wrapped` | [`../src/legacy/commands/status/status.command.ts`](../src/legacy/commands/status/status.command.ts) | +| `migration list` | `wrapped` | [`../src/legacy/commands/migration/list/list.command.ts`](../src/legacy/commands/migration/list/list.command.ts) | +| `migration new` | `wrapped` | [`../src/legacy/commands/migration/new/new.command.ts`](../src/legacy/commands/migration/new/new.command.ts) | +| `migration repair` | `wrapped` | [`../src/legacy/commands/migration/repair/repair.command.ts`](../src/legacy/commands/migration/repair/repair.command.ts) | +| `migration squash` | `wrapped` | [`../src/legacy/commands/migration/squash/squash.command.ts`](../src/legacy/commands/migration/squash/squash.command.ts) | +| `migration up` | `wrapped` | [`../src/legacy/commands/migration/up/up.command.ts`](../src/legacy/commands/migration/up/up.command.ts) | +| `migration down` | `wrapped` | [`../src/legacy/commands/migration/down/down.command.ts`](../src/legacy/commands/migration/down/down.command.ts) | +| `migration fetch` | `wrapped` | [`../src/legacy/commands/migration/fetch/fetch.command.ts`](../src/legacy/commands/migration/fetch/fetch.command.ts) | +| `gen types` | `wrapped` | [`../src/legacy/commands/gen/types/types.command.ts`](../src/legacy/commands/gen/types/types.command.ts) | +| `gen signing-key` | `wrapped` | [`../src/legacy/commands/gen/signing-key/signing-key.command.ts`](../src/legacy/commands/gen/signing-key/signing-key.command.ts) | +| `gen bearer-jwt` | `wrapped` | [`../src/legacy/commands/gen/bearer-jwt/bearer-jwt.command.ts`](../src/legacy/commands/gen/bearer-jwt/bearer-jwt.command.ts) | +| `gen keys` | `wrapped` | [`../src/legacy/commands/gen/keys/keys.command.ts`](../src/legacy/commands/gen/keys/keys.command.ts) | +| `functions list` | `wrapped` | [`../src/legacy/commands/functions/list/list.command.ts`](../src/legacy/commands/functions/list/list.command.ts) | +| `functions delete` | `wrapped` | [`../src/legacy/commands/functions/delete/delete.command.ts`](../src/legacy/commands/functions/delete/delete.command.ts) | +| `functions download` | `wrapped` | [`../src/legacy/commands/functions/download/download.command.ts`](../src/legacy/commands/functions/download/download.command.ts) | +| `functions deploy` | `wrapped` | [`../src/legacy/commands/functions/deploy/deploy.command.ts`](../src/legacy/commands/functions/deploy/deploy.command.ts) | +| `functions new` | `wrapped` | [`../src/legacy/commands/functions/new/new.command.ts`](../src/legacy/commands/functions/new/new.command.ts) | +| `functions serve` | `wrapped` | [`../src/legacy/commands/functions/serve/serve.command.ts`](../src/legacy/commands/functions/serve/serve.command.ts) | +| `storage ls` | `wrapped` | [`../src/legacy/commands/storage/ls/ls.command.ts`](../src/legacy/commands/storage/ls/ls.command.ts) | +| `storage cp` | `wrapped` | [`../src/legacy/commands/storage/cp/cp.command.ts`](../src/legacy/commands/storage/cp/cp.command.ts) | +| `storage mv` | `wrapped` | [`../src/legacy/commands/storage/mv/mv.command.ts`](../src/legacy/commands/storage/mv/mv.command.ts) | +| `storage rm` | `wrapped` | [`../src/legacy/commands/storage/rm/rm.command.ts`](../src/legacy/commands/storage/rm/rm.command.ts) | +| `test db` | `wrapped` | [`../src/legacy/commands/test/db/db.command.ts`](../src/legacy/commands/test/db/db.command.ts) | +| `test new` | `wrapped` | [`../src/legacy/commands/test/new/new.command.ts`](../src/legacy/commands/test/new/new.command.ts) | +| `seed buckets` | `wrapped` | [`../src/legacy/commands/seed/buckets/buckets.command.ts`](../src/legacy/commands/seed/buckets/buckets.command.ts) | +| `db diff` | `wrapped` | [`../src/legacy/commands/db/diff/diff.command.ts`](../src/legacy/commands/db/diff/diff.command.ts) | +| `db dump` | `wrapped` | [`../src/legacy/commands/db/dump/dump.command.ts`](../src/legacy/commands/db/dump/dump.command.ts) | +| `db push` | `wrapped` | [`../src/legacy/commands/db/push/push.command.ts`](../src/legacy/commands/db/push/push.command.ts) | +| `db pull` | `wrapped` | [`../src/legacy/commands/db/pull/pull.command.ts`](../src/legacy/commands/db/pull/pull.command.ts) — includes `--diff-engine` (migra\|pg-delta, mutually exclusive with `--use-pg-delta`) | +| `db reset` | `wrapped` | [`../src/legacy/commands/db/reset/reset.command.ts`](../src/legacy/commands/db/reset/reset.command.ts) | +| `db lint` | `wrapped` | [`../src/legacy/commands/db/lint/lint.command.ts`](../src/legacy/commands/db/lint/lint.command.ts) | +| `db start` | `wrapped` | [`../src/legacy/commands/db/start/start.command.ts`](../src/legacy/commands/db/start/start.command.ts) | +| `db query` | `wrapped` | [`../src/legacy/commands/db/query/query.command.ts`](../src/legacy/commands/db/query/query.command.ts) | +| `db advisors` | `wrapped` | [`../src/legacy/commands/db/advisors/advisors.command.ts`](../src/legacy/commands/db/advisors/advisors.command.ts) | +| `db test` | `wrapped` | [`../src/legacy/commands/db/test/test.command.ts`](../src/legacy/commands/db/test/test.command.ts) | +| `db branch create` | `wrapped` | [`../src/legacy/commands/db/branch/create/create.command.ts`](../src/legacy/commands/db/branch/create/create.command.ts) | +| `db branch delete` | `wrapped` | [`../src/legacy/commands/db/branch/delete/delete.command.ts`](../src/legacy/commands/db/branch/delete/delete.command.ts) | +| `db branch list` | `wrapped` | [`../src/legacy/commands/db/branch/list/list.command.ts`](../src/legacy/commands/db/branch/list/list.command.ts) | +| `db branch switch` | `wrapped` | [`../src/legacy/commands/db/branch/switch/switch.command.ts`](../src/legacy/commands/db/branch/switch/switch.command.ts) | +| `db remote changes` | `wrapped` | [`../src/legacy/commands/db/remote/changes/changes.command.ts`](../src/legacy/commands/db/remote/changes/changes.command.ts) | +| `db remote commit` | `wrapped` | [`../src/legacy/commands/db/remote/commit/commit.command.ts`](../src/legacy/commands/db/remote/commit/commit.command.ts) | +| `db schema declarative sync` | `wrapped` | [`../src/legacy/commands/db/schema/declarative/sync/sync.command.ts`](../src/legacy/commands/db/schema/declarative/sync/sync.command.ts) | +| `db schema declarative generate` | `wrapped` | [`../src/legacy/commands/db/schema/declarative/generate/generate.command.ts`](../src/legacy/commands/db/schema/declarative/generate/generate.command.ts) | diff --git a/apps/cli/package.json b/apps/cli/package.json index 67ab24316..e0e6ebc09 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -64,7 +64,9 @@ "react": "^19.2.6", "react-devtools-core": "^7.0.1", "semantic-release": "^24.2.9", - "vitest": "catalog:" + "smol-toml": "^1.6.1", + "vitest": "catalog:", + "yaml": "^2.9.0" }, "optionalDependencies": { "@supabase/cli-darwin-arm64": "workspace:*", diff --git a/apps/cli/src/legacy/auth/legacy-credentials.layer.ts b/apps/cli/src/legacy/auth/legacy-credentials.layer.ts new file mode 100644 index 000000000..fca5e4c84 --- /dev/null +++ b/apps/cli/src/legacy/auth/legacy-credentials.layer.ts @@ -0,0 +1,155 @@ +import { Effect, FileSystem, Layer, Option, Path, Redacted } from "effect"; + +import { RuntimeInfo } from "../../shared/runtime/runtime-info.service.ts"; +import { LegacyCliConfig } from "../config/legacy-cli-config.service.ts"; +import { LegacyCredentials } from "./legacy-credentials.service.ts"; +import { LegacyInvalidAccessTokenError } from "./legacy-errors.ts"; + +const KEYRING_SERVICE = "Supabase CLI"; +const LEGACY_KEYRING_ACCOUNT = "access-token"; +const WSL_OSRELEASE_PATH = "/proc/sys/kernel/osrelease"; + +const ACCESS_TOKEN_PATTERN = /^sbp_(oauth_)?[a-f0-9]{40}$/; + +const INVALID_TOKEN_MESSAGE = "Invalid access token format. Must be like `sbp_0102...1920`."; + +type KeyringModule = typeof import("@napi-rs/keyring"); + +const detectWsl = (fs: FileSystem.FileSystem): Effect.Effect => + Effect.gen(function* () { + const exists = yield* fs.exists(WSL_OSRELEASE_PATH).pipe(Effect.orElseSucceed(() => false)); + if (!exists) return false; + const content = yield* fs + .readFileString(WSL_OSRELEASE_PATH) + .pipe(Effect.orElseSucceed(() => "")); + return content.includes("WSL") || content.includes("Microsoft"); + }); + +const tryKeyringRead = ( + module: KeyringModule, + account: string, +): Effect.Effect> => + Effect.try({ + try: () => { + const entry = new module.Entry(KEYRING_SERVICE, account); + const value = entry.getPassword(); + return value && value.length > 0 ? Option.some(value) : Option.none(); + }, + catch: () => Option.none(), + }).pipe(Effect.orElseSucceed(() => Option.none())); + +const tryKeyringWrite = ( + module: KeyringModule, + account: string, + token: string, +): Effect.Effect => + Effect.try({ + try: () => { + const entry = new module.Entry(KEYRING_SERVICE, account); + entry.setPassword(token); + return true; + }, + catch: () => false, + }).pipe(Effect.orElseSucceed(() => false)); + +const tryKeyringDelete = (module: KeyringModule, account: string): Effect.Effect => + Effect.try({ + try: () => { + const entry = new module.Entry(KEYRING_SERVICE, account); + const value = entry.getPassword(); + if (!value) return false; + entry.deleteCredential(); + return true; + }, + catch: () => false, + }).pipe(Effect.orElseSucceed(() => false)); + +const makeLegacyCredentials = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const runtimeInfo = yield* RuntimeInfo; + const cliConfig = yield* LegacyCliConfig; + const profileAccount = cliConfig.profile; + + // ~/.supabase/access-token — fallback file path + const fallbackDir = path.join(runtimeInfo.homeDir, ".supabase"); + const fallbackPath = path.join(fallbackDir, "access-token"); + + const wsl = yield* detectWsl(fs); + const keyringModule = wsl + ? Option.none() + : yield* Effect.tryPromise(() => import("@napi-rs/keyring")).pipe(Effect.option); + + const validate = (token: string): Effect.Effect => + ACCESS_TOKEN_PATTERN.test(token) + ? Effect.succeed(token) + : Effect.fail(new LegacyInvalidAccessTokenError({ message: INVALID_TOKEN_MESSAGE })); + + const readKeyring = Effect.gen(function* () { + if (Option.isNone(keyringModule)) return Option.none(); + const profileResult = yield* tryKeyringRead(keyringModule.value, profileAccount); + if (Option.isSome(profileResult)) return profileResult; + return yield* tryKeyringRead(keyringModule.value, LEGACY_KEYRING_ACCOUNT); + }); + + const readFile = Effect.gen(function* () { + const exists = yield* fs.exists(fallbackPath).pipe(Effect.orElseSucceed(() => false)); + if (!exists) return Option.none(); + const content = yield* fs.readFileString(fallbackPath).pipe(Effect.orElseSucceed(() => "")); + const trimmed = content.trim(); + return trimmed.length === 0 ? Option.none() : Option.some(trimmed); + }); + + return LegacyCredentials.of({ + getAccessToken: Effect.gen(function* () { + // Env takes precedence (matches access_token.go:38). + if (Option.isSome(cliConfig.accessToken)) { + yield* validate(Redacted.value(cliConfig.accessToken.value)); + return Option.some(cliConfig.accessToken.value); + } + + // Keyring (profile key, then legacy key). Skipped on WSL. + const keyringValue = yield* readKeyring; + if (Option.isSome(keyringValue)) { + yield* validate(keyringValue.value); + return Option.some(Redacted.make(keyringValue.value)); + } + + // Filesystem fallback at ~/.supabase/access-token. + const fileValue = yield* readFile; + if (Option.isSome(fileValue)) { + yield* validate(fileValue.value); + return Option.some(Redacted.make(fileValue.value)); + } + + return Option.none(); + }), + + saveAccessToken: (token: string) => + Effect.gen(function* () { + yield* validate(token); + if (Option.isSome(keyringModule)) { + const ok = yield* tryKeyringWrite(keyringModule.value, profileAccount, token); + if (ok) return; + } + yield* fs.makeDirectory(fallbackDir, { recursive: true, mode: 0o700 }).pipe(Effect.orDie); + yield* fs.writeFileString(fallbackPath, token, { mode: 0o600 }).pipe(Effect.orDie); + }), + + deleteAccessToken: Effect.gen(function* () { + let anyDeleted = false; + if (Option.isSome(keyringModule)) { + if (yield* tryKeyringDelete(keyringModule.value, profileAccount)) anyDeleted = true; + if (yield* tryKeyringDelete(keyringModule.value, LEGACY_KEYRING_ACCOUNT)) anyDeleted = true; + } + const exists = yield* fs.exists(fallbackPath).pipe(Effect.orElseSucceed(() => false)); + if (exists) { + yield* fs.remove(fallbackPath).pipe(Effect.orDie); + anyDeleted = true; + } + return anyDeleted; + }), + }); +}); + +export const legacyCredentialsLayer = Layer.effect(LegacyCredentials, makeLegacyCredentials); diff --git a/apps/cli/src/legacy/auth/legacy-credentials.layer.unit.test.ts b/apps/cli/src/legacy/auth/legacy-credentials.layer.unit.test.ts new file mode 100644 index 000000000..13b1a0286 --- /dev/null +++ b/apps/cli/src/legacy/auth/legacy-credentials.layer.unit.test.ts @@ -0,0 +1,230 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { describe, expect, it } from "@effect/vitest"; +import { BunServices } from "@effect/platform-bun"; +import { Effect, Layer, Option, Redacted } from "effect"; +import { afterEach, beforeEach, vi } from "vitest"; + +import { LegacyProfileFlag, LegacyWorkdirFlag } from "../../shared/legacy/global-flags.ts"; +import { mockRuntimeInfo, processEnvLayer } from "../../../tests/helpers/mocks.ts"; +import { legacyCliConfigLayer } from "../config/legacy-cli-config.layer.ts"; +import { legacyCredentialsLayer } from "./legacy-credentials.layer.ts"; +import { LegacyCredentials } from "./legacy-credentials.service.ts"; +import { LegacyInvalidAccessTokenError } from "./legacy-errors.ts"; + +// --------------------------------------------------------------------------- +// Keyring mock +// --------------------------------------------------------------------------- + +const passwords = new Map(); +let throwOnSetPassword = false; +const throwOnGetPasswordAccounts = new Set(); + +vi.mock("@napi-rs/keyring", () => ({ + Entry: class Entry { + service: string; + account: string; + constructor(service: string, account: string) { + this.service = service; + this.account = account; + } + getPassword(): string | null { + const key = `${this.service}/${this.account}`; + if (throwOnGetPasswordAccounts.has(key)) { + throw new Error("Keyring unavailable"); + } + return passwords.get(key) ?? null; + } + setPassword(value: string): void { + if (throwOnSetPassword) throw new Error("Keyring unavailable"); + passwords.set(`${this.service}/${this.account}`, value); + } + deleteCredential(): boolean { + const key = `${this.service}/${this.account}`; + if (!passwords.has(key)) throw new Error("not found"); + passwords.delete(key); + return true; + } + }, +})); + +// --------------------------------------------------------------------------- +// Layer wiring +// --------------------------------------------------------------------------- + +let tempHome: string; + +function makeLayer(opts: { env?: Record; home?: string } = {}) { + const home = opts.home ?? tempHome; + const env = { HOME: home, ...opts.env }; + const runtimeInfoLayer = mockRuntimeInfo({ homeDir: home, cwd: home }); + const cliConfigLayer = legacyCliConfigLayer.pipe( + Layer.provide(Layer.succeed(LegacyProfileFlag, "supabase")), + Layer.provide(Layer.succeed(LegacyWorkdirFlag, Option.none())), + Layer.provide(runtimeInfoLayer), + Layer.provide(BunServices.layer), + Layer.provide(processEnvLayer(env)), + ); + return legacyCredentialsLayer.pipe( + Layer.provide(cliConfigLayer), + Layer.provide(runtimeInfoLayer), + Layer.provide(BunServices.layer), + Layer.provide(processEnvLayer(env)), + ); +} + +beforeEach(() => { + passwords.clear(); + throwOnSetPassword = false; + throwOnGetPasswordAccounts.clear(); + tempHome = mkdtempSync(join(tmpdir(), "supabase-legacy-creds-")); +}); + +afterEach(() => { + rmSync(tempHome, { recursive: true, force: true }); +}); + +const VALID_TOKEN = "sbp_" + "a".repeat(40); +const VALID_OAUTH_TOKEN = "sbp_oauth_" + "b".repeat(40); + +const expectSomeToken = (token: Option.Option>, expected: string) => { + expect(Option.isSome(token)).toBe(true); + if (Option.isSome(token)) { + expect(Redacted.value(token.value)).toBe(expected); + } +}; + +describe("legacyCredentialsLayer.getAccessToken", () => { + it.effect("returns the SUPABASE_ACCESS_TOKEN env value (highest precedence)", () => { + passwords.set("Supabase CLI/supabase", "sbp_" + "9".repeat(40)); + return Effect.gen(function* () { + const { getAccessToken } = yield* LegacyCredentials; + const token = yield* getAccessToken; + expectSomeToken(token, VALID_TOKEN); + }).pipe(Effect.provide(makeLayer({ env: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN } }))); + }); + + it.effect("uses the keyring profile account when env is unset", () => { + passwords.set("Supabase CLI/supabase", VALID_TOKEN); + return Effect.gen(function* () { + const { getAccessToken } = yield* LegacyCredentials; + const token = yield* getAccessToken; + expectSomeToken(token, VALID_TOKEN); + }).pipe(Effect.provide(makeLayer())); + }); + + it.effect("falls through to the legacy access-token keyring entry", () => { + passwords.set("Supabase CLI/access-token", VALID_OAUTH_TOKEN); + return Effect.gen(function* () { + const { getAccessToken } = yield* LegacyCredentials; + const token = yield* getAccessToken; + expectSomeToken(token, VALID_OAUTH_TOKEN); + }).pipe(Effect.provide(makeLayer())); + }); + + it.effect("falls back to ~/.supabase/access-token when keyring entries miss", () => { + const supaDir = join(tempHome, ".supabase"); + mkdirSync(supaDir, { recursive: true }); + writeFileSync(join(supaDir, "access-token"), `${VALID_TOKEN}\n`, { mode: 0o600 }); + return Effect.gen(function* () { + const { getAccessToken } = yield* LegacyCredentials; + const token = yield* getAccessToken; + expectSomeToken(token, VALID_TOKEN); + }).pipe(Effect.provide(makeLayer())); + }); + + it.effect("returns None when no source provides a token", () => + Effect.gen(function* () { + const { getAccessToken } = yield* LegacyCredentials; + const token = yield* getAccessToken; + expect(token).toEqual(Option.none()); + }).pipe(Effect.provide(makeLayer())), + ); + + it.effect("fails with LegacyInvalidAccessTokenError when token format is invalid", () => { + passwords.set("Supabase CLI/supabase", "not-a-valid-token"); + return Effect.gen(function* () { + const { getAccessToken } = yield* LegacyCredentials; + const exit = yield* Effect.exit(getAccessToken); + expect(exit._tag).toBe("Failure"); + if (exit._tag === "Failure") { + const errorJson = JSON.stringify(exit.cause); + expect(errorJson).toContain("LegacyInvalidAccessTokenError"); + expect(errorJson).toContain("Invalid access token format"); + } + }).pipe(Effect.provide(makeLayer())); + }); + + it.effect("falls back to the filesystem when keyring throws", () => { + throwOnGetPasswordAccounts.add("Supabase CLI/supabase"); + throwOnGetPasswordAccounts.add("Supabase CLI/access-token"); + const supaDir = join(tempHome, ".supabase"); + mkdirSync(supaDir, { recursive: true }); + writeFileSync(join(supaDir, "access-token"), VALID_TOKEN, { mode: 0o600 }); + return Effect.gen(function* () { + const { getAccessToken } = yield* LegacyCredentials; + const token = yield* getAccessToken; + expectSomeToken(token, VALID_TOKEN); + }).pipe(Effect.provide(makeLayer())); + }); +}); + +describe("legacyCredentialsLayer.saveAccessToken", () => { + it.effect("rejects invalid token formats up front", () => + Effect.gen(function* () { + const { saveAccessToken } = yield* LegacyCredentials; + const exit = yield* Effect.exit(saveAccessToken("nope")); + expect(exit._tag).toBe("Failure"); + if (exit._tag === "Failure") { + expect(JSON.stringify(exit.cause)).toContain("LegacyInvalidAccessTokenError"); + } + }).pipe(Effect.provide(makeLayer())), + ); + + it.effect("writes to the keyring profile entry when available", () => + Effect.gen(function* () { + const { saveAccessToken } = yield* LegacyCredentials; + yield* saveAccessToken(VALID_TOKEN); + expect(passwords.get("Supabase CLI/supabase")).toBe(VALID_TOKEN); + }).pipe(Effect.provide(makeLayer())), + ); + + it.effect("falls back to the filesystem when the keyring write throws", () => { + throwOnSetPassword = true; + return Effect.gen(function* () { + const { saveAccessToken } = yield* LegacyCredentials; + yield* saveAccessToken(VALID_TOKEN); + const content = readFileSync(join(tempHome, ".supabase", "access-token"), "utf-8"); + expect(content).toBe(VALID_TOKEN); + }).pipe(Effect.provide(makeLayer())); + }); +}); + +describe("legacyCredentialsLayer.deleteAccessToken", () => { + it.effect("returns false when no token is stored anywhere", () => + Effect.gen(function* () { + const { deleteAccessToken } = yield* LegacyCredentials; + expect(yield* deleteAccessToken).toBe(false); + }).pipe(Effect.provide(makeLayer())), + ); + + it.effect("removes both keyring entries plus the filesystem file", () => { + passwords.set("Supabase CLI/supabase", VALID_TOKEN); + passwords.set("Supabase CLI/access-token", VALID_OAUTH_TOKEN); + const supaDir = join(tempHome, ".supabase"); + mkdirSync(supaDir, { recursive: true }); + writeFileSync(join(supaDir, "access-token"), VALID_TOKEN, { mode: 0o600 }); + return Effect.gen(function* () { + const { deleteAccessToken } = yield* LegacyCredentials; + expect(yield* deleteAccessToken).toBe(true); + expect(passwords.has("Supabase CLI/supabase")).toBe(false); + expect(passwords.has("Supabase CLI/access-token")).toBe(false); + expect(existsSync(join(supaDir, "access-token"))).toBe(false); + }).pipe(Effect.provide(makeLayer())); + }); +}); + +// Suppress unused-import nag — referenced in JSDoc. +void LegacyInvalidAccessTokenError; diff --git a/apps/cli/src/legacy/auth/legacy-credentials.service.ts b/apps/cli/src/legacy/auth/legacy-credentials.service.ts new file mode 100644 index 000000000..911b0f07d --- /dev/null +++ b/apps/cli/src/legacy/auth/legacy-credentials.service.ts @@ -0,0 +1,17 @@ +import type { Effect, Option, Redacted } from "effect"; +import { Context } from "effect"; + +import type { LegacyInvalidAccessTokenError } from "./legacy-errors.ts"; + +interface LegacyCredentialsShape { + readonly getAccessToken: Effect.Effect< + Option.Option>, + LegacyInvalidAccessTokenError + >; + readonly saveAccessToken: (token: string) => Effect.Effect; + readonly deleteAccessToken: Effect.Effect; +} + +export class LegacyCredentials extends Context.Service()( + "supabase/legacy/Credentials", +) {} diff --git a/apps/cli/src/legacy/auth/legacy-errors.ts b/apps/cli/src/legacy/auth/legacy-errors.ts new file mode 100644 index 000000000..ab5c93694 --- /dev/null +++ b/apps/cli/src/legacy/auth/legacy-errors.ts @@ -0,0 +1,13 @@ +import { Data } from "effect"; + +export class LegacyInvalidAccessTokenError extends Data.TaggedError( + "LegacyInvalidAccessTokenError", +)<{ + readonly message: string; +}> {} + +export class LegacyPlatformAuthRequiredError extends Data.TaggedError( + "LegacyPlatformAuthRequiredError", +)<{ + readonly message: string; +}> {} diff --git a/apps/cli/src/legacy/auth/legacy-platform-api.layer.ts b/apps/cli/src/legacy/auth/legacy-platform-api.layer.ts new file mode 100644 index 000000000..2735e82c3 --- /dev/null +++ b/apps/cli/src/legacy/auth/legacy-platform-api.layer.ts @@ -0,0 +1,37 @@ +import { makeApiClient } from "@supabase/api/effect"; +import { Effect, Layer, Option } from "effect"; + +import { LegacyCliConfig } from "../config/legacy-cli-config.service.ts"; +import { LegacyCredentials } from "./legacy-credentials.service.ts"; +import { LegacyPlatformAuthRequiredError } from "./legacy-errors.ts"; +import { LegacyPlatformApi } from "./legacy-platform-api.service.ts"; + +const MISSING_TOKEN_MESSAGE = + "Access token not provided. Supply an access token by running `supabase login` or setting the SUPABASE_ACCESS_TOKEN environment variable."; + +const makeLegacyPlatformApiServices = Effect.gen(function* () { + const cliConfig = yield* LegacyCliConfig; + const credentials = yield* LegacyCredentials; + + // Env takes precedence over keyring/file (already inside LegacyCredentials), but + // LegacyCliConfig.accessToken is the env value alone — read in the same order Go uses. + const configuredToken = cliConfig.accessToken; + const storedToken = Option.isSome(configuredToken) + ? configuredToken + : yield* credentials.getAccessToken; + + if (Option.isNone(storedToken)) { + return yield* Effect.fail( + new LegacyPlatformAuthRequiredError({ message: MISSING_TOKEN_MESSAGE }), + ); + } + + const api = yield* makeApiClient({ + baseUrl: cliConfig.apiUrl, + accessToken: storedToken.value, + userAgent: cliConfig.userAgent, + }); + return Layer.succeed(LegacyPlatformApi, api); +}); + +export const legacyPlatformApiLayer = Layer.unwrap(makeLegacyPlatformApiServices); diff --git a/apps/cli/src/legacy/auth/legacy-platform-api.layer.unit.test.ts b/apps/cli/src/legacy/auth/legacy-platform-api.layer.unit.test.ts new file mode 100644 index 000000000..205034f58 --- /dev/null +++ b/apps/cli/src/legacy/auth/legacy-platform-api.layer.unit.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, Layer, Option, Redacted } from "effect"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; +import type * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; + +import { LegacyCliConfig } from "../config/legacy-cli-config.service.ts"; +import { LegacyCredentials } from "./legacy-credentials.service.ts"; +import { legacyPlatformApiLayer } from "./legacy-platform-api.layer.ts"; +import { LegacyPlatformApi } from "./legacy-platform-api.service.ts"; + +const VALID_TOKEN = "sbp_" + "a".repeat(40); + +function mockCliConfig(opts: { accessToken?: string; apiUrl?: string; userAgent?: string }) { + return Layer.succeed(LegacyCliConfig, { + profile: "supabase", + apiUrl: opts.apiUrl ?? "https://api.supabase.com", + accessToken: + opts.accessToken === undefined ? Option.none() : Option.some(Redacted.make(opts.accessToken)), + projectId: Option.none(), + workdir: "/tmp", + userAgent: opts.userAgent ?? "SupabaseCLI/0.0.0-dev", + }); +} + +function mockCredentials(token: Option.Option) { + return Layer.succeed(LegacyCredentials, { + getAccessToken: Effect.succeed(Option.map(token, Redacted.make)), + saveAccessToken: () => Effect.void, + deleteAccessToken: Effect.succeed(false), + }); +} + +function captureRequests() { + const requests: Array<{ + url: string; + headers: Readonly>; + }> = []; + const httpClient = HttpClient.make((request: HttpClientRequest.HttpClientRequest) => { + requests.push({ url: request.url, headers: request.headers }); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(JSON.stringify([]), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ), + ); + }); + return { layer: Layer.succeed(HttpClient.HttpClient, httpClient), requests }; +} + +describe("legacyPlatformApiLayer", () => { + it.effect("uses env access token over keyring-stored token", () => { + const http = captureRequests(); + const layer = legacyPlatformApiLayer.pipe( + Layer.provide(mockCliConfig({ accessToken: VALID_TOKEN })), + Layer.provide(mockCredentials(Option.some("sbp_" + "9".repeat(40)))), + Layer.provide(http.layer), + ); + return Effect.gen(function* () { + const api = yield* LegacyPlatformApi; + yield* api.v1.listAllProjects(); + expect(http.requests).toHaveLength(1); + expect(http.requests[0]?.headers.authorization).toBe(`Bearer ${VALID_TOKEN}`); + }).pipe(Effect.provide(layer)); + }); + + it.effect("uses LegacyCredentials.getAccessToken when env is unset", () => { + const http = captureRequests(); + const layer = legacyPlatformApiLayer.pipe( + Layer.provide(mockCliConfig({})), + Layer.provide(mockCredentials(Option.some(VALID_TOKEN))), + Layer.provide(http.layer), + ); + return Effect.gen(function* () { + const api = yield* LegacyPlatformApi; + yield* api.v1.listAllProjects(); + expect(http.requests[0]?.headers.authorization).toBe(`Bearer ${VALID_TOKEN}`); + }).pipe(Effect.provide(layer)); + }); + + it.effect("fails with LegacyPlatformAuthRequiredError when no token is configured", () => { + const http = captureRequests(); + const layer = legacyPlatformApiLayer.pipe( + Layer.provide(mockCliConfig({})), + Layer.provide(mockCredentials(Option.none())), + Layer.provide(http.layer), + ); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + Effect.gen(function* () { + const api = yield* LegacyPlatformApi; + return yield* api.v1.listAllProjects(); + }).pipe(Effect.provide(layer)), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const errorJson = JSON.stringify(exit.cause); + expect(errorJson).toContain("LegacyPlatformAuthRequiredError"); + expect(errorJson).toContain("Access token not provided"); + } + }); + }); + + it.effect("sends Go-style User-Agent and no X-Supabase-Command headers", () => { + const http = captureRequests(); + const layer = legacyPlatformApiLayer.pipe( + Layer.provide(mockCliConfig({ accessToken: VALID_TOKEN, userAgent: "SupabaseCLI/1.123.4" })), + Layer.provide(mockCredentials(Option.none())), + Layer.provide(http.layer), + ); + return Effect.gen(function* () { + const api = yield* LegacyPlatformApi; + yield* api.v1.listAllProjects(); + expect(http.requests[0]?.headers["user-agent"]).toBe("SupabaseCLI/1.123.4"); + expect(http.requests[0]?.headers["x-supabase-command"]).toBeUndefined(); + expect(http.requests[0]?.headers["x-supabase-command-run-id"]).toBeUndefined(); + }).pipe(Effect.provide(layer)); + }); + + it.effect("targets the configured apiUrl rather than SUPABASE_API_URL env", () => { + const http = captureRequests(); + const layer = legacyPlatformApiLayer.pipe( + Layer.provide( + mockCliConfig({ accessToken: VALID_TOKEN, apiUrl: "https://api.supabase.green" }), + ), + Layer.provide(mockCredentials(Option.none())), + Layer.provide(http.layer), + ); + return Effect.gen(function* () { + const api = yield* LegacyPlatformApi; + yield* api.v1.listAllProjects(); + expect(http.requests[0]?.url).toContain("https://api.supabase.green/"); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/cli/src/legacy/auth/legacy-platform-api.service.ts b/apps/cli/src/legacy/auth/legacy-platform-api.service.ts new file mode 100644 index 000000000..6631afa1e --- /dev/null +++ b/apps/cli/src/legacy/auth/legacy-platform-api.service.ts @@ -0,0 +1,6 @@ +import type { ApiClient } from "@supabase/api/effect"; +import { Context } from "effect"; + +export class LegacyPlatformApi extends Context.Service()( + "supabase/legacy/PlatformApi", +) {} diff --git a/apps/cli/src/legacy/commands/backups/backups.encoders.ts b/apps/cli/src/legacy/commands/backups/backups.encoders.ts new file mode 100644 index 000000000..58edb1d83 --- /dev/null +++ b/apps/cli/src/legacy/commands/backups/backups.encoders.ts @@ -0,0 +1,118 @@ +import type { V1ListAllBackupsOutput } from "@supabase/api/effect"; +import { stringify as stringifyToml } from "smol-toml"; +import { stringify as stringifyYaml } from "yaml"; + +/** + * Reproduces Go's `encoding/json` output for `V1BackupsResponse`: + * - Top-level and nested struct fields serialize in alphabetical declaration order. + * - Go emits `null` for a nil `Backups` slice. The TS schema decodes both `null` + * and `[]` upstream into `[]`, so we re-substitute `null` for empty arrays + * to match the common PITR-only response shape. + */ +export function encodeGoJson(response: typeof V1ListAllBackupsOutput.Type): string { + const source = response.backups.length > 0 ? response : { ...response, backups: null }; + return JSON.stringify(sortKeysDeep(source), null, 2) + "\n"; +} + +function sortKeysDeep(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sortKeysDeep); + if (value === null || typeof value !== "object") return value; + const sorted: Record = {}; + for (const key of Object.keys(value as Record).sort()) { + sorted[key] = sortKeysDeep((value as Record)[key]); + } + return sorted; +} + +export function encodeYaml(value: unknown): string { + return stringifyYaml(value); +} + +export function encodeToml(value: unknown): string { + // smol-toml refuses top-level non-object values; wrap if needed. + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return stringifyToml({ value }); + } + return stringifyToml(value as Record); +} + +/** + * Reproduces Go's `utils.ToEnvMap` + `godotenv.Marshal` byte shape for the + * Supabase CLI's `--output env` mode (see `apps/cli-go/internal/utils/output.go:86-107`). + * + * - Viper's `AllKeys()` descends into nested maps using dotted paths; the loop + * then `strings.ToUpper(strings.ReplaceAll(k, ".", "_"))` produces SCREAMING_SNAKE_CASE keys. + * - Viper does **not** descend into slices. An array value lands as a single + * leaf whose `GetString` rendering is the empty string — so e.g. + * `{backups: [{...}, {...}]}` becomes one `BACKUPS=""` entry, not indexed leaves. + * - Integer-parseable values are emitted unquoted (`KEY=123`), matching + * `godotenv.Marshal`'s `strconv.Atoi` branch. Everything else is double-quoted + * with `"` / `\\` escaped, matching the `fmt.Sprintf("%q", ...)` branch. + * - Lines are sorted lexicographically by key, then joined with `\n`. + */ +export function encodeEnv(value: unknown): string { + const flat = flatten(value); + const lines: string[] = []; + const keys = Object.keys(flat).sort(); + for (const key of keys) { + lines.push(`${key}=${formatEnvValue(flat[key] ?? "")}`); + } + return lines.join("\n"); +} + +function flatten( + value: unknown, + prefix = "", + out: Record = {}, +): Record { + if (value === null || value === undefined) { + if (prefix.length > 0) out[toEnvKey(prefix)] = ""; + return out; + } + if (Array.isArray(value)) { + // Go's viper does not descend into slices — the entire array collapses to a + // single empty-string leaf at the array's parent key. + if (prefix.length > 0) out[toEnvKey(prefix)] = ""; + return out; + } + if (typeof value === "object") { + // Go's viper.AllKeys() omits empty nested maps entirely (unlike empty + // slices, which leave a single empty-string leaf). Match that — recurse + // into populated maps; emit nothing for `{}`. + for (const [key, child] of Object.entries(value as Record)) { + flatten(child, prefix.length === 0 ? key : `${prefix}.${key}`, out); + } + return out; + } + if (prefix.length > 0) { + out[toEnvKey(prefix)] = stringifyScalar(value); + } + return out; +} + +function toEnvKey(key: string): string { + return key.replaceAll(".", "_").toUpperCase(); +} + +function stringifyScalar(value: unknown): string { + if (typeof value === "boolean") return value ? "true" : "false"; + if (typeof value === "number") return Number.isFinite(value) ? String(value) : ""; + return String(value); +} + +// strconv.Atoi accepts an optional +/- sign followed by base-10 digits. Match +// that surface so integer values flow through Go's unquoted `%d` branch. +const INTEGER_PATTERN = /^[+-]?\d+$/; + +function formatEnvValue(value: string): string { + if (INTEGER_PATTERN.test(value)) { + const parsed = Number(value); + // Mirror godotenv's `%d` formatting (round-trip through int — drops a leading + // `+` and any leading zeros, matching Go's strconv.Atoi + fmt.Sprintf("%d"). + if (Number.isSafeInteger(parsed)) { + return String(parsed); + } + } + const escaped = value.replaceAll("\\", "\\\\").replaceAll('"', '\\"'); + return `"${escaped}"`; +} diff --git a/apps/cli/src/legacy/commands/backups/backups.encoders.unit.test.ts b/apps/cli/src/legacy/commands/backups/backups.encoders.unit.test.ts new file mode 100644 index 000000000..0087b54a0 --- /dev/null +++ b/apps/cli/src/legacy/commands/backups/backups.encoders.unit.test.ts @@ -0,0 +1,186 @@ +import { V1ListAllBackupsOutput } from "@supabase/api/effect"; +import { describe, expect, it } from "vitest"; + +import { encodeEnv, encodeGoJson, encodeToml, encodeYaml } from "./backups.encoders.ts"; + +const SAMPLE_RESPONSE: typeof V1ListAllBackupsOutput.Type = { + region: "ap-southeast-1", + walg_enabled: true, + pitr_enabled: true, + backups: [ + { + id: 1, + is_physical_backup: true, + status: "COMPLETED", + inserted_at: "2026-02-08T16:44:07Z", + }, + ], + physical_backup_data: { + earliest_physical_backup_date_unix: 1700000000, + latest_physical_backup_date_unix: 1700001000, + }, +}; + +describe("encodeGoJson", () => { + it("emits Go's alphabetical struct-field order and trailing newline for a populated response", () => { + const out = encodeGoJson(SAMPLE_RESPONSE); + expect(out).toBe( + `{ + "backups": [ + { + "id": 1, + "inserted_at": "2026-02-08T16:44:07Z", + "is_physical_backup": true, + "status": "COMPLETED" + } + ], + "physical_backup_data": { + "earliest_physical_backup_date_unix": 1700000000, + "latest_physical_backup_date_unix": 1700001000 + }, + "pitr_enabled": true, + "region": "ap-southeast-1", + "walg_enabled": true +} +`, + ); + }); + + it("emits backups: null and an empty physical_backup_data object for a PITR-only response", () => { + // Matches Go's `apps/cli-go/internal/backups/list/list_test.go` "encodes json output" fixture + // — empty backups slice serializes as null, omitempty physical_backup_data fields drop out. + const out = encodeGoJson({ + region: "ap-southeast-1", + walg_enabled: false, + pitr_enabled: false, + backups: [], + physical_backup_data: {}, + }); + expect(out).toBe( + `{ + "backups": null, + "physical_backup_data": {}, + "pitr_enabled": false, + "region": "ap-southeast-1", + "walg_enabled": false +} +`, + ); + }); +}); + +describe("encodeYaml", () => { + it("renders nested objects as YAML", () => { + const out = encodeYaml(SAMPLE_RESPONSE); + expect(out).toContain("region: ap-southeast-1"); + expect(out).toContain("walg_enabled: true"); + expect(out).toContain("status: COMPLETED"); + expect(out).toContain("earliest_physical_backup_date_unix: 1700000000"); + }); +}); + +describe("encodeToml", () => { + it("renders a TOML document for the response", () => { + const out = encodeToml(SAMPLE_RESPONSE); + expect(out).toContain('region = "ap-southeast-1"'); + expect(out).toContain("walg_enabled = true"); + expect(out).toContain("[physical_backup_data]"); + expect(out).toContain("earliest_physical_backup_date_unix = 1700000000"); + }); +}); + +describe("encodeEnv", () => { + it("quotes string values and flattens nested fields to uppercased dotted keys", () => { + const out = encodeEnv(SAMPLE_RESPONSE); + const lines = out.split("\n"); + expect(lines).toContain('REGION="ap-southeast-1"'); + // Booleans are stringified to "true"/"false" — not integers under strconv.Atoi, + // so godotenv quotes them. + expect(lines).toContain('WALG_ENABLED="true"'); + expect(lines).toContain('PITR_ENABLED="true"'); + }); + + it("emits integer-parseable values unquoted (matches godotenv strconv.Atoi branch)", () => { + const out = encodeEnv(SAMPLE_RESPONSE); + const lines = out.split("\n"); + expect(lines).toContain("PHYSICAL_BACKUP_DATA_EARLIEST_PHYSICAL_BACKUP_DATE_UNIX=1700000000"); + expect(lines).toContain("PHYSICAL_BACKUP_DATA_LATEST_PHYSICAL_BACKUP_DATE_UNIX=1700001000"); + }); + + it("collapses arrays to a single empty leaf (Go viper does not descend into slices)", () => { + // Go output for `backups: [{...}]` is `BACKUPS=""`, not `BACKUPS_0_STATUS=...` + // — viper.AllKeys() stops at slice boundaries and GetString of a slice is "". + const out = encodeEnv(SAMPLE_RESPONSE); + const lines = out.split("\n"); + expect(lines).toContain('BACKUPS=""'); + expect(lines.some((line) => line.startsWith("BACKUPS_0_"))).toBe(false); + }); + + it("matches Go's full env output for the sample backup response", () => { + // Verified byte-for-byte against `apps/cli-go` invoking utils.EncodeOutput("env", ...). + expect(encodeEnv(SAMPLE_RESPONSE)).toBe( + [ + 'BACKUPS=""', + "PHYSICAL_BACKUP_DATA_EARLIEST_PHYSICAL_BACKUP_DATE_UNIX=1700000000", + "PHYSICAL_BACKUP_DATA_LATEST_PHYSICAL_BACKUP_DATE_UNIX=1700001000", + 'PITR_ENABLED="true"', + 'REGION="ap-southeast-1"', + 'WALG_ENABLED="true"', + ].join("\n"), + ); + }); + + it("escapes embedded backslashes and double quotes", () => { + const out = encodeEnv({ message: 'with "quotes" and \\backslash' }); + expect(out).toBe('MESSAGE="with \\"quotes\\" and \\\\backslash"'); + }); + + it("sorts keys deterministically and emits numeric leafs without quotes", () => { + const out = encodeEnv({ z: 1, a: 2, m: 3 }); + expect(out.split("\n")).toEqual(["A=2", "M=3", "Z=1"]); + }); + + it("omits empty nested maps entirely (Go viper parity)", () => { + // Go output for `{physical_backup_data: {}}` is empty — viper.AllKeys() + // does not surface a key for a map with no children. Contrast with empty + // arrays, which Go DOES surface as `KEY=""`. + expect(encodeEnv({ physical_backup_data: {} })).toBe(""); + }); + + it("matches Go for the PITR-only response shape with empty physical_backup_data", () => { + // Verified byte-for-byte against `apps/cli-go` invoking utils.EncodeOutput("env", ...) + // with a JSON-decoded V1BackupsResponse whose physical_backup_data is `{}`. + expect( + encodeEnv({ + region: "ap-southeast-1", + walg_enabled: true, + pitr_enabled: true, + backups: [], + physical_backup_data: {}, + }), + ).toBe( + ['BACKUPS=""', 'PITR_ENABLED="true"', 'REGION="ap-southeast-1"', 'WALG_ENABLED="true"'].join( + "\n", + ), + ); + }); + + it("emits an empty-string value for an explicit null leaf", () => { + // Go: viper does surface a nil leaf as `KEY=""` (it still has a key path). + expect(encodeEnv({ physical_backup_data: { earliest_physical_backup_date_unix: null } })).toBe( + 'PHYSICAL_BACKUP_DATA_EARLIEST_PHYSICAL_BACKUP_DATE_UNIX=""', + ); + }); + + it("treats non-integer numeric strings as strings (quoted)", () => { + const out = encodeEnv({ ratio: "3.14", empty: "" }); + const lines = out.split("\n"); + expect(lines).toContain('RATIO="3.14"'); + expect(lines).toContain('EMPTY=""'); + }); + + it("handles negative integers unquoted", () => { + const out = encodeEnv({ offset: -42 }); + expect(out).toBe("OFFSET=-42"); + }); +}); diff --git a/apps/cli/src/legacy/commands/backups/backups.errors.ts b/apps/cli/src/legacy/commands/backups/backups.errors.ts new file mode 100644 index 000000000..67c2fc46f --- /dev/null +++ b/apps/cli/src/legacy/commands/backups/backups.errors.ts @@ -0,0 +1,95 @@ +import type { SupabaseApiError } from "@supabase/api/effect"; +import { Data, Effect } from "effect"; +import * as HttpClientError from "effect/unstable/http/HttpClientError"; + +export class LegacyBackupListNetworkError extends Data.TaggedError("LegacyBackupListNetworkError")<{ + readonly message: string; +}> {} + +export class LegacyBackupListUnexpectedStatusError extends Data.TaggedError( + "LegacyBackupListUnexpectedStatusError", +)<{ + readonly status: number; + readonly body: string; + readonly message: string; +}> {} + +export class LegacyBackupRestoreNetworkError extends Data.TaggedError( + "LegacyBackupRestoreNetworkError", +)<{ + readonly message: string; +}> {} + +export class LegacyBackupRestoreUnexpectedStatusError extends Data.TaggedError( + "LegacyBackupRestoreUnexpectedStatusError", +)<{ + readonly status: number; + readonly body: string; + readonly message: string; +}> {} + +// HttpClientError reasons that indicate the server returned an actual response (vs a transport +// failure). Anything in this set surfaces as an `UnexpectedStatusError`; everything else maps +// to a `NetworkError`. +const RESPONSE_ERROR_TAGS: ReadonlySet = new Set([ + "StatusCodeError", + "DecodeError", + "EmptyBodyError", +]); + +// Caps the response body that gets embedded in error structures. The Management API is +// trusted, but capping prevents oversized error envelopes from flooding `--output-format json` +// and avoids forwarding arbitrary bytes verbatim if the trust boundary ever changes. +const MAX_BODY_LEN = 1024; + +type NetworkErrorFactory = new (args: { readonly message: string }) => E; + +type StatusErrorFactory = new (args: { + readonly status: number; + readonly body: string; + readonly message: string; +}) => E; + +/** + * Build an error mapper that classifies a `SupabaseApiError` into either a typed network + * error or a typed unexpected-status error. Pulled out of the handlers so both commands + * share the dispatch logic, the body truncation, and the `RESPONSE_ERROR_TAGS` policy. + * + * `networkMessage` and `statusMessage` are templates: they build the human-readable error + * string with the same exact phrasing the handlers used before, so existing error-message + * assertions (and Go parity for status messages) continue to hold. + */ +export function mapLegacyBackupHttpError(opts: { + readonly networkError: NetworkErrorFactory; + readonly statusError: StatusErrorFactory; + readonly networkMessage: (cause: string) => string; + readonly statusMessage: (status: number, body: string) => string; +}): (cause: SupabaseApiError) => Effect.Effect { + return (cause) => + Effect.gen(function* () { + if (HttpClientError.isHttpClientError(cause)) { + if (RESPONSE_ERROR_TAGS.has(cause.reason._tag) && cause.response !== undefined) { + const status = cause.response.status; + const rawBody = yield* cause.response.text.pipe( + Effect.orElseSucceed(() => cause.reason.description ?? ""), + ); + const body = rawBody.length > MAX_BODY_LEN ? rawBody.slice(0, MAX_BODY_LEN) : rawBody; + return yield* Effect.fail( + new opts.statusError({ + status, + body, + message: opts.statusMessage(status, body), + }), + ); + } + const description = cause.reason.description ?? cause.reason._tag; + return yield* Effect.fail( + new opts.networkError({ message: opts.networkMessage(description) }), + ); + } + // SchemaError or HttpBodyError — treat as transport-level network error. + return yield* Effect.fail( + new opts.networkError({ message: opts.networkMessage(String(cause)) }), + ); + }); +} diff --git a/apps/cli/src/legacy/commands/backups/backups.format.ts b/apps/cli/src/legacy/commands/backups/backups.format.ts new file mode 100644 index 000000000..a04ce811d --- /dev/null +++ b/apps/cli/src/legacy/commands/backups/backups.format.ts @@ -0,0 +1,51 @@ +const REGION_NAMES: Readonly> = { + "ap-east-1": "East Asia (Hong Kong)", + "ap-northeast-1": "Northeast Asia (Tokyo)", + "ap-northeast-2": "Northeast Asia (Seoul)", + "ap-south-1": "South Asia (Mumbai)", + "ap-southeast-1": "Southeast Asia (Singapore)", + "ap-southeast-2": "Oceania (Sydney)", + "ca-central-1": "Canada (Central)", + "eu-central-1": "Central EU (Frankfurt)", + "eu-central-2": "Central Europe (Zurich)", + "eu-north-1": "North EU (Stockholm)", + "eu-west-1": "West EU (Ireland)", + "eu-west-2": "West Europe (London)", + "eu-west-3": "West EU (Paris)", + "sa-east-1": "South America (São Paulo)", + "us-east-1": "East US (North Virginia)", + "us-east-2": "East US (Ohio)", + "us-west-1": "West US (North California)", + "us-west-2": "West US (Oregon)", +}; + +export function formatRegion(region: string): string { + return REGION_NAMES[region] ?? region; +} + +function pad2(value: number): string { + return value.toString().padStart(2, "0"); +} + +/** + * Reproduces `utils.FormatTimestamp` from `apps/cli-go/internal/utils/render.go:17`: + * parse RFC3339; on success format as UTC "YYYY-MM-DD HH:MM:SS"; on failure + * return the input verbatim. + */ +export function formatBackupTimestamp(value: string): string { + if (value.length === 0) return value; + // Go uses time.Parse(time.RFC3339, value). Date.parse accepts a broader format + // surface, so we additionally require the year-month-day prefix to weed out + // values like "2026-02-08 16:44:07" (already-formatted) that Date.parse would + // happily accept but Go's strict RFC3339 parser would reject. + if (!/^\d{4}-\d{2}-\d{2}T/.test(value)) { + return value; + } + const parsed = Date.parse(value); + if (Number.isNaN(parsed)) return value; + const date = new Date(parsed); + return ( + `${date.getUTCFullYear()}-${pad2(date.getUTCMonth() + 1)}-${pad2(date.getUTCDate())} ` + + `${pad2(date.getUTCHours())}:${pad2(date.getUTCMinutes())}:${pad2(date.getUTCSeconds())}` + ); +} diff --git a/apps/cli/src/legacy/commands/backups/backups.format.unit.test.ts b/apps/cli/src/legacy/commands/backups/backups.format.unit.test.ts new file mode 100644 index 000000000..4d270cdc2 --- /dev/null +++ b/apps/cli/src/legacy/commands/backups/backups.format.unit.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; + +import { formatBackupTimestamp, formatRegion } from "./backups.format.ts"; + +describe("formatRegion", () => { + it.each([ + ["ap-east-1", "East Asia (Hong Kong)"], + ["ap-northeast-1", "Northeast Asia (Tokyo)"], + ["ap-northeast-2", "Northeast Asia (Seoul)"], + ["ap-south-1", "South Asia (Mumbai)"], + ["ap-southeast-1", "Southeast Asia (Singapore)"], + ["ap-southeast-2", "Oceania (Sydney)"], + ["ca-central-1", "Canada (Central)"], + ["eu-central-1", "Central EU (Frankfurt)"], + ["eu-central-2", "Central Europe (Zurich)"], + ["eu-north-1", "North EU (Stockholm)"], + ["eu-west-1", "West EU (Ireland)"], + ["eu-west-2", "West Europe (London)"], + ["eu-west-3", "West EU (Paris)"], + ["sa-east-1", "South America (São Paulo)"], + ["us-east-1", "East US (North Virginia)"], + ["us-east-2", "East US (Ohio)"], + ["us-west-1", "West US (North California)"], + ["us-west-2", "West US (Oregon)"], + ])("maps %s to %s", (input, expected) => { + expect(formatRegion(input)).toBe(expected); + }); + + it("returns the region unchanged when unknown", () => { + expect(formatRegion("xx-unknown-9")).toBe("xx-unknown-9"); + }); +}); + +describe("formatBackupTimestamp", () => { + it("formats valid RFC3339 to YYYY-MM-DD HH:MM:SS UTC", () => { + expect(formatBackupTimestamp("2026-02-08T16:44:07Z")).toBe("2026-02-08 16:44:07"); + }); + + it("handles offsets by normalizing to UTC", () => { + expect(formatBackupTimestamp("2026-02-08T18:44:07+02:00")).toBe("2026-02-08 16:44:07"); + }); + + it("falls back to the original value for already-formatted timestamps", () => { + // Go's time.Parse(time.RFC3339, ...) rejects "2026-02-08 16:44:07" (space, not T). + expect(formatBackupTimestamp("2026-02-08 16:44:07")).toBe("2026-02-08 16:44:07"); + }); + + it("falls back for malformed input", () => { + expect(formatBackupTimestamp("not-a-timestamp")).toBe("not-a-timestamp"); + }); + + it("returns empty string unchanged", () => { + expect(formatBackupTimestamp("")).toBe(""); + }); +}); diff --git a/apps/cli/src/legacy/commands/backups/backups.layers.ts b/apps/cli/src/legacy/commands/backups/backups.layers.ts new file mode 100644 index 000000000..a40d47a3b --- /dev/null +++ b/apps/cli/src/legacy/commands/backups/backups.layers.ts @@ -0,0 +1,30 @@ +import { Layer } from "effect"; +import { FetchHttpClient } from "effect/unstable/http"; + +import { legacyCredentialsLayer } from "../../auth/legacy-credentials.layer.ts"; +import { legacyPlatformApiLayer } from "../../auth/legacy-platform-api.layer.ts"; +import { legacyCliConfigLayer } from "../../config/legacy-cli-config.layer.ts"; +import { legacyProjectRefLayer } from "../../config/legacy-project-ref.layer.ts"; +import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; + +// Shared platform-API stack used by every `backups` subcommand. `legacyCliConfigLayer` +// is only provided once at this scope — Effect dedupes by layer identity, so handing it +// to dependent layers below would be redundant. +const legacyBackupsPlatformApiLayer = legacyPlatformApiLayer.pipe( + Layer.provide(legacyCredentialsLayer), + Layer.provide(legacyCliConfigLayer), + Layer.provide(FetchHttpClient.layer), +); + +/** + * Composes the runtime layer for a `supabase backups ` invocation. + * + * @param subcommand - command path segments after `supabase`, e.g. `["backups", "list"]`. + */ +export function legacyBackupsRuntimeLayer(subcommand: ReadonlyArray) { + return Layer.mergeAll( + legacyBackupsPlatformApiLayer, + legacyProjectRefLayer.pipe(Layer.provide(legacyBackupsPlatformApiLayer)), + commandRuntimeLayer([...subcommand]), + ); +} diff --git a/apps/cli/src/legacy/commands/backups/list/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/backups/list/SIDE_EFFECTS.md index 1b5c3b886..0665a9f58 100644 --- a/apps/cli/src/legacy/commands/backups/list/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/backups/list/SIDE_EFFECTS.md @@ -2,9 +2,13 @@ ## Files Read -| Path | Format | When | -| -------------------------- | ------------------------- | ---------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| Path | Format | When | +| ----------------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------- | +| `/proc/sys/kernel/osrelease` (Linux) | plain text | once on layer init — disables keyring on WSL (`WSL` / `Microsoft` substring match) | +| keyring `"Supabase CLI"` / `` | OS keychain | when `SUPABASE_ACCESS_TOKEN` unset and keyring available; account = `LegacyCliConfig.profile` | +| keyring `"Supabase CLI"` / `access-token` | OS keychain | legacy-key fallback when the profile-keyed lookup misses | +| `~/.supabase/access-token` | plain text (token string) | last-resort fallback after env + keyring miss | +| `/supabase/.temp/project-ref` | plain text | when `--project-ref` and `SUPABASE_PROJECT_ID` are both unset | ## Files Written @@ -14,44 +18,67 @@ ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ------------------------------------- | ------------ | ------------ | -------------------------------------------------------------------------------------------- | -| `GET` | `/v1/projects/{ref}/database/backups` | Bearer token | none | `{region, walg_enabled, pitr_enabled, backups: [{inserted_at, status, is_physical_backup}]}` | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ------------------------------------- | ------------ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/v1/projects/{ref}/database/backups` | Bearer token | none | `{region, walg_enabled, pitr_enabled, backups: [{inserted_at, status, is_physical_backup}], physical_backup_data: {earliest_physical_backup_date_unix?, latest_physical_backup_date_unix?}}` | +| `GET` | `/v1/projects` | Bearer token | none | `[{id, ref, name, organization_slug, region, ...}]` — TTY-prompt fallback only | ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| Variable | Purpose | Required? | +| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | +| `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` | no (defaults to `supabase`) | +| `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`) | +| ~~`SUPABASE_API_URL`~~ | **not honored** — Go parity. Use `SUPABASE_PROFILE` to override the API base URL. | — | ## Exit Codes -| Code | Condition | -| ---- | ------------------------------------------------------ | -| `0` | success — backup list printed to stdout | -| `1` | authentication error — no valid token found | -| `1` | missing `--project-ref` and no linked project | -| `1` | API error — non-2xx response from the backups endpoint | -| `1` | network / connection failure | +| Code | Condition | +| ---- | ------------------------------------------------------------------------------------------ | +| `0` | success — backup list printed to stdout | +| `1` | `LegacyPlatformAuthRequiredError` — no token in env/keyring/file | +| `1` | `LegacyInvalidAccessTokenError` — token violates `^sbp_(oauth_)?[a-f0-9]{40}$` | +| `1` | `LegacyProjectNotLinkedError` — `--project-ref` unset, env/file empty, and stdin not a TTY | +| `1` | `LegacyInvalidProjectRefError` — resolved ref violates `^[a-z]{20}$` | +| `1` | `LegacyBackupListUnexpectedStatusError` — non-2xx response from the backups endpoint | +| `1` | `LegacyBackupListNetworkError` — transport-level network failure | ## Output -### `--output-format text` (Go CLI compatible) +The legacy `--output {pretty,json,yaml,toml,env}` flag (Go-compatible) and the new global `--output-format {text,json,stream-json}` flag are both honored. `--output` wins when both are supplied. `pretty` and `text` map to the same render path. -For PITR-enabled projects, prints a table with columns: `REGION`, `WALG`, `PITR`, `EARLIEST TIMESTAMP`, `LATEST TIMESTAMP`. +### `--output pretty` (Go default) / `--output-format text` -For projects with physical backups, prints a table with columns: `REGION`, `BACKUP TYPE`, `STATUS`, `CREATED AT (UTC)`. +For PITR-only projects, prints a Glamour-styled markdown table with columns: `REGION`, `WALG`, `PITR`, `EARLIEST TIMESTAMP`, `LATEST TIMESTAMP`. For projects with logical/physical backups, prints columns: `REGION`, `BACKUP TYPE`, `STATUS`, `CREATED AT (UTC)`. The table is rendered byte-for-byte to match Go's `glamour.WithStandardStyle(styles.AsciiStyle)` output. + +### `--output json` (Go-compat) + +Indented JSON (`json.MarshalIndent(resp, "", " ")` equivalent) of the full backup response, terminated by a newline. + +### `--output yaml` + +YAML document (`yaml@2` equivalent of Go's `yaml.v3`) of the full backup response. + +### `--output toml` + +TOML document (`smol-toml` equivalent of Go's `BurntSushi/toml`) of the full backup response. JSON shape is preserved; leaf order may differ from Go. + +### `--output env` + +`KEY=VALUE` lines (one per leaf), one per line, sorted lexicographically. Keys are flattened with `.` separators then converted to SCREAMING_SNAKE_CASE; values are double-quoted with `"` and `\\` escaped. ### `--output-format json` -Single JSON object with the full backup response as returned by the Management API. +Single JSON object emitted via `Output.success` with the full backup response as the `data` field. ### `--output-format stream-json` -One `result` event on success containing the backup response object. +One `result` NDJSON event on success containing the backup response object. ## Notes -- Requires `--project-ref` or a linked project (resolved from `.supabase/config.json`). -- Phase 0 proxy: all invocations are forwarded to the bundled Go binary via `LegacyGoProxy`. +- Requires `--project-ref`, `SUPABASE_PROJECT_ID`, a populated `/supabase/.temp/project-ref` file, or a TTY for the interactive project picker. +- The interactive picker calls `GET /v1/projects` and writes `"Selected project: "` to stderr in text mode (matches Go `project_ref.go:50`). It does **not** persist the choice; only `supabase link` and `supabase bootstrap` write the temp file. +- Sends `User-Agent: SupabaseCLI/` and Bearer auth. No `X-Supabase-Command` headers — Go parity. diff --git a/apps/cli/src/legacy/commands/backups/list/list.command.ts b/apps/cli/src/legacy/commands/backups/list/list.command.ts index 99a08adff..df4690590 100644 --- a/apps/cli/src/legacy/commands/backups/list/list.command.ts +++ b/apps/cli/src/legacy/commands/backups/list/list.command.ts @@ -1,4 +1,9 @@ +import type * as CliCommand from "effect/unstable/cli/Command"; import { Command, Flag } from "effect/unstable/cli"; + +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { withCommandInstrumentation } from "../../../../shared/telemetry/command-instrumentation.ts"; +import { legacyBackupsRuntimeLayer } from "../backups.layers.ts"; import { legacyBackupsList } from "./list.handler.ts"; const config = { @@ -6,11 +11,13 @@ const config = { Flag.withDescription("Project ref of the Supabase project."), Flag.optional, ), -}; +} as const; + +export type LegacyBackupsListFlags = CliCommand.Command.Config.Infer; export const legacyBackupsListCommand = Command.make("list", config).pipe( - Command.withDescription("Lists available physical backups for the linked project."), - Command.withShortDescription("List available physical backups"), + Command.withDescription("Lists available physical backups"), + Command.withShortDescription("Lists available physical backups"), Command.withExamples([ { command: "supabase backups list", @@ -21,5 +28,8 @@ export const legacyBackupsListCommand = Command.make("list", config).pipe( description: "List backups for a specific project", }, ]), - Command.withHandler((flags) => legacyBackupsList(flags)), + Command.withHandler((flags) => + legacyBackupsList(flags).pipe(withCommandInstrumentation(), withJsonErrorHandling), + ), + Command.provide(legacyBackupsRuntimeLayer(["backups", "list"])), ); diff --git a/apps/cli/src/legacy/commands/backups/list/list.e2e.test.ts b/apps/cli/src/legacy/commands/backups/list/list.e2e.test.ts new file mode 100644 index 000000000..6f43c1d96 --- /dev/null +++ b/apps/cli/src/legacy/commands/backups/list/list.e2e.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, test } from "vitest"; +import { runSupabase } from "../../../../../tests/helpers/cli.ts"; + +const E2E_TIMEOUT_MS = 30_000; + +describe("supabase backups list (legacy)", () => { + test("exposes the --project-ref flag through --help", { timeout: E2E_TIMEOUT_MS }, async () => { + const { stdout, exitCode } = await runSupabase(["backups", "list", "--help"], { + entrypoint: "legacy", + }); + + expect(exitCode).toBe(0); + expect(stdout).toContain("--project-ref"); + }); +}); diff --git a/apps/cli/src/legacy/commands/backups/list/list.handler.ts b/apps/cli/src/legacy/commands/backups/list/list.handler.ts index 410bdae09..e41d2ba5b 100644 --- a/apps/cli/src/legacy/commands/backups/list/list.handler.ts +++ b/apps/cli/src/legacy/commands/backups/list/list.handler.ts @@ -1,15 +1,105 @@ +import type { V1ListAllBackupsOutput } from "@supabase/api/effect"; import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; -interface LegacyBackupsListFlags { - readonly projectRef: Option.Option; +import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; +import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { LegacyOutputFlag } from "../../../../shared/legacy/global-flags.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { renderGlamourTable } from "../../../output/legacy-glamour-table.ts"; +import { + LegacyBackupListNetworkError, + LegacyBackupListUnexpectedStatusError, + mapLegacyBackupHttpError, +} from "../backups.errors.ts"; +import { encodeEnv, encodeGoJson, encodeToml, encodeYaml } from "../backups.encoders.ts"; +import { formatBackupTimestamp, formatRegion } from "../backups.format.ts"; +import type { LegacyBackupsListFlags } from "./list.command.ts"; + +type BackupsResponse = typeof V1ListAllBackupsOutput.Type; + +const mapListError = mapLegacyBackupHttpError({ + networkError: LegacyBackupListNetworkError, + statusError: LegacyBackupListUnexpectedStatusError, + networkMessage: (cause) => `failed to list physical backups: ${cause}`, + statusMessage: (status, body) => `unexpected list backup status ${status}: ${body}`, +}); + +const PITR_HEADERS = ["REGION", "WALG", "PITR", "EARLIEST TIMESTAMP", "LATEST TIMESTAMP"] as const; + +const LOGICAL_HEADERS = ["REGION", "BACKUP TYPE", "STATUS", "CREATED AT (UTC)"] as const; + +function renderPitrTable(response: BackupsResponse): string { + const region = formatRegion(response.region); + const earliest = response.physical_backup_data.earliest_physical_backup_date_unix ?? 0; + const latest = response.physical_backup_data.latest_physical_backup_date_unix ?? 0; + return renderGlamourTable(PITR_HEADERS, [ + [ + region, + response.walg_enabled ? "true" : "false", + response.pitr_enabled ? "true" : "false", + String(earliest), + String(latest), + ], + ]); +} + +function renderLogicalTable(response: BackupsResponse): string { + const region = formatRegion(response.region); + const rows = response.backups.map((backup) => [ + region, + backup.is_physical_backup ? "PHYSICAL" : "LOGICAL", + backup.status, + formatBackupTimestamp(backup.inserted_at), + ]); + return renderGlamourTable(LOGICAL_HEADERS, rows); } export const legacyBackupsList = Effect.fn("legacy.backups.list")(function* ( flags: LegacyBackupsListFlags, ) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["backups", "list"]; - if (Option.isSome(flags.projectRef)) args.push("--project-ref", flags.projectRef.value); - yield* proxy.exec(args); + const output = yield* Output; + const goOutputFlag = yield* LegacyOutputFlag; + const api = yield* LegacyPlatformApi; + const resolver = yield* LegacyProjectRefResolver; + + const ref = yield* resolver.resolve(flags.projectRef); + + // The fetching spinner is only meaningful in human-facing text mode — in JSON / stream-json + // it would surface dangling `[task] start:` lines on stderr with no completion message. + const fetching = output.format === "text" ? yield* output.task("Fetching backups...") : undefined; + const response = yield* api.v1.listAllBackups({ ref }).pipe( + Effect.tapError(() => fetching?.fail() ?? Effect.void), + Effect.catch(mapListError), + ); + yield* fetching?.clear() ?? Effect.void; + + const goFmt = Option.getOrUndefined(goOutputFlag); + + if (goFmt === "json") { + yield* output.raw(encodeGoJson(response)); + return; + } + if (goFmt === "yaml") { + yield* output.raw(encodeYaml(response)); + return; + } + if (goFmt === "toml") { + yield* output.raw(encodeToml(response) + "\n"); + return; + } + if (goFmt === "env") { + yield* output.raw(encodeEnv(response) + "\n"); + return; + } + + // goFmt is undefined or "pretty" — defer to TS --output-format for JSON/stream-json, + // otherwise render the Glamour-styled table (Go --output pretty parity). + if (output.format === "json" || output.format === "stream-json") { + yield* output.success("", response); + return; + } + + const table = + response.backups.length > 0 ? renderLogicalTable(response) : renderPitrTable(response); + yield* output.raw(table); }); diff --git a/apps/cli/src/legacy/commands/backups/list/list.integration.test.ts b/apps/cli/src/legacy/commands/backups/list/list.integration.test.ts new file mode 100644 index 000000000..fd96bc21f --- /dev/null +++ b/apps/cli/src/legacy/commands/backups/list/list.integration.test.ts @@ -0,0 +1,485 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { type V1ListAllBackupsOutput, makeApiClient } from "@supabase/api/effect"; +import { describe, expect, it } from "@effect/vitest"; +import { BunServices } from "@effect/platform-bun"; +import { Effect, Exit, Layer, Option, Redacted } from "effect"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientError from "effect/unstable/http/HttpClientError"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; +import type * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import { afterEach, beforeEach } from "vitest"; + +import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; +import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; +import { legacyProjectRefLayer } from "../../../config/legacy-project-ref.layer.ts"; +import { LegacyOutputFlag } from "../../../../shared/legacy/global-flags.ts"; +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { mockOutput, mockTty } from "../../../../../tests/helpers/mocks.ts"; +import { mockProcessControl } from "../../../../../tests/helpers/mocks.ts"; +import { legacyBackupsList } from "./list.handler.ts"; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const VALID_REF = "abcdefghijklmnopqrst"; +const VALID_TOKEN = "sbp_" + "a".repeat(40); + +const PITR_RESPONSE: typeof V1ListAllBackupsOutput.Type = { + region: "ap-southeast-1", + walg_enabled: true, + pitr_enabled: true, + backups: [], + physical_backup_data: {}, +}; + +const LOGICAL_RESPONSE: typeof V1ListAllBackupsOutput.Type = { + region: "ap-southeast-1", + walg_enabled: true, + pitr_enabled: true, + backups: [ + { + id: 1, + is_physical_backup: true, + status: "COMPLETED", + inserted_at: "2026-02-08T16:44:07Z", + }, + ], + physical_backup_data: {}, +}; + +function jsonResponse(request: HttpClientRequest.HttpClientRequest, status: number, body: unknown) { + return HttpClientResponse.fromWeb( + request, + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }), + ); +} + +function httpClientLayer( + handler: ( + request: HttpClientRequest.HttpClientRequest, + ) => Effect.Effect, +) { + return Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => handler(request)), + ); +} + +function mockPlatformApi(opts: { + response?: typeof V1ListAllBackupsOutput.Type; + status?: number; + network?: "fail"; + apiUrl?: string; + userAgent?: string; +}) { + const requests: Array<{ + url: string; + method: string; + headers: Readonly>; + }> = []; + + const status = opts.status ?? 200; + const handler = ( + request: HttpClientRequest.HttpClientRequest, + ): Effect.Effect => { + requests.push({ url: request.url, method: request.method, headers: request.headers }); + if (opts.network === "fail") { + return Effect.fail( + new HttpClientError.HttpClientError({ + reason: new HttpClientError.TransportError({ + request, + description: "ECONNREFUSED", + }), + }), + ); + } + return Effect.succeed(jsonResponse(request, status, opts.response ?? PITR_RESPONSE)); + }; + + const layer = Layer.effect( + LegacyPlatformApi, + makeApiClient({ + baseUrl: opts.apiUrl ?? "https://api.supabase.com", + accessToken: VALID_TOKEN, + userAgent: opts.userAgent ?? "SupabaseCLI/0.0.0-dev", + }), + ).pipe(Layer.provide(httpClientLayer(handler))); + + return { layer, requests }; +} + +function mockCliConfig(opts: { workdir: string; apiUrl?: string; userAgent?: string }) { + return Layer.succeed(LegacyCliConfig, { + profile: "supabase", + apiUrl: opts.apiUrl ?? "https://api.supabase.com", + accessToken: Option.some(Redacted.make(VALID_TOKEN)), + projectId: Option.some(VALID_REF), + workdir: opts.workdir, + userAgent: opts.userAgent ?? "SupabaseCLI/0.0.0-dev", + }); +} + +interface SetupOpts { + format?: "text" | "json" | "stream-json"; + goOutput?: "env" | "pretty" | "json" | "toml" | "yaml"; + response?: typeof V1ListAllBackupsOutput.Type; + status?: number; + network?: "fail"; + stdinIsTty?: boolean; + apiUrl?: string; + userAgent?: string; +} + +let tempRoot: string; +let currentOut: ReturnType; + +function setup(opts: SetupOpts = {}) { + const out = mockOutput({ format: opts.format ?? "text" }); + currentOut = out; + const api = mockPlatformApi({ + response: opts.response, + status: opts.status, + network: opts.network, + apiUrl: opts.apiUrl, + userAgent: opts.userAgent, + }); + const cliConfig = mockCliConfig({ + workdir: tempRoot, + apiUrl: opts.apiUrl, + userAgent: opts.userAgent, + }); + const processCtl = mockProcessControl(); + const goOutputValue = opts.goOutput === undefined ? Option.none() : Option.some(opts.goOutput); + const layer = Layer.mergeAll( + out.layer, + api.layer, + cliConfig, + mockTty({ stdinIsTty: opts.stdinIsTty ?? false, stdoutIsTty: false }), + processCtl.layer, + legacyProjectRefLayer.pipe( + Layer.provide(api.layer), + Layer.provide(cliConfig), + Layer.provide(mockTty({ stdinIsTty: opts.stdinIsTty ?? false, stdoutIsTty: false })), + Layer.provide(out.layer), + Layer.provide(BunServices.layer), + ), + BunServices.layer, + Layer.succeed(LegacyOutputFlag, goOutputValue), + ); + return { layer, out, api, processCtl, tempRoot }; +} + +const stdoutText = () => currentOut.stdoutText; + +beforeEach(() => { + tempRoot = mkdtempSync(join(tmpdir(), "supabase-backups-list-int-")); +}); + +afterEach(() => { + rmSync(tempRoot, { recursive: true, force: true }); +}); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("legacy backups list integration", () => { + it.live("renders a PITR-only table when no physical backups exist", () => { + const { layer } = setup({ response: PITR_RESPONSE }); + return Effect.gen(function* () { + yield* legacyBackupsList({ projectRef: Option.none() }); + const out = stdoutText(); + expect(out).toContain("REGION"); + expect(out).toContain("WALG"); + expect(out).toContain("PITR"); + expect(out).toContain("EARLIEST TIMESTAMP"); + expect(out).toContain("LATEST TIMESTAMP"); + expect(out).toContain("Southeast Asia (Singapore)"); + expect(out).toContain("| true "); + }).pipe(Effect.provide(layer)); + }); + + it.live("renders a logical backups table with PHYSICAL classification", () => { + const { layer } = setup({ response: LOGICAL_RESPONSE }); + return Effect.gen(function* () { + yield* legacyBackupsList({ projectRef: Option.none() }); + const out = stdoutText(); + expect(out).toContain("BACKUP TYPE"); + expect(out).toContain("PHYSICAL"); + expect(out).toContain("COMPLETED"); + expect(out).toContain("2026-02-08 16:44:07"); + }).pipe(Effect.provide(layer)); + }); + + it.live("translates ap-southeast-1 to Southeast Asia (Singapore)", () => { + const { layer } = setup({ response: PITR_RESPONSE }); + return Effect.gen(function* () { + yield* legacyBackupsList({ projectRef: Option.none() }); + expect(stdoutText()).toContain("Southeast Asia (Singapore)"); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a JSON success event when --output-format=json", () => { + const { layer, out } = setup({ format: "json", response: PITR_RESPONSE }); + return Effect.gen(function* () { + yield* legacyBackupsList({ projectRef: Option.none() }); + const success = out.messages.find((m) => m.type === "success"); + expect(success).toBeDefined(); + expect(success?.data).toMatchObject({ region: "ap-southeast-1", walg_enabled: true }); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a result event for --output-format=stream-json", () => { + const { layer, out } = setup({ format: "stream-json", response: PITR_RESPONSE }); + return Effect.gen(function* () { + yield* legacyBackupsList({ projectRef: Option.none() }); + const success = out.messages.find((m) => m.type === "success"); + expect(success).toBeDefined(); + expect(success?.data).toMatchObject({ region: "ap-southeast-1" }); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits indented JSON to stdout for --output json (Go-compat)", () => { + const { layer } = setup({ goOutput: "json", response: PITR_RESPONSE }); + return Effect.gen(function* () { + yield* legacyBackupsList({ projectRef: Option.none() }); + // Byte-identical to Go's `encoding/json` output: alphabetical struct-field order, + // and a nil Backups slice serializes as `null` (matches + // `apps/cli-go/internal/backups/list/list_test.go` fixture). + expect(stdoutText()).toBe( + `{ + "backups": null, + "physical_backup_data": {}, + "pitr_enabled": true, + "region": "ap-southeast-1", + "walg_enabled": true +} +`, + ); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits YAML to stdout for --output yaml", () => { + const { layer } = setup({ goOutput: "yaml", response: PITR_RESPONSE }); + return Effect.gen(function* () { + yield* legacyBackupsList({ projectRef: Option.none() }); + const out = stdoutText(); + expect(out).toContain("region: ap-southeast-1"); + expect(out).toContain("walg_enabled: true"); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits TOML to stdout for --output toml", () => { + const { layer } = setup({ goOutput: "toml", response: PITR_RESPONSE }); + return Effect.gen(function* () { + yield* legacyBackupsList({ projectRef: Option.none() }); + const out = stdoutText(); + expect(out).toContain('region = "ap-southeast-1"'); + expect(out).toContain("walg_enabled = true"); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits KEY=VALUE lines for --output env", () => { + const { layer } = setup({ goOutput: "env", response: PITR_RESPONSE }); + return Effect.gen(function* () { + yield* legacyBackupsList({ projectRef: Option.none() }); + const out = stdoutText(); + expect(out).toContain('REGION="ap-southeast-1"'); + expect(out).toContain('WALG_ENABLED="true"'); + }).pipe(Effect.provide(layer)); + }); + + it.live("treats --output pretty as identical to text mode (Glamour table)", () => { + const { layer } = setup({ goOutput: "pretty", response: PITR_RESPONSE }); + return Effect.gen(function* () { + yield* legacyBackupsList({ projectRef: Option.none() }); + expect(stdoutText()).toContain("Southeast Asia (Singapore)"); + }).pipe(Effect.provide(layer)); + }); + + it.live("--output flag value wins over --output-format when both provided", () => { + const { layer } = setup({ + format: "json", + goOutput: "yaml", + response: PITR_RESPONSE, + }); + return Effect.gen(function* () { + yield* legacyBackupsList({ projectRef: Option.none() }); + const out = stdoutText(); + expect(out).toContain("region: ap-southeast-1"); + // YAML-shape rather than indented JSON. + expect(out.startsWith("{")).toBe(false); + }).pipe(Effect.provide(layer)); + }); + + it.live("passes the resolved project ref into the listAllBackups URL", () => { + const { layer, api } = setup({ response: PITR_RESPONSE }); + return Effect.gen(function* () { + yield* legacyBackupsList({ projectRef: Option.none() }); + expect(api.requests).toHaveLength(1); + expect(api.requests[0]?.url).toContain(`/v1/projects/${VALID_REF}/database/backups`); + }).pipe(Effect.provide(layer)); + }); + + it.live("uses --project-ref flag value over LegacyCliConfig.projectId env", () => { + const flagRef = "zzzzzzzzzzzzzzzzzzzz"; + const { layer, api } = setup({ response: PITR_RESPONSE }); + return Effect.gen(function* () { + yield* legacyBackupsList({ projectRef: Option.some(flagRef) }); + expect(api.requests[0]?.url).toContain(`/v1/projects/${flagRef}/`); + }).pipe(Effect.provide(layer)); + }); + + it.live("reads supabase/.temp/project-ref when env and flag are unset", () => { + const tempRoot = mkdtempSync(join(tmpdir(), "supabase-backups-list-int-fileref-")); + const fileRef = "filerefabcdefghijklm"; + mkdirSync(join(tempRoot, "supabase", ".temp"), { recursive: true }); + writeFileSync(join(tempRoot, "supabase", ".temp", "project-ref"), fileRef); + + const out = mockOutput({ format: "text" }); + const api = mockPlatformApi({ response: PITR_RESPONSE }); + const cliConfig = Layer.succeed(LegacyCliConfig, { + profile: "supabase", + apiUrl: "https://api.supabase.com", + accessToken: Option.some(Redacted.make(VALID_TOKEN)), + projectId: Option.none(), + workdir: tempRoot, + userAgent: "SupabaseCLI/0.0.0-dev", + }); + const processCtl = mockProcessControl(); + const layer = Layer.mergeAll( + out.layer, + api.layer, + cliConfig, + mockTty({ stdinIsTty: false, stdoutIsTty: false }), + processCtl.layer, + legacyProjectRefLayer.pipe( + Layer.provide(api.layer), + Layer.provide(cliConfig), + Layer.provide(mockTty({ stdinIsTty: false, stdoutIsTty: false })), + Layer.provide(out.layer), + Layer.provide(BunServices.layer), + ), + BunServices.layer, + Layer.succeed(LegacyOutputFlag, Option.none()), + ); + + return Effect.gen(function* () { + yield* legacyBackupsList({ projectRef: Option.none() }); + expect(api.requests[0]?.url).toContain(`/v1/projects/${fileRef}/`); + }).pipe( + Effect.provide(layer), + Effect.ensuring(Effect.sync(() => rmSync(tempRoot, { recursive: true, force: true }))), + ); + }); + + it.live("fails with LegacyProjectNotLinkedError when no ref source matches off-TTY", () => { + const tempRoot = mkdtempSync(join(tmpdir(), "supabase-backups-list-int-no-ref-")); + const out = mockOutput({ format: "text" }); + const api = mockPlatformApi({ response: PITR_RESPONSE }); + const cliConfig = Layer.succeed(LegacyCliConfig, { + profile: "supabase", + apiUrl: "https://api.supabase.com", + accessToken: Option.some(Redacted.make(VALID_TOKEN)), + projectId: Option.none(), + workdir: tempRoot, + userAgent: "SupabaseCLI/0.0.0-dev", + }); + const processCtl = mockProcessControl(); + const layer = Layer.mergeAll( + out.layer, + api.layer, + cliConfig, + mockTty({ stdinIsTty: false, stdoutIsTty: false }), + processCtl.layer, + legacyProjectRefLayer.pipe( + Layer.provide(api.layer), + Layer.provide(cliConfig), + Layer.provide(mockTty({ stdinIsTty: false, stdoutIsTty: false })), + Layer.provide(out.layer), + Layer.provide(BunServices.layer), + ), + BunServices.layer, + Layer.succeed(LegacyOutputFlag, Option.none()), + ); + + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacyBackupsList({ projectRef: Option.none() }).pipe(Effect.provide(layer)), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyProjectNotLinkedError"); + } + }).pipe(Effect.ensuring(Effect.sync(() => rmSync(tempRoot, { recursive: true, force: true })))); + }); + + it.live("fails with LegacyInvalidProjectRefError when the resolved ref is malformed", () => { + const { layer } = setup({ response: PITR_RESPONSE }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyBackupsList({ projectRef: Option.some("BADREF") })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyInvalidProjectRefError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with LegacyBackupListUnexpectedStatusError on HTTP 503", () => { + const { layer } = setup({ status: 503, response: PITR_RESPONSE }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyBackupsList({ projectRef: Option.none() })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const errorJson = JSON.stringify(exit.cause); + expect(errorJson).toContain("LegacyBackupListUnexpectedStatusError"); + expect(errorJson).toContain("unexpected list backup status 503"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with LegacyBackupListNetworkError on transport failure", () => { + const { layer } = setup({ network: "fail", response: PITR_RESPONSE }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyBackupsList({ projectRef: Option.none() })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const errorJson = JSON.stringify(exit.cause); + expect(errorJson).toContain("LegacyBackupListNetworkError"); + expect(errorJson).toContain("failed to list physical backups"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a fail event when withJsonErrorHandling wraps a JSON-mode error", () => { + const { layer, out } = setup({ format: "json", status: 503, response: PITR_RESPONSE }); + return Effect.gen(function* () { + yield* legacyBackupsList({ projectRef: Option.none() }).pipe(withJsonErrorHandling); + expect(out.messages.some((m) => m.type === "fail")).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live( + "sends User-Agent SupabaseCLI/ and no X-Supabase-Command headers (Go parity)", + () => { + const { layer, api } = setup({ + response: PITR_RESPONSE, + userAgent: "SupabaseCLI/1.42.0", + }); + return Effect.gen(function* () { + yield* legacyBackupsList({ projectRef: Option.none() }); + const headers = api.requests[0]?.headers; + expect(headers?.["user-agent"]).toBe("SupabaseCLI/1.42.0"); + expect(headers?.["x-supabase-command"]).toBeUndefined(); + expect(headers?.["x-supabase-command-run-id"]).toBeUndefined(); + }).pipe(Effect.provide(layer)); + }, + ); +}); diff --git a/apps/cli/src/legacy/commands/backups/restore/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/backups/restore/SIDE_EFFECTS.md index 07bc78a5d..d504cbc81 100644 --- a/apps/cli/src/legacy/commands/backups/restore/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/backups/restore/SIDE_EFFECTS.md @@ -2,9 +2,13 @@ ## Files Read -| Path | Format | When | -| -------------------------- | ------------------------- | ---------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| Path | Format | When | +| ----------------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------- | +| `/proc/sys/kernel/osrelease` (Linux) | plain text | once on layer init — disables keyring on WSL (`WSL` / `Microsoft` substring match) | +| keyring `"Supabase CLI"` / `` | OS keychain | when `SUPABASE_ACCESS_TOKEN` unset and keyring available; account = `LegacyCliConfig.profile` | +| keyring `"Supabase CLI"` / `access-token` | OS keychain | legacy-key fallback when the profile-keyed lookup misses | +| `~/.supabase/access-token` | plain text (token string) | last-resort fallback after env + keyring miss | +| `/supabase/.temp/project-ref` | plain text | when `--project-ref` and `SUPABASE_PROJECT_ID` are both unset | ## Files Written @@ -14,43 +18,57 @@ ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | -------------------------------------------------- | ------------ | ------------------------------------ | ---------------------- | -| `POST` | `/v1/projects/{ref}/database/backups/restore-pitr` | Bearer token | `{recovery_time_target_unix: int64}` | none (201 Created) | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | -------------------------------------------------- | ------------ | ------------------------------------ | ------------------------------------------------------------------------------ | +| `POST` | `/v1/projects/{ref}/database/backups/restore-pitr` | Bearer token | `{recovery_time_target_unix: int64}` | none (201 Created) | +| `GET` | `/v1/projects` | Bearer token | none | `[{id, ref, name, organization_slug, region, ...}]` — TTY-prompt fallback only | ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| Variable | Purpose | Required? | +| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | +| `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` | no (defaults to `supabase`) | +| `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`) | +| ~~`SUPABASE_API_URL`~~ | **not honored** — Go parity. Use `SUPABASE_PROFILE` to override the API base URL. | — | ## Exit Codes -| Code | Condition | -| ---- | ----------------------------------------------------------- | -| `0` | success — restore initiated | -| `1` | authentication error — no valid token found | -| `1` | missing `--project-ref` and no linked project | -| `1` | API error — non-2xx response from the restore-pitr endpoint | -| `1` | network / connection failure | +| Code | Condition | +| ---- | ------------------------------------------------------------------------------------------ | +| `0` | success — restore initiated | +| `1` | `LegacyPlatformAuthRequiredError` — no token in env/keyring/file | +| `1` | `LegacyInvalidAccessTokenError` — token violates `^sbp_(oauth_)?[a-f0-9]{40}$` | +| `1` | `LegacyProjectNotLinkedError` — `--project-ref` unset, env/file empty, and stdin not a TTY | +| `1` | `LegacyInvalidProjectRefError` — resolved ref violates `^[a-z]{20}$` | +| `1` | `LegacyBackupRestoreUnexpectedStatusError` — non-201 response from the restore endpoint | +| `1` | `LegacyBackupRestoreNetworkError` — transport-level network failure | ## Output -### `--output-format text` (Go CLI compatible) +Go's `restore` command ignores `--output` entirely (`apps/cli-go/internal/backups/restore/restore.go:22`) and always writes the success line to **stderr**. The legacy port mirrors that for every Go `--output` value. The `--output-format` (TS-only) JSON modes get a structured payload — non-breaking because Go has no JSON for restore. -Prints a confirmation message on success. No table output. +### `--output pretty|yaml|toml|env` (Go-compat) / `--output-format text` + +Writes `"Started PITR restore: \n"` to **stderr** (byte-identical to Go). + +### `--output json` (Go-compat) + +Indented JSON object on stdout: `{ "message": "Started PITR restore", "project_ref": "" }`. ### `--output-format json` -No structured JSON output (command is action-only). +Single JSON success event via `Output.success("Started PITR restore", { project_ref })`. ### `--output-format stream-json` -One `result` event on success. +One `result` NDJSON event on success with the project ref payload. ## Notes -- `--timestamp` / `-t` accepts seconds since Unix epoch (int64). Defaults to `0`. -- Requires `--project-ref` or a linked project. -- Phase 0 proxy: all invocations are forwarded to the bundled Go binary via `LegacyGoProxy`. +- `--timestamp` / `-t` accepts seconds since Unix epoch (int64). Defaults to `0`, which the API interprets as "now". +- Known Go-parity gap: the generated `V1RestorePitrBackupInput` schema enforces `recovery_time_target_unix >= 0`. Go's `int64` has no lower bound, so a negative value is rejected locally with a schema decode error instead of being forwarded to the API. Resolving this requires an upstream OpenAPI spec change. +- Requires `--project-ref`, `SUPABASE_PROJECT_ID`, a populated `/supabase/.temp/project-ref` file, or a TTY for the interactive project picker. +- The interactive picker calls `GET /v1/projects` and writes `"Selected project: "` to stderr in text mode (matches Go `project_ref.go:50`). It does **not** persist the choice; only `supabase link` and `supabase bootstrap` write the temp file. +- Sends `User-Agent: SupabaseCLI/` and Bearer auth. No `X-Supabase-Command` headers — Go parity. diff --git a/apps/cli/src/legacy/commands/backups/restore/restore.command.ts b/apps/cli/src/legacy/commands/backups/restore/restore.command.ts index 99a5cda4e..445931730 100644 --- a/apps/cli/src/legacy/commands/backups/restore/restore.command.ts +++ b/apps/cli/src/legacy/commands/backups/restore/restore.command.ts @@ -1,4 +1,9 @@ +import type * as CliCommand from "effect/unstable/cli/Command"; import { Command, Flag } from "effect/unstable/cli"; + +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { withCommandInstrumentation } from "../../../../shared/telemetry/command-instrumentation.ts"; +import { legacyBackupsRuntimeLayer } from "../backups.layers.ts"; import { legacyBackupsRestore } from "./restore.handler.ts"; const config = { @@ -11,10 +16,12 @@ const config = { Flag.withDescription("The recovery time target in seconds since epoch."), Flag.optional, ), -}; +} as const; + +export type LegacyBackupsRestoreFlags = CliCommand.Command.Config.Infer; export const legacyBackupsRestoreCommand = Command.make("restore", config).pipe( - Command.withDescription("Restore to a specific timestamp using Point-in-Time Recovery (PITR)."), + Command.withDescription("Restore to a specific timestamp using PITR"), Command.withShortDescription("Restore to a specific timestamp using PITR"), Command.withExamples([ { @@ -22,5 +29,8 @@ export const legacyBackupsRestoreCommand = Command.make("restore", config).pipe( description: "Restore to the given Unix epoch timestamp", }, ]), - Command.withHandler((flags) => legacyBackupsRestore(flags)), + Command.withHandler((flags) => + legacyBackupsRestore(flags).pipe(withCommandInstrumentation(), withJsonErrorHandling), + ), + Command.provide(legacyBackupsRuntimeLayer(["backups", "restore"])), ); diff --git a/apps/cli/src/legacy/commands/backups/restore/restore.e2e.test.ts b/apps/cli/src/legacy/commands/backups/restore/restore.e2e.test.ts new file mode 100644 index 000000000..a18ef54a0 --- /dev/null +++ b/apps/cli/src/legacy/commands/backups/restore/restore.e2e.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from "vitest"; +import { runSupabase } from "../../../../../tests/helpers/cli.ts"; + +const E2E_TIMEOUT_MS = 30_000; + +describe("supabase backups restore (legacy)", () => { + test( + "exposes the --project-ref and --timestamp flags through --help", + { timeout: E2E_TIMEOUT_MS }, + async () => { + const { stdout, exitCode } = await runSupabase(["backups", "restore", "--help"], { + entrypoint: "legacy", + }); + + expect(exitCode).toBe(0); + expect(stdout).toContain("--project-ref"); + expect(stdout).toContain("--timestamp"); + }, + ); +}); diff --git a/apps/cli/src/legacy/commands/backups/restore/restore.handler.ts b/apps/cli/src/legacy/commands/backups/restore/restore.handler.ts index 7dbf6a3e1..01f21c9dc 100644 --- a/apps/cli/src/legacy/commands/backups/restore/restore.handler.ts +++ b/apps/cli/src/legacy/commands/backups/restore/restore.handler.ts @@ -1,17 +1,60 @@ import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; -interface LegacyBackupsRestoreFlags { - readonly projectRef: Option.Option; - readonly timestamp: Option.Option; -} +import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; +import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { LegacyOutputFlag } from "../../../../shared/legacy/global-flags.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { + LegacyBackupRestoreNetworkError, + LegacyBackupRestoreUnexpectedStatusError, + mapLegacyBackupHttpError, +} from "../backups.errors.ts"; +import type { LegacyBackupsRestoreFlags } from "./restore.command.ts"; + +const mapRestoreError = mapLegacyBackupHttpError({ + networkError: LegacyBackupRestoreNetworkError, + statusError: LegacyBackupRestoreUnexpectedStatusError, + networkMessage: (cause) => `failed to restore backup: ${cause}`, + statusMessage: (status, body) => `unexpected restore backup status ${status}: ${body}`, +}); export const legacyBackupsRestore = Effect.fn("legacy.backups.restore")(function* ( flags: LegacyBackupsRestoreFlags, ) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["backups", "restore"]; - if (Option.isSome(flags.projectRef)) args.push("--project-ref", flags.projectRef.value); - if (Option.isSome(flags.timestamp)) args.push("--timestamp", String(flags.timestamp.value)); - yield* proxy.exec(args); + const output = yield* Output; + const goOutputFlag = yield* LegacyOutputFlag; + const api = yield* LegacyPlatformApi; + const resolver = yield* LegacyProjectRefResolver; + + const ref = yield* resolver.resolve(flags.projectRef); + const recoveryTimeTargetUnix = Option.getOrElse(flags.timestamp, () => 0); + + // Spinner only in human-facing text mode — see list.handler.ts. + const restoring = + output.format === "text" ? yield* output.task("Initiating PITR restore...") : undefined; + yield* api.v1.restorePitrBackup({ ref, recovery_time_target_unix: recoveryTimeTargetUnix }).pipe( + Effect.tapError(() => restoring?.fail() ?? Effect.void), + Effect.catch(mapRestoreError), + ); + yield* restoring?.clear() ?? Effect.void; + + const goFmt = Option.getOrUndefined(goOutputFlag); + + // Go ignores --output entirely (restore.go:22) and always writes the text line to stderr. + // We mirror that for every Go --output value except `json`, where we provide a TS-only + // structured payload (Go has no JSON for restore — adding one is non-breaking). + if (goFmt === "json") { + yield* output.raw( + JSON.stringify({ message: "Started PITR restore", project_ref: ref }, null, 2) + "\n", + ); + return; + } + + if (goFmt === undefined && (output.format === "json" || output.format === "stream-json")) { + yield* output.success("Started PITR restore", { project_ref: ref }); + return; + } + + // pretty/yaml/toml/env (Go-compat) + TS text mode → byte-identical text line on stderr. + yield* output.raw(`Started PITR restore: ${ref}\n`, "stderr"); }); diff --git a/apps/cli/src/legacy/commands/backups/restore/restore.integration.test.ts b/apps/cli/src/legacy/commands/backups/restore/restore.integration.test.ts new file mode 100644 index 000000000..0fa1ed6c2 --- /dev/null +++ b/apps/cli/src/legacy/commands/backups/restore/restore.integration.test.ts @@ -0,0 +1,404 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { makeApiClient } from "@supabase/api/effect"; +import { describe, expect, it } from "@effect/vitest"; +import { BunServices } from "@effect/platform-bun"; +import { Effect, Exit, Layer, Option, Redacted } from "effect"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientError from "effect/unstable/http/HttpClientError"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; +import type * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import { afterEach, beforeEach } from "vitest"; + +import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; +import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; +import { legacyProjectRefLayer } from "../../../config/legacy-project-ref.layer.ts"; +import { LegacyOutputFlag } from "../../../../shared/legacy/global-flags.ts"; +import { mockOutput, mockProcessControl, mockTty } from "../../../../../tests/helpers/mocks.ts"; +import { legacyBackupsRestore } from "./restore.handler.ts"; + +const VALID_REF = "abcdefghijklmnopqrst"; +const VALID_TOKEN = "sbp_" + "a".repeat(40); + +function httpClientLayer( + handler: ( + request: HttpClientRequest.HttpClientRequest, + ) => Effect.Effect, +) { + return Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => handler(request)), + ); +} + +function mockPlatformApi(opts: { status?: number; network?: "fail" }) { + const requests: Array<{ + url: string; + method: string; + body?: unknown; + }> = []; + const handler = (request: HttpClientRequest.HttpClientRequest) => + Effect.gen(function* () { + let body: unknown = undefined; + if (request.body._tag === "Uint8Array") { + const decoded = new TextDecoder().decode(request.body.body); + try { + body = JSON.parse(decoded); + } catch { + body = decoded; + } + } + requests.push({ url: request.url, method: request.method, body }); + if (opts.network === "fail") { + return yield* Effect.fail( + new HttpClientError.HttpClientError({ + reason: new HttpClientError.TransportError({ + request, + description: "ECONNREFUSED", + }), + }), + ); + } + return HttpClientResponse.fromWeb( + request, + new Response(null, { status: opts.status ?? 201 }), + ); + }); + + const layer = Layer.effect( + LegacyPlatformApi, + makeApiClient({ + baseUrl: "https://api.supabase.com", + accessToken: VALID_TOKEN, + userAgent: "SupabaseCLI/0.0.0-dev", + }), + ).pipe(Layer.provide(httpClientLayer(handler))); + + return { layer, requests }; +} + +function mockCliConfig(workdir: string) { + return Layer.succeed(LegacyCliConfig, { + profile: "supabase", + apiUrl: "https://api.supabase.com", + accessToken: Option.some(Redacted.make(VALID_TOKEN)), + projectId: Option.some(VALID_REF), + workdir, + userAgent: "SupabaseCLI/0.0.0-dev", + }); +} + +interface SetupOpts { + format?: "text" | "json" | "stream-json"; + goOutput?: "env" | "pretty" | "json" | "toml" | "yaml"; + status?: number; + network?: "fail"; + stdinIsTty?: boolean; +} + +let tempRoot: string; +let currentOut: ReturnType; + +function setup(opts: SetupOpts = {}) { + const out = mockOutput({ format: opts.format ?? "text" }); + currentOut = out; + const api = mockPlatformApi({ status: opts.status, network: opts.network }); + const cliConfig = mockCliConfig(tempRoot); + const processCtl = mockProcessControl(); + const goOutputValue = opts.goOutput === undefined ? Option.none() : Option.some(opts.goOutput); + const layer = Layer.mergeAll( + out.layer, + api.layer, + cliConfig, + mockTty({ stdinIsTty: opts.stdinIsTty ?? false, stdoutIsTty: false }), + processCtl.layer, + legacyProjectRefLayer.pipe( + Layer.provide(api.layer), + Layer.provide(cliConfig), + Layer.provide(mockTty({ stdinIsTty: opts.stdinIsTty ?? false, stdoutIsTty: false })), + Layer.provide(out.layer), + Layer.provide(BunServices.layer), + ), + BunServices.layer, + Layer.succeed(LegacyOutputFlag, goOutputValue), + ); + return { layer, out, api, tempRoot }; +} + +const stdoutText = () => currentOut.stdoutText; +const stderrText = () => currentOut.stderrText; + +beforeEach(() => { + tempRoot = mkdtempSync(join(tmpdir(), "supabase-backups-restore-int-")); +}); + +afterEach(() => { + rmSync(tempRoot, { recursive: true, force: true }); +}); + +describe("legacy backups restore integration", () => { + it.live("sends recovery_time_target_unix=0 when --timestamp is omitted", () => { + const { layer, api } = setup(); + return Effect.gen(function* () { + yield* legacyBackupsRestore({ + projectRef: Option.none(), + timestamp: Option.none(), + }); + expect(api.requests).toHaveLength(1); + expect(api.requests[0]?.body).toEqual({ recovery_time_target_unix: 0 }); + }).pipe(Effect.provide(layer)); + }); + + it.live("sends the supplied timestamp when --timestamp is provided", () => { + const { layer, api } = setup(); + return Effect.gen(function* () { + yield* legacyBackupsRestore({ + projectRef: Option.none(), + timestamp: Option.some(1_707_407_047), + }); + expect(api.requests[0]?.body).toEqual({ recovery_time_target_unix: 1_707_407_047 }); + }).pipe(Effect.provide(layer)); + }); + + it.live("writes 'Started PITR restore: \\n' to stderr in text mode (Go parity)", () => { + const { layer } = setup(); + return Effect.gen(function* () { + yield* legacyBackupsRestore({ + projectRef: Option.none(), + timestamp: Option.none(), + }); + expect(stderrText()).toBe(`Started PITR restore: ${VALID_REF}\n`); + expect(stdoutText()).toBe(""); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a JSON success event for --output-format=json", () => { + const { layer, out } = setup({ format: "json" }); + return Effect.gen(function* () { + yield* legacyBackupsRestore({ + projectRef: Option.none(), + timestamp: Option.none(), + }); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.message).toBe("Started PITR restore"); + expect(success?.data).toEqual({ project_ref: VALID_REF }); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a result event for --output-format=stream-json", () => { + const { layer, out } = setup({ format: "stream-json" }); + return Effect.gen(function* () { + yield* legacyBackupsRestore({ + projectRef: Option.none(), + timestamp: Option.none(), + }); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.data).toEqual({ project_ref: VALID_REF }); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits indented JSON to stdout for --output json (Go-compat)", () => { + const { layer } = setup({ goOutput: "json" }); + return Effect.gen(function* () { + yield* legacyBackupsRestore({ + projectRef: Option.none(), + timestamp: Option.none(), + }); + const out = stdoutText(); + expect(out).toContain('"message": "Started PITR restore"'); + expect(out).toContain(`"project_ref": "${VALID_REF}"`); + }).pipe(Effect.provide(layer)); + }); + + it.live( + "renders the stderr text line for --output {pretty,yaml,toml,env} (Go ignores --output)", + () => { + const { layer } = setup({ goOutput: "yaml" }); + return Effect.gen(function* () { + yield* legacyBackupsRestore({ + projectRef: Option.none(), + timestamp: Option.none(), + }); + expect(stderrText()).toBe(`Started PITR restore: ${VALID_REF}\n`); + expect(stdoutText()).toBe(""); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("uses --project-ref flag over LegacyCliConfig.projectId", () => { + const flagRef = "zzzzzzzzzzzzzzzzzzzz"; + const { layer, api } = setup(); + return Effect.gen(function* () { + yield* legacyBackupsRestore({ + projectRef: Option.some(flagRef), + timestamp: Option.none(), + }); + expect(api.requests[0]?.url).toContain(`/v1/projects/${flagRef}/`); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with LegacyBackupRestoreUnexpectedStatusError on HTTP 503", () => { + const { layer } = setup({ status: 503 }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacyBackupsRestore({ projectRef: Option.none(), timestamp: Option.none() }), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const errorJson = JSON.stringify(exit.cause); + expect(errorJson).toContain("LegacyBackupRestoreUnexpectedStatusError"); + expect(errorJson).toContain("unexpected restore backup status 503"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with LegacyBackupRestoreNetworkError on transport failure", () => { + const { layer } = setup({ network: "fail" }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacyBackupsRestore({ projectRef: Option.none(), timestamp: Option.none() }), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const errorJson = JSON.stringify(exit.cause); + expect(errorJson).toContain("LegacyBackupRestoreNetworkError"); + expect(errorJson).toContain("failed to restore backup"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with LegacyProjectNotLinkedError non-interactively when no ref source", () => { + const tempRoot = mkdtempSync(join(tmpdir(), "supabase-backups-restore-int-noref-")); + const out = mockOutput({ format: "text" }); + const api = mockPlatformApi({}); + const cliConfig = Layer.succeed(LegacyCliConfig, { + profile: "supabase", + apiUrl: "https://api.supabase.com", + accessToken: Option.some(Redacted.make(VALID_TOKEN)), + projectId: Option.none(), + workdir: tempRoot, + userAgent: "SupabaseCLI/0.0.0-dev", + }); + const processCtl = mockProcessControl(); + const layer = Layer.mergeAll( + out.layer, + api.layer, + cliConfig, + mockTty({ stdinIsTty: false, stdoutIsTty: false }), + processCtl.layer, + legacyProjectRefLayer.pipe( + Layer.provide(api.layer), + Layer.provide(cliConfig), + Layer.provide(mockTty({ stdinIsTty: false, stdoutIsTty: false })), + Layer.provide(out.layer), + Layer.provide(BunServices.layer), + ), + BunServices.layer, + Layer.succeed(LegacyOutputFlag, Option.none()), + ); + + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacyBackupsRestore({ projectRef: Option.none(), timestamp: Option.none() }).pipe( + Effect.provide(layer), + ), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyProjectNotLinkedError"); + } + }).pipe(Effect.ensuring(Effect.sync(() => rmSync(tempRoot, { recursive: true, force: true })))); + }); + + it.live("prompts via TTY when no ref source matches and stdin is a TTY", () => { + const tempRoot = mkdtempSync(join(tmpdir(), "supabase-backups-restore-int-prompt-")); + const out = mockOutput({ + format: "text", + promptSelectResponses: [VALID_REF], + }); + const handler = (request: HttpClientRequest.HttpClientRequest) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response( + JSON.stringify([ + { + id: VALID_REF, + ref: VALID_REF, + organization_id: "org_123", + organization_slug: "acme", + name: "alpha", + region: "us-east-1", + created_at: "2026-01-01T00:00:00Z", + status: "ACTIVE_HEALTHY", + database: { + host: "db.example.com", + version: "15.0", + postgres_engine: "supabase-postgres", + release_channel: "ga", + }, + }, + ]), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ), + ); + const api = Layer.effect( + LegacyPlatformApi, + makeApiClient({ + baseUrl: "https://api.supabase.com", + accessToken: VALID_TOKEN, + userAgent: "SupabaseCLI/0.0.0-dev", + }), + ).pipe(Layer.provide(httpClientLayer(handler))); + + const cliConfig = Layer.succeed(LegacyCliConfig, { + profile: "supabase", + apiUrl: "https://api.supabase.com", + accessToken: Option.some(Redacted.make(VALID_TOKEN)), + projectId: Option.none(), + workdir: tempRoot, + userAgent: "SupabaseCLI/0.0.0-dev", + }); + const processCtl = mockProcessControl(); + const layer = Layer.mergeAll( + out.layer, + api, + cliConfig, + mockTty({ stdinIsTty: true, stdoutIsTty: true }), + processCtl.layer, + legacyProjectRefLayer.pipe( + Layer.provide(api), + Layer.provide(cliConfig), + Layer.provide(mockTty({ stdinIsTty: true, stdoutIsTty: true })), + Layer.provide(out.layer), + Layer.provide(BunServices.layer), + ), + BunServices.layer, + Layer.succeed(LegacyOutputFlag, Option.none()), + ); + + return Effect.gen(function* () { + yield* legacyBackupsRestore({ projectRef: Option.none(), timestamp: Option.none() }).pipe( + Effect.provide(layer), + ); + expect(out.promptSelectCalls).toHaveLength(1); + expect(out.stderrText).toContain(`Started PITR restore: ${VALID_REF}\n`); + }).pipe(Effect.ensuring(Effect.sync(() => rmSync(tempRoot, { recursive: true, force: true })))); + }); + + it.live("accepts --timestamp short alias -t in the same way (no separate parse path)", () => { + // The flag layer is responsible for parsing -t into `timestamp`; once parsed, + // the handler does not differentiate, so we just verify the handler honors the value. + const { layer, api } = setup(); + return Effect.gen(function* () { + yield* legacyBackupsRestore({ + projectRef: Option.none(), + timestamp: Option.some(42), + }); + expect(api.requests[0]?.body).toEqual({ recovery_time_target_unix: 42 }); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/cli/src/legacy/config/legacy-cli-config.layer.ts b/apps/cli/src/legacy/config/legacy-cli-config.layer.ts new file mode 100644 index 000000000..25ba9c99f --- /dev/null +++ b/apps/cli/src/legacy/config/legacy-cli-config.layer.ts @@ -0,0 +1,102 @@ +import { Effect, FileSystem, Layer, Option, Path, Redacted } from "effect"; +import { CLI_VERSION } from "../../shared/cli/version.ts"; +import { LegacyProfileFlag, LegacyWorkdirFlag } from "../../shared/legacy/global-flags.ts"; +import { RuntimeInfo } from "../../shared/runtime/runtime-info.service.ts"; +import { LegacyCliConfig, type LegacyProfileName } from "./legacy-cli-config.service.ts"; + +const PROFILE_API_URLS: Record = { + supabase: "https://api.supabase.com", + "supabase-staging": "https://api.supabase.green", + "supabase-local": "http://localhost:8080", +}; + +const KNOWN_PROFILES: ReadonlySet = new Set(Object.keys(PROFILE_API_URLS)); + +function resolveProfileName(flagValue: string, envValue: string | undefined): LegacyProfileName { + const candidate = flagValue !== "supabase" ? flagValue : (envValue ?? "supabase"); + if (KNOWN_PROFILES.has(candidate)) { + return candidate as LegacyProfileName; + } + return "supabase"; +} + +function resolveWorkdir( + flagValue: Option.Option, + envValue: string | undefined, + cwd: string, + configTomlExists: (path: string) => Effect.Effect, + path: Path.Path, +): Effect.Effect { + return Effect.gen(function* () { + if (Option.isSome(flagValue) && flagValue.value.length > 0) { + return flagValue.value; + } + if (envValue !== undefined && envValue.length > 0) { + return envValue; + } + let current = cwd; + // Walk up until we hit a directory containing supabase/config.toml or the FS root. + while (true) { + const candidate = path.join(current, "supabase", "config.toml"); + if (yield* configTomlExists(candidate)) { + return current; + } + const parent = path.dirname(current); + if (parent === current) { + return cwd; + } + current = parent; + } + }); +} + +export const legacyCliConfigLayer = Layer.unwrap( + Effect.gen(function* () { + const profileFlag = yield* LegacyProfileFlag; + const workdirFlag = yield* LegacyWorkdirFlag; + + return Layer.effect( + LegacyCliConfig, + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const runtimeInfo = yield* RuntimeInfo; + const env = process.env; + + const profile = resolveProfileName(profileFlag, env["SUPABASE_PROFILE"]); + const apiUrl = PROFILE_API_URLS[profile]; + + const rawAccessToken = env["SUPABASE_ACCESS_TOKEN"]; + const accessToken = + rawAccessToken === undefined || rawAccessToken.length === 0 + ? Option.none>() + : Option.some(Redacted.make(rawAccessToken, { label: "SUPABASE_ACCESS_TOKEN" })); + + const rawProjectId = env["SUPABASE_PROJECT_ID"]; + const projectId = + rawProjectId === undefined || rawProjectId.length === 0 + ? Option.none() + : Option.some(rawProjectId); + + const workdir = yield* resolveWorkdir( + workdirFlag, + env["SUPABASE_WORKDIR"], + runtimeInfo.cwd, + (filePath) => fs.exists(filePath).pipe(Effect.orElseSucceed(() => false)), + path, + ); + + const userAgent = `SupabaseCLI/${CLI_VERSION}`; + + return LegacyCliConfig.of({ + profile, + apiUrl, + accessToken, + projectId, + workdir, + userAgent, + }); + }), + ); + }), +); diff --git a/apps/cli/src/legacy/config/legacy-cli-config.layer.unit.test.ts b/apps/cli/src/legacy/config/legacy-cli-config.layer.unit.test.ts new file mode 100644 index 000000000..228d98c3f --- /dev/null +++ b/apps/cli/src/legacy/config/legacy-cli-config.layer.unit.test.ts @@ -0,0 +1,163 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { describe, expect, it } from "@effect/vitest"; +import { BunServices } from "@effect/platform-bun"; +import { Effect, Layer, Option, Redacted } from "effect"; +import { afterEach, beforeEach } from "vitest"; + +import { LegacyProfileFlag, LegacyWorkdirFlag } from "../../shared/legacy/global-flags.ts"; +import { mockRuntimeInfo, processEnvLayer } from "../../../tests/helpers/mocks.ts"; +import { legacyCliConfigLayer } from "./legacy-cli-config.layer.ts"; +import { LegacyCliConfig } from "./legacy-cli-config.service.ts"; + +function makeLayer(opts: { + profileFlag?: string; + workdirFlag?: Option.Option; + env?: Record; + cwd?: string; +}) { + const profileFlag = opts.profileFlag ?? "supabase"; + const workdirFlag = opts.workdirFlag ?? Option.none(); + return legacyCliConfigLayer.pipe( + Layer.provide(Layer.succeed(LegacyProfileFlag, profileFlag)), + Layer.provide(Layer.succeed(LegacyWorkdirFlag, workdirFlag)), + Layer.provide(mockRuntimeInfo({ cwd: opts.cwd ?? "/test/cwd" })), + Layer.provide(BunServices.layer), + Layer.provide(processEnvLayer(opts.env ?? {})), + ); +} + +let tempRoot: string; + +beforeEach(() => { + tempRoot = mkdtempSync(join(tmpdir(), "supabase-legacy-cli-config-")); +}); + +afterEach(() => { + rmSync(tempRoot, { recursive: true, force: true }); +}); + +describe("legacyCliConfigLayer", () => { + it.effect("defaults to supabase profile and api.supabase.com when no flags or env", () => + Effect.gen(function* () { + const config = yield* LegacyCliConfig; + expect(config.profile).toBe("supabase"); + expect(config.apiUrl).toBe("https://api.supabase.com"); + }).pipe(Effect.provide(makeLayer({ cwd: tempRoot }))), + ); + + it.effect("uses SUPABASE_PROFILE env when the flag is left at default", () => + Effect.gen(function* () { + const config = yield* LegacyCliConfig; + expect(config.profile).toBe("supabase-staging"); + expect(config.apiUrl).toBe("https://api.supabase.green"); + }).pipe( + Effect.provide(makeLayer({ env: { SUPABASE_PROFILE: "supabase-staging" }, cwd: tempRoot })), + ), + ); + + it.effect("uses supabase-local profile and localhost API URL", () => + Effect.gen(function* () { + const config = yield* LegacyCliConfig; + expect(config.apiUrl).toBe("http://localhost:8080"); + }).pipe(Effect.provide(makeLayer({ profileFlag: "supabase-local", cwd: tempRoot }))), + ); + + it.effect("falls back to supabase profile when SUPABASE_PROFILE is unknown", () => + Effect.gen(function* () { + const config = yield* LegacyCliConfig; + expect(config.profile).toBe("supabase"); + expect(config.apiUrl).toBe("https://api.supabase.com"); + }).pipe( + Effect.provide(makeLayer({ env: { SUPABASE_PROFILE: "rogue-profile" }, cwd: tempRoot })), + ), + ); + + it.effect("ignores SUPABASE_API_URL — Go parity", () => + Effect.gen(function* () { + const config = yield* LegacyCliConfig; + expect(config.apiUrl).toBe("https://api.supabase.com"); + }).pipe( + Effect.provide( + makeLayer({ env: { SUPABASE_API_URL: "https://nope.example.com" }, cwd: tempRoot }), + ), + ), + ); + + it.effect("captures SUPABASE_ACCESS_TOKEN as a Redacted value", () => + Effect.gen(function* () { + const config = yield* LegacyCliConfig; + expect(Option.isSome(config.accessToken)).toBe(true); + if (Option.isSome(config.accessToken)) { + expect(Redacted.value(config.accessToken.value)).toBe("sbp_test"); + } + }).pipe( + Effect.provide(makeLayer({ env: { SUPABASE_ACCESS_TOKEN: "sbp_test" }, cwd: tempRoot })), + ), + ); + + it.effect("captures SUPABASE_PROJECT_ID env", () => + Effect.gen(function* () { + const config = yield* LegacyCliConfig; + expect(Option.getOrUndefined(config.projectId)).toBe("myrefabcdefghijklmno"); + }).pipe( + Effect.provide( + makeLayer({ env: { SUPABASE_PROJECT_ID: "myrefabcdefghijklmno" }, cwd: tempRoot }), + ), + ), + ); + + it.effect("prefers --workdir flag over env and walk-up", () => + Effect.gen(function* () { + const config = yield* LegacyCliConfig; + expect(config.workdir).toBe("/flag/workdir"); + }).pipe( + Effect.provide( + makeLayer({ + workdirFlag: Option.some("/flag/workdir"), + env: { SUPABASE_WORKDIR: "/env/workdir" }, + cwd: tempRoot, + }), + ), + ), + ); + + it.effect("uses SUPABASE_WORKDIR env when flag is unset", () => + Effect.gen(function* () { + const config = yield* LegacyCliConfig; + expect(config.workdir).toBe("/env/workdir"); + }).pipe( + Effect.provide(makeLayer({ env: { SUPABASE_WORKDIR: "/env/workdir" }, cwd: tempRoot })), + ), + ); + + it.effect("walks up from CWD looking for supabase/config.toml", () => { + const projectRoot = join(tempRoot, "project"); + const nested = join(projectRoot, "deep", "child"); + mkdirSync(join(projectRoot, "supabase"), { recursive: true }); + mkdirSync(nested, { recursive: true }); + writeFileSync(join(projectRoot, "supabase", "config.toml"), 'project_id = "x"\n'); + + return Effect.gen(function* () { + const config = yield* LegacyCliConfig; + expect(config.workdir).toBe(projectRoot); + }).pipe(Effect.provide(makeLayer({ cwd: nested }))); + }); + + it.effect("falls back to CWD when no supabase/config.toml found", () => + Effect.gen(function* () { + const config = yield* LegacyCliConfig; + expect(config.workdir).toBe(tempRoot); + }).pipe(Effect.provide(makeLayer({ cwd: tempRoot }))), + ); + + it.effect("populates userAgent from CLI_VERSION", () => + Effect.gen(function* () { + const config = yield* LegacyCliConfig; + // The sentinel `0.0.0-dev` value applies when SUPABASE_CLI_VERSION is unset (tests). + expect(config.userAgent).toMatch(/^SupabaseCLI\//); + }).pipe(Effect.provide(makeLayer({ cwd: tempRoot }))), + ); +}); diff --git a/apps/cli/src/legacy/config/legacy-cli-config.service.ts b/apps/cli/src/legacy/config/legacy-cli-config.service.ts new file mode 100644 index 000000000..93ba81f27 --- /dev/null +++ b/apps/cli/src/legacy/config/legacy-cli-config.service.ts @@ -0,0 +1,17 @@ +import type { Option, Redacted } from "effect"; +import { Context } from "effect"; + +export type LegacyProfileName = "supabase" | "supabase-staging" | "supabase-local"; + +interface LegacyCliConfigShape { + readonly profile: LegacyProfileName; + readonly apiUrl: string; + readonly accessToken: Option.Option>; + readonly projectId: Option.Option; + readonly workdir: string; + readonly userAgent: string; +} + +export class LegacyCliConfig extends Context.Service()( + "supabase/legacy/CliConfig", +) {} diff --git a/apps/cli/src/legacy/config/legacy-project-ref.errors.ts b/apps/cli/src/legacy/config/legacy-project-ref.errors.ts new file mode 100644 index 000000000..9b672d325 --- /dev/null +++ b/apps/cli/src/legacy/config/legacy-project-ref.errors.ts @@ -0,0 +1,10 @@ +import { Data } from "effect"; + +export class LegacyProjectNotLinkedError extends Data.TaggedError("LegacyProjectNotLinkedError")<{ + readonly message: string; +}> {} + +export class LegacyInvalidProjectRefError extends Data.TaggedError("LegacyInvalidProjectRefError")<{ + readonly ref: string; + readonly message: string; +}> {} diff --git a/apps/cli/src/legacy/config/legacy-project-ref.layer.ts b/apps/cli/src/legacy/config/legacy-project-ref.layer.ts new file mode 100644 index 000000000..f729eb13d --- /dev/null +++ b/apps/cli/src/legacy/config/legacy-project-ref.layer.ts @@ -0,0 +1,100 @@ +import { Effect, FileSystem, Layer, Option, Path } from "effect"; + +import { LegacyPlatformApi } from "../auth/legacy-platform-api.service.ts"; +import { Output } from "../../shared/output/output.service.ts"; +import { Tty } from "../../shared/runtime/tty.service.ts"; +import { LegacyCliConfig } from "./legacy-cli-config.service.ts"; +import { + LegacyInvalidProjectRefError, + LegacyProjectNotLinkedError, +} from "./legacy-project-ref.errors.ts"; +import { + INVALID_PROJECT_REF_MESSAGE, + LegacyProjectRefResolver, + PROJECT_NOT_LINKED_MESSAGE, + PROJECT_REF_PATTERN, +} from "./legacy-project-ref.service.ts"; + +function assertValid(ref: string): Effect.Effect { + if (PROJECT_REF_PATTERN.test(ref)) { + return Effect.succeed(ref); + } + return Effect.fail( + new LegacyInvalidProjectRefError({ ref, message: INVALID_PROJECT_REF_MESSAGE }), + ); +} + +export const legacyProjectRefLayer = Layer.effect( + LegacyProjectRefResolver, + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cliConfig = yield* LegacyCliConfig; + const tty = yield* Tty; + const output = yield* Output; + const api = yield* LegacyPlatformApi; + + const refPath = path.join(cliConfig.workdir, "supabase", ".temp", "project-ref"); + + const readRefFile = Effect.gen(function* () { + const exists = yield* fs.exists(refPath).pipe(Effect.orElseSucceed(() => false)); + if (!exists) return Option.none(); + const content = yield* fs.readFileString(refPath).pipe(Effect.orElseSucceed(() => "")); + const trimmed = content.trim(); + return trimmed.length === 0 ? Option.none() : Option.some(trimmed); + }); + + const promptForProjectRef = Effect.gen(function* () { + const projects = yield* api.v1.listAllProjects().pipe( + Effect.mapError( + (cause) => + new LegacyProjectNotLinkedError({ + message: `${PROJECT_NOT_LINKED_MESSAGE}\n Reason: failed to retrieve projects: ${String( + cause, + )}`, + }), + ), + ); + const options = projects.map((project) => ({ + value: project.id, + label: project.id, + hint: `name: ${project.name}, org: ${project.organization_slug}, region: ${project.region}`, + })); + const chosen = yield* output.promptSelect("Select a project:", options).pipe( + Effect.mapError( + (cause) => + new LegacyProjectNotLinkedError({ + message: `${PROJECT_NOT_LINKED_MESSAGE}\n Reason: ${cause.detail}`, + }), + ), + ); + // Go writes "Selected project: " to stderr (project_ref.go:50). In text mode + // `output.info` lands on stderr; in json/stream-json modes it is a no-op. + yield* output.info(`Selected project: ${chosen}`); + return chosen; + }); + + return LegacyProjectRefResolver.of({ + resolve: (flagValue) => + Effect.gen(function* () { + if (Option.isSome(flagValue) && flagValue.value.length > 0) { + return yield* assertValid(flagValue.value); + } + if (Option.isSome(cliConfig.projectId)) { + return yield* assertValid(cliConfig.projectId.value); + } + const fileValue = yield* readRefFile; + if (Option.isSome(fileValue)) { + return yield* assertValid(fileValue.value); + } + if (tty.stdinIsTty && output.interactive) { + const chosen = yield* promptForProjectRef; + return yield* assertValid(chosen); + } + return yield* Effect.fail( + new LegacyProjectNotLinkedError({ message: PROJECT_NOT_LINKED_MESSAGE }), + ); + }), + }); + }), +); diff --git a/apps/cli/src/legacy/config/legacy-project-ref.layer.unit.test.ts b/apps/cli/src/legacy/config/legacy-project-ref.layer.unit.test.ts new file mode 100644 index 000000000..5bbef7847 --- /dev/null +++ b/apps/cli/src/legacy/config/legacy-project-ref.layer.unit.test.ts @@ -0,0 +1,228 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import type { ApiClient } from "@supabase/api/effect"; +import { describe, expect, it } from "@effect/vitest"; +import { BunServices } from "@effect/platform-bun"; +import { Effect, Exit, Layer, Option } from "effect"; +import { afterEach, beforeEach } from "vitest"; + +import { LegacyPlatformApi } from "../auth/legacy-platform-api.service.ts"; +import { mockOutput, mockTty } from "../../../tests/helpers/mocks.ts"; +import { LegacyCliConfig } from "./legacy-cli-config.service.ts"; +import { LegacyProjectRefResolver } from "./legacy-project-ref.service.ts"; +import { legacyProjectRefLayer } from "./legacy-project-ref.layer.ts"; + +const VALID_REF = "abcdefghijklmnopqrst"; +const ANOTHER_REF = "qrstuvwxyzabcdefghij"; + +function mockCliConfig(opts: { workdir: string; projectId?: string }) { + return Layer.succeed(LegacyCliConfig, { + profile: "supabase", + apiUrl: "https://api.supabase.com", + accessToken: Option.none(), + projectId: opts.projectId === undefined ? Option.none() : Option.some(opts.projectId), + workdir: opts.workdir, + userAgent: "SupabaseCLI/0.0.0-dev", + }); +} + +function mockPlatformApi( + projects: ReadonlyArray<{ + id: string; + name: string; + organization_slug: string; + region: string; + }>, +) { + const api = { + v1: { + listAllProjects: () => Effect.succeed(projects), + }, + } as unknown as ApiClient; + return Layer.succeed(LegacyPlatformApi, api); +} + +function makeLayer(opts: { + workdir: string; + projectId?: string; + stdinIsTty?: boolean; + format?: "text" | "json" | "stream-json"; + projects?: ReadonlyArray<{ + id: string; + name: string; + organization_slug: string; + region: string; + }>; + promptSelectResponses?: ReadonlyArray; +}) { + const out = mockOutput({ + format: opts.format ?? "text", + promptSelectResponses: opts.promptSelectResponses, + }); + const layer = legacyProjectRefLayer.pipe( + Layer.provide(mockCliConfig(opts)), + Layer.provide(mockTty({ stdinIsTty: opts.stdinIsTty ?? false, stdoutIsTty: false })), + Layer.provide(out.layer), + Layer.provide(mockPlatformApi(opts.projects ?? [])), + Layer.provide(BunServices.layer), + ); + return { layer, out }; +} + +let tempRoot: string; + +beforeEach(() => { + tempRoot = mkdtempSync(join(tmpdir(), "supabase-legacy-project-ref-")); +}); + +afterEach(() => { + rmSync(tempRoot, { recursive: true, force: true }); +}); + +function writeRefFile(workdir: string, content: string) { + const tempDir = join(workdir, "supabase", ".temp"); + mkdirSync(tempDir, { recursive: true }); + writeFileSync(join(tempDir, "project-ref"), content); +} + +describe("legacyProjectRefLayer", () => { + it.effect("prefers --project-ref flag over env and file", () => { + writeRefFile(tempRoot, ANOTHER_REF); + const { layer } = makeLayer({ workdir: tempRoot, projectId: ANOTHER_REF }); + return Effect.gen(function* () { + const { resolve } = yield* LegacyProjectRefResolver; + const ref = yield* resolve(Option.some(VALID_REF)); + expect(ref).toBe(VALID_REF); + }).pipe(Effect.provide(layer)); + }); + + it.effect("uses SUPABASE_PROJECT_ID when flag is unset", () => { + writeRefFile(tempRoot, ANOTHER_REF); + const { layer } = makeLayer({ workdir: tempRoot, projectId: VALID_REF }); + return Effect.gen(function* () { + const { resolve } = yield* LegacyProjectRefResolver; + const ref = yield* resolve(Option.none()); + expect(ref).toBe(VALID_REF); + }).pipe(Effect.provide(layer)); + }); + + it.effect("reads /supabase/.temp/project-ref when env and flag are unset", () => { + writeRefFile(tempRoot, VALID_REF); + const { layer } = makeLayer({ workdir: tempRoot }); + return Effect.gen(function* () { + const { resolve } = yield* LegacyProjectRefResolver; + const ref = yield* resolve(Option.none()); + expect(ref).toBe(VALID_REF); + }).pipe(Effect.provide(layer)); + }); + + it.effect("trims whitespace from the temp/project-ref file content", () => { + writeRefFile(tempRoot, ` ${VALID_REF}\n\n`); + const { layer } = makeLayer({ workdir: tempRoot }); + return Effect.gen(function* () { + const { resolve } = yield* LegacyProjectRefResolver; + const ref = yield* resolve(Option.none()); + expect(ref).toBe(VALID_REF); + }).pipe(Effect.provide(layer)); + }); + + it.effect("prompts via Output.promptSelect when on a TTY with no other source", () => { + const projects = [ + { id: VALID_REF, name: "alpha", organization_slug: "acme", region: "us-east-1" }, + { id: ANOTHER_REF, name: "beta", organization_slug: "acme", region: "eu-west-1" }, + ]; + const { layer, out } = makeLayer({ + workdir: tempRoot, + stdinIsTty: true, + projects, + promptSelectResponses: [ANOTHER_REF], + }); + return Effect.gen(function* () { + const { resolve } = yield* LegacyProjectRefResolver; + const ref = yield* resolve(Option.none()); + expect(ref).toBe(ANOTHER_REF); + const call = out.promptSelectCalls[0]; + expect(call?.message).toBe("Select a project:"); + expect(call?.options[0]).toEqual({ + value: VALID_REF, + label: VALID_REF, + hint: "name: alpha, org: acme, region: us-east-1", + }); + // "Selected project: ..." is emitted via output.info (-> stderr in text mode). + const infos = out.messages.filter((m) => m.type === "info").map((m) => m.message); + expect(infos).toContain(`Selected project: ${ANOTHER_REF}`); + }).pipe(Effect.provide(layer)); + }); + + it.effect("does not persist the selected ref to the temp file (Go parity)", () => { + const projects = [ + { id: VALID_REF, name: "alpha", organization_slug: "acme", region: "us-east-1" }, + ]; + const refPath = join(tempRoot, "supabase", ".temp", "project-ref"); + const { layer } = makeLayer({ + workdir: tempRoot, + stdinIsTty: true, + projects, + promptSelectResponses: [VALID_REF], + }); + return Effect.gen(function* () { + const { resolve } = yield* LegacyProjectRefResolver; + yield* resolve(Option.none()); + // The resolver must not write the file — only `supabase link` does. + const exists = yield* Effect.tryPromise({ + try: () => import("node:fs").then((m) => m.existsSync(refPath)), + catch: () => false, + }); + expect(exists).toBe(false); + }).pipe(Effect.provide(layer)); + }); + + it.effect("fails with LegacyProjectNotLinkedError on non-TTY with no source", () => { + const { layer } = makeLayer({ workdir: tempRoot }); + return Effect.gen(function* () { + const { resolve } = yield* LegacyProjectRefResolver; + const exit = yield* Effect.exit(resolve(Option.none())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const errorJson = JSON.stringify(exit.cause); + expect(errorJson).toContain("LegacyProjectNotLinkedError"); + expect(errorJson).toContain("supabase link"); + } + }).pipe(Effect.provide(layer)); + }); + + it.effect("fails with LegacyInvalidProjectRefError when the resolved ref is malformed", () => { + const { layer } = makeLayer({ workdir: tempRoot, projectId: "not-a-valid-ref" }); + return Effect.gen(function* () { + const { resolve } = yield* LegacyProjectRefResolver; + const exit = yield* Effect.exit(resolve(Option.none())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const errorJson = JSON.stringify(exit.cause); + expect(errorJson).toContain("LegacyInvalidProjectRefError"); + expect(errorJson).toContain("Invalid project ref format"); + } + }).pipe(Effect.provide(layer)); + }); + + it.effect("rejects invalid ref from --project-ref flag", () => { + const { layer } = makeLayer({ workdir: tempRoot }); + return Effect.gen(function* () { + const { resolve } = yield* LegacyProjectRefResolver; + const exit = yield* Effect.exit(resolve(Option.some("BADREF"))); + expect(Exit.isFailure(exit)).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.effect("rejects invalid ref from temp/project-ref file", () => { + writeRefFile(tempRoot, "BADREF"); + const { layer } = makeLayer({ workdir: tempRoot }); + return Effect.gen(function* () { + const { resolve } = yield* LegacyProjectRefResolver; + const exit = yield* Effect.exit(resolve(Option.none())); + expect(Exit.isFailure(exit)).toBe(true); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/cli/src/legacy/config/legacy-project-ref.service.ts b/apps/cli/src/legacy/config/legacy-project-ref.service.ts new file mode 100644 index 000000000..647233038 --- /dev/null +++ b/apps/cli/src/legacy/config/legacy-project-ref.service.ts @@ -0,0 +1,25 @@ +import type { Effect, Option } from "effect"; +import { Context } from "effect"; + +import type { + LegacyInvalidProjectRefError, + LegacyProjectNotLinkedError, +} from "./legacy-project-ref.errors.ts"; + +interface LegacyProjectRefResolverShape { + readonly resolve: ( + flagValue: Option.Option, + ) => Effect.Effect; +} + +export class LegacyProjectRefResolver extends Context.Service< + LegacyProjectRefResolver, + LegacyProjectRefResolverShape +>()("supabase/legacy/ProjectRefResolver") {} + +export const PROJECT_REF_PATTERN = /^[a-z]{20}$/; + +export const PROJECT_NOT_LINKED_MESSAGE = "Cannot find project ref. Have you run `supabase link`?"; + +export const INVALID_PROJECT_REF_MESSAGE = + "Invalid project ref format. Must be like `abcdefghijklmnopqrst`."; diff --git a/apps/cli/src/legacy/output/legacy-glamour-table.ts b/apps/cli/src/legacy/output/legacy-glamour-table.ts new file mode 100644 index 000000000..7c8d6cf42 --- /dev/null +++ b/apps/cli/src/legacy/output/legacy-glamour-table.ts @@ -0,0 +1,45 @@ +/** + * renderGlamourTable - Reproduces the byte output of Go's `glamour.RenderTable` + * using `styles.AsciiStyle` for the markdown tables the Go CLI emits (see + * `apps/cli-go/internal/utils/output.go:109-122`). + * + * Output shape (each line terminated by "\n"): + * + * + * <2-space prefix> <- decorative empty line Glamour emits + * ||... + * <2-space prefix>||... + * |... + * ... + * + * + * Each cell is padded to the column width: max(len(header), max(len(row[i]))). + * The padded cell is wrapped with " ... " (one space either side), so the cell + * width in the output is colWidth + 2. The separator row uses dashes of the + * same width, joined by "|". + */ +export function renderGlamourTable( + headers: ReadonlyArray, + rows: ReadonlyArray>, +): string { + const widths = headers.map((header, columnIndex) => + Math.max(header.length, ...rows.map((row) => (row[columnIndex] ?? "").length)), + ); + + const renderRow = (cells: ReadonlyArray): string => + " " + + cells.map((cell, columnIndex) => " " + cell.padEnd(widths[columnIndex] ?? 0) + " ").join("|"); + + const separator = " " + widths.map((width) => "-".repeat(width + 2)).join("|"); + + const lines: string[] = []; + lines.push(""); + lines.push(" "); + lines.push(renderRow(headers)); + lines.push(separator); + for (const row of rows) { + lines.push(renderRow(row)); + } + lines.push(""); + return lines.join("\n") + "\n"; +} diff --git a/apps/cli/src/legacy/output/legacy-glamour-table.unit.test.ts b/apps/cli/src/legacy/output/legacy-glamour-table.unit.test.ts new file mode 100644 index 000000000..90b3701c0 --- /dev/null +++ b/apps/cli/src/legacy/output/legacy-glamour-table.unit.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; + +import { renderGlamourTable } from "./legacy-glamour-table.ts"; + +describe("renderGlamourTable", () => { + // Byte-for-byte parity with the Go fixture in + // apps/cli-go/internal/backups/list/list_test.go (TestListBackup/lists PITR backup). + it("matches the Go PITR-backup table fixture", () => { + const out = renderGlamourTable( + ["REGION", "WALG", "PITR", "EARLIEST TIMESTAMP", "LATEST TIMESTAMP"], + [["Southeast Asia (Singapore)", "true", "true", "0", "0"]], + ); + + const expected = + "\n" + + " \n" + + " REGION | WALG | PITR | EARLIEST TIMESTAMP | LATEST TIMESTAMP \n" + + " ----------------------------|------|------|--------------------|------------------\n" + + " Southeast Asia (Singapore) | true | true | 0 | 0 \n" + + "\n"; + + expect(out).toBe(expected); + }); + + // Byte-for-byte parity with the Go fixture in + // apps/cli-go/internal/backups/list/list_test.go (TestListBackup/lists WALG backup). + it("matches the Go logical-backup table fixture", () => { + const out = renderGlamourTable( + ["REGION", "BACKUP TYPE", "STATUS", "CREATED AT (UTC)"], + [["Southeast Asia (Singapore)", "PHYSICAL", "COMPLETED", "2026-02-08 16:44:07"]], + ); + + const expected = + "\n" + + " \n" + + " REGION | BACKUP TYPE | STATUS | CREATED AT (UTC) \n" + + " ----------------------------|-------------|-----------|---------------------\n" + + " Southeast Asia (Singapore) | PHYSICAL | COMPLETED | 2026-02-08 16:44:07 \n" + + "\n"; + + expect(out).toBe(expected); + }); +}); diff --git a/apps/cli/src/next/commands/platform/platform-input.unit.test.ts b/apps/cli/src/next/commands/platform/platform-input.unit.test.ts index 4d63cfd0d..4ab663afa 100644 --- a/apps/cli/src/next/commands/platform/platform-input.unit.test.ts +++ b/apps/cli/src/next/commands/platform/platform-input.unit.test.ts @@ -390,6 +390,7 @@ describe("platform input", () => { }), success: () => Effect.void, fail: () => Effect.void, + raw: () => Effect.void, }); return Effect.gen(function* () { diff --git a/apps/cli/src/next/commands/platform/platform-schema.integration.test.ts b/apps/cli/src/next/commands/platform/platform-schema.integration.test.ts index 8b43cbe20..2921958df 100644 --- a/apps/cli/src/next/commands/platform/platform-schema.integration.test.ts +++ b/apps/cli/src/next/commands/platform/platform-schema.integration.test.ts @@ -38,8 +38,7 @@ describe("api schema payload", () => { route: "/v1/projects", method: "GET", summary: "List all projects", - description: - "Returns a list of all projects you've previously created.\n\nUse `/v1/organizations/{slug}/projects` instead when possible to get more precise results and pagination support.", + description: "Returns a list of all projects you've previously created.", }); }); diff --git a/apps/cli/src/shared/output/json-error-handling.unit.test.ts b/apps/cli/src/shared/output/json-error-handling.unit.test.ts index 73ca62355..a6e7ca02e 100644 --- a/apps/cli/src/shared/output/json-error-handling.unit.test.ts +++ b/apps/cli/src/shared/output/json-error-handling.unit.test.ts @@ -75,6 +75,7 @@ function mockOutput(format: "text" | "json" | "stream-json" = "text") { promptSelect: (_message, options) => Effect.succeed(options[0]!.value), promptMultiSelect: (_message, options) => Effect.succeed(options.map((option) => option.value)), + raw: (_text: string, _stream?: "stdout" | "stderr") => Effect.void, }), get failCalls() { return failCalls; diff --git a/apps/cli/src/shared/output/output.layer.ts b/apps/cli/src/shared/output/output.layer.ts index 88a46a221..7d7488f9d 100644 --- a/apps/cli/src/shared/output/output.layer.ts +++ b/apps/cli/src/shared/output/output.layer.ts @@ -341,6 +341,14 @@ export const textOutputLayer = Layer.effect( yield* Effect.sync(() => outro(suggestion)); } }), + raw: (text: string, stream: "stdout" | "stderr" = "stdout") => + Effect.sync(() => { + if (stream === "stderr") { + process.stderr.write(text); + } else { + process.stdout.write(text); + } + }), }); }), ); @@ -408,6 +416,8 @@ export const jsonOutputLayer = Layer.effect( writeStdout(JSON.stringify({ ...data, message }) + "\n"), fail: (err: { code: string; message: string; detail?: string; suggestion?: string }) => writeStdout(JSON.stringify({ _tag: "Error", error: err }) + "\n"), + raw: (text: string, stream: "stdout" | "stderr" = "stdout") => + stream === "stderr" ? writeStderr(text) : writeStdout(text), }); }), ); @@ -420,6 +430,8 @@ export const streamJsonOutputLayer = Layer.effect( const writeStdout = (s: string) => Stream.make(s).pipe(Stream.run(stdio.stdout()), Effect.orDie); + const writeStderr = (s: string) => + Stream.make(s).pipe(Stream.run(stdio.stderr()), Effect.orDie); const emitLog = (level: "info" | "warn" | "success" | "error", message: string) => { const event: StreamEvent = { type: "log", @@ -502,6 +514,8 @@ export const streamJsonOutputLayer = Layer.effect( }; return writeStdout(JSON.stringify(event) + "\n"); }, + raw: (text: string, stream: "stdout" | "stderr" = "stdout") => + stream === "stderr" ? writeStderr(text) : writeStdout(text), }); }), ); diff --git a/apps/cli/src/shared/output/output.service.ts b/apps/cli/src/shared/output/output.service.ts index d3b5cd4eb..5f394b618 100644 --- a/apps/cli/src/shared/output/output.service.ts +++ b/apps/cli/src/shared/output/output.service.ts @@ -74,6 +74,14 @@ interface OutputShape { readonly detail?: string; readonly suggestion?: string; }) => Effect.Effect; + /** + * Writes a raw chunk to stdout or stderr without framing. + * + * Reserved for byte-exact parity output (legacy Go-format encoders, Glamour-styled tables) + * where structured framing would change the bytes on the wire. Routes through the active + * output layer so tests can capture it without monkey-patching `process.stdout` / `process.stderr`. + */ + readonly raw: (text: string, stream?: "stdout" | "stderr") => Effect.Effect; } /** diff --git a/apps/cli/tests/helpers/mocks.ts b/apps/cli/tests/helpers/mocks.ts index d5ab3253d..aeb12f008 100644 --- a/apps/cli/tests/helpers/mocks.ts +++ b/apps/cli/tests/helpers/mocks.ts @@ -229,6 +229,7 @@ export function mockOutput( const messages: OutputMessage[] = []; const progressEvents: ProgressEvent[] = []; const events: OutputEvent[] = []; + const rawChunks: Array<{ text: string; stream: "stdout" | "stderr" }> = []; const promptSelectCalls: Array<{ message: string; options: ReadonlyArray<{ @@ -378,11 +379,28 @@ export function mockOutput( }), promptMultiSelect: (_message, options) => Effect.succeed(options.map((option) => option.value)), + raw: (text: string, stream: "stdout" | "stderr" = "stdout") => + Effect.sync(() => { + rawChunks.push({ text, stream }); + }), }), messages, progressEvents, events, promptSelectCalls, + rawChunks, + get stdoutText() { + return rawChunks + .filter((c) => c.stream === "stdout") + .map((c) => c.text) + .join(""); + }, + get stderrText() { + return rawChunks + .filter((c) => c.stream === "stderr") + .map((c) => c.text) + .join(""); + }, }; } diff --git a/packages/api/src/generated/contracts.ts b/packages/api/src/generated/contracts.ts index 19cd2765f..5b2998217 100644 --- a/packages/api/src/generated/contracts.ts +++ b/packages/api/src/generated/contracts.ts @@ -22,7 +22,9 @@ export const BranchResponse = Schema.Struct({ "MIGRATIONS_FAILED", "FUNCTIONS_DEPLOYED", "FUNCTIONS_FAILED", - ]), + ]).annotate({ + description: "This field is deprecated. List action runs to get branch status instead.", + }), created_at: Schema.String.annotate({ format: "date-time" }), updated_at: Schema.String.annotate({ format: "date-time" }), review_requested_at: Schema.optionalKey(Schema.String.annotate({ format: "date-time" })), @@ -333,6 +335,7 @@ export const V1AuthorizeJitAccessOutput = Schema.Struct({ allowed_cidrs_v6: Schema.optionalKey(Schema.Array(Schema.Struct({ cidr: Schema.String }))), }), ), + branches_only: Schema.optionalKey(Schema.Boolean), }), }); export const V1AuthorizeUserInput = Schema.Struct({ @@ -506,7 +509,9 @@ export const V1CreateABranchOutput = Schema.Struct({ "MIGRATIONS_FAILED", "FUNCTIONS_DEPLOYED", "FUNCTIONS_FAILED", - ]), + ]).annotate({ + description: "This field is deprecated. List action runs to get branch status instead.", + }), created_at: Schema.String.annotate({ format: "date-time" }), updated_at: Schema.String.annotate({ format: "date-time" }), review_requested_at: Schema.optionalKey(Schema.String.annotate({ format: "date-time" })), @@ -992,6 +997,7 @@ export const V1DeleteHostnameConfigInput = Schema.Struct({ ref: Schema.String.check(Schema.isMinLength(20)) .check(Schema.isMaxLength(20)) .check(Schema.isPattern(new RegExp("^[a-z]+$"))), + remove_addon: Schema.optionalKey(Schema.Boolean), }); export const V1DeleteABranchInput = Schema.Struct({ branch_id_or_ref: Schema.Union( @@ -1269,7 +1275,9 @@ export const V1GetABranchOutput = Schema.Struct({ "MIGRATIONS_FAILED", "FUNCTIONS_DEPLOYED", "FUNCTIONS_FAILED", - ]), + ]).annotate({ + description: "This field is deprecated. List action runs to get branch status instead.", + }), created_at: Schema.String.annotate({ format: "date-time" }), updated_at: Schema.String.annotate({ format: "date-time" }), review_requested_at: Schema.optionalKey(Schema.String.annotate({ format: "date-time" })), @@ -1801,6 +1809,10 @@ export const V1GetAuthServiceConfigOutput = Schema.Struct({ mfa_phone_verify_enabled: Schema.Union([Schema.Boolean, Schema.Null]), mfa_web_authn_enroll_enabled: Schema.Union([Schema.Boolean, Schema.Null]), mfa_web_authn_verify_enabled: Schema.Union([Schema.Boolean, Schema.Null]), + passkey_enabled: Schema.Boolean, + webauthn_rp_display_name: Schema.Union([Schema.String, Schema.Null]), + webauthn_rp_id: Schema.Union([Schema.String, Schema.Null]), + webauthn_rp_origins: Schema.Union([Schema.String, Schema.Null]), mfa_phone_otp_length: Schema.Number.check(Schema.isInt()), mfa_phone_template: Schema.Union([Schema.String, Schema.Null]), mfa_phone_max_frequency: Schema.Union([Schema.Number.check(Schema.isInt()), Schema.Null]), @@ -1987,6 +1999,20 @@ export const V1GetAvailableRegionsOutput = Schema.Struct({ ), }), }); +export const V1GetBackupScheduleInput = Schema.Struct({ + ref: Schema.String.check(Schema.isMinLength(20)) + .check(Schema.isMaxLength(20)) + .check(Schema.isPattern(new RegExp("^[a-z]+$"))), +}); +export const V1GetBackupScheduleOutput = Schema.Struct({ + schedule_for: Schema.String.annotate({ + description: "Time of day to schedule daily backups, in UTC. Format: HH:MM:SS.", + }), + updated_at: Schema.String.annotate({ + description: "Timestamp of when the backup schedule was last updated.", + format: "date-time", + }), +}); export const V1GetDatabaseDiskInput = Schema.Struct({ ref: Schema.String.check(Schema.isMinLength(20)) .check(Schema.isMaxLength(20)) @@ -2113,6 +2139,7 @@ export const V1GetJitAccessOutput = Schema.Struct({ ), }), ), + branches_only: Schema.optionalKey(Schema.Boolean), }), ), }); @@ -2121,23 +2148,23 @@ export const V1GetJitAccessConfigInput = Schema.Struct({ .check(Schema.isMaxLength(20)) .check(Schema.isPattern(new RegExp("^[a-z]+$"))), }); -export const V1GetJitAccessConfigOutput = Schema.Struct({ - user_id: Schema.String.annotate({ format: "uuid" }), - user_roles: Schema.Array( +export const V1GetJitAccessConfigOutput = Schema.Union( + [ Schema.Struct({ - role: Schema.String.check(Schema.isMinLength(1)), - expires_at: Schema.optionalKey(Schema.Number.check(Schema.isFinite())), - allowed_networks: Schema.optionalKey( - Schema.Struct({ - allowed_cidrs: Schema.optionalKey(Schema.Array(Schema.Struct({ cidr: Schema.String }))), - allowed_cidrs_v6: Schema.optionalKey( - Schema.Array(Schema.Struct({ cidr: Schema.String })), - ), - }), - ), + state: Schema.Literals(["enabled", "disabled"]), + appliedSuccessfully: Schema.optionalKey(Schema.Boolean), }), - ), -}); + Schema.Struct({ + state: Schema.Literal("unavailable"), + unavailableReason: Schema.Literals([ + "manual_migration_required", + "postgres_upgrade_required", + "temporarily_unavailable", + ]), + }), + ], + { mode: "oneOf" }, +); export const V1GetLegacySigningKeyInput = Schema.Struct({ ref: Schema.String.check(Schema.isMinLength(20)) .check(Schema.isMaxLength(20)) @@ -2200,6 +2227,7 @@ export const V1GetOrganizationEntitlementsOutput = Schema.Struct({ "security.audit_logs_days", "security.questionnaire", "security.soc2_report", + "security.iso27001_certificate", "security.private_link", "security.enforce_mfa", "log.retention_days", @@ -2208,6 +2236,7 @@ export const V1GetOrganizationEntitlementsOutput = Schema.Struct({ "ipv4", "pitr.available_variants", "log_drains", + "audit_log_drains", "branching_limit", "branching_persistent", "auth.mfa_phone", @@ -2222,8 +2251,10 @@ export const V1GetOrganizationEntitlementsOutput = Schema.Struct({ "auth.advanced_auth_settings", "auth.performance_settings", "auth.password_hibp", + "auth.custom_oauth.max_providers", "backup.retention_days", "backup.restore_to_new_project", + "backup.schedule", "function.max_count", "function.size_limit_mb", "realtime.max_concurrent_users", @@ -2508,10 +2539,17 @@ export const V1GetPostgresUpgradeEligibilityOutput = Schema.Struct({ type: Schema.Literal("active_replication_slot"), slot_name: Schema.String, }), + Schema.Struct({ type: Schema.Literal("x86_architecture") }), + Schema.Struct({ type: Schema.Literal("project_hibernating") }), ], { mode: "oneOf" }, ), ), + warnings: Schema.Array( + Schema.Union([Schema.Struct({ type: Schema.Literal("pg_graphql_introspection_change") })], { + mode: "oneOf", + }), + ), }); export const V1GetPostgresUpgradeStatusInput = Schema.Struct({ ref: Schema.String.check(Schema.isMinLength(20)) @@ -3192,6 +3230,7 @@ export const V1ListAllBackupsOutput = Schema.Struct({ pitr_enabled: Schema.Boolean, backups: Schema.Array( Schema.Struct({ + id: Schema.Number.check(Schema.isInt()), is_physical_backup: Schema.Boolean, status: Schema.Literals([ "COMPLETED", @@ -3380,6 +3419,7 @@ export const V1ListJitAccessOutput = Schema.Struct({ ), }), ), + branches_only: Schema.optionalKey(Schema.Boolean), }), ), }), @@ -3750,6 +3790,12 @@ export const V1RestoreAProjectInput = Schema.Struct({ .check(Schema.isMaxLength(20)) .check(Schema.isPattern(new RegExp("^[a-z]+$"))), }); +export const V1RestorePhysicalBackupInput = Schema.Struct({ + ref: Schema.String.check(Schema.isMinLength(20)) + .check(Schema.isMaxLength(20)) + .check(Schema.isPattern(new RegExp("^[a-z]+$"))), + id: Schema.Number.check(Schema.isInt()), +}); export const V1RestorePitrBackupInput = Schema.Struct({ ref: Schema.String.check(Schema.isMinLength(20)) .check(Schema.isMaxLength(20)) @@ -3872,7 +3918,9 @@ export const V1UpdateABranchConfigOutput = Schema.Struct({ "MIGRATIONS_FAILED", "FUNCTIONS_DEPLOYED", "FUNCTIONS_FAILED", - ]), + ]).annotate({ + description: "This field is deprecated. List action runs to get branch status instead.", + }), created_at: Schema.String.annotate({ format: "date-time" }), updated_at: Schema.String.annotate({ format: "date-time" }), review_requested_at: Schema.optionalKey(Schema.String.annotate({ format: "date-time" })), @@ -4493,6 +4541,10 @@ export const V1UpdateAuthServiceConfigInput = Schema.Struct({ mfa_totp_verify_enabled: Schema.optionalKey(Schema.Union([Schema.Boolean, Schema.Null])), mfa_web_authn_enroll_enabled: Schema.optionalKey(Schema.Union([Schema.Boolean, Schema.Null])), mfa_web_authn_verify_enabled: Schema.optionalKey(Schema.Union([Schema.Boolean, Schema.Null])), + passkey_enabled: Schema.optionalKey(Schema.Boolean), + webauthn_rp_display_name: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + webauthn_rp_id: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + webauthn_rp_origins: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), mfa_phone_enroll_enabled: Schema.optionalKey(Schema.Union([Schema.Boolean, Schema.Null])), mfa_phone_verify_enabled: Schema.optionalKey(Schema.Union([Schema.Boolean, Schema.Null])), mfa_phone_max_frequency: Schema.optionalKey( @@ -4703,6 +4755,10 @@ export const V1UpdateAuthServiceConfigOutput = Schema.Struct({ mfa_phone_verify_enabled: Schema.Union([Schema.Boolean, Schema.Null]), mfa_web_authn_enroll_enabled: Schema.Union([Schema.Boolean, Schema.Null]), mfa_web_authn_verify_enabled: Schema.Union([Schema.Boolean, Schema.Null]), + passkey_enabled: Schema.Boolean, + webauthn_rp_display_name: Schema.Union([Schema.String, Schema.Null]), + webauthn_rp_id: Schema.Union([Schema.String, Schema.Null]), + webauthn_rp_origins: Schema.Union([Schema.String, Schema.Null]), mfa_phone_otp_length: Schema.Number.check(Schema.isInt()), mfa_phone_template: Schema.Union([Schema.String, Schema.Null]), mfa_phone_max_frequency: Schema.Union([Schema.Number.check(Schema.isInt()), Schema.Null]), @@ -4788,6 +4844,23 @@ export const V1UpdateAuthServiceConfigOutput = Schema.Struct({ custom_oauth_enabled: Schema.Boolean, custom_oauth_max_providers: Schema.Number.check(Schema.isInt()), }); +export const V1UpdateBackupScheduleInput = Schema.Struct({ + ref: Schema.String.check(Schema.isMinLength(20)) + .check(Schema.isMaxLength(20)) + .check(Schema.isPattern(new RegExp("^[a-z]+$"))), + schedule_for: Schema.String.annotate({ + description: "Time of day to schedule daily backups, in UTC. Format: HH:MM:SS.", + }), +}); +export const V1UpdateBackupScheduleOutput = Schema.Struct({ + schedule_for: Schema.String.annotate({ + description: "Time of day to schedule daily backups, in UTC. Format: HH:MM:SS.", + }), + updated_at: Schema.String.annotate({ + description: "Timestamp of when the backup schedule was last updated.", + format: "date-time", + }), +}); export const V1UpdateDatabasePasswordInput = Schema.Struct({ ref: Schema.String.check(Schema.isMinLength(20)) .check(Schema.isMaxLength(20)) @@ -4854,6 +4927,7 @@ export const V1UpdateJitAccessInput = Schema.Struct({ ), }), ), + branches_only: Schema.optionalKey(Schema.Boolean), }), ), }); @@ -4871,6 +4945,7 @@ export const V1UpdateJitAccessOutput = Schema.Struct({ ), }), ), + branches_only: Schema.optionalKey(Schema.Boolean), }), ), }); @@ -4878,25 +4953,25 @@ export const V1UpdateJitAccessConfigInput = Schema.Struct({ ref: Schema.String.check(Schema.isMinLength(20)) .check(Schema.isMaxLength(20)) .check(Schema.isPattern(new RegExp("^[a-z]+$"))), - state: Schema.Literals(["enabled", "disabled", "unavailable"]), + state: Schema.Literals(["enabled", "disabled"]), }); -export const V1UpdateJitAccessConfigOutput = Schema.Struct({ - user_id: Schema.String.annotate({ format: "uuid" }), - user_roles: Schema.Array( +export const V1UpdateJitAccessConfigOutput = Schema.Union( + [ Schema.Struct({ - role: Schema.String.check(Schema.isMinLength(1)), - expires_at: Schema.optionalKey(Schema.Number.check(Schema.isFinite())), - allowed_networks: Schema.optionalKey( - Schema.Struct({ - allowed_cidrs: Schema.optionalKey(Schema.Array(Schema.Struct({ cidr: Schema.String }))), - allowed_cidrs_v6: Schema.optionalKey( - Schema.Array(Schema.Struct({ cidr: Schema.String })), - ), - }), - ), + state: Schema.Literals(["enabled", "disabled"]), + appliedSuccessfully: Schema.optionalKey(Schema.Boolean), }), - ), -}); + Schema.Struct({ + state: Schema.Literal("unavailable"), + unavailableReason: Schema.Literals([ + "manual_migration_required", + "postgres_upgrade_required", + "temporarily_unavailable", + ]), + }), + ], + { mode: "oneOf" }, +); export const V1UpdateNetworkRestrictionsInput = Schema.Struct({ ref: Schema.String.check(Schema.isMinLength(20)) .check(Schema.isMaxLength(20)) @@ -5370,6 +5445,7 @@ export const V1ReadOnlyQueryOutput = Schema.Void; export const V1RemoveAReadReplicaOutput = Schema.Void; export const V1RemoveProjectAddonOutput = Schema.Void; export const V1RestoreAProjectOutput = Schema.Void; +export const V1RestorePhysicalBackupOutput = Schema.Void; export const V1RestorePitrBackupOutput = Schema.Void; export const V1RevokeTokenOutput = Schema.Void; export const V1RollbackMigrationsOutput = Schema.Void; @@ -5439,6 +5515,7 @@ export const openApiOperationIdMap = { "v1-get-an-organization": "v1GetAnOrganization", "v1-get-auth-service-config": "v1GetAuthServiceConfig", "v1-get-available-regions": "v1GetAvailableRegions", + "v1-get-backup-schedule": "v1GetBackupSchedule", "v1-get-database-disk": "v1GetDatabaseDisk", "v1-get-database-metadata": "v1GetDatabaseMetadata", "v1-get-database-openapi": "v1GetDatabaseOpenapi", @@ -5512,6 +5589,7 @@ export const openApiOperationIdMap = { "v1-reset-a-branch": "v1ResetABranch", "v1-restore-a-branch": "v1RestoreABranch", "v1-restore-a-project": "v1RestoreAProject", + "v1-restore-physical-backup": "v1RestorePhysicalBackup", "v1-restore-pitr-backup": "v1RestorePitrBackup", "v1-revoke-token": "v1RevokeToken", "v1-rollback-migrations": "v1RollbackMigrations", @@ -5525,6 +5603,7 @@ export const openApiOperationIdMap = { "v1-update-a-sso-provider": "v1UpdateASsoProvider", "v1-update-action-run-status": "v1UpdateActionRunStatus", "v1-update-auth-service-config": "v1UpdateAuthServiceConfig", + "v1-update-backup-schedule": "v1UpdateBackupSchedule", "v1-update-database-password": "v1UpdateDatabasePassword", "v1-update-hostname-config": "v1UpdateHostnameConfig", "v1-update-jit-access": "v1UpdateJitAccess", @@ -5975,7 +6054,7 @@ export const operationDefinitions = { method: "DELETE", path: "/v1/projects/{ref}/custom-hostname", pathParams: ["ref"], - queryParams: [], + queryParams: ["remove_addon"], headerParams: [], requestBody: { kind: "none" }, response: { kind: "void" }, @@ -6378,6 +6457,19 @@ export const operationDefinitions = { inputSchema: V1GetAvailableRegionsInput, outputSchema: V1GetAvailableRegionsOutput, }, + v1GetBackupSchedule: { + id: "v1GetBackupSchedule", + description: "Gets the backup schedule for a project", + method: "GET", + path: "/v1/projects/{ref}/database/backups/schedule", + pathParams: ["ref"], + queryParams: [], + headerParams: [], + requestBody: { kind: "none" }, + response: { kind: "json" }, + inputSchema: V1GetBackupScheduleInput, + outputSchema: V1GetBackupScheduleOutput, + }, v1GetDatabaseDisk: { id: "v1GetDatabaseDisk", description: "Get database disk attributes", @@ -6460,7 +6552,7 @@ export const operationDefinitions = { }, v1GetJitAccessConfig: { id: "v1GetJitAccessConfig", - description: "[Beta] Get project's just-in-time access configuration.", + description: "[Beta] Get project's temporary access configuration.", method: "GET", path: "/v1/projects/{ref}/jit-access", pathParams: ["ref"], @@ -7026,8 +7118,7 @@ export const operationDefinitions = { }, v1ListAllProjects: { id: "v1ListAllProjects", - description: - "Returns a list of all projects you've previously created.\n\nUse `/v1/organizations/{slug}/projects` instead when possible to get more precise results and pagination support.", + description: "Returns a list of all projects you've previously created.", method: "GET", path: "/v1/projects", pathParams: [], @@ -7350,6 +7441,19 @@ export const operationDefinitions = { inputSchema: V1RestoreAProjectInput, outputSchema: V1RestoreAProjectOutput, }, + v1RestorePhysicalBackup: { + id: "v1RestorePhysicalBackup", + description: "Restores a physical backup for a database", + method: "POST", + path: "/v1/projects/{ref}/database/backups/restore", + pathParams: ["ref"], + queryParams: [], + headerParams: [], + requestBody: { kind: "json", contentType: "application/json", fields: ["id"] }, + response: { kind: "void" }, + inputSchema: V1RestorePhysicalBackupInput, + outputSchema: V1RestorePhysicalBackupOutput, + }, v1RestorePitrBackup: { id: "v1RestorePitrBackup", description: "Restores a PITR backup for a database", @@ -7777,6 +7881,10 @@ export const operationDefinitions = { "mfa_totp_verify_enabled", "mfa_web_authn_enroll_enabled", "mfa_web_authn_verify_enabled", + "passkey_enabled", + "webauthn_rp_display_name", + "webauthn_rp_id", + "webauthn_rp_origins", "mfa_phone_enroll_enabled", "mfa_phone_verify_enabled", "mfa_phone_max_frequency", @@ -7794,6 +7902,20 @@ export const operationDefinitions = { inputSchema: V1UpdateAuthServiceConfigInput, outputSchema: V1UpdateAuthServiceConfigOutput, }, + v1UpdateBackupSchedule: { + id: "v1UpdateBackupSchedule", + description: + "Sets the time at which the daily backup runs. The change takes effect on the next backup window that includes the new time. If the new time has already passed for today, the first backup at the new time will occur the following day. It can only be updated 3 times per 24 hours.", + method: "PATCH", + path: "/v1/projects/{ref}/database/backups/schedule", + pathParams: ["ref"], + queryParams: [], + headerParams: [], + requestBody: { kind: "json", contentType: "application/json", fields: ["schedule_for"] }, + response: { kind: "json" }, + inputSchema: V1UpdateBackupScheduleInput, + outputSchema: V1UpdateBackupScheduleOutput, + }, v1UpdateDatabasePassword: { id: "v1UpdateDatabasePassword", description: "Updates the database password", @@ -7835,7 +7957,7 @@ export const operationDefinitions = { }, v1UpdateJitAccessConfig: { id: "v1UpdateJitAccessConfig", - description: "[Beta] Update project's just-in-time access configuration.", + description: "[Beta] Update project's temporary access configuration.", method: "PUT", path: "/v1/projects/{ref}/jit-access", pathParams: ["ref"], diff --git a/packages/api/src/generated/effect-client.ts b/packages/api/src/generated/effect-client.ts index ae7e49835..4c2fe4600 100644 --- a/packages/api/src/generated/effect-client.ts +++ b/packages/api/src/generated/effect-client.ts @@ -790,6 +790,20 @@ export const versionedEffectOperations = { input, ); }), + getBackupSchedule: ( + input: typeof operationDefinitions.v1GetBackupSchedule.inputSchema.Type, + ): Effect.Effect< + typeof operationDefinitions.v1GetBackupSchedule.outputSchema.Type, + SupabaseApiError, + SupabaseApiClient + > => + Effect.gen(function* () { + const client = yield* SupabaseApiClient; + return yield* client.execute<"v1GetBackupSchedule">( + operationDefinitions.v1GetBackupSchedule, + input, + ); + }), getDatabaseDisk: ( input: typeof operationDefinitions.v1GetDatabaseDisk.inputSchema.Type, ): Effect.Effect< @@ -1788,6 +1802,20 @@ export const versionedEffectOperations = { input, ); }), + restorePhysicalBackup: ( + input: typeof operationDefinitions.v1RestorePhysicalBackup.inputSchema.Type, + ): Effect.Effect< + typeof operationDefinitions.v1RestorePhysicalBackup.outputSchema.Type, + SupabaseApiError, + SupabaseApiClient + > => + Effect.gen(function* () { + const client = yield* SupabaseApiClient; + return yield* client.execute<"v1RestorePhysicalBackup">( + operationDefinitions.v1RestorePhysicalBackup, + input, + ); + }), restorePitrBackup: ( input: typeof operationDefinitions.v1RestorePitrBackup.inputSchema.Type, ): Effect.Effect< @@ -1961,6 +1989,20 @@ export const versionedEffectOperations = { input, ); }), + updateBackupSchedule: ( + input: typeof operationDefinitions.v1UpdateBackupSchedule.inputSchema.Type, + ): Effect.Effect< + typeof operationDefinitions.v1UpdateBackupSchedule.outputSchema.Type, + SupabaseApiError, + SupabaseApiClient + > => + Effect.gen(function* () { + const client = yield* SupabaseApiClient; + return yield* client.execute<"v1UpdateBackupSchedule">( + operationDefinitions.v1UpdateBackupSchedule, + input, + ); + }), updateDatabasePassword: ( input: typeof operationDefinitions.v1UpdateDatabasePassword.inputSchema.Type, ): Effect.Effect< @@ -2453,6 +2495,10 @@ export function executeApiClientOperation( return Schema.decodeUnknownEffect(operationDefinitions.v1GetAvailableRegions.inputSchema)( input, ).pipe(Effect.flatMap((decoded) => api.v1.getAvailableRegions(decoded))); + case "v1GetBackupSchedule": + return Schema.decodeUnknownEffect(operationDefinitions.v1GetBackupSchedule.inputSchema)( + input, + ).pipe(Effect.flatMap((decoded) => api.v1.getBackupSchedule(decoded))); case "v1GetDatabaseDisk": return Schema.decodeUnknownEffect(operationDefinitions.v1GetDatabaseDisk.inputSchema)( input, @@ -2745,6 +2791,10 @@ export function executeApiClientOperation( return Schema.decodeUnknownEffect(operationDefinitions.v1RestoreAProject.inputSchema)( input, ).pipe(Effect.flatMap((decoded) => api.v1.restoreAProject(decoded))); + case "v1RestorePhysicalBackup": + return Schema.decodeUnknownEffect(operationDefinitions.v1RestorePhysicalBackup.inputSchema)( + input, + ).pipe(Effect.flatMap((decoded) => api.v1.restorePhysicalBackup(decoded))); case "v1RestorePitrBackup": return Schema.decodeUnknownEffect(operationDefinitions.v1RestorePitrBackup.inputSchema)( input, @@ -2797,6 +2847,10 @@ export function executeApiClientOperation( return Schema.decodeUnknownEffect(operationDefinitions.v1UpdateAuthServiceConfig.inputSchema)( input, ).pipe(Effect.flatMap((decoded) => api.v1.updateAuthServiceConfig(decoded))); + case "v1UpdateBackupSchedule": + return Schema.decodeUnknownEffect(operationDefinitions.v1UpdateBackupSchedule.inputSchema)( + input, + ).pipe(Effect.flatMap((decoded) => api.v1.updateBackupSchedule(decoded))); case "v1UpdateDatabasePassword": return Schema.decodeUnknownEffect(operationDefinitions.v1UpdateDatabasePassword.inputSchema)( input, diff --git a/packages/api/src/generated/openapi.json b/packages/api/src/generated/openapi.json index ef0825e9d..9ddc8fced 100644 --- a/packages/api/src/generated/openapi.json +++ b/packages/api/src/generated/openapi.json @@ -617,7 +617,7 @@ }, "/v1/projects": { "get": { - "description": "Returns a list of all projects you've previously created.\n\nUse `/v1/organizations/{slug}/projects` instead when possible to get more precise results and pagination support.", + "description": "Returns a list of all projects you've previously created.", "operationId": "v1-list-all-projects", "parameters": [], "responses": { @@ -633,6 +633,15 @@ } } } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden action" + }, + "429": { + "description": "Rate limit exceeded" } }, "security": [ @@ -677,6 +686,15 @@ } } } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden action" + }, + "429": { + "description": "Rate limit exceeded" } }, "security": [ @@ -803,6 +821,15 @@ } } }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden action" + }, + "429": { + "description": "Rate limit exceeded" + }, "500": { "description": "Unexpected error listing organizations" } @@ -850,6 +877,15 @@ } } }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden action" + }, + "429": { + "description": "Rate limit exceeded" + }, "500": { "description": "Unexpected error creating an organization" } @@ -1136,6 +1172,15 @@ "responses": { "204": { "description": "" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden action" + }, + "429": { + "description": "Rate limit exceeded" } }, "security": [ @@ -1216,6 +1261,15 @@ } } }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden action" + }, + "429": { + "description": "Rate limit exceeded" + }, "500": { "description": "Failed to list user's SQL snippets" } @@ -1266,6 +1320,15 @@ } } }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden action" + }, + "429": { + "description": "Rate limit exceeded" + }, "500": { "description": "Failed to retrieve SQL snippet" } @@ -2261,15 +2324,6 @@ } } }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Forbidden action" - }, - "429": { - "description": "Rate limit exceeded" - }, "500": { "description": "Failed to retrieve database branches" } @@ -2335,15 +2389,6 @@ } } }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Forbidden action" - }, - "429": { - "description": "Rate limit exceeded" - }, "500": { "description": "Failed to create database branch" } @@ -2464,15 +2509,6 @@ } } }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Forbidden action" - }, - "429": { - "description": "Rate limit exceeded" - }, "500": { "description": "Failed to fetch database branch" } @@ -2576,6 +2612,16 @@ "example": "abcdefghijklmnopqrst", "type": "string" } + }, + { + "name": "remove_addon", + "required": false, + "in": "query", + "description": "If true, also removes the custom domain add-on from the project subscription.", + "schema": { + "default": "false", + "type": "boolean" + } } ], "responses": { @@ -2835,7 +2881,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/JitAccessResponse" + "$ref": "#/components/schemas/JitStateResponse" } } } @@ -2850,7 +2896,7 @@ "description": "Rate limit exceeded" }, "500": { - "description": "Failed to retrieve project's JIT access config" + "description": "Failed to retrieve project's temporary access configuration." } }, "security": [ @@ -2861,7 +2907,7 @@ "fga_permissions": ["project_admin_read"] } ], - "summary": "[Beta] Get project's just-in-time access configuration.", + "summary": "[Beta] Get project's temporary access configuration.", "tags": ["Database"], "x-badges": [ { @@ -2905,7 +2951,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/JitAccessResponse" + "$ref": "#/components/schemas/JitStateResponse" } } } @@ -2920,7 +2966,7 @@ "description": "Rate limit exceeded" }, "500": { - "description": "Failed to update project's just-in-time access configuration." + "description": "Failed to update project's temporary access configuration." } }, "security": [ @@ -2931,7 +2977,7 @@ "fga_permissions": ["project_admin_write"] } ], - "summary": "[Beta] Update project's just-in-time access configuration.", + "summary": "[Beta] Update project's temporary access configuration.", "tags": ["Database"], "x-badges": [ { @@ -10316,9 +10362,9 @@ "x-oauth-scope": "database:read" } }, - "/v1/projects/{ref}/database/backups/undo": { + "/v1/projects/{ref}/database/backups/restore": { "post": { - "operationId": "v1-undo", + "operationId": "v1-restore-physical-backup", "parameters": [ { "name": "ref", @@ -10339,7 +10385,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V1UndoBody" + "$ref": "#/components/schemas/V1RestoreBackupBody" } } } @@ -10366,7 +10412,7 @@ "fga_permissions": ["backups_write"] } ], - "summary": "Initiates an undo to a given restore point", + "summary": "Restores a physical backup for a database", "tags": ["Database"], "x-badges": [ { @@ -10379,19 +10425,20 @@ "x-oauth-scope": "database:write" } }, - "/v1/organizations/{slug}/entitlements": { + "/v1/projects/{ref}/database/backups/schedule": { "get": { - "description": "Returns the entitlements available to the organization based on their plan and any overrides.", - "operationId": "v1-get-organization-entitlements", + "operationId": "v1-get-backup-schedule", "parameters": [ { - "name": "slug", + "name": "ref", "required": true, "in": "path", - "description": "Organization slug", + "description": "Project ref", "schema": { - "pattern": "^[\\w-]+$", - "example": "tsrqponmlkjihgfedcba", + "minLength": 20, + "maxLength": 20, + "pattern": "^[a-z]+$", + "example": "abcdefghijklmnopqrst", "type": "string" } } @@ -10402,7 +10449,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V1ListEntitlementsResponse" + "$ref": "#/components/schemas/V1BackupScheduleResponse" } } } @@ -10410,11 +10457,20 @@ "401": { "description": "Unauthorized" }, + "402": { + "description": "Feature requires a higher plan" + }, "403": { "description": "Forbidden action" }, + "404": { + "description": "Project or backup schedule not found" + }, "429": { "description": "Rate limit exceeded" + }, + "500": { + "description": "Failed to retrieve backup schedule" } }, "security": [ @@ -10422,50 +10478,79 @@ "bearer": [] }, { - "fga_permissions": ["organization_projects_read"] + "fga_permissions": ["backups_read"] } ], - "summary": "Get entitlements for an organization", - "tags": ["Organizations"], + "summary": "Gets the backup schedule for a project", + "tags": ["Database"], "x-badges": [ { - "name": "OAuth scope: organizations:read", + "name": "OAuth scope: database:read", "position": "after" } ], - "x-endpoint-owners": ["billing"], - "x-oauth-scope": "organizations:read" - } - }, - "/v1/organizations/{slug}/members": { - "get": { - "operationId": "v1-list-organization-members", + "x-endpoint-owners": ["infra"], + "x-oauth-scope": "database:read" + }, + "patch": { + "description": "Sets the time at which the daily backup runs. The change takes effect on the next backup window that includes the new time. If the new time has already passed for today, the first backup at the new time will occur the following day. It can only be updated 3 times per 24 hours.", + "operationId": "v1-update-backup-schedule", "parameters": [ { - "name": "slug", + "name": "ref", "required": true, "in": "path", - "description": "Organization slug", + "description": "Project ref", "schema": { - "pattern": "^[\\w-]+$", - "example": "tsrqponmlkjihgfedcba", + "minLength": 20, + "maxLength": 20, + "pattern": "^[a-z]+$", + "example": "abcdefghijklmnopqrst", "type": "string" } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V1UpdateBackupScheduleBody" + } + } + } + }, "responses": { "200": { "description": "", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/V1OrganizationMemberResponse" - } + "$ref": "#/components/schemas/V1BackupScheduleResponse" } } } + }, + "400": { + "description": "Invalid schedule_for format" + }, + "401": { + "description": "Unauthorized" + }, + "402": { + "description": "Feature requires a higher plan" + }, + "403": { + "description": "Forbidden action" + }, + "404": { + "description": "Project or backup schedule not found" + }, + "429": { + "description": "Rate limit exceeded" + }, + "500": { + "description": "Failed to update backup schedule" } }, "security": [ @@ -10473,47 +10558,52 @@ "bearer": [] }, { - "fga_permissions": ["members_read"] + "fga_permissions": ["backups_write"] } ], - "summary": "List members of an organization", - "tags": ["Organizations"], + "summary": "Updates the backup schedule time for a project", + "tags": ["Database"], "x-badges": [ { - "name": "OAuth scope: organizations:read", + "name": "OAuth scope: database:write", "position": "after" } ], - "x-endpoint-owners": ["management-api"], - "x-oauth-scope": "organizations:read" + "x-endpoint-owners": ["infra"], + "x-oauth-scope": "database:write" } }, - "/v1/organizations/{slug}": { - "get": { - "operationId": "v1-get-an-organization", + "/v1/projects/{ref}/database/backups/undo": { + "post": { + "operationId": "v1-undo", "parameters": [ { - "name": "slug", + "name": "ref", "required": true, "in": "path", - "description": "Organization slug", + "description": "Project ref", "schema": { - "pattern": "^[\\w-]+$", - "example": "tsrqponmlkjihgfedcba", + "minLength": 20, + "maxLength": 20, + "pattern": "^[a-z]+$", + "example": "abcdefghijklmnopqrst", "type": "string" } } ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V1OrganizationSlugResponse" - } + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V1UndoBody" } } + } + }, + "responses": { + "201": { + "description": "" }, "401": { "description": "Unauthorized" @@ -10530,24 +10620,26 @@ "bearer": [] }, { - "fga_permissions": ["organization_admin_read"] + "fga_permissions": ["backups_write"] } ], - "summary": "Gets information about the organization", - "tags": ["Organizations"], + "summary": "Initiates an undo to a given restore point", + "tags": ["Database"], "x-badges": [ { - "name": "OAuth scope: organizations:read", + "name": "OAuth scope: database:write", "position": "after" } ], - "x-endpoint-owners": ["management-api"], - "x-oauth-scope": "organizations:read" + "x-endpoint-owners": ["infra"], + "x-internal": true, + "x-oauth-scope": "database:write" } }, - "/v1/organizations/{slug}/project-claim/{token}": { + "/v1/organizations/{slug}/entitlements": { "get": { - "operationId": "v1-get-organization-project-claim", + "description": "Returns the entitlements available to the organization based on their plan and any overrides.", + "operationId": "v1-get-organization-entitlements", "parameters": [ { "name": "slug", @@ -10559,15 +10651,6 @@ "example": "tsrqponmlkjihgfedcba", "type": "string" } - }, - { - "name": "token", - "required": true, - "in": "path", - "schema": { - "example": "0123456789abcdef0123456789abcdef01234567", - "type": "string" - } } ], "responses": { @@ -10576,7 +10659,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OrganizationProjectClaimResponse" + "$ref": "#/components/schemas/V1ListEntitlementsResponse" } } } @@ -10596,7 +10679,181 @@ "bearer": [] }, { - "fga_permissions": ["organization_admin_write"] + "fga_permissions": ["organization_admin_read"] + } + ], + "summary": "Get entitlements for an organization", + "tags": ["Organizations"], + "x-badges": [ + { + "name": "OAuth scope: organizations:read", + "position": "after" + } + ], + "x-endpoint-owners": ["billing"], + "x-oauth-scope": "organizations:read" + } + }, + "/v1/organizations/{slug}/members": { + "get": { + "operationId": "v1-list-organization-members", + "parameters": [ + { + "name": "slug", + "required": true, + "in": "path", + "description": "Organization slug", + "schema": { + "pattern": "^[\\w-]+$", + "example": "tsrqponmlkjihgfedcba", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V1OrganizationMemberResponse" + } + } + } + } + } + }, + "security": [ + { + "bearer": [] + }, + { + "fga_permissions": ["members_read"] + } + ], + "summary": "List members of an organization", + "tags": ["Organizations"], + "x-badges": [ + { + "name": "OAuth scope: organizations:read", + "position": "after" + } + ], + "x-endpoint-owners": ["management-api"], + "x-oauth-scope": "organizations:read" + } + }, + "/v1/organizations/{slug}": { + "get": { + "operationId": "v1-get-an-organization", + "parameters": [ + { + "name": "slug", + "required": true, + "in": "path", + "description": "Organization slug", + "schema": { + "pattern": "^[\\w-]+$", + "example": "tsrqponmlkjihgfedcba", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V1OrganizationSlugResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden action" + }, + "429": { + "description": "Rate limit exceeded" + } + }, + "security": [ + { + "bearer": [] + }, + { + "fga_permissions": ["organization_admin_read"] + } + ], + "summary": "Gets information about the organization", + "tags": ["Organizations"], + "x-badges": [ + { + "name": "OAuth scope: organizations:read", + "position": "after" + } + ], + "x-endpoint-owners": ["management-api"], + "x-oauth-scope": "organizations:read" + } + }, + "/v1/organizations/{slug}/project-claim/{token}": { + "get": { + "operationId": "v1-get-organization-project-claim", + "parameters": [ + { + "name": "slug", + "required": true, + "in": "path", + "description": "Organization slug", + "schema": { + "pattern": "^[\\w-]+$", + "example": "tsrqponmlkjihgfedcba", + "type": "string" + } + }, + { + "name": "token", + "required": true, + "in": "path", + "schema": { + "example": "0123456789abcdef0123456789abcdef01234567", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrganizationProjectClaimResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden action" + }, + "429": { + "description": "Rate limit exceeded" + } + }, + "security": [ + { + "bearer": [] + }, + { + "fga_permissions": ["organization_admin_write"] } ], "summary": "Gets project details for the specified organization and claim token", @@ -10741,6 +10998,15 @@ } } }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden action" + }, + "429": { + "description": "Rate limit exceeded" + }, "500": { "description": "Failed to retrieve projects" } @@ -10755,7 +11021,14 @@ ], "summary": "Gets all projects for the given organization", "tags": ["Projects"], - "x-endpoint-owners": ["management-api"] + "x-badges": [ + { + "name": "OAuth scope: projects:read", + "position": "after" + } + ], + "x-endpoint-owners": ["management-api"], + "x-oauth-scope": "projects:read" } } }, @@ -10912,7 +11185,9 @@ "MIGRATIONS_FAILED", "FUNCTIONS_DEPLOYED", "FUNCTIONS_FAILED" - ] + ], + "description": "This field is deprecated. List action runs to get branch status instead.", + "deprecated": true }, "created_at": { "type": "string", @@ -11015,126 +11290,6 @@ }, "required": ["message"] }, - "V1ListProjectsPaginatedResponse": { - "type": "object", - "properties": { - "projects": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "number" - }, - "cloud_provider": { - "type": "string" - }, - "inserted_at": { - "type": "string", - "nullable": true - }, - "name": { - "type": "string" - }, - "organization_id": { - "type": "number" - }, - "organization_slug": { - "type": "string" - }, - "ref": { - "type": "string" - }, - "region": { - "type": "string" - }, - "status": { - "type": "string" - }, - "subscription_id": { - "type": "string", - "nullable": true - }, - "is_branch_enabled": { - "type": "boolean" - }, - "is_physical_backups_enabled": { - "type": "boolean", - "nullable": true - }, - "preview_branch_refs": { - "type": "array", - "items": { - "type": "string" - } - }, - "disk_volume_size_gb": { - "type": "number" - }, - "infra_compute_size": { - "type": "string", - "enum": [ - "pico", - "nano", - "micro", - "small", - "medium", - "large", - "xlarge", - "2xlarge", - "4xlarge", - "8xlarge", - "12xlarge", - "16xlarge", - "24xlarge", - "24xlarge_optimized_memory", - "24xlarge_optimized_cpu", - "24xlarge_high_memory", - "48xlarge", - "48xlarge_optimized_memory", - "48xlarge_optimized_cpu", - "48xlarge_high_memory" - ] - } - }, - "required": [ - "id", - "cloud_provider", - "inserted_at", - "name", - "organization_id", - "organization_slug", - "ref", - "region", - "status", - "subscription_id", - "is_branch_enabled", - "is_physical_backups_enabled", - "preview_branch_refs" - ] - } - }, - "pagination": { - "type": "object", - "properties": { - "count": { - "type": "number", - "description": "Total number of projects. Use this to calculate the total number of pages." - }, - "limit": { - "type": "number", - "description": "Maximum number of projects per page (actual number may be less)" - }, - "offset": { - "type": "number", - "description": "Number of projects skipped in this response" - } - }, - "required": ["count", "limit", "offset"] - } - }, - "required": ["projects", "pagination"] - }, "V1ProjectWithDatabaseResponse": { "type": "object", "properties": { @@ -12450,67 +12605,50 @@ "custom_hostname": "docs.example.com" } }, - "JitAccessResponse": { - "type": "object", - "properties": { - "user_id": { - "type": "string", - "format": "uuid" + "JitStateResponse": { + "discriminator": { + "propertyName": "state" + }, + "oneOf": [ + { + "type": "object", + "properties": { + "state": { + "type": "string", + "enum": ["enabled", "disabled"] + }, + "appliedSuccessfully": { + "type": "boolean" + } + }, + "required": ["state"] }, - "user_roles": { - "type": "array", - "items": { - "type": "object", - "properties": { - "role": { - "type": "string", - "minLength": 1 - }, - "expires_at": { - "type": "number" - }, - "allowed_networks": { - "type": "object", - "properties": { - "allowed_cidrs": { - "type": "array", - "items": { - "type": "object", - "properties": { - "cidr": { - "type": "string" - } - }, - "required": ["cidr"] - } - }, - "allowed_cidrs_v6": { - "type": "array", - "items": { - "type": "object", - "properties": { - "cidr": { - "type": "string" - } - }, - "required": ["cidr"] - } - } - } - } + { + "type": "object", + "properties": { + "state": { + "type": "string", + "enum": ["unavailable"] }, - "required": ["role"] - } + "unavailableReason": { + "type": "string", + "enum": [ + "manual_migration_required", + "postgres_upgrade_required", + "temporarily_unavailable" + ] + } + }, + "required": ["state", "unavailableReason"] } - }, - "required": ["user_id", "user_roles"] + ] }, "JitAccessRequestRequest": { "type": "object", "properties": { "state": { "type": "string", - "enum": ["enabled", "disabled", "unavailable"] + "enum": ["enabled", "disabled"] } }, "required": ["state"], @@ -13270,6 +13408,46 @@ } }, "required": ["type", "slot_name"] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["x86_architecture"] + } + }, + "required": ["type"] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["project_hibernating"] + } + }, + "required": ["type"] + } + ] + } + }, + "warnings": { + "type": "array", + "items": { + "discriminator": { + "propertyName": "type" + }, + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["pg_graphql_introspection_change"] + } + }, + "required": ["type"] } ] } @@ -13286,7 +13464,8 @@ "objects_to_be_dropped", "unsupported_extensions", "user_defined_objects_in_internal_schemas", - "validation_errors" + "validation_errors", + "warnings" ] }, "DatabaseUpgradeStatusResponse": { @@ -14462,6 +14641,21 @@ "type": "boolean", "nullable": true }, + "passkey_enabled": { + "type": "boolean" + }, + "webauthn_rp_display_name": { + "type": "string", + "nullable": true + }, + "webauthn_rp_id": { + "type": "string", + "nullable": true + }, + "webauthn_rp_origins": { + "type": "string", + "nullable": true + }, "mfa_phone_otp_length": { "type": "integer" }, @@ -14902,6 +15096,10 @@ "mfa_phone_verify_enabled", "mfa_web_authn_enroll_enabled", "mfa_web_authn_verify_enabled", + "passkey_enabled", + "webauthn_rp_display_name", + "webauthn_rp_id", + "webauthn_rp_origins", "mfa_phone_otp_length", "mfa_phone_template", "mfa_phone_max_frequency", @@ -15899,6 +16097,21 @@ "type": "boolean", "nullable": true }, + "passkey_enabled": { + "type": "boolean" + }, + "webauthn_rp_display_name": { + "type": "string", + "nullable": true + }, + "webauthn_rp_id": { + "type": "string", + "nullable": true + }, + "webauthn_rp_origins": { + "type": "string", + "nullable": true + }, "mfa_phone_enroll_enabled": { "type": "boolean", "nullable": true @@ -16907,6 +17120,64 @@ }, "required": ["message"] }, + "JitAccessResponse": { + "type": "object", + "properties": { + "user_id": { + "type": "string", + "format": "uuid" + }, + "user_roles": { + "type": "array", + "items": { + "type": "object", + "properties": { + "role": { + "type": "string", + "minLength": 1 + }, + "expires_at": { + "type": "number" + }, + "allowed_networks": { + "type": "object", + "properties": { + "allowed_cidrs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "cidr": { + "type": "string" + } + }, + "required": ["cidr"] + } + }, + "allowed_cidrs_v6": { + "type": "array", + "items": { + "type": "object", + "properties": { + "cidr": { + "type": "string" + } + }, + "required": ["cidr"] + } + } + } + }, + "branches_only": { + "type": "boolean" + } + }, + "required": ["role"] + } + } + }, + "required": ["user_id", "user_roles"] + }, "AuthorizeJitAccessBody": { "type": "object", "properties": { @@ -16970,6 +17241,9 @@ } } } + }, + "branches_only": { + "type": "boolean" } }, "required": ["role"] @@ -17029,6 +17303,9 @@ } } } + }, + "branches_only": { + "type": "boolean" } }, "required": ["role"] @@ -17089,6 +17366,9 @@ } } } + }, + "branches_only": { + "type": "boolean" } }, "required": ["role"] @@ -17108,7 +17388,8 @@ "cidr": "203.0.113.0/24" } ] - } + }, + "branches_only": false } ] } @@ -19063,6 +19344,9 @@ "items": { "type": "object", "properties": { + "id": { + "type": "integer" + }, "is_physical_backup": { "type": "boolean" }, @@ -19074,7 +19358,7 @@ "type": "string" } }, - "required": ["is_physical_backup", "status", "inserted_at"] + "required": ["id", "is_physical_backup", "status", "inserted_at"] } }, "physical_backup_data": { @@ -19136,6 +19420,49 @@ }, "required": ["name", "status", "completed_on"] }, + "V1RestoreBackupBody": { + "type": "object", + "properties": { + "id": { + "type": "integer" + } + }, + "required": ["id"], + "example": { + "id": 12345 + } + }, + "V1BackupScheduleResponse": { + "type": "object", + "properties": { + "schedule_for": { + "type": "string", + "description": "Time of day to schedule daily backups, in UTC. Format: HH:MM:SS.", + "example": "04:00:00" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "description": "Timestamp of when the backup schedule was last updated.", + "example": "2026-05-04T14:40:44+00:00" + } + }, + "required": ["schedule_for", "updated_at"] + }, + "V1UpdateBackupScheduleBody": { + "type": "object", + "properties": { + "schedule_for": { + "type": "string", + "description": "Time of day to schedule daily backups, in UTC. Format: HH:MM:SS.", + "example": "04:00:00" + } + }, + "required": ["schedule_for"], + "example": { + "schedule_for": "04:00:00" + } + }, "V1UndoBody": { "type": "object", "properties": { @@ -19177,6 +19504,7 @@ "security.audit_logs_days", "security.questionnaire", "security.soc2_report", + "security.iso27001_certificate", "security.private_link", "security.enforce_mfa", "log.retention_days", @@ -19185,6 +19513,7 @@ "ipv4", "pitr.available_variants", "log_drains", + "audit_log_drains", "branching_limit", "branching_persistent", "auth.mfa_phone", @@ -19199,8 +19528,10 @@ "auth.advanced_auth_settings", "auth.performance_settings", "auth.password_hibp", + "auth.custom_oauth.max_providers", "backup.retention_days", "backup.restore_to_new_project", + "backup.schedule", "function.max_count", "function.size_limit_mb", "realtime.max_concurrent_users", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index da3dd252e..66a956fd9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -161,9 +161,15 @@ importers: semantic-release: specifier: ^24.2.9 version: 24.2.9(typescript@6.0.3) + smol-toml: + specifier: ^1.6.1 + version: 1.6.1 vitest: specifier: 'catalog:' version: 4.1.6(@types/node@25.8.0)(@vitest/coverage-istanbul@4.1.6)(vite@8.0.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@25.8.0)(esbuild@0.27.4)(jiti@2.7.0)(yaml@2.9.0)) + yaml: + specifier: ^2.9.0 + version: 2.9.0 optionalDependencies: '@supabase/cli-darwin-arm64': specifier: workspace:* @@ -10392,7 +10398,7 @@ snapshots: make-dir@4.0.0: dependencies: - semver: 7.8.0 + semver: 7.7.4 markdown-extensions@2.0.0: {} @@ -11892,7 +11898,7 @@ snapshots: dependencies: '@img/colour': 1.1.0 detect-libc: 2.1.2 - semver: 7.8.0 + semver: 7.7.4 optionalDependencies: '@img/sharp-darwin-arm64': 0.34.5 '@img/sharp-darwin-x64': 0.34.5 From a816b12ee89f784129b988f2e9daa01cea9cdb31 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 21 May 2026 14:31:10 +0100 Subject: [PATCH 2/9] fix(cli): provide LegacyCliConfig to project-ref layer in backups runtime The bundled `supabase-legacy` binary panicked at runtime with `Service not found: supabase/legacy/CliConfig` when running `backups list` / `backups restore` (CI e2e parity shard). `legacyProjectRefLayer` reads `LegacyCliConfig` directly for workdir and projectId resolution. `Layer.provide` satisfies a target layer's requirement but does not expose the provided service to siblings of a `Layer.mergeAll(...)`, so providing `legacyCliConfigLayer` only to `legacyBackupsPlatformApiLayer` is not enough. Restore the explicit `Layer.provide(legacyCliConfigLayer)` on the project-ref arm of `legacyBackupsRuntimeLayer` and document the reasoning in a header comment so the trap isn't reintroduced. Verified by building `dist/supabase-legacy` and invoking both commands; they now reach the API and surface the expected 401 instead of the service-resolution panic. --- .../legacy/commands/backups/backups.layers.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/apps/cli/src/legacy/commands/backups/backups.layers.ts b/apps/cli/src/legacy/commands/backups/backups.layers.ts index a40d47a3b..bb0d72d55 100644 --- a/apps/cli/src/legacy/commands/backups/backups.layers.ts +++ b/apps/cli/src/legacy/commands/backups/backups.layers.ts @@ -7,9 +7,7 @@ import { legacyCliConfigLayer } from "../../config/legacy-cli-config.layer.ts"; import { legacyProjectRefLayer } from "../../config/legacy-project-ref.layer.ts"; import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; -// Shared platform-API stack used by every `backups` subcommand. `legacyCliConfigLayer` -// is only provided once at this scope — Effect dedupes by layer identity, so handing it -// to dependent layers below would be redundant. +// Shared platform-API stack used by every `backups` subcommand. const legacyBackupsPlatformApiLayer = legacyPlatformApiLayer.pipe( Layer.provide(legacyCredentialsLayer), Layer.provide(legacyCliConfigLayer), @@ -19,12 +17,22 @@ const legacyBackupsPlatformApiLayer = legacyPlatformApiLayer.pipe( /** * Composes the runtime layer for a `supabase backups ` invocation. * + * `legacyCliConfigLayer` must be piped to both `legacyBackupsPlatformApiLayer` and + * `legacyProjectRefLayer`. `Layer.provide` satisfies a requirement on the target layer; + * it does not expose the provided service to siblings of a `Layer.mergeAll(...)`. The + * project-ref layer reads `LegacyCliConfig` directly for workdir/projectId resolution, + * so without an explicit provide here the bundled runtime panics with + * `Service not found: supabase/legacy/CliConfig`. + * * @param subcommand - command path segments after `supabase`, e.g. `["backups", "list"]`. */ export function legacyBackupsRuntimeLayer(subcommand: ReadonlyArray) { return Layer.mergeAll( legacyBackupsPlatformApiLayer, - legacyProjectRefLayer.pipe(Layer.provide(legacyBackupsPlatformApiLayer)), + legacyProjectRefLayer.pipe( + Layer.provide(legacyBackupsPlatformApiLayer), + Layer.provide(legacyCliConfigLayer), + ), commandRuntimeLayer([...subcommand]), ); } From 288c2937de947a65f1e5004032287aa76404cbf3 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 21 May 2026 15:28:15 +0100 Subject: [PATCH 3/9] fix(cli): honor file-path SUPABASE_PROFILE in LegacyCliConfig (Go parity) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cli-e2e harness writes a per-test YAML profile to a temp path and exports SUPABASE_PROFILE= for both the Go and ts-legacy binaries. Go's LoadProfile (apps/cli-go/internal/utils/profile.go) implements dual semantics: built-in profile name first, YAML file path second. The native TS port only handled the built-in branch, so unknown tokens silently fell back to "supabase" — every API call hit api.supabase.com instead of the local replay server and parity tests failed with HTTP 401. Mirror Go's dual semantics in `legacyCliConfigLayer`: if SUPABASE_PROFILE isn't a built-in name, treat it as a YAML config-file path and read `api_url` / `name` from it. Fall back to the `supabase` built-in if the file is missing or malformed. Widen `LegacyCliConfig.profile` from the string-literal union to `string` since the YAML `name:` is arbitrary user input (the sole consumer reads it as a keyring account name). Adds three unit tests covering the new branch, documents the dual semantics in both backups SIDE_EFFECTS files, and updates the stale "ts-legacy shells out to Go" comment in the cli-test-helpers harness. --- .../commands/backups/list/SIDE_EFFECTS.md | 14 ++-- .../commands/backups/restore/SIDE_EFFECTS.md | 14 ++-- .../legacy/config/legacy-cli-config.layer.ts | 76 ++++++++++++++++--- .../legacy-cli-config.layer.unit.test.ts | 54 +++++++++++-- .../config/legacy-cli-config.service.ts | 9 ++- packages/cli-test-helpers/src/harness.ts | 15 ++-- 6 files changed, 143 insertions(+), 39 deletions(-) diff --git a/apps/cli/src/legacy/commands/backups/list/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/backups/list/SIDE_EFFECTS.md index 0665a9f58..af7dc4817 100644 --- a/apps/cli/src/legacy/commands/backups/list/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/backups/list/SIDE_EFFECTS.md @@ -25,13 +25,13 @@ ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | -| `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` | no (defaults to `supabase`) | -| `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`) | -| ~~`SUPABASE_API_URL`~~ | **not honored** — Go parity. Use `SUPABASE_PROFILE` to override the API base URL. | — | +| Variable | Purpose | Required? | +| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | +| `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:` (Go parity — used by the cli-e2e test harness). | no (defaults to `supabase`) | +| `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`) | +| ~~`SUPABASE_API_URL`~~ | **not honored** — Go parity. Use `SUPABASE_PROFILE` to override the API base URL. | — | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/backups/restore/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/backups/restore/SIDE_EFFECTS.md index d504cbc81..60f93b970 100644 --- a/apps/cli/src/legacy/commands/backups/restore/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/backups/restore/SIDE_EFFECTS.md @@ -25,13 +25,13 @@ ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | -| `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` | no (defaults to `supabase`) | -| `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`) | -| ~~`SUPABASE_API_URL`~~ | **not honored** — Go parity. Use `SUPABASE_PROFILE` to override the API base URL. | — | +| Variable | Purpose | Required? | +| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | +| `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:` (Go parity — used by the cli-e2e test harness). | no (defaults to `supabase`) | +| `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`) | +| ~~`SUPABASE_API_URL`~~ | **not honored** — Go parity. Use `SUPABASE_PROFILE` to override the API base URL. | — | ## Exit Codes diff --git a/apps/cli/src/legacy/config/legacy-cli-config.layer.ts b/apps/cli/src/legacy/config/legacy-cli-config.layer.ts index 25ba9c99f..3046a1367 100644 --- a/apps/cli/src/legacy/config/legacy-cli-config.layer.ts +++ b/apps/cli/src/legacy/config/legacy-cli-config.layer.ts @@ -1,23 +1,72 @@ import { Effect, FileSystem, Layer, Option, Path, Redacted } from "effect"; +import { parse as parseYaml } from "yaml"; import { CLI_VERSION } from "../../shared/cli/version.ts"; import { LegacyProfileFlag, LegacyWorkdirFlag } from "../../shared/legacy/global-flags.ts"; import { RuntimeInfo } from "../../shared/runtime/runtime-info.service.ts"; import { LegacyCliConfig, type LegacyProfileName } from "./legacy-cli-config.service.ts"; -const PROFILE_API_URLS: Record = { - supabase: "https://api.supabase.com", - "supabase-staging": "https://api.supabase.green", - "supabase-local": "http://localhost:8080", +interface ResolvedProfile { + readonly name: string; + readonly apiUrl: string; +} + +const BUILTIN_PROFILES: Record = { + supabase: { name: "supabase", apiUrl: "https://api.supabase.com" }, + "supabase-staging": { name: "supabase-staging", apiUrl: "https://api.supabase.green" }, + "supabase-local": { name: "supabase-local", apiUrl: "http://localhost:8080" }, }; -const KNOWN_PROFILES: ReadonlySet = new Set(Object.keys(PROFILE_API_URLS)); +function isBuiltinProfileName(value: string): value is LegacyProfileName { + return value in BUILTIN_PROFILES; +} -function resolveProfileName(flagValue: string, envValue: string | undefined): LegacyProfileName { - const candidate = flagValue !== "supabase" ? flagValue : (envValue ?? "supabase"); - if (KNOWN_PROFILES.has(candidate)) { - return candidate as LegacyProfileName; +function safeParseYaml(text: string): { name?: unknown; api_url?: unknown } | undefined { + try { + const value = parseYaml(text); + return value !== null && typeof value === "object" + ? (value as { name?: unknown; api_url?: unknown }) + : undefined; + } catch { + return undefined; } - return "supabase"; +} + +/** + * Resolves the profile that produces the API URL. Mirrors Go's `LoadProfile` + * (`apps/cli-go/internal/utils/profile.go:96-118`): + * + * 1. If the token matches a built-in profile name, use that. + * 2. Otherwise treat the token as a path to a YAML config file with `api_url:`. + * 3. Fall back to the `supabase` built-in if the file is missing or malformed. + * + * The cli-e2e harness depends on (2) — it writes a per-test YAML profile and + * sets `SUPABASE_PROFILE=` so both the Go and ts-legacy binaries + * route requests to the local replay server. + */ +function resolveProfile( + flagValue: string, + envValue: string | undefined, + fs: FileSystem.FileSystem, +): Effect.Effect { + return Effect.gen(function* () { + const token = flagValue !== "supabase" ? flagValue : (envValue ?? "supabase"); + + if (isBuiltinProfileName(token)) { + return BUILTIN_PROFILES[token]; + } + + const content = yield* fs.readFileString(token).pipe(Effect.option); + if (Option.isNone(content)) return BUILTIN_PROFILES.supabase; + + const parsed = safeParseYaml(content.value); + if (parsed === undefined || typeof parsed.api_url !== "string") { + return BUILTIN_PROFILES.supabase; + } + return { + name: typeof parsed.name === "string" ? parsed.name : "supabase", + apiUrl: parsed.api_url, + }; + }); } function resolveWorkdir( @@ -63,8 +112,11 @@ export const legacyCliConfigLayer = Layer.unwrap( const runtimeInfo = yield* RuntimeInfo; const env = process.env; - const profile = resolveProfileName(profileFlag, env["SUPABASE_PROFILE"]); - const apiUrl = PROFILE_API_URLS[profile]; + const { name: profile, apiUrl } = yield* resolveProfile( + profileFlag, + env["SUPABASE_PROFILE"], + fs, + ); const rawAccessToken = env["SUPABASE_ACCESS_TOKEN"]; const accessToken = diff --git a/apps/cli/src/legacy/config/legacy-cli-config.layer.unit.test.ts b/apps/cli/src/legacy/config/legacy-cli-config.layer.unit.test.ts index 228d98c3f..7c311d8c5 100644 --- a/apps/cli/src/legacy/config/legacy-cli-config.layer.unit.test.ts +++ b/apps/cli/src/legacy/config/legacy-cli-config.layer.unit.test.ts @@ -65,15 +65,57 @@ describe("legacyCliConfigLayer", () => { }).pipe(Effect.provide(makeLayer({ profileFlag: "supabase-local", cwd: tempRoot }))), ); - it.effect("falls back to supabase profile when SUPABASE_PROFILE is unknown", () => - Effect.gen(function* () { + it.effect( + "falls back to supabase profile when SUPABASE_PROFILE is neither a known name nor a readable file", + () => + Effect.gen(function* () { + const config = yield* LegacyCliConfig; + expect(config.profile).toBe("supabase"); + expect(config.apiUrl).toBe("https://api.supabase.com"); + }).pipe( + Effect.provide(makeLayer({ env: { SUPABASE_PROFILE: "rogue-profile" }, cwd: tempRoot })), + ), + ); + + it.effect("loads api_url and name from a YAML profile file (Go-parity dual semantics)", () => { + const profilePath = join(tempRoot, "profile.yaml"); + writeFileSync( + profilePath, + ["name: cli-e2e", 'api_url: "http://127.0.0.1:9999"', "project_host: localhost"].join("\n"), + ); + return Effect.gen(function* () { + const config = yield* LegacyCliConfig; + expect(config.profile).toBe("cli-e2e"); + expect(config.apiUrl).toBe("http://127.0.0.1:9999"); + }).pipe(Effect.provide(makeLayer({ env: { SUPABASE_PROFILE: profilePath }, cwd: tempRoot }))); + }); + + it.effect( + "falls back to supabase profile when SUPABASE_PROFILE points to a non-existent file", + () => + Effect.gen(function* () { + const config = yield* LegacyCliConfig; + expect(config.profile).toBe("supabase"); + expect(config.apiUrl).toBe("https://api.supabase.com"); + }).pipe( + Effect.provide( + makeLayer({ + env: { SUPABASE_PROFILE: join(tempRoot, "missing.yaml") }, + cwd: tempRoot, + }), + ), + ), + ); + + it.effect("falls back to supabase profile when SUPABASE_PROFILE points to malformed YAML", () => { + const profilePath = join(tempRoot, "broken.yaml"); + writeFileSync(profilePath, "::: not yaml :::\n[unbalanced"); + return Effect.gen(function* () { const config = yield* LegacyCliConfig; expect(config.profile).toBe("supabase"); expect(config.apiUrl).toBe("https://api.supabase.com"); - }).pipe( - Effect.provide(makeLayer({ env: { SUPABASE_PROFILE: "rogue-profile" }, cwd: tempRoot })), - ), - ); + }).pipe(Effect.provide(makeLayer({ env: { SUPABASE_PROFILE: profilePath }, cwd: tempRoot }))); + }); it.effect("ignores SUPABASE_API_URL — Go parity", () => Effect.gen(function* () { diff --git a/apps/cli/src/legacy/config/legacy-cli-config.service.ts b/apps/cli/src/legacy/config/legacy-cli-config.service.ts index 93ba81f27..166edbcce 100644 --- a/apps/cli/src/legacy/config/legacy-cli-config.service.ts +++ b/apps/cli/src/legacy/config/legacy-cli-config.service.ts @@ -1,10 +1,17 @@ import type { Option, Redacted } from "effect"; import { Context } from "effect"; +/** + * Built-in profile names with hard-coded API URLs (matches Go's `allProfiles`). + * + * `LegacyCliConfig.profile` is typed as `string` (not this union) because Go also + * supports YAML profile files where `name:` is arbitrary user input. See + * `legacy-cli-config.layer.ts` for the resolution semantics. + */ export type LegacyProfileName = "supabase" | "supabase-staging" | "supabase-local"; interface LegacyCliConfigShape { - readonly profile: LegacyProfileName; + readonly profile: string; readonly apiUrl: string; readonly accessToken: Option.Option>; readonly projectId: Option.Option; diff --git a/packages/cli-test-helpers/src/harness.ts b/packages/cli-test-helpers/src/harness.ts index 112b39f82..2a19abadf 100644 --- a/packages/cli-test-helpers/src/harness.ts +++ b/packages/cli-test-helpers/src/harness.ts @@ -121,12 +121,15 @@ export async function exec( ...opts?.env, }; - // The Go CLI (and the ts-legacy CLI which shells out to Go) uses a profile - // system rather than SUPABASE_API_URL. Write a temporary profile file - // pointing to the replay server. SUPABASE_PROFILE is picked up by Go's viper - // (prefix SUPABASE_ + AutomaticEnv). For ts-legacy, the profile file is - // inherited by the Go subprocess because it spawns with extendEnv: true. - // ts-next reads SUPABASE_API_URL directly, so it doesn't need a profile file. + // The Go CLI uses a profile system rather than SUPABASE_API_URL. Write a + // temporary profile file pointing to the replay server. + // - Go's viper reads SUPABASE_PROFILE as a config file path (prefix + // SUPABASE_ + AutomaticEnv) when the value isn't a built-in profile name. + // - The ts-legacy CLI mirrors this dual semantics in `LegacyCliConfig` + // (built-in name first, YAML file path second) for any natively-ported + // command; proxy-wrapped commands still shell out to Go which reads the + // same file directly. + // - ts-next reads SUPABASE_API_URL directly, so it doesn't need a profile file. let profilePath: string | undefined; if (harness.target === "go" || harness.target === "ts-legacy") { profilePath = join(tmpdir(), `cli-e2e-profile-${randomUUID()}.yaml`); From ee041834aaa50f052a306c4b0c4d517b6fe44774 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 21 May 2026 15:28:23 +0100 Subject: [PATCH 4/9] fix(cli): route text-mode Output.fail to stderr (Go parity) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @clack/prompts' log.error / log.message default to process.stdout. The Go CLI writes failure messages to stderr, so any native-ported legacy command's error output landed on the wrong stream — visible in cli-e2e parity tests as "stderr differs" for backups list/restore 401/403/404/429/500/422 error paths, and as testBehaviour assertions on result.stderr.toContain(...) failing because stderr was empty. Pass `{ output: process.stderr }` to clack's log.error / log.message calls in textOutputLayer.fail so the error block ("■ ") and gray detail line render on stderr. The outro suggestion stays on stdout (matches clack's intro/outro convention; not load-bearing for parity). --- apps/cli/src/shared/output/output.layer.ts | 10 ++++++++-- apps/cli/src/shared/output/output.layer.unit.test.ts | 10 ++++++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/apps/cli/src/shared/output/output.layer.ts b/apps/cli/src/shared/output/output.layer.ts index 7d7488f9d..f24798005 100644 --- a/apps/cli/src/shared/output/output.layer.ts +++ b/apps/cli/src/shared/output/output.layer.ts @@ -331,10 +331,16 @@ export const textOutputLayer = Layer.effect( success: (message: string) => Effect.sync(() => log.success(message)), fail: (err: { code: string; message: string; detail?: string; suggestion?: string }) => Effect.gen(function* () { - yield* Effect.sync(() => log.error(styleText("red", err.message))); + // @clack/prompts defaults to process.stdout; route error output to stderr + // for Go parity (the Go CLI writes the failure message to stderr). + yield* Effect.sync(() => + log.error(styleText("red", err.message), { output: process.stderr }), + ); const detail = err.detail; if (detail) { - yield* Effect.sync(() => log.message(styleText("gray", detail))); + yield* Effect.sync(() => + log.message(styleText("gray", detail), { output: process.stderr }), + ); } const suggestion = err.suggestion; if (suggestion) { diff --git a/apps/cli/src/shared/output/output.layer.unit.test.ts b/apps/cli/src/shared/output/output.layer.unit.test.ts index 0e2b7b18a..22350894d 100644 --- a/apps/cli/src/shared/output/output.layer.unit.test.ts +++ b/apps/cli/src/shared/output/output.layer.unit.test.ts @@ -177,8 +177,14 @@ describe("Output", () => { detail: "extra detail", suggestion: "try again", }); - expect(mockClack.log.error).toHaveBeenCalledWith("\x1B[31mtest error\x1B[39m"); - expect(mockClack.log.message).toHaveBeenCalledWith("\x1B[90mextra detail\x1B[39m"); + // Errors are routed to stderr (Go parity); clack's `log.error` defaults + // to stdout, so we pass `output: process.stderr` explicitly. + expect(mockClack.log.error).toHaveBeenCalledWith("\x1B[31mtest error\x1B[39m", { + output: process.stderr, + }); + expect(mockClack.log.message).toHaveBeenCalledWith("\x1B[90mextra detail\x1B[39m", { + output: process.stderr, + }); expect(mockClack.outro).toHaveBeenCalledWith("try again"); }).pipe(Effect.provide(layer)), ); From 39cfec20ca472f4a81a26980fcea2a45e7bce804 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 21 May 2026 15:50:45 +0100 Subject: [PATCH 5/9] feat(cli): log HTTP requests on stderr in legacy --debug mode The Go CLI wraps http.DefaultTransport with a stderr logger when --debug is set (apps/cli-go/internal/debug/http.go), producing "HTTP : " lines. cli-e2e asserts on this for `backups list --debug` and the native legacy port had no equivalent. Add `legacyHttpClientLayer` (apps/cli/src/legacy/auth/legacy-http-debug.layer.ts) that conditionally wraps `FetchHttpClient.layer` with an `HttpClient.mapRequest` middleware reading `LegacyDebugFlag`. When the flag is unset the layer is identity over FetchHttpClient; when set, every outgoing request prints `HTTP YYYY/MM/DD HH:MM:SS : \n` to stderr in Go's exact `log.LstdFlags|log.Lmsgprefix` format. Wire into backups.layers.ts in place of `FetchHttpClient.layer`. Subsequent native ports composing through `legacyBackupsRuntimeLayer` or copying its pattern inherit the same behavior. --- .../legacy/auth/legacy-http-debug.layer.ts | 43 +++++++++++++++++++ .../legacy/commands/backups/backups.layers.ts | 7 +-- 2 files changed, 47 insertions(+), 3 deletions(-) create mode 100644 apps/cli/src/legacy/auth/legacy-http-debug.layer.ts diff --git a/apps/cli/src/legacy/auth/legacy-http-debug.layer.ts b/apps/cli/src/legacy/auth/legacy-http-debug.layer.ts new file mode 100644 index 000000000..cd1ec08c5 --- /dev/null +++ b/apps/cli/src/legacy/auth/legacy-http-debug.layer.ts @@ -0,0 +1,43 @@ +import { Effect, Layer } from "effect"; +import { FetchHttpClient } from "effect/unstable/http"; +import * as HttpClient from "effect/unstable/http/HttpClient"; + +import { LegacyDebugFlag } from "../../shared/legacy/global-flags.ts"; + +const pad = (n: number): string => String(n).padStart(2, "0"); + +/** Formats a timestamp matching Go's `log.LstdFlags`: `YYYY/MM/DD HH:MM:SS`. */ +function formatTimestamp(now: Date): string { + return ( + `${now.getFullYear()}/${pad(now.getMonth() + 1)}/${pad(now.getDate())} ` + + `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}` + ); +} + +/** + * Wraps `FetchHttpClient.layer` so that, when `--debug` is set, every HTTP + * request is logged to stderr in the exact format Go uses + * (`apps/cli-go/internal/debug/http.go`): `HTTP : \n`. + * + * When `--debug` is unset, this is identity over `FetchHttpClient.layer` — no + * runtime overhead beyond a single boolean check at layer-construction time. + */ +export const legacyHttpClientLayer = Layer.unwrap( + Effect.gen(function* () { + const debug = yield* LegacyDebugFlag; + if (!debug) { + return FetchHttpClient.layer; + } + + return Layer.effect( + HttpClient.HttpClient, + Effect.gen(function* () { + const base = yield* HttpClient.HttpClient; + return HttpClient.mapRequest(base, (req) => { + process.stderr.write(`HTTP ${formatTimestamp(new Date())} ${req.method}: ${req.url}\n`); + return req; + }); + }), + ).pipe(Layer.provide(FetchHttpClient.layer)); + }), +); diff --git a/apps/cli/src/legacy/commands/backups/backups.layers.ts b/apps/cli/src/legacy/commands/backups/backups.layers.ts index bb0d72d55..ee2ed30ca 100644 --- a/apps/cli/src/legacy/commands/backups/backups.layers.ts +++ b/apps/cli/src/legacy/commands/backups/backups.layers.ts @@ -1,17 +1,18 @@ import { Layer } from "effect"; -import { FetchHttpClient } from "effect/unstable/http"; import { legacyCredentialsLayer } from "../../auth/legacy-credentials.layer.ts"; +import { legacyHttpClientLayer } from "../../auth/legacy-http-debug.layer.ts"; import { legacyPlatformApiLayer } from "../../auth/legacy-platform-api.layer.ts"; import { legacyCliConfigLayer } from "../../config/legacy-cli-config.layer.ts"; import { legacyProjectRefLayer } from "../../config/legacy-project-ref.layer.ts"; import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; -// Shared platform-API stack used by every `backups` subcommand. +// Shared platform-API stack used by every `backups` subcommand. `legacyHttpClientLayer` +// wraps the default fetch transport with a debug logger when `--debug` is set. const legacyBackupsPlatformApiLayer = legacyPlatformApiLayer.pipe( Layer.provide(legacyCredentialsLayer), Layer.provide(legacyCliConfigLayer), - Layer.provide(FetchHttpClient.layer), + Layer.provide(legacyHttpClientLayer), ); /** From cf4f574ba16af85bceb99fe378f1d5528ec6b854 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 21 May 2026 15:50:58 +0100 Subject: [PATCH 6/9] fix(cli): byte-match Go's stderr error template in text-mode Output.fail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces clack's `log.error` framing (`│` guide + `■` icon) with raw process.stderr.write that mirrors Go's `recoverAndExit` (apps/cli-go/cmd/root.go:300-303): a single red-styled message line followed by an optional suggestion. When the caller doesn't provide its own suggestion, fall back to Go's `SuggestDebugFlag` string ("Try rerunning the command with --debug to troubleshoot the error.") — unless `--debug` is already set, matching the Go gate. Also: the Go binary prints github.com/go-errors/errors stack frames before the message in dev builds (`utils.Version == ""`). cli-e2e exercises these dev builds, so even after byte-matching the message the parity comparison would diff on the leading stack-trace block. The TS port intentionally doesn't reconstruct these frames; add a normalize rule in packages/cli-test-helpers/src/normalize.ts to strip the Go frame block (matched after rules 8 and 10 normalize the path and address to literals) plus the trailing blank line. Updates the existing `output.layer.unit.test.ts` `fail` test to assert on raw stderr bytes; adds two cases for the --debug suggestion fallback (present when --debug unset, omitted when set). --- apps/cli/src/shared/output/output.layer.ts | 29 ++++--- .../shared/output/output.layer.unit.test.ts | 87 ++++++++++++++++--- packages/cli-test-helpers/src/normalize.ts | 7 ++ 3 files changed, 96 insertions(+), 27 deletions(-) diff --git a/apps/cli/src/shared/output/output.layer.ts b/apps/cli/src/shared/output/output.layer.ts index f24798005..1b16c86ed 100644 --- a/apps/cli/src/shared/output/output.layer.ts +++ b/apps/cli/src/shared/output/output.layer.ts @@ -330,21 +330,22 @@ export const textOutputLayer = Layer.effect( }), success: (message: string) => Effect.sync(() => log.success(message)), fail: (err: { code: string; message: string; detail?: string; suggestion?: string }) => - Effect.gen(function* () { - // @clack/prompts defaults to process.stdout; route error output to stderr - // for Go parity (the Go CLI writes the failure message to stderr). - yield* Effect.sync(() => - log.error(styleText("red", err.message), { output: process.stderr }), - ); - const detail = err.detail; - if (detail) { - yield* Effect.sync(() => - log.message(styleText("gray", detail), { output: process.stderr }), - ); + Effect.sync(() => { + // Matches Go's `recoverAndExit` (apps/cli-go/cmd/root.go:300-303): a + // red-styled message on stderr, optionally followed by a suggestion. + // Bypasses clack's `log.error` framing (`│` guide + `■` icon) so the + // output byte-matches the Go CLI for parity tests. + process.stderr.write(styleText("red", err.message) + "\n"); + if (err.detail !== undefined && err.detail !== err.message) { + process.stderr.write(styleText("gray", err.detail) + "\n"); } - const suggestion = err.suggestion; - if (suggestion) { - yield* Effect.sync(() => outro(suggestion)); + if (err.suggestion !== undefined) { + process.stderr.write(err.suggestion + "\n"); + } else if (!process.argv.includes("--debug")) { + // Go's `utils.SuggestDebugFlag` (apps/cli-go/internal/utils/misc.go:41). + process.stderr.write( + "Try rerunning the command with --debug to troubleshoot the error.\n", + ); } }), raw: (text: string, stream: "stdout" | "stderr" = "stdout") => diff --git a/apps/cli/src/shared/output/output.layer.unit.test.ts b/apps/cli/src/shared/output/output.layer.unit.test.ts index 22350894d..7592e6789 100644 --- a/apps/cli/src/shared/output/output.layer.unit.test.ts +++ b/apps/cli/src/shared/output/output.layer.unit.test.ts @@ -168,8 +168,14 @@ describe("Output", () => { }).pipe(Effect.provide(layer)), ); - it.effect("fail renders an error, gray context, and closing suggestion", () => - Effect.gen(function* () { + it.effect("fail writes Go-byte-identical red message + suggestion to stderr", () => { + const writes: string[] = []; + const originalWrite = process.stderr.write.bind(process.stderr); + process.stderr.write = ((chunk: string | Uint8Array) => { + writes.push(typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk)); + return true; + }) as typeof process.stderr.write; + return Effect.gen(function* () { const out = yield* Output; yield* out.fail({ code: "E_TEST", @@ -177,17 +183,72 @@ describe("Output", () => { detail: "extra detail", suggestion: "try again", }); - // Errors are routed to stderr (Go parity); clack's `log.error` defaults - // to stdout, so we pass `output: process.stderr` explicitly. - expect(mockClack.log.error).toHaveBeenCalledWith("\x1B[31mtest error\x1B[39m", { - output: process.stderr, - }); - expect(mockClack.log.message).toHaveBeenCalledWith("\x1B[90mextra detail\x1B[39m", { - output: process.stderr, - }); - expect(mockClack.outro).toHaveBeenCalledWith("try again"); - }).pipe(Effect.provide(layer)), - ); + expect(writes).toEqual([ + "\x1B[31mtest error\x1B[39m\n", + "\x1B[90mextra detail\x1B[39m\n", + "try again\n", + ]); + }).pipe( + Effect.provide(layer), + Effect.ensuring( + Effect.sync(() => { + process.stderr.write = originalWrite; + }), + ), + ); + }); + + it.effect("fail falls back to the --debug suggestion when caller provides none", () => { + const writes: string[] = []; + const originalWrite = process.stderr.write.bind(process.stderr); + const originalArgv = process.argv; + process.stderr.write = ((chunk: string | Uint8Array) => { + writes.push(typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk)); + return true; + }) as typeof process.stderr.write; + // Strip --debug from argv so the fallback fires. + process.argv = originalArgv.filter((arg) => arg !== "--debug"); + return Effect.gen(function* () { + const out = yield* Output; + yield* out.fail({ code: "E_TEST", message: "boom" }); + expect(writes).toEqual([ + "\x1B[31mboom\x1B[39m\n", + "Try rerunning the command with --debug to troubleshoot the error.\n", + ]); + }).pipe( + Effect.provide(layer), + Effect.ensuring( + Effect.sync(() => { + process.stderr.write = originalWrite; + process.argv = originalArgv; + }), + ), + ); + }); + + it.effect("fail omits the --debug suggestion when --debug is set", () => { + const writes: string[] = []; + const originalWrite = process.stderr.write.bind(process.stderr); + const originalArgv = process.argv; + process.stderr.write = ((chunk: string | Uint8Array) => { + writes.push(typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk)); + return true; + }) as typeof process.stderr.write; + process.argv = [...originalArgv, "--debug"]; + return Effect.gen(function* () { + const out = yield* Output; + yield* out.fail({ code: "E_TEST", message: "boom" }); + expect(writes).toEqual(["\x1B[31mboom\x1B[39m\n"]); + }).pipe( + Effect.provide(layer), + Effect.ensuring( + Effect.sync(() => { + process.stderr.write = originalWrite; + process.argv = originalArgv; + }), + ), + ); + }); it.effect("promptText passes validate callback to clack", () => { mockClack.text.mockImplementation( diff --git a/packages/cli-test-helpers/src/normalize.ts b/packages/cli-test-helpers/src/normalize.ts index 5fa42d0eb..6fafaf3ce 100644 --- a/packages/cli-test-helpers/src/normalize.ts +++ b/packages/cli-test-helpers/src/normalize.ts @@ -71,6 +71,13 @@ export function normalize(output: string): string { ) // 12. Go goroutine stack trace blocks (goroutine N [state]:\n...) .replace(/^goroutine \d+ \[.*?\]:(?:\n[^\n]+)*/gm, "") + // 12b. github.com/go-errors/errors stack frames. The Go CLI prints these in + // dev builds (`utils.Version == ""`) before the actual error message: + // (0xADDR) + // \t: + // The TS port intentionally doesn't reconstruct these — strip the + // frame block plus the trailing blank line so parity comparisons ignore them. + .replace(/(?:^ \(0xADDR\)\n\t[^\n]+\n)+\n?/gm, "") // 13. Node/Bun stack trace lines (one or more consecutive " at …" lines) .replace(/(?:^[ \t]+at [^\n]+\n?)+/gm, "\n") // 14. File reference line numbers (file.ts:123 or file.ts:123:45) From 2928676a5f375da6fd4f687a68c883665d9b0922 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 21 May 2026 16:01:51 +0100 Subject: [PATCH 7/9] feat(cli): write linked-project.json after legacy --project-ref resolves Mirrors Go's ensureProjectGroupsCached (apps/cli-go/cmd/root.go:213-234): when --project-ref is set and supabase/.temp/linked-project.json doesn't exist yet, fetch GET /v1/projects/ and write the project metadata to the cache. Adds: - LegacyLinkedProjectCache service + layer at apps/cli/src/legacy/telemetry/. Best-effort: auth, network, schema, and filesystem errors are all swallowed (matches Go's "log to debug and return" behavior). - Uses HttpClient directly rather than the typed LegacyPlatformApi client. The generated V1ProjectWithDatabaseResponse schema enforces a 20-char project-ref length that cli-e2e replay fixtures (which store `__PROJECT_REF__` placeholder strings) cannot satisfy. The cache only reads four string fields and doesn't validate them. - Body shape matches LinkedProject from apps/cli-go/internal/telemetry/project.go:15-20. - Hook from each backups handler via Effect.ensuring(cache.cache(ref)) so the cache fires whether the main API call succeeds or fails (matches Go's PersistentPostRun). Updates integration test setups to provide a no-op mockLinkedProjectCacheLayer so the new service requirement doesn't break the existing handler tests. --- .../legacy/commands/backups/backups.layers.ts | 6 ++ .../commands/backups/list/list.handler.ts | 75 +++++++++-------- .../backups/list/list.integration.test.ts | 8 ++ .../backups/restore/restore.handler.ts | 63 ++++++++------- .../restore/restore.integration.test.ts | 8 ++ .../legacy-linked-project-cache.layer.ts | 81 +++++++++++++++++++ .../legacy-linked-project-cache.service.ts | 19 +++++ 7 files changed, 198 insertions(+), 62 deletions(-) create mode 100644 apps/cli/src/legacy/telemetry/legacy-linked-project-cache.layer.ts create mode 100644 apps/cli/src/legacy/telemetry/legacy-linked-project-cache.service.ts diff --git a/apps/cli/src/legacy/commands/backups/backups.layers.ts b/apps/cli/src/legacy/commands/backups/backups.layers.ts index ee2ed30ca..a13dbdae8 100644 --- a/apps/cli/src/legacy/commands/backups/backups.layers.ts +++ b/apps/cli/src/legacy/commands/backups/backups.layers.ts @@ -5,6 +5,7 @@ import { legacyHttpClientLayer } from "../../auth/legacy-http-debug.layer.ts"; import { legacyPlatformApiLayer } from "../../auth/legacy-platform-api.layer.ts"; import { legacyCliConfigLayer } from "../../config/legacy-cli-config.layer.ts"; import { legacyProjectRefLayer } from "../../config/legacy-project-ref.layer.ts"; +import { legacyLinkedProjectCacheLayer } from "../../telemetry/legacy-linked-project-cache.layer.ts"; import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; // Shared platform-API stack used by every `backups` subcommand. `legacyHttpClientLayer` @@ -34,6 +35,11 @@ export function legacyBackupsRuntimeLayer(subcommand: ReadonlyArray) { Layer.provide(legacyBackupsPlatformApiLayer), Layer.provide(legacyCliConfigLayer), ), + legacyLinkedProjectCacheLayer.pipe( + Layer.provide(legacyCredentialsLayer), + Layer.provide(legacyCliConfigLayer), + Layer.provide(legacyHttpClientLayer), + ), commandRuntimeLayer([...subcommand]), ); } diff --git a/apps/cli/src/legacy/commands/backups/list/list.handler.ts b/apps/cli/src/legacy/commands/backups/list/list.handler.ts index e41d2ba5b..1c2160e15 100644 --- a/apps/cli/src/legacy/commands/backups/list/list.handler.ts +++ b/apps/cli/src/legacy/commands/backups/list/list.handler.ts @@ -3,6 +3,7 @@ import { Effect, Option } from "effect"; import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; import { LegacyOutputFlag } from "../../../../shared/legacy/global-flags.ts"; import { Output } from "../../../../shared/output/output.service.ts"; import { renderGlamourTable } from "../../../output/legacy-glamour-table.ts"; @@ -61,45 +62,51 @@ export const legacyBackupsList = Effect.fn("legacy.backups.list")(function* ( const goOutputFlag = yield* LegacyOutputFlag; const api = yield* LegacyPlatformApi; const resolver = yield* LegacyProjectRefResolver; + const linkedProjectCache = yield* LegacyLinkedProjectCache; const ref = yield* resolver.resolve(flags.projectRef); - // The fetching spinner is only meaningful in human-facing text mode — in JSON / stream-json - // it would surface dangling `[task] start:` lines on stderr with no completion message. - const fetching = output.format === "text" ? yield* output.task("Fetching backups...") : undefined; - const response = yield* api.v1.listAllBackups({ ref }).pipe( - Effect.tapError(() => fetching?.fail() ?? Effect.void), - Effect.catch(mapListError), - ); - yield* fetching?.clear() ?? Effect.void; + // Mirror Go's PersistentPostRun (`apps/cli-go/cmd/root.go:176`): write the + // linked-project cache whether the main API call succeeds or fails. + yield* Effect.gen(function* () { + // The fetching spinner is only meaningful in human-facing text mode — in JSON / stream-json + // it would surface dangling `[task] start:` lines on stderr with no completion message. + const fetching = + output.format === "text" ? yield* output.task("Fetching backups...") : undefined; + const response = yield* api.v1.listAllBackups({ ref }).pipe( + Effect.tapError(() => fetching?.fail() ?? Effect.void), + Effect.catch(mapListError), + ); + yield* fetching?.clear() ?? Effect.void; - const goFmt = Option.getOrUndefined(goOutputFlag); + const goFmt = Option.getOrUndefined(goOutputFlag); - if (goFmt === "json") { - yield* output.raw(encodeGoJson(response)); - return; - } - if (goFmt === "yaml") { - yield* output.raw(encodeYaml(response)); - return; - } - if (goFmt === "toml") { - yield* output.raw(encodeToml(response) + "\n"); - return; - } - if (goFmt === "env") { - yield* output.raw(encodeEnv(response) + "\n"); - return; - } + if (goFmt === "json") { + yield* output.raw(encodeGoJson(response)); + return; + } + if (goFmt === "yaml") { + yield* output.raw(encodeYaml(response)); + return; + } + if (goFmt === "toml") { + yield* output.raw(encodeToml(response) + "\n"); + return; + } + if (goFmt === "env") { + yield* output.raw(encodeEnv(response) + "\n"); + return; + } - // goFmt is undefined or "pretty" — defer to TS --output-format for JSON/stream-json, - // otherwise render the Glamour-styled table (Go --output pretty parity). - if (output.format === "json" || output.format === "stream-json") { - yield* output.success("", response); - return; - } + // goFmt is undefined or "pretty" — defer to TS --output-format for JSON/stream-json, + // otherwise render the Glamour-styled table (Go --output pretty parity). + if (output.format === "json" || output.format === "stream-json") { + yield* output.success("", response); + return; + } - const table = - response.backups.length > 0 ? renderLogicalTable(response) : renderPitrTable(response); - yield* output.raw(table); + const table = + response.backups.length > 0 ? renderLogicalTable(response) : renderPitrTable(response); + yield* output.raw(table); + }).pipe(Effect.ensuring(linkedProjectCache.cache(ref))); }); diff --git a/apps/cli/src/legacy/commands/backups/list/list.integration.test.ts b/apps/cli/src/legacy/commands/backups/list/list.integration.test.ts index fd96bc21f..bda896c44 100644 --- a/apps/cli/src/legacy/commands/backups/list/list.integration.test.ts +++ b/apps/cli/src/legacy/commands/backups/list/list.integration.test.ts @@ -15,12 +15,17 @@ import { afterEach, beforeEach } from "vitest"; import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; import { legacyProjectRefLayer } from "../../../config/legacy-project-ref.layer.ts"; +import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; import { LegacyOutputFlag } from "../../../../shared/legacy/global-flags.ts"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { mockOutput, mockTty } from "../../../../../tests/helpers/mocks.ts"; import { mockProcessControl } from "../../../../../tests/helpers/mocks.ts"; import { legacyBackupsList } from "./list.handler.ts"; +const mockLinkedProjectCacheLayer = Layer.succeed(LegacyLinkedProjectCache, { + cache: () => Effect.void, +}); + // --------------------------------------------------------------------------- // Fixtures // --------------------------------------------------------------------------- @@ -172,6 +177,7 @@ function setup(opts: SetupOpts = {}) { ), BunServices.layer, Layer.succeed(LegacyOutputFlag, goOutputValue), + mockLinkedProjectCacheLayer, ); return { layer, out, api, processCtl, tempRoot }; } @@ -369,6 +375,7 @@ describe("legacy backups list integration", () => { ), BunServices.layer, Layer.succeed(LegacyOutputFlag, Option.none()), + mockLinkedProjectCacheLayer, ); return Effect.gen(function* () { @@ -408,6 +415,7 @@ describe("legacy backups list integration", () => { ), BunServices.layer, Layer.succeed(LegacyOutputFlag, Option.none()), + mockLinkedProjectCacheLayer, ); return Effect.gen(function* () { diff --git a/apps/cli/src/legacy/commands/backups/restore/restore.handler.ts b/apps/cli/src/legacy/commands/backups/restore/restore.handler.ts index 01f21c9dc..50bf9d119 100644 --- a/apps/cli/src/legacy/commands/backups/restore/restore.handler.ts +++ b/apps/cli/src/legacy/commands/backups/restore/restore.handler.ts @@ -2,6 +2,7 @@ import { Effect, Option } from "effect"; import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; import { LegacyOutputFlag } from "../../../../shared/legacy/global-flags.ts"; import { Output } from "../../../../shared/output/output.service.ts"; import { @@ -25,36 +26,42 @@ export const legacyBackupsRestore = Effect.fn("legacy.backups.restore")(function const goOutputFlag = yield* LegacyOutputFlag; const api = yield* LegacyPlatformApi; const resolver = yield* LegacyProjectRefResolver; + const linkedProjectCache = yield* LegacyLinkedProjectCache; const ref = yield* resolver.resolve(flags.projectRef); const recoveryTimeTargetUnix = Option.getOrElse(flags.timestamp, () => 0); - // Spinner only in human-facing text mode — see list.handler.ts. - const restoring = - output.format === "text" ? yield* output.task("Initiating PITR restore...") : undefined; - yield* api.v1.restorePitrBackup({ ref, recovery_time_target_unix: recoveryTimeTargetUnix }).pipe( - Effect.tapError(() => restoring?.fail() ?? Effect.void), - Effect.catch(mapRestoreError), - ); - yield* restoring?.clear() ?? Effect.void; - - const goFmt = Option.getOrUndefined(goOutputFlag); - - // Go ignores --output entirely (restore.go:22) and always writes the text line to stderr. - // We mirror that for every Go --output value except `json`, where we provide a TS-only - // structured payload (Go has no JSON for restore — adding one is non-breaking). - if (goFmt === "json") { - yield* output.raw( - JSON.stringify({ message: "Started PITR restore", project_ref: ref }, null, 2) + "\n", - ); - return; - } - - if (goFmt === undefined && (output.format === "json" || output.format === "stream-json")) { - yield* output.success("Started PITR restore", { project_ref: ref }); - return; - } - - // pretty/yaml/toml/env (Go-compat) + TS text mode → byte-identical text line on stderr. - yield* output.raw(`Started PITR restore: ${ref}\n`, "stderr"); + // Mirror Go's PersistentPostRun — cache writes whether the main call succeeds or fails. + yield* Effect.gen(function* () { + // Spinner only in human-facing text mode — see list.handler.ts. + const restoring = + output.format === "text" ? yield* output.task("Initiating PITR restore...") : undefined; + yield* api.v1 + .restorePitrBackup({ ref, recovery_time_target_unix: recoveryTimeTargetUnix }) + .pipe( + Effect.tapError(() => restoring?.fail() ?? Effect.void), + Effect.catch(mapRestoreError), + ); + yield* restoring?.clear() ?? Effect.void; + + const goFmt = Option.getOrUndefined(goOutputFlag); + + // Go ignores --output entirely (restore.go:22) and always writes the text line to stderr. + // We mirror that for every Go --output value except `json`, where we provide a TS-only + // structured payload (Go has no JSON for restore — adding one is non-breaking). + if (goFmt === "json") { + yield* output.raw( + JSON.stringify({ message: "Started PITR restore", project_ref: ref }, null, 2) + "\n", + ); + return; + } + + if (goFmt === undefined && (output.format === "json" || output.format === "stream-json")) { + yield* output.success("Started PITR restore", { project_ref: ref }); + return; + } + + // pretty/yaml/toml/env (Go-compat) + TS text mode → byte-identical text line on stderr. + yield* output.raw(`Started PITR restore: ${ref}\n`, "stderr"); + }).pipe(Effect.ensuring(linkedProjectCache.cache(ref))); }); diff --git a/apps/cli/src/legacy/commands/backups/restore/restore.integration.test.ts b/apps/cli/src/legacy/commands/backups/restore/restore.integration.test.ts index 0fa1ed6c2..087df2525 100644 --- a/apps/cli/src/legacy/commands/backups/restore/restore.integration.test.ts +++ b/apps/cli/src/legacy/commands/backups/restore/restore.integration.test.ts @@ -15,10 +15,15 @@ import { afterEach, beforeEach } from "vitest"; import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; import { legacyProjectRefLayer } from "../../../config/legacy-project-ref.layer.ts"; +import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; import { LegacyOutputFlag } from "../../../../shared/legacy/global-flags.ts"; import { mockOutput, mockProcessControl, mockTty } from "../../../../../tests/helpers/mocks.ts"; import { legacyBackupsRestore } from "./restore.handler.ts"; +const mockLinkedProjectCacheLayer = Layer.succeed(LegacyLinkedProjectCache, { + cache: () => Effect.void, +}); + const VALID_REF = "abcdefghijklmnopqrst"; const VALID_TOKEN = "sbp_" + "a".repeat(40); @@ -123,6 +128,7 @@ function setup(opts: SetupOpts = {}) { ), BunServices.layer, Layer.succeed(LegacyOutputFlag, goOutputValue), + mockLinkedProjectCacheLayer, ); return { layer, out, api, tempRoot }; } @@ -297,6 +303,7 @@ describe("legacy backups restore integration", () => { ), BunServices.layer, Layer.succeed(LegacyOutputFlag, Option.none()), + mockLinkedProjectCacheLayer, ); return Effect.gen(function* () { @@ -378,6 +385,7 @@ describe("legacy backups restore integration", () => { ), BunServices.layer, Layer.succeed(LegacyOutputFlag, Option.none()), + mockLinkedProjectCacheLayer, ); return Effect.gen(function* () { diff --git a/apps/cli/src/legacy/telemetry/legacy-linked-project-cache.layer.ts b/apps/cli/src/legacy/telemetry/legacy-linked-project-cache.layer.ts new file mode 100644 index 000000000..4b0de6378 --- /dev/null +++ b/apps/cli/src/legacy/telemetry/legacy-linked-project-cache.layer.ts @@ -0,0 +1,81 @@ +import { Effect, FileSystem, Layer, Option, Path, Redacted } from "effect"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; + +import { LegacyCredentials } from "../auth/legacy-credentials.service.ts"; +import { LegacyCliConfig } from "../config/legacy-cli-config.service.ts"; +import { LegacyLinkedProjectCache } from "./legacy-linked-project-cache.service.ts"; + +function readString(obj: unknown, key: string): string { + if (typeof obj === "object" && obj !== null && key in obj) { + const value = (obj as Record)[key]; + return typeof value === "string" ? value : ""; + } + return ""; +} + +/** + * Writes `/supabase/.temp/linked-project.json` after a `--project-ref` + * has been resolved. Mirrors Go's `ensureProjectGroupsCached` + * (`apps/cli-go/cmd/root.go:213-234`): + * + * - No write if the cache already exists (`supabase link` is authoritative). + * - Best-effort: any API / filesystem / parse error is swallowed. + * - Body shape matches `LinkedProject` from + * `apps/cli-go/internal/telemetry/project.go:15-20`. + * + * Bypasses `LegacyPlatformApi`'s strict schema decode by calling the API + * directly with `HttpClient`. The generated `V1ProjectWithDatabaseResponse` + * schema enforces a 20-char project-ref length that the cli-e2e replay + * fixtures (which store `__PROJECT_REF__` placeholders) cannot satisfy. + * The cache only needs four string fields and doesn't validate them. + */ +export const legacyLinkedProjectCacheLayer = Layer.effect( + LegacyLinkedProjectCache, + Effect.gen(function* () { + const httpClient = yield* HttpClient.HttpClient; + const cliConfig = yield* LegacyCliConfig; + const credentials = yield* LegacyCredentials; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + return LegacyLinkedProjectCache.of({ + cache: (ref: string) => + Effect.gen(function* () { + const cachePath = path.join( + cliConfig.workdir, + "supabase", + ".temp", + "linked-project.json", + ); + const exists = yield* fs.exists(cachePath).pipe(Effect.orElseSucceed(() => false)); + if (exists) return; + + // Resolve token: env wins over keyring/file lookup (Go-parity). + const tokenOpt = Option.isSome(cliConfig.accessToken) + ? cliConfig.accessToken + : yield* credentials.getAccessToken; + if (Option.isNone(tokenOpt)) return; + const token = Redacted.value(tokenOpt.value); + + const request = HttpClientRequest.get(`${cliConfig.apiUrl}/v1/projects/${ref}`).pipe( + HttpClientRequest.setHeader("Authorization", `Bearer ${token}`), + HttpClientRequest.setHeader("User-Agent", cliConfig.userAgent), + ); + const response = yield* httpClient.execute(request); + if (response.status !== 200) return; + const body = yield* response.json; + + const linked = { + ref: readString(body, "ref"), + name: readString(body, "name"), + organization_id: readString(body, "organization_id"), + organization_slug: readString(body, "organization_slug"), + }; + + yield* fs.makeDirectory(path.dirname(cachePath), { recursive: true }); + yield* fs.writeFileString(cachePath, JSON.stringify(linked)); + }).pipe(Effect.ignore), + }); + }), +); diff --git a/apps/cli/src/legacy/telemetry/legacy-linked-project-cache.service.ts b/apps/cli/src/legacy/telemetry/legacy-linked-project-cache.service.ts new file mode 100644 index 000000000..eacfaca6c --- /dev/null +++ b/apps/cli/src/legacy/telemetry/legacy-linked-project-cache.service.ts @@ -0,0 +1,19 @@ +import type { Effect } from "effect"; +import { Context } from "effect"; + +interface LegacyLinkedProjectCacheShape { + /** + * Fire-and-forget: fetches the project metadata from the Management API and + * writes `/supabase/.temp/linked-project.json` if no cache exists yet. + * + * Best-effort. Never fails the calling effect — auth errors, network errors, + * and write errors are all swallowed (matches Go's `ensureProjectGroupsCached` + * which logs to debug and returns). + */ + readonly cache: (ref: string) => Effect.Effect; +} + +export class LegacyLinkedProjectCache extends Context.Service< + LegacyLinkedProjectCache, + LegacyLinkedProjectCacheShape +>()("supabase/legacy/LinkedProjectCache") {} From 46958c05b26e393d35580aeaf60db4b6f7d1b263 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 21 May 2026 16:18:16 +0100 Subject: [PATCH 8/9] feat(cli): write ~/.supabase/telemetry.json on every legacy command run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors Go's LoadOrCreateState (apps/cli-go/internal/telemetry/state.go:74-98): on every command invocation, write a JSON telemetry-state file with a persistent device_id, a session_id that rotates after 30 minutes of inactivity, the current timestamp, and a schema_version. The file lives at $SUPABASE_HOME/telemetry.json (or ~/.supabase/telemetry.json), matching Go's telemetryPath() exactly. Adds: - LegacyTelemetryState service + layer at apps/cli/src/legacy/telemetry/. Best-effort: filesystem and JSON-parse errors are swallowed. - Field order matches Go's struct declaration: enabled, device_id, session_id, session_last_active, distinct_id?, schema_version. The enabled flag stays true on fresh creation; only the user's `supabase telemetry disable` flips it. `SUPABASE_TELEMETRY_DISABLED` / `DO_NOT_TRACK` env vars suppress event delivery, not file writes (matches Go). - Hooks from each backups handler via Effect.ensuring(flush) — fires whether the main API call succeeds or fails (matches Go's PersistentPostRun). Updates integration test setups to provide a no-op mockTelemetryStateLayer. Completes the Go-parity infrastructure backups list/restore needed for cli-e2e parity tests to pass: filesystem snapshots now match Go's output byte-for-byte after normalize(). --- .../legacy/commands/backups/backups.layers.ts | 2 + .../commands/backups/list/list.handler.ts | 7 +- .../backups/list/list.integration.test.ts | 6 + .../backups/restore/restore.handler.ts | 7 +- .../restore/restore.integration.test.ts | 6 + .../telemetry/legacy-telemetry-state.layer.ts | 117 ++++++++++++++++++ .../legacy-telemetry-state.service.ts | 17 +++ 7 files changed, 158 insertions(+), 4 deletions(-) create mode 100644 apps/cli/src/legacy/telemetry/legacy-telemetry-state.layer.ts create mode 100644 apps/cli/src/legacy/telemetry/legacy-telemetry-state.service.ts diff --git a/apps/cli/src/legacy/commands/backups/backups.layers.ts b/apps/cli/src/legacy/commands/backups/backups.layers.ts index a13dbdae8..17c67c6fc 100644 --- a/apps/cli/src/legacy/commands/backups/backups.layers.ts +++ b/apps/cli/src/legacy/commands/backups/backups.layers.ts @@ -6,6 +6,7 @@ import { legacyPlatformApiLayer } from "../../auth/legacy-platform-api.layer.ts" import { legacyCliConfigLayer } from "../../config/legacy-cli-config.layer.ts"; import { legacyProjectRefLayer } from "../../config/legacy-project-ref.layer.ts"; import { legacyLinkedProjectCacheLayer } from "../../telemetry/legacy-linked-project-cache.layer.ts"; +import { legacyTelemetryStateLayer } from "../../telemetry/legacy-telemetry-state.layer.ts"; import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; // Shared platform-API stack used by every `backups` subcommand. `legacyHttpClientLayer` @@ -40,6 +41,7 @@ export function legacyBackupsRuntimeLayer(subcommand: ReadonlyArray) { Layer.provide(legacyCliConfigLayer), Layer.provide(legacyHttpClientLayer), ), + legacyTelemetryStateLayer, commandRuntimeLayer([...subcommand]), ); } diff --git a/apps/cli/src/legacy/commands/backups/list/list.handler.ts b/apps/cli/src/legacy/commands/backups/list/list.handler.ts index 1c2160e15..4a4a6e19a 100644 --- a/apps/cli/src/legacy/commands/backups/list/list.handler.ts +++ b/apps/cli/src/legacy/commands/backups/list/list.handler.ts @@ -4,6 +4,7 @@ import { Effect, Option } from "effect"; import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; import { LegacyOutputFlag } from "../../../../shared/legacy/global-flags.ts"; import { Output } from "../../../../shared/output/output.service.ts"; import { renderGlamourTable } from "../../../output/legacy-glamour-table.ts"; @@ -63,11 +64,13 @@ export const legacyBackupsList = Effect.fn("legacy.backups.list")(function* ( const api = yield* LegacyPlatformApi; const resolver = yield* LegacyProjectRefResolver; const linkedProjectCache = yield* LegacyLinkedProjectCache; + const telemetryState = yield* LegacyTelemetryState; const ref = yield* resolver.resolve(flags.projectRef); // Mirror Go's PersistentPostRun (`apps/cli-go/cmd/root.go:176`): write the - // linked-project cache whether the main API call succeeds or fails. + // linked-project cache and persist the telemetry state file whether the main + // API call succeeds or fails. yield* Effect.gen(function* () { // The fetching spinner is only meaningful in human-facing text mode — in JSON / stream-json // it would surface dangling `[task] start:` lines on stderr with no completion message. @@ -108,5 +111,5 @@ export const legacyBackupsList = Effect.fn("legacy.backups.list")(function* ( const table = response.backups.length > 0 ? renderLogicalTable(response) : renderPitrTable(response); yield* output.raw(table); - }).pipe(Effect.ensuring(linkedProjectCache.cache(ref))); + }).pipe(Effect.ensuring(linkedProjectCache.cache(ref)), Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/backups/list/list.integration.test.ts b/apps/cli/src/legacy/commands/backups/list/list.integration.test.ts index bda896c44..5ba057835 100644 --- a/apps/cli/src/legacy/commands/backups/list/list.integration.test.ts +++ b/apps/cli/src/legacy/commands/backups/list/list.integration.test.ts @@ -16,6 +16,7 @@ import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts" import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; import { legacyProjectRefLayer } from "../../../config/legacy-project-ref.layer.ts"; import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; import { LegacyOutputFlag } from "../../../../shared/legacy/global-flags.ts"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { mockOutput, mockTty } from "../../../../../tests/helpers/mocks.ts"; @@ -26,6 +27,8 @@ const mockLinkedProjectCacheLayer = Layer.succeed(LegacyLinkedProjectCache, { cache: () => Effect.void, }); +const mockTelemetryStateLayer = Layer.succeed(LegacyTelemetryState, { flush: Effect.void }); + // --------------------------------------------------------------------------- // Fixtures // --------------------------------------------------------------------------- @@ -178,6 +181,7 @@ function setup(opts: SetupOpts = {}) { BunServices.layer, Layer.succeed(LegacyOutputFlag, goOutputValue), mockLinkedProjectCacheLayer, + mockTelemetryStateLayer, ); return { layer, out, api, processCtl, tempRoot }; } @@ -376,6 +380,7 @@ describe("legacy backups list integration", () => { BunServices.layer, Layer.succeed(LegacyOutputFlag, Option.none()), mockLinkedProjectCacheLayer, + mockTelemetryStateLayer, ); return Effect.gen(function* () { @@ -416,6 +421,7 @@ describe("legacy backups list integration", () => { BunServices.layer, Layer.succeed(LegacyOutputFlag, Option.none()), mockLinkedProjectCacheLayer, + mockTelemetryStateLayer, ); return Effect.gen(function* () { diff --git a/apps/cli/src/legacy/commands/backups/restore/restore.handler.ts b/apps/cli/src/legacy/commands/backups/restore/restore.handler.ts index 50bf9d119..7ca658e72 100644 --- a/apps/cli/src/legacy/commands/backups/restore/restore.handler.ts +++ b/apps/cli/src/legacy/commands/backups/restore/restore.handler.ts @@ -3,6 +3,7 @@ import { Effect, Option } from "effect"; import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; import { LegacyOutputFlag } from "../../../../shared/legacy/global-flags.ts"; import { Output } from "../../../../shared/output/output.service.ts"; import { @@ -27,11 +28,13 @@ export const legacyBackupsRestore = Effect.fn("legacy.backups.restore")(function const api = yield* LegacyPlatformApi; const resolver = yield* LegacyProjectRefResolver; const linkedProjectCache = yield* LegacyLinkedProjectCache; + const telemetryState = yield* LegacyTelemetryState; const ref = yield* resolver.resolve(flags.projectRef); const recoveryTimeTargetUnix = Option.getOrElse(flags.timestamp, () => 0); - // Mirror Go's PersistentPostRun — cache writes whether the main call succeeds or fails. + // Mirror Go's PersistentPostRun — cache + telemetry flush whether the main + // call succeeds or fails. yield* Effect.gen(function* () { // Spinner only in human-facing text mode — see list.handler.ts. const restoring = @@ -63,5 +66,5 @@ export const legacyBackupsRestore = Effect.fn("legacy.backups.restore")(function // pretty/yaml/toml/env (Go-compat) + TS text mode → byte-identical text line on stderr. yield* output.raw(`Started PITR restore: ${ref}\n`, "stderr"); - }).pipe(Effect.ensuring(linkedProjectCache.cache(ref))); + }).pipe(Effect.ensuring(linkedProjectCache.cache(ref)), Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/backups/restore/restore.integration.test.ts b/apps/cli/src/legacy/commands/backups/restore/restore.integration.test.ts index 087df2525..2a1df38f6 100644 --- a/apps/cli/src/legacy/commands/backups/restore/restore.integration.test.ts +++ b/apps/cli/src/legacy/commands/backups/restore/restore.integration.test.ts @@ -16,6 +16,7 @@ import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts" import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; import { legacyProjectRefLayer } from "../../../config/legacy-project-ref.layer.ts"; import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; import { LegacyOutputFlag } from "../../../../shared/legacy/global-flags.ts"; import { mockOutput, mockProcessControl, mockTty } from "../../../../../tests/helpers/mocks.ts"; import { legacyBackupsRestore } from "./restore.handler.ts"; @@ -24,6 +25,8 @@ const mockLinkedProjectCacheLayer = Layer.succeed(LegacyLinkedProjectCache, { cache: () => Effect.void, }); +const mockTelemetryStateLayer = Layer.succeed(LegacyTelemetryState, { flush: Effect.void }); + const VALID_REF = "abcdefghijklmnopqrst"; const VALID_TOKEN = "sbp_" + "a".repeat(40); @@ -129,6 +132,7 @@ function setup(opts: SetupOpts = {}) { BunServices.layer, Layer.succeed(LegacyOutputFlag, goOutputValue), mockLinkedProjectCacheLayer, + mockTelemetryStateLayer, ); return { layer, out, api, tempRoot }; } @@ -304,6 +308,7 @@ describe("legacy backups restore integration", () => { BunServices.layer, Layer.succeed(LegacyOutputFlag, Option.none()), mockLinkedProjectCacheLayer, + mockTelemetryStateLayer, ); return Effect.gen(function* () { @@ -386,6 +391,7 @@ describe("legacy backups restore integration", () => { BunServices.layer, Layer.succeed(LegacyOutputFlag, Option.none()), mockLinkedProjectCacheLayer, + mockTelemetryStateLayer, ); return Effect.gen(function* () { diff --git a/apps/cli/src/legacy/telemetry/legacy-telemetry-state.layer.ts b/apps/cli/src/legacy/telemetry/legacy-telemetry-state.layer.ts new file mode 100644 index 000000000..057fbfba5 --- /dev/null +++ b/apps/cli/src/legacy/telemetry/legacy-telemetry-state.layer.ts @@ -0,0 +1,117 @@ +import { Effect, FileSystem, Layer, Path } from "effect"; +import { homedir } from "node:os"; + +import { LegacyTelemetryState } from "./legacy-telemetry-state.service.ts"; + +interface State { + readonly enabled: boolean; + readonly device_id: string; + readonly session_id: string; + readonly session_last_active: string; + readonly distinct_id?: string; + readonly schema_version: number; +} + +const SCHEMA_VERSION = 1; +const SESSION_ROTATION_MS = 30 * 60 * 1000; + +function telemetryPath(env: Record, pathSvc: Path.Path): string { + const supabaseHome = env["SUPABASE_HOME"]?.trim(); + if (supabaseHome !== undefined && supabaseHome.length > 0) { + return pathSvc.join(supabaseHome, "telemetry.json"); + } + return pathSvc.join(homedir(), ".supabase", "telemetry.json"); +} + +function isStringField(value: unknown, key: string): boolean { + if (typeof value !== "object" || value === null) return false; + const field = (value as Record)[key]; + return typeof field === "string" && field.length > 0; +} + +interface PriorState { + enabled?: boolean; + device_id?: string; + session_id?: string; + session_last_active?: string; + distinct_id?: string; +} + +function readExistingState(text: string): PriorState | undefined { + try { + const parsed = JSON.parse(text); + if (typeof parsed !== "object" || parsed === null) return undefined; + const record = parsed as Record; + const out: PriorState = {}; + if (typeof record.enabled === "boolean") out.enabled = record.enabled; + if (isStringField(parsed, "device_id")) out.device_id = record.device_id as string; + if (isStringField(parsed, "session_id")) out.session_id = record.session_id as string; + if (isStringField(parsed, "session_last_active")) { + out.session_last_active = record.session_last_active as string; + } + if (isStringField(parsed, "distinct_id")) out.distinct_id = record.distinct_id as string; + return out; + } catch { + return undefined; + } +} + +/** + * Writes `/telemetry.json` on every command run. + * Mirrors Go's `LoadOrCreateState` (`apps/cli-go/internal/telemetry/state.go:74-98`): + * + * - Reuses an existing `device_id` if the file is present. + * - Rotates `session_id` if `session_last_active` is older than 30 minutes. + * - Always sets `enabled: true` on a fresh state (matches Go — the field is + * only flipped to `false` if the user has run `supabase telemetry disable`, + * in which case the prior value is preserved). The + * `SUPABASE_TELEMETRY_DISABLED` / `DO_NOT_TRACK` env vars suppress event + * delivery, not state-file writes. + * - Always writes — Go persists the state file even when telemetry is + * disabled; only event delivery is suppressed. + * + * Best-effort: filesystem or JSON parse errors are swallowed. + */ +export const legacyTelemetryStateLayer = Layer.effect( + LegacyTelemetryState, + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const pathSvc = yield* Path.Path; + const env = process.env; + + return LegacyTelemetryState.of({ + flush: Effect.gen(function* () { + const filePath = telemetryPath(env, pathSvc); + + const existing = yield* fs.readFileString(filePath).pipe( + Effect.option, + Effect.map((opt) => (opt._tag === "Some" ? opt.value : undefined)), + ); + const prior = existing !== undefined ? readExistingState(existing) : undefined; + + const now = new Date(); + const nowIso = now.toISOString(); + + const priorActive = + prior?.session_last_active !== undefined + ? new Date(prior.session_last_active).getTime() + : 0; + const expired = + !Number.isFinite(priorActive) || now.getTime() - priorActive > SESSION_ROTATION_MS; + + const state: State = { + enabled: prior?.enabled ?? true, + device_id: prior?.device_id ?? crypto.randomUUID(), + session_id: + !expired && prior?.session_id !== undefined ? prior.session_id : crypto.randomUUID(), + session_last_active: nowIso, + ...(prior?.distinct_id !== undefined ? { distinct_id: prior.distinct_id } : {}), + schema_version: SCHEMA_VERSION, + }; + + yield* fs.makeDirectory(pathSvc.dirname(filePath), { recursive: true }); + yield* fs.writeFileString(filePath, JSON.stringify(state)); + }).pipe(Effect.ignore), + }); + }), +); diff --git a/apps/cli/src/legacy/telemetry/legacy-telemetry-state.service.ts b/apps/cli/src/legacy/telemetry/legacy-telemetry-state.service.ts new file mode 100644 index 000000000..c39e09448 --- /dev/null +++ b/apps/cli/src/legacy/telemetry/legacy-telemetry-state.service.ts @@ -0,0 +1,17 @@ +import type { Effect } from "effect"; +import { Context } from "effect"; + +interface LegacyTelemetryStateShape { + /** + * Persists the legacy telemetry state to disk (matches Go's + * `LoadOrCreateState` in `apps/cli-go/internal/telemetry/state.go:74-98`). + * + * Best-effort: any filesystem error is swallowed. + */ + readonly flush: Effect.Effect; +} + +export class LegacyTelemetryState extends Context.Service< + LegacyTelemetryState, + LegacyTelemetryStateShape +>()("supabase/legacy/TelemetryState") {} From e68baaf53b214135d7029fa372e7062bd426f667 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 22 May 2026 10:40:32 +0100 Subject: [PATCH 9/9] docs(cli): add legacy-port lessons to AGENTS.md and drop --help e2e smokes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captures the recurring Go-parity gotchas, hoisting policy, and helper-file shape that emerged from the backups port so the next agent doesn't relearn them from failing cli-e2e diffs. Also removes the two `backups list` / `backups restore` e2e files that were pure `--help` smoke tests — the exact pattern the workspace e2e policy forbids, now cited as the canonical "do not write" example. --- apps/cli/AGENTS.md | 66 +++++++++++++++++++ .../commands/backups/list/list.e2e.test.ts | 15 ----- .../backups/restore/restore.e2e.test.ts | 20 ------ 3 files changed, 66 insertions(+), 35 deletions(-) delete mode 100644 apps/cli/src/legacy/commands/backups/list/list.e2e.test.ts delete mode 100644 apps/cli/src/legacy/commands/backups/restore/restore.e2e.test.ts diff --git a/apps/cli/AGENTS.md b/apps/cli/AGENTS.md index e59fed8f5..cc5658b0a 100644 --- a/apps/cli/AGENTS.md +++ b/apps/cli/AGENTS.md @@ -88,6 +88,17 @@ Always check `src/shared/` before writing new infrastructure. Do not duplicate w | `shared/runtime/` | `Browser`, `Stdin`, `Tty`, `ProcessControl`, `RuntimeInfo` services + layers | | `shared/telemetry/` | `withCommandInstrumentation`, `Analytics`, tracing | +Also check the following `legacy/` infrastructure before writing equivalent helpers from scratch: + +| Path | What it provides | +| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `legacy/config/legacy-cli-config.layer.ts` | `LegacyCliConfig` — resolves `SUPABASE_PROFILE` (built-in name **or** YAML file path), `--workdir`, `--experimental`, project-id from `supabase/config.toml` | +| `legacy/config/legacy-project-ref.layer.ts` | `LegacyProjectRefResolver` — `--project-ref` flag → env → linked-project.json → config fallback chain; matches Go's resolver order | +| `legacy/telemetry/legacy-telemetry-state.layer.ts` | `LegacyTelemetryState.flush` — writes `~/.supabase/telemetry.json`, runs in every command's `Effect.ensuring` | +| `legacy/telemetry/legacy-linked-project-cache.layer.ts` | `LegacyLinkedProjectCache.cache(ref)` — writes `~/.supabase//linked-project.json` after `--project-ref` resolves; bypasses generated schema validation (uses raw HTTP client) | +| `legacy/auth/legacy-http-debug.layer.ts` | `legacyHttpClientLayer` — wraps the HTTP transport with a `--debug` stderr logger in Go's `log.LstdFlags` format | +| `legacy/output/legacy-glamour-table.ts` | `renderGlamourTable(headers, rows)` — byte-exact ASCII match for Go's `glamour.RenderTable(..., AsciiStyle)` | + --- ## Phase 0: Go Binary Wrapper @@ -139,6 +150,21 @@ src/legacy/commands// SIDE_EFFECTS.md # Required for every legacy command — see section below ``` +When a command grows beyond a single handler file, follow the optional helper-file shape that emerged from the backups port: + +``` +src/legacy/commands// + .command.ts # Effect CLI Command + flag wiring + layer provide + .handler.ts # native Effect handler + .errors.ts # Data.TaggedError types + .layers.ts # runtime layer composition for the command family + .format.ts # text formatters (timestamps, regions, booleans) + .encoders.ts # Go-compatible JSON / YAML / TOML / env encoders + SIDE_EFFECTS.md +``` + +The `.format.ts` and `.encoders.ts` files should be pure functions with no Effect or service dependencies — that keeps them unit-testable and makes Go-parity rules explicit (e.g. JSON key sort order, env-var SCREAMING_SNAKE_CASE flattening, empty arrays coerced to null). + Commands with subcommands use nested directories: ``` @@ -192,6 +218,27 @@ Many Management API commands in `next/commands/` have already been implemented. --- +## Legacy Port: Hoist Before You Duplicate + +Before writing handler code for a new port, scan the already-ported commands for overlapping logic. If two commands need the same helper (HTTP-error mapping, output encoder, formatter, runtime layer composition), hoist it instead of inlining a copy. + +Decision rule: + +- **Used by one command only** → keep it in the command's own directory (e.g. `backups/backups.errors.ts`). +- **Used by ≥2 commands in the same command family** → keep it in the family root (e.g. `backups/backups.encoders.ts` is shared by `list` and `restore`). +- **Used by ≥2 commands across families** → hoist to `src/legacy/shared/` (create the directory if it doesn't exist) and refactor the existing call sites in the same change. Do not leave the older command using its inlined copy while the new command uses the hoisted version. + +Concrete examples worth watching for as more commands land: + +- HTTP-error → tagged-error mapping (`backups.errors.ts:mapLegacyBackupHttpError`) — almost every Management API command will need this shape. +- Go-compatible JSON / YAML / TOML / env encoders (`backups.encoders.ts`) — the flag `--output {json,yaml,toml,env}` is supported by many Go subcommands. +- Glamour-table rendering helpers and column padding — currently in `legacy/output/legacy-glamour-table.ts`, already correctly hoisted. +- Timestamp / region / boolean formatters (`backups.format.ts`) — likely shared the moment a second command renders a backup/project/region field. + +This rule is consistent with the repo-wide **Refactoring Policy** ("delete obsolete helpers, shims, and parallel code paths as part of the refactor") — it just makes the policy concrete for the legacy-port workflow. + +--- + ## Legacy Port: Go CLI Output Parity The legacy shell is a **strict 1:1 port** — not a redesign. The compatibility contract covers: @@ -206,6 +253,24 @@ When in doubt about expected output or behavior, run the equivalent command agai --- +## Legacy Port: Go Parity Checklist + +When porting a Management-API-style command, verify each item before marking the command as `ported`: + +1. **Telemetry + linked-project writes run on every invocation** — Go uses `PersistentPostRun` (see `apps/cli-go/cmd/root.go:176`). Wrap the handler body in `.pipe(Effect.ensuring(linkedProjectCache.cache(ref)), Effect.ensuring(telemetryState.flush))` so both files are written on success **and** failure. See `backups/list/list.handler.ts:74-114` as the canonical pattern. + +2. **Errors go to stderr in text mode, byte-matching Go's template** — `Output.fail` now writes a frame-free message to stderr followed by the "Try rerunning the command with --debug to get more details." suggestion when `--debug` is unset. Don't reintroduce clack's `■ … │` frame. Reference: commits `ee041834`, `cf4f574b`. + +3. **`--debug` logs every HTTP request on stderr** — Format `"HTTP YYYY/MM/DD HH:MM:SS : \n"` (Go's `log.LstdFlags|log.Lmsgprefix`). Provided automatically by `legacyHttpClientLayer`; ensure that layer (not the raw `HttpClient.layer`) is what every legacy command's runtime composes. Reference: commit `39cfec20`. + +4. **`SUPABASE_PROFILE` is dual-mode** — accept either a built-in name (`supabase`, `supabase-staging`, `supabase-local`) **or** a filesystem path to a YAML file with `api_url:` / `gotrue_url:` / `db_url:` keys. cli-e2e harness relies on the file-path mode. Reference: commit `288c2937`. + +5. **`Layer.provide` does not share to siblings inside `Layer.mergeAll`** — if two sibling layers each require `LegacyCliConfig`, provide it to both explicitly. Smoke-test the bundled binary (`bun run build && ./dist/supabase-legacy …`) when changing production layer wiring; in-process tests don't always catch the missing-service panic. Reference: commit `a816b12e`, `backups.layers.ts:32-46`. + +6. **Both `--output` (Go) and `--output-format` (TS) must be honored** — Go's `--output` (`pretty|json|yaml|toml|env`) takes priority when set. Pattern in `backups/list/list.handler.ts:85-113`: branch on `goOutputFlag` first, then fall through to TS `--output-format` text/json/stream-json. + +--- + ## Legacy Port: File Location Compatibility The legacy shell bridges two worlds: it must behave exactly like the Go CLI for existing users, and it must lay the groundwork for a seamless upgrade to the next shell. @@ -311,6 +376,7 @@ Read https://www.effect.solutions/testing for Effect testing patterns. Note that - If a test needs multiple service replacements or `Layer.mergeAll(...)`, it likely belongs in `*.integration.test.ts`. - Prefer assertions on outputs and accumulated state over spy-heavy interaction tests. - Keep `*.e2e.test.ts` focused on golden paths, CLI surface behavior, and subprocess correctness, not branch-by-branch coverage. +- **Forbidden pattern (do not add):** spawning the CLI to assert that `--help` renders a flag. Help text is dynamic over flag wiring and is exercised by the integration test's flag parser. The two backups e2e files removed alongside this guidance update are the canonical example of what not to write. --- diff --git a/apps/cli/src/legacy/commands/backups/list/list.e2e.test.ts b/apps/cli/src/legacy/commands/backups/list/list.e2e.test.ts deleted file mode 100644 index 6f43c1d96..000000000 --- a/apps/cli/src/legacy/commands/backups/list/list.e2e.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { runSupabase } from "../../../../../tests/helpers/cli.ts"; - -const E2E_TIMEOUT_MS = 30_000; - -describe("supabase backups list (legacy)", () => { - test("exposes the --project-ref flag through --help", { timeout: E2E_TIMEOUT_MS }, async () => { - const { stdout, exitCode } = await runSupabase(["backups", "list", "--help"], { - entrypoint: "legacy", - }); - - expect(exitCode).toBe(0); - expect(stdout).toContain("--project-ref"); - }); -}); diff --git a/apps/cli/src/legacy/commands/backups/restore/restore.e2e.test.ts b/apps/cli/src/legacy/commands/backups/restore/restore.e2e.test.ts deleted file mode 100644 index a18ef54a0..000000000 --- a/apps/cli/src/legacy/commands/backups/restore/restore.e2e.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { runSupabase } from "../../../../../tests/helpers/cli.ts"; - -const E2E_TIMEOUT_MS = 30_000; - -describe("supabase backups restore (legacy)", () => { - test( - "exposes the --project-ref and --timestamp flags through --help", - { timeout: E2E_TIMEOUT_MS }, - async () => { - const { stdout, exitCode } = await runSupabase(["backups", "restore", "--help"], { - entrypoint: "legacy", - }); - - expect(exitCode).toBe(0); - expect(stdout).toContain("--project-ref"); - expect(stdout).toContain("--timestamp"); - }, - ); -});