diff --git a/apps/cli/docs/go-cli-porting-status.md b/apps/cli/docs/go-cli-porting-status.md index 49a7fe9c95..18a2fd46a5 100644 --- a/apps/cli/docs/go-cli-porting-status.md +++ b/apps/cli/docs/go-cli-porting-status.md @@ -155,12 +155,12 @@ The old Go `storage` family could target either the linked project or the local Current TS only exposes low-level Management API routes under [`api`](../src/next/commands/platform/api.command.ts). This tracker does not count those routes as parity for the old `storage` object-management CLI surface, especially because there is no TS equivalent for the old local Storage API workflow. -| Old command | TS status | New TS counterpart(s) | Notes | -| ------------ | --------- | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `storage cp` | `missing` | `missing` | No TS object copy command in `next/`. Legacy shell proxy exposes `--recursive`, `--local`, `--linked`, `--cache-control`, `--content-type`, `--copy-metadata` matching the Go CLI flag surface. | -| `storage ls` | `missing` | `missing` | No TS object listing command in `next/`. Legacy shell proxy exposes `--recursive`, `--local`, `--linked` matching the Go CLI flag surface. | -| `storage mv` | `missing` | `missing` | No TS object move command in `next/`. Legacy shell proxy exposes `--recursive`, `--local`, `--linked` matching the Go CLI flag surface. | -| `storage rm` | `missing` | `missing` | No TS object remove command in `next/`. Legacy shell proxy exposes `--recursive`, `--local`, `--linked` matching the Go CLI flag surface. Pass global `--yes` to skip the interactive confirmation prompt. | +| Old command | TS status | New TS counterpart(s) | Notes | +| ------------ | --------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `storage cp` | `missing` | `missing` | No TS object copy command in `next/`. Natively ported in the legacy shell ([`legacy/commands/storage/cp/`](../src/legacy/commands/storage/cp/cp.command.ts)) — `--recursive`, `--local`, `--linked`, `--cache-control`, `--content-type`, `--jobs` (Go has no `--copy-metadata`). Adds TS-only `--output-format json\|stream-json`. | +| `storage ls` | `missing` | `missing` | No TS object listing command in `next/`. Natively ported in the legacy shell ([`legacy/commands/storage/ls/`](../src/legacy/commands/storage/ls/ls.command.ts)) — `--recursive`, `--local`, `--linked`. Adds TS-only `--output-format json\|stream-json`. | +| `storage mv` | `missing` | `missing` | No TS object move command in `next/`. Natively ported in the legacy shell ([`legacy/commands/storage/mv/`](../src/legacy/commands/storage/mv/mv.command.ts)) — `--recursive`, `--local`, `--linked`. Adds TS-only `--output-format json\|stream-json`. | +| `storage rm` | `missing` | `missing` | No TS object remove command in `next/`. Natively ported in the legacy shell ([`legacy/commands/storage/rm/`](../src/legacy/commands/storage/rm/rm.command.ts)) — `--recursive`, `--local`, `--linked`; global `--yes`/`SUPABASE_YES` skips the confirmation. Adds TS-only `--output-format json\|stream-json`. | ## Management APIs @@ -291,10 +291,10 @@ Legend: | `functions deploy` | `ported` | [`../src/legacy/commands/functions/deploy/deploy.command.ts`](../src/legacy/commands/functions/deploy/deploy.command.ts) | | `functions new` | `wrapped` | [`../src/legacy/commands/functions/new/new.command.ts`](../src/legacy/commands/functions/new/new.command.ts) | | `functions serve` | `ported` | [`../src/legacy/commands/functions/serve/serve.command.ts`](../src/legacy/commands/functions/serve/serve.command.ts) | -| `storage ls` | `wrapped` | [`../src/legacy/commands/storage/ls/ls.command.ts`](../src/legacy/commands/storage/ls/ls.command.ts) | -| `storage cp` | `wrapped` | [`../src/legacy/commands/storage/cp/cp.command.ts`](../src/legacy/commands/storage/cp/cp.command.ts) | -| `storage mv` | `wrapped` | [`../src/legacy/commands/storage/mv/mv.command.ts`](../src/legacy/commands/storage/mv/mv.command.ts) | -| `storage rm` | `wrapped` | [`../src/legacy/commands/storage/rm/rm.command.ts`](../src/legacy/commands/storage/rm/rm.command.ts) | +| `storage ls` | `ported` | [`../src/legacy/commands/storage/ls/ls.command.ts`](../src/legacy/commands/storage/ls/ls.command.ts) | +| `storage cp` | `ported` | [`../src/legacy/commands/storage/cp/cp.command.ts`](../src/legacy/commands/storage/cp/cp.command.ts) | +| `storage mv` | `ported` | [`../src/legacy/commands/storage/mv/mv.command.ts`](../src/legacy/commands/storage/mv/mv.command.ts) | +| `storage rm` | `ported` | [`../src/legacy/commands/storage/rm/rm.command.ts`](../src/legacy/commands/storage/rm/rm.command.ts) | | `test db` | `ported` | [`../src/legacy/commands/test/db/db.command.ts`](../src/legacy/commands/test/db/db.command.ts) | | `test new` | `ported` | [`../src/legacy/commands/test/new/new.command.ts`](../src/legacy/commands/test/new/new.command.ts) | | `seed buckets` | `ported` | [`../src/legacy/commands/seed/buckets/buckets.command.ts`](../src/legacy/commands/seed/buckets/buckets.command.ts) | 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 8c23144d05..20c9b269fe 100644 --- a/apps/cli/src/legacy/commands/seed/buckets/buckets.command.ts +++ b/apps/cli/src/legacy/commands/seed/buckets/buckets.command.ts @@ -6,7 +6,7 @@ import { withJsonErrorHandling } from "../../../../shared/output/json-error-hand import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; import { LegacySeedLinkedFlag, LegacySeedLocalFlag } from "../seed.flags.ts"; import { legacyAssertSeedTargetsExclusive } from "./buckets.flags.ts"; -import { legacySeedRuntimeLayer } from "../seed.layers.ts"; +import { legacyStorageGatewayRuntimeLayer } from "../../../shared/legacy-storage-runtime.layer.ts"; import { legacySeedBuckets } from "./buckets.handler.ts"; // `--linked`/`--local` are scoped globals on the `seed` group (`seed.flags.ts`), @@ -36,5 +36,5 @@ export const legacyBucketsCommand = Command.make("buckets").pipe( return yield* legacySeedBuckets(flags).pipe(withLegacyCommandInstrumentation({ flags })); }).pipe(withJsonErrorHandling), ), - Command.provide(legacySeedRuntimeLayer(["seed", "buckets"])), + Command.provide(legacyStorageGatewayRuntimeLayer(["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 index b5b72fa75f..2f3e9efa6f 100644 --- a/apps/cli/src/legacy/commands/seed/buckets/buckets.errors.ts +++ b/apps/cli/src/legacy/commands/seed/buckets/buckets.errors.ts @@ -1,35 +1,20 @@ import { Data } from "effect"; /** - * Domain errors for `supabase seed buckets`. + * Domain errors specific to `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. + * The Storage gateway and credential-derivation errors are shared with + * `storage ls/cp/mv/rm` and live in `legacy/shared/legacy-storage-gateway.errors.ts` + * and `legacy/shared/legacy-storage-credentials.errors.ts`. This file keeps only + * the seed-specific errors. */ -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. + * Raised when `supabase/config.toml` cannot be parsed, or a config-load-time + * validation Go runs before any Storage call fails (bucket name regex, + * `file_size_limit` numeral). Mirrors the `config push` CLI-1489 tradeoff: + * `loadProjectConfig` raises `ProjectConfigParseError` on `env(...)` refs over + * numeric/bool fields, which Go resolves transparently. */ export class LegacySeedConfigLoadError extends Data.TaggedError("LegacySeedConfigLoadError")<{ readonly message: string; @@ -44,37 +29,3 @@ export class LegacySeedMutuallyExclusiveFlagsError extends Data.TaggedError( )<{ readonly message: string; }> {} - -/** - * Raised on `--linked` when the project's api-keys response yields no keys, - * mirroring Go's `tenant.GetApiKeys` → `errMissingKey` ("Anon key not found.", - * `apps/cli-go/internal/utils/tenant/client.go:16,80-82`), which aborts before - * the remote Storage client is built. Message matches Go verbatim. - */ -export class LegacySeedMissingApiKeyError extends Data.TaggedError("LegacySeedMissingApiKeyError")<{ - readonly message: string; -}> {} - -/** - * Transport failure fetching the project's api-keys on `--linked`, mirroring Go's - * `tenant.GetApiKeys` network path (`failed to get api keys: `). - */ -export class LegacySeedApiKeysNetworkError extends Data.TaggedError( - "LegacySeedApiKeysNetworkError", -)<{ - readonly message: string; -}> {} - -/** - * `GET /v1/projects/{ref}/api-keys?reveal=true` returned a non-200 status on a - * `--linked` run. Byte-matches Go's `tenant.GetApiKeys` → `ErrAuthToken`, - * `"Authorization failed for the access token and project ref pair: " + body` - * (`apps/cli-go/internal/utils/tenant/client.go:15,77-78`). This is the user-facing - * error for an invalid access token / project-ref pair — distinct from the - * `projects api-keys` helper's `unexpected get api keys status ...`. - */ -export class LegacySeedAuthTokenError extends Data.TaggedError("LegacySeedAuthTokenError")<{ - readonly status: number; - readonly body: string; - readonly message: string; -}> {} diff --git a/apps/cli/src/legacy/commands/seed/buckets/buckets.flags.ts b/apps/cli/src/legacy/commands/seed/buckets/buckets.flags.ts index b9a7f587b8..3f4bb5fe46 100644 --- a/apps/cli/src/legacy/commands/seed/buckets/buckets.flags.ts +++ b/apps/cli/src/legacy/commands/seed/buckets/buckets.flags.ts @@ -1,67 +1,18 @@ import { Effect } from "effect"; -import { - VALUE_CONSUMING_LONG_FLAGS, - VALUE_CONSUMING_SHORT_FLAGS, -} from "../../../shared/legacy-db-target-flags.ts"; +import { legacyChangedLinkedLocalFlags } from "../../../shared/legacy-db-target-flags.ts"; import { LegacySeedMutuallyExclusiveFlagsError } from "./buckets.errors.ts"; /** - * Detects which of `--local` / `--linked` were explicitly set on the command - * line, reproducing cobra's `pflag.Changed` for `seed`'s - * `MarkFlagsMutuallyExclusive("local", "linked")` (`apps/cli-go/cmd/seed.go:32`). - * - * Effect CLI's parsed flags carry no `Changed` bit, so we re-derive it from raw - * argv. Value-consuming flags (`--workdir `, `-o `, …) skip their - * value token to avoid false positives like `--workdir --linked`. - * - * Returned in cobra's alphabetically-sorted order `["linked", "local"]` so the - * rendered conflict string matches Go exactly. + * Detects which of `--local` / `--linked` were explicitly set, reproducing + * cobra's `pflag.Changed` for `seed`'s `MarkFlagsMutuallyExclusive` + * (`apps/cli-go/cmd/seed.go:32`). Delegates to the shared linked/local scanner + * (also used by `storage`). The seed target is selected from this changed set + * (Go's `flag.Changed`, via `internal/utils/flags/db_url.go:46-63`), not the + * parsed flag value. */ 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; - // Treat Effect CLI's boolean negation form (`--no-linked`/`--no-local`) as - // "changed" too — it sets the flag false but is unambiguously present on - // argv, the TS equivalent of cobra's `pflag.Changed` (and the seed target - // is selected from Changed, not the value, so `--no-linked` is still the - // linked path). Mirrors the sibling DB scanner (legacy-db-target-flags.ts). - if (name === "linked" || name === "no-linked") { - linked = true; - continue; - } - if (name === "local" || name === "no-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; + return legacyChangedLinkedLocalFlags(args); } /** diff --git a/apps/cli/src/legacy/commands/seed/buckets/buckets.gateway.unit.test.ts b/apps/cli/src/legacy/commands/seed/buckets/buckets.gateway.unit.test.ts deleted file mode 100644 index da2b2972b5..0000000000 --- a/apps/cli/src/legacy/commands/seed/buckets/buckets.gateway.unit.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { legacyBucketBody } from "./buckets.gateway.ts"; - -describe("legacyBucketBody", () => { - it("omits public when undefined (Go *bool nil / omitempty)", () => { - expect(legacyBucketBody({ public: undefined, fileSizeLimit: 0, allowedMimeTypes: [] })).toEqual( - {}, - ); - }); - - it("includes public when explicitly set (true or false)", () => { - expect(legacyBucketBody({ public: true, fileSizeLimit: 0, allowedMimeTypes: [] })).toEqual({ - public: true, - }); - expect(legacyBucketBody({ public: false, fileSizeLimit: 0, allowedMimeTypes: [] })).toEqual({ - public: false, - }); - }); - - it("omits file_size_limit when 0 and allowed_mime_types when empty", () => { - expect( - legacyBucketBody({ public: undefined, fileSizeLimit: 0, allowedMimeTypes: [] }), - ).not.toHaveProperty("file_size_limit"); - }); - - it("includes file_size_limit and allowed_mime_types when present", () => { - expect( - legacyBucketBody({ - public: false, - fileSizeLimit: 52_428_800, - allowedMimeTypes: ["image/png"], - }), - ).toEqual({ - public: false, - file_size_limit: 52_428_800, - allowed_mime_types: ["image/png"], - }); - }); -}); diff --git a/apps/cli/src/legacy/commands/seed/buckets/buckets.handler.ts b/apps/cli/src/legacy/commands/seed/buckets/buckets.handler.ts index 9450ac1b56..7e91961b04 100644 --- a/apps/cli/src/legacy/commands/seed/buckets/buckets.handler.ts +++ b/apps/cli/src/legacy/commands/seed/buckets/buckets.handler.ts @@ -1,10 +1,8 @@ import { - KONG_LOCAL_CA_CERT, loadProjectConfig, type LoadProjectConfigOptions, ProjectConfigSchema, } from "@supabase/config"; -import { defaultJwtSecret, generateJwt } from "@supabase/stack/effect"; import { Effect, FileSystem, Option, Path, Schema } from "effect"; import { FetchHttpClient } from "effect/unstable/http"; import type { PlatformError } from "effect/PlatformError"; @@ -13,145 +11,41 @@ import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; import { LegacyYesFlag } from "../../../../shared/legacy/global-flags.ts"; import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; -import { LegacyPlatformApiFactory } from "../../../auth/legacy-platform-api-factory.service.ts"; -import { legacyMapTenantApiKeysError } from "../../../shared/legacy-get-tenant-api-keys.ts"; -import { legacyExtractServiceKeys } from "../../../shared/legacy-tenant-keys.ts"; -import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; -import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; import { legacySeedChangedTargetFlags } from "./buckets.flags.ts"; import { legacyBold, legacyYellow } from "../../../shared/legacy-colors.ts"; -import { legacyGetHostname } from "../../../shared/legacy-hostname.ts"; -import { Output } from "../../../../shared/output/output.service.ts"; import { - legacyIsLocalVectorBucketsUnavailable, - legacyIsVectorBucketsFeatureNotEnabled, -} from "./buckets.classify.ts"; + legacyResolveStorageCredentials, + legacyStorageGatewayFetch, +} from "../../../shared/legacy-storage-credentials.ts"; +import { + legacyParseFileSizeLimit, + legacyResolveBucketProps, +} from "../../../shared/legacy-storage-bucket-config.ts"; import { type LegacyStorageGateway, type LegacyUpsertBucketProps, legacyMakeStorageGateway, -} from "./buckets.gateway.ts"; +} from "../../../shared/legacy-storage-gateway.ts"; +import type { LegacyStorageGatewayError } from "../../../shared/legacy-storage-gateway.errors.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; import { - LegacySeedApiKeysNetworkError, - LegacySeedAuthTokenError, - LegacySeedConfigLoadError, - LegacySeedMissingApiKeyError, - LegacySeedStorageNetworkError, - LegacySeedStorageStatusError, -} from "./buckets.errors.ts"; + legacyIsLocalVectorBucketsUnavailable, + legacyIsVectorBucketsFeatureNotEnabled, +} from "./buckets.classify.ts"; +import { LegacySeedConfigLoadError } from "./buckets.errors.ts"; +import { legacyBucketObjectKey } from "./buckets.upload.ts"; +import { legacyPromptYesNo } from "../../../shared/legacy-prompt-yes-no.ts"; import { - legacyBucketObjectKey, legacyContentTypeForUpload, - legacyParseFileSizeLimit, -} from "./buckets.upload.ts"; + legacyReadSniffBytes, +} from "../../../shared/legacy-storage-content-type.ts"; +import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; import type { LegacyBucketsFlags } from "./buckets.command.ts"; const CONFIG_PATH = "supabase/config.toml"; const UPLOAD_CONCURRENCY = 5; -/** - * Builds a `typeof globalThis.fetch` that injects `tls.ca` into every request, - * trusting the provided CA PEM for HTTPS connections to the local Kong gateway. - * - * Mirrors Go's `newLocalClient` (`apps/cli-go/internal/storage/client/api.go:30-37`), - * which appends `utils.Config.Api.Tls.CertContent` to the TLS cert pool. - * - * Bun's fetch accepts `{ tls: { ca: string } }` in the same position as - * `BunFetchRequestInit.tls`; the `ca` field is Bun-specific and is typed via - * `BunFetchRequestInit` (a Bun global). No `as` cast is needed: the init object - * is typed as `BunFetchRequestInit` which extends the standard `RequestInit`. - */ -function legacyKongCaFetch(ca: string): typeof globalThis.fetch { - const fetchImpl = async ( - input: string | URL | Request, - init?: RequestInit, - ): Promise => { - const caInit: BunFetchRequestInit = { ...init, tls: { ca } }; - return globalThis.fetch(input, caInit); - }; - // Attach `preconnect` so the override is structurally complete as - // `typeof globalThis.fetch` — mirrors the same pattern in legacy-http-dns.ts. - return Object.assign(fetchImpl, { preconnect: globalThis.fetch.preconnect }); -} - -/** - * Validates and resolves the local Kong TLS configuration, mirroring Go's - * `(*api).Validate` (`apps/cli-go/pkg/config/config.go:845-861`) which runs at - * config-load before `NewStorageAPI`: - * 1. `cert_path` set, `key_path` empty → error - * 2. `cert_path` set, unreadable → error - * 3. `key_path` set, `cert_path` empty → error - * 4. `key_path` set, unreadable → error - * 5. Both set and readable → returns the CA PEM (cert content) - * 6. Neither set → returns the embedded `KONG_LOCAL_CA_CERT` - * - * The CLI only uses the CA cert for trusting the Kong gateway, but Go also reads - * the key purely to validate the pairing, so we mirror that behaviour. - * - * // TODO: broader `@supabase/config` gap — `packages/config/src/api.ts` models - * // `tls.cert_path` / `tls.key_path` but has no pairing or readability validation. - * // Once @supabase/config adds `(*api).Validate`, this helper can be removed and - * // the error mapping moved to the `ProjectConfigParseError` catch above. - * - * Only called when `projectRef === ""` (local) AND `config.api.enabled` AND - * `config.api.tls.enabled` — Go gates both path resolution (`config.go:795`) - * and validation (`config.go:841`) on `c.Api.Enabled`. - */ -const validateLocalKongTls = Effect.fnUntraced(function* ( - fs: FileSystem.FileSystem, - path: Path.Path, - workdir: string, - certPath: string | undefined, - keyPath: string | undefined, -) { - const hasCert = certPath !== undefined && certPath.length > 0; - const hasKey = keyPath !== undefined && keyPath.length > 0; - - if (hasCert && !hasKey) { - return yield* new LegacySeedConfigLoadError({ - message: "Missing required field in config: api.tls.key_path", - }); - } - if (hasKey && !hasCert) { - return yield* new LegacySeedConfigLoadError({ - message: "Missing required field in config: api.tls.cert_path", - }); - } - - if (hasCert) { - // Go joins TLS paths unconditionally with the supabase dir — NO IsAbs guard - // (config.go:795-801 uses path.Join, which absorbs a leading "/" on the - // joined element), so `cert_path = "/tmp/kong.crt"` resolves under - // supabase/tmp/kong.crt. This differs from objects_path below, which Go - // guards with !filepath.IsAbs (config.go:753-761). - const absCert = path.join(workdir, "supabase", certPath); - const certContent = yield* fs.readFileString(absCert).pipe( - Effect.catchTag( - "PlatformError", - (cause) => - new LegacySeedConfigLoadError({ - message: `failed to read TLS cert: ${String(cause.cause ?? cause)}`, - }), - ), - ); - // keyPath is non-empty here because hasKey === true (cert+key both present); - // joined unconditionally, same as cert_path above (config.go:795-801). - const absKey = path.join(workdir, "supabase", keyPath!); - yield* fs.readFileString(absKey).pipe( - Effect.catchTag( - "PlatformError", - (cause) => - new LegacySeedConfigLoadError({ - message: `failed to read TLS key: ${String(cause.cause ?? cause)}`, - }), - ), - ); - return certContent; - } - - return KONG_LOCAL_CA_CERT; -}); - /** * Mirrors Go's `ValidateBucketName` regex (`apps/cli-go/pkg/config/config.go:1382`). * Used to validate `[storage.buckets]` names before any Storage API call, matching @@ -175,8 +69,6 @@ const legacyValidateBucketName = Effect.fnUntraced(function* (name: string) { } }); -type StorageError = LegacySeedStorageNetworkError | LegacySeedStorageStatusError; - interface CollectedFile { readonly absPath: string; readonly displayPath: string; @@ -213,9 +105,8 @@ function emptySummary(): SeedSummary { * Embedded-default project config, decoded from an empty object — the same * `decodeUnknownSync(ProjectConfigSchema)({})` the loader uses internally * (`packages/config/src/io.ts:54-56`). Go's `seed buckets` never aborts on a - * missing `config.toml`: it reads the package-global `utils.Config`, which is - * initialized to embedded defaults (`internal/utils/config.go:100`), and - * `config.Load` no-ops on a missing file (`mergeFileConfig` → nil). So "no + * missing `config.toml`: it reads the package-global `utils.Config`, initialized + * to embedded defaults, and `config.Load` no-ops on a missing file. So "no * config file" behaves like the embedded-default config. */ const legacyDecodeDefaultProjectConfig = Schema.decodeUnknownSync(ProjectConfigSchema); @@ -251,14 +142,9 @@ export const legacySeedBuckets = Effect.fn("legacy.seed.buckets")(function* ( yield* Effect.gen(function* () { // 1. Resolve the project ref for --linked BEFORE loading config, so that // the matching `[remotes.]` override (whose `project_id == ref`) is - // merged over the base config by `loadProjectConfig`. Mirrors Go's - // `Config.ProjectId = ProjectRef` → `config.Load` sequence - // (`apps/cli-go/pkg/config/config.go:505-518`). - // Go selects the target from `flag.Changed`, not the flag value - // (`internal/utils/flags/db_url.go:46-63`): `--linked` is the linked path - // whenever it's *set*, even `--linked=false`. Use the changed-flag set - // (the `--local`/`--linked` mutual-exclusivity is enforced before - // instrumentation in `buckets.command.ts`), not `flags.linked`'s value. + // merged over the base config by `loadProjectConfig`. Go selects the target + // from `flag.Changed`, not the flag value: `--linked` is the linked path + // whenever it's *set* (even `--linked=false`). const setFlags = legacySeedChangedTargetFlags(cliArgs.args); const projectRefResolver = yield* LegacyProjectRefResolver; const projectRef = setFlags.includes("linked") @@ -280,20 +166,14 @@ export const legacySeedBuckets = Effect.fn("legacy.seed.buckets")(function* ( ), ); // A missing config file is NOT an early exit: Go uses embedded defaults and - // still gates the no-op on `len(projectRef) == 0` (`internal/seed/buckets/ - // buckets.go:16-20`). So local + no-config falls into the no-op short-circuit - // below (emitting the empty summary in json/stream-json); `--linked` + - // no-config falls through to the remote path so auth/project/API failures - // surface, exactly as the Go command does. + // still gates the no-op on `len(projectRef) == 0`. So local + no-config falls + // into the no-op short-circuit; `--linked` + no-config falls through to the + // remote path so auth/project/API failures surface. const config = loaded === null ? legacyDecodeDefaultProjectConfig({}) : loaded.config; const document = loaded === null ? undefined : loaded.document; - // Go prints this from inside config load (`config.go:513`, - // `fmt.Fprintln(os.Stderr, "Loading config override:", idToName[projectId])`), - // unconditionally and before any command output, whenever a `[remotes.*]` - // block's project_id matched the linked ref. `appliedRemote` is the bare name, - // bracketed here to match Go's `idToName` value (`config.go:511`). Same emit as - // `config push` (push.handler.ts). stderr in all output modes (diagnostic-only). + // Go prints this from inside config load (`config.go:513`) whenever a + // `[remotes.*]` block matched the linked ref. stderr in all output modes. if (loaded !== null && loaded.appliedRemote !== undefined) { yield* output.raw(`Loading config override: [remotes.${loaded.appliedRemote}]\n`, "stderr"); } @@ -313,8 +193,7 @@ export const legacySeedBuckets = Effect.fn("legacy.seed.buckets")(function* ( yield* legacyValidateBucketName(name); } - // 3b. Storage-level file_size_limit, parsed unconditionally (Go unmarshals - // `storage.FileSizeLimit` at config.Load regardless of buckets). + // 3b. Storage-level file_size_limit, parsed unconditionally. const storageFileSizeLimitBytes = yield* parseFileSizeLimitOrFail( config.storage.file_size_limit, ); @@ -330,8 +209,6 @@ export const legacySeedBuckets = Effect.fn("legacy.seed.buckets")(function* ( // 3d. Short-circuit: nothing to seed (ref present → never short-circuits). if (projectRef === "" && bucketNames.length === 0 && !hasVectorBuckets) { - // Go emits nothing in text mode; in the additive json/stream-json modes a - // scripted caller still expects a result object, so emit an empty summary. if (output.format !== "text") { yield* output.success("", { ...emptySummary() }); } @@ -339,88 +216,16 @@ export const legacySeedBuckets = Effect.fn("legacy.seed.buckets")(function* ( } // 4. Build the Storage service-gateway client (local or remote). - let baseUrl: string; - let apiKey: string; - - if (projectRef === "") { - baseUrl = resolveLocalBaseUrl(config); - apiKey = yield* resolveLocalServiceRoleKey(config.auth); - } else { - baseUrl = `https://${projectRef}.${cliConfig.projectHost}`; - const envKey = process.env["SUPABASE_AUTH_SERVICE_ROLE_KEY"]; - if (envKey !== undefined && envKey.length > 0) { - apiKey = envKey; - } else { - // Go builds the remote Storage client via `tenant.GetApiKeys` - // (`internal/storage/client/api.go:22`), which maps a non-200 to - // `Authorization failed for the access token and project ref pair: ` - // (`internal/utils/tenant/client.go:15,77-78`) — NOT the `projects api-keys` - // helper's `unexpected get api keys status ...`. Resolve the client lazily - // so the local path never triggers Management API auth. - const api = yield* (yield* LegacyPlatformApiFactory).make; - const keys = legacyExtractServiceKeys( - yield* api.v1.getProjectApiKeys({ ref: projectRef, reveal: true }).pipe( - Effect.catch( - legacyMapTenantApiKeysError({ - networkError: LegacySeedApiKeysNetworkError, - statusError: LegacySeedAuthTokenError, - }), - ), - ), - ); - // Go's tenant.GetApiKeys fails with errMissingKey ("Anon key not found.") - // when the api-keys response yields nothing, before building the remote - // Storage client (`internal/utils/tenant/client.go:24-26,80-82`). - if (keys.anon === "" && keys.serviceRole === "") { - return yield* new LegacySeedMissingApiKeyError({ message: "Anon key not found." }); - } - apiKey = keys.serviceRole; - } - } + const credentials = yield* legacyResolveStorageCredentials({ projectRef, config }); - // Kong CA trust for the LOCAL path. Go's `newLocalClient` installs - // `status.NewKongClient` unconditionally (`internal/storage/client/api.go:30-37`) - // — its embedded CA only matters for https — and `(*api).Validate` resolves - // `cert_path`/`key_path` (`config.go:795`) and validates the cert/key pairing - // (`config.go:841-861`) only when `api.enabled && api.tls.enabled` (both - // blocks are gated on `c.Api.Enabled`). So: validate (and resolve a cert_path - // CA) only when the api is enabled AND tls is enabled; inject the CA whenever - // the resolved local URL is https — Go derives the scheme from `api.tls.enabled` - // alone (`config.go:639-642`, NOT gated on `api.enabled`), so an `enabled=false` - // + `tls.enabled=true` config still yields an https URL and the embedded CA — - // and never for the remote `--linked` host. - let localKongCa: string | undefined; - if (projectRef === "") { - const validatedCa = - config.api.enabled && config.api.tls.enabled - ? yield* validateLocalKongTls( - fs, - path, - cliConfig.workdir, - config.api.tls.cert_path, - config.api.tls.key_path, - ) - : undefined; - if (baseUrl.startsWith("https:")) { - localKongCa = validatedCa ?? KONG_LOCAL_CA_CERT; - } - } - - // All gateway operations run with an explicit non-DoH fetch. Storage calls - // never use DoH in Go: `newLocalClient` uses `status.NewKongClient` and - // `newRemoteClient` uses `http.DefaultClient` — `withFallbackDNS` is installed - // only in `utils.GetSupabase` (Management API, `internal/utils/api.go:125-127`). - // `legacyHttpClientLayer` bakes the DoH wrapper into the shared client, so we - // override `FetchHttpClient.Fetch` at this scope UNCONDITIONALLY: a CA-trusting - // fetch for local + https, plain `globalThis.fetch` otherwise. (`Fetch` is read - // per request from the fiber context, so the scope override applies to every - // gateway call.) The api-keys lookup above runs through the platform API factory - // BEFORE this scope, so it still honors `--dns-resolver https`, matching Go's - // `tenant.GetApiKeys` → `GetSupabase`. + // All gateway operations run with an explicit non-DoH fetch (CA-trusting for + // local + https, plain `globalThis.fetch` otherwise). The api-keys lookup + // inside `legacyResolveStorageCredentials` runs BEFORE this scope, so it + // still honors `--dns-resolver https`, matching Go's `tenant.GetApiKeys`. const gatewayOps = Effect.gen(function* () { const gateway = yield* legacyMakeStorageGateway({ - baseUrl, - apiKey, + baseUrl: credentials.baseUrl, + apiKey: credentials.apiKey, userAgent: cliConfig.userAgent, }); @@ -458,19 +263,15 @@ export const legacySeedBuckets = Effect.fn("legacy.seed.buckets")(function* ( } }); - // Non-DoH fetch for every gateway call: CA-trusting for local + https, plain - // `globalThis.fetch` otherwise. Never the DoH-wrapped shared client. yield* gatewayOps.pipe( Effect.provideService( FetchHttpClient.Fetch, - localKongCa !== undefined ? legacyKongCaFetch(localKongCa) : globalThis.fetch, + legacyStorageGatewayFetch(credentials.localKongCa), ), ); }).pipe( // Go's root `Execute` caches the linked project + fires org/project group // identify whenever `flags.ProjectRef` is set — only on the --linked path. - // `suspend` defers reading `linkedRef` until the finalizer runs (after the - // ref has been resolved inside the gen). Effect.ensuring( Effect.suspend(() => (linkedRef === "" ? Effect.void : linkedProjectCache.cache(linkedRef))), ), @@ -478,82 +279,6 @@ export const legacySeedBuckets = Effect.fn("legacy.seed.buckets")(function* ( ); }); -/** - * Local API URL, mirroring Go's `config.go:634-644` + `misc.go:298`: an explicit - * `api.external_url` wins, otherwise `://:` where the scheme - * follows `api.tls.enabled`, the host is resolved by `legacyGetHostname` (Go's - * `utils.GetHostname`: `SUPABASE_SERVICES_HOSTNAME` → TCP Docker daemon host → - * `127.0.0.1`), and the port is `api.port`. - */ -function resolveLocalBaseUrl(config: { - readonly api: { - readonly external_url?: string; - readonly port: number; - readonly tls: { readonly enabled: boolean }; - }; -}): string { - if (config.api.external_url !== undefined && config.api.external_url.length > 0) { - return config.api.external_url; - } - const host = legacyGetHostname(); - const scheme = config.api.tls.enabled ? "https" : "http"; - // Go builds the host:port with net.JoinHostPort (config.go:636-638), which - // brackets an IPv6 host (e.g. `::1` → `[::1]:54321`); a bare `::1:54321` is an - // invalid URL. legacyGetHostname returns the unbracketed host, so bracket here. - const hostPort = host.includes(":") - ? `[${host}]:${config.api.port}` - : `${host}:${config.api.port}`; - return `${scheme}://${hostPort}`; -} - -/** - * Resolve the service-role key used against the local Storage gateway, mirroring - * Go's `(*auth).generateAPIKeys` (`apps/cli-go/pkg/config/apikeys.go:43-63`), - * which `config.Load` always runs before `NewStorageAPI`. Applies env-var - * precedence matching Go's Viper `AutomaticEnv`+`SUPABASE_` prefix - * (`apps/cli-go/pkg/config/config.go:492-497`): - * - jwt secret: `SUPABASE_AUTH_JWT_SECRET` env (if set & non-empty) → - * `auth.jwt_secret` (if non-empty) → `defaultJwtSecret`; - * - a resolved secret shorter than 16 chars is rejected; - * - service-role key: `SUPABASE_AUTH_SERVICE_ROLE_KEY` env (if set & non-empty) → - * `auth.service_role_key` (if non-empty) → sign from resolved secret. - * - * `@supabase/config` has no `generateAPIKeys` equivalent (the keys are - * `optionalKey` with no default), so this fill-in is the caller's job. Empty - * checks use length, not nullishness, so an explicit `service_role_key = ""` is - * regenerated like Go (`??` would have sent the empty string). An unresolved - * `env(...)` literal is passed through verbatim, exactly as Go does - * (`pkg/config/decode_hooks.go:15-26` leaves it, and a non-empty literal is not - * regenerated by `generateAPIKeys`). - */ -const resolveLocalServiceRoleKey = Effect.fnUntraced(function* (auth: { - readonly jwt_secret?: string; - readonly service_role_key?: string; -}) { - // Apply env-var precedence for jwt_secret (Go Viper AutomaticEnv). - const envSecret = process.env["SUPABASE_AUTH_JWT_SECRET"]; - const configuredSecret = - envSecret !== undefined && envSecret.length > 0 ? envSecret : auth.jwt_secret; - - let jwtSecret: string; - if (configuredSecret === undefined || configuredSecret.length === 0) { - jwtSecret = defaultJwtSecret; - } else if (configuredSecret.length < 16) { - return yield* new LegacySeedConfigLoadError({ - message: "Invalid config for auth.jwt_secret. Must be at least 16 characters", - }); - } else { - jwtSecret = configuredSecret; - } - - // Apply env-var precedence for service_role_key (Go Viper AutomaticEnv). - const envKey = process.env["SUPABASE_AUTH_SERVICE_ROLE_KEY"]; - const configuredKey = envKey !== undefined && envKey.length > 0 ? envKey : auth.service_role_key; - return configuredKey !== undefined && configuredKey.length > 0 - ? configuredKey - : generateJwt(jwtSecret, "service_role"); -}); - type BucketsConfig = Readonly< Record< string, @@ -566,56 +291,6 @@ type BucketsConfig = Readonly< > >; -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -/** - * Whether the bucket's TOML entry explicitly declares a `public` key. Go reads - * `public` into a `*bool`, so an absent key serialises as omitted (not `false`). - * The decoded `@supabase/config` value defaults to `false` and loses this, so we - * recover presence from the raw (post-`env()`) document. - */ -function bucketHasPublicKey(document: Record | undefined, name: string): boolean { - return bucketHasKey(document, name, "public"); -} - -/** - * Whether the bucket's TOML entry explicitly declares `file_size_limit`. Absent - * decodes to the bucket schema default (`50MiB`), losing the "omitted" signal Go - * relies on to inherit the storage-level limit, so recover presence from the raw - * (post-`env()`) document — same approach as `bucketHasPublicKey`. - */ -function bucketHasFileSizeLimit( - document: Record | undefined, - name: string, -): boolean { - return bucketHasKey(document, name, "file_size_limit"); -} - -function bucketHasKey( - document: Record | undefined, - name: string, - key: string, -): boolean { - if (document === undefined) return false; - const storage = document["storage"]; - if (!isRecord(storage)) return false; - const buckets = storage["buckets"]; - if (!isRecord(buckets)) return false; - const bucket = buckets[name]; - return isRecord(bucket) && key in bucket; -} - -/** - * Resolve a bucket's create/update props, mirroring Go's `config.resolve()` - * (`apps/cli-go/pkg/config/config.go:753-756`) + the `sizeInBytes` decode that - * happens at config-load **before** `NewStorageAPI`: - * - an omitted or zero `file_size_limit` inherits the storage-level limit; - * - the size is parsed up front, so an invalid value fails (mapped to a - * config-load error) before any Storage list/create/update side effect — Go - * rejects the same config during `LoadConfig`. - */ // Parse a `file_size_limit` string to bytes, mapping a parse failure to a // config-load error (Go rejects an invalid `sizeInBytes` during `config.Load`, // before NewStorageAPI). @@ -628,50 +303,19 @@ const parseFileSizeLimitOrFail = (value: string) => }), }); -const computeBucketProps = Effect.fnUntraced(function* ( +const computeBucketProps = ( document: Record | undefined, name: string, bucket: BucketsConfig[string], storageFileSizeLimitBytes: number, -) { - // Go's resolve() inherits the (already-parsed) storage-level limit when the - // bucket omits its own / sets 0 (`config.go:753-756`). - const bucketBytes = bucketHasFileSizeLimit(document, name) - ? yield* parseFileSizeLimitOrFail(bucket.file_size_limit) - : 0; - const fileSizeLimit = bucketBytes === 0 ? storageFileSizeLimitBytes : bucketBytes; - - return { - public: bucketHasPublicKey(document, name) ? bucket.public : undefined, - fileSizeLimit, - allowedMimeTypes: bucket.allowed_mime_types, - } satisfies LegacyUpsertBucketProps; -}); - -/** - * Confirm-or-default prompt mirroring Go's `console.PromptYesNo` - * (`internal/utils/console.go`): `--yes`/`SUPABASE_YES` echoes `