diff --git a/apps/cli/docs/go-cli-porting-status.md b/apps/cli/docs/go-cli-porting-status.md index f5969bd199..2780e1c396 100644 --- a/apps/cli/docs/go-cli-porting-status.md +++ b/apps/cli/docs/go-cli-porting-status.md @@ -80,51 +80,51 @@ These commands exist in the TS CLI today but have no direct top-level equivalent ## Database -| Old command | TS status | TS command path or `missing` | Missing flags/params | Extra TS flags/params | Notes | -| --------------------------------- | --------- | -------------------------------------------------- | -------------------- | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `db diff` | `ported` | `legacy/commands/db/diff/` | `n/a` | `n/a` | Native TS port. Native pg-delta / migra diff via edge-runtime against a Go-seam-provisioned live shadow (`db __shadow`); `--use-pgadmin` / `--use-pg-schema` delegate to the Go binary. | -| `db dump` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | -| `db lint` | `ported` | `legacy/commands/db/lint/` | `n/a` | `n/a` | Native TS port. Runs `plpgsql_check` in a rolled-back transaction via LegacyDbConnection; emits Go-parity pretty JSON. | -| `db pull` | `ported` | `legacy/commands/db/pull/` | `n/a` | `n/a` | Native TS port. Native pg-delta / migra migration + `--declarative` pg-delta export; reconciles `schema_migrations`. `--experimental` dump + initial-pull `pg_dump` (migra) delegate to the Go binary. | -| `db push` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | -| `db reset` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | -| `db start` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | -| `inspect report` | `ported` | `legacy/commands/inspect/report/` | `n/a` | `n/a` | Native TS port. Runs every inspect query via server-side `COPY ... CSV`, writes 14 CSVs under `//`, then renders a Go-parity Glamour rules summary (bounded csvq-subset evaluator; custom `[experimental.inspect.rules]` supported). | -| `inspect db db-stats` | `ported` | `legacy/commands/inspect/db/db-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db replication-slots` | `ported` | `legacy/commands/inspect/db/replication-slots/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db locks` | `ported` | `legacy/commands/inspect/db/locks/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db blocking` | `ported` | `legacy/commands/inspect/db/blocking/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db outliers` | `ported` | `legacy/commands/inspect/db/outliers/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db calls` | `ported` | `legacy/commands/inspect/db/calls/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db index-stats` | `ported` | `legacy/commands/inspect/db/index-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db long-running-queries` | `ported` | `legacy/commands/inspect/db/long-running-queries/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db bloat` | `ported` | `legacy/commands/inspect/db/bloat/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db role-stats` | `ported` | `legacy/commands/inspect/db/role-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db vacuum-stats` | `ported` | `legacy/commands/inspect/db/vacuum-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db table-stats` | `ported` | `legacy/commands/inspect/db/table-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db traffic-profile` | `ported` | `legacy/commands/inspect/db/traffic-profile/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db cache-hit` | `ported` | `legacy/commands/inspect/db/cache-hit/` | `n/a` | `n/a` | Native TS port. Deprecated (use db-stats); routes to the active query. | -| `inspect db index-usage` | `ported` | `legacy/commands/inspect/db/index-usage/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | -| `inspect db total-index-size` | `ported` | `legacy/commands/inspect/db/total-index-size/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | -| `inspect db index-sizes` | `ported` | `legacy/commands/inspect/db/index-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | -| `inspect db table-sizes` | `ported` | `legacy/commands/inspect/db/table-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | -| `inspect db table-index-sizes` | `ported` | `legacy/commands/inspect/db/table-index-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | -| `inspect db total-table-sizes` | `ported` | `legacy/commands/inspect/db/total-table-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | -| `inspect db unused-indexes` | `ported` | `legacy/commands/inspect/db/unused-indexes/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | -| `inspect db table-record-counts` | `ported` | `legacy/commands/inspect/db/table-record-counts/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | -| `inspect db seq-scans` | `ported` | `legacy/commands/inspect/db/seq-scans/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | -| `inspect db role-configs` | `ported` | `legacy/commands/inspect/db/role-configs/` | `n/a` | `n/a` | Native TS port. Deprecated (use role-stats); routes to the active query. | -| `inspect db role-connections` | `ported` | `legacy/commands/inspect/db/role-connections/` | `n/a` | `n/a` | Native TS port. Deprecated (use role-stats); routes to the active query. | -| `migration down` | `ported` | `legacy/commands/migration/down/` | `n/a` | `n/a` | Native TS port. Revert prompt → drop user schemas → vault upsert → migrate&seed to the target version; defaults to `--local`. Skips Go's pgcache catalog write. | -| `migration fetch` | `ported` | `legacy/commands/migration/fetch/` | `n/a` | `n/a` | Native TS port. Reads `schema_migrations` and writes `supabase/migrations/_.sql`; overwrite prompt for a non-empty dir. | -| `migration list` | `ported` | `legacy/commands/migration/list/` | `n/a` | `n/a` | Native TS port. Merges remote `schema_migrations` with local files into a Glamour ASCII table (Local / Remote / Time-UTC columns); defaults to `--linked`. | -| `migration new` | `ported` | `legacy/commands/migration/new/` | `n/a` | `n/a` | Native TS port. Writes `supabase/migrations/_.sql` (mode 0644) from piped stdin; no DB/API. | -| `migration repair` | `ported` | `legacy/commands/migration/repair/` | `n/a` | `n/a` | Native TS port. Transactional create-table + TRUNCATE/UPSERT/DELETE; applied mode reads local files; repair-all prompt; defaults to `--linked`. | -| `migration squash` | `missing` | `missing` | `n/a` | `n/a` | Deliberate Go-proxy delegate (parity-preserving). A native port would emit pg-delta diff format instead of Go's `pg_dump` bytes (an accepted divergence, CLI-1597) and needs a bare-baseline shadow the seam does not yet expose; kept on the proxy for byte parity until CLI-1597's squash rewrite lands. | -| `migration up` | `ported` | `legacy/commands/migration/up/` | `n/a` | `n/a` | Native TS port. Computes pending migrations, upserts `[db.vault]`, applies each transactionally; `--include-all` for out-of-order; defaults to `--local`. Does not seed (matches Go). | -| `seed buckets` | `ported` | `legacy/commands/seed/buckets/` | `n/a` | `n/a` | Native TS port. Local-only (Go's `seed` defines no `--project-ref`, so the ref is always empty): seeds `[storage.buckets]` + `[storage.vector]` against the local Storage service gateway; remote/analytics paths are unreachable and omitted. `--linked`/`--local` accepted for surface parity (both seed local). Vector graceful-skip WARNINGs ported. | -| `test db` | `ported` | `legacy/commands/test/db/` | `n/a` | `n/a` | Native TS port. `--db-url`/`--local`/`--linked` + variadic paths; runs `supabase/pg_prove:3.36` via `docker run`; pgTAP enable/disable via `@effect/sql-pg`. `--network-id` override and `[images]` config override not modeled (documented divergences). | -| `test new` | `ported` | `legacy/commands/test/new/` | `n/a` | `n/a` | Native TS port. Writes `supabase/tests/_test.sql` from the embedded pgtap template; `--template` (pgtap). | +| Old command | TS status | TS command path or `missing` | Missing flags/params | Extra TS flags/params | Notes | +| --------------------------------- | --------- | -------------------------------------------------- | -------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `db diff` | `ported` | `legacy/commands/db/diff/` | `n/a` | `n/a` | Native TS port. Native pg-delta / migra diff via edge-runtime against a Go-seam-provisioned live shadow (`db __shadow`); `--use-pgadmin` / `--use-pg-schema` delegate to the Go binary. | +| `db dump` | `ported` | `legacy/commands/db/dump/` | `n/a` | `n/a` | Native TS port. Streams `pg_dump`/`pg_dumpall` via a Docker container (`LegacyDockerRun`); schema/data/role modes, `--dry-run` script print, IPv4 transaction-pooler fallback. | +| `db lint` | `ported` | `legacy/commands/db/lint/` | `n/a` | `n/a` | Native TS port. Runs `plpgsql_check` in a rolled-back transaction via LegacyDbConnection; emits Go-parity pretty JSON. | +| `db pull` | `ported` | `legacy/commands/db/pull/` | `n/a` | `n/a` | Native TS port. Native pg-delta / migra migration + `--declarative` pg-delta export; reconciles `schema_migrations`. The initial-migra pull dumps the remote schema natively (`pg_dump`) then appends the migra diff. Only `--experimental` (structured dump) still delegates to Go, pending a TS PostgreSQL DDL parser for `format.WriteStructuredSchemas`. | +| `db push` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | +| `db reset` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | +| `db start` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | +| `inspect report` | `ported` | `legacy/commands/inspect/report/` | `n/a` | `n/a` | Native TS port. Runs every inspect query via server-side `COPY ... CSV`, writes 14 CSVs under `//`, then renders a Go-parity Glamour rules summary (bounded csvq-subset evaluator; custom `[experimental.inspect.rules]` supported). | +| `inspect db db-stats` | `ported` | `legacy/commands/inspect/db/db-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db replication-slots` | `ported` | `legacy/commands/inspect/db/replication-slots/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db locks` | `ported` | `legacy/commands/inspect/db/locks/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db blocking` | `ported` | `legacy/commands/inspect/db/blocking/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db outliers` | `ported` | `legacy/commands/inspect/db/outliers/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db calls` | `ported` | `legacy/commands/inspect/db/calls/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db index-stats` | `ported` | `legacy/commands/inspect/db/index-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db long-running-queries` | `ported` | `legacy/commands/inspect/db/long-running-queries/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db bloat` | `ported` | `legacy/commands/inspect/db/bloat/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db role-stats` | `ported` | `legacy/commands/inspect/db/role-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db vacuum-stats` | `ported` | `legacy/commands/inspect/db/vacuum-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db table-stats` | `ported` | `legacy/commands/inspect/db/table-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db traffic-profile` | `ported` | `legacy/commands/inspect/db/traffic-profile/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db cache-hit` | `ported` | `legacy/commands/inspect/db/cache-hit/` | `n/a` | `n/a` | Native TS port. Deprecated (use db-stats); routes to the active query. | +| `inspect db index-usage` | `ported` | `legacy/commands/inspect/db/index-usage/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | +| `inspect db total-index-size` | `ported` | `legacy/commands/inspect/db/total-index-size/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | +| `inspect db index-sizes` | `ported` | `legacy/commands/inspect/db/index-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | +| `inspect db table-sizes` | `ported` | `legacy/commands/inspect/db/table-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | +| `inspect db table-index-sizes` | `ported` | `legacy/commands/inspect/db/table-index-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | +| `inspect db total-table-sizes` | `ported` | `legacy/commands/inspect/db/total-table-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | +| `inspect db unused-indexes` | `ported` | `legacy/commands/inspect/db/unused-indexes/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | +| `inspect db table-record-counts` | `ported` | `legacy/commands/inspect/db/table-record-counts/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | +| `inspect db seq-scans` | `ported` | `legacy/commands/inspect/db/seq-scans/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | +| `inspect db role-configs` | `ported` | `legacy/commands/inspect/db/role-configs/` | `n/a` | `n/a` | Native TS port. Deprecated (use role-stats); routes to the active query. | +| `inspect db role-connections` | `ported` | `legacy/commands/inspect/db/role-connections/` | `n/a` | `n/a` | Native TS port. Deprecated (use role-stats); routes to the active query. | +| `migration down` | `ported` | `legacy/commands/migration/down/` | `n/a` | `n/a` | Native TS port. Revert prompt → drop user schemas → vault upsert → migrate&seed to the target version; defaults to `--local`. Skips Go's pgcache catalog write. | +| `migration fetch` | `ported` | `legacy/commands/migration/fetch/` | `n/a` | `n/a` | Native TS port. Reads `schema_migrations` and writes `supabase/migrations/_.sql`; overwrite prompt for a non-empty dir. | +| `migration list` | `ported` | `legacy/commands/migration/list/` | `n/a` | `n/a` | Native TS port. Merges remote `schema_migrations` with local files into a Glamour ASCII table (Local / Remote / Time-UTC columns); defaults to `--linked`. | +| `migration new` | `ported` | `legacy/commands/migration/new/` | `n/a` | `n/a` | Native TS port. Writes `supabase/migrations/_.sql` (mode 0644) from piped stdin; no DB/API. | +| `migration repair` | `ported` | `legacy/commands/migration/repair/` | `n/a` | `n/a` | Native TS port. Transactional create-table + TRUNCATE/UPSERT/DELETE; applied mode reads local files; repair-all prompt; defaults to `--linked`. | +| `migration squash` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | +| `migration up` | `ported` | `legacy/commands/migration/up/` | `n/a` | `n/a` | Native TS port. Computes pending migrations, upserts `[db.vault]`, applies each transactionally; `--include-all` for out-of-order; defaults to `--local`. Does not seed (matches Go). | +| `seed buckets` | `ported` | `legacy/commands/seed/buckets/` | `n/a` | `n/a` | Native TS port. Local-only (Go's `seed` defines no `--project-ref`, so the ref is always empty): seeds `[storage.buckets]` + `[storage.vector]` against the local Storage service gateway; remote/analytics paths are unreachable and omitted. `--linked`/`--local` accepted for surface parity (both seed local). Vector graceful-skip WARNINGs ported. | +| `test db` | `ported` | `legacy/commands/test/db/` | `n/a` | `n/a` | Native TS port. `--db-url`/`--local`/`--linked` + variadic paths; runs `supabase/pg_prove:3.36` via `docker run`; pgTAP enable/disable via `@effect/sql-pg`. `--network-id` override and `[images]` config override not modeled (documented divergences). | +| `test new` | `ported` | `legacy/commands/test/new/` | `n/a` | `n/a` | Native TS port. Writes `supabase/tests/_test.sql` from the embedded pgtap template; `--template` (pgtap). | ## Code Generation @@ -211,111 +211,111 @@ 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` | `ported` | [`../src/legacy/commands/orgs/list/list.command.ts`](../src/legacy/commands/orgs/list/list.command.ts) | -| `orgs create` | `ported` | [`../src/legacy/commands/orgs/create/create.command.ts`](../src/legacy/commands/orgs/create/create.command.ts) | -| `projects list` | `ported` | [`../src/legacy/commands/projects/list/list.command.ts`](../src/legacy/commands/projects/list/list.command.ts) | -| `projects create` | `ported` | [`../src/legacy/commands/projects/create/create.command.ts`](../src/legacy/commands/projects/create/create.command.ts) | -| `projects delete` | `ported` | [`../src/legacy/commands/projects/delete/delete.command.ts`](../src/legacy/commands/projects/delete/delete.command.ts) | -| `projects api-keys` | `ported` | [`../src/legacy/commands/projects/api-keys/api-keys.command.ts`](../src/legacy/commands/projects/api-keys/api-keys.command.ts) | -| `branches list` | `ported` | [`../src/legacy/commands/branches/list/list.command.ts`](../src/legacy/commands/branches/list/list.command.ts) | -| `branches create` | `ported` | [`../src/legacy/commands/branches/create/create.command.ts`](../src/legacy/commands/branches/create/create.command.ts) | -| `branches get` | `ported` | [`../src/legacy/commands/branches/get/get.command.ts`](../src/legacy/commands/branches/get/get.command.ts) | -| `branches update` | `ported` | [`../src/legacy/commands/branches/update/update.command.ts`](../src/legacy/commands/branches/update/update.command.ts) | -| `branches pause` | `ported` | [`../src/legacy/commands/branches/pause/pause.command.ts`](../src/legacy/commands/branches/pause/pause.command.ts) | -| `branches unpause` | `ported` | [`../src/legacy/commands/branches/unpause/unpause.command.ts`](../src/legacy/commands/branches/unpause/unpause.command.ts) | -| `branches delete` | `ported` | [`../src/legacy/commands/branches/delete/delete.command.ts`](../src/legacy/commands/branches/delete/delete.command.ts) | -| `branches disable` | `ported` | [`../src/legacy/commands/branches/disable/disable.command.ts`](../src/legacy/commands/branches/disable/disable.command.ts) | -| `secrets list` | `ported` | [`../src/legacy/commands/secrets/list/list.command.ts`](../src/legacy/commands/secrets/list/list.command.ts) | -| `secrets set` | `ported` | [`../src/legacy/commands/secrets/set/set.command.ts`](../src/legacy/commands/secrets/set/set.command.ts) | -| `secrets unset` | `ported` | [`../src/legacy/commands/secrets/unset/unset.command.ts`](../src/legacy/commands/secrets/unset/unset.command.ts) | -| `config push` | `ported` | [`../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` | `ported` | [`../src/legacy/commands/snippets/list/list.command.ts`](../src/legacy/commands/snippets/list/list.command.ts) | -| `snippets download` | `ported` | [`../src/legacy/commands/snippets/download/download.command.ts`](../src/legacy/commands/snippets/download/download.command.ts) | -| `sso list` | `ported` | [`../src/legacy/commands/sso/list/list.command.ts`](../src/legacy/commands/sso/list/list.command.ts) | -| `sso add` | `ported` | [`../src/legacy/commands/sso/add/add.command.ts`](../src/legacy/commands/sso/add/add.command.ts) | -| `sso remove` | `ported` | [`../src/legacy/commands/sso/remove/remove.command.ts`](../src/legacy/commands/sso/remove/remove.command.ts) | -| `sso update` | `ported` | [`../src/legacy/commands/sso/update/update.command.ts`](../src/legacy/commands/sso/update/update.command.ts) | -| `sso show` | `ported` | [`../src/legacy/commands/sso/show/show.command.ts`](../src/legacy/commands/sso/show/show.command.ts) | -| `sso info` | `ported` | [`../src/legacy/commands/sso/info/info.command.ts`](../src/legacy/commands/sso/info/info.command.ts) | -| `domains create` | `ported` | [`../src/legacy/commands/domains/create/create.command.ts`](../src/legacy/commands/domains/create/create.command.ts) | -| `domains get` | `ported` | [`../src/legacy/commands/domains/get/get.command.ts`](../src/legacy/commands/domains/get/get.command.ts) | -| `domains reverify` | `ported` | [`../src/legacy/commands/domains/reverify/reverify.command.ts`](../src/legacy/commands/domains/reverify/reverify.command.ts) | -| `domains activate` | `ported` | [`../src/legacy/commands/domains/activate/activate.command.ts`](../src/legacy/commands/domains/activate/activate.command.ts) | -| `domains delete` | `ported` | [`../src/legacy/commands/domains/delete/delete.command.ts`](../src/legacy/commands/domains/delete/delete.command.ts) | -| `vanity-subdomains get` | `ported` | [`../src/legacy/commands/vanity-subdomains/get/get.command.ts`](../src/legacy/commands/vanity-subdomains/get/get.command.ts) | -| `vanity-subdomains check-availability` | `ported` | [`../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` | `ported` | [`../src/legacy/commands/vanity-subdomains/activate/activate.command.ts`](../src/legacy/commands/vanity-subdomains/activate/activate.command.ts) | -| `vanity-subdomains delete` | `ported` | [`../src/legacy/commands/vanity-subdomains/delete/delete.command.ts`](../src/legacy/commands/vanity-subdomains/delete/delete.command.ts) | -| `network-bans get` | `ported` | [`../src/legacy/commands/network-bans/get/get.command.ts`](../src/legacy/commands/network-bans/get/get.command.ts) | -| `network-bans remove` | `ported` | [`../src/legacy/commands/network-bans/remove/remove.command.ts`](../src/legacy/commands/network-bans/remove/remove.command.ts) | -| `network-restrictions get` | `ported` | [`../src/legacy/commands/network-restrictions/get/get.command.ts`](../src/legacy/commands/network-restrictions/get/get.command.ts) | -| `network-restrictions update` | `ported` | [`../src/legacy/commands/network-restrictions/update/update.command.ts`](../src/legacy/commands/network-restrictions/update/update.command.ts) | -| `encryption get-root-key` | `ported` | [`../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` | `ported` | [`../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` | `ported` | [`../src/legacy/commands/ssl-enforcement/get/get.command.ts`](../src/legacy/commands/ssl-enforcement/get/get.command.ts) | -| `ssl-enforcement update` | `ported` | [`../src/legacy/commands/ssl-enforcement/update/update.command.ts`](../src/legacy/commands/ssl-enforcement/update/update.command.ts) | -| `postgres-config get` | `ported` | [`../src/legacy/commands/postgres-config/get/get.command.ts`](../src/legacy/commands/postgres-config/get/get.command.ts) | -| `postgres-config update` | `ported` | [`../src/legacy/commands/postgres-config/update/update.command.ts`](../src/legacy/commands/postgres-config/update/update.command.ts) | -| `postgres-config delete` | `ported` | [`../src/legacy/commands/postgres-config/delete/delete.command.ts`](../src/legacy/commands/postgres-config/delete/delete.command.ts) | -| `login` | `ported` | [`../src/legacy/commands/login/login.command.ts`](../src/legacy/commands/login/login.command.ts) | -| `logout` | `ported` | [`../src/legacy/commands/logout/logout.command.ts`](../src/legacy/commands/logout/logout.command.ts) | -| `link` | `ported` | [`../src/legacy/commands/link/link.command.ts`](../src/legacy/commands/link/link.command.ts) | -| `unlink` | `ported` | [`../src/legacy/commands/unlink/unlink.command.ts`](../src/legacy/commands/unlink/unlink.command.ts) | -| `bootstrap` | `ported` | [`../src/legacy/commands/bootstrap/bootstrap.command.ts`](../src/legacy/commands/bootstrap/bootstrap.command.ts) (native; `db push` step delegated to the Go binary — interim) | -| `init` | `ported` | [`../src/legacy/commands/init/init.command.ts`](../src/legacy/commands/init/init.command.ts) | -| `services` | `ported` | [`../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) | -| `telemetry enable` | `ported` | [`../src/legacy/commands/telemetry/enable/enable.command.ts`](../src/legacy/commands/telemetry/enable/enable.command.ts) | -| `telemetry disable` | `ported` | [`../src/legacy/commands/telemetry/disable/disable.command.ts`](../src/legacy/commands/telemetry/disable/disable.command.ts) | -| `telemetry status` | `ported` | [`../src/legacy/commands/telemetry/status/status.command.ts`](../src/legacy/commands/telemetry/status/status.command.ts) | -| `migration list` | `ported` | [`../src/legacy/commands/migration/list/list.command.ts`](../src/legacy/commands/migration/list/list.command.ts) — native; merged Local/Remote/Time-UTC Glamour table | -| `migration new` | `ported` | [`../src/legacy/commands/migration/new/new.command.ts`](../src/legacy/commands/migration/new/new.command.ts) — native; writes `supabase/migrations/_.sql` from piped stdin | -| `migration repair` | `ported` | [`../src/legacy/commands/migration/repair/repair.command.ts`](../src/legacy/commands/migration/repair/repair.command.ts) — native; transactional TRUNCATE/UPSERT/DELETE, repair-all prompt | -| `migration squash` | `wrapped` | [`../src/legacy/commands/migration/squash/squash.command.ts`](../src/legacy/commands/migration/squash/squash.command.ts) — deliberate Go delegate for byte parity (native pg-delta squash deferred to CLI-1597) | -| `migration up` | `ported` | [`../src/legacy/commands/migration/up/up.command.ts`](../src/legacy/commands/migration/up/up.command.ts) — native; pending compute + vault upsert + per-file apply | -| `migration down` | `ported` | [`../src/legacy/commands/migration/down/down.command.ts`](../src/legacy/commands/migration/down/down.command.ts) — native; drop + vault + migrate&seed to target version | -| `migration fetch` | `ported` | [`../src/legacy/commands/migration/fetch/fetch.command.ts`](../src/legacy/commands/migration/fetch/fetch.command.ts) — native; writes history rows to `supabase/migrations/` | -| `gen types` | `ported` | [`../src/legacy/commands/gen/types/types.command.ts`](../src/legacy/commands/gen/types/types.command.ts) | -| `gen signing-key` | `ported` | [`../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` | `ported` | [`../src/legacy/commands/functions/delete/delete.command.ts`](../src/legacy/commands/functions/delete/delete.command.ts) | -| `functions download` | `ported` | [`../src/legacy/commands/functions/download/download.command.ts`](../src/legacy/commands/functions/download/download.command.ts) | -| `functions deploy` | `ported` | [`../src/legacy/commands/functions/deploy/deploy.command.ts`](../src/legacy/commands/functions/deploy/deploy.command.ts) | -| `functions new` | `ported` | [`../src/legacy/commands/functions/new/new.command.ts`](../src/legacy/commands/functions/new/new.command.ts) | -| `functions serve` | `ported` | [`../src/legacy/commands/functions/serve/serve.command.ts`](../src/legacy/commands/functions/serve/serve.command.ts) | -| `storage ls` | `ported` | [`../src/legacy/commands/storage/ls/ls.command.ts`](../src/legacy/commands/storage/ls/ls.command.ts) | -| `storage cp` | `ported` | [`../src/legacy/commands/storage/cp/cp.command.ts`](../src/legacy/commands/storage/cp/cp.command.ts) | -| `storage mv` | `ported` | [`../src/legacy/commands/storage/mv/mv.command.ts`](../src/legacy/commands/storage/mv/mv.command.ts) | -| `storage rm` | `ported` | [`../src/legacy/commands/storage/rm/rm.command.ts`](../src/legacy/commands/storage/rm/rm.command.ts) | -| `test db` | `ported` | [`../src/legacy/commands/test/db/db.command.ts`](../src/legacy/commands/test/db/db.command.ts) | -| `test new` | `ported` | [`../src/legacy/commands/test/new/new.command.ts`](../src/legacy/commands/test/new/new.command.ts) | -| `seed buckets` | `ported` | [`../src/legacy/commands/seed/buckets/buckets.command.ts`](../src/legacy/commands/seed/buckets/buckets.command.ts) | -| `db diff` | `ported` | [`../src/legacy/commands/db/diff/diff.command.ts`](../src/legacy/commands/db/diff/diff.command.ts) — native pg-delta / migra; `--use-pgadmin` / `--use-pg-schema` delegate to Go | -| `db dump` | `ported` | [`../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` | `ported` | [`../src/legacy/commands/db/pull/pull.command.ts`](../src/legacy/commands/db/pull/pull.command.ts) — native pg-delta / migra; `--declarative` (deprecated alias `--use-pg-delta`) + `--diff-engine` (migra\|pg-delta); `--experimental` / initial `pg_dump` delegate to Go | -| `db reset` | `wrapped` | [`../src/legacy/commands/db/reset/reset.command.ts`](../src/legacy/commands/db/reset/reset.command.ts) — includes Go-parity `--sql-paths` override for `[db.seed].sql_paths` | -| `db lint` | `ported` | [`../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` | `ported` | [`../src/legacy/commands/db/query/query.command.ts`](../src/legacy/commands/db/query/query.command.ts) | -| `db advisors` | `ported` | [`../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` | `ported` | [`../src/legacy/commands/db/schema/declarative/sync/sync.command.ts`](../src/legacy/commands/db/schema/declarative/sync/sync.command.ts) | -| `db schema declarative generate` | `ported` | [`../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` | `ported` | [`../src/legacy/commands/orgs/list/list.command.ts`](../src/legacy/commands/orgs/list/list.command.ts) | +| `orgs create` | `ported` | [`../src/legacy/commands/orgs/create/create.command.ts`](../src/legacy/commands/orgs/create/create.command.ts) | +| `projects list` | `ported` | [`../src/legacy/commands/projects/list/list.command.ts`](../src/legacy/commands/projects/list/list.command.ts) | +| `projects create` | `ported` | [`../src/legacy/commands/projects/create/create.command.ts`](../src/legacy/commands/projects/create/create.command.ts) | +| `projects delete` | `ported` | [`../src/legacy/commands/projects/delete/delete.command.ts`](../src/legacy/commands/projects/delete/delete.command.ts) | +| `projects api-keys` | `ported` | [`../src/legacy/commands/projects/api-keys/api-keys.command.ts`](../src/legacy/commands/projects/api-keys/api-keys.command.ts) | +| `branches list` | `ported` | [`../src/legacy/commands/branches/list/list.command.ts`](../src/legacy/commands/branches/list/list.command.ts) | +| `branches create` | `ported` | [`../src/legacy/commands/branches/create/create.command.ts`](../src/legacy/commands/branches/create/create.command.ts) | +| `branches get` | `ported` | [`../src/legacy/commands/branches/get/get.command.ts`](../src/legacy/commands/branches/get/get.command.ts) | +| `branches update` | `ported` | [`../src/legacy/commands/branches/update/update.command.ts`](../src/legacy/commands/branches/update/update.command.ts) | +| `branches pause` | `ported` | [`../src/legacy/commands/branches/pause/pause.command.ts`](../src/legacy/commands/branches/pause/pause.command.ts) | +| `branches unpause` | `ported` | [`../src/legacy/commands/branches/unpause/unpause.command.ts`](../src/legacy/commands/branches/unpause/unpause.command.ts) | +| `branches delete` | `ported` | [`../src/legacy/commands/branches/delete/delete.command.ts`](../src/legacy/commands/branches/delete/delete.command.ts) | +| `branches disable` | `ported` | [`../src/legacy/commands/branches/disable/disable.command.ts`](../src/legacy/commands/branches/disable/disable.command.ts) | +| `secrets list` | `ported` | [`../src/legacy/commands/secrets/list/list.command.ts`](../src/legacy/commands/secrets/list/list.command.ts) | +| `secrets set` | `ported` | [`../src/legacy/commands/secrets/set/set.command.ts`](../src/legacy/commands/secrets/set/set.command.ts) | +| `secrets unset` | `ported` | [`../src/legacy/commands/secrets/unset/unset.command.ts`](../src/legacy/commands/secrets/unset/unset.command.ts) | +| `config push` | `ported` | [`../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` | `ported` | [`../src/legacy/commands/snippets/list/list.command.ts`](../src/legacy/commands/snippets/list/list.command.ts) | +| `snippets download` | `ported` | [`../src/legacy/commands/snippets/download/download.command.ts`](../src/legacy/commands/snippets/download/download.command.ts) | +| `sso list` | `ported` | [`../src/legacy/commands/sso/list/list.command.ts`](../src/legacy/commands/sso/list/list.command.ts) | +| `sso add` | `ported` | [`../src/legacy/commands/sso/add/add.command.ts`](../src/legacy/commands/sso/add/add.command.ts) | +| `sso remove` | `ported` | [`../src/legacy/commands/sso/remove/remove.command.ts`](../src/legacy/commands/sso/remove/remove.command.ts) | +| `sso update` | `ported` | [`../src/legacy/commands/sso/update/update.command.ts`](../src/legacy/commands/sso/update/update.command.ts) | +| `sso show` | `ported` | [`../src/legacy/commands/sso/show/show.command.ts`](../src/legacy/commands/sso/show/show.command.ts) | +| `sso info` | `ported` | [`../src/legacy/commands/sso/info/info.command.ts`](../src/legacy/commands/sso/info/info.command.ts) | +| `domains create` | `ported` | [`../src/legacy/commands/domains/create/create.command.ts`](../src/legacy/commands/domains/create/create.command.ts) | +| `domains get` | `ported` | [`../src/legacy/commands/domains/get/get.command.ts`](../src/legacy/commands/domains/get/get.command.ts) | +| `domains reverify` | `ported` | [`../src/legacy/commands/domains/reverify/reverify.command.ts`](../src/legacy/commands/domains/reverify/reverify.command.ts) | +| `domains activate` | `ported` | [`../src/legacy/commands/domains/activate/activate.command.ts`](../src/legacy/commands/domains/activate/activate.command.ts) | +| `domains delete` | `ported` | [`../src/legacy/commands/domains/delete/delete.command.ts`](../src/legacy/commands/domains/delete/delete.command.ts) | +| `vanity-subdomains get` | `ported` | [`../src/legacy/commands/vanity-subdomains/get/get.command.ts`](../src/legacy/commands/vanity-subdomains/get/get.command.ts) | +| `vanity-subdomains check-availability` | `ported` | [`../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` | `ported` | [`../src/legacy/commands/vanity-subdomains/activate/activate.command.ts`](../src/legacy/commands/vanity-subdomains/activate/activate.command.ts) | +| `vanity-subdomains delete` | `ported` | [`../src/legacy/commands/vanity-subdomains/delete/delete.command.ts`](../src/legacy/commands/vanity-subdomains/delete/delete.command.ts) | +| `network-bans get` | `ported` | [`../src/legacy/commands/network-bans/get/get.command.ts`](../src/legacy/commands/network-bans/get/get.command.ts) | +| `network-bans remove` | `ported` | [`../src/legacy/commands/network-bans/remove/remove.command.ts`](../src/legacy/commands/network-bans/remove/remove.command.ts) | +| `network-restrictions get` | `ported` | [`../src/legacy/commands/network-restrictions/get/get.command.ts`](../src/legacy/commands/network-restrictions/get/get.command.ts) | +| `network-restrictions update` | `ported` | [`../src/legacy/commands/network-restrictions/update/update.command.ts`](../src/legacy/commands/network-restrictions/update/update.command.ts) | +| `encryption get-root-key` | `ported` | [`../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` | `ported` | [`../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` | `ported` | [`../src/legacy/commands/ssl-enforcement/get/get.command.ts`](../src/legacy/commands/ssl-enforcement/get/get.command.ts) | +| `ssl-enforcement update` | `ported` | [`../src/legacy/commands/ssl-enforcement/update/update.command.ts`](../src/legacy/commands/ssl-enforcement/update/update.command.ts) | +| `postgres-config get` | `ported` | [`../src/legacy/commands/postgres-config/get/get.command.ts`](../src/legacy/commands/postgres-config/get/get.command.ts) | +| `postgres-config update` | `ported` | [`../src/legacy/commands/postgres-config/update/update.command.ts`](../src/legacy/commands/postgres-config/update/update.command.ts) | +| `postgres-config delete` | `ported` | [`../src/legacy/commands/postgres-config/delete/delete.command.ts`](../src/legacy/commands/postgres-config/delete/delete.command.ts) | +| `login` | `ported` | [`../src/legacy/commands/login/login.command.ts`](../src/legacy/commands/login/login.command.ts) | +| `logout` | `ported` | [`../src/legacy/commands/logout/logout.command.ts`](../src/legacy/commands/logout/logout.command.ts) | +| `link` | `ported` | [`../src/legacy/commands/link/link.command.ts`](../src/legacy/commands/link/link.command.ts) | +| `unlink` | `ported` | [`../src/legacy/commands/unlink/unlink.command.ts`](../src/legacy/commands/unlink/unlink.command.ts) | +| `bootstrap` | `ported` | [`../src/legacy/commands/bootstrap/bootstrap.command.ts`](../src/legacy/commands/bootstrap/bootstrap.command.ts) (native; `db push` step delegated to the Go binary — interim) | +| `init` | `ported` | [`../src/legacy/commands/init/init.command.ts`](../src/legacy/commands/init/init.command.ts) | +| `services` | `ported` | [`../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) | +| `telemetry enable` | `ported` | [`../src/legacy/commands/telemetry/enable/enable.command.ts`](../src/legacy/commands/telemetry/enable/enable.command.ts) | +| `telemetry disable` | `ported` | [`../src/legacy/commands/telemetry/disable/disable.command.ts`](../src/legacy/commands/telemetry/disable/disable.command.ts) | +| `telemetry status` | `ported` | [`../src/legacy/commands/telemetry/status/status.command.ts`](../src/legacy/commands/telemetry/status/status.command.ts) | +| `migration list` | `ported` | [`../src/legacy/commands/migration/list/list.command.ts`](../src/legacy/commands/migration/list/list.command.ts) — native; merged Local/Remote/Time-UTC Glamour table | +| `migration new` | `ported` | [`../src/legacy/commands/migration/new/new.command.ts`](../src/legacy/commands/migration/new/new.command.ts) — native; writes `supabase/migrations/_.sql` from piped stdin | +| `migration repair` | `ported` | [`../src/legacy/commands/migration/repair/repair.command.ts`](../src/legacy/commands/migration/repair/repair.command.ts) — native; transactional TRUNCATE/UPSERT/DELETE, repair-all prompt | +| `migration squash` | `wrapped` | [`../src/legacy/commands/migration/squash/squash.command.ts`](../src/legacy/commands/migration/squash/squash.command.ts) | +| `migration up` | `ported` | [`../src/legacy/commands/migration/up/up.command.ts`](../src/legacy/commands/migration/up/up.command.ts) — native; pending compute + vault upsert + per-file apply | +| `migration down` | `ported` | [`../src/legacy/commands/migration/down/down.command.ts`](../src/legacy/commands/migration/down/down.command.ts) — native; drop + vault + migrate&seed to target version | +| `migration fetch` | `ported` | [`../src/legacy/commands/migration/fetch/fetch.command.ts`](../src/legacy/commands/migration/fetch/fetch.command.ts) — native; writes history rows to `supabase/migrations/` | +| `gen types` | `ported` | [`../src/legacy/commands/gen/types/types.command.ts`](../src/legacy/commands/gen/types/types.command.ts) | +| `gen signing-key` | `ported` | [`../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` | `ported` | [`../src/legacy/commands/functions/delete/delete.command.ts`](../src/legacy/commands/functions/delete/delete.command.ts) | +| `functions download` | `ported` | [`../src/legacy/commands/functions/download/download.command.ts`](../src/legacy/commands/functions/download/download.command.ts) | +| `functions deploy` | `ported` | [`../src/legacy/commands/functions/deploy/deploy.command.ts`](../src/legacy/commands/functions/deploy/deploy.command.ts) | +| `functions new` | `ported` | [`../src/legacy/commands/functions/new/new.command.ts`](../src/legacy/commands/functions/new/new.command.ts) | +| `functions serve` | `ported` | [`../src/legacy/commands/functions/serve/serve.command.ts`](../src/legacy/commands/functions/serve/serve.command.ts) | +| `storage ls` | `ported` | [`../src/legacy/commands/storage/ls/ls.command.ts`](../src/legacy/commands/storage/ls/ls.command.ts) | +| `storage cp` | `ported` | [`../src/legacy/commands/storage/cp/cp.command.ts`](../src/legacy/commands/storage/cp/cp.command.ts) | +| `storage mv` | `ported` | [`../src/legacy/commands/storage/mv/mv.command.ts`](../src/legacy/commands/storage/mv/mv.command.ts) | +| `storage rm` | `ported` | [`../src/legacy/commands/storage/rm/rm.command.ts`](../src/legacy/commands/storage/rm/rm.command.ts) | +| `test db` | `ported` | [`../src/legacy/commands/test/db/db.command.ts`](../src/legacy/commands/test/db/db.command.ts) | +| `test new` | `ported` | [`../src/legacy/commands/test/new/new.command.ts`](../src/legacy/commands/test/new/new.command.ts) | +| `seed buckets` | `ported` | [`../src/legacy/commands/seed/buckets/buckets.command.ts`](../src/legacy/commands/seed/buckets/buckets.command.ts) | +| `db diff` | `ported` | [`../src/legacy/commands/db/diff/diff.command.ts`](../src/legacy/commands/db/diff/diff.command.ts) — native pg-delta / migra; `--use-pgadmin` / `--use-pg-schema` delegate to Go | +| `db dump` | `ported` | [`../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` | `ported` | [`../src/legacy/commands/db/pull/pull.command.ts`](../src/legacy/commands/db/pull/pull.command.ts) — native pg-delta / migra; `--declarative` (deprecated alias `--use-pg-delta`) + `--diff-engine` (migra\|pg-delta); initial-migra pull dumps the schema natively (`pg_dump`) + appends the diff; only `--experimental` structured dump still delegates to Go (needs a TS DDL parser for `WriteStructuredSchemas`) | +| `db reset` | `wrapped` | [`../src/legacy/commands/db/reset/reset.command.ts`](../src/legacy/commands/db/reset/reset.command.ts) — includes Go-parity `--sql-paths` override for `[db.seed].sql_paths` | +| `db lint` | `ported` | [`../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` | `ported` | [`../src/legacy/commands/db/query/query.command.ts`](../src/legacy/commands/db/query/query.command.ts) | +| `db advisors` | `ported` | [`../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` | `ported` | [`../src/legacy/commands/db/schema/declarative/sync/sync.command.ts`](../src/legacy/commands/db/schema/declarative/sync/sync.command.ts) | +| `db schema declarative generate` | `ported` | [`../src/legacy/commands/db/schema/declarative/generate/generate.command.ts`](../src/legacy/commands/db/schema/declarative/generate/generate.command.ts) | Flag divergences from the Go reference: diff --git a/apps/cli/src/legacy/commands/config/push/push.command.ts b/apps/cli/src/legacy/commands/config/push/push.command.ts index 744b143201..25ed8446fd 100644 --- a/apps/cli/src/legacy/commands/config/push/push.command.ts +++ b/apps/cli/src/legacy/commands/config/push/push.command.ts @@ -1,8 +1,10 @@ +import { Layer } from "effect"; import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { legacyManagementApiRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts"; +import { legacyPromptInputRuntimeLayer } from "../../../shared/legacy-prompt-input.layer.ts"; import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; import { legacyConfigPush } from "./push.handler.ts"; @@ -34,5 +36,10 @@ export const legacyConfigPushCommand = Command.make("push", config).pipe( withJsonErrorHandling, ), ), - Command.provide(legacyManagementApiRuntimeLayer(["config", "push"])), + Command.provide( + Layer.mergeAll( + legacyManagementApiRuntimeLayer(["config", "push"]), + legacyPromptInputRuntimeLayer, + ), + ), ); diff --git a/apps/cli/src/legacy/commands/config/push/push.handler.ts b/apps/cli/src/legacy/commands/config/push/push.handler.ts index 7624c2c23e..01bfd0fd76 100644 --- a/apps/cli/src/legacy/commands/config/push/push.handler.ts +++ b/apps/cli/src/legacy/commands/config/push/push.handler.ts @@ -1,14 +1,16 @@ import { loadProjectConfig } from "@supabase/config"; -import { Effect } from "effect"; +import { Effect, FileSystem, Path } from "effect"; import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; -import { legacyResolveYes } from "../../../../shared/legacy/global-flags.ts"; +import { legacyResolveYesWithProjectEnv } from "../../../../shared/legacy/global-flags.ts"; import { Output } from "../../../../shared/output/output.service.ts"; import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; +import { legacyLoadProjectEnv } from "../../../shared/legacy-db-config.toml-read.ts"; import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; +import { legacyPromptYesNo } from "../../../shared/legacy-prompt-yes-no.ts"; import { apiSubsetFromConfig, apiToUpdateBody, diffApiWithRemote } from "./config-sync/api.sync.ts"; import { applyRemoteAuthConfig, @@ -87,8 +89,15 @@ export const legacyConfigPush = Effect.fn("legacy.config.push")(function* ( const linkedProjectCache = yield* LegacyLinkedProjectCache; const telemetryState = yield* LegacyTelemetryState; const runtimeInfo = yield* RuntimeInfo; - // `--yes` OR `SUPABASE_YES` (Go's viper AutomaticEnv, root.go:318-320). - const yes = yield* legacyResolveYes; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + // `--yes` OR `SUPABASE_YES` (Go's viper AutomaticEnv, root.go:318-320). Go's + // `config push` runs `flags.LoadConfig`, which imports `supabase/.env` before + // `PromptYesNo` reads `viper.GetBool("YES")`, so a `SUPABASE_YES` set only in + // `supabase/.env` auto-confirms. Resolve against the project env, not just the + // flag + shell env. + const projectEnv = yield* legacyLoadProjectEnv(fs, path, runtimeInfo.cwd); + const yes = yield* legacyResolveYesWithProjectEnv(projectEnv); const ref = yield* resolver.resolve(flags.projectRef); @@ -154,24 +163,17 @@ export const legacyConfigPush = Effect.fn("legacy.config.push")(function* ( yield* output.raw(`Pushing config to project: ${projectId}\n`, "stderr"); - // keep(name): Go push.go `keep` + console.PromptYesNo(title, true). - const keep = (name: string): Effect.Effect => + // keep(name): Go push.go `keep` + console.PromptYesNo(title, true). The shared + // helper mirrors Go's prompt across all modes, including scanning piped stdin on + // a non-TTY before falling back to the default (`console.go:64-82`). + const keep = (name: string) => Effect.gen(function* () { const item = cost.get(name); const title = item === undefined ? `Do you want to push ${name} config to remote?` : `Enabling ${item.name} will cost you ${item.price}. Keep it enabled?`; - if (output.format !== "text") { - return true; - } - if (yes) { - yield* output.raw(`${title} [Y/n] y\n`, "stderr"); - return true; - } - return yield* output - .promptConfirm(title, { defaultValue: true }) - .pipe(Effect.orElseSucceed(() => true)); + return yield* legacyPromptYesNo(output, yes, title, true); }); const services: Array = []; diff --git a/apps/cli/src/legacy/commands/config/push/push.integration.test.ts b/apps/cli/src/legacy/commands/config/push/push.integration.test.ts index 0cc0ffe678..7e32117764 100644 --- a/apps/cli/src/legacy/commands/config/push/push.integration.test.ts +++ b/apps/cli/src/legacy/commands/config/push/push.integration.test.ts @@ -17,7 +17,8 @@ import { mockLegacyTelemetryStateTracked, useLegacyTempWorkdir, } from "../../../../../tests/helpers/legacy-mocks.ts"; -import { mockRuntimeInfo } from "../../../../../tests/helpers/mocks.ts"; +import { mockRuntimeInfo, mockTty } from "../../../../../tests/helpers/mocks.ts"; +import { mockLegacyPromptInput } from "../../../../../tests/helpers/legacy-prompt-input.ts"; import { LegacyYesFlag } from "../../../../shared/legacy/global-flags.ts"; import { legacyConfigPush } from "./push.handler.ts"; @@ -57,6 +58,10 @@ function setup(opts: { readonly yes?: boolean; readonly confirm?: ReadonlyArray; readonly promptFail?: boolean; + /** stdin interactivity; defaults to a TTY so prompt-driven tests reach the confirm. */ + readonly stdinIsTty?: boolean; + /** Piped (non-TTY) stdin answers, one consumed per confirmation prompt. */ + readonly pipedAnswers?: ReadonlyArray; }) { writeConfig(opts.toml); const routes = opts.routes ?? {}; @@ -114,7 +119,9 @@ function setup(opts: { runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), telemetry: telemetry.layer, linkedProjectCache: linkedProjectCache.layer, + tty: mockTty({ stdinIsTty: opts.stdinIsTty ?? true, stdoutIsTty: false }), }), + mockLegacyPromptInput({ pipedLines: opts.pipedAnswers }), Layer.succeed(LegacyYesFlag, opts.yes ?? false), ); return { layer, out, api, telemetry, linkedProjectCache }; @@ -296,10 +303,13 @@ project_id = "abcdefghijklmnopqrst" }).pipe(Effect.provide(layer)); }); - it.live("defaults to yes in non-TTY text without --yes", () => { - const { layer, api } = setup({ + it.live("defaults to yes on empty non-TTY stdin, echoing the prompt", () => { + // Go's `PromptYesNo(..., true)` (`push.go:36`) prints the label and scans + // stdin even on a non-terminal (`console.go:96-102`); with no piped input the + // scan is empty and it falls back to the default (`true`), so the push proceeds. + const { layer, api, out } = setup({ toml: API_ONLY_TOML, - promptFail: true, + stdinIsTty: false, routes: { postgrestGet: { status: 200, body: POSTGREST_DISABLED }, postgresGet: { status: 200, body: {} }, @@ -310,9 +320,66 @@ project_id = "abcdefghijklmnopqrst" expect(api.requests.some((r) => r.method === "PATCH" && r.url.includes("/postgrest"))).toBe( true, ); + // Label printed + empty answer echoed (Go's non-TTY `PromptText`). + expect(out.stderrText).toContain("Do you want to push api config to remote? [Y/n] \n"); + }).pipe(Effect.provide(layer)); + }); + + it.live("honors a piped 'n' decline on non-TTY stdin (no update)", () => { + // Regression: Go scans piped stdin before defaulting (`console.go:74-82`), so a + // piped `n` cancels the push even on a non-terminal — it must not silently apply. + const { layer, api, out } = setup({ + toml: API_ONLY_TOML, + stdinIsTty: false, + pipedAnswers: ["n"], + routes: { + postgrestGet: { status: 200, body: POSTGREST_DISABLED }, + postgresGet: { status: 200, body: {} }, + }, + }); + return Effect.gen(function* () { + yield* legacyConfigPush({ projectRef: Option.none() }); + expect(api.requests.some((r) => r.method === "PATCH" && r.url.includes("/postgrest"))).toBe( + false, + ); + // The consumed answer is echoed to stderr (Go's non-TTY `PromptText`). + expect(out.stderrText).toContain("Do you want to push api config to remote? [Y/n] n"); }).pipe(Effect.provide(layer)); }); + it.live("honors SUPABASE_YES from supabase/.env even against a piped 'n'", () => { + // Go's config push runs `flags.LoadConfig`, importing `supabase/.env` before + // `PromptYesNo`, so a project-local `SUPABASE_YES=true` auto-confirms before + // stdin is read — the push proceeds despite the piped `n`. + const prev = process.env["SUPABASE_YES"]; + delete process.env["SUPABASE_YES"]; + const { layer, api } = setup({ + toml: API_ONLY_TOML, + stdinIsTty: false, + pipedAnswers: ["n"], + routes: { + postgrestGet: { status: 200, body: POSTGREST_DISABLED }, + postgresGet: { status: 200, body: {} }, + }, + }); + // Written after setup()'s writeConfig created supabase/. + writeFileSync(join(tempRoot.current, "supabase", ".env"), "SUPABASE_YES=true\n"); + return Effect.gen(function* () { + yield* legacyConfigPush({ projectRef: Option.none() }); + expect(api.requests.some((r) => r.method === "PATCH" && r.url.includes("/postgrest"))).toBe( + true, + ); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (prev === undefined) delete process.env["SUPABASE_YES"]; + else process.env["SUPABASE_YES"] = prev; + }), + ), + Effect.provide(layer), + ); + }); + it.live("emits a structured summary in json mode without prompts", () => { const { layer, out } = setup({ toml: API_ONLY_TOML, @@ -394,6 +461,7 @@ file_size_limit = "50MiB" cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), }), + mockLegacyPromptInput(), Layer.succeed(LegacyYesFlag, true), ); return Effect.gen(function* () { @@ -462,7 +530,10 @@ function setupService(opts: { runtimeInfo: mockRuntimeInfo({ cwd: opts.runtimeCwd ?? tempRoot.current }), telemetry: telemetry.layer, linkedProjectCache: linkedProjectCache.layer, + // Gated-service prompts model an interactive user answering via `confirm`. + tty: mockTty({ stdinIsTty: true, stdoutIsTty: false }), }), + mockLegacyPromptInput(), Layer.succeed(LegacyYesFlag, opts.yes ?? false), ); return { layer, out, apiMock }; diff --git a/apps/cli/src/legacy/commands/db/dump/dump.handler.ts b/apps/cli/src/legacy/commands/db/dump/dump.handler.ts index 0a7ae2202b..323bfafd90 100644 --- a/apps/cli/src/legacy/commands/db/dump/dump.handler.ts +++ b/apps/cli/src/legacy/commands/db/dump/dump.handler.ts @@ -8,19 +8,13 @@ import type { LegacyDbConnType } from "../../../shared/legacy-db-target-flags.ts import { legacyReadDbToml } from "../../../shared/legacy-db-config.toml-read.ts"; import { legacyReadProjectRefFile } from "../../../shared/legacy-temp-paths.ts"; import { legacyResolveDbImage } from "../../../shared/legacy-db-image.ts"; -import { LegacyDockerRun } from "../../../shared/legacy-docker-run.service.ts"; -import { legacyGetRegistryImageUrl } from "../../../shared/legacy-docker-registry.ts"; import { legacyIpv6Suggestion, legacyIsIPv6ConnectivityError, } from "../../../shared/legacy-connect-errors.ts"; import { legacyBold, legacyYellow } from "../../../shared/legacy-colors.ts"; -import { - LegacyDnsResolverFlag, - LegacyNetworkIdFlag, -} from "../../../../shared/legacy/global-flags.ts"; +import { LegacyDnsResolverFlag } from "../../../../shared/legacy/global-flags.ts"; import { Output } from "../../../../shared/output/output.service.ts"; -import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; import type { LegacyDbDumpFlags } from "./dump.command.ts"; import { LegacyDbDumpMutuallyExclusiveFlagsError, @@ -33,12 +27,13 @@ import { legacyBuildRoleDumpEnv, legacyBuildSchemaDumpEnv, legacyExpandScript, -} from "./dump.env.ts"; +} from "../shared/legacy-pg-dump.env.ts"; +import { legacyStreamPgDump } from "../shared/legacy-pg-dump.run.ts"; import { legacyDumpDataScript, legacyDumpRoleScript, legacyDumpSchemaScript, -} from "./dump.scripts.ts"; +} from "../shared/legacy-pg-dump.scripts.ts"; /** * Mutually-exclusive flag groups, in cobra's check order (it sorts the joined @@ -62,15 +57,12 @@ const toOpenFileError = (cause: { readonly message: string }) => export const legacyDbDump = Effect.fn("legacy.db.dump")(function* (flags: LegacyDbDumpFlags) { const output = yield* Output; const resolver = yield* LegacyDbConfigResolver; - const docker = yield* LegacyDockerRun; const cliConfig = yield* LegacyCliConfig; - const runtimeInfo = yield* RuntimeInfo; const telemetryState = yield* LegacyTelemetryState; const linkedProjectCache = yield* LegacyLinkedProjectCache; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const dnsResolver = yield* LegacyDnsResolverFlag; - const networkIdFlag = yield* LegacyNetworkIdFlag; // Resolved linked ref, captured so the post-run finalizer can cache the project // (GET /v1/projects/{ref}) AFTER the command's own API calls — matching Go's @@ -190,7 +182,7 @@ export const legacyDbDump = Effect.fn("legacy.db.dump")(function* (flags: Legacy // remote `project_id`) fails rather than silently printing a script. const tomlValues = yield* legacyReadDbToml(fs, path, cliConfig.workdir, linkedRef); - // 4. Pick the mode-specific script + env (pure builders, `dump.env.ts`). + // 4. Pick the mode-specific script + env (pure builders, `legacy-pg-dump.env.ts`). // Go declares --schema/-s and --exclude/-x as cobra StringSlice // (`apps/cli-go/cmd/db.go:432,444`); both flags are CSV-parsed at the flag // level via `legacyParseSchemaFlags` (pflag `readAsCSV` semantics, quoted @@ -267,33 +259,14 @@ export const legacyDbDump = Effect.fn("legacy.db.dump")(function* (flags: Legacy // 6. Diagnostic to stderr (Go writes this for both real and dry-run paths). yield* output.raw(`Dumping ${mode.verb} from ${db} database...\n`, "stderr"); - // 7. Run the pg_dump container, capturing stdout. dump always uses host - // networking (`dockerExec` sets `NetworkMode: NetworkHost`), overridden only - // by `--network-id` (Go's `DockerStart`). No `SecurityOpt` is set. - const networkId = Option.getOrUndefined(networkIdFlag); - const network = - networkId !== undefined && networkId.length > 0 - ? { _tag: "named" as const, name: networkId } - : { _tag: "host" as const }; - const extraHosts = - runtimeInfo.platform === "linux" ? ["host.docker.internal:host-gateway"] : []; - - const dockerOpts = (env: Readonly>) => ({ - image: legacyGetRegistryImageUrl(image), - cmd: ["bash", "-c", mode.script, "--"], - env, - binds: [], - workingDir: Option.none(), - securityOpt: [], - extraHosts, - network, - }); - + // 7. Run the pg_dump container, streaming stdout. `legacyStreamPgDump` applies + // the registry mirror + host networking (overridden by `--network-id`) and + // tees stderr, mirroring Go's `dockerExec` (`internal/db/dump/dump.go`). + // // Go streams pg_dump stdout straight to the destination sink (the `--file` handle // or `os.Stdout`) via `stdcopy.StdCopy` with `Follow:true`, at constant memory // (`apps/cli-go/internal/utils/docker.go:374,394`). Mirror that: write each chunk - // to the destination as it arrives instead of buffering the whole dump. stderr is - // teed live (Go's `io.MultiWriter(os.Stderr, errBuf)`). + // to the destination as it arrives instead of buffering the whole dump. const runContainer = (env: Readonly>) => Option.isSome(resolvedFile) ? // `--file`: (re)truncate then append-stream. Truncating per attempt @@ -309,10 +282,12 @@ export const legacyDbDump = Effect.fn("legacy.db.dump")(function* (flags: Legacy const file = yield* fs .open(resolvedFile.value, { flag: "a" }) .pipe(Effect.mapError(toOpenFileError)); - return yield* docker.runStream(dockerOpts(env), { + return yield* legacyStreamPgDump({ + image, + script: mode.script, + env, onStdout: (chunk) => file.writeAll(chunk).pipe(Effect.mapError(toOpenFileError)), - teeStderr: true, }); }), ), @@ -321,9 +296,11 @@ export const legacyDbDump = Effect.fn("legacy.db.dump")(function* (flags: Legacy : // stdout: write each chunk straight to stdout (binary-safe, no decode). // On a pooler retry Go leaves the partial first-attempt bytes on stdout // (its `resetOutput` can't rewind a pipe); streaming matches that. - docker.runStream(dockerOpts(env), { + legacyStreamPgDump({ + image, + script: mode.script, + env, onStdout: (chunk) => output.rawBytes(chunk), - teeStderr: true, }); let result = yield* runContainer(modeEnv); diff --git a/apps/cli/src/legacy/commands/db/dump/dump.live.test.ts b/apps/cli/src/legacy/commands/db/dump/dump.live.test.ts new file mode 100644 index 0000000000..6577c268b5 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/dump/dump.live.test.ts @@ -0,0 +1,48 @@ +import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; + +import { + describeLiveDataPlane, + requireLiveProjectRef, + runSupabaseLive, +} from "../../../../../tests/helpers/live.ts"; + +const LIVE_TIMEOUT_MS = 300_000; + +// A fresh, isolated temp workdir so the CLI writes the dump there and never touches +// the repo tree. The provisioned project ref is supplied to `--linked` via the +// `SUPABASE_PROJECT_ID` env var — that is the `--linked` resolver chain in both Go +// and the legacy port (flag → `SUPABASE_PROJECT_ID` → `supabase/.temp/project-ref`); +// `config.toml`'s `project_id` is NOT consulted for `--linked`. +function tempWorkdir(): string { + return mkdtempSync(join(tmpdir(), "sb-db-dump-live-")); +} + +// Data-plane: needs a provisioned project whose database is routable (the +// cli-e2e-ci Linux runner). `describeLiveDataPlane` runs this only when the project +// instance is ACTIVE_HEALTHY, so a control-plane-only stack (ref set but the DB +// unreachable, e.g. local macOS or the current cli-e2e-ci control-plane case) is +// skipped rather than timing out on pg_dump. +describeLiveDataPlane("supabase db dump (live)", () => { + test("dumps the linked project's schema to a file", { timeout: LIVE_TIMEOUT_MS }, async () => { + const ref = requireLiveProjectRef(); + const dir = tempWorkdir(); + try { + const outFile = join(dir, "schema.sql"); + const { exitCode, stdout, stderr } = await runSupabaseLive( + ["db", "dump", "--linked", "-f", outFile], + { cwd: dir, env: { SUPABASE_PROJECT_ID: ref }, exitTimeoutMs: LIVE_TIMEOUT_MS - 20_000 }, + ); + expect(`${stdout}${stderr}`).not.toContain("Unauthorized"); + expect(exitCode).toBe(0); + // The native pg_dump container (shared `legacyStreamPgDump`) opened + wrote + // the dump file. A fresh project's public schema may be near-empty, so assert + // the file was created rather than its size. + expect(existsSync(outFile)).toBe(true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/cli/src/legacy/commands/db/pull/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/pull/SIDE_EFFECTS.md index c8ae8ac6e9..6ca74fd5f5 100644 --- a/apps/cli/src/legacy/commands/db/pull/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/pull/SIDE_EFFECTS.md @@ -2,9 +2,12 @@ Native Effect port. Pulls the remote schema into either a new timestamped migration (diffing a throwaway shadow against the remote, native pg-delta or -migra) or declarative files (`--declarative`, native pg-delta export). The rare -`--experimental` structured-dump and initial-pull `pg_dump` (migra) sub-branches -delegate to the bundled Go binary. +migra) or declarative files (`--declarative`, native pg-delta export). The +initial-migra pull (no local migrations) seeds the migration file with a native +`pg_dump` of the remote schema (a Docker `pg_dump` container, with IPv4 +transaction-pooler fallback) and then appends the migra diff. Only the rare +`--experimental` structured-dump sub-branch still delegates to the bundled Go +binary (it needs `format.WriteStructuredSchemas`, which has no TS port yet). ## Files Read @@ -17,18 +20,20 @@ delegate to the bundled Go binary. ## Files Written -| Path | Format | When | -| ----------------------------------------------------------- | ------ | ------------------------------------- | -| `/supabase/migrations/_.sql` | SQL | migration-style pull (non-empty diff) | -| `/supabase/database/**` | SQL | `--declarative` | -| `~/.supabase//linked-project.json` | JSON | linked (post-run cache) | -| `~/.supabase/telemetry.json` | JSON | every invocation (post-run) | +| Path | Format | When | +| ----------------------------------------------------------- | ------ | -------------------------------------------------------------------------- | +| `/supabase/migrations/_.sql` | SQL | migration-style pull (non-empty diff, or the initial-migra `pg_dump` seed) | +| `/supabase/database/**` | SQL | `--declarative` | +| `~/.supabase//linked-project.json` | JSON | linked (post-run cache) | +| `~/.supabase/telemetry.json` | JSON | every invocation (post-run) | ## Docker - Edge-runtime container (pg-delta export / pg-delta or migra diff). - Shadow Postgres container (provisioned + torn down via the Go `db __shadow` seam). - `supabase/migra` container — the migra OOM bash fallback only. +- `pg_dump` container — the initial-migra pull's native remote-schema dump + (`legacyStreamPgDump`, shared with `db dump`). ## API Routes / DB @@ -47,7 +52,7 @@ delegate to the bundled Go binary. | `SUPABASE_ACCESS_TOKEN` | auth for the linked target | no | | `SUPABASE_DB_PASSWORD` | remote DB password (overridden by `-p`) | no | | `SUPABASE_EXPERIMENTAL_PG_DELTA` | force pg-delta diff engine | no | -| `SUPABASE_EXPERIMENTAL` | structured-dump pull branch (delegates to Go) | no | +| `SUPABASE_EXPERIMENTAL` | structured-dump pull branch (still delegates) | no | | `PGDELTA_NPM_REGISTRY` | scoped npm registry for edge-runtime | no | ## Exit Codes @@ -82,7 +87,12 @@ Progress strings still go to stderr; stdout carries a single structured envelope - `--declarative` / deprecated `--use-pg-delta` are mutually exclusive with `--diff-engine`; `--db-url` / `--linked` (default) / `--local` are a target group. - `--use-pg-delta` is hidden and emits the cobra deprecation line to stderr. -- The `--experimental` structured-dump branch and the initial-pull `pg_dump` (migra, - no local migrations) rebuild the argv and exec the bundled Go binary (their side - effects are Go's); the Go child's telemetry is disabled so the single - `cli_command_executed` event comes from this TS command. +- The initial-migra pull (no local migrations) is native: it streams a `pg_dump` of + the remote schema into the migration file, then appends the migra diff. An empty + diff after a non-empty dump is swallowed (Go's `swallowInitialInSync`); an empty + dump + empty diff is "No schema changes found". +- The `--experimental` structured-dump branch still rebuilds the argv and execs the + bundled Go binary (its side effects are Go's), because Go's + `format.WriteStructuredSchemas` needs a PostgreSQL DDL AST parser that has no TS + port yet. The Go child's telemetry is disabled so the single `cli_command_executed` + event comes from this TS command. diff --git a/apps/cli/src/legacy/commands/db/pull/pull.errors.ts b/apps/cli/src/legacy/commands/db/pull/pull.errors.ts index eaf81d3da6..25c3c36300 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.errors.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.errors.ts @@ -49,3 +49,15 @@ export class LegacyDbPullInSyncError extends Data.TaggedError("LegacyDbPullInSyn export class LegacyDbPullWriteError extends Data.TaggedError("LegacyDbPullWriteError")<{ readonly message: string; }> {} + +/** + * The initial-pull pg_dump container exited non-zero. Go's `dumpRemoteSchema` + * (`internal/db/pull/pull.go:144-158`) propagates the `dump.RunWithPoolerFallback` + * error; byte-matches the dump's `"error running container: exit " + code`. Carries + * the same optional IPv6 transaction-pooler hint the dump path attaches, which + * `Output.fail` prints bare on stderr after the message. + */ +export class LegacyDbPullDumpError extends Data.TaggedError("LegacyDbPullDumpError")<{ + readonly message: string; + readonly suggestion?: string; +}> {} diff --git a/apps/cli/src/legacy/commands/db/pull/pull.handler.ts b/apps/cli/src/legacy/commands/db/pull/pull.handler.ts index aa7a44fc27..d559318b29 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.handler.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.handler.ts @@ -3,20 +3,26 @@ import { Clock, Effect, FileSystem, Option, Path } from "effect"; import { LegacyDnsResolverFlag, LegacyExperimentalFlag, - LegacyYesFlag, + legacyResolveYesWithProjectEnv, } from "../../../../shared/legacy/global-flags.ts"; import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; import { Output } from "../../../../shared/output/output.service.ts"; import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; import { legacyAqua, legacyBold, legacyYellow } from "../../../shared/legacy-colors.ts"; -import { legacyIsIPv6ConnectivityError } from "../../../shared/legacy-connect-errors.ts"; +import { legacyPromptYesNo } from "../../../shared/legacy-prompt-yes-no.ts"; +import { + legacyIpv6Suggestion, + legacyIsIPv6ConnectivityError, +} from "../../../shared/legacy-connect-errors.ts"; import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; +import { legacyResolveDbImage } from "../../../shared/legacy-db-image.ts"; import { LegacyDbConnection, type LegacyPgConnInput, } from "../../../shared/legacy-db-connection.service.ts"; import { + legacyLoadProjectEnv, legacyReadDbToml, legacyResolveDeclarativeDir, } from "../../../shared/legacy-db-config.toml-read.ts"; @@ -37,6 +43,9 @@ import { legacyShouldUsePgDelta, } from "../shared/legacy-diff-engine.ts"; import { legacyDiffMigra } from "../shared/legacy-migra.ts"; +import { type LegacyDumpOptions, legacyBuildSchemaDumpEnv } from "../shared/legacy-pg-dump.env.ts"; +import { legacyStreamPgDump } from "../shared/legacy-pg-dump.run.ts"; +import { legacyDumpSchemaScript } from "../shared/legacy-pg-dump.scripts.ts"; import { legacyFormatMigrationTimestamp, legacyGetMigrationPath, @@ -53,6 +62,7 @@ import { legacySaveEmptyPgDeltaPullDebug } from "./pull.debug.ts"; import { LegacyDeclarativeSeam } from "../shared/legacy-pgdelta.seam.service.ts"; import type { LegacyDbPullFlags } from "./pull.command.ts"; import { + LegacyDbPullDumpError, LegacyDbPullEngineConflictError, LegacyDbPullInSyncError, LegacyDbPullMigrationConflictError, @@ -71,6 +81,9 @@ import { legacyUpdateMigrationHistory } from "./pull.sync.ts"; const DEPRECATION_LINE = "Flag --use-pg-delta has been deprecated, use --declarative with [experimental.pgdelta] enabled = true in your config.toml instead."; +/** Migration-file mode for the initial pg_dump seed (Go's `OpenFile(..., 0644)`). */ +const MIGRATION_FILE_MODE = 0o644; + /** Builds a plain Postgres URL from a resolved connection (Go's `ToPostgresURL`). */ const connToUrl = (conn: LegacyPgConnInput): string => legacyToPostgresURL({ @@ -88,7 +101,7 @@ const connToUrl = (conn: LegacyPgConnInput): string => : {}), }); -/** Rebuilds the `db pull` argv for the Go-delegated branches (initial-migra / EXPERIMENTAL dump). */ +/** Rebuilds the `db pull` argv for the Go-delegated `--experimental` structured-dump branch. */ const rebuildDelegateArgs = (flags: LegacyDbPullFlags): Array => { const args = ["db", "pull"]; if (Option.isSome(flags.name)) args.push(flags.name.value); @@ -135,13 +148,19 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy const cliConfig = yield* LegacyCliConfig; const telemetryState = yield* LegacyTelemetryState; const linkedProjectCache = yield* LegacyLinkedProjectCache; - const yes = yield* LegacyYesFlag; const experimental = yield* LegacyExperimentalFlag; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const dnsResolver = yield* LegacyDnsResolverFlag; const cliArgs = yield* CliArgs; + // `--yes` OR `SUPABASE_YES` (Go's `viper.GetBool("YES")`, root.go:318-320). Go + // loads the project `.env` via `loadNestedEnv` inside `ParseDatabaseConfig` + // (config.go:701) before `PromptYesNo`, so a `SUPABASE_YES` set only in + // `supabase/.env` auto-confirms the native initial-migra history repair too. + const projectEnv = yield* legacyLoadProjectEnv(fs, path, cliConfig.workdir); + const yes = yield* legacyResolveYesWithProjectEnv(projectEnv); + let linkedRefForCache: string | undefined; yield* Effect.gen(function* () { @@ -284,19 +303,15 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy }), }); - // Runs a Go-delegated pull (initial-migra / EXPERIMENTAL structured dump). In - // machine-output mode the child's stdout is captured and a structured envelope - // is emitted instead, so scripted callers get valid JSON rather than the Go - // child's human output on stdout (CLI-1546: stdout is payload-only in machine - // mode). The child is run with a non-TTY stdin (`"ignore"`) so the migration - // path's "Update remote migration history table?" prompt (Go's `PromptYesNo`, - // `internal/db/pull/pull.go:73`) takes its `true` default without blocking the - // JSON caller. `remoteHistoryUpdated` is passed per call site because the two - // delegated Go paths differ: the initial-migra path prompts + calls - // `repair.UpdateMigrationTable` (so `true`), while the EXPERIMENTAL structured - // dump returns before writing a migration or touching `schema_migrations` - // (`pull.go:49-61`, so `false`). `schemaWritten` stays `null` — the child owns - // the write and doesn't surface the path on stdout. + // Runs the Go-delegated `--experimental` structured dump (still delegated; see the + // EXPERIMENTAL branch below for why). In machine-output mode the child's stdout is + // captured and a structured envelope is emitted instead, so scripted callers get + // valid JSON rather than the Go child's human output on stdout (CLI-1546: stdout is + // payload-only in machine mode). The child is run with a non-TTY stdin (`"ignore"`) + // so any prompt takes its default without blocking the JSON caller. The EXPERIMENTAL + // structured dump returns before writing a migration or touching `schema_migrations` + // (`pull.go:49-61`), so `remoteHistoryUpdated` is `false`; `schemaWritten` stays + // `null` — the child owns the write and doesn't surface the path on stdout. const delegatePull = ( engine: "migra" | "pg-delta", opts: { readonly remoteHistoryUpdated: boolean }, @@ -383,11 +398,18 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy return; } - // Go's `EXPERIMENTAL` structured-dump branch depends on unported `pg_dump` - // — delegate the whole pull to Go. viper resolves `EXPERIMENTAL` from - // *either* the global `--experimental` pflag or `SUPABASE_EXPERIMENTAL` - // (`cmd/root.go:318-320,327,334`), so honor both forms here; the legacy - // root only forwards `--experimental` to Go proxy argv, never into env. + // Go's `EXPERIMENTAL` structured-dump branch (`pull.go:49-61`) stays + // delegated to Go. pg_dump itself is now native (used by the initial-migra + // path below), but this branch also calls `format.WriteStructuredSchemas` + // (`cli-go/internal/migration/format/format.go:99`), which parses every + // dumped statement with a PostgreSQL DDL AST parser (`multigres`, ~50 node + // types) to route objects into structured files. No Postgres DDL parser + // exists in TS yet, so porting it is tracked separately; until then the + // experimental path delegates the whole pull to Go. viper resolves + // `EXPERIMENTAL` from *either* the global `--experimental` pflag or + // `SUPABASE_EXPERIMENTAL` (`cmd/root.go:318-320,327,334`), so honor both + // forms here; the legacy root only forwards `--experimental` to Go proxy + // argv, never into env. if (experimental || legacyParseBoolEnv(toml.envLookup("SUPABASE_EXPERIMENTAL"))) { // Go's structured-dump path returns before writing a migration or // touching schema_migrations (`pull.go:49-61`), so no history repair. @@ -417,12 +439,123 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy }), ); } - if (sync.kind === "missing" && !usePgDeltaDiff) { - // Initial pull with the migra engine needs `pg_dump` — delegate to Go. - // Go's migration path prompts + updates schema_migrations on the non-TTY - // default (`pull.go:73-76`), so the history is repaired. - yield* delegatePull("migra", { remoteHistoryUpdated: true }); - return; + // Initial pull, migra engine (Go's `run` → `assertRemoteInSync` returns + // `errMissing`): seed the migration file with a pg_dump of the remote schema + // (`dumpRemoteSchema`, `pull.go:144-158`), then run the migra diff below as a + // second pass appended to the same file (`diffRemoteSchema(ctx, nil, …)`), + // which captures default privileges / managed schemas pg_dump can't emit. + // pg-delta initial pulls skip the dump (`pull.go:126` `if !usePgDeltaDiff`): + // they diff against an empty shadow, which already yields the full schema. + const seededFromDump = sync.kind === "missing" && !usePgDeltaDiff; + // Tracks whether the pg_dump seed wrote any bytes, for Go's + // `ensureMigrationWritten` (`pull.go:68,263-268`): an empty dump + empty diff + // is "in sync", a non-empty dump is a valid initial migration on its own. + let seedWroteBytes = false; + if (seededFromDump) { + yield* legacyMakeDir(fs, path.dirname(migrationPath)).pipe( + Effect.mapError((cause) => new LegacyDbPullWriteError({ message: cause.message })), + ); + const image = yield* legacyResolveDbImage( + fs, + path, + cliConfig.workdir, + toml.majorVersion, + Option.getOrUndefined(toml.orioledbVersion), + ); + // Go's `migration.DumpSchema` default options: no schema filter (so the + // internal-schema exclude list applies) and comments stripped (`EXTRA_SED`). + const dumpEnvOpt: LegacyDumpOptions = { + schema: [], + keepComments: false, + excludeTable: [], + columnInsert: false, + }; + const toDumpOpenError = (cause: { readonly message: string }) => + new LegacyDbPullDumpError({ message: `failed to open dump file: ${cause.message}` }); + // Stream pg_dump → migration file, (re)truncating per attempt so a pooler + // retry leaves only the successful attempt's bytes (Go's `resetOutput`). + const runSchemaDump = (target: LegacyPgConnInput) => { + // Reset per attempt alongside the truncate, mirroring Go's `resetOutput` + // (`pooler_fallback.go:98-113`) which zeroes the file before the pooler + // retry. Go decides in-sync from the file on disk (`hasMigrationContent`, + // `pull.go:251-268`), so only the final successful attempt's bytes count: a + // partial direct write that then IPv6-fails must not leave this flag stuck + // true, or an empty pooler retry would be mis-reported as a schema write. + seedWroteBytes = false; + return fs + .writeFile(migrationPath, new Uint8Array(0), { mode: MIGRATION_FILE_MODE }) + .pipe(Effect.mapError(toDumpOpenError)) + .pipe( + Effect.andThen( + Effect.scoped( + Effect.gen(function* () { + const file = yield* fs + .open(migrationPath, { flag: "a" }) + .pipe(Effect.mapError(toDumpOpenError)); + return yield* legacyStreamPgDump({ + image, + script: legacyDumpSchemaScript, + env: legacyBuildSchemaDumpEnv(target, dumpEnvOpt), + onStdout: (chunk) => { + if (chunk.length > 0) seedWroteBytes = true; + return file.writeAll(chunk).pipe( + Effect.mapError( + (cause) => + new LegacyDbPullWriteError({ + message: `failed to write migration file: ${cause.message}`, + }), + ), + ); + }, + }); + }), + ), + ), + ); + }; + // Go's `dumpRemoteSchema` prints this once, before `RunWithPoolerFallback`. + yield* output.raw("Dumping schema from remote database...\n", "stderr"); + let dumpResult = yield* runSchemaDump(resolved.conn); + // Container-level pooler fallback (Go's `dump.RunWithPoolerFallback`): on an + // IPv6 connectivity failure from a `--linked` direct `db.` host, warn + // and retry once via the IPv4 transaction pooler. Go's `PoolerFallbackConfig` + // emits the warning and does NOT re-print "Dumping…" on the retry. + if ( + dumpResult.exitCode !== 0 && + connType === "linked" && + !resolved.isLocal && + resolved.conn.host.startsWith("db.") && + resolved.conn.host.endsWith(`.${cliConfig.projectHost}`) && + legacyIsIPv6ConnectivityError(dumpResult.stderr) + ) { + const pooler = yield* resolver + .resolvePoolerFallback({ + dbUrl: flags.dbUrl, + connType: "linked", + dnsResolver, + password: flags.password ?? Option.none(), + }) + .pipe(Effect.orElseSucceed(() => Option.none())); + if (Option.isSome(pooler)) { + yield* output.raw( + `${legacyYellow( + `Warning: Direct connection to ${resolved.conn.host} is unavailable because this environment does not support IPv6.\nRetrying via the IPv4 connection pooler.`, + )}\n`, + "stderr", + ); + dumpResult = yield* runSchemaDump(pooler.value); + } + } + if (dumpResult.exitCode !== 0) { + return yield* Effect.fail( + new LegacyDbPullDumpError({ + message: `error running container: exit ${dumpResult.exitCode}`, + ...(legacyIsIPv6ConnectivityError(dumpResult.stderr) + ? { suggestion: legacyIpv6Suggestion() } + : {}), + }), + ); + } } // Native diff: shadow (baseline + local migrations) vs remote → migration SQL. @@ -504,7 +637,12 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy }).pipe(Effect.ensuring(seam.removeShadowContainer(shadow.container))); const out = diffOutcome.sql; - if (out.trim().length === 0) { + const diffEmpty = out.trim().length === 0; + // A non-initial pull with an empty diff is "in sync" and fails (Go's + // `diffRemoteSchema`). The initial-migra path seeded the file with a pg_dump + // above, so its empty second pass is swallowed (`swallowInitialInSync`, + // `pull.go:256-261`) and falls through to the shared tail below. + if (diffEmpty && !seededFromDump) { // Go saves a pg-delta debug bundle and embeds its path in the in-sync // error when PGDELTA_DEBUG is set (`internal/db/pull/pull.go:176-185`); a // bundle-save failure falls through to the plain in-sync error. @@ -541,17 +679,54 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy new LegacyDbPullInSyncError({ message: "No schema changes found" }), ); } - yield* legacyMakeDir(fs, path.dirname(migrationPath)).pipe( - Effect.mapError((cause) => new LegacyDbPullWriteError({ message: cause.message })), - ); - yield* fs.writeFileString(migrationPath, out).pipe( - Effect.mapError( - (cause) => - new LegacyDbPullWriteError({ - message: `failed to write migration file: ${cause.message}`, + + if (!diffEmpty) { + if (seededFromDump) { + // Append the migra diff to the dump-seeded file (Go's `diffRemoteSchema` + // opens the migration file `O_APPEND`, `pull.go:191`). + yield* Effect.scoped( + Effect.gen(function* () { + const file = yield* fs.open(migrationPath, { flag: "a" }).pipe( + Effect.mapError( + (cause) => + new LegacyDbPullWriteError({ + message: `failed to open migration file: ${cause.message}`, + }), + ), + ); + yield* file.writeAll(new TextEncoder().encode(out)).pipe( + Effect.mapError( + (cause) => + new LegacyDbPullWriteError({ + message: `failed to write migration file: ${cause.message}`, + }), + ), + ); }), - ), - ); + ); + } else { + yield* legacyMakeDir(fs, path.dirname(migrationPath)).pipe( + Effect.mapError((cause) => new LegacyDbPullWriteError({ message: cause.message })), + ); + yield* fs.writeFileString(migrationPath, out).pipe( + Effect.mapError( + (cause) => + new LegacyDbPullWriteError({ + message: `failed to write migration file: ${cause.message}`, + }), + ), + ); + } + } + + // Go's `ensureMigrationWritten` (`pull.go:68,263-268`): a dump that produced + // nothing followed by an empty diff leaves the file empty → in sync. + if (seededFromDump && !seedWroteBytes && diffEmpty) { + return yield* Effect.fail( + new LegacyDbPullInSyncError({ message: "No schema changes found" }), + ); + } + yield* output.raw(`Schema written to ${legacyBold(migrationPath)}\n`, "stderr"); // Prompt to update the remote migration history table. Go calls @@ -561,20 +736,10 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy // (`internal/utils/console.go:74-82`) — it never fails the command. let remoteHistoryUpdated = false; const updateHistoryTitle = "Update remote migration history table?"; - const shouldUpdate = yield* Effect.gen(function* () { - // Machine output (json/stream-json) never prompts — the non-text layers - // report non-interactive and fail every prompt — so take Go's default. - if (output.format !== "text") return true; - if (yes) { - yield* output.raw(`${updateHistoryTitle} [Y/n] y\n`, "stderr"); - return true; - } - // A non-interactive stdin or any prompt error falls back to the default, - // matching Go's `PromptYesNo` returning `def` on error/timeout. - return yield* output - .promptConfirm(updateHistoryTitle, { defaultValue: true }) - .pipe(Effect.orElseSucceed(() => true)); - }); + // Go's `PromptYesNo(ctx, title, true)` (`internal/db/pull/pull.go:73`): honors + // `--yes`, scans piped stdin on a non-TTY before falling back to the default + // (`console.go:64-82`), and otherwise prompts on a real TTY. + const shouldUpdate = yield* legacyPromptYesNo(output, yes, updateHistoryTitle, true); if (shouldUpdate) { yield* legacyUpdateMigrationHistory(session, fs, path, migrationPath, timestamp); remoteHistoryUpdated = true; diff --git a/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts b/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts index 794b61ab5e..fd73a1d63d 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts @@ -10,6 +10,7 @@ import { mockLegacyTelemetryStateTracked, useLegacyTempWorkdir, } from "../../../../../tests/helpers/legacy-mocks.ts"; +import { mockLegacyPromptInput } from "../../../../../tests/helpers/legacy-prompt-input.ts"; import { mockOutput, mockRuntimeInfo, mockTty } from "../../../../../tests/helpers/mocks.ts"; import { LegacyDnsResolverFlag, @@ -44,6 +45,8 @@ interface SetupOpts { readonly remoteVersions?: ReadonlyArray; readonly edgeStdout?: string; // diff SQL or declarative export JSON readonly stdinIsTty?: boolean; + // Piped (non-TTY) stdin answers, one consumed per confirmation prompt. + readonly pipedAnswers?: ReadonlyArray; readonly yes?: boolean; readonly experimental?: boolean; readonly shadowTargetOverride?: string; @@ -56,6 +59,17 @@ interface SetupOpts { readonly poolerAvailable?: boolean; readonly delegateStdout?: string; // stdout returned by a captured Go-delegate run readonly catalogStdout?: string; // stdout returned by pg-delta catalog-export runs + // Initial-migra pull: the bytes the native pg_dump container streams to its sink, + // its exit code / stderr, and (when set) an IPv6 stderr that fails the FIRST dump + // attempt so the pooler retry runs (the second attempt then streams `dumpStdout`). + readonly dumpStdout?: string; + readonly dumpExitCode?: number; + readonly dumpStderr?: string; + readonly dumpFailFirstWith?: string; + // Bytes the FIRST dump attempt streams to its sink before it fails with + // `dumpFailFirstWith`, reproducing a direct attempt that emits preamble then + // exits non-zero on an IPv6 drop. + readonly dumpFailFirstPartialBytes?: string; // Raw argv seen by the handler (CliArgs). Only consulted when both // `--declarative` and `--use-pg-delta` are present, to replay pflag's // last-occurrence-wins ordering; defaults to empty. @@ -112,10 +126,30 @@ function setup(workdir: string, opts: SetupOpts = {}) { }, }); + // The initial-migra pull seeds the migration file with a native pg_dump via + // `runStream`; deliver the configured bytes to `onStdout` (as Go's StdCopy would), + // then report the exit code + stderr. `dumpFailFirstWith` fails the first attempt + // so the pooler retry runs. + const dumpCalls: Array<{ env: Readonly>; image: string }> = []; + let dumpRunCount = 0; const docker = Layer.succeed(LegacyDockerRun, { run: () => Effect.die("run unused"), runCapture: () => Effect.die("runCapture unused"), - runStream: () => Effect.die("runStream unused"), + runStream: (runOpts, streamOpts) => + Effect.gen(function* () { + dumpRunCount += 1; + dumpCalls.push({ env: runOpts.env, image: runOpts.image }); + if (opts.dumpFailFirstWith !== undefined && dumpRunCount === 1) { + if (opts.dumpFailFirstPartialBytes !== undefined) { + const partial = new TextEncoder().encode(opts.dumpFailFirstPartialBytes); + if (partial.length > 0) yield* streamOpts.onStdout(partial); + } + return { exitCode: 1, stderr: opts.dumpFailFirstWith }; + } + const bytes = new TextEncoder().encode(opts.dumpStdout ?? ""); + if (bytes.length > 0) yield* streamOpts.onStdout(bytes); + return { exitCode: opts.dumpExitCode ?? 0, stderr: opts.dumpStderr ?? "" }; + }), }); const execLog: string[] = []; @@ -196,6 +230,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { proxy, mockLegacyCliConfig({ workdir, projectId: Option.some("test") }), mockTty({ stdinIsTty: opts.stdinIsTty ?? false, stdoutIsTty: false }), + mockLegacyPromptInput({ pipedLines: opts.pipedAnswers }), Layer.succeed(LegacyYesFlag, opts.yes ?? false), Layer.succeed(LegacyExperimentalFlag, opts.experimental ?? false), Layer.succeed(LegacyDnsResolverFlag, "native"), @@ -216,6 +251,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { historyUpserts, execLog, poolerFallbackCalls, + dumpCalls, get edgeRunCount() { return edgeRunCount; }, @@ -422,42 +458,177 @@ describe("legacy db pull", () => { }).pipe(Effect.provide(s.layer)); }); - it.effect("an initial pull with no local migrations delegates the dump to Go (migra)", () => { - const s = setup(tmp.current, { remoteVersions: [] }); - return Effect.gen(function* () { - yield* legacyDbPull(flags()); - expect(s.proxyCalls).toHaveLength(1); - expect(s.proxyCalls[0]?.args[0]).toBe("db"); - expect(s.proxyCalls[0]?.args[1]).toBe("pull"); - expect(s.proxyCalls[0]?.env).toEqual({ SUPABASE_TELEMETRY_DISABLED: "1" }); - }).pipe(Effect.provide(s.layer)); - }); + it.effect( + "an initial pull (no local migrations, migra) dumps the schema natively then appends the diff", + () => { + // Go's `run` → `dumpRemoteSchema` (pg_dump, now native) + `diffRemoteSchema(nil)` + // appended (`pull.go:117-141`). No Go delegation. + const s = setup(tmp.current, { + remoteVersions: [], + dumpStdout: "create table dumped ();\n", + edgeStdout: "create table diffed ();\n", // the migra second pass + yes: true, + }); + return Effect.gen(function* () { + yield* legacyDbPull(flags()); + expect(s.proxyCalls).toHaveLength(0); + expect(s.proxyCaptureCalls).toHaveLength(0); + // pg_dump ran with the schema-dump env (internal-schema exclude + comment strip). + expect(s.dumpCalls).toHaveLength(1); + expect(s.dumpCalls[0]?.env["EXTRA_SED"]).toBe("/^--/d"); + expect(s.dumpCalls[0]?.env["EXCLUDED_SCHEMAS"]).toContain("auth"); + // The diff ran against the shadow with the migra engine (no schema filter). + expect(s.provisionCalls[0]?.usePgDelta).toBe(false); + // The migration file holds the dump output followed by the appended diff. + const dir = join(tmp.current, "supabase", "migrations"); + const file = readdirSync(dir).find((f) => f.endsWith("_remote_schema.sql")); + expect(file).toBeDefined(); + const content = readFileSync(join(dir, file ?? ""), "utf8"); + expect(content).toContain("create table dumped ();"); + expect(content).toContain("create table diffed ();"); + expect(content.indexOf("dumped")).toBeLessThan(content.indexOf("diffed")); + // stderr order: dump → shadow → diff → written. + const err = streamText(s.out, "stderr"); + expect(err).toContain("Dumping schema from remote database..."); + expect(err).toContain("Creating shadow database..."); + expect(err).toContain("Schema written to"); + expect(err.indexOf("Dumping schema")).toBeLessThan(err.indexOf("Creating shadow")); + expect(s.historyUpserts.length).toBe(1); + }).pipe(Effect.provide(s.layer)); + }, + ); - it.effect("an initial pull in json mode emits a structured envelope (delegated output)", () => { - // Regression: the initial-migra delegate inherited stdout and returned without - // output.success, so machine-mode callers got the Go child's human output - // instead of a JSON envelope (CLI-1546). Now the child's stdout is captured and - // a structured payload is emitted instead. + it.effect("an initial pull in json mode emits a native structured envelope", () => { const s = setup(tmp.current, { format: "json", remoteVersions: [], - delegateStdout: "Schema written to supabase/migrations/x.sql\n", + dumpStdout: "create table dumped ();\n", + edgeStdout: "create table diffed ();\n", }); return Effect.gen(function* () { yield* legacyDbPull(flags()); expect(s.proxyCalls).toHaveLength(0); - expect(s.proxyCaptureCalls).toHaveLength(1); - // The delegated child runs with a non-TTY stdin so its history-update prompt - // takes Go's default (true) without blocking the JSON caller; the child then - // updates the history, so the envelope reports remoteHistoryUpdated: true. - expect(s.proxyCaptureCalls[0]?.stdin).toBe("ignore"); + expect(s.proxyCaptureCalls).toHaveLength(0); const success = s.out.messages.find((m) => m.type === "success"); + // Machine mode never prompts, so history is updated on Go's default (true); + // `schemaWritten` is the real native migration path (not null as when delegated). expect(success?.data).toMatchObject({ declarative: false, - schemaWritten: null, remoteHistoryUpdated: true, engine: "migra", }); + const data = success?.data as { schemaWritten?: string } | undefined; + expect(data?.schemaWritten).toMatch(/_remote_schema\.sql$/u); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("an initial pull swallows an empty migra diff once the dump wrote content", () => { + // Go's `swallowInitialInSync` (`pull.go:256-261`): after the pg_dump seed, an + // empty second pass is success, not "in sync". + const s = setup(tmp.current, { + remoteVersions: [], + dumpStdout: "create table dumped ();\n", + edgeStdout: "", // empty migra diff + yes: true, + }); + return Effect.gen(function* () { + const exit = yield* legacyDbPull(flags()).pipe(Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + const dir = join(tmp.current, "supabase", "migrations"); + const file = readdirSync(dir).find((f) => f.endsWith("_remote_schema.sql")); + expect(file).toBeDefined(); + expect(readFileSync(join(dir, file ?? ""), "utf8")).toContain("create table dumped ();"); + expect(streamText(s.out, "stderr")).toContain("Schema written to"); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("an initial pull with an empty schema reports 'No schema changes found'", () => { + // Go's `ensureMigrationWritten` (`pull.go:68,263-268`): an empty dump + empty diff + // leaves the file empty → in sync. + const s = setup(tmp.current, { remoteVersions: [], dumpStdout: "", edgeStdout: "" }); + return Effect.gen(function* () { + const error = yield* legacyDbPull(flags()).pipe(Effect.flip); + expect(error.message).toBe("No schema changes found"); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect( + "an initial-pull direct write that IPv6-fails then an empty pooler retry reports 'No schema changes found'", + () => { + // Regression: the direct attempt streams preamble bytes then drops over IPv6; + // the pooler retry succeeds empty. Go truncates the file before the retry + // (`resetOutput`, pooler_fallback.go:98-113) and decides in-sync from the file + // on disk (`hasMigrationContent`, pull.go:251-268), so an empty pooler retry + + // empty diff is in sync — not a schema write + migration-history upsert. The + // sticky `seedWroteBytes` flag must therefore reset per attempt. + const s = setup(tmp.current, { + remoteVersions: [], + dumpFailFirstWith: "could not translate host name: network is unreachable", + dumpFailFirstPartialBytes: "-- partial preamble\n", + dumpStdout: "", // pooler retry streams nothing + edgeStdout: "", // empty migra diff + poolerAvailable: true, + yes: true, + }); + return Effect.gen(function* () { + const error = yield* legacyDbPull(flags()).pipe(Effect.flip); + expect(error.message).toBe("No schema changes found"); + expect(s.dumpCalls).toHaveLength(2); // direct attempt + pooler retry + expect(s.historyUpserts).toHaveLength(0); // no migration-history row written + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect("an initial pull fails when the pg_dump container exits non-zero", () => { + const s = setup(tmp.current, { + remoteVersions: [], + dumpExitCode: 1, + dumpStderr: "connection refused", + }); + return Effect.gen(function* () { + const error = yield* legacyDbPull(flags()).pipe(Effect.flip); + expect(error.message).toContain("error running container: exit 1"); + // The diff pass never ran — the dump failure aborts before provisioning a shadow. + expect(s.provisionCalls).toHaveLength(0); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("an initial-pull dump retries via the IPv4 pooler on an IPv6 failure", () => { + // Go's `dump.RunWithPoolerFallback`: a `--linked` direct-host dump that fails over + // IPv6 retries once through the transaction pooler (`pull.go:155`). + const s = setup(tmp.current, { + remoteVersions: [], + dumpFailFirstWith: "could not translate host name: network is unreachable", + dumpStdout: "create table dumped ();\n", + edgeStdout: "create table diffed ();\n", + poolerAvailable: true, + yes: true, + }); + return Effect.gen(function* () { + yield* legacyDbPull(flags()); + expect(s.dumpCalls).toHaveLength(2); // direct attempt + pooler retry + expect(s.poolerFallbackCalls).toHaveLength(1); + const err = streamText(s.out, "stderr"); + expect(err).toContain("does not support IPv6"); + expect(err).toContain("Retrying via the IPv4 connection pooler"); + // The "Dumping schema…" line is printed once (before the fallback), not re-printed + // on the pooler retry (Go's `PoolerFallbackConfig` only emits the warning). + expect(err.match(/Dumping schema from remote database/gu)).toHaveLength(1); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("an initial-pull IPv6 dump failure with no pooler surfaces the dump error", () => { + const s = setup(tmp.current, { + remoteVersions: [], + dumpExitCode: 1, + dumpStderr: "could not translate host name: network is unreachable", + poolerAvailable: false, + }); + return Effect.gen(function* () { + const error = yield* legacyDbPull(flags()).pipe(Effect.flip); + expect(error.message).toContain("error running container: exit 1"); + expect(s.poolerFallbackCalls).toHaveLength(1); // gate checked, no pooler resolved + expect(streamText(s.out, "stderr")).not.toContain("Retrying via the IPv4 connection pooler"); }).pipe(Effect.provide(s.layer)); }); @@ -556,6 +727,45 @@ describe("legacy db pull", () => { }).pipe(Effect.provide(s.layer)); }); + it.effect("updates history on an empty non-interactive stdin (Go default)", () => { + // Go's `PromptYesNo` scans stdin and only falls back to the default (`true`) when + // the scan is empty/exhausted (`console.go:64-82`). With no piped input a + // non-interactive `db pull` therefore proceeds to update the remote history. + // (The production clack prompt would hang on a non-TTY — that no-hang behavior is + // proven end-to-end in `pull.live.test.ts`; here the empty piped scan defaults.) + seedMigration(tmp.current, "20240101000000"); + const s = setup(tmp.current, { + remoteVersions: ["20240101000000"], + edgeStdout: "create table remote ();\n", + stdinIsTty: false, + }); + return Effect.gen(function* () { + yield* legacyDbPull(flags()); + expect(s.historyUpserts.length).toBe(1); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("declines the history update on a piped 'n' (non-tty)", () => { + // Regression: Go scans piped stdin before defaulting (`console.go:74-82`), so a + // piped `n` cancels the history update even on a non-terminal — `schema_migrations` + // must not be touched against the user's explicit decline. + seedMigration(tmp.current, "20240101000000"); + const s = setup(tmp.current, { + remoteVersions: ["20240101000000"], + edgeStdout: "create table remote ();\n", + stdinIsTty: false, + pipedAnswers: ["n"], + }); + return Effect.gen(function* () { + yield* legacyDbPull(flags()); + expect(s.historyUpserts.length).toBe(0); + // Go prints the label then echoes the consumed answer (`console.go:96-102`). + expect(streamText(s.out, "stderr")).toContain( + "Update remote migration history table? [Y/n] n", + ); + }).pipe(Effect.provide(s.layer)); + }); + it.effect("emits a json envelope and suppresses 'Finished' in machine mode", () => { seedMigration(tmp.current, "20240101000000"); const s = setup(tmp.current, { @@ -587,6 +797,104 @@ describe("legacy db pull", () => { }).pipe(Effect.provide(s.layer)); }); + it.effect("honors SUPABASE_YES for the initial-pull history update", () => { + // Go's `PromptYesNo` reads `viper.GetBool("YES")`, which includes the + // `SUPABASE_YES` env var (AutomaticEnv), so it auto-confirms even on a TTY with + // no piped answer. The native path resolves `yes` via `legacyResolveYesWithProjectEnv`, + // not the raw `--yes` flag, so the shell env var is honored here too. + const prev = process.env["SUPABASE_YES"]; + process.env["SUPABASE_YES"] = "1"; + seedMigration(tmp.current, "20240101000000"); + const s = setup(tmp.current, { + remoteVersions: ["20240101000000"], + edgeStdout: "create table remote ();\n", + // A TTY with no scripted prompt response: only SUPABASE_YES makes this pass. + stdinIsTty: true, + }); + return Effect.gen(function* () { + yield* legacyDbPull(flags()); + expect(s.historyUpserts.length).toBe(1); + expect(streamText(s.out, "stderr")).toContain( + "Update remote migration history table? [Y/n] y", + ); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (prev === undefined) delete process.env["SUPABASE_YES"]; + else process.env["SUPABASE_YES"] = prev; + }), + ), + Effect.provide(s.layer), + ); + }); + + it.effect("honors SUPABASE_YES from supabase/.env for the initial-pull history update", () => { + // Go loads the project `.env` (loadNestedEnv) inside ParseDatabaseConfig before + // PromptYesNo (config.go:701), so `SUPABASE_YES` set only in `supabase/.env` + // auto-confirms — with no shell env or `--yes`. The native path resolves via + // `legacyResolveYesWithProjectEnv`, reading the loaded project env map. + const prev = process.env["SUPABASE_YES"]; + delete process.env["SUPABASE_YES"]; // only the project .env value must apply + seedMigration(tmp.current, "20240101000000"); + writeFileSync(join(tmp.current, "supabase", ".env"), "SUPABASE_YES=true\n"); + const s = setup(tmp.current, { + remoteVersions: ["20240101000000"], + edgeStdout: "create table remote ();\n", + // Pipe `n` on a non-TTY: only honoring the .env SUPABASE_YES (which is read + // before stdin, so it wins over the piped decline) still updates history. + stdinIsTty: false, + pipedAnswers: ["n"], + }); + return Effect.gen(function* () { + yield* legacyDbPull(flags()); + expect(s.historyUpserts.length).toBe(1); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (prev === undefined) delete process.env["SUPABASE_YES"]; + else process.env["SUPABASE_YES"] = prev; + }), + ), + Effect.provide(s.layer), + ); + }); + + it.effect( + "resolves the pg_dump image via SUPABASE_INTERNAL_IMAGE_REGISTRY from supabase/.env", + () => { + // Go's LoadConfig applies the project `.env` (os.Setenv) before GetRegistryImageUrl, + // so a registry mirror set only in `supabase/.env` is used for the native pg_dump + // seed. `legacyLoadProjectEnv` now mirrors that by writing to process.env. + const prev = process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]; + delete process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]; + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync( + join(tmp.current, "supabase", ".env"), + "SUPABASE_INTERNAL_IMAGE_REGISTRY=my-mirror.example.com\n", + ); + const s = setup(tmp.current, { + remoteVersions: [], // no remote history → initial-migra pg_dump path + dumpStdout: "create table dumped ();\n", + edgeStdout: "", + yes: true, + }); + return Effect.gen(function* () { + yield* legacyDbPull(flags()); + expect(s.dumpCalls.length).toBeGreaterThanOrEqual(1); + // The pg_dump container image is rewritten to the configured mirror. + expect(s.dumpCalls[0]?.image).toMatch(/^my-mirror\.example\.com\/supabase\//u); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (prev === undefined) delete process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]; + else process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"] = prev; + }), + ), + Effect.provide(s.layer), + ); + }, + ); + it.effect("SUPABASE_EXPERIMENTAL delegates the structured-dump pull to Go", () => { const s = setup(tmp.current); return Effect.gen(function* () { diff --git a/apps/cli/src/legacy/commands/db/pull/pull.layers.ts b/apps/cli/src/legacy/commands/db/pull/pull.layers.ts index 0e298b8bc0..d0a1dfe656 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.layers.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.layers.ts @@ -10,6 +10,7 @@ import { legacyEdgeRuntimeScriptLayer } from "../../../shared/legacy-edge-runtim import { legacyIdentityStitchLayer } from "../../../shared/legacy-identity-stitch.ts"; import { legacyLinkedDbResolverRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts"; import { legacyPgDeltaSslProbeLayer } from "../../../shared/legacy-pgdelta-ssl-probe.layer.ts"; +import { legacyPromptInputRuntimeLayer } from "../../../shared/legacy-prompt-input.layer.ts"; import { legacyTelemetryStateLayer } from "../../../telemetry/legacy-telemetry-state.layer.ts"; import { legacyDeclarativeSeamLayer } from "../shared/legacy-pgdelta.seam.layer.ts"; @@ -47,4 +48,5 @@ export const legacyDbPullRuntimeLayer = Layer.mergeAll( legacyTelemetryStateLayer, legacyLinkedDbResolverRuntimeLayer(["db", "pull"]).pipe(Layer.provide(legacyIdentityStitchLayer)), commandRuntimeLayer(["db", "pull"]), + legacyPromptInputRuntimeLayer, ); diff --git a/apps/cli/src/legacy/commands/db/pull/pull.live.test.ts b/apps/cli/src/legacy/commands/db/pull/pull.live.test.ts new file mode 100644 index 0000000000..da3128b9b1 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/pull/pull.live.test.ts @@ -0,0 +1,64 @@ +import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; + +import { + describeLiveDataPlane, + requireLiveProjectRef, + runSupabaseLive, +} from "../../../../../tests/helpers/live.ts"; + +const LIVE_TIMEOUT_MS = 300_000; + +// A fresh, isolated temp workdir so the CLI writes migrations there and never +// touches the repo tree. The provisioned project ref is supplied to `--linked` via +// the `SUPABASE_PROJECT_ID` env var — that is the `--linked` resolver chain in both +// Go and the legacy port (flag → `SUPABASE_PROJECT_ID` → `supabase/.temp/project-ref`); +// `config.toml`'s `project_id` is NOT consulted for `--linked`. +function tempWorkdir(): string { + return mkdtempSync(join(tmpdir(), "sb-db-pull-live-")); +} + +// Data-plane: needs a provisioned project whose database is routable (the +// cli-e2e-ci Linux runner). `describeLiveDataPlane` runs this only when the project +// instance is ACTIVE_HEALTHY, so a control-plane-only stack (ref set but the DB +// unreachable, e.g. local macOS or the current cli-e2e-ci control-plane case) is +// skipped rather than timing out on the pg_dump seed. +describeLiveDataPlane("supabase db pull (live)", () => { + test( + "initial pull from the linked project (native pg_dump seed + migra diff)", + { timeout: LIVE_TIMEOUT_MS }, + async () => { + const ref = requireLiveProjectRef(); + const dir = tempWorkdir(); + try { + const { stdout, stderr } = await runSupabaseLive(["db", "pull", "--linked"], { + cwd: dir, + env: { SUPABASE_PROJECT_ID: ref }, + exitTimeoutMs: LIVE_TIMEOUT_MS - 20_000, + // Decline the "Update remote migration history table?" prompt with a piped + // `n`: this project ref is shared across live runs, and writing a + // `schema_migrations` row here would make a later run see it as an extra + // remote migration and fail with a history conflict before pulling. The + // piped answer also exercises the native prompt's stdin scanning end to end. + stdin: "n\n", + }); + const combined = `${stdout}${stderr}`; + expect(combined).not.toContain("Unauthorized"); + // No local migrations → the native initial-migra path runs: pg_dump the remote + // schema, then append the migra diff. Assert on the durable side effect + // regardless of exit code: a provisioned project with schema writes a + // `_remote_schema.sql` migration; a fresh empty schema reports + // "No schema changes found". Either proves the path ran end to end against the + // real database without hanging. + const migDir = join(dir, "supabase", "migrations"); + const wroteMigration = + existsSync(migDir) && readdirSync(migDir).some((f) => f.endsWith("_remote_schema.sql")); + expect(wroteMigration || combined.includes("No schema changes found")).toBe(true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }, + ); +}); diff --git a/apps/cli/src/legacy/commands/db/dump/dump.env.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pg-dump.env.ts similarity index 98% rename from apps/cli/src/legacy/commands/db/dump/dump.env.ts rename to apps/cli/src/legacy/commands/db/shared/legacy-pg-dump.env.ts index 8c83a06102..770aca703a 100644 --- a/apps/cli/src/legacy/commands/db/dump/dump.env.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pg-dump.env.ts @@ -3,8 +3,8 @@ import type { LegacyPgConnInput } from "../../../shared/legacy-db-connection.ser /** * Pure pg_dump environment builders, ported 1:1 from Go's `pkg/migration/dump.go`. * No Effect or service dependencies, so the schema/role/config lists and the - * `os.Expand` dry-run expansion stay unit-testable in isolation. Promote to - * `legacy/shared/` if `db diff` / `db pull` ever need the same env builders. + * `os.Expand` dry-run expansion stay unit-testable in isolation. Shared by the + * `db` command family (`db dump`, and `db pull`'s initial-migra schema dump). */ /** `migration.InternalSchemas` (`pkg/migration/dump.go:18-49`). Used by schema dumps. */ diff --git a/apps/cli/src/legacy/commands/db/dump/dump.env.unit.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pg-dump.env.unit.test.ts similarity index 98% rename from apps/cli/src/legacy/commands/db/dump/dump.env.unit.test.ts rename to apps/cli/src/legacy/commands/db/shared/legacy-pg-dump.env.unit.test.ts index 4ff33a2d9f..1488772664 100644 --- a/apps/cli/src/legacy/commands/db/dump/dump.env.unit.test.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pg-dump.env.unit.test.ts @@ -15,12 +15,12 @@ import { legacyQuoteUpperCase, legacyToDumpEnv, type LegacyDumpOptions, -} from "./dump.env.ts"; +} from "./legacy-pg-dump.env.ts"; import { legacyDumpDataScript, legacyDumpRoleScript, legacyDumpSchemaScript, -} from "./dump.scripts.ts"; +} from "./legacy-pg-dump.scripts.ts"; const CONN: LegacyPgConnInput = { host: "db.example.supabase.co", diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pg-dump.run.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pg-dump.run.ts new file mode 100644 index 0000000000..5d6918bddd --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pg-dump.run.ts @@ -0,0 +1,54 @@ +import { Effect, Option } from "effect"; + +import { LegacyNetworkIdFlag } from "../../../../shared/legacy/global-flags.ts"; +import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; +import { legacyGetRegistryImageUrl } from "../../../shared/legacy-docker-registry.ts"; +import { LegacyDockerRun } from "../../../shared/legacy-docker-run.service.ts"; + +/** + * Runs a pg_dump / pg_dumpall bash script in a one-shot container, streaming its + * stdout chunk-by-chunk to `onStdout` and teeing stderr live, returning the exit + * code + captured stderr for failure classification. Mirrors Go's `dockerExec` + * (`apps/cli-go/internal/db/dump/dump.go`): host networking by default (overridden + * by the global `--network-id`), no security-opt, and the Linux-only + * `host.docker.internal:host-gateway` extra host. + * + * Shared by `db dump` (streams to `--file`/stdout) and `db pull`'s initial-migra + * schema dump (streams to the migration file). The pooler-fallback *decision* + * stays with the caller — this helper runs a single attempt and surfaces its + * exit/stderr so the caller can classify with `legacyIsIPv6ConnectivityError`. + */ +export const legacyStreamPgDump = Effect.fnUntraced(function* (params: { + /** Resolved Postgres image tag (pre-registry-URL); the helper applies the registry mirror. */ + readonly image: string; + /** The bash pg_dump/pg_dumpall script (`legacyDump{Schema,Data,Role}Script`). */ + readonly script: string; + readonly env: Readonly>; + /** Receives each stdout chunk in arrival order; its failure aborts the run as `E`. */ + readonly onStdout: (chunk: Uint8Array) => Effect.Effect; +}) { + const docker = yield* LegacyDockerRun; + const runtimeInfo = yield* RuntimeInfo; + const networkIdFlag = yield* LegacyNetworkIdFlag; + + const networkId = Option.getOrUndefined(networkIdFlag); + const network = + networkId !== undefined && networkId.length > 0 + ? { _tag: "named" as const, name: networkId } + : { _tag: "host" as const }; + const extraHosts = runtimeInfo.platform === "linux" ? ["host.docker.internal:host-gateway"] : []; + + return yield* docker.runStream( + { + image: legacyGetRegistryImageUrl(params.image), + cmd: ["bash", "-c", params.script, "--"], + env: params.env, + binds: [], + workingDir: Option.none(), + securityOpt: [], + extraHosts, + network, + }, + { onStdout: params.onStdout, teeStderr: true }, + ); +}); diff --git a/apps/cli/src/legacy/commands/db/dump/dump.scripts.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pg-dump.scripts.ts similarity index 96% rename from apps/cli/src/legacy/commands/db/dump/dump.scripts.ts rename to apps/cli/src/legacy/commands/db/shared/legacy-pg-dump.scripts.ts index cf9659adcf..48dd02fbf7 100644 --- a/apps/cli/src/legacy/commands/db/dump/dump.scripts.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pg-dump.scripts.ts @@ -1,6 +1,6 @@ // Verbatim copies of the Go pg_dump scripts (`apps/cli-go/pkg/migration/scripts/`). -// These embed the dump pipelines byte-for-byte; `dump.scripts.unit.test.ts` asserts -// equality against the Go `.sh` sources. Do not hand-edit — regenerate from Go. +// These embed the dump pipelines byte-for-byte; `legacy-pg-dump.env.unit.test.ts` +// asserts equality against the Go `.sh` sources. Do not hand-edit — regenerate from Go. export const legacyDumpSchemaScript = '#!/usr/bin/env bash\nset -euo pipefail\n\nexport PGHOST="$PGHOST"\nexport PGPORT="$PGPORT"\nexport PGUSER="$PGUSER"\nexport PGPASSWORD="$PGPASSWORD"\nexport PGDATABASE="$PGDATABASE"\n\n# Explanation of pg_dump flags:\n#\n# --schema-only omit data like migration history, pgsodium key, etc.\n# --exclude-schema omit internal schemas as they are maintained by platform\n#\n# Explanation of sed substitutions:\n#\n# - do not emit psql meta commands\n# - do not alter superuser role "supabase_admin"\n# - do not alter foreign data wrappers owner\n# - do not include ACL changes on internal schemas\n# - do not include RLS policies on cron extension schema\n# - do not include event triggers\n# - do not create pgtle schema and extension comments\n# - do not create publication "supabase_realtime"\n# - do not set transaction_timeout which requires pg17\npg_dump \\\n --schema-only \\\n --quote-all-identifier \\\n --role "postgres" \\\n --exclude-schema "${EXCLUDED_SCHEMAS:-}" \\\n ${EXTRA_FLAGS:-} \\\n| sed -E \'s/^\\\\(un)?restrict .*$/-- &/\' \\\n| sed -E \'s/^CREATE SCHEMA "/CREATE SCHEMA IF NOT EXISTS "/\' \\\n| sed -E \'s/^CREATE TABLE "/CREATE TABLE IF NOT EXISTS "/\' \\\n| sed -E \'s/^CREATE SEQUENCE "/CREATE SEQUENCE IF NOT EXISTS "/\' \\\n| sed -E \'s/^CREATE VIEW "/CREATE OR REPLACE VIEW "/\' \\\n| sed -E \'s/^CREATE FUNCTION "/CREATE OR REPLACE FUNCTION "/\' \\\n| sed -E \'s/^CREATE TRIGGER "/CREATE OR REPLACE TRIGGER "/\' \\\n| sed -E \'s/^CREATE PUBLICATION "supabase_realtime/-- &/\' \\\n| sed -E \'s/^CREATE EVENT TRIGGER /-- &/\' \\\n| sed -E \'s/^ WHEN TAG IN /-- &/\' \\\n| sed -E \'s/^ EXECUTE FUNCTION /-- &/\' \\\n| sed -E \'s/^ALTER EVENT TRIGGER /-- &/\' \\\n| sed -E \'s/^ALTER PUBLICATION "supabase_realtime_/-- &/\' \\\n| sed -E \'s/^ALTER FOREIGN DATA WRAPPER (.+) OWNER TO /-- &/\' \\\n| sed -E \'s/^ALTER DEFAULT PRIVILEGES FOR ROLE "supabase_admin"/-- &/\' \\\n| sed -E \'s/^GRANT ALL ON FOREIGN DATA WRAPPER (.+) TO "postgres" WITH GRANT OPTION/-- &/\' \\\n| sed -E "s/^GRANT (.+) ON (.+) \\"(${EXCLUDED_SCHEMAS:-})\\"/-- &/" \\\n| sed -E "s/^REVOKE (.+) ON (.+) \\"(${EXCLUDED_SCHEMAS:-})\\"/-- &/" \\\n| sed -E \'s/^(CREATE EXTENSION IF NOT EXISTS "pg_tle").+/\\1;/\' \\\n| sed -E \'s/^(CREATE EXTENSION IF NOT EXISTS "pgsodium").+/\\1;/\' \\\n| sed -E \'s/^(CREATE EXTENSION IF NOT EXISTS "pgmq").+/\\1;/\' \\\n| sed -E \'s/^COMMENT ON EXTENSION (.+)/-- &/\' \\\n| sed -E \'s/^CREATE POLICY "cron_job_/-- &/\' \\\n| sed -E \'s/^ALTER TABLE "cron"/-- &/\' \\\n| sed -E \'s/^SET transaction_timeout = 0;/-- &/\' \\\n| sed -E "${EXTRA_SED:-}"\n'; diff --git a/apps/cli/src/legacy/commands/logout/logout.handler.ts b/apps/cli/src/legacy/commands/logout/logout.handler.ts index 4539c9fb14..8b4a606249 100644 --- a/apps/cli/src/legacy/commands/logout/logout.handler.ts +++ b/apps/cli/src/legacy/commands/logout/logout.handler.ts @@ -2,8 +2,9 @@ import { Effect } from "effect"; import { LegacyCredentials } from "../../auth/legacy-credentials.service.ts"; import { LegacyTelemetryState } from "../../telemetry/legacy-telemetry-state.service.ts"; -import { LegacyYesFlag } from "../../../shared/legacy/global-flags.ts"; +import { legacyResolveYes } from "../../../shared/legacy/global-flags.ts"; import { Output } from "../../../shared/output/output.service.ts"; +import { legacyPromptYesNo } from "../../shared/legacy-prompt-yes-no.ts"; import { LegacyLogoutCancelledError, LEGACY_LOGOUT_CANCELLED_MESSAGE } from "./logout.errors.ts"; const LOGGED_OUT_MSG = "Access token deleted successfully. You are now logged out."; @@ -12,16 +13,28 @@ export const legacyLogout = Effect.fn("legacy.logout")(function* () { const output = yield* Output; const credentials = yield* LegacyCredentials; const telemetryState = yield* LegacyTelemetryState; - const yes = yield* LegacyYesFlag; + // `--yes` OR `SUPABASE_YES` (Go's `viper.GetBool("YES")`, root.go:318-320): Go + // reads it before scanning stdin, so the env var auto-confirms logout too. + const yes = yield* legacyResolveYes; + + const confirmLabel = + "Do you want to log out? This will remove the access token from your system."; const body = Effect.gen(function* () { - // Confirm prompt, honoring the global `--yes` (`logout.go:15`). - const confirmed = yes - ? true - : yield* output.promptConfirm( - "Do you want to log out? This will remove the access token from your system.", - { defaultValue: false }, - ); + // Confirm prompt, honoring the global `--yes`/`SUPABASE_YES` (`logout.go:15-16`). + const confirmed = yield* Effect.gen(function* () { + if (yes) return true; + // Machine (json/stream-json) mode has no Go equivalent — fail loudly on a + // non-interactive prompt rather than silently defaulting, preserving the + // existing contract. + if (output.format !== "text") { + return yield* output.promptConfirm(confirmLabel, { defaultValue: false }); + } + // Text mode mirrors Go's `PromptYesNo(..., false)` (`logout.go:16`): it scans + // piped stdin before falling back to the default (`console.go:64-82`), so + // `printf 'y\n' | supabase logout` deletes the token. + return yield* legacyPromptYesNo(output, yes, confirmLabel, false); + }); if (!confirmed) { return yield* Effect.fail( new LegacyLogoutCancelledError({ message: LEGACY_LOGOUT_CANCELLED_MESSAGE }), diff --git a/apps/cli/src/legacy/commands/logout/logout.integration.test.ts b/apps/cli/src/legacy/commands/logout/logout.integration.test.ts index 39b3701f36..6a153b83d8 100644 --- a/apps/cli/src/legacy/commands/logout/logout.integration.test.ts +++ b/apps/cli/src/legacy/commands/logout/logout.integration.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, Exit, Layer } from "effect"; -import { mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; +import { mockLegacyPromptInput } from "../../../../tests/helpers/legacy-prompt-input.ts"; import { mockLegacyCredentialsTracked, mockLegacyTelemetryStateTracked, @@ -15,6 +16,10 @@ interface SetupOpts { readonly yes?: boolean; readonly deleteOutcome?: "ok" | "notLoggedIn" | "deleteError"; readonly promptConfirmFail?: boolean; + /** stdin interactivity; defaults to a TTY so prompt-driven tests reach the confirm. */ + readonly stdinIsTty?: boolean; + /** Piped (non-TTY) stdin answers, one consumed per confirmation prompt. */ + readonly pipedAnswers?: ReadonlyArray; } function setupLegacyLogout(opts: SetupOpts = {}) { @@ -30,6 +35,8 @@ function setupLegacyLogout(opts: SetupOpts = {}) { credentials.layer, telemetry.layer, Layer.succeed(LegacyYesFlag, opts.yes ?? false), + mockTty({ stdinIsTty: opts.stdinIsTty ?? true, stdoutIsTty: false }), + mockLegacyPromptInput({ pipedLines: opts.pipedAnswers }), ); return { layer, out, telemetry, credentials }; } @@ -69,6 +76,53 @@ describe("legacy logout integration", () => { }).pipe(Effect.provide(layer)); }); + it.live("empty non-interactive stdin takes Go's default (false) and cancels", () => { + // Go's `PromptYesNo(..., false)` scans stdin and falls back to the default when + // the scan is empty (`logout.go:16`, `console.go:64-82`). With no piped input + // logout cancels — without hanging on the clack confirm. + const { layer, credentials } = setupLegacyLogout({ stdinIsTty: false }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyLogout()); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyLogoutCancelledError"); + } + expect(credentials.deletedAll).toBe(false); + }).pipe(Effect.provide(layer)); + }); + + it.live("honors a piped 'y' on non-interactive stdin and logs out", () => { + // Regression: Go scans piped stdin before defaulting (`console.go:74-82`), so + // `printf 'y\n' | supabase logout` deletes the token even on a non-terminal. + const { layer, credentials } = setupLegacyLogout({ stdinIsTty: false, pipedAnswers: ["y"] }); + return Effect.gen(function* () { + yield* legacyLogout(); + expect(credentials.deletedAll).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("honors SUPABASE_YES and logs out even when a piped 'n' is present", () => { + // Go reads `viper.GetBool("YES")` (incl. the SUPABASE_YES env var) BEFORE + // scanning stdin (`console.go:71`), so `SUPABASE_YES=1 printf 'n\n' | supabase + // logout` auto-confirms and deletes rather than consuming the piped `n`. The + // handler resolves `yes` via legacyResolveYes, not the raw --yes flag. + const prev = process.env["SUPABASE_YES"]; + process.env["SUPABASE_YES"] = "1"; + const { layer, credentials } = setupLegacyLogout({ stdinIsTty: false, pipedAnswers: ["n"] }); + return Effect.gen(function* () { + yield* legacyLogout(); + expect(credentials.deletedAll).toBe(true); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (prev === undefined) delete process.env["SUPABASE_YES"]; + else process.env["SUPABASE_YES"] = prev; + }), + ), + Effect.provide(layer), + ); + }); + it.live("not logged in: prints to stderr, exits 0, and does not sweep credentials", () => { const { layer, out, credentials } = setupLegacyLogout({ yes: true, diff --git a/apps/cli/src/legacy/commands/logout/logout.layers.ts b/apps/cli/src/legacy/commands/logout/logout.layers.ts index a2b44c218d..42fdc97a41 100644 --- a/apps/cli/src/legacy/commands/logout/logout.layers.ts +++ b/apps/cli/src/legacy/commands/logout/logout.layers.ts @@ -4,6 +4,7 @@ import { legacyCredentialsLayer } from "../../auth/legacy-credentials.layer.ts"; import { legacyCliConfigLayer } from "../../config/legacy-cli-config.layer.ts"; import { legacyDebugLoggerLayer } from "../../shared/legacy-debug-logger.layer.ts"; import { legacyTelemetryStateLayer } from "../../telemetry/legacy-telemetry-state.layer.ts"; +import { legacyPromptInputRuntimeLayer } from "../../shared/legacy-prompt-input.layer.ts"; import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; /** @@ -28,4 +29,5 @@ export const legacyLogoutRuntimeLayer = Layer.mergeAll( cliConfig, legacyTelemetryStateLayer, commandRuntimeLayer(["logout"]), + legacyPromptInputRuntimeLayer, ); diff --git a/apps/cli/src/legacy/commands/seed/buckets/buckets.command.ts b/apps/cli/src/legacy/commands/seed/buckets/buckets.command.ts index 20c9b269fe..23326c9e64 100644 --- a/apps/cli/src/legacy/commands/seed/buckets/buckets.command.ts +++ b/apps/cli/src/legacy/commands/seed/buckets/buckets.command.ts @@ -1,4 +1,4 @@ -import { Effect } from "effect"; +import { Effect, Layer } from "effect"; import { Command } from "effect/unstable/cli"; import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; @@ -6,6 +6,7 @@ import { withJsonErrorHandling } from "../../../../shared/output/json-error-hand import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; import { LegacySeedLinkedFlag, LegacySeedLocalFlag } from "../seed.flags.ts"; import { legacyAssertSeedTargetsExclusive } from "./buckets.flags.ts"; +import { legacyPromptInputRuntimeLayer } from "../../../shared/legacy-prompt-input.layer.ts"; import { legacyStorageGatewayRuntimeLayer } from "../../../shared/legacy-storage-runtime.layer.ts"; import { legacySeedBuckets } from "./buckets.handler.ts"; @@ -36,5 +37,10 @@ export const legacyBucketsCommand = Command.make("buckets").pipe( return yield* legacySeedBuckets(flags).pipe(withLegacyCommandInstrumentation({ flags })); }).pipe(withJsonErrorHandling), ), - Command.provide(legacyStorageGatewayRuntimeLayer(["seed", "buckets"])), + Command.provide( + Layer.mergeAll( + legacyStorageGatewayRuntimeLayer(["seed", "buckets"]), + legacyPromptInputRuntimeLayer, + ), + ), ); diff --git a/apps/cli/src/legacy/commands/seed/buckets/buckets.integration.test.ts b/apps/cli/src/legacy/commands/seed/buckets/buckets.integration.test.ts index 674cf9f82e..10d11218fe 100644 --- a/apps/cli/src/legacy/commands/seed/buckets/buckets.integration.test.ts +++ b/apps/cli/src/legacy/commands/seed/buckets/buckets.integration.test.ts @@ -8,7 +8,8 @@ import { Effect, Exit, Layer, Option } from "effect"; import * as HttpClient from "effect/unstable/http/HttpClient"; import type * as HttpClientError from "effect/unstable/http/HttpClientError"; -import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { mockOutput, mockTty } from "../../../../../tests/helpers/mocks.ts"; +import { mockLegacyPromptInput } from "../../../../../tests/helpers/legacy-prompt-input.ts"; import { LEGACY_VALID_REF, legacyJsonResponse, @@ -53,6 +54,8 @@ function setupLegacySeedBuckets( readonly format?: OutputFormat; readonly confirm?: ReadonlyArray; readonly promptConfirmFail?: boolean; + /** Piped (non-TTY) stdin answers, one consumed per confirmation prompt. */ + readonly pipedAnswers?: ReadonlyArray; readonly args?: ReadonlyArray; readonly yes?: boolean; /** Project ref returned by loadProjectRef for --linked tests. */ @@ -181,6 +184,9 @@ function setupLegacySeedBuckets( telemetry.layer, mockLegacyCliConfig({ workdir }), BunServices.layer, + // Seed-bucket prompts model an interactive user answering via `confirm`. + mockTty({ stdinIsTty: true, stdoutIsTty: false }), + mockLegacyPromptInput({ pipedLines: opts.pipedAnswers }), Layer.succeed(CliArgs, { args: opts.args ?? ["seed", "buckets"] }), Layer.succeed(LegacyYesFlag, opts.yes ?? false), projectRefLayer, diff --git a/apps/cli/src/legacy/commands/storage/rm/rm.command.ts b/apps/cli/src/legacy/commands/storage/rm/rm.command.ts index ebd45bbf79..c6d48cd614 100644 --- a/apps/cli/src/legacy/commands/storage/rm/rm.command.ts +++ b/apps/cli/src/legacy/commands/storage/rm/rm.command.ts @@ -1,10 +1,11 @@ -import { Effect } from "effect"; +import { Effect, Layer } from "effect"; import { Argument, Command, Flag } from "effect/unstable/cli"; import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; import { legacyRequireExperimental } from "../../../shared/legacy-experimental-gate.ts"; +import { legacyPromptInputRuntimeLayer } from "../../../shared/legacy-prompt-input.layer.ts"; import { legacyStorageGatewayRuntimeLayer } from "../../../shared/legacy-storage-runtime.layer.ts"; import { LegacyStorageLinkedFlagDef, @@ -59,5 +60,10 @@ export const legacyStorageRmCommand = Command.make("rm", config).pipe( }).pipe(withLegacyCommandInstrumentation({ flags: telemetryFlags })); }).pipe(withJsonErrorHandling), ), - Command.provide(legacyStorageGatewayRuntimeLayer(["storage", "rm"])), + Command.provide( + Layer.mergeAll( + legacyStorageGatewayRuntimeLayer(["storage", "rm"]), + legacyPromptInputRuntimeLayer, + ), + ), ); diff --git a/apps/cli/src/legacy/commands/storage/rm/rm.integration.test.ts b/apps/cli/src/legacy/commands/storage/rm/rm.integration.test.ts index cc35dfd5e5..c279b49460 100644 --- a/apps/cli/src/legacy/commands/storage/rm/rm.integration.test.ts +++ b/apps/cli/src/legacy/commands/storage/rm/rm.integration.test.ts @@ -119,6 +119,52 @@ describe("legacy storage rm", () => { }); }); + it.live("honors a piped 'y' on non-TTY stdin and deletes", () => { + // Go scans piped stdin before defaulting (`console.go:74-82`); a piped `y` + // overrides the `n` default and deletes, even on a non-terminal. + const { layer, requests, out } = setupLegacyStorage(tmp.current, { + toml: 'project_id = "test"\n', + local: true, + stdinIsTty: false, + pipedAnswers: ["y"], + routes: [{ method: "DELETE", match: DELETE_OBJECT("private"), body: [{ name: "a.pdf" }] }], + }); + return Effect.gen(function* () { + const exit = yield* legacyStorageRm({ + files: ["ss:///private/a.pdf"], + recursive: false, + linked: true, + local: true, + }).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + expect(requests.some((r) => r.method === "DELETE")).toBe(true); + // The consumed answer is echoed after the label (Go's non-TTY `PromptText`). + expect(out.stderrText).toContain("[y/N] y"); + }); + }); + + it.live("falls back to the default (no) on an unparseable piped answer", () => { + // Go's `parseYesNo` returns nil for unrecognized input (`console.go:84-93`), so + // `PromptYesNo` keeps the `n` default and the deletion is skipped. + const { layer, requests } = setupLegacyStorage(tmp.current, { + toml: 'project_id = "test"\n', + local: true, + stdinIsTty: false, + pipedAnswers: ["maybe"], + routes: [{ method: "DELETE", match: DELETE_OBJECT("private"), body: [] }], + }); + return Effect.gen(function* () { + const exit = yield* legacyStorageRm({ + files: ["ss:///private/a.pdf"], + recursive: false, + linked: true, + local: true, + }).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + expect(requests.some((r) => r.method === "DELETE")).toBe(false); + }); + }); + it.live("uses the default (no) when non-interactive and skips deletion", () => { const { layer, requests } = setupLegacyStorage(tmp.current, { toml: 'project_id = "test"\n', diff --git a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts index ac8e8e573a..e20a098d19 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts @@ -480,8 +480,9 @@ function legacyJoinSupabaseSeedPath(pattern: string): string { const DEFAULT_SUPABASE_ENV = "development"; /** - * Load the project's nested `.env` files into a lookup map, mirroring Go's - * `loadNestedEnv` + `loadDefaultEnv` (`pkg/config/config.go:1047-1085`). Go walks + * Load the project's nested `.env` files into a lookup map **and apply them to + * `process.env`** (godotenv `os.Setenv`, never overriding an existing value), + * mirroring Go's `loadNestedEnv` + `loadDefaultEnv` (`pkg/config/config.go:1047-1085`). Go walks * from the `supabase/` directory up to the repo root and, in each directory, * loads `.env..local`, `.env.local` (skipped when `SUPABASE_ENV=test`), * `.env.`, then `.env` via `godotenv.Load`, which never overrides a value @@ -536,6 +537,16 @@ export const legacyLoadProjectEnv = Effect.fnUntraced(function* ( } } } + // Mirror Go's `godotenv.Load` (`loadNestedEnv`): the loaded values are written + // into the process environment so downstream `process.env` readers see project + // `.env` values — matching Go's `viper.AutomaticEnv`. This is why, e.g., + // `SUPABASE_INTERNAL_IMAGE_REGISTRY` set only in `supabase/.env` reaches + // `legacyGetRegistryImageUrl` (which reads `process.env`) for native Docker + // pulls. `loaded` already excludes keys present in `process.env`, so an existing + // shell value is never overridden. + for (const [key, value] of Object.entries(loaded)) { + process.env[key] = value; + } return loaded; }); diff --git a/apps/cli/src/legacy/shared/legacy-prompt-input.layer.ts b/apps/cli/src/legacy/shared/legacy-prompt-input.layer.ts new file mode 100644 index 0000000000..7ba6d31386 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-prompt-input.layer.ts @@ -0,0 +1,66 @@ +import { BunServices } from "@effect/platform-bun"; +import { Effect, Layer, Option, Pull, Ref, Stdio, Stream } from "effect"; + +import { LegacyPromptInput } from "./legacy-prompt-input.service.ts"; + +// Go's non-TTY `ReadLine` timeout (`apps/cli-go/internal/utils/console.go:36`): a +// non-terminal read that yields no line within this window falls back to the +// prompt's default instead of blocking on EOF. +const NON_TTY_TIMEOUT = "100 millis"; + +/** + * Builds {@link LegacyPromptInput} over the platform stdin stream. Reads piped + * stdin **lazily, one line at a time** — mirroring Go's persistent `bufio.Scanner` + * with its non-TTY timeout (`console.go:31-61`): + * - `Stream.splitLines` preserves leading/interior blank lines (Go scans then + * trims one line at a time), so answers to successive prompts stay aligned; + * - `Stream.toPull` + `Effect.timeoutOption` return as soon as a line is + * available and never wait for EOF, so a pipe that stays open (e.g. + * `yes y | …`) answers the first prompt instead of hanging; + * - each line is trimmed to match Go's `strings.TrimSpace(scanner.Text())`. + */ +const legacyPromptInputLayer = Layer.effect( + LegacyPromptInput, + Effect.gen(function* () { + const stdio = yield* Stdio.Stdio; + const lines = stdio.stdin.pipe(Stream.decodeText(), Stream.splitLines); + const pull = yield* Stream.toPull(lines); + // Leftover lines from the last pulled chunk (a single pull may yield several). + const bufferRef = yield* Ref.make>([]); + // Pull the next chunk of lines: success -> Some(chunk); EOF or a read error + // -> None. The timeout is applied by the caller, per prompt. + const readChunk = Pull.matchEffect(pull, { + onSuccess: (chunk) => Effect.succeed(Option.some(chunk)), + onFailure: () => Effect.succeedNone, + onDone: () => Effect.succeedNone, + }); + const nextLine = Effect.gen(function* () { + const buffered = yield* Ref.get(bufferRef); + if (buffered.length > 0) { + yield* Ref.set(bufferRef, buffered.slice(1)); + return Option.some((buffered[0] ?? "").trim()); + } + // Bounded by Go's non-TTY timeout so an open pipe without a newline (e.g. + // `yes y | …`) doesn't block on EOF. Outer `None` = timed out; inner `None` + // = EOF / read error; either way the prompt takes its default. + const pulled = yield* readChunk.pipe(Effect.timeoutOption(NON_TTY_TIMEOUT)); + if (Option.isNone(pulled) || Option.isNone(pulled.value)) { + return Option.none(); + } + const chunk = pulled.value.value; + yield* Ref.set(bufferRef, chunk.slice(1)); + return Option.some((chunk[0] ?? "").trim()); + }); + return { nextLine }; + }), +); + +/** + * Self-contained {@link LegacyPromptInput} provider: bundles the platform + * (`Stdio`) dependency so a command only needs to merge this one layer. Provided + * explicitly per command (not via sibling leakage in a `Layer.mergeAll`); see + * `encryption/update-root-key/update-root-key.command.ts`. + */ +export const legacyPromptInputRuntimeLayer = legacyPromptInputLayer.pipe( + Layer.provide(BunServices.layer), +); diff --git a/apps/cli/src/legacy/shared/legacy-prompt-input.service.ts b/apps/cli/src/legacy/shared/legacy-prompt-input.service.ts new file mode 100644 index 0000000000..e8556e8415 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-prompt-input.service.ts @@ -0,0 +1,21 @@ +import type { Effect, Option } from "effect"; +import { Context } from "effect"; + +interface LegacyPromptInputShape { + /** + * The next line of piped (non-TTY) stdin, or `None` once stdin is empty or + * exhausted. Mirrors Go's persistent `bufio.Scanner` over `os.Stdin` + * (`apps/cli-go/internal/utils/console.go:20,50`): each prompt consumes the + * next line, and an empty/exhausted scan falls back to the prompt's default. + */ + readonly nextLine: Effect.Effect>; +} + +/** + * Per-command reader for piped stdin. Scoped per command so a command issuing + * several confirmations (e.g. `config push`, `seed buckets`) answers each prompt + * from a distinct piped line, exactly as Go's single scanner does. + */ +export class LegacyPromptInput extends Context.Service()( + "supabase/legacy/PromptInput", +) {} diff --git a/apps/cli/src/legacy/shared/legacy-prompt-yes-no.ts b/apps/cli/src/legacy/shared/legacy-prompt-yes-no.ts index 13ad746906..24fe1fd4a5 100644 --- a/apps/cli/src/legacy/shared/legacy-prompt-yes-no.ts +++ b/apps/cli/src/legacy/shared/legacy-prompt-yes-no.ts @@ -1,16 +1,37 @@ -import { Effect } from "effect"; +import { Effect, Option } from "effect"; import { Output } from "../../shared/output/output.service.ts"; +import { Tty } from "../../shared/runtime/tty.service.ts"; +import { LegacyPromptInput } from "./legacy-prompt-input.service.ts"; + +/** + * Port of Go's `parseYesNo` (`apps/cli-go/internal/utils/console.go:84-93`): + * case-insensitive and trimmed. `y`/`yes` → `true`, `n`/`no` → `false`, + * anything else → `undefined` (caller falls back to the default). + */ +export const legacyParseYesNo = (input: string): boolean | undefined => { + const s = input.trim().toLowerCase(); + if (s === "y" || s === "yes") { + return true; + } + if (s === "n" || s === "no") { + return false; + } + return undefined; +}; /** * Confirm-or-default prompt mirroring Go's `console.PromptYesNo` - * (`apps/cli-go/internal/utils/console.go`), shared by `seed buckets` and - * `storage rm`: + * (`apps/cli-go/internal/utils/console.go:64-82`), shared by `config push`, + * `db pull`, `seed buckets`, and `storage rm`: * - when `yes` is set, echoes `