diff --git a/apps/cli/docs/go-cli-porting-status.md b/apps/cli/docs/go-cli-porting-status.md index fb8beae0a9..42fb725a0e 100644 --- a/apps/cli/docs/go-cli-porting-status.md +++ b/apps/cli/docs/go-cli-porting-status.md @@ -19,7 +19,7 @@ Percentages and counts below are based on final leaf commands only. Command grou | Metric | Count | Percent | | ------------------------- | ------: | ------: | -| Fully ported commands | 8 / 94 | 8.5% | +| Fully ported commands | 9 / 94 | 9.6% | | Partially ported commands | 55 / 94 | 58.5% | ## Family Summary @@ -28,7 +28,7 @@ Percentages and counts below are based on final leaf commands only. Command grou | ------------------------- | -------------: | --------: | --------: | ---------: | ----------------: | | Quick Start | 1 | 0 (0%) | 0 (0%) | 1 (100%) | 0 (0%) | | Project / Stack Lifecycle | 9 | 2 (22.2%) | 7 (77.8%) | 0 (0%) | 9 (100%) | -| Database | 19 | 2 (10.5%) | 0 (0%) | 17 (89.5%) | 2 (10.5%) | +| Database | 19 | 3 (15.8%) | 0 (0%) | 16 (84.2%) | 3 (15.8%) | | Code Generation | 3 | 0 (0%) | 0 (0%) | 3 (100%) | 0 (0%) | | Functions | 6 | 0 (0%) | 0 (0%) | 6 (100%) | 0 (0%) | | Storage | 4 | 0 (0%) | 0 (0%) | 4 (100%) | 0 (0%) | @@ -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` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | -| `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` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | -| `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` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | -| `migration fetch` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | -| `migration list` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | -| `migration new` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | -| `migration repair` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | -| `migration squash` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | -| `migration up` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | -| `seed buckets` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | -| `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` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | +| `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` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | +| `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` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | +| `migration fetch` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | +| `migration list` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | +| `migration new` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | +| `migration repair` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | +| `migration squash` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | +| `migration up` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | +| `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 @@ -297,7 +297,7 @@ Legend: | `storage rm` | `wrapped` | [`../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` | `wrapped` | [`../src/legacy/commands/seed/buckets/buckets.command.ts`](../src/legacy/commands/seed/buckets/buckets.command.ts) | +| `seed buckets` | `ported` | [`../src/legacy/commands/seed/buckets/buckets.command.ts`](../src/legacy/commands/seed/buckets/buckets.command.ts) | | `db diff` | `wrapped` | [`../src/legacy/commands/db/diff/diff.command.ts`](../src/legacy/commands/db/diff/diff.command.ts) | | `db dump` | `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) | diff --git a/apps/cli/package.json b/apps/cli/package.json index 097e5af91a..866f055a5f 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -115,7 +115,8 @@ "src/shared/telemetry/event-catalog.ts" ], "ignoreBinaries": [ - "nx" + "nx", + "mkfifo" ], "ignoreDependencies": [ "@parcel/watcher-darwin-arm64", diff --git a/apps/cli/src/legacy/commands/bootstrap/bootstrap.integration.test.ts b/apps/cli/src/legacy/commands/bootstrap/bootstrap.integration.test.ts index b7e31fdafd..f43cc2164c 100644 --- a/apps/cli/src/legacy/commands/bootstrap/bootstrap.integration.test.ts +++ b/apps/cli/src/legacy/commands/bootstrap/bootstrap.integration.test.ts @@ -153,6 +153,7 @@ function setup(opts: SetupOpts = {}) { BunServices.layer, out.layer, api.layer, + api.factoryLayer, api.httpClientLayer, cliConfig, mockTty({ stdinIsTty: opts.stdinIsTty ?? true, stdoutIsTty: false }), diff --git a/apps/cli/src/legacy/commands/bootstrap/bootstrap.workdir-cache.integration.test.ts b/apps/cli/src/legacy/commands/bootstrap/bootstrap.workdir-cache.integration.test.ts index 2905edb629..1e224b0f90 100644 --- a/apps/cli/src/legacy/commands/bootstrap/bootstrap.workdir-cache.integration.test.ts +++ b/apps/cli/src/legacy/commands/bootstrap/bootstrap.workdir-cache.integration.test.ts @@ -149,6 +149,7 @@ describe("legacy bootstrap linked-project cache location", () => { BunServices.layer, out.layer, api.layer, + api.factoryLayer, api.httpClientLayer, configLayer, cacheLayer, diff --git a/apps/cli/src/legacy/commands/config/push/config-sync/api.sync.ts b/apps/cli/src/legacy/commands/config/push/config-sync/api.sync.ts index cf842adc32..c555f72ff9 100644 --- a/apps/cli/src/legacy/commands/config/push/config-sync/api.sync.ts +++ b/apps/cli/src/legacy/commands/config/push/config-sync/api.sync.ts @@ -2,7 +2,7 @@ import type { ProjectConfig } from "@supabase/config"; import { diff } from "./config-sync.diff.ts"; import { encodeToml, type TomlField, type TomlValue } from "./config-sync.toml.ts"; -import { intToUint } from "./config-sync.units.ts"; +import { intToUint } from "../../../../shared/legacy-size-units.ts"; /** * Push-subset of Go's `api` struct (`pkg/config/api.go`). Only `toml`-tagged diff --git a/apps/cli/src/legacy/commands/config/push/config-sync/auth.sync.ts b/apps/cli/src/legacy/commands/config/push/config-sync/auth.sync.ts index 447187f30d..17a53670ea 100644 --- a/apps/cli/src/legacy/commands/config/push/config-sync/auth.sync.ts +++ b/apps/cli/src/legacy/commands/config/push/config-sync/auth.sync.ts @@ -13,7 +13,7 @@ import type { ProjectConfig } from "@supabase/config"; import { diff } from "./config-sync.diff.ts"; import { type TomlField, type TomlValue, encodeToml } from "./config-sync.toml.ts"; -import { intToUint } from "./config-sync.units.ts"; +import { intToUint } from "../../../../shared/legacy-size-units.ts"; import { durationString, parseDuration, secondsToDurationString } from "./config-sync.duration.ts"; import { secretHash } from "./config-sync.secret.ts"; diff --git a/apps/cli/src/legacy/commands/config/push/config-sync/db.sync.ts b/apps/cli/src/legacy/commands/config/push/config-sync/db.sync.ts index b0a35d609c..fee74e8c52 100644 --- a/apps/cli/src/legacy/commands/config/push/config-sync/db.sync.ts +++ b/apps/cli/src/legacy/commands/config/push/config-sync/db.sync.ts @@ -2,7 +2,7 @@ import type { ProjectConfig } from "@supabase/config"; import { diff } from "./config-sync.diff.ts"; import { encodeToml, type TomlField, type TomlValue } from "./config-sync.toml.ts"; -import { intToUint } from "./config-sync.units.ts"; +import { intToUint } from "../../../../shared/legacy-size-units.ts"; /** * Push-subset of Go's `db.Settings`, `db.NetworkRestrictions`, diff --git a/apps/cli/src/legacy/commands/config/push/config-sync/storage.sync.ts b/apps/cli/src/legacy/commands/config/push/config-sync/storage.sync.ts index 65c10c7167..66705a0e7b 100644 --- a/apps/cli/src/legacy/commands/config/push/config-sync/storage.sync.ts +++ b/apps/cli/src/legacy/commands/config/push/config-sync/storage.sync.ts @@ -2,7 +2,7 @@ import type { ProjectConfig } from "@supabase/config"; import { diff } from "./config-sync.diff.ts"; import { encodeToml, type TomlField, type TomlValue } from "./config-sync.toml.ts"; -import { bytesSize, intToUint, ramInBytes } from "./config-sync.units.ts"; +import { bytesSize, intToUint, ramInBytes } from "../../../../shared/legacy-size-units.ts"; /** * Push-subset of Go's `storage` struct (`pkg/config/storage.go`). `toml:"-"` diff --git a/apps/cli/src/legacy/commands/link/link.handler.ts b/apps/cli/src/legacy/commands/link/link.handler.ts index 88e2503771..f1cca7c3e5 100644 --- a/apps/cli/src/legacy/commands/link/link.handler.ts +++ b/apps/cli/src/legacy/commands/link/link.handler.ts @@ -17,7 +17,8 @@ import { GroupProject, } from "../../../shared/telemetry/event-catalog.ts"; import { legacyDashboardUrl } from "../../shared/legacy-profile.ts"; -import { mapLegacyHttpError, sanitizeLegacyErrorBody } from "../../shared/legacy-http-errors.ts"; +import { legacyMapTenantApiKeysError } from "../../shared/legacy-get-tenant-api-keys.ts"; +import { sanitizeLegacyErrorBody } from "../../shared/legacy-http-errors.ts"; import { legacyLinkServicesCore } from "../../shared/legacy-link-services-core.ts"; import { legacyExtractServiceKeys } from "../../shared/legacy-tenant-keys.ts"; import { legacyTempPaths } from "../../shared/legacy-temp-paths.ts"; @@ -73,12 +74,9 @@ const classifyProjectError = ( type WriteTempFile = (filePath: string, content: string) => Effect.Effect; -const mapApiKeysError = mapLegacyHttpError({ +const mapApiKeysError = legacyMapTenantApiKeysError({ networkError: LegacyLinkApiKeysNetworkError, statusError: LegacyLinkAuthTokenError, - networkMessage: (cause) => `failed to get api keys: ${cause}`, - statusMessage: (_status, body) => - `Authorization failed for the access token and project ref pair: ${body}`, }); export const legacyLink = Effect.fn("legacy.link")(function* (flags: LegacyLinkFlags) { diff --git a/apps/cli/src/legacy/commands/seed/buckets/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/seed/buckets/SIDE_EFFECTS.md index ac81101201..67676ca81d 100644 --- a/apps/cli/src/legacy/commands/seed/buckets/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/seed/buckets/SIDE_EFFECTS.md @@ -1,57 +1,181 @@ # `supabase seed buckets` +Seeds Supabase Storage buckets from `[storage.buckets]` and +`[storage.vector]` in `supabase/config.toml`. Port of +`apps/cli-go/internal/seed/buckets/buckets.go`. Without `--linked` the local +stack is used; with `--linked` the remote project is used. + ## Files Read -| Path | Format | When | -| -------------------------------- | ---------- | ------------------------------------------------- | -| `/supabase/config.toml` | TOML | always, to read `[storage.buckets]` configuration | -| `~/.supabase/access-token` | plain text | when `--linked` and `SUPABASE_ACCESS_TOKEN` unset | +| Path | Format | When | +| ---------------------------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | always, to read `[storage.buckets]` / `[storage.vector]` config; on `--linked`, the matching `[remotes.]` block (whose `project_id` equals the resolved project ref) is merged over the base config before decode, so remote-specific storage config takes effect | +| `/supabase//**` | any (bytes) | per configured bucket with a non-empty `objects_path`, recursively; a relative `objects_path` resolves under `supabase/` (Go `config.go:757-759`), an absolute path is used as-is | +| `/supabase/` | PEM text | local runs only, when `[api.tls] enabled = true` AND `api.tls.cert_path` is set; the file is read to obtain the CA certificate for trusting the local Kong HTTPS gateway. If `cert_path` is not set, the embedded `kong.local.crt` constant is used instead (no file read). | +| `/supabase/` | PEM text | local runs only, when `[api.tls] enabled = true` AND `api.tls.key_path` is set; read purely to validate the cert/key pairing (Go `config.go:845-861`) — the key content is not used by the CLI. If `cert_path` is set without `key_path` (or vice-versa), the command exits `1`. | ## Files Written -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | +| Path | Format | When | +| ---------------------------------------------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | `--linked` only, once the project ref resolves and no cache exists yet — mirrors Go's `ensureProjectGroupsCached` (`cmd/root.go`). Best-effort (auth/network/write errors are swallowed). Local runs never write it. | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | -------------------- | ------------ | ------------------------- | ---------------------- | -| `POST` | `/storage/v1/bucket` | Bearer token | `{id, name, public, ...}` | `{name}` | +### Storage gateway routes (local and remote) + +**Local:** `api.external_url` (default `http://:54321`, where `` follows Go's +`utils.GetHostname`: `SUPABASE_SERVICES_HOSTNAME` → TCP `DOCKER_HOST` → `127.0.0.1`). + +**Remote (`--linked`):** `https://.` (default host: `supabase.co`). + +Auth: an `apikey` header set to the service-role key; an `Authorization: Bearer ` +header is also sent, except when the key is an opaque `sb_...` key, which Go's +`withAuthToken` (`pkg/fetcher/gateway.go:22`) treats as a non-JWT and omits. + +| Method | Path | Auth | Request body | Response (used fields) | +| -------- | --------------------------------------- | ------------ | --------------------------------------------------------------------------------------- | -------------------------------------- | +| `GET` | `/storage/v1/bucket` | service-role | none | `[{name, id}]` | +| `POST` | `/storage/v1/bucket` | service-role | `{name, public, file_size_limit?, allowed_mime_types?}` | — (created) | +| `PUT` | `/storage/v1/bucket/{id}` | service-role | `{public, file_size_limit?, allowed_mime_types?}` | — (updated) | +| `POST` | `/storage/v1/vector/ListVectorBuckets` | service-role | `{}` | `{vectorBuckets:[{vectorBucketName}]}` | +| `POST` | `/storage/v1/vector/CreateVectorBucket` | service-role | `{vectorBucketName}` | — (created) | +| `POST` | `/storage/v1/vector/DeleteVectorBucket` | service-role | `{vectorBucketName}` | — (pruned) | +| `POST` | `/storage/v1/object/{bucket}/{key}` | service-role | raw file bytes; headers `Content-Type`, `Cache-Control: max-age=3600`, `x-upsert: true` | — (uploaded) | +| `GET` | `/storage/v1/iceberg/bucket` | service-role | none | `[{name, id, created_at, updated_at}]` | +| `POST` | `/storage/v1/iceberg/bucket` | service-role | `{bucketName}` | — (created) | +| `DELETE` | `/storage/v1/iceberg/bucket/{name}` | service-role | none | — (pruned) | + +A bucket that omits `file_size_limit` (or sets it to `0`) inherits the +storage-level `[storage].file_size_limit` (Go `config.go:753-756`). The +storage-level limit and all bucket sizes are parsed up front (the storage-level +one unconditionally, even with only vector buckets), so an invalid value fails +before any Storage call. +`file_size_limit` is omitted from the body when the resolved value is `0`; +`allowed_mime_types` is omitted when empty (Go `omitempty`). + +Analytics bucket routes (`/storage/v1/iceberg/...`) are only reached when +`[storage.analytics].enabled = true` AND `--linked` is passed. + +### Management API routes (remote `--linked` only, when env var not set) + +| Method | Path | When | Response (used fields) | +| ------ | ----------------------------------------- | ------------------------------------------- | ---------------------------------------------- | +| `GET` | `/v1/projects/{ref}/api-keys?reveal=true` | `SUPABASE_AUTH_SERVICE_ROLE_KEY` is not set | `[{name, api_key, type, secret_jwt_template}]` | ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | -------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token for `--linked` mode | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| Variable | Purpose | Required? | +| -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | +| `SUPABASE_SERVICES_HOSTNAME` | override the local services host (highest precedence) | no | +| `DOCKER_HOST` | when a `tcp://host:port` endpoint, the local services host falls back to it before `127.0.0.1` | no | +| `SUPABASE_AUTH_SERVICE_ROLE_KEY` | when set and non-empty: for `--linked`, used as the service-role key (skips Management API key fetch); for local runs, used as the service-role key instead of `auth.service_role_key` (Go Viper AutomaticEnv parity) | no | +| `SUPABASE_AUTH_JWT_SECRET` | local runs only: when set and non-empty, overrides `auth.jwt_secret` for service-role key derivation (Go Viper `AutomaticEnv`+`SUPABASE_` prefix parity, `config.go:492-497`) | no | ## Exit Codes -| Code | Condition | -| ---- | ------------------------------------- | -| `0` | success | -| `1` | API error (non-2xx response) | -| `1` | authentication error (no token found) | -| `1` | config parsing failure | +| Code | Condition | +| ---- | ------------------------------------------------------------------------------------------------------------- | +| `0` | success (including the empty-config short-circuit) | +| `1` | `supabase/config.toml` parse failure | +| `1` | `auth.jwt_secret` (or `SUPABASE_AUTH_JWT_SECRET`) set but shorter than 16 characters | +| `1` | `[storage.buckets]` entry has an invalid name (contains characters outside Go's `ValidateBucketName` regex) | +| `1` | `api.tls.cert_path` set without `api.tls.key_path` (or vice-versa) when `api.tls.enabled = true` (local only) | +| `1` | `api.tls.cert_path` or `api.tls.key_path` points to an unreadable file (local TLS only) | +| `1` | Storage API error (non-2xx) other than vector-unavailable | +| `1` | network / connection failure to the Storage gateway | +| `1` | malformed list response (a 200 body whose shape doesn't decode, mirroring Go's strict `ParseJSON`) | +| `1` | unreadable `objects_path` (filesystem error during walk/upload) | + +## Telemetry Events Fired + +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | ----------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` | + +No custom `phtelemetry.*` events exist in the Go command. ## Output ### `--output-format text` (Go CLI compatible) -Prints progress and success messages as buckets are created. +All progress is written to **stderr** (stdout stays empty), byte-matching Go: + +``` +Creating Storage bucket: +Updating Storage bucket: +Updating analytics buckets... +Bucket already exists: +Creating analytics bucket: +Pruning analytics bucket: +Updating vector buckets... +Bucket already exists: +Creating vector bucket: +Pruning vector bucket: +Uploading: / => / +Skipping non-regular file: +WARNING: Vector buckets are not available in this project's region yet. Skipping vector bucket seeding. +WARNING: Vector buckets are not available in the local storage service. If this project is linked, run `supabase link` to update service versions, then restart the local stack. Skipping vector bucket seeding. +``` + +Interactive (TTY) prompts: + +``` +Bucket already exists. Do you want to overwrite its properties? [Y/n] +Bucket not found in supabase/config.toml. Do you want to prune it? [y/N] +``` ### `--output-format json` -Not applicable (proxied to Go binary). +Additive (no Go equivalent). A final `result` object summarising the run is +emitted on stdout; progress/prompts are suppressed (prompts use their defaults: +overwrite → yes, prune → no). ### `--output-format stream-json` -Not applicable (proxied to Go binary). +Additive. NDJSON events; the operation's progress lines are suppressed from +stdout and a terminal `result`/`error` event is emitted. ## Notes -- Seeds storage buckets declared in `[storage.buckets]` in `supabase/config.toml`. -- `--local` (default `true`) seeds the local database. -- `--linked` seeds the linked project. -- Phase 0 proxy: all invocations are forwarded to the bundled Go binary. +- **Remote (`--linked`) — config override merge.** The project ref is resolved + BEFORE config is loaded. `loadProjectConfig` then merges the `[remotes.]` + block whose `project_id` equals the resolved ref over the base config (including + `storage.buckets`, `storage.vector`, `storage.analytics`), mirroring Go's + `Config.ProjectId = ProjectRef` → `config.Load` sequence (`config.go:505-518`). + Local runs load the base config verbatim with no merge. +- **Remote (`--linked`).** The remote base URL is `https://.` + (default: `supabase.co`). The service-role key is read from + `SUPABASE_AUTH_SERVICE_ROLE_KEY` if set; otherwise fetched via + `GET /v1/projects/{ref}/api-keys?reveal=true`. +- **Bucket name validation.** Every `[storage.buckets]` name is validated against + Go's `ValidateBucketName` regex (`^(\w|!|-|\.|\*|'|\(|\)| |&|\$|@|=|;|:|\+|,|\?)*$`, + `config.go:1382`) before any Storage call. Invalid names exit `1` with the exact + Go error message. Vector and analytics bucket names are NOT validated. +- **Local env-var overrides.** For local runs, `SUPABASE_AUTH_JWT_SECRET` (if set + and non-empty) overrides `auth.jwt_secret`, and `SUPABASE_AUTH_SERVICE_ROLE_KEY` + (if set and non-empty) overrides `auth.service_role_key`, mirroring Go's Viper + `AutomaticEnv`+`SUPABASE_` prefix (`config.go:492-497`). The `<16`-char rejection + applies to the resolved secret (env or config value). +- **Analytics buckets.** Analytics bucket upsert (`/storage/v1/iceberg/...`) is + gated on `[storage.analytics].enabled = true` AND `--linked`. It is never + reached for local runs. Errors from analytics routes propagate (no graceful skip). +- **Vector graceful skip.** When vector buckets are configured but the local + service does not support them (`FeatureNotEnabled`, `Vector service not +configured`, or a 404 on `ListVectorBuckets`), a WARNING is printed and object + upload still proceeds; the command exits `0`. +- **Idempotent.** Existing buckets are updated (after an overwrite confirm), + objects are uploaded with `x-upsert: true`. +- **Content-Type** for uploaded objects mirrors Go (`objects.go:77-108`): the first + 512 bytes are sniffed with a 1:1 port of `http.DetectContentType` + (`legacy/shared/legacy-detect-content-type.ts`), and only a generic `text/plain` + result is refined by extension via Go's built-in `mime` table. (Go's + `mime.TypeByExtension` also consults the host OS MIME database, which is + host-dependent and not reproduced; the deterministic built-in table is used.) +- **Local Kong TLS.** When `[api.tls] enabled = true` for a local stack, the + cert/key pairing is validated before seeding (Go `(*api).Validate`, `config.go:845-861`): + `cert_path` and `key_path` must both be set or both absent; setting only one exits `1`. + When both are set, both files are read for validation; `cert_path` provides the CA PEM + used to trust the Kong gateway. If neither is set, the embedded `kong.local.crt` constant + is used. Resolved against `/supabase/` (or absolute path as-is). The CA is + injected into Bun's `fetch` via `tls: { ca: }` — no system trust store modification. diff --git a/apps/cli/src/legacy/commands/seed/buckets/buckets.classify.ts b/apps/cli/src/legacy/commands/seed/buckets/buckets.classify.ts new file mode 100644 index 0000000000..f9af8b6ef8 --- /dev/null +++ b/apps/cli/src/legacy/commands/seed/buckets/buckets.classify.ts @@ -0,0 +1,27 @@ +/** + * Vector-bucket error classifiers — ports of `isVectorBucketsFeatureNotEnabled` + * and `isLocalVectorBucketsUnavailable` (`apps/cli-go/internal/seed/buckets/buckets.go:71-84`). + * + * Both inspect the error message string. The Storage gateway client raises + * status errors whose message reproduces Go's `Error status : `, so the + * same substring checks apply. + */ + +/** Remote region has not enabled vector buckets yet (`buckets.go:71-73`). */ +export function legacyIsVectorBucketsFeatureNotEnabled(message: string): boolean { + return message.includes("FeatureNotEnabled"); +} + +/** + * The local Storage service does not expose the vector routes (`buckets.go:75-84`): + * either it reports the vector service is not configured, or the `ListVectorBuckets` + * route returns 404 (older local image without vector support). + */ +export function legacyIsLocalVectorBucketsUnavailable(message: string): boolean { + return ( + message.includes("Vector service not configured") || + (message.includes("Error status 404:") && + message.includes("Route POST:") && + message.includes("ListVectorBuckets")) + ); +} diff --git a/apps/cli/src/legacy/commands/seed/buckets/buckets.classify.unit.test.ts b/apps/cli/src/legacy/commands/seed/buckets/buckets.classify.unit.test.ts new file mode 100644 index 0000000000..5c56967858 --- /dev/null +++ b/apps/cli/src/legacy/commands/seed/buckets/buckets.classify.unit.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { + legacyIsLocalVectorBucketsUnavailable, + legacyIsVectorBucketsFeatureNotEnabled, +} from "./buckets.classify.ts"; + +describe("legacyIsVectorBucketsFeatureNotEnabled", () => { + it("matches when the message mentions FeatureNotEnabled", () => { + expect( + legacyIsVectorBucketsFeatureNotEnabled('Error status 400: {"code":"FeatureNotEnabled"}'), + ).toBe(true); + }); + + it("does not match an unrelated error", () => { + expect(legacyIsVectorBucketsFeatureNotEnabled("Error status 500: boom")).toBe(false); + }); +}); + +describe("legacyIsLocalVectorBucketsUnavailable", () => { + it("matches the 'Vector service not configured' message", () => { + expect( + legacyIsLocalVectorBucketsUnavailable( + "Error status 409: The feature Vector service not configured is not enabled", + ), + ).toBe(true); + }); + + it("matches a 404 on the ListVectorBuckets route", () => { + expect( + legacyIsLocalVectorBucketsUnavailable( + "Error status 404: Route POST:/vector/ListVectorBuckets not found", + ), + ).toBe(true); + }); + + it("does not match a 404 on a different route", () => { + expect( + legacyIsLocalVectorBucketsUnavailable("Error status 404: Route POST:/something not found"), + ).toBe(false); + }); + + it("does not match an unrelated error", () => { + expect(legacyIsLocalVectorBucketsUnavailable("Error status 500: boom")).toBe(false); + }); +}); 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 ee781406b7..cff5e26433 100644 --- a/apps/cli/src/legacy/commands/seed/buckets/buckets.command.ts +++ b/apps/cli/src/legacy/commands/seed/buckets/buckets.command.ts @@ -1,6 +1,13 @@ +import { Effect } from "effect"; import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; -import { legacyBuckets } from "./buckets.handler.ts"; + +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 { legacyAssertSeedTargetsExclusive } from "./buckets.flags.ts"; +import { legacySeedRuntimeLayer } from "../seed.layers.ts"; +import { legacySeedBuckets } from "./buckets.handler.ts"; const config = { linked: Flag.boolean("linked").pipe(Flag.withDescription("Seeds the linked project.")), @@ -12,5 +19,15 @@ export type LegacyBucketsFlags = CliCommand.Command.Config.Infer; export const legacyBucketsCommand = Command.make("buckets", config).pipe( Command.withDescription("Seed buckets declared in [storage.buckets]."), Command.withShortDescription("Seed buckets declared in [storage.buckets]"), - Command.withHandler((flags) => legacyBuckets(flags)), + Command.withHandler((flags) => + Effect.gen(function* () { + // Enforce --local/--linked mutual exclusivity BEFORE instrumentation, so a + // flag-validation rejection doesn't emit `cli_command_executed` (Go rejects + // it at cobra flag validation, before RunE/PostRun). + const cliArgs = yield* CliArgs; + yield* legacyAssertSeedTargetsExclusive(cliArgs.args); + return yield* legacySeedBuckets(flags).pipe(withLegacyCommandInstrumentation({ flags })); + }).pipe(withJsonErrorHandling), + ), + Command.provide(legacySeedRuntimeLayer(["seed", "buckets"])), ); diff --git a/apps/cli/src/legacy/commands/seed/buckets/buckets.e2e.test.ts b/apps/cli/src/legacy/commands/seed/buckets/buckets.e2e.test.ts new file mode 100644 index 0000000000..4a7ff14e3b --- /dev/null +++ b/apps/cli/src/legacy/commands/seed/buckets/buckets.e2e.test.ts @@ -0,0 +1,53 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; + +import { runSupabase } from "../../../../../tests/helpers/cli.ts"; + +const E2E_TIMEOUT_MS = 30_000; + +/** + * Golden-path e2e: exercises the real compiled-binary boundary for the two + * network-free paths of `seed buckets`: + * - an empty `[storage]` config is a no-op (exit 0, no stdout); + * - `--local --linked` is rejected by the mutually-exclusive flag check. + * Bucket/object seeding parity is covered by the integration + unit suites. + */ +describe("supabase seed buckets (legacy)", () => { + let projectDir: string; + + beforeAll(() => { + projectDir = mkdtempSync(join(tmpdir(), "supabase-seed-buckets-e2e-")); + mkdirSync(join(projectDir, "supabase"), { recursive: true }); + writeFileSync(join(projectDir, "supabase", "config.toml"), 'project_id = "test"\n'); + }); + + afterAll(() => { + rmSync(projectDir, { recursive: true, force: true }); + }); + + test( + "is a no-op with exit 0 when no buckets are configured", + { timeout: E2E_TIMEOUT_MS }, + async () => { + const { exitCode, stdout } = await runSupabase(["seed", "buckets"], { + entrypoint: "legacy", + cwd: projectDir, + }); + expect(exitCode).toBe(0); + expect(stdout.trim()).toBe(""); + }, + ); + + test("rejects passing both --local and --linked", { timeout: E2E_TIMEOUT_MS }, async () => { + const { exitCode, stdout, stderr } = await runSupabase( + ["seed", "buckets", "--local", "--linked"], + { entrypoint: "legacy", cwd: projectDir }, + ); + expect(exitCode).toBe(1); + expect(`${stdout}${stderr}`).toContain( + "if any flags in the group [linked local] are set none of the others can be", + ); + }); +}); diff --git a/apps/cli/src/legacy/commands/seed/buckets/buckets.errors.ts b/apps/cli/src/legacy/commands/seed/buckets/buckets.errors.ts new file mode 100644 index 0000000000..b5b72fa75f --- /dev/null +++ b/apps/cli/src/legacy/commands/seed/buckets/buckets.errors.ts @@ -0,0 +1,80 @@ +import { Data } from "effect"; + +/** + * Domain errors for `supabase seed buckets`. + * + * The Storage service-gateway calls fail with one of two shapes, mirroring Go's + * `pkg/fetcher`: + * - transport failure (`failed to execute http request`) → + * `LegacySeedStorageNetworkError` + * - non-2xx response (`Error status : `, `pkg/fetcher/http.go:112`) → + * `LegacySeedStorageStatusError` + * + * `message` reproduces Go's verbatim error text so the vector graceful-skip + * classifiers in `buckets.classify.ts` match on the same substrings Go inspects. + */ +export class LegacySeedStorageNetworkError extends Data.TaggedError( + "LegacySeedStorageNetworkError", +)<{ + readonly message: string; +}> {} + +export class LegacySeedStorageStatusError extends Data.TaggedError("LegacySeedStorageStatusError")<{ + readonly status: number; + readonly body: string; + readonly message: string; +}> {} + +/** + * Raised when `supabase/config.toml` cannot be parsed. Mirrors the `config push` + * CLI-1489 tradeoff (`config/push/push.handler.ts:96-114`): `loadProjectConfig` + * raises `ProjectConfigParseError` on `env(...)` refs over numeric/bool fields, + * which Go resolves transparently. + */ +export class LegacySeedConfigLoadError extends Data.TaggedError("LegacySeedConfigLoadError")<{ + readonly message: string; +}> {} + +/** + * Raised when `--local` and `--linked` are both passed, reproducing cobra's + * `MarkFlagsMutuallyExclusive("local", "linked")` (`apps/cli-go/cmd/seed.go:32`). + */ +export class LegacySeedMutuallyExclusiveFlagsError extends Data.TaggedError( + "LegacySeedMutuallyExclusiveFlagsError", +)<{ + readonly message: string; +}> {} + +/** + * Raised on `--linked` when the project's api-keys response yields no keys, + * mirroring Go's `tenant.GetApiKeys` → `errMissingKey` ("Anon key not found.", + * `apps/cli-go/internal/utils/tenant/client.go:16,80-82`), which aborts before + * the remote Storage client is built. Message matches Go verbatim. + */ +export class LegacySeedMissingApiKeyError extends Data.TaggedError("LegacySeedMissingApiKeyError")<{ + readonly message: string; +}> {} + +/** + * Transport failure fetching the project's api-keys on `--linked`, mirroring Go's + * `tenant.GetApiKeys` network path (`failed to get api keys: `). + */ +export class LegacySeedApiKeysNetworkError extends Data.TaggedError( + "LegacySeedApiKeysNetworkError", +)<{ + readonly message: string; +}> {} + +/** + * `GET /v1/projects/{ref}/api-keys?reveal=true` returned a non-200 status on a + * `--linked` run. Byte-matches Go's `tenant.GetApiKeys` → `ErrAuthToken`, + * `"Authorization failed for the access token and project ref pair: " + body` + * (`apps/cli-go/internal/utils/tenant/client.go:15,77-78`). This is the user-facing + * error for an invalid access token / project-ref pair — distinct from the + * `projects api-keys` helper's `unexpected get api keys status ...`. + */ +export class LegacySeedAuthTokenError extends Data.TaggedError("LegacySeedAuthTokenError")<{ + readonly status: number; + readonly body: string; + readonly message: string; +}> {} diff --git a/apps/cli/src/legacy/commands/seed/buckets/buckets.flags.ts b/apps/cli/src/legacy/commands/seed/buckets/buckets.flags.ts new file mode 100644 index 0000000000..a4c232f940 --- /dev/null +++ b/apps/cli/src/legacy/commands/seed/buckets/buckets.flags.ts @@ -0,0 +1,77 @@ +import { Effect } from "effect"; + +import { + VALUE_CONSUMING_LONG_FLAGS, + VALUE_CONSUMING_SHORT_FLAGS, +} from "../../../shared/legacy-db-target-flags.ts"; +import { LegacySeedMutuallyExclusiveFlagsError } from "./buckets.errors.ts"; + +/** + * Detects which of `--local` / `--linked` were explicitly set on the command + * line, reproducing cobra's `pflag.Changed` for `seed`'s + * `MarkFlagsMutuallyExclusive("local", "linked")` (`apps/cli-go/cmd/seed.go:32`). + * + * Effect CLI's parsed flags carry no `Changed` bit, so we re-derive it from raw + * argv. Value-consuming flags (`--workdir `, `-o `, …) skip their + * value token to avoid false positives like `--workdir --linked`. + * + * Returned in cobra's alphabetically-sorted order `["linked", "local"]` so the + * rendered conflict string matches Go exactly. + */ +export function legacySeedChangedTargetFlags(args: ReadonlyArray): ReadonlyArray { + let linked = false; + let local = false; + let skipNext = false; + + for (const token of args) { + if (skipNext) { + skipNext = false; + continue; + } + if (token === "--") break; + + if (token.startsWith("--")) { + const eqIdx = token.indexOf("="); + const name = eqIdx === -1 ? token.slice(2) : token.slice(2, eqIdx); + const isBare = eqIdx === -1; + if (name === "linked") { + linked = true; + continue; + } + if (name === "local") { + local = true; + continue; + } + if (isBare && VALUE_CONSUMING_LONG_FLAGS.has(name)) skipNext = true; + continue; + } + + if (token.startsWith("-") && token.length >= 2 && token.charAt(1) !== "-") { + if (token.length === 2 && VALUE_CONSUMING_SHORT_FLAGS.has(token.charAt(1))) { + skipNext = true; + } + } + } + + const setFlags: Array = []; + if (linked) setFlags.push("linked"); + if (local) setFlags.push("local"); + return setFlags; +} + +/** + * Reproduce cobra's `MarkFlagsMutuallyExclusive("local", "linked")` + * (`apps/cli-go/cmd/seed.go:32`). Go rejects this at flag validation — before + * `RunE`/`PersistentPostRun` — so it must NOT emit `cli_command_executed`; the + * command calls this BEFORE `withLegacyCommandInstrumentation`. + */ +export const legacyAssertSeedTargetsExclusive = Effect.fnUntraced(function* ( + args: ReadonlyArray, +) { + const setFlags = legacySeedChangedTargetFlags(args); + if (setFlags.length > 1) { + return yield* new LegacySeedMutuallyExclusiveFlagsError({ + message: `if any flags in the group [linked local] are set none of the others can be; [${setFlags.join(" ")}] were all set`, + }); + } +}); diff --git a/apps/cli/src/legacy/commands/seed/buckets/buckets.flags.unit.test.ts b/apps/cli/src/legacy/commands/seed/buckets/buckets.flags.unit.test.ts new file mode 100644 index 0000000000..c327948d53 --- /dev/null +++ b/apps/cli/src/legacy/commands/seed/buckets/buckets.flags.unit.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { Effect, Exit } from "effect"; + +import { legacyAssertSeedTargetsExclusive, legacySeedChangedTargetFlags } from "./buckets.flags.ts"; + +describe("legacySeedChangedTargetFlags", () => { + it("returns both selectors in cobra's sorted order when both are set", () => { + expect(legacySeedChangedTargetFlags(["seed", "buckets", "--local", "--linked"])).toEqual([ + "linked", + "local", + ]); + }); + + it("returns a single selector", () => { + expect(legacySeedChangedTargetFlags(["seed", "buckets", "--linked"])).toEqual(["linked"]); + expect(legacySeedChangedTargetFlags(["seed", "buckets", "--local"])).toEqual(["local"]); + }); + + it("returns nothing when neither is set", () => { + expect(legacySeedChangedTargetFlags(["seed", "buckets"])).toEqual([]); + }); + + it("does not treat a value-consuming flag's value as a selector", () => { + expect(legacySeedChangedTargetFlags(["seed", "buckets", "--workdir", "--linked"])).toEqual([]); + }); + + it("skips the value token after a short value-consuming flag", () => { + expect(legacySeedChangedTargetFlags(["-o", "--linked", "--local"])).toEqual(["local"]); + }); + + it("stops scanning at the -- terminator", () => { + expect(legacySeedChangedTargetFlags(["seed", "buckets", "--", "--local", "--linked"])).toEqual( + [], + ); + }); + + it("handles = forms", () => { + expect(legacySeedChangedTargetFlags(["--local=true", "--linked=false"])).toEqual([ + "linked", + "local", + ]); + }); +}); + +describe("legacyAssertSeedTargetsExclusive", () => { + it("fails when both --local and --linked are set (cobra mutual exclusivity)", () => { + const exit = Effect.runSyncExit( + legacyAssertSeedTargetsExclusive(["seed", "buckets", "--local", "--linked"]), + ); + expect(Exit.isFailure(exit)).toBe(true); + expect(JSON.stringify(exit)).toContain( + "if any flags in the group [linked local] are set none of the others can be; [linked local] were all set", + ); + }); + + it("succeeds when at most one target flag is set", () => { + for (const args of [ + ["seed", "buckets", "--linked"], + ["seed", "buckets", "--local"], + ["seed", "buckets"], + ]) { + expect(Exit.isSuccess(Effect.runSyncExit(legacyAssertSeedTargetsExclusive(args)))).toBe(true); + } + }); +}); diff --git a/apps/cli/src/legacy/commands/seed/buckets/buckets.gateway.ts b/apps/cli/src/legacy/commands/seed/buckets/buckets.gateway.ts new file mode 100644 index 0000000000..9096c55807 --- /dev/null +++ b/apps/cli/src/legacy/commands/seed/buckets/buckets.gateway.ts @@ -0,0 +1,442 @@ +import { Effect, FileSystem } from "effect"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientError from "effect/unstable/http/HttpClientError"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; + +import { LegacySeedStorageNetworkError, LegacySeedStorageStatusError } from "./buckets.errors.ts"; + +/** + * Native TypeScript client for the Supabase Storage **service gateway** (Kong), + * mirroring `apps/cli-go/pkg/storage/{buckets,objects,vector}.go` and the + * `fetcher.NewServiceGateway` auth headers: the `apikey` header is always sent, + * and `Authorization: Bearer ` is added only when the key is a JWT — Go's + * `withAuthToken` (`pkg/fetcher/gateway.go:22`) omits it for opaque `sb_...` + * keys, which are not bearer tokens. + * + * Scope is limited to what `seed buckets` reaches against the **local** stack + * (list/create/update buckets, upload objects, vector list/create/delete). No + * TS gateway client existed before this port (storage ls/cp/mv/rm are still Go + * proxies); this is the hoist candidate for `legacy/shared/` once those land. + */ + +interface LegacyBucketSummary { + readonly name: string; + readonly id: string; +} + +export interface LegacyUpsertBucketProps { + /** + * Tri-state to match Go's `Public *bool` with `json:"public,omitempty"`: + * `undefined` when `public` is absent from the bucket's TOML (field omitted), + * otherwise the explicit value. + */ + readonly public: boolean | undefined; + /** Byte count; omitted from the request body when 0 (Go `omitempty`). */ + readonly fileSizeLimit: number; + readonly allowedMimeTypes: ReadonlyArray; +} + +export interface LegacyStorageGateway { + readonly listBuckets: () => Effect.Effect< + ReadonlyArray, + LegacySeedStorageNetworkError | LegacySeedStorageStatusError + >; + readonly createBucket: ( + name: string, + props: LegacyUpsertBucketProps, + ) => Effect.Effect; + readonly updateBucket: ( + id: string, + props: LegacyUpsertBucketProps, + ) => Effect.Effect; + readonly listVectorBuckets: () => Effect.Effect< + ReadonlyArray, + LegacySeedStorageNetworkError | LegacySeedStorageStatusError + >; + readonly createVectorBucket: ( + name: string, + ) => Effect.Effect; + readonly deleteVectorBucket: ( + name: string, + ) => Effect.Effect; + readonly uploadObject: ( + remotePath: string, + absPath: string, + contentType: string, + ) => Effect.Effect; + readonly listAnalyticsBuckets: () => Effect.Effect< + ReadonlyArray, + LegacySeedStorageNetworkError | LegacySeedStorageStatusError + >; + readonly createAnalyticsBucket: ( + name: string, + ) => Effect.Effect; + readonly deleteAnalyticsBucket: ( + name: string, + ) => Effect.Effect; +} + +/** + * Strict JSON decode mirroring Go's `fetcher.ParseJSON[T]` + * (`pkg/fetcher/http.go` — `json.NewDecoder(r).Decode(&data)`): a body whose + * shape doesn't match the typed target aborts before any bucket mutation. Only + * missing fields, `null` (decoded as the zero-value struct/field), empty arrays, + * and extra keys are tolerated (zero values); a non-matching top-level type, a + * non-null non-object element (number/array/string), or a present-but-wrong-typed + * string field all fail. The graceful-skip classifiers + * never see these (the message doesn't match), so they propagate, like Go. + */ +function failParse(detail: string): LegacySeedStorageNetworkError { + return new LegacySeedStorageNetworkError({ message: `failed to parse response body: ${detail}` }); +} + +/** + * The port to use for the local-gateway port-conflict hint, mirroring Go's + * `localGatewayHint` (`apps/cli-go/pkg/fetcher/http.go:117-143`), which parses + * the configured **server URL**: the hint only fires for a loopback host + * (`127.0.0.1`/`localhost`/`::1`) that has a port, and reports THAT URL's port — + * not `api.port`, which can differ when `api.external_url` is overridden. Returns + * undefined for a non-loopback/remote host (so `--linked` never gets the hint). + */ +function localGatewayHintPort(baseUrl: string): string | undefined { + try { + const url = new URL(baseUrl); + const host = url.hostname.replace(/^\[|\]$/g, ""); // WHATWG brackets IPv6 + if ((host === "127.0.0.1" || host === "localhost" || host === "::1") && url.port.length > 0) { + return url.port; + } + } catch { + // Unparseable base URL → no hint. + } + return undefined; +} + +/** + * Byte-identical to Go's `localGatewayHint` message. Go gates on its net/http + * error strings (`malformed HTTP response` / timeout); Bun/undici don't emit + * those, so the caller gates on an Effect `TransportError` instead — the text is + * unchanged. Hoist to `legacy/shared/` when `storage ls/cp/mv/rm` land. + */ +function legacyLocalGatewayHint(port: string): string { + return ( + "The local Supabase API gateway did not return a valid HTTP response. " + + `Another process may be listening on the configured API port ${port}. ` + + `Check the port with \`lsof -nP -iTCP:${port} -sTCP:LISTEN\`, then stop the conflicting process or set a different \`api.port\` in supabase/config.toml.` + ); +} + +/** + * Whether a transport failure is a plain connection-refused (the local stack is + * stopped). Go's `localGatewayHint` only fires for a malformed HTTP response, + * header timeout, or context-deadline timeout — NOT `ECONNREFUSED` — so the + * port-conflict hint is suppressed for refused connections. Bun/undici don't + * emit Go's net/http strings, so this is a substring check over the transport + * error's description/cause/message. + */ +function isConnectionRefused(error: HttpClientError.TransportError): boolean { + const detail = + `${error.description ?? ""} ${String(error.cause ?? "")} ${error.message}`.toLowerCase(); + return /econnrefused|connection ?refused|unable to connect/.test(detail); +} + +const parseJsonBody = (body: string): Effect.Effect => + Effect.try({ + try: () => JSON.parse(body) as unknown, + catch: (cause) => failParse(String(cause)), + }); + +/** + * A JSON object → itself; a JSON `null` → `{}` (Go's zero-value struct: decoding + * `null` into a non-pointer struct is a no-op that leaves it zero, no error — + * same `encoding/json` rule as the string-field level below); a number / array / + * string → `null` to signal a real Go-struct decode failure (`encoding/json` + * errors on those). Combined with the null-tolerant `decodeStringField`, a `null` + * list element decodes to the zero-value struct (empty `name`/`id`) and the + * upsert loops continue, exactly as Go's do. + */ +function asObject(entry: unknown): Record | null { + if (entry === null) return {}; + return typeof entry === "object" && !Array.isArray(entry) + ? (entry as Record) + : null; +} + +/** + * Go-struct string field: absent OR JSON `null` → "" (zero value, tolerated). + * Go decodes via `json.NewDecoder(...).Decode(&data)` (fetcher/http.go:144-151) + * into plain `string` fields (not `*string`), and `encoding/json` leaves a + * non-pointer scalar at its zero value for a `null` JSON value rather than + * erroring — so `{"name": null}` is `Name == ""`, not a parse failure. A + * present-but-not-a-string value → `null` (decode failure, matching Go's + * type-mismatch error). Distinguish the failure via `=== null`. + */ +function decodeStringField(obj: Record, key: string): string | null { + const value = obj[key]; + if (value === undefined || value === null) return ""; + return typeof value === "string" ? value : null; +} + +/** Decode an array body of `{name, id}` objects (Go `[]BucketResponse`). */ +const decodeBucketSummaries = ( + body: string, +): Effect.Effect, LegacySeedStorageNetworkError> => + Effect.gen(function* () { + const parsed = yield* parseJsonBody(body); + if (parsed === null) return []; + if (!Array.isArray(parsed)) { + return yield* Effect.fail(failParse("expected an array of buckets")); + } + const result: Array = []; + for (const entry of parsed) { + const obj = asObject(entry); + const name = obj === null ? null : decodeStringField(obj, "name"); + const id = obj === null ? null : decodeStringField(obj, "id"); + if (name === null || id === null) { + return yield* Effect.fail(failParse("invalid bucket entry")); + } + result.push({ name, id }); + } + return result; + }); + +/** Decode the `{vectorBuckets: [{vectorBucketName}]}` body (Go `ListVectorBucketsResponse`). */ +const decodeVectorBucketNames = ( + body: string, +): Effect.Effect, LegacySeedStorageNetworkError> => + Effect.gen(function* () { + const parsed = yield* parseJsonBody(body); + const root = asObject(parsed); + if (root === null) { + return yield* Effect.fail(failParse("expected a vector bucket list object")); + } + const list = root["vectorBuckets"]; + // Absent or null → empty: Go decodes `{"vectorBuckets": null}` (and the + // zero `ListVectorBucketsResponse{}`) into a nil slice, i.e. no buckets. + if (list === undefined || list === null) return []; + if (!Array.isArray(list)) { + return yield* Effect.fail(failParse("vectorBuckets must be an array")); + } + const names: Array = []; + for (const entry of list) { + const obj = asObject(entry); + const name = obj === null ? null : decodeStringField(obj, "vectorBucketName"); + if (name === null) { + return yield* Effect.fail(failParse("invalid vector bucket entry")); + } + names.push(name); + } + return names; + }); + +/** + * Validate a create/update bucket success body. Go's `CreateBucket`/`UpdateBucket` + * decode the 200 body via `fetcher.ParseJSON` into `{name}`/`{message}` + * (`pkg/storage/buckets.go:46,65`) and fail on a non-JSON/empty body before later + * uploads. The decoded value is unused (Go ignores it too) — this is purely the + * validity gate. `null` is tolerated (Go's `json.Decode` accepts it); a non-object + * top-level or a present-but-wrong-typed field fails. + */ +const decodeMutationResponse = ( + body: string, + field: string, +): Effect.Effect => + Effect.gen(function* () { + const parsed = yield* parseJsonBody(body); + if (parsed === null) return; + const obj = asObject(parsed); + if (obj === null || decodeStringField(obj, field) === null) { + return yield* Effect.fail( + failParse(`invalid ${field === "name" ? "create" : "update"} bucket response`), + ); + } + }); + +/** Decode an array body of `{name, ...}` objects to names (Go `[]AnalyticsBucketResponse`). */ +const decodeAnalyticsBucketNames = ( + body: string, +): Effect.Effect, LegacySeedStorageNetworkError> => + Effect.gen(function* () { + const parsed = yield* parseJsonBody(body); + if (parsed === null) return []; + if (!Array.isArray(parsed)) { + return yield* Effect.fail(failParse("expected an array of analytics buckets")); + } + const names: Array = []; + for (const entry of parsed) { + const obj = asObject(entry); + const name = obj === null ? null : decodeStringField(obj, "name"); + if (name === null) { + return yield* Effect.fail(failParse("invalid analytics bucket entry")); + } + names.push(name); + } + return names; + }); + +/** + * Build the create/update bucket body with Go's `omitempty` semantics + * (`pkg/storage/buckets.go:29-54`): `public` (a `*bool`) is omitted when absent + * from the TOML, `file_size_limit` when 0, `allowed_mime_types` when empty. + * Exported for focused unit coverage. + */ +export function legacyBucketBody(props: LegacyUpsertBucketProps): Record { + const body: Record = {}; + if (props.public !== undefined) { + body["public"] = props.public; + } + if (props.fileSizeLimit > 0) { + body["file_size_limit"] = props.fileSizeLimit; + } + if (props.allowedMimeTypes.length > 0) { + body["allowed_mime_types"] = props.allowedMimeTypes; + } + return body; +} + +export const makeLegacyStorageGateway = Effect.fnUntraced(function* (opts: { + readonly baseUrl: string; + readonly apiKey: string; + readonly userAgent: string; +}) { + const httpClient = yield* HttpClient.HttpClient; + const fs = yield* FileSystem.FileSystem; + + // Port for Go's local-gateway hint, derived from the actual base URL: only a + // loopback host with a port qualifies (so remote/custom hosts never get it). + const hintPort = localGatewayHintPort(opts.baseUrl); + + // Map a transport/request failure to a network error, appending Go's + // local-gateway port-conflict hint when the base URL is a local loopback + // gateway and the failure is at the transport layer (`localGatewayHint`). + const networkError = (cause: unknown): LegacySeedStorageNetworkError => { + const base = `failed to execute http request: ${cause}`; + if ( + hintPort !== undefined && + HttpClientError.isHttpClientError(cause) && + cause.reason._tag === "TransportError" && + !isConnectionRefused(cause.reason) + ) { + return new LegacySeedStorageNetworkError({ + message: `${base}\n\n${legacyLocalGatewayHint(hintPort)}`, + }); + } + return new LegacySeedStorageNetworkError({ message: base }); + }; + + // Go's `withAuthToken` (`pkg/fetcher/gateway.go:22`) gates the bearer header on + // a plain `sb_` prefix check: opaque `sb_...` keys are not JWTs, so only the + // `apikey` header is sent for them. + const isOpaqueServiceKey = opts.apiKey.startsWith("sb_"); + const withAuth = ( + req: HttpClientRequest.HttpClientRequest, + ): HttpClientRequest.HttpClientRequest => { + const withApiKey = req.pipe( + HttpClientRequest.setHeader("apikey", opts.apiKey), + HttpClientRequest.setHeader("User-Agent", opts.userAgent), + ); + return isOpaqueServiceKey + ? withApiKey + : withApiKey.pipe(HttpClientRequest.setHeader("Authorization", `Bearer ${opts.apiKey}`)); + }; + + // Sends a request and returns the response body text, reproducing the Go + // fetcher's error shapes (`pkg/fetcher/http.go`): transport failure → + // network error; non-200 → `Error status : ` status error. Go's + // service gateway installs `WithExpectedStatus(http.StatusOK)` + // (`pkg/fetcher/gateway.go:17`), so only exactly 200 is a success — a 201/204 + // from an incompatible route is an error, not a silent pass. + const send = Effect.fnUntraced(function* (req: HttpClientRequest.HttpClientRequest) { + const { status, body } = yield* Effect.gen(function* () { + const response = yield* httpClient.execute(req); + const text = yield* response.text; + return { status: response.status, body: text }; + }).pipe(Effect.mapError(networkError)); + if (status !== 200) { + return yield* Effect.fail( + new LegacySeedStorageStatusError({ + status, + body, + message: `Error status ${status}: ${body}`, + }), + ); + } + return body; + }); + + const url = (path: string) => `${opts.baseUrl}${path}`; + + const gateway: LegacyStorageGateway = { + listBuckets: () => + send(withAuth(HttpClientRequest.get(url("/storage/v1/bucket")))).pipe( + Effect.flatMap(decodeBucketSummaries), + ), + createBucket: (name, props) => + send( + withAuth(HttpClientRequest.post(url("/storage/v1/bucket"))).pipe( + HttpClientRequest.bodyJsonUnsafe({ name, ...legacyBucketBody(props) }), + ), + ).pipe(Effect.flatMap((body) => decodeMutationResponse(body, "name"))), + updateBucket: (id, props) => + send( + withAuth(HttpClientRequest.put(url(`/storage/v1/bucket/${id}`))).pipe( + HttpClientRequest.bodyJsonUnsafe(legacyBucketBody(props)), + ), + ).pipe(Effect.flatMap((body) => decodeMutationResponse(body, "message"))), + listVectorBuckets: () => + send( + withAuth(HttpClientRequest.post(url("/storage/v1/vector/ListVectorBuckets"))).pipe( + HttpClientRequest.bodyJsonUnsafe({}), + ), + ).pipe(Effect.flatMap(decodeVectorBucketNames)), + createVectorBucket: (name) => + send( + withAuth(HttpClientRequest.post(url("/storage/v1/vector/CreateVectorBucket"))).pipe( + HttpClientRequest.bodyJsonUnsafe({ vectorBucketName: name }), + ), + ).pipe(Effect.asVoid), + deleteVectorBucket: (name) => + send( + withAuth(HttpClientRequest.post(url("/storage/v1/vector/DeleteVectorBucket"))).pipe( + HttpClientRequest.bodyJsonUnsafe({ vectorBucketName: name }), + ), + ).pipe(Effect.asVoid), + listAnalyticsBuckets: () => + send(withAuth(HttpClientRequest.get(url("/storage/v1/iceberg/bucket")))).pipe( + Effect.flatMap(decodeAnalyticsBucketNames), + ), + createAnalyticsBucket: (name) => + send( + withAuth(HttpClientRequest.post(url("/storage/v1/iceberg/bucket"))).pipe( + HttpClientRequest.bodyJsonUnsafe({ bucketName: name }), + ), + ).pipe(Effect.asVoid), + deleteAnalyticsBucket: (name) => + send( + withAuth(HttpClientRequest.make("DELETE")(url(`/storage/v1/iceberg/bucket/${name}`))), + ).pipe(Effect.asVoid), + uploadObject: (remotePath, absPath, contentType) => { + const trimmed = remotePath.startsWith("/") ? remotePath.slice(1) : remotePath; + const req = withAuth(HttpClientRequest.post(url(`/storage/v1/object/${trimmed}`))).pipe( + HttpClientRequest.setHeader("Cache-Control", "max-age=3600"), + HttpClientRequest.setHeader("x-upsert", "true"), + ); + // `bodyFile` stats the file for Content-Length and streams it via + // FileSystem rather than buffering — the analogue of Go's open-and-stream + // upload. The captured FileSystem is supplied here so the gateway's public + // Effect type stays free of a service requirement. + return HttpClientRequest.bodyFile(req, absPath, { contentType }).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.mapError( + (cause) => + new LegacySeedStorageNetworkError({ + message: `failed to execute http request: ${cause}`, + }), + ), + Effect.flatMap(send), + Effect.asVoid, + ); + }, + }; + + return gateway; +}); diff --git a/apps/cli/src/legacy/commands/seed/buckets/buckets.gateway.unit.test.ts b/apps/cli/src/legacy/commands/seed/buckets/buckets.gateway.unit.test.ts new file mode 100644 index 0000000000..da2b2972b5 --- /dev/null +++ b/apps/cli/src/legacy/commands/seed/buckets/buckets.gateway.unit.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; + +import { legacyBucketBody } from "./buckets.gateway.ts"; + +describe("legacyBucketBody", () => { + it("omits public when undefined (Go *bool nil / omitempty)", () => { + expect(legacyBucketBody({ public: undefined, fileSizeLimit: 0, allowedMimeTypes: [] })).toEqual( + {}, + ); + }); + + it("includes public when explicitly set (true or false)", () => { + expect(legacyBucketBody({ public: true, fileSizeLimit: 0, allowedMimeTypes: [] })).toEqual({ + public: true, + }); + expect(legacyBucketBody({ public: false, fileSizeLimit: 0, allowedMimeTypes: [] })).toEqual({ + public: false, + }); + }); + + it("omits file_size_limit when 0 and allowed_mime_types when empty", () => { + expect( + legacyBucketBody({ public: undefined, fileSizeLimit: 0, allowedMimeTypes: [] }), + ).not.toHaveProperty("file_size_limit"); + }); + + it("includes file_size_limit and allowed_mime_types when present", () => { + expect( + legacyBucketBody({ + public: false, + fileSizeLimit: 52_428_800, + allowedMimeTypes: ["image/png"], + }), + ).toEqual({ + public: false, + file_size_limit: 52_428_800, + allowed_mime_types: ["image/png"], + }); + }); +}); diff --git a/apps/cli/src/legacy/commands/seed/buckets/buckets.handler.ts b/apps/cli/src/legacy/commands/seed/buckets/buckets.handler.ts index 7af7724196..95873c499d 100644 --- a/apps/cli/src/legacy/commands/seed/buckets/buckets.handler.ts +++ b/apps/cli/src/legacy/commands/seed/buckets/buckets.handler.ts @@ -1,13 +1,984 @@ -import { Effect } from "effect"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { + KONG_LOCAL_CA_CERT, + loadProjectConfig, + type LoadProjectConfigOptions, + ProjectConfigSchema, +} from "@supabase/config"; +import { defaultJwtSecret, generateJwt } from "@supabase/stack/effect"; +import { Effect, FileSystem, Option, Path, Schema } from "effect"; +import { FetchHttpClient } from "effect/unstable/http"; +import type { PlatformError } from "effect/PlatformError"; + +import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; +import { LegacyYesFlag } from "../../../../shared/legacy/global-flags.ts"; +import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; +import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { LegacyPlatformApiFactory } from "../../../auth/legacy-platform-api-factory.service.ts"; +import { legacyMapTenantApiKeysError } from "../../../shared/legacy-get-tenant-api-keys.ts"; +import { legacyExtractServiceKeys } from "../../../shared/legacy-tenant-keys.ts"; +import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { legacySeedChangedTargetFlags } from "./buckets.flags.ts"; +import { legacyBold, legacyYellow } from "../../../shared/legacy-colors.ts"; +import { legacyGetHostname } from "../../../shared/legacy-hostname.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { + legacyIsLocalVectorBucketsUnavailable, + legacyIsVectorBucketsFeatureNotEnabled, +} from "./buckets.classify.ts"; +import { + type LegacyStorageGateway, + type LegacyUpsertBucketProps, + makeLegacyStorageGateway, +} from "./buckets.gateway.ts"; +import { + LegacySeedApiKeysNetworkError, + LegacySeedAuthTokenError, + LegacySeedConfigLoadError, + LegacySeedMissingApiKeyError, + LegacySeedStorageNetworkError, + LegacySeedStorageStatusError, +} from "./buckets.errors.ts"; +import { + legacyBucketObjectKey, + legacyContentTypeForUpload, + legacyParseFileSizeLimit, +} from "./buckets.upload.ts"; import type { LegacyBucketsFlags } from "./buckets.command.ts"; -export const legacyBuckets = Effect.fn("legacy.seed.buckets")(function* ( - flags: LegacyBucketsFlags, +const CONFIG_PATH = "supabase/config.toml"; +const UPLOAD_CONCURRENCY = 5; + +/** + * Builds a `typeof globalThis.fetch` that injects `tls.ca` into every request, + * trusting the provided CA PEM for HTTPS connections to the local Kong gateway. + * + * Mirrors Go's `newLocalClient` (`apps/cli-go/internal/storage/client/api.go:30-37`), + * which appends `utils.Config.Api.Tls.CertContent` to the TLS cert pool. + * + * Bun's fetch accepts `{ tls: { ca: string } }` in the same position as + * `BunFetchRequestInit.tls`; the `ca` field is Bun-specific and is typed via + * `BunFetchRequestInit` (a Bun global). No `as` cast is needed: the init object + * is typed as `BunFetchRequestInit` which extends the standard `RequestInit`. + */ +function legacyKongCaFetch(ca: string): typeof globalThis.fetch { + const fetchImpl = async ( + input: string | URL | Request, + init?: RequestInit, + ): Promise => { + const caInit: BunFetchRequestInit = { ...init, tls: { ca } }; + return globalThis.fetch(input, caInit); + }; + // Attach `preconnect` so the override is structurally complete as + // `typeof globalThis.fetch` — mirrors the same pattern in legacy-http-dns.ts. + return Object.assign(fetchImpl, { preconnect: globalThis.fetch.preconnect }); +} + +/** + * Validates and resolves the local Kong TLS configuration, mirroring Go's + * `(*api).Validate` (`apps/cli-go/pkg/config/config.go:845-861`) which runs at + * config-load before `NewStorageAPI`: + * 1. `cert_path` set, `key_path` empty → error + * 2. `cert_path` set, unreadable → error + * 3. `key_path` set, `cert_path` empty → error + * 4. `key_path` set, unreadable → error + * 5. Both set and readable → returns the CA PEM (cert content) + * 6. Neither set → returns the embedded `KONG_LOCAL_CA_CERT` + * + * The CLI only uses the CA cert for trusting the Kong gateway, but Go also reads + * the key purely to validate the pairing, so we mirror that behaviour. + * + * // TODO: broader `@supabase/config` gap — `packages/config/src/api.ts` models + * // `tls.cert_path` / `tls.key_path` but has no pairing or readability validation. + * // Once @supabase/config adds `(*api).Validate`, this helper can be removed and + * // the error mapping moved to the `ProjectConfigParseError` catch above. + * + * Only called when `projectRef === ""` (local) AND `config.api.enabled` AND + * `config.api.tls.enabled` — Go gates both path resolution (`config.go:795`) + * and validation (`config.go:841`) on `c.Api.Enabled`. + */ +const validateLocalKongTls = Effect.fnUntraced(function* ( + fs: FileSystem.FileSystem, + path: Path.Path, + workdir: string, + certPath: string | undefined, + keyPath: string | undefined, ) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["seed", "buckets"]; - if (flags.linked) args.push("--linked"); - if (flags.local) args.push("--local"); - yield* proxy.exec(args); + const hasCert = certPath !== undefined && certPath.length > 0; + const hasKey = keyPath !== undefined && keyPath.length > 0; + + if (hasCert && !hasKey) { + return yield* new LegacySeedConfigLoadError({ + message: "Missing required field in config: api.tls.key_path", + }); + } + if (hasKey && !hasCert) { + return yield* new LegacySeedConfigLoadError({ + message: "Missing required field in config: api.tls.cert_path", + }); + } + + if (hasCert) { + // Go joins TLS paths unconditionally with the supabase dir — NO IsAbs guard + // (config.go:795-801 uses path.Join, which absorbs a leading "/" on the + // joined element), so `cert_path = "/tmp/kong.crt"` resolves under + // supabase/tmp/kong.crt. This differs from objects_path below, which Go + // guards with !filepath.IsAbs (config.go:753-761). + const absCert = path.join(workdir, "supabase", certPath); + const certContent = yield* fs.readFileString(absCert).pipe( + Effect.catchTag( + "PlatformError", + (cause) => + new LegacySeedConfigLoadError({ + message: `failed to read TLS cert: ${String(cause.cause ?? cause)}`, + }), + ), + ); + // keyPath is non-empty here because hasKey === true (cert+key both present); + // joined unconditionally, same as cert_path above (config.go:795-801). + const absKey = path.join(workdir, "supabase", keyPath!); + yield* fs.readFileString(absKey).pipe( + Effect.catchTag( + "PlatformError", + (cause) => + new LegacySeedConfigLoadError({ + message: `failed to read TLS key: ${String(cause.cause ?? cause)}`, + }), + ), + ); + return certContent; + } + + return KONG_LOCAL_CA_CERT; }); + +/** + * Mirrors Go's `ValidateBucketName` regex (`apps/cli-go/pkg/config/config.go:1382`). + * Used to validate `[storage.buckets]` names before any Storage API call, matching + * Go's config-load-time check (`config.go:899-903`). Vector and analytics names are + * NOT validated here — Go only validates `[storage.buckets]`. + */ +const LEGACY_BUCKET_NAME_PATTERN = /^(?:[0-9A-Za-z_]|!|-|\.|\*|'|\(|\)| |&|\$|@|=|;|:|\+|,|\?)*$/; + +/** + * Verbatim Go regex literal (`config.go:1382`) — used in the error message so it + * is byte-identical to Go's output. Do NOT derive from `LEGACY_BUCKET_NAME_PATTERN.source`. + */ +const LEGACY_BUCKET_NAME_PATTERN_SOURCE = + "^(\\w|!|-|\\.|\\*|'|\\(|\\)| |&|\\$|@|=|;|:|\\+|,|\\?)*$"; + +const legacyValidateBucketName = Effect.fnUntraced(function* (name: string) { + if (!LEGACY_BUCKET_NAME_PATTERN.test(name)) { + return yield* new LegacySeedConfigLoadError({ + message: `Invalid Bucket name: ${name}. Only lowercase letters, numbers, dots, hyphens, and spaces are allowed. (${LEGACY_BUCKET_NAME_PATTERN_SOURCE})`, + }); + } +}); + +type StorageError = LegacySeedStorageNetworkError | LegacySeedStorageStatusError; + +interface CollectedFile { + readonly absPath: string; + readonly displayPath: string; +} + +/** Mutable run summary, emitted as the structured result in json/stream-json mode. */ +interface SeedSummary { + readonly buckets_created: Array; + readonly buckets_updated: Array; + readonly buckets_skipped: Array; + readonly vector_created: Array; + readonly vector_pruned: Array; + vector_skipped: boolean; + readonly objects_uploaded: Array; + readonly analytics_created: Array; + readonly analytics_pruned: Array; +} + +function emptySummary(): SeedSummary { + return { + buckets_created: [], + buckets_updated: [], + buckets_skipped: [], + vector_created: [], + vector_pruned: [], + vector_skipped: false, + objects_uploaded: [], + analytics_created: [], + analytics_pruned: [], + }; +} + +/** + * Embedded-default project config, decoded from an empty object — the same + * `decodeUnknownSync(ProjectConfigSchema)({})` the loader uses internally + * (`packages/config/src/io.ts:54-56`). Go's `seed buckets` never aborts on a + * missing `config.toml`: it reads the package-global `utils.Config`, which is + * initialized to embedded defaults (`internal/utils/config.go:100`), and + * `config.Load` no-ops on a missing file (`mergeFileConfig` → nil). So "no + * config file" behaves like the embedded-default config. + */ +const legacyDecodeDefaultProjectConfig = Schema.decodeUnknownSync(ProjectConfigSchema); + +/** + * `supabase seed buckets` — seeds Storage buckets from + * `[storage.buckets]` / `[storage.vector]` in `supabase/config.toml`. + * + * Port of `apps/cli-go/internal/seed/buckets/buckets.go`. When `--linked` is + * passed, the remote Storage gateway is used with the project's service-role key; + * otherwise the local stack is used. + */ +export const legacySeedBuckets = Effect.fn("legacy.seed.buckets")(function* ( + // Target is selected from the changed-flag set (Go's flag.Changed), not the + // parsed value, so the flags arg itself is unused here. + _flags: LegacyBucketsFlags, +) { + const output = yield* Output; + const cliConfig = yield* LegacyCliConfig; + const telemetryState = yield* LegacyTelemetryState; + const linkedProjectCache = yield* LegacyLinkedProjectCache; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cliArgs = yield* CliArgs; + const yes = yield* LegacyYesFlag; + + // Set once --linked resolves a ref; drives the post-run linked-project cache + // write + org/project group identify, mirroring Go's `ensureProjectGroupsCached` + // (`cmd/root.go`, gated on a non-empty `flags.ProjectRef`). Empty on the local + // path, so the cache is never written there. + let linkedRef = ""; + + yield* Effect.gen(function* () { + // 1. Resolve the project ref for --linked BEFORE loading config, so that + // the matching `[remotes.]` override (whose `project_id == ref`) is + // merged over the base config by `loadProjectConfig`. Mirrors Go's + // `Config.ProjectId = ProjectRef` → `config.Load` sequence + // (`apps/cli-go/pkg/config/config.go:505-518`). + // Go selects the target from `flag.Changed`, not the flag value + // (`internal/utils/flags/db_url.go:46-63`): `--linked` is the linked path + // whenever it's *set*, even `--linked=false`. Use the changed-flag set + // (the `--local`/`--linked` mutual-exclusivity is enforced before + // instrumentation in `buckets.command.ts`), not `flags.linked`'s value. + const setFlags = legacySeedChangedTargetFlags(cliArgs.args); + const projectRefResolver = yield* LegacyProjectRefResolver; + const projectRef = setFlags.includes("linked") + ? yield* projectRefResolver.loadProjectRef(Option.none()) + : ""; + linkedRef = projectRef; + + // 2. Load config.toml, passing projectRef so `[remotes.*]` overrides are + // merged for --linked. A parse failure aborts before any network call. + const loadOptions: LoadProjectConfigOptions | undefined = + projectRef !== "" ? { projectRef } : undefined; + const loaded = yield* loadProjectConfig(cliConfig.workdir, loadOptions).pipe( + Effect.catchTag( + "ProjectConfigParseError", + (cause) => + new LegacySeedConfigLoadError({ + message: `failed to parse supabase/config.toml: ${String(cause.cause)}`, + }), + ), + ); + // A missing config file is NOT an early exit: Go uses embedded defaults and + // still gates the no-op on `len(projectRef) == 0` (`internal/seed/buckets/ + // buckets.go:16-20`). So local + no-config falls into the no-op short-circuit + // below (emitting the empty summary in json/stream-json); `--linked` + + // no-config falls through to the remote path so auth/project/API failures + // surface, exactly as the Go command does. + const config = loaded === null ? legacyDecodeDefaultProjectConfig({}) : loaded.config; + const document = loaded === null ? undefined : loaded.document; + + // Go prints this from inside config load (`config.go:513`, + // `fmt.Fprintln(os.Stderr, "Loading config override:", idToName[projectId])`), + // unconditionally and before any command output, whenever a `[remotes.*]` + // block's project_id matched the linked ref. `appliedRemote` is the bare name, + // bracketed here to match Go's `idToName` value (`config.go:511`). Same emit as + // `config push` (push.handler.ts). stderr in all output modes (diagnostic-only). + if (loaded !== null && loaded.appliedRemote !== undefined) { + yield* output.raw(`Loading config override: [remotes.${loaded.appliedRemote}]\n`, "stderr"); + } + const bucketsConfig = config.storage.buckets ?? {}; + const bucketNames = Object.keys(bucketsConfig); + const vectorEnabled = config.storage.vector.enabled; + const vectorBucketNames = Object.keys(config.storage.vector.buckets); + const hasVectorBuckets = vectorBucketNames.length > 0; + + // 3. Config-load-time validations run BEFORE the no-op short-circuit: Go + // decodes the whole config (storage.FileSizeLimit, bucket sizes) and runs + // ValidateBucketName during config.Load — before `buckets.Run` can take its + // no-op path — so an invalid value fails even when there's nothing to seed. + // + // 3a. Bucket names (Go ValidateBucketName, config.go:899-903). + for (const name of bucketNames) { + yield* legacyValidateBucketName(name); + } + + // 3b. Storage-level file_size_limit, parsed unconditionally (Go unmarshals + // `storage.FileSizeLimit` at config.Load regardless of buckets). + const storageFileSizeLimitBytes = yield* parseFileSizeLimitOrFail( + config.storage.file_size_limit, + ); + + // 3c. Per-bucket props (sizes parsed before any Storage call). + const bucketPropsByName = new Map(); + for (const [name, bucket] of Object.entries(bucketsConfig)) { + bucketPropsByName.set( + name, + yield* computeBucketProps(document, name, bucket, storageFileSizeLimitBytes), + ); + } + + // 3d. Short-circuit: nothing to seed (ref present → never short-circuits). + if (projectRef === "" && bucketNames.length === 0 && !hasVectorBuckets) { + // Go emits nothing in text mode; in the additive json/stream-json modes a + // scripted caller still expects a result object, so emit an empty summary. + if (output.format !== "text") { + yield* output.success("", { ...emptySummary() }); + } + return; + } + + // 4. Build the Storage service-gateway client (local or remote). + let baseUrl: string; + let apiKey: string; + + if (projectRef === "") { + baseUrl = resolveLocalBaseUrl(config); + apiKey = yield* resolveLocalServiceRoleKey(config.auth); + } else { + baseUrl = `https://${projectRef}.${cliConfig.projectHost}`; + const envKey = process.env["SUPABASE_AUTH_SERVICE_ROLE_KEY"]; + if (envKey !== undefined && envKey.length > 0) { + apiKey = envKey; + } else { + // Go builds the remote Storage client via `tenant.GetApiKeys` + // (`internal/storage/client/api.go:22`), which maps a non-200 to + // `Authorization failed for the access token and project ref pair: ` + // (`internal/utils/tenant/client.go:15,77-78`) — NOT the `projects api-keys` + // helper's `unexpected get api keys status ...`. Resolve the client lazily + // so the local path never triggers Management API auth. + const api = yield* (yield* LegacyPlatformApiFactory).make; + const keys = legacyExtractServiceKeys( + yield* api.v1.getProjectApiKeys({ ref: projectRef, reveal: true }).pipe( + Effect.catch( + legacyMapTenantApiKeysError({ + networkError: LegacySeedApiKeysNetworkError, + statusError: LegacySeedAuthTokenError, + }), + ), + ), + ); + // Go's tenant.GetApiKeys fails with errMissingKey ("Anon key not found.") + // when the api-keys response yields nothing, before building the remote + // Storage client (`internal/utils/tenant/client.go:24-26,80-82`). + if (keys.anon === "" && keys.serviceRole === "") { + return yield* new LegacySeedMissingApiKeyError({ message: "Anon key not found." }); + } + apiKey = keys.serviceRole; + } + } + + // Kong CA trust for the LOCAL path. Go's `newLocalClient` installs + // `status.NewKongClient` unconditionally (`internal/storage/client/api.go:30-37`) + // — its embedded CA only matters for https — and `(*api).Validate` resolves + // `cert_path`/`key_path` (`config.go:795`) and validates the cert/key pairing + // (`config.go:841-861`) only when `api.enabled && api.tls.enabled` (both + // blocks are gated on `c.Api.Enabled`). So: validate (and resolve a cert_path + // CA) only when the api is enabled AND tls is enabled; inject the CA whenever + // the resolved local URL is https — Go derives the scheme from `api.tls.enabled` + // alone (`config.go:639-642`, NOT gated on `api.enabled`), so an `enabled=false` + // + `tls.enabled=true` config still yields an https URL and the embedded CA — + // and never for the remote `--linked` host. + let localKongCa: string | undefined; + if (projectRef === "") { + const validatedCa = + config.api.enabled && config.api.tls.enabled + ? yield* validateLocalKongTls( + fs, + path, + cliConfig.workdir, + config.api.tls.cert_path, + config.api.tls.key_path, + ) + : undefined; + if (baseUrl.startsWith("https:")) { + localKongCa = validatedCa ?? KONG_LOCAL_CA_CERT; + } + } + + // All gateway operations run with an explicit non-DoH fetch. Storage calls + // never use DoH in Go: `newLocalClient` uses `status.NewKongClient` and + // `newRemoteClient` uses `http.DefaultClient` — `withFallbackDNS` is installed + // only in `utils.GetSupabase` (Management API, `internal/utils/api.go:125-127`). + // `legacyHttpClientLayer` bakes the DoH wrapper into the shared client, so we + // override `FetchHttpClient.Fetch` at this scope UNCONDITIONALLY: a CA-trusting + // fetch for local + https, plain `globalThis.fetch` otherwise. (`Fetch` is read + // per request from the fiber context, so the scope override applies to every + // gateway call.) The api-keys lookup above runs through the platform API factory + // BEFORE this scope, so it still honors `--dns-resolver https`, matching Go's + // `tenant.GetApiKeys` → `GetSupabase`. + const gatewayOps = Effect.gen(function* () { + const gateway = yield* makeLegacyStorageGateway({ + baseUrl, + apiKey, + userAgent: cliConfig.userAgent, + }); + + const summary = emptySummary(); + + // 5. Upsert configured buckets. + yield* upsertBuckets(output, yes, gateway, bucketPropsByName, summary); + + // 6. Upsert analytics buckets (remote --linked only). + if (config.storage.analytics.enabled && projectRef !== "") { + yield* output.raw("Updating analytics buckets...\n", "stderr"); + yield* upsertAnalyticsBuckets( + output, + yes, + gateway, + Object.keys(config.storage.analytics.buckets), + summary, + ); + } + + // 7. Upsert vector buckets (local), with graceful skip on unavailability. + if (vectorEnabled && hasVectorBuckets) { + yield* output.raw("Updating vector buckets...\n", "stderr"); + yield* upsertVectorBuckets(output, yes, gateway, vectorBucketNames, summary).pipe( + Effect.catch((error) => handleVectorError(output, error, summary)), + ); + } + + // 8. Upload objects for each bucket with a configured objects_path. + yield* uploadObjects(fs, path, output, gateway, cliConfig.workdir, bucketsConfig, summary); + + // 9. Machine-readable summary (Go has none; text mode emits nothing extra). + if (output.format !== "text") { + yield* output.success("", { ...summary }); + } + }); + + // Non-DoH fetch for every gateway call: CA-trusting for local + https, plain + // `globalThis.fetch` otherwise. Never the DoH-wrapped shared client. + yield* gatewayOps.pipe( + Effect.provideService( + FetchHttpClient.Fetch, + localKongCa !== undefined ? legacyKongCaFetch(localKongCa) : globalThis.fetch, + ), + ); + }).pipe( + // Go's root `Execute` caches the linked project + fires org/project group + // identify whenever `flags.ProjectRef` is set — only on the --linked path. + // `suspend` defers reading `linkedRef` until the finalizer runs (after the + // ref has been resolved inside the gen). + Effect.ensuring( + Effect.suspend(() => (linkedRef === "" ? Effect.void : linkedProjectCache.cache(linkedRef))), + ), + Effect.ensuring(telemetryState.flush), + ); +}); + +/** + * Local API URL, mirroring Go's `config.go:634-644` + `misc.go:298`: an explicit + * `api.external_url` wins, otherwise `://:` where the scheme + * follows `api.tls.enabled`, the host is resolved by `legacyGetHostname` (Go's + * `utils.GetHostname`: `SUPABASE_SERVICES_HOSTNAME` → TCP Docker daemon host → + * `127.0.0.1`), and the port is `api.port`. + */ +function resolveLocalBaseUrl(config: { + readonly api: { + readonly external_url?: string; + readonly port: number; + readonly tls: { readonly enabled: boolean }; + }; +}): string { + if (config.api.external_url !== undefined && config.api.external_url.length > 0) { + return config.api.external_url; + } + const host = legacyGetHostname(); + const scheme = config.api.tls.enabled ? "https" : "http"; + // Go builds the host:port with net.JoinHostPort (config.go:636-638), which + // brackets an IPv6 host (e.g. `::1` → `[::1]:54321`); a bare `::1:54321` is an + // invalid URL. legacyGetHostname returns the unbracketed host, so bracket here. + const hostPort = host.includes(":") + ? `[${host}]:${config.api.port}` + : `${host}:${config.api.port}`; + return `${scheme}://${hostPort}`; +} + +/** + * Resolve the service-role key used against the local Storage gateway, mirroring + * Go's `(*auth).generateAPIKeys` (`apps/cli-go/pkg/config/apikeys.go:43-63`), + * which `config.Load` always runs before `NewStorageAPI`. Applies env-var + * precedence matching Go's Viper `AutomaticEnv`+`SUPABASE_` prefix + * (`apps/cli-go/pkg/config/config.go:492-497`): + * - jwt secret: `SUPABASE_AUTH_JWT_SECRET` env (if set & non-empty) → + * `auth.jwt_secret` (if non-empty) → `defaultJwtSecret`; + * - a resolved secret shorter than 16 chars is rejected; + * - service-role key: `SUPABASE_AUTH_SERVICE_ROLE_KEY` env (if set & non-empty) → + * `auth.service_role_key` (if non-empty) → sign from resolved secret. + * + * `@supabase/config` has no `generateAPIKeys` equivalent (the keys are + * `optionalKey` with no default), so this fill-in is the caller's job. Empty + * checks use length, not nullishness, so an explicit `service_role_key = ""` is + * regenerated like Go (`??` would have sent the empty string). An unresolved + * `env(...)` literal is passed through verbatim, exactly as Go does + * (`pkg/config/decode_hooks.go:15-26` leaves it, and a non-empty literal is not + * regenerated by `generateAPIKeys`). + */ +const resolveLocalServiceRoleKey = Effect.fnUntraced(function* (auth: { + readonly jwt_secret?: string; + readonly service_role_key?: string; +}) { + // Apply env-var precedence for jwt_secret (Go Viper AutomaticEnv). + const envSecret = process.env["SUPABASE_AUTH_JWT_SECRET"]; + const configuredSecret = + envSecret !== undefined && envSecret.length > 0 ? envSecret : auth.jwt_secret; + + let jwtSecret: string; + if (configuredSecret === undefined || configuredSecret.length === 0) { + jwtSecret = defaultJwtSecret; + } else if (configuredSecret.length < 16) { + return yield* new LegacySeedConfigLoadError({ + message: "Invalid config for auth.jwt_secret. Must be at least 16 characters", + }); + } else { + jwtSecret = configuredSecret; + } + + // Apply env-var precedence for service_role_key (Go Viper AutomaticEnv). + const envKey = process.env["SUPABASE_AUTH_SERVICE_ROLE_KEY"]; + const configuredKey = envKey !== undefined && envKey.length > 0 ? envKey : auth.service_role_key; + return configuredKey !== undefined && configuredKey.length > 0 + ? configuredKey + : generateJwt(jwtSecret, "service_role"); +}); + +type BucketsConfig = Readonly< + Record< + string, + { + readonly public: boolean; + readonly file_size_limit: string; + readonly allowed_mime_types: ReadonlyArray; + readonly objects_path: string; + } + > +>; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Whether the bucket's TOML entry explicitly declares a `public` key. Go reads + * `public` into a `*bool`, so an absent key serialises as omitted (not `false`). + * The decoded `@supabase/config` value defaults to `false` and loses this, so we + * recover presence from the raw (post-`env()`) document. + */ +function bucketHasPublicKey(document: Record | undefined, name: string): boolean { + return bucketHasKey(document, name, "public"); +} + +/** + * Whether the bucket's TOML entry explicitly declares `file_size_limit`. Absent + * decodes to the bucket schema default (`50MiB`), losing the "omitted" signal Go + * relies on to inherit the storage-level limit, so recover presence from the raw + * (post-`env()`) document — same approach as `bucketHasPublicKey`. + */ +function bucketHasFileSizeLimit( + document: Record | undefined, + name: string, +): boolean { + return bucketHasKey(document, name, "file_size_limit"); +} + +function bucketHasKey( + document: Record | undefined, + name: string, + key: string, +): boolean { + if (document === undefined) return false; + const storage = document["storage"]; + if (!isRecord(storage)) return false; + const buckets = storage["buckets"]; + if (!isRecord(buckets)) return false; + const bucket = buckets[name]; + return isRecord(bucket) && key in bucket; +} + +/** + * Resolve a bucket's create/update props, mirroring Go's `config.resolve()` + * (`apps/cli-go/pkg/config/config.go:753-756`) + the `sizeInBytes` decode that + * happens at config-load **before** `NewStorageAPI`: + * - an omitted or zero `file_size_limit` inherits the storage-level limit; + * - the size is parsed up front, so an invalid value fails (mapped to a + * config-load error) before any Storage list/create/update side effect — Go + * rejects the same config during `LoadConfig`. + */ +// Parse a `file_size_limit` string to bytes, mapping a parse failure to a +// config-load error (Go rejects an invalid `sizeInBytes` during `config.Load`, +// before NewStorageAPI). +const parseFileSizeLimitOrFail = (value: string) => + Effect.try({ + try: () => legacyParseFileSizeLimit(value), + catch: (cause) => + new LegacySeedConfigLoadError({ + message: cause instanceof Error ? cause.message : String(cause), + }), + }); + +const computeBucketProps = Effect.fnUntraced(function* ( + document: Record | undefined, + name: string, + bucket: BucketsConfig[string], + storageFileSizeLimitBytes: number, +) { + // Go's resolve() inherits the (already-parsed) storage-level limit when the + // bucket omits its own / sets 0 (`config.go:753-756`). + const bucketBytes = bucketHasFileSizeLimit(document, name) + ? yield* parseFileSizeLimitOrFail(bucket.file_size_limit) + : 0; + const fileSizeLimit = bucketBytes === 0 ? storageFileSizeLimitBytes : bucketBytes; + + return { + public: bucketHasPublicKey(document, name) ? bucket.public : undefined, + fileSizeLimit, + allowedMimeTypes: bucket.allowed_mime_types, + } satisfies LegacyUpsertBucketProps; +}); + +/** + * Confirm-or-default prompt mirroring Go's `console.PromptYesNo` + * (`internal/utils/console.go`): `--yes`/`SUPABASE_YES` echoes `