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-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/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..17c67c6fc --- /dev/null +++ b/apps/cli/src/legacy/commands/backups/backups.layers.ts @@ -0,0 +1,47 @@ +import { Layer } from "effect"; + +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 { 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` +// 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(legacyHttpClientLayer), +); + +/** + * 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), + Layer.provide(legacyCliConfigLayer), + ), + legacyLinkedProjectCacheLayer.pipe( + Layer.provide(legacyCredentialsLayer), + Layer.provide(legacyCliConfigLayer), + Layer.provide(legacyHttpClientLayer), + ), + legacyTelemetryStateLayer, + 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..af7dc4817 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`. 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 -| 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..4a4a6e19a 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,115 @@ +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 { 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"; +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 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 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. + 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); + }).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 new file mode 100644 index 000000000..5ba057835 --- /dev/null +++ b/apps/cli/src/legacy/commands/backups/list/list.integration.test.ts @@ -0,0 +1,499 @@ +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 { 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"; +import { mockProcessControl } from "../../../../../tests/helpers/mocks.ts"; +import { legacyBackupsList } from "./list.handler.ts"; + +const mockLinkedProjectCacheLayer = Layer.succeed(LegacyLinkedProjectCache, { + cache: () => Effect.void, +}); + +const mockTelemetryStateLayer = Layer.succeed(LegacyTelemetryState, { flush: Effect.void }); + +// --------------------------------------------------------------------------- +// 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), + mockLinkedProjectCacheLayer, + mockTelemetryStateLayer, + ); + 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()), + mockLinkedProjectCacheLayer, + mockTelemetryStateLayer, + ); + + 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()), + mockLinkedProjectCacheLayer, + mockTelemetryStateLayer, + ); + + 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..60f93b970 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`. 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 -| 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..7ca658e72 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,70 @@ 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 { 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 { + 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 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 + 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 = + 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)), 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 new file mode 100644 index 000000000..2a1df38f6 --- /dev/null +++ b/apps/cli/src/legacy/commands/backups/restore/restore.integration.test.ts @@ -0,0 +1,418 @@ +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 { 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"; + +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); + +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), + mockLinkedProjectCacheLayer, + mockTelemetryStateLayer, + ); + 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()), + mockLinkedProjectCacheLayer, + mockTelemetryStateLayer, + ); + + 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()), + mockLinkedProjectCacheLayer, + mockTelemetryStateLayer, + ); + + 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..3046a1367 --- /dev/null +++ b/apps/cli/src/legacy/config/legacy-cli-config.layer.ts @@ -0,0 +1,154 @@ +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"; + +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" }, +}; + +function isBuiltinProfileName(value: string): value is LegacyProfileName { + return value in BUILTIN_PROFILES; +} + +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; + } +} + +/** + * 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( + 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 { name: profile, apiUrl } = yield* resolveProfile( + profileFlag, + env["SUPABASE_PROFILE"], + fs, + ); + + 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..7c311d8c5 --- /dev/null +++ b/apps/cli/src/legacy/config/legacy-cli-config.layer.unit.test.ts @@ -0,0 +1,205 @@ +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 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: profilePath }, 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..166edbcce --- /dev/null +++ b/apps/cli/src/legacy/config/legacy-cli-config.service.ts @@ -0,0 +1,24 @@ +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: string; + 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/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") {} 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") {} 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..1b16c86ed 100644 --- a/apps/cli/src/shared/output/output.layer.ts +++ b/apps/cli/src/shared/output/output.layer.ts @@ -330,15 +330,30 @@ 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))); - const detail = err.detail; - if (detail) { - yield* Effect.sync(() => log.message(styleText("gray", detail))); + 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") => + Effect.sync(() => { + if (stream === "stderr") { + process.stderr.write(text); + } else { + process.stdout.write(text); } }), }); @@ -408,6 +423,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 +437,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 +521,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.layer.unit.test.ts b/apps/cli/src/shared/output/output.layer.unit.test.ts index 0e2b7b18a..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,11 +183,72 @@ 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"); - 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/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/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`); 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) 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