From 4cd9b2c7e65d0c81a9876b0a0836778b69ec34be Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 12:16:34 +0000 Subject: [PATCH 01/26] feat(cli): port `seed buckets` to native TypeScript Replace the Phase-0 Go proxy for `supabase seed buckets` with a native Effect implementation that is output-compatible with the Go command. The command is local-only in practice: Go's `seed` command defines no `--project-ref` flag, so the project ref is always empty and the remote client factory, service-role-key resolution, and analytics-bucket upsert are unreachable. Only the reachable local behavior is ported: - a native Storage service-gateway HTTP client (list/create/update bucket, vector list/create/delete, object upload) under `seed/buckets/` - bucket upsert with overwrite prompt, vector upsert with the two graceful-skip WARNINGs, and object-tree upload (5-way concurrency) - a lightweight `legacySeedRuntimeLayer` exposing only HttpClient, LegacyCliConfig, LegacyTelemetryState, and CommandRuntime - json/stream-json structured summary + prompt suppression Hoist the docker go-units size helpers (`ramInBytes`/`bytesSize`/ `intToUint`) out of `config/push` into `legacy/shared/legacy-size-units.ts` since `seed buckets` now also parses `file_size_limit`. Rewrite the `seed buckets` SIDE_EFFECTS.md (it described the wrong route) and mark the command `ported` in the porting-status tracker. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01X56gEKaCJZ6SxEasUq7pXM --- apps/cli/docs/go-cli-porting-status.md | 96 ++-- apps/cli/package.json | 3 +- .../config/push/config-sync/api.sync.ts | 2 +- .../config/push/config-sync/auth.sync.ts | 2 +- .../config/push/config-sync/db.sync.ts | 2 +- .../config/push/config-sync/storage.sync.ts | 2 +- .../commands/seed/buckets/SIDE_EFFECTS.md | 107 +++- .../commands/seed/buckets/buckets.classify.ts | 27 + .../buckets/buckets.classify.unit.test.ts | 46 ++ .../commands/seed/buckets/buckets.command.ts | 14 +- .../commands/seed/buckets/buckets.errors.ts | 36 ++ .../commands/seed/buckets/buckets.gateway.ts | 189 +++++++ .../commands/seed/buckets/buckets.handler.ts | 371 +++++++++++++- .../seed/buckets/buckets.integration.test.ts | 478 ++++++++++++++++++ .../commands/seed/buckets/buckets.upload.ts | 86 ++++ .../seed/buckets/buckets.upload.unit.test.ts | 59 +++ .../src/legacy/commands/seed/seed.layers.ts | 59 +++ .../legacy-size-units.ts} | 4 + 18 files changed, 1495 insertions(+), 88 deletions(-) create mode 100644 apps/cli/src/legacy/commands/seed/buckets/buckets.classify.ts create mode 100644 apps/cli/src/legacy/commands/seed/buckets/buckets.classify.unit.test.ts create mode 100644 apps/cli/src/legacy/commands/seed/buckets/buckets.errors.ts create mode 100644 apps/cli/src/legacy/commands/seed/buckets/buckets.gateway.ts create mode 100644 apps/cli/src/legacy/commands/seed/buckets/buckets.integration.test.ts create mode 100644 apps/cli/src/legacy/commands/seed/buckets/buckets.upload.ts create mode 100644 apps/cli/src/legacy/commands/seed/buckets/buckets.upload.unit.test.ts create mode 100644 apps/cli/src/legacy/commands/seed/seed.layers.ts rename apps/cli/src/legacy/{commands/config/push/config-sync/config-sync.units.ts => shared/legacy-size-units.ts} (93%) 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/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/seed/buckets/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/seed/buckets/SIDE_EFFECTS.md index ac81101201..0ff740483f 100644 --- a/apps/cli/src/legacy/commands/seed/buckets/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/seed/buckets/SIDE_EFFECTS.md @@ -1,11 +1,15 @@ # `supabase seed buckets` +Seeds the **local** Supabase Storage stack from `[storage.buckets]` and +`[storage.vector]` in `supabase/config.toml`. Port of +`apps/cli-go/internal/seed/buckets/buckets.go`. + ## 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 | +| `/.objects_path/**` | any (bytes) | per configured bucket with a non-empty `objects_path`, recursively | ## Files Written @@ -15,43 +19,98 @@ ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | -------------------- | ------------ | ------------------------- | ---------------------- | -| `POST` | `/storage/v1/bucket` | Bearer token | `{id, name, public, ...}` | `{name}` | +All routes target the local **Storage service gateway** (Kong) at +`api.external_url` (default `http://127.0.0.1:54321`). Auth: `apikey` + +`Authorization: Bearer` headers, both set to the local service-role key +(`auth.service_role_key`, or a JWT signed from `auth.jwt_secret`). + +| 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) | + +`file_size_limit` is omitted from the body when `0`; `allowed_mime_types` is +omitted when empty (Go `omitempty`). ## 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 (default `127.0.0.1`) | 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` | Storage API error (non-2xx) other than vector-unavailable | +| `1` | network / connection failure to the Storage gateway | +| `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 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. +- **Local-only.** Go's `seed` command defines no `--project-ref` flag, so + `flags.ParseProjectRef` never runs and the project ref is always empty. The + remote client factory, service-role-key resolution via the Management API, and + analytics-bucket upsert (gated on a non-empty ref) are therefore unreachable + and are not implemented. `--linked` and `--local` are accepted for CLI-surface + parity but both seed the local stack identically. +- **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 is derived from the file extension — a + best-effort approximation of Go's `http.DetectContentType` + `mime.TypeByExtension`. 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..efd5a1a1a2 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,10 @@ import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; -import { legacyBuckets } from "./buckets.handler.ts"; + +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.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 +16,11 @@ 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) => + legacySeedBuckets(flags).pipe( + withLegacyCommandInstrumentation({ flags }), + withJsonErrorHandling, + ), + ), + Command.provide(legacySeedRuntimeLayer(["seed", "buckets"])), ); 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..1afb1a1285 --- /dev/null +++ b/apps/cli/src/legacy/commands/seed/buckets/buckets.errors.ts @@ -0,0 +1,36 @@ +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; +}> {} 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..18d17fcebc --- /dev/null +++ b/apps/cli/src/legacy/commands/seed/buckets/buckets.gateway.ts @@ -0,0 +1,189 @@ +import { Effect } from "effect"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +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 (`apikey` + `Authorization: Bearer`). + * + * 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 { + readonly public: boolean; + /** 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, + bytes: Uint8Array, + contentType: string, + ) => Effect.Effect; +} + +function readString(obj: unknown, key: string): string { + if (typeof obj === "object" && obj !== null && key in obj) { + const value = (obj as Record)[key]; + return typeof value === "string" ? value : ""; + } + return ""; +} + +/** Build the create/update bucket body with Go's `omitempty` semantics. */ +function bucketBody(props: LegacyUpsertBucketProps): Record { + const body: Record = { 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 withAuth = ( + req: HttpClientRequest.HttpClientRequest, + ): HttpClientRequest.HttpClientRequest => + req.pipe( + HttpClientRequest.setHeader("apikey", opts.apiKey), + HttpClientRequest.setHeader("Authorization", `Bearer ${opts.apiKey}`), + HttpClientRequest.setHeader("User-Agent", opts.userAgent), + ); + + // 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-2xx → `Error status : ` status error. + 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( + (cause) => + new LegacySeedStorageNetworkError({ + message: `failed to execute http request: ${cause}`, + }), + ), + ); + if (status < 200 || status >= 300) { + 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.map((body) => { + const parsed: unknown = JSON.parse(body); + if (!Array.isArray(parsed)) return []; + return parsed.map((entry) => ({ + name: readString(entry, "name"), + id: readString(entry, "id"), + })); + }), + ), + createBucket: (name, props) => + send( + withAuth(HttpClientRequest.post(url("/storage/v1/bucket"))).pipe( + HttpClientRequest.bodyJsonUnsafe({ name, ...bucketBody(props) }), + ), + ).pipe(Effect.asVoid), + updateBucket: (id, props) => + send( + withAuth(HttpClientRequest.put(url(`/storage/v1/bucket/${id}`))).pipe( + HttpClientRequest.bodyJsonUnsafe(bucketBody(props)), + ), + ).pipe(Effect.asVoid), + listVectorBuckets: () => + send( + withAuth(HttpClientRequest.post(url("/storage/v1/vector/ListVectorBuckets"))).pipe( + HttpClientRequest.bodyJsonUnsafe({}), + ), + ).pipe( + Effect.map((body) => { + const parsed: unknown = JSON.parse(body); + const list = + typeof parsed === "object" && parsed !== null + ? (parsed as { vectorBuckets?: unknown }).vectorBuckets + : undefined; + if (!Array.isArray(list)) return []; + return list.map((entry) => readString(entry, "vectorBucketName")); + }), + ), + 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), + uploadObject: (remotePath, bytes, 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"), + ); + return send(HttpClientRequest.bodyUint8Array(req, bytes, contentType)).pipe(Effect.asVoid); + }, + }; + + return gateway; +}); 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..a84ef8c2d9 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,366 @@ -import { Effect } from "effect"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { loadProjectConfig } from "@supabase/config"; +import { defaultJwtSecret, generateJwt } from "@supabase/stack/effect"; +import { Effect, FileSystem, Path } from "effect"; +import type { PlatformError } from "effect/PlatformError"; + +import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { legacyBold, legacyYellow } from "../../../shared/legacy-colors.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 { + LegacySeedConfigLoadError, + LegacySeedStorageNetworkError, + LegacySeedStorageStatusError, +} from "./buckets.errors.ts"; +import { + legacyBucketObjectKey, + legacyContentTypeForPath, + 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; + +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; +} + +function emptySummary(): SeedSummary { + return { + buckets_created: [], + buckets_updated: [], + buckets_skipped: [], + vector_created: [], + vector_pruned: [], + vector_skipped: false, + objects_uploaded: [], + }; +} + +/** + * `supabase seed buckets` — seeds the **local** Storage stack from + * `[storage.buckets]` / `[storage.vector]` in `supabase/config.toml`. + * + * Port of `apps/cli-go/internal/seed/buckets/buckets.go`. Local-only: Go's + * `seed` command never resolves a project ref (see `seed.layers.ts`), so the + * remote / analytics paths are unreachable and omitted. + */ +export const legacySeedBuckets = Effect.fn("legacy.seed.buckets")(function* ( + _flags: LegacyBucketsFlags, +) { + const output = yield* Output; + const cliConfig = yield* LegacyCliConfig; + const telemetryState = yield* LegacyTelemetryState; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + yield* Effect.gen(function* () { + // 1. Load config.toml. A parse failure aborts before any network call. + const loaded = yield* loadProjectConfig(cliConfig.workdir).pipe( + Effect.catchTag( + "ProjectConfigParseError", + (cause) => + new LegacySeedConfigLoadError({ + message: `failed to parse supabase/config.toml: ${String(cause.cause)}`, + }), + ), + ); + if (loaded === null) { + return; + } + const config = loaded.config; + 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; + + // 2. Short-circuit: nothing to seed (projectRef is always empty locally). + if (bucketNames.length === 0 && !hasVectorBuckets) { + return; + } + + // 3. Build the local Storage service-gateway client. + const baseUrl = resolveLocalBaseUrl(config); + const apiKey = + config.auth.service_role_key ?? + generateJwt(config.auth.jwt_secret ?? defaultJwtSecret, "service_role"); + const gateway = yield* makeLegacyStorageGateway({ + baseUrl, + apiKey, + userAgent: cliConfig.userAgent, + }); + + const summary = emptySummary(); + + // 4. Upsert configured buckets. + yield* upsertBuckets(output, gateway, bucketsConfig, summary); + + // 5. Upsert vector buckets (local), with graceful skip on unavailability. + if (vectorEnabled && hasVectorBuckets) { + yield* output.raw("Updating vector buckets...\n", "stderr"); + yield* upsertVectorBuckets(output, gateway, vectorBucketNames, summary).pipe( + Effect.catch((error) => handleVectorError(output, error, summary)), + ); + } + + // 6. Upload objects for each bucket with a configured objects_path. + yield* uploadObjects(fs, path, output, gateway, cliConfig.workdir, bucketsConfig, summary); + + // 7. Machine-readable summary (Go has none; text mode emits nothing extra). + if (output.format !== "text") { + yield* output.success("", { ...summary }); + } + }).pipe(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 `SUPABASE_SERVICES_HOSTNAME` (or + * `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 = process.env["SUPABASE_SERVICES_HOSTNAME"] ?? "127.0.0.1"; + const scheme = config.api.tls.enabled ? "https" : "http"; + return `${scheme}://${host}:${config.api.port}`; +} + +type BucketsConfig = Readonly< + Record< + string, + { + readonly public: boolean; + readonly file_size_limit: string; + readonly allowed_mime_types: ReadonlyArray; + readonly objects_path: string; + } + > +>; + +function bucketProps(bucket: BucketsConfig[string]): LegacyUpsertBucketProps { + return { + public: bucket.public, + fileSizeLimit: legacyParseFileSizeLimit(bucket.file_size_limit), + allowedMimeTypes: bucket.allowed_mime_types, + }; +} + +/** + * Confirm-or-default prompt mirroring Go's `console.PromptYesNo`: a real TTY in + * text mode prompts; everything else (non-interactive, json/stream-json) uses + * the default without printing. + */ +const promptYesNo = Effect.fnUntraced(function* ( + output: typeof Output.Service, + label: string, + defaultValue: boolean, +) { + if (output.format !== "text") { + return defaultValue; + } + return yield* output + .promptConfirm(label, { defaultValue }) + .pipe(Effect.catchTag("NonInteractiveError", () => Effect.succeed(defaultValue))); +}); + +// Port of `pkg/storage/batch.go:UpsertBuckets`. +const upsertBuckets = Effect.fnUntraced(function* ( + output: typeof Output.Service, + gateway: LegacyStorageGateway, + bucketsConfig: BucketsConfig, + summary: SeedSummary, +) { + const existing = yield* gateway.listBuckets(); + const byName = new Map(existing.map((b) => [b.name, b.id])); + + for (const [name, bucket] of Object.entries(bucketsConfig)) { + const bucketId = byName.get(name); + if (bucketId !== undefined) { + const overwrite = yield* promptYesNo( + output, + `Bucket ${legacyBold(bucketId)} already exists. Do you want to overwrite its properties?`, + true, + ); + if (!overwrite) { + summary.buckets_skipped.push(bucketId); + continue; + } + yield* output.raw(`Updating Storage bucket: ${bucketId}\n`, "stderr"); + yield* gateway.updateBucket(bucketId, bucketProps(bucket)); + summary.buckets_updated.push(bucketId); + } else { + yield* output.raw(`Creating Storage bucket: ${name}\n`, "stderr"); + yield* gateway.createBucket(name, bucketProps(bucket)); + summary.buckets_created.push(name); + } + } +}); + +// Port of `pkg/storage/vector.go:UpsertVectorBuckets`. +const upsertVectorBuckets = Effect.fnUntraced(function* ( + output: typeof Output.Service, + gateway: LegacyStorageGateway, + configuredNames: ReadonlyArray, + summary: SeedSummary, ) { - 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 existing = yield* gateway.listVectorBuckets(); + const existingSet = new Set(existing); + const configuredSet = new Set(configuredNames); + const toDelete = existing.filter((name) => !configuredSet.has(name)); + + for (const name of configuredNames) { + if (existingSet.has(name)) { + yield* output.raw(`Bucket already exists: ${name}\n`, "stderr"); + continue; + } + yield* output.raw(`Creating vector bucket: ${name}\n`, "stderr"); + yield* gateway.createVectorBucket(name); + summary.vector_created.push(name); + } + + for (const name of toDelete) { + const prune = yield* promptYesNo( + output, + `Bucket ${legacyBold(name)} not found in ${legacyBold(CONFIG_PATH)}. Do you want to prune it?`, + false, + ); + if (!prune) { + continue; + } + yield* output.raw(`Pruning vector bucket: ${name}\n`, "stderr"); + yield* gateway.deleteVectorBucket(name); + summary.vector_pruned.push(name); + } +}); + +/** + * Vector graceful-skip (`buckets.go:57-66`): on `FeatureNotEnabled` / + * local-unavailable errors, print the matching WARNING and continue (object + * upload still runs). Any other error propagates. + */ +const handleVectorError = Effect.fnUntraced(function* ( + output: typeof Output.Service, + error: StorageError, + summary: SeedSummary, +) { + if (legacyIsVectorBucketsFeatureNotEnabled(error.message)) { + yield* output.raw( + `${legacyYellow("WARNING:")} Vector buckets are not available in this project's region yet. Skipping vector bucket seeding.\n`, + "stderr", + ); + summary.vector_skipped = true; + return; + } + if (legacyIsLocalVectorBucketsUnavailable(error.message)) { + yield* output.raw( + `${legacyYellow("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.\n`, + "stderr", + ); + summary.vector_skipped = true; + return; + } + return yield* Effect.fail(error); }); + +// Port of `pkg/storage/batch.go:UpsertObjects` (+ object walk in objects.go). +const uploadObjects = Effect.fnUntraced(function* ( + fs: FileSystem.FileSystem, + path: Path.Path, + output: typeof Output.Service, + gateway: LegacyStorageGateway, + workdir: string, + bucketsConfig: BucketsConfig, + summary: SeedSummary, +) { + for (const [name, bucket] of Object.entries(bucketsConfig)) { + const objectsPath = bucket.objects_path; + if (objectsPath.length === 0) { + continue; + } + const absRoot = path.resolve(workdir, objectsPath); + const files = yield* collectFiles(fs, path, output, absRoot, objectsPath); + yield* Effect.forEach( + files, + (file) => + Effect.gen(function* () { + const dstPath = legacyBucketObjectKey(name, objectsPath, file.displayPath); + yield* output.raw(`Uploading: ${file.displayPath} => ${dstPath}\n`, "stderr"); + const bytes = yield* fs.readFile(file.absPath); + yield* gateway.uploadObject(dstPath, bytes, legacyContentTypeForPath(file.absPath)); + summary.objects_uploaded.push(dstPath); + }), + { concurrency: UPLOAD_CONCURRENCY }, + ); + } +}); + +/** + * Recursively collect regular files under `absRoot`, lexically ordered (Go's + * `fs.WalkDir`). `displayRoot` is the config-relative path Go prints in the + * `Uploading:` line. Non-regular entries are skipped with Go's + * `Skipping non-regular file:` notice. + */ +const collectFiles = ( + fs: FileSystem.FileSystem, + path: Path.Path, + output: typeof Output.Service, + absRoot: string, + displayRoot: string, +): Effect.Effect, PlatformError> => + Effect.gen(function* () { + const info = yield* fs.stat(absRoot); + if (info.type === "Directory") { + const names = [...(yield* fs.readDirectory(absRoot))].sort(); + const collected: Array = []; + for (const name of names) { + const nested = yield* collectFiles( + fs, + path, + output, + path.join(absRoot, name), + path.join(displayRoot, name), + ); + collected.push(...nested); + } + return collected; + } + if (info.type === "File") { + return [{ absPath: absRoot, displayPath: displayRoot }]; + } + yield* output.raw(`Skipping non-regular file: ${displayRoot}\n`, "stderr"); + return []; + }); diff --git a/apps/cli/src/legacy/commands/seed/buckets/buckets.integration.test.ts b/apps/cli/src/legacy/commands/seed/buckets/buckets.integration.test.ts new file mode 100644 index 0000000000..120d888a5b --- /dev/null +++ b/apps/cli/src/legacy/commands/seed/buckets/buckets.integration.test.ts @@ -0,0 +1,478 @@ +import { execFileSync } from "node:child_process"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; + +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, Layer } from "effect"; +import * as HttpClient from "effect/unstable/http/HttpClient"; + +import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { + legacyJsonResponse, + legacyTransportFailure, + mockLegacyCliConfig, + mockLegacyTelemetryStateTracked, + useLegacyTempWorkdir, +} from "../../../../../tests/helpers/legacy-mocks.ts"; +import type { OutputFormat } from "../../../../shared/output/types.ts"; +import { legacySeedBuckets } from "./buckets.handler.ts"; +import type { LegacyBucketsFlags } from "./buckets.command.ts"; + +interface MockRoute { + readonly method: string; + /** Substring matched against the request URL. */ + readonly match: string; + readonly status?: number; + readonly body?: unknown; + /** When set, the route fails with a transport error instead of responding. */ + readonly transport?: boolean; +} + +const DEFAULT_FLAGS: LegacyBucketsFlags = { linked: false, local: true }; + +function setupLegacySeedBuckets( + workdir: string, + opts: { + readonly toml?: string; + readonly routes?: ReadonlyArray; + readonly files?: Readonly>; + readonly format?: OutputFormat; + readonly confirm?: ReadonlyArray; + readonly promptConfirmFail?: boolean; + }, +) { + if (opts.toml !== undefined) { + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync(join(workdir, "supabase", "config.toml"), opts.toml); + } + + for (const [rel, content] of Object.entries(opts.files ?? {})) { + const abs = join(workdir, rel); + mkdirSync(dirname(abs), { recursive: true }); + writeFileSync(abs, content); + } + + const out = mockOutput({ + format: opts.format ?? "text", + promptConfirmResponses: opts.confirm, + promptConfirmFail: opts.promptConfirmFail, + }); + + const requests: Array<{ method: string; url: string }> = []; + const routes = opts.routes ?? []; + const httpLayer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => { + requests.push({ method: request.method, url: request.url }); + const route = routes.find( + (r) => r.method === request.method && request.url.includes(r.match), + ); + if (route === undefined) { + return Effect.succeed(legacyJsonResponse(request, 404, { message: "no mock route" })); + } + if (route.transport === true) { + return Effect.fail(legacyTransportFailure(request)); + } + return Effect.succeed(legacyJsonResponse(request, route.status ?? 200, route.body ?? {})); + }), + ); + + const telemetry = mockLegacyTelemetryStateTracked(); + + const layer = Layer.mergeAll( + out.layer, + httpLayer, + telemetry.layer, + mockLegacyCliConfig({ workdir }), + BunServices.layer, + ); + + return { layer, out, requests, telemetry }; +} + +const VECTOR_LIST = "/storage/v1/vector/ListVectorBuckets"; +const VECTOR_CREATE = "/storage/v1/vector/CreateVectorBucket"; +const VECTOR_DELETE = "/storage/v1/vector/DeleteVectorBucket"; + +describe("legacy seed buckets", () => { + const tmp = useLegacyTempWorkdir("supabase-seed-buckets-"); + + it.live("short-circuits with no output when nothing is configured", () => { + const { layer, out, requests } = setupLegacySeedBuckets(tmp.current, { + toml: 'project_id = "test"\n', + }); + return Effect.gen(function* () { + const exit = yield* legacySeedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + expect(requests).toHaveLength(0); + expect(out.stderrText).toBe(""); + }); + }); + + it.live("creates a new bucket and updates an existing one (overwrite default yes)", () => { + const { layer, out, requests } = setupLegacySeedBuckets(tmp.current, { + toml: "[storage.buckets.test]\npublic = true\n[storage.buckets.private]\npublic = false\n", + routes: [ + { method: "GET", match: "/storage/v1/bucket", body: [{ name: "test", id: "test" }] }, + { method: "PUT", match: "/storage/v1/bucket/test", body: {} }, + { method: "POST", match: "/storage/v1/bucket", body: { name: "private" } }, + ], + // Non-interactive text mode: prompt fails → overwrite default (true) applies. + promptConfirmFail: true, + }); + return Effect.gen(function* () { + const exit = yield* legacySeedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + expect(out.stderrText).toContain("Updating Storage bucket: test"); + expect(out.stderrText).toContain("Creating Storage bucket: private"); + expect(requests.some((r) => r.method === "PUT" && r.url.includes("/bucket/test"))).toBe(true); + expect( + requests.some((r) => r.method === "POST" && r.url.endsWith("/storage/v1/bucket")), + ).toBe(true); + }); + }); + + it.live("skips the update when the overwrite prompt is declined", () => { + const { layer, out, requests } = setupLegacySeedBuckets(tmp.current, { + toml: "[storage.buckets.test]\npublic = true\n", + routes: [ + { method: "GET", match: "/storage/v1/bucket", body: [{ name: "test", id: "test" }] }, + ], + confirm: [false], + }); + return Effect.gen(function* () { + const exit = yield* legacySeedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + expect(out.stderrText).not.toContain("Updating Storage bucket"); + expect(requests.some((r) => r.method === "PUT")).toBe(false); + }); + }); + + it.live("creates configured vector buckets and leaves stale ones (prune default no)", () => { + const { layer, out, requests } = setupLegacySeedBuckets(tmp.current, { + toml: "[storage.vector]\nenabled = true\n[storage.vector.buckets.documents-openai]\n[storage.vector.buckets.existing-vec]\n", + routes: [ + { method: "GET", match: "/storage/v1/bucket", body: [] }, + { + method: "POST", + match: VECTOR_LIST, + body: { + vectorBuckets: [ + { vectorBucketName: "existing-vec" }, + { vectorBucketName: "stale-vec" }, + ], + }, + }, + { method: "POST", match: VECTOR_CREATE, body: {} }, + { method: "POST", match: VECTOR_DELETE, body: {} }, + ], + // Non-interactive: prune prompt fails → default (false) → no delete. + promptConfirmFail: true, + }); + return Effect.gen(function* () { + const exit = yield* legacySeedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + expect(out.stderrText).toContain("Updating vector buckets..."); + expect(out.stderrText).toContain("Creating vector bucket: documents-openai"); + expect(out.stderrText).toContain("Bucket already exists: existing-vec"); + expect(requests.some((r) => r.url.includes(VECTOR_CREATE))).toBe(true); + expect(requests.some((r) => r.url.includes(VECTOR_DELETE))).toBe(false); + }); + }); + + it.live("prunes a stale vector bucket when the prompt is accepted", () => { + const { layer, out, requests } = setupLegacySeedBuckets(tmp.current, { + toml: "[storage.vector]\nenabled = true\n[storage.vector.buckets.keep-vec]\n", + routes: [ + { method: "GET", match: "/storage/v1/bucket", body: [] }, + { + method: "POST", + match: VECTOR_LIST, + body: { + vectorBuckets: [{ vectorBucketName: "keep-vec" }, { vectorBucketName: "stale-vec" }], + }, + }, + { method: "POST", match: VECTOR_DELETE, body: {} }, + ], + confirm: [true], + }); + return Effect.gen(function* () { + const exit = yield* legacySeedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + expect(out.stderrText).toContain("Pruning vector bucket: stale-vec"); + expect(requests.some((r) => r.url.includes(VECTOR_DELETE))).toBe(true); + }); + }); + + it.live("warns and continues when vector buckets are unavailable in the region", () => { + const { layer, out } = setupLegacySeedBuckets(tmp.current, { + toml: "[storage.vector]\nenabled = true\n[storage.vector.buckets.documents-openai]\n", + routes: [ + { method: "GET", match: "/storage/v1/bucket", body: [] }, + { method: "POST", match: VECTOR_LIST, status: 400, body: { code: "FeatureNotEnabled" } }, + ], + }); + return Effect.gen(function* () { + const exit = yield* legacySeedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + expect(out.stderrText).toContain("WARNING:"); + expect(out.stderrText).toContain( + "Vector buckets are not available in this project's region yet", + ); + }); + }); + + it.live("warns and continues when the local vector service is unavailable", () => { + const { layer, out } = setupLegacySeedBuckets(tmp.current, { + toml: "[storage.vector]\nenabled = true\n[storage.vector.buckets.documents-openai]\n", + routes: [ + { method: "GET", match: "/storage/v1/bucket", body: [] }, + { + method: "POST", + match: VECTOR_LIST, + status: 404, + body: { message: "Route POST:/vector/ListVectorBuckets not found" }, + }, + ], + }); + return Effect.gen(function* () { + const exit = yield* legacySeedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + expect(out.stderrText).toContain( + "Vector buckets are not available in the local storage service", + ); + expect(out.stderrText).toContain("supabase link"); + expect(out.stderrText).toContain("restart the local stack"); + }); + }); + + it.live("propagates an unclassified vector error", () => { + const { layer } = setupLegacySeedBuckets(tmp.current, { + toml: "[storage.vector]\nenabled = true\n[storage.vector.buckets.documents-openai]\n", + routes: [ + { method: "GET", match: "/storage/v1/bucket", body: [] }, + { method: "POST", match: VECTOR_LIST, status: 500, body: { message: "boom" } }, + ], + }); + return Effect.gen(function* () { + const exit = yield* legacySeedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + }); + }); + + it.live("uploads objects from a bucket's objects_path", () => { + const { layer, out, requests } = setupLegacySeedBuckets(tmp.current, { + toml: '[storage.buckets.images]\npublic = true\nobjects_path = "./assets"\n', + files: { + "assets/a.txt": "hello", + "assets/sub/b.txt": "world", + }, + routes: [ + { method: "GET", match: "/storage/v1/bucket", body: [] }, + { method: "POST", match: "/storage/v1/object/", body: {} }, + { method: "POST", match: "/storage/v1/bucket", body: { name: "images" } }, + ], + }); + return Effect.gen(function* () { + const exit = yield* legacySeedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + expect(out.stderrText).toContain("Uploading: assets/a.txt => images/a.txt"); + expect(out.stderrText).toContain("Uploading: assets/sub/b.txt => images/sub/b.txt"); + const uploads = requests.filter((r) => r.url.includes("/storage/v1/object/")); + expect(uploads).toHaveLength(2); + }); + }); + + it.live("fails with a config-load error on malformed config.toml", () => { + const { layer } = setupLegacySeedBuckets(tmp.current, { toml: "[storage\n" }); + return Effect.gen(function* () { + const exit = yield* legacySeedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + }); + }); + + it.live("emits a structured result and suppresses prompts in json mode", () => { + const { layer, out, requests } = setupLegacySeedBuckets(tmp.current, { + toml: "[storage.buckets.test]\npublic = true\n", + routes: [ + { method: "GET", match: "/storage/v1/bucket", body: [{ name: "test", id: "test" }] }, + { method: "PUT", match: "/storage/v1/bucket/test", body: {} }, + ], + format: "json", + }); + return Effect.gen(function* () { + const exit = yield* legacySeedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + // json mode does not prompt; overwrite default (yes) → bucket updated. + expect(out.promptConfirmCalls).toHaveLength(0); + expect(requests.some((r) => r.method === "PUT" && r.url.includes("/bucket/test"))).toBe(true); + }); + }); + + it.live("returns without output when no config.toml is found", () => { + const { layer, out, requests } = setupLegacySeedBuckets(tmp.current, {}); + return Effect.gen(function* () { + const exit = yield* legacySeedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + expect(requests).toHaveLength(0); + expect(out.stderrText).toBe(""); + }); + }); + + it.live("honors an explicit external_url and service_role_key", () => { + const { layer, requests } = setupLegacySeedBuckets(tmp.current, { + toml: [ + "[api]", + 'external_url = "http://gateway.test:9999"', + "[auth]", + 'service_role_key = "explicit-key"', + "[storage.buckets.media]", + "public = true", + 'allowed_mime_types = ["image/png"]', + 'file_size_limit = "0"', + ].join("\n"), + routes: [ + { method: "GET", match: "/storage/v1/bucket", body: [] }, + { method: "POST", match: "/storage/v1/bucket", body: { name: "media" } }, + ], + }); + return Effect.gen(function* () { + const exit = yield* legacySeedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + // baseUrl is the configured external_url, not the 127.0.0.1 default. + expect(requests.every((r) => r.url.startsWith("http://gateway.test:9999"))).toBe(true); + }); + }); + + it.live("derives the service-role key from auth.jwt_secret when no key is set", () => { + const { layer } = setupLegacySeedBuckets(tmp.current, { + toml: [ + "[auth]", + 'jwt_secret = "custom-jwt-secret-at-least-32-characters-long"', + "[storage.buckets.docs]", + "public = false", + ].join("\n"), + routes: [ + { method: "GET", match: "/storage/v1/bucket", body: [] }, + { method: "POST", match: "/storage/v1/bucket", body: { name: "docs" } }, + ], + }); + return Effect.gen(function* () { + const exit = yield* legacySeedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + }); + }); + + it.live("propagates a transport failure from the Storage gateway", () => { + const { layer } = setupLegacySeedBuckets(tmp.current, { + toml: "[storage.buckets.test]\npublic = true\n", + routes: [{ method: "GET", match: "/storage/v1/bucket", transport: true }], + }); + return Effect.gen(function* () { + const exit = yield* legacySeedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + }); + }); + + it.live("skips vector seeding when enabled but no vector buckets are configured", () => { + const { layer, out, requests } = setupLegacySeedBuckets(tmp.current, { + toml: "[storage.vector]\nenabled = true\n[storage.buckets.images]\npublic = true\n", + routes: [ + { method: "GET", match: "/storage/v1/bucket", body: [] }, + { method: "POST", match: "/storage/v1/bucket", body: { name: "images" } }, + ], + }); + return Effect.gen(function* () { + const exit = yield* legacySeedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + expect(out.stderrText).not.toContain("Updating vector buckets..."); + expect(requests.some((r) => r.url.includes("/vector/"))).toBe(false); + }); + }); + + it.live("falls back to the default host when external_url is empty", () => { + const { layer, requests } = setupLegacySeedBuckets(tmp.current, { + toml: '[api]\nexternal_url = ""\n[storage.buckets.images]\npublic = true\n', + routes: [ + { method: "GET", match: "/storage/v1/bucket", body: [] }, + { method: "POST", match: "/storage/v1/bucket", body: { name: "images" } }, + ], + }); + return Effect.gen(function* () { + const exit = yield* legacySeedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + expect(requests.every((r) => r.url.startsWith("http://127.0.0.1:54321"))).toBe(true); + }); + }); + + it.live("tolerates malformed entries in the bucket list response", () => { + const { layer, requests } = setupLegacySeedBuckets(tmp.current, { + toml: "[storage.buckets.images]\npublic = true\n", + routes: [ + { + method: "GET", + match: "/storage/v1/bucket", + // Missing key, non-object entry, and a non-string field exercise the + // defensive readString branches. + body: [{ id: "x" }, "not-an-object", { name: 42, id: "y" }], + }, + { method: "POST", match: "/storage/v1/bucket", body: { name: "images" } }, + ], + }); + return Effect.gen(function* () { + const exit = yield* legacySeedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + expect(requests.some((r) => r.method === "POST")).toBe(true); + }); + }); + + it.live("builds an https base URL with a host override when tls is enabled", () => { + const previousHost = process.env["SUPABASE_SERVICES_HOSTNAME"]; + process.env["SUPABASE_SERVICES_HOSTNAME"] = "docker.host"; + const { layer, requests } = setupLegacySeedBuckets(tmp.current, { + toml: "[api]\nport = 7654\n[api.tls]\nenabled = true\n[storage.buckets.images]\npublic = true\n", + routes: [ + { method: "GET", match: "/storage/v1/bucket", body: [] }, + { method: "POST", match: "/storage/v1/bucket", body: { name: "images" } }, + ], + }); + return Effect.gen(function* () { + const exit = yield* legacySeedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + expect(requests.every((r) => r.url.startsWith("https://docker.host:7654"))).toBe(true); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (previousHost === undefined) { + delete process.env["SUPABASE_SERVICES_HOSTNAME"]; + } else { + process.env["SUPABASE_SERVICES_HOSTNAME"] = previousHost; + } + }), + ), + ); + }); + + it.live("skips non-regular files during the object walk", () => { + // A FIFO is neither a regular file nor a directory, exercising the skip path. + mkdirSync(join(tmp.current, "assets"), { recursive: true }); + writeFileSync(join(tmp.current, "assets", "a.txt"), "hello"); + execFileSync("mkfifo", [join(tmp.current, "assets", "pipe")]); + const { layer, out, requests } = setupLegacySeedBuckets(tmp.current, { + toml: '[storage.buckets.images]\npublic = true\nobjects_path = "./assets"\n', + routes: [ + { method: "GET", match: "/storage/v1/bucket", body: [] }, + { method: "POST", match: "/storage/v1/object/", body: {} }, + { method: "POST", match: "/storage/v1/bucket", body: { name: "images" } }, + ], + }); + return Effect.gen(function* () { + const exit = yield* legacySeedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + expect(out.stderrText).toContain("Skipping non-regular file: assets/pipe"); + const uploads = requests.filter((r) => r.url.includes("/storage/v1/object/")); + expect(uploads).toHaveLength(1); + }); + }); +}); diff --git a/apps/cli/src/legacy/commands/seed/buckets/buckets.upload.ts b/apps/cli/src/legacy/commands/seed/buckets/buckets.upload.ts new file mode 100644 index 0000000000..4749fe3ff8 --- /dev/null +++ b/apps/cli/src/legacy/commands/seed/buckets/buckets.upload.ts @@ -0,0 +1,86 @@ +import * as nodePath from "node:path"; + +import { ramInBytes } from "../../../shared/legacy-size-units.ts"; + +/** + * Pure path/encoding helpers for object upload, ported from + * `apps/cli-go/pkg/storage/{objects,batch}.go`. Kept free of Effect / services + * so the Go-parity rules (destination-key mapping, size parsing, content-type + * fallback) stay unit-testable. + */ + +/** + * Destination object key for a local file, ported from `UpsertObjects` + * (`batch.go:101-118`). Mirrors Go's `filepath.Rel(localPath, filePath)` + + * `path.Join(name, …)`: + * - single-file `objects_path` (the file is the path itself, Go's `relPath == "."`) + * → `/` + * - otherwise → `/` + * + * `objectsPath` and `filePath` are OS paths; the relative segment is normalised + * to forward slashes (`filepath.ToSlash`) for the remote key. + */ +export function legacyBucketObjectKey( + bucketName: string, + objectsPath: string, + filePath: string, +): string { + const relPath = nodePath.relative(objectsPath, filePath); + if (relPath === "") { + return nodePath.posix.join(bucketName, nodePath.basename(filePath)); + } + const relPosix = relPath.split(nodePath.sep).join(nodePath.posix.sep); + return nodePath.posix.join(bucketName, relPosix); +} + +/** + * Parse a `[storage.buckets.*].file_size_limit` config string (e.g. `"50MiB"`) + * to the int64 byte count Go sends in the create/update bucket body + * (`int64(bucket.FileSizeLimit)`, `batch.go:38/49`). `@supabase/config` keeps + * the field as the raw human-readable string, so the conversion Go performs at + * config-load time happens here instead. Throws on an unparseable value, which + * the handler maps to a config-load error. + */ +export function legacyParseFileSizeLimit(sizeStr: string): number { + return ramInBytes(sizeStr); +} + +/** + * Best-effort content-type by file extension. Go derives the type from + * `http.DetectContentType` (first 512 bytes) with a `mime.TypeByExtension` + * override for generic `text/plain` (`objects.go:66-109`); this extension-based + * lookup is a parity approximation that is sufficient for the storage server, + * which stores whatever is sent. Unknown extensions fall back to + * `application/octet-stream`. + */ +export function legacyContentTypeForPath(filePath: string): string { + const ext = nodePath.extname(filePath).toLowerCase(); + return CONTENT_TYPES[ext] ?? "application/octet-stream"; +} + +const CONTENT_TYPES: Readonly> = { + ".txt": "text/plain; charset=utf-8", + ".html": "text/html; charset=utf-8", + ".htm": "text/html; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".csv": "text/csv; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".mjs": "text/javascript; charset=utf-8", + ".json": "application/json", + ".xml": "application/xml", + ".pdf": "application/pdf", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".svg": "image/svg+xml", + ".ico": "image/vnd.microsoft.icon", + ".mp4": "video/mp4", + ".webm": "video/webm", + ".mp3": "audio/mpeg", + ".wav": "audio/wave", + ".zip": "application/zip", + ".gz": "application/gzip", + ".wasm": "application/wasm", +}; diff --git a/apps/cli/src/legacy/commands/seed/buckets/buckets.upload.unit.test.ts b/apps/cli/src/legacy/commands/seed/buckets/buckets.upload.unit.test.ts new file mode 100644 index 0000000000..b7e5a74630 --- /dev/null +++ b/apps/cli/src/legacy/commands/seed/buckets/buckets.upload.unit.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { + legacyBucketObjectKey, + legacyContentTypeForPath, + legacyParseFileSizeLimit, +} from "./buckets.upload.ts"; + +describe("legacyBucketObjectKey", () => { + it("maps a single-file objects_path to /", () => { + expect(legacyBucketObjectKey("docs", "assets/file.pdf", "assets/file.pdf")).toBe( + "docs/file.pdf", + ); + }); + + it("maps a direct child to /", () => { + expect(legacyBucketObjectKey("docs", "assets", "assets/a.txt")).toBe("docs/a.txt"); + }); + + it("maps a nested file to /", () => { + expect(legacyBucketObjectKey("docs", "assets", "assets/sub/dir/b.txt")).toBe( + "docs/sub/dir/b.txt", + ); + }); + + it("normalises a leading ./ in objects_path", () => { + expect(legacyBucketObjectKey("docs", "./assets", "assets/a.txt")).toBe("docs/a.txt"); + }); +}); + +describe("legacyParseFileSizeLimit", () => { + it("parses a human-readable size to bytes", () => { + expect(legacyParseFileSizeLimit("50MiB")).toBe(50 * 1024 * 1024); + }); + + it("returns 0 for a zero limit", () => { + expect(legacyParseFileSizeLimit("0")).toBe(0); + }); + + it("throws on an unparseable value", () => { + expect(() => legacyParseFileSizeLimit("not-a-size")).toThrow(); + }); +}); + +describe("legacyContentTypeForPath", () => { + it("maps a known extension", () => { + expect(legacyContentTypeForPath("/x/a.png")).toBe("image/png"); + expect(legacyContentTypeForPath("/x/a.json")).toBe("application/json"); + }); + + it("is case-insensitive on the extension", () => { + expect(legacyContentTypeForPath("/x/A.JSON")).toBe("application/json"); + }); + + it("falls back to application/octet-stream for unknown extensions", () => { + expect(legacyContentTypeForPath("/x/a.unknownext")).toBe("application/octet-stream"); + expect(legacyContentTypeForPath("/x/noext")).toBe("application/octet-stream"); + }); +}); diff --git a/apps/cli/src/legacy/commands/seed/seed.layers.ts b/apps/cli/src/legacy/commands/seed/seed.layers.ts new file mode 100644 index 0000000000..44437d0e84 --- /dev/null +++ b/apps/cli/src/legacy/commands/seed/seed.layers.ts @@ -0,0 +1,59 @@ +import { Layer } from "effect"; +import type * as HttpClient from "effect/unstable/http/HttpClient"; + +import { legacyHttpClientLayer } from "../../auth/legacy-http-debug.layer.ts"; +import { LegacyCliConfig } from "../../config/legacy-cli-config.service.ts"; +import { legacyCliConfigLayer } from "../../config/legacy-cli-config.layer.ts"; +import { legacyDebugLoggerLayer } from "../../shared/legacy-debug-logger.layer.ts"; +import { LegacyTelemetryState } from "../../telemetry/legacy-telemetry-state.service.ts"; +import { legacyTelemetryStateLayer } from "../../telemetry/legacy-telemetry-state.layer.ts"; +import { CommandRuntime } from "../../../shared/runtime/command-runtime.service.ts"; +import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; + +/** + * Runtime layer for `supabase seed `. + * + * `seed buckets` is a **local-only** command: Go's `seed` command defines no + * `--project-ref` flag, so `flags.ParseProjectRef` (gated on that flag, + * `cmd/root.go:112`) never runs and `flags.ProjectRef` is always empty. The + * remote client factory, service-role-key resolution, and analytics-bucket + * upsert are therefore unreachable and intentionally absent — this layer + * deliberately omits the credentials / platform-API / project-ref / linked-cache + * stack that `legacyManagementApiRuntimeLayer` carries. + * + * It exposes only: + * - `HttpClient` (via `legacyHttpClientLayer`, with `--debug` request logging) + * for the Storage service-gateway calls, + * - `LegacyCliConfig` for `--workdir` resolution (config-file base path), + * - `LegacyTelemetryState` for the telemetry flush (`PersistentPostRun` parity), + * - `CommandRuntime` for command-scoped instrumentation. + * + * `Output`, `Tty`, `RuntimeInfo`, and `FileSystem`/`Path` (`BunServices`) come + * from the root `runCli` wiring. + */ +export function legacySeedRuntimeLayer(subcommand: ReadonlyArray) { + const cliConfig = legacyCliConfigLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); + const httpClient = legacyHttpClientLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); + + const built = Layer.mergeAll( + httpClient, + cliConfig, + legacyTelemetryStateLayer, + commandRuntimeLayer([...subcommand]), + ); + + // Compile-time guarantee that the merged layer exposes exactly the services a + // seed handler is allowed to yield from its top-level `Effect.fn` body. The + // assertion uses `unknown` for E and R so it fires only for missing exposed + // services, mirroring `legacy-management-api-runtime.layer.ts`. + const _serviceCoverageCheck: Layer.Layer = built; + void _serviceCoverageCheck; + + return built; +} + +type LegacySeedServices = + | HttpClient.HttpClient + | LegacyCliConfig + | LegacyTelemetryState + | CommandRuntime; diff --git a/apps/cli/src/legacy/commands/config/push/config-sync/config-sync.units.ts b/apps/cli/src/legacy/shared/legacy-size-units.ts similarity index 93% rename from apps/cli/src/legacy/commands/config/push/config-sync/config-sync.units.ts rename to apps/cli/src/legacy/shared/legacy-size-units.ts index 4fe8fbb3fe..9729737bd0 100644 --- a/apps/cli/src/legacy/commands/config/push/config-sync/config-sync.units.ts +++ b/apps/cli/src/legacy/shared/legacy-size-units.ts @@ -5,6 +5,10 @@ * implements `MarshalText`, so BurntSushi emits a quoted human-readable size, * e.g. `"5MiB"`). * + * Shared across the legacy shell: `config push` (storage/auth/api/db diffing) + * and `seed buckets` (which converts each `[storage.buckets.*].file_size_limit` + * string to the int64 byte count Go sends in the create/update bucket body). + * * @see github.com/docker/go-units@v0.5.0/size.go */ From 2249cdf094378baaf491223e67fff23002197369 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Mon, 22 Jun 2026 14:35:08 +0100 Subject: [PATCH 02/26] test(cli): add seed buckets e2e + --local/--linked mutual-exclusivity Reconciles the cloud port with the local branch: ports the acceptance-criteria e2e compatibility test and reproduces cobra's MarkFlagsMutuallyExclusive("local","linked") (seed.go:32) with the verbatim conflict message. --- .../commands/seed/buckets/buckets.e2e.test.ts | 53 +++++++++++++++++ .../commands/seed/buckets/buckets.errors.ts | 10 ++++ .../commands/seed/buckets/buckets.flags.ts | 57 +++++++++++++++++++ .../seed/buckets/buckets.flags.unit.test.ts | 42 ++++++++++++++ .../commands/seed/buckets/buckets.handler.ts | 12 ++++ .../seed/buckets/buckets.integration.test.ts | 18 ++++++ 6 files changed, 192 insertions(+) create mode 100644 apps/cli/src/legacy/commands/seed/buckets/buckets.e2e.test.ts create mode 100644 apps/cli/src/legacy/commands/seed/buckets/buckets.flags.ts create mode 100644 apps/cli/src/legacy/commands/seed/buckets/buckets.flags.unit.test.ts 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 index 1afb1a1285..e2231e5a0b 100644 --- a/apps/cli/src/legacy/commands/seed/buckets/buckets.errors.ts +++ b/apps/cli/src/legacy/commands/seed/buckets/buckets.errors.ts @@ -34,3 +34,13 @@ export class LegacySeedStorageStatusError extends Data.TaggedError("LegacySeedSt 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; +}> {} 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..84afba1eae --- /dev/null +++ b/apps/cli/src/legacy/commands/seed/buckets/buckets.flags.ts @@ -0,0 +1,57 @@ +import { + VALUE_CONSUMING_LONG_FLAGS, + VALUE_CONSUMING_SHORT_FLAGS, +} from "../../../shared/legacy-db-target-flags.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; +} 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..79b509f5f4 --- /dev/null +++ b/apps/cli/src/legacy/commands/seed/buckets/buckets.flags.unit.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; + +import { 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", + ]); + }); +}); 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 a84ef8c2d9..c96110c227 100644 --- a/apps/cli/src/legacy/commands/seed/buckets/buckets.handler.ts +++ b/apps/cli/src/legacy/commands/seed/buckets/buckets.handler.ts @@ -3,8 +3,10 @@ import { defaultJwtSecret, generateJwt } from "@supabase/stack/effect"; import { Effect, FileSystem, Path } from "effect"; import type { PlatformError } from "effect/PlatformError"; +import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; import { LegacyCliConfig } from "../../../config/legacy-cli-config.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 { Output } from "../../../../shared/output/output.service.ts"; import { @@ -18,6 +20,7 @@ import { } from "./buckets.gateway.ts"; import { LegacySeedConfigLoadError, + LegacySeedMutuallyExclusiveFlagsError, LegacySeedStorageNetworkError, LegacySeedStorageStatusError, } from "./buckets.errors.ts"; @@ -77,8 +80,17 @@ export const legacySeedBuckets = Effect.fn("legacy.seed.buckets")(function* ( const telemetryState = yield* LegacyTelemetryState; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const cliArgs = yield* CliArgs; yield* Effect.gen(function* () { + // 0. Reproduce cobra's MarkFlagsMutuallyExclusive("local", "linked"). + const setFlags = legacySeedChangedTargetFlags(cliArgs.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`, + }); + } + // 1. Load config.toml. A parse failure aborts before any network call. const loaded = yield* loadProjectConfig(cliConfig.workdir).pipe( Effect.catchTag( diff --git a/apps/cli/src/legacy/commands/seed/buckets/buckets.integration.test.ts b/apps/cli/src/legacy/commands/seed/buckets/buckets.integration.test.ts index 120d888a5b..3293d377b3 100644 --- a/apps/cli/src/legacy/commands/seed/buckets/buckets.integration.test.ts +++ b/apps/cli/src/legacy/commands/seed/buckets/buckets.integration.test.ts @@ -15,6 +15,7 @@ import { mockLegacyTelemetryStateTracked, useLegacyTempWorkdir, } from "../../../../../tests/helpers/legacy-mocks.ts"; +import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; import type { OutputFormat } from "../../../../shared/output/types.ts"; import { legacySeedBuckets } from "./buckets.handler.ts"; import type { LegacyBucketsFlags } from "./buckets.command.ts"; @@ -40,6 +41,7 @@ function setupLegacySeedBuckets( readonly format?: OutputFormat; readonly confirm?: ReadonlyArray; readonly promptConfirmFail?: boolean; + readonly args?: ReadonlyArray; }, ) { if (opts.toml !== undefined) { @@ -86,6 +88,7 @@ function setupLegacySeedBuckets( telemetry.layer, mockLegacyCliConfig({ workdir }), BunServices.layer, + Layer.succeed(CliArgs, { args: opts.args ?? ["seed", "buckets"] }), ); return { layer, out, requests, telemetry }; @@ -110,6 +113,21 @@ describe("legacy seed buckets", () => { }); }); + it.live("rejects passing both --local and --linked", () => { + const { layer, requests } = setupLegacySeedBuckets(tmp.current, { + toml: 'project_id = "test"\n', + args: ["seed", "buckets", "--local", "--linked"], + }); + return Effect.gen(function* () { + const exit = yield* legacySeedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + 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", + ); + expect(requests).toHaveLength(0); + }); + }); + it.live("creates a new bucket and updates an existing one (overwrite default yes)", () => { const { layer, out, requests } = setupLegacySeedBuckets(tmp.current, { toml: "[storage.buckets.test]\npublic = true\n[storage.buckets.private]\npublic = false\n", From b26a497879ccab04041d0081228612e6405af6d4 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Mon, 22 Jun 2026 15:43:42 +0100 Subject: [PATCH 03/26] fix(cli): seed buckets Go-parity fixes from review - Honor --yes/SUPABASE_YES in prompts, echoing Go's `