diff --git a/.github/workflows/cli-go-ci.yml b/.github/workflows/cli-go-ci.yml index 8e62d6afb1..8f1f7219c6 100644 --- a/.github/workflows/cli-go-ci.yml +++ b/.github/workflows/cli-go-ci.yml @@ -91,7 +91,7 @@ jobs: with: persist-credentials: false - - uses: jdx/mise-action@9e7f7633ff6f6d6048a9418a68d48f288f50eb14 # v4 + - uses: jdx/mise-action@7e36c90d9ab29c415a2384db3006f3ec8a8cc654 # v4 with: version: 2026.7.0 install: true diff --git a/.github/workflows/verify-install-channels.yml b/.github/workflows/verify-install-channels.yml index 606067cbaa..cf5a6983bb 100644 --- a/.github/workflows/verify-install-channels.yml +++ b/.github/workflows/verify-install-channels.yml @@ -17,9 +17,19 @@ name: Verify Install Channels # instead of trusting the manifest the publish step wrote. # # Each leg goes beyond `supabase --version` (handled by the Bun wrapper without -# touching the sidecar) and runs `supabase completion bash`, a Go-proxied -# command, so a package that omits or misplaces the colocated `supabase-go` -# sidecar fails here instead of silently shipping broken proxied commands. +# touching the sidecar) and directly checks that the `supabase-go` sidecar +# binary is present and executable in the channel's install directory, so a +# package that omits or misplaces it fails here instead of silently shipping +# a CLI whose still-Go-proxied commands (see docs/go-cli-porting-status.md) +# would fail for every user. +# +# This used to run `supabase completion bash`, since that command was +# Go-proxied. It no longer is (CLI-1965 ported shell completion to native +# TypeScript, and the Go CLI's own completion command was subsequently +# removed too), so that probe silently stopped testing the sidecar at all. +# Checking for the sidecar file directly instead of routing through some +# still-proxied command avoids repeating that mistake as more commands get +# natively ported. on: workflow_call: @@ -156,20 +166,20 @@ jobs: - name: Verify Go sidecar run: | set -euo pipefail - # `completion bash` is proxied to the colocated `supabase-go` sidecar, - # so this fails (NotFound: ChildProcess.spawn) if the package omitted - # or misplaced supabase-go, even though `--version` above passed. - out="$(supabase completion bash 2>&1)" || { - echo "${out}" - echo "Go sidecar probe failed: 'supabase completion bash' did not exit 0" >&2 + # Homebrew's `bin.install` symlinks both `supabase` and `supabase-go` + # into the same prefix bin/ directory, so the sidecar must sit right + # next to whichever `supabase` resolved from PATH. + bin_dir="$(dirname "$(command -v supabase)")" + sidecar="${bin_dir}/supabase-go" + if [ ! -e "${sidecar}" ]; then + echo "Go sidecar probe failed: ${sidecar} does not exist" >&2 exit 1 - } - printf '%s' "${out}" | grep -q "supabase" || { - echo "${out}" - echo "Go sidecar probe failed: unexpected completion output" >&2 + fi + if [ ! -x "${sidecar}" ]; then + echo "Go sidecar probe failed: ${sidecar} exists but is not executable" >&2 exit 1 - } - echo "Go sidecar probe OK" + fi + echo "Go sidecar probe OK: ${sidecar}" scoop: name: Scoop (${{ inputs.scoop_name }}) @@ -212,20 +222,19 @@ jobs: shell: bash run: | set -euo pipefail - # `completion bash` is proxied to the colocated `supabase-go` sidecar, - # so this fails if the package omitted or misplaced supabase-go.exe, - # even though `--version` above passed. - out="$(supabase completion bash 2>&1)" || { - echo "${out}" - echo "Go sidecar probe failed: 'supabase completion bash' did not exit 0" >&2 - exit 1 - } - printf '%s' "${out}" | grep -q "supabase" || { - echo "${out}" - echo "Go sidecar probe failed: unexpected completion output" >&2 + # Scoop's manifest only declares `supabase.exe` in `bin` (see + # apps/cli/scripts/update-scoop.ts), so only `supabase` gets a shim in + # ~/scoop/shims — `dirname "$(command -v supabase)"` would resolve to + # the shim directory, not the real install directory supabase-go.exe + # actually lives in. Go straight to the app's current version + # directory instead, which Scoop always maintains regardless of shims. + app_dir="${HOME}/scoop/apps/${SCOOP_NAME}/current" + sidecar="${app_dir}/supabase-go.exe" + if [ ! -e "${sidecar}" ]; then + echo "Go sidecar probe failed: ${sidecar} does not exist" >&2 exit 1 - } - echo "Go sidecar probe OK" + fi + echo "Go sidecar probe OK: ${sidecar}" install-script: name: install script (${{ matrix.runner }}) @@ -270,17 +279,17 @@ jobs: shell: bash run: | set -euo pipefail - # `completion bash` is proxied to the colocated `supabase-go` sidecar, - # so this fails if the install script did not place supabase-go next - # to supabase, even though `--version` above passed. - out="$(supabase completion bash 2>&1)" || { - echo "${out}" - echo "Go sidecar probe failed: 'supabase completion bash' did not exit 0" >&2 + # The install script places `supabase-go` right next to `supabase`, + # so the sidecar must sit in the same directory `supabase` resolved + # from on PATH. + bin_dir="$(dirname "$(command -v supabase)")" + sidecar="${bin_dir}/supabase-go" + if [ ! -e "${sidecar}" ]; then + echo "Go sidecar probe failed: ${sidecar} does not exist" >&2 exit 1 - } - printf '%s' "${out}" | grep -q "supabase" || { - echo "${out}" - echo "Go sidecar probe failed: unexpected completion output" >&2 + fi + if [ ! -x "${sidecar}" ]; then + echo "Go sidecar probe failed: ${sidecar} exists but is not executable" >&2 exit 1 - } - echo "Go sidecar probe OK" + fi + echo "Go sidecar probe OK: ${sidecar}" diff --git a/apps/cli-e2e/src/tests/live/db-reset-start.live.e2e.test.ts b/apps/cli-e2e/src/tests/live/db-reset-start.live.e2e.test.ts index 278b984341..d3fba9d7e7 100644 --- a/apps/cli-e2e/src/tests/live/db-reset-start.live.e2e.test.ts +++ b/apps/cli-e2e/src/tests/live/db-reset-start.live.e2e.test.ts @@ -16,12 +16,14 @@ import { testLive } from "./live-context.ts"; // Exercises `db start`'s native container-bootstrap sequence (network/volume/container // bring-up, health wait, the fresh-volume SetupLocalDatabase-equivalent pipeline, and // `_current_branch`) and `db reset --local`'s container-recreate flow end-to-end — the -// real-Docker boundary the in-process integration suites mock. `db reset --local` still -// delegates its container-recreate flow to the bundled Go binary's hidden -// `db __db-bootstrap --mode recreate` seam (CLI-1955, unclaimed as of CLI-1954); `db start` -// no longer does (see `commands/db/start/start.handler.ts`). The start → already-running → -// reset cycle runs in one test so it shares a single booted stack, and `finally` stops it -// (legacy proxies `stop` to Go) so the run never leaves containers behind. +// real-Docker boundary the in-process integration suites mock. Both are fully native TS +// now: `db reset --local`'s hidden Go `db __db-bootstrap` seam (`--mode recreate`/ +// `--mode await-storage`) was removed in CLI-1955 (see +// `commands/db/reset/reset.handler.ts` / `shared/db-bootstrap/recreate-local-database.ts`), +// the same way `db start`'s own seam usage was removed in CLI-1954 (see +// `commands/db/start/start.handler.ts`). The start → already-running → reset cycle runs +// in one test so it shares a single booted stack, and `finally` stops it (legacy proxies +// `stop` to Go) so the run never leaves containers behind. describe.skipIf(TARGET === "ts-next")("db start / db reset --local (live, local Docker)", () => { testLive( "db start boots, is idempotent, and db reset --local recreates", diff --git a/apps/cli-go/cmd/db.go b/apps/cli-go/cmd/db.go index f4c80517b1..df04364e44 100644 --- a/apps/cli-go/cmd/db.go +++ b/apps/cli-go/cmd/db.go @@ -270,66 +270,6 @@ var ( }, } - bootstrapMode string - bootstrapSqlPaths []string - bootstrapVersion string - bootstrapNoSeed bool - - // dbBootstrapCmd is a hidden seam used by the native-TypeScript `db reset --local` - // command to drive the container-bootstrap primitives that are not yet ported to - // TypeScript: recreating the local Postgres container, applying the initial - // schema, and the storage health gate. The TS caller orchestrates everything else - // (version/last resolution, bucket seeding, the git-branch "Finished…" line, - // telemetry, and --output-format shaping); the seam stays in Go only for the - // Docker lifecycle. It mirrors the existing db __shadow seam: it carries no - // db-url/local/linked target flags, so it loads supabase/config.toml explicitly - // (the root PersistentPreRunE only loads it when a target flag is set). Progress - // goes to stderr; the only stdout output is a single machine-parseable marker - // for --mode await-storage ("ready" or "absent"). `db start`'s own container - // bootstrap (--mode start) was removed from this seam by CLI-1954 — it is now a - // fully native TypeScript implementation - // (apps/cli/src/legacy/commands/db/start/start.handler.ts), reusing - // legacy/shared/db-bootstrap/'s already-ported container-bootstrap primitives - // instead of shelling out to this binary. `start.StartDatabase` itself (called - // below by the real, customer-facing `db start` Go command) is untouched — it - // remains the parity oracle this TS port was checked against. - dbBootstrapCmd = &cobra.Command{ - Use: "__db-bootstrap", - Hidden: true, - Short: "Internal: container bootstrap for the native db start / db reset commands", - RunE: func(cmd *cobra.Command, args []string) error { - fsys := afero.NewOsFs() - if err := flags.LoadConfig(fsys); err != nil { - return err - } - switch bootstrapMode { - case "recreate": - // The PG14/PG15 container-recreate half of local db reset. The TS - // caller has already printed "Resetting local database…" and validated - // the flags. Apply the same seed handling as `db reset` (dbResetCmd): - // `--no-seed` disables the seed, `--sql-paths` overrides the seed paths, - // before MigrateAndSeed runs inside the recreate. - if err := applyDbResetSeedFlags(bootstrapNoSeed, bootstrapSqlPaths); err != nil { - return err - } - return reset.RecreateLocalDatabase(cmd.Context(), bootstrapVersion, fsys) - case "await-storage": - ready, err := reset.AwaitStorageReady(cmd.Context()) - if err != nil { - return err - } - if ready { - fmt.Println("ready") - } else { - fmt.Println("absent") - } - return nil - default: - return fmt.Errorf("unknown bootstrap mode: %s", bootstrapMode) - } - }, - } - dbRemoteCmd = &cobra.Command{ Hidden: true, Use: "remote", @@ -680,13 +620,6 @@ func init() { shadowFlags.StringSliceVarP(&shadowSchema, "schema", "s", []string{}, "Comma separated list of schema to include.") shadowFlags.StringVar(&shadowProjectRef, "project-ref", "", "Linked project ref, so the shadow merges the matching [remotes.] config override.") dbCmd.AddCommand(dbShadowCmd) - // Build hidden container-bootstrap seam command (native db start / db reset) - bootstrapFlags := dbBootstrapCmd.Flags() - bootstrapFlags.StringVar(&bootstrapMode, "mode", "recreate", "Bootstrap mode: recreate or await-storage.") - bootstrapFlags.StringVar(&bootstrapVersion, "version", "", "Reset up to the specified version (recreate mode).") - bootstrapFlags.BoolVar(&bootstrapNoSeed, "no-seed", false, "Skip the seed script after recreate (recreate mode).") - bootstrapFlags.StringArrayVar(&bootstrapSqlPaths, "sql-paths", nil, "Override [db.seed].sql_paths for the recreate (recreate mode).") - dbCmd.AddCommand(dbBootstrapCmd) // Build remote command remoteFlags := dbRemoteCmd.PersistentFlags() remoteFlags.StringSliceVarP(&schema, "schema", "s", []string{}, "Comma separated list of schema to include.") diff --git a/apps/cli-go/cmd/root.go b/apps/cli-go/cmd/root.go index f83eb6d11b..2ab16e2ae2 100644 --- a/apps/cli-go/cmd/root.go +++ b/apps/cli-go/cmd/root.go @@ -320,6 +320,17 @@ func init() { viper.AutomaticEnv() }) + // Shell tab-completion is fully native in the TS shim now (CLI-1965); the + // TS entrypoint intercepts completion/__complete/__completeNoDesc before + // ever delegating to this binary, so cobra's own completion command is + // unreachable dead weight here. This only removes the visible + // `completion ` command — cobra's ExecuteC() unconditionally + // (re-)registers the hidden __complete/__completeNoDesc responder on + // every run with no opt-out (command.go's initCompleteCmd), so that + // protocol handler stays present but, same as above, unreachable through + // the shipped CLI. + rootCmd.CompletionOptions.DisableDefaultCmd = true + flags := rootCmd.PersistentFlags() flags.Bool("yes", false, "answer yes to all prompts") flags.Bool("debug", false, "output debug logs to stderr") diff --git a/apps/cli-go/cmd/sso.go b/apps/cli-go/cmd/sso.go index 7c8bc9150a..3a2cc03141 100644 --- a/apps/cli-go/cmd/sso.go +++ b/apps/cli-go/cmd/sso.go @@ -163,8 +163,6 @@ func init() { ssoAddFlags.Var(&ssoNameIDFormat, "name-id-format", "URI reference representing the classification of string-based identifier information.") ssoAddCmd.MarkFlagsMutuallyExclusive("metadata-file", "metadata-url") cobra.CheckErr(ssoAddCmd.MarkFlagRequired("type")) - cobra.CheckErr(ssoAddCmd.MarkFlagFilename("metadata-file", "xml")) - cobra.CheckErr(ssoAddCmd.MarkFlagFilename("attribute-mapping-file", "json")) ssoUpdateFlags := ssoUpdateCmd.Flags() ssoUpdateFlags.StringSliceVar(&ssoDomains, "domains", []string{}, "Replace domains with this comma separated list of email domains.") @@ -178,8 +176,6 @@ func init() { ssoUpdateCmd.MarkFlagsMutuallyExclusive("metadata-file", "metadata-url") ssoUpdateCmd.MarkFlagsMutuallyExclusive("domains", "add-domains") ssoUpdateCmd.MarkFlagsMutuallyExclusive("domains", "remove-domains") - cobra.CheckErr(ssoUpdateCmd.MarkFlagFilename("metadata-file", "xml")) - cobra.CheckErr(ssoUpdateCmd.MarkFlagFilename("attribute-mapping-file", "json")) ssoShowFlags := ssoShowCmd.Flags() ssoShowFlags.BoolVar(&ssoMetadata, "metadata", false, "Show SAML 2.0 XML Metadata only") diff --git a/apps/cli-go/cmd/start.go b/apps/cli-go/cmd/start.go index 2ca8560b45..1431d5b0aa 100644 --- a/apps/cli-go/cmd/start.go +++ b/apps/cli-go/cmd/start.go @@ -5,8 +5,10 @@ package cmd // talks to Docker directly for `start` and never delegates to this binary // for it, and no other still-live TS->Go delegation seam (db test, db // branch/remote, db diff --use-pgadmin/--use-pg-schema, db pull -// --experimental, the hidden db __db-bootstrap/__shadow/__catalog seams, -// etc.) ever called into internal/start either -- see +// --experimental, the hidden db __shadow/__catalog seams -- the sibling +// hidden db __db-bootstrap seam was removed outright by CLI-1955, once +// native `db reset --local` became its last remaining caller -- etc.) ever +// called into internal/start either -- see // apps/cli/docs/binary-distribution.md § Removed commands for the full // rationale. This command's registration and flags are kept only so this // binary's cobra tree / `--help` / `__complete` output stays stable for diff --git a/apps/cli-go/docs/main.go b/apps/cli-go/docs/main.go index bfacba3118..011d7ac724 100644 --- a/apps/cli-go/docs/main.go +++ b/apps/cli-go/docs/main.go @@ -47,7 +47,6 @@ func generate(version string) error { return err } root := cli.GetRootCmd() - root.InitDefaultCompletionCmd() root.InitDefaultHelpFlag() spec := SpecDoc{ Clispec: "001", diff --git a/apps/cli-go/internal/db/reset/reset.go b/apps/cli-go/internal/db/reset/reset.go index 742fd6d413..7ac3f8e4f1 100644 --- a/apps/cli-go/internal/db/reset/reset.go +++ b/apps/cli-go/internal/db/reset/reset.go @@ -93,38 +93,6 @@ func toLogMessage(version string) string { return "..." } -// RecreateLocalDatabase is the container-lifecycle half of a local `db reset`, -// exposed for the native-TypeScript `db reset --local` seam (cmd db __db-bootstrap). -// It performs the PG14/PG15 branch — recreate the db container/volume, init schema, -// migrate + seed, and restart the satellite containers — WITHOUT the leading -// "Resetting local database…" line, which the TS caller prints itself. Mirrors -// resetDatabase (above) minus that message. -func RecreateLocalDatabase(ctx context.Context, version string, fsys afero.Fs, options ...func(*pgx.ConnConfig)) error { - if utils.Config.Db.MajorVersion <= 14 { - return resetDatabase14(ctx, version, fsys, options...) - } - return resetDatabase15(ctx, version, fsys, options...) -} - -// AwaitStorageReady mirrors the storage-health gate that local `db reset` runs -// before seeding buckets (Run, above): if the storage container exists but is not -// healthy, wait up to 30s for it. It reports whether the storage container exists -// so the native-TypeScript caller knows whether to run the (already-ported) bucket -// seeding. Any inspect error is treated as "storage not running" → false, matching -// Go's `err == nil` gate, which silently skips buckets on any inspect failure. -func AwaitStorageReady(ctx context.Context) (bool, error) { - resp, err := utils.Docker.ContainerInspect(ctx, utils.StorageId) - if err != nil { - return false, nil - } - if resp.State.Health == nil || resp.State.Health.Status != types.Healthy { - if err := start.WaitForHealthyService(ctx, 30*time.Second, utils.StorageId); err != nil { - return false, err - } - } - return true, nil -} - func resetDatabase14(ctx context.Context, version string, fsys afero.Fs, options ...func(*pgx.ConnConfig)) error { if err := recreateDatabase(ctx, options...); err != nil { return err diff --git a/apps/cli-go/pkg/config/templates/Dockerfile b/apps/cli-go/pkg/config/templates/Dockerfile index 4a228bea7a..4eb05cad99 100644 --- a/apps/cli-go/pkg/config/templates/Dockerfile +++ b/apps/cli-go/pkg/config/templates/Dockerfile @@ -11,9 +11,9 @@ FROM supabase/edge-runtime:v1.74.3 AS edgeruntime FROM timberio/vector:0.53.0-alpine AS vector FROM supabase/supavisor:2.9.7 AS supavisor FROM supabase/gotrue:v2.195.0 AS gotrue -FROM supabase/realtime:v2.123.5 AS realtime -FROM supabase/storage-api:v1.68.8 AS storage -FROM supabase/logflare:1.50.0 AS logflare +FROM supabase/realtime:v2.124.2 AS realtime +FROM supabase/storage-api:v1.68.10 AS storage +FROM supabase/logflare:1.50.1 AS logflare # Append to JobImages when adding new dependencies below FROM supabase/pgadmin-schema-diff:cli-0.0.5 AS differ FROM supabase/migra:3.0.1663481299 AS migra diff --git a/apps/cli-go/pkg/migration/file.go b/apps/cli-go/pkg/migration/file.go index 83c07f53c7..d7526ac4dd 100644 --- a/apps/cli-go/pkg/migration/file.go +++ b/apps/cli-go/pkg/migration/file.go @@ -110,9 +110,12 @@ func trimLeadingSQLComments(sql string) string { } } -// ExecBatch is also reached from the shipped supabase-go sidecar: local `db -// start` / `db reset` delegate migration apply to the `db __db-bootstrap` -// seam (apps/cli-go/cmd/db.go), which calls this via apply.MigrateAndSeed. +// ExecBatch is also reached from the shipped supabase-go sidecar via the +// remaining Go-delegated paths (e.g. remote `db push`/`db reset`'s +// apply.MigrateAndSeed) — local `db start`/`db reset` no longer delegate here +// at all: CLI-1954/CLI-1955 removed the hidden `db __db-bootstrap` seam +// (apps/cli-go/cmd/db.go) this comment used to describe, in favor of a fully +// native TypeScript container-bootstrap port. func (m *MigrationFile) ExecBatch(ctx context.Context, conn *pgx.Conn) error { batch := &pgconn.Batch{} batchSize := 0 diff --git a/apps/cli/docs/binary-distribution.md b/apps/cli/docs/binary-distribution.md index 738309f0c2..3eef8bcb85 100644 --- a/apps/cli/docs/binary-distribution.md +++ b/apps/cli/docs/binary-distribution.md @@ -101,7 +101,7 @@ This: ### Removed commands -`apps/cli-go/internal/start` (Go's `supabase start` implementation) was deleted outright (CLI-1966), not just excluded from the shipped binary. Native TS `start` talks to Docker directly and never proxies to Go for it, and no other still-live TS→Go delegation seam (`db test`, `db branch`/`db remote`, `db diff --use-pgadmin`/`--use-pg-schema`, `db pull --experimental`, the hidden `db __db-bootstrap`/`__shadow`/`__catalog` seams, etc.) ever called into `internal/start` either — a repo-wide `grep` for the import confirmed the only reference anywhere in `apps/cli-go` was `start`'s own cobra registration. `internal/start` alone previously accounted for roughly half the shipped Go binary's size via its exclusive dependency tree (docker-compose/v2, buildx, buildkit, k8s client-go, aws-sdk-go-v2, notary, secret-detector), which `go mod tidy` dropped entirely once the package was deleted. `cmd/start.go` keeps `start`'s cobra registration and flag surface (needed by the `__complete` passthrough) but its `RunE` is a permanent stub returning a "not available in supabase-go" error — see `apps/cli-go/cmd/start_test.go` for the pinned error text. There is no longer a `bundled` build tag: with no second implementation to select between, the Go CLI's `cmd` package has only one `start`. +`apps/cli-go/internal/start` (Go's `supabase start` implementation) was deleted outright (CLI-1966), not just excluded from the shipped binary. Native TS `start` talks to Docker directly and never proxies to Go for it, and no other still-live TS→Go delegation seam (`db test`, `db branch`/`db remote`, `db diff --use-pgadmin`/`--use-pg-schema`, `db pull --experimental`, the hidden `db __shadow`/`__catalog` seams — the sibling hidden `db __db-bootstrap` seam was removed outright by CLI-1955, once native `db reset --local` became its last remaining caller — etc.) ever called into `internal/start` either — a repo-wide `grep` for the import confirmed the only reference anywhere in `apps/cli-go` was `start`'s own cobra registration. `internal/start` alone previously accounted for roughly half the shipped Go binary's size via its exclusive dependency tree (docker-compose/v2, buildx, buildkit, k8s client-go, aws-sdk-go-v2, notary, secret-detector), which `go mod tidy` dropped entirely once the package was deleted. `cmd/start.go` keeps `start`'s cobra registration and flag surface (needed by the `__complete` passthrough) but its `RunE` is a permanent stub returning a "not available in supabase-go" error — see `apps/cli-go/cmd/start_test.go` for the pinned error text. There is no longer a `bundled` build tag: with no second implementation to select between, the Go CLI's `cmd` package has only one `start`. ## See Also diff --git a/apps/cli/docs/go-cli-porting-status.md b/apps/cli/docs/go-cli-porting-status.md index defaffc3e2..6b2470e42b 100644 --- a/apps/cli/docs/go-cli-porting-status.md +++ b/apps/cli/docs/go-cli-porting-status.md @@ -80,51 +80,51 @@ These commands exist in the TS CLI today but have no direct top-level equivalent ## Database -| Old command | TS status | TS command path or `missing` | Missing flags/params | Extra TS flags/params | Notes | -| --------------------------------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `db diff` | `ported` | `legacy/commands/db/diff/` | `n/a` | `n/a` | Native TS port. Native pg-delta / migra diff via edge-runtime against a Go-seam-provisioned live shadow (`db __shadow`); `--use-pgadmin` / `--use-pg-schema` delegate to the Go binary. `--use-pg-schema` is deprecated (CLI-1960: TS-only stderr warning + `--help` note) in favor of the pg-delta engine or the default migra engine — it wraps the in-process `stripe/pg-schema-diff` Go library, which has no TS/container equivalent, so it is a documented keep-in-Go exception, not a pending port. It will be the sole remaining Go delegation once `--use-pgadmin`, the `db __shadow`/`db __db-bootstrap` seams, and the other in-flight M9 issues are done. | -| `db dump` | `ported` | `legacy/commands/db/dump/` | `n/a` | `n/a` | Native TS port. Streams `pg_dump`/`pg_dumpall` via a Docker container (`LegacyDockerRun`); schema/data/role modes, `--dry-run` script print, IPv4 transaction-pooler fallback. | -| `db lint` | `ported` | `legacy/commands/db/lint/` | `n/a` | `n/a` | Native TS port. Runs `plpgsql_check` in a rolled-back transaction via LegacyDbConnection; emits Go-parity pretty JSON. | -| `db pull` | `ported` | `legacy/commands/db/pull/` | `n/a` | `n/a` | Native TS port. Native pg-delta / migra migration + `--declarative` pg-delta export; reconciles `schema_migrations`. The initial-migra pull dumps the remote schema natively (`pg_dump`) then appends the migra diff. Only `--experimental` (structured dump) still delegates to Go, deprecated in favor of `--declarative` (CLI-1957) — it needs a TS PostgreSQL DDL parser for Go's `format.WriteStructuredSchemas` that has no equivalent in this repo, and `--declarative` already delivers the same per-object schema split via pg-delta catalog introspection. | -| `db push` | `ported` | `legacy/commands/db/push/` | `n/a` | `n/a` | Native TS port. Connects local/linked/`--db-url`; pushes pending migrations, `--include-seed` seeds (`seed_files` hash tracking), `--include-roles`, `[db.vault]` secrets including decrypted `encrypted:` values; `--dry-run`; best-effort pg-delta migrations-catalog cache (warning-only on failure). Pipeline-incompatible statements (`CREATE INDEX CONCURRENTLY`, `VACUUM`, …) run standalone outside the batch transaction — from the closed Go PR supabase/cli#5156, also ported into `apps/cli-go` (CLI-1989 ruling). | -| `db reset` | `ported` | `legacy/commands/db/reset/` | `n/a` | `n/a` | Remote path native (drop user schemas, vault upsert, MigrateAndSeed, `--version`/`--last`, `--sql-paths` seed override). Local path native: running check, recreate + migrate + seed via the hidden Go `db __db-bootstrap` seam, storage-gated bucket seeding (reuses `seed buckets`), git-branch `Finished…` line. Only the niche `--experimental` remote schema-files path still delegates to the Go binary (telemetry-disabled). Pipeline-incompatible statements run standalone outside the batch transaction, same as `db push` (closed Go PR supabase/cli#5156, CLI-1989 ruling). | -| `db start` | `ported` | `legacy/commands/db/start/` | `n/a` | `n/a` | Fully native TS port (CLI-1954 removed the last Go delegation). Validates config, checks "already running" (prints Go's line, native `docker container inspect`), else natively brings up the Postgres container (network/volume/create/start via `legacy/shared/db-bootstrap/`'s shared primitives), waits for health, runs the fresh-volume `SetupLocalDatabase`-equivalent pipeline (including its best-effort pg-delta migrations-catalog warmup), and writes `_current_branch`. `--from-backup` is fully native too: a third entrypoint variant (schema.sql + a ported `restore.sh`, no `webhook.sql`), a `backup volume already exists` guard when the volume isn't fresh, and a swallowed (not failed) health-check timeout. No status table / `cli_stack_started` (those are `supabase start`). | -| `inspect report` | `ported` | `legacy/commands/inspect/report/` | `n/a` | `n/a` | Native TS port. Runs every inspect query via server-side `COPY ... CSV`, writes 14 CSVs under `//`, then renders a Go-parity Glamour rules summary (bounded csvq-subset evaluator; custom `[experimental.inspect.rules]` supported). | -| `inspect db db-stats` | `ported` | `legacy/commands/inspect/db/db-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db replication-slots` | `ported` | `legacy/commands/inspect/db/replication-slots/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db locks` | `ported` | `legacy/commands/inspect/db/locks/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db blocking` | `ported` | `legacy/commands/inspect/db/blocking/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db outliers` | `ported` | `legacy/commands/inspect/db/outliers/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db calls` | `ported` | `legacy/commands/inspect/db/calls/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db index-stats` | `ported` | `legacy/commands/inspect/db/index-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db long-running-queries` | `ported` | `legacy/commands/inspect/db/long-running-queries/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db bloat` | `ported` | `legacy/commands/inspect/db/bloat/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db role-stats` | `ported` | `legacy/commands/inspect/db/role-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db vacuum-stats` | `ported` | `legacy/commands/inspect/db/vacuum-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db table-stats` | `ported` | `legacy/commands/inspect/db/table-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db traffic-profile` | `ported` | `legacy/commands/inspect/db/traffic-profile/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db cache-hit` | `ported` | `legacy/commands/inspect/db/cache-hit/` | `n/a` | `n/a` | Native TS port. Deprecated (use db-stats); routes to the active query. | -| `inspect db index-usage` | `ported` | `legacy/commands/inspect/db/index-usage/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | -| `inspect db total-index-size` | `ported` | `legacy/commands/inspect/db/total-index-size/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | -| `inspect db index-sizes` | `ported` | `legacy/commands/inspect/db/index-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | -| `inspect db table-sizes` | `ported` | `legacy/commands/inspect/db/table-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | -| `inspect db table-index-sizes` | `ported` | `legacy/commands/inspect/db/table-index-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | -| `inspect db total-table-sizes` | `ported` | `legacy/commands/inspect/db/total-table-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | -| `inspect db unused-indexes` | `ported` | `legacy/commands/inspect/db/unused-indexes/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | -| `inspect db table-record-counts` | `ported` | `legacy/commands/inspect/db/table-record-counts/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | -| `inspect db seq-scans` | `ported` | `legacy/commands/inspect/db/seq-scans/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | -| `inspect db role-configs` | `ported` | `legacy/commands/inspect/db/role-configs/` | `n/a` | `n/a` | Native TS port. Deprecated (use role-stats); routes to the active query. | -| `inspect db role-connections` | `ported` | `legacy/commands/inspect/db/role-connections/` | `n/a` | `n/a` | Native TS port. Deprecated (use role-stats); routes to the active query. | -| `migration down` | `ported` | `legacy/commands/migration/down/` | `n/a` | `n/a` | Native TS port. Revert prompt → drop user schemas → vault upsert → migrate&seed to the target version; defaults to `--local`. Skips Go's pgcache catalog write. | -| `migration fetch` | `ported` | `legacy/commands/migration/fetch/` | `n/a` | `n/a` | Native TS port. Reads `schema_migrations` and writes `supabase/migrations/_.sql`; overwrite prompt for a non-empty dir. | -| `migration list` | `ported` | `legacy/commands/migration/list/` | `n/a` | `n/a` | Native TS port. Merges remote `schema_migrations` with local files into a Glamour ASCII table (Local / Remote / Time-UTC columns); defaults to `--linked`. | -| `migration new` | `ported` | `legacy/commands/migration/new/` | `n/a` | `n/a` | Native TS port. Writes `supabase/migrations/_.sql` (mode 0644) from piped stdin; no DB/API. | -| `migration repair` | `ported` | `legacy/commands/migration/repair/` | `n/a` | `n/a` | Native TS port. Transactional create-table + TRUNCATE/UPSERT/DELETE; applied mode reads local files; repair-all prompt; defaults to `--linked`. | -| `migration squash` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | -| `migration up` | `ported` | `legacy/commands/migration/up/` | `n/a` | `n/a` | Native TS port. Computes pending migrations, upserts `[db.vault]`, applies each transactionally (pipeline-incompatible statements run standalone — closed Go PR supabase/cli#5156, ported into `apps/cli-go`, CLI-1989 ruling); `--include-all` for out-of-order; defaults to `--local`. Does not seed (matches Go). | -| `seed buckets` | `ported` | `legacy/commands/seed/buckets/` | `n/a` | `n/a` | Native TS port. Local-only (Go's `seed` defines no `--project-ref`, so the ref is always empty): seeds `[storage.buckets]` + `[storage.vector]` against the local Storage service gateway; remote/analytics paths are unreachable and omitted. `--linked`/`--local` accepted for surface parity (both seed local). Vector graceful-skip WARNINGs ported. | -| `test db` | `ported` | `legacy/shared/legacy-test-db.*` (command definitions in `legacy/commands/test/db/` and its hidden `db test` alias, `legacy/commands/db/test/`) | `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 is honored. `[images]` config override not modeled (documented divergence). | -| `test new` | `ported` | `legacy/commands/test/new/` | `n/a` | `n/a` | Native TS port. Writes `supabase/tests/_test.sql` from the embedded pgtap template; `--template` (pgtap). | +| Old command | TS status | TS command path or `missing` | Missing flags/params | Extra TS flags/params | Notes | +| --------------------------------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `db diff` | `ported` | `legacy/commands/db/diff/` | `n/a` | `n/a` | Native TS port. Native pg-delta / migra diff via edge-runtime against a Go-seam-provisioned live shadow (`db __shadow`); `--use-pgadmin` / `--use-pg-schema` delegate to the Go binary. `--use-pg-schema` is deprecated (CLI-1960: TS-only stderr warning + `--help` note) in favor of the pg-delta engine or the default migra engine — it wraps the in-process `stripe/pg-schema-diff` Go library, which has no TS/container equivalent, so it is a documented keep-in-Go exception, not a pending port. It will be the sole remaining Go delegation once `--use-pgadmin`, the `db __shadow`/`db __db-bootstrap` seams, and the other in-flight M9 issues are done. | +| `db dump` | `ported` | `legacy/commands/db/dump/` | `n/a` | `n/a` | Native TS port. Streams `pg_dump`/`pg_dumpall` via a Docker container (`LegacyDockerRun`); schema/data/role modes, `--dry-run` script print, IPv4 transaction-pooler fallback. | +| `db lint` | `ported` | `legacy/commands/db/lint/` | `n/a` | `n/a` | Native TS port. Runs `plpgsql_check` in a rolled-back transaction via LegacyDbConnection; emits Go-parity pretty JSON. | +| `db pull` | `ported` | `legacy/commands/db/pull/` | `n/a` | `n/a` | Native TS port. Native pg-delta / migra migration + `--declarative` pg-delta export; reconciles `schema_migrations`. The initial-migra pull dumps the remote schema natively (`pg_dump`) then appends the migra diff. Only `--experimental` (structured dump) still delegates to Go, deprecated in favor of `--declarative` (CLI-1957) — it needs a TS PostgreSQL DDL parser for Go's `format.WriteStructuredSchemas` that has no equivalent in this repo, and `--declarative` already delivers the same per-object schema split via pg-delta catalog introspection. | +| `db push` | `ported` | `legacy/commands/db/push/` | `n/a` | `n/a` | Native TS port. Connects local/linked/`--db-url`; pushes pending migrations, `--include-seed` seeds (`seed_files` hash tracking), `--include-roles`, `[db.vault]` secrets including decrypted `encrypted:` values; `--dry-run`; best-effort pg-delta migrations-catalog cache (warning-only on failure). Pipeline-incompatible statements (`CREATE INDEX CONCURRENTLY`, `VACUUM`, …) run standalone outside the batch transaction — from the closed Go PR supabase/cli#5156, also ported into `apps/cli-go` (CLI-1989 ruling). | +| `db reset` | `ported` | `legacy/commands/db/reset/` | `n/a` | `n/a` | Fully native TS port (CLI-1955 removed the last Go delegation on this command — the hidden `db __db-bootstrap` seam's `recreate`/`await-storage` modes no longer exist). Remote path: drop user schemas, vault upsert, MigrateAndSeed, `--version`/`--last`, `--sql-paths` seed override. Local path: running check, then a reset-specific PG14/PG15 recreate composition (`legacy/shared/db-bootstrap/recreate-local-database.ts`) over the same container-bootstrap primitives `db start` uses — container/volume remove-then-recreate (PG15) or a template1 `DROP`/`CREATE DATABASE` sequence + `InitSchema14`/`ApplyApiPrivileges` (PG14) — followed by a concurrent satellite-container restart (storage/auth/realtime/pooler) and a Kong `nginx` reload that FAILS the whole command on error (`legacy/shared/db-bootstrap/restart-services.ts`), then storage-gated bucket seeding (reuses `seed buckets`, native storage-health gate in `await-storage-ready.ts`) and the git-branch `Finished…` line. Only the niche `--experimental` schema-files path with no resolved version still delegates to the Go binary, and only for the REMOTE target (the local target's `--experimental` path is fully native via `legacyMigrateAndSeed`'s existing declarative-schema-files branch). The local-reset composition is hoisted into `legacy/shared/db-bootstrap/reset-local-database.ts`'s `legacyResetLocalDatabase` (CLI-2062); `db schema declarative`'s smart-target and `db schema sync` now call it in-process too, instead of the removed `LegacyDeclarativeSeam.execInherit` seam that used to shell out to a second `supabase-go db reset --local` child. The best-effort pg-delta migrations-catalog cache warmup (`pgcache.TryCacheMigrationsCatalog`, reachable via `SetupLocalDatabase` on the PG15 recreate path) IS ported too, same as `db start`. Pipeline-incompatible statements run standalone outside the batch transaction, same as `db push` (closed Go PR supabase/cli#5156, CLI-1989 ruling). | +| `db start` | `ported` | `legacy/commands/db/start/` | `n/a` | `n/a` | Fully native TS port (CLI-1954 removed the last Go delegation). Validates config, checks "already running" (prints Go's line, native `docker container inspect`), else natively brings up the Postgres container (network/volume/create/start via `legacy/shared/db-bootstrap/`'s shared primitives), waits for health, runs the fresh-volume `SetupLocalDatabase`-equivalent pipeline (including its best-effort pg-delta migrations-catalog warmup), and writes `_current_branch`. `--from-backup` is fully native too: a third entrypoint variant (schema.sql + a ported `restore.sh`, no `webhook.sql`), a `backup volume already exists` guard when the volume isn't fresh, and a swallowed (not failed) health-check timeout. No status table / `cli_stack_started` (those are `supabase start`). | +| `inspect report` | `ported` | `legacy/commands/inspect/report/` | `n/a` | `n/a` | Native TS port. Runs every inspect query via server-side `COPY ... CSV`, writes 14 CSVs under `//`, then renders a Go-parity Glamour rules summary (bounded csvq-subset evaluator; custom `[experimental.inspect.rules]` supported). | +| `inspect db db-stats` | `ported` | `legacy/commands/inspect/db/db-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db replication-slots` | `ported` | `legacy/commands/inspect/db/replication-slots/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db locks` | `ported` | `legacy/commands/inspect/db/locks/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db blocking` | `ported` | `legacy/commands/inspect/db/blocking/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db outliers` | `ported` | `legacy/commands/inspect/db/outliers/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db calls` | `ported` | `legacy/commands/inspect/db/calls/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db index-stats` | `ported` | `legacy/commands/inspect/db/index-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db long-running-queries` | `ported` | `legacy/commands/inspect/db/long-running-queries/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db bloat` | `ported` | `legacy/commands/inspect/db/bloat/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db role-stats` | `ported` | `legacy/commands/inspect/db/role-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db vacuum-stats` | `ported` | `legacy/commands/inspect/db/vacuum-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db table-stats` | `ported` | `legacy/commands/inspect/db/table-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db traffic-profile` | `ported` | `legacy/commands/inspect/db/traffic-profile/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db cache-hit` | `ported` | `legacy/commands/inspect/db/cache-hit/` | `n/a` | `n/a` | Native TS port. Deprecated (use db-stats); routes to the active query. | +| `inspect db index-usage` | `ported` | `legacy/commands/inspect/db/index-usage/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | +| `inspect db total-index-size` | `ported` | `legacy/commands/inspect/db/total-index-size/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | +| `inspect db index-sizes` | `ported` | `legacy/commands/inspect/db/index-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | +| `inspect db table-sizes` | `ported` | `legacy/commands/inspect/db/table-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | +| `inspect db table-index-sizes` | `ported` | `legacy/commands/inspect/db/table-index-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | +| `inspect db total-table-sizes` | `ported` | `legacy/commands/inspect/db/total-table-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | +| `inspect db unused-indexes` | `ported` | `legacy/commands/inspect/db/unused-indexes/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | +| `inspect db table-record-counts` | `ported` | `legacy/commands/inspect/db/table-record-counts/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | +| `inspect db seq-scans` | `ported` | `legacy/commands/inspect/db/seq-scans/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | +| `inspect db role-configs` | `ported` | `legacy/commands/inspect/db/role-configs/` | `n/a` | `n/a` | Native TS port. Deprecated (use role-stats); routes to the active query. | +| `inspect db role-connections` | `ported` | `legacy/commands/inspect/db/role-connections/` | `n/a` | `n/a` | Native TS port. Deprecated (use role-stats); routes to the active query. | +| `migration down` | `ported` | `legacy/commands/migration/down/` | `n/a` | `n/a` | Native TS port. Revert prompt → drop user schemas → vault upsert → migrate&seed to the target version; defaults to `--local`. Skips Go's pgcache catalog write. | +| `migration fetch` | `ported` | `legacy/commands/migration/fetch/` | `n/a` | `n/a` | Native TS port. Reads `schema_migrations` and writes `supabase/migrations/_.sql`; overwrite prompt for a non-empty dir. | +| `migration list` | `ported` | `legacy/commands/migration/list/` | `n/a` | `n/a` | Native TS port. Merges remote `schema_migrations` with local files into a Glamour ASCII table (Local / Remote / Time-UTC columns); defaults to `--linked`. | +| `migration new` | `ported` | `legacy/commands/migration/new/` | `n/a` | `n/a` | Native TS port. Writes `supabase/migrations/_.sql` (mode 0644) from piped stdin; no DB/API. | +| `migration repair` | `ported` | `legacy/commands/migration/repair/` | `n/a` | `n/a` | Native TS port. Transactional create-table + TRUNCATE/UPSERT/DELETE; applied mode reads local files; repair-all prompt; defaults to `--linked`. | +| `migration squash` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | +| `migration up` | `ported` | `legacy/commands/migration/up/` | `n/a` | `n/a` | Native TS port. Computes pending migrations, upserts `[db.vault]`, applies each transactionally (pipeline-incompatible statements run standalone — closed Go PR supabase/cli#5156, ported into `apps/cli-go`, CLI-1989 ruling); `--include-all` for out-of-order; defaults to `--local`. Does not seed (matches Go). | +| `seed buckets` | `ported` | `legacy/commands/seed/buckets/` | `n/a` | `n/a` | Native TS port. Local-only (Go's `seed` defines no `--project-ref`, so the ref is always empty): seeds `[storage.buckets]` + `[storage.vector]` against the local Storage service gateway; remote/analytics paths are unreachable and omitted. `--linked`/`--local` accepted for surface parity (both seed local). Vector graceful-skip WARNINGs ported. | +| `test db` | `ported` | `legacy/shared/legacy-test-db.*` (command definitions in `legacy/commands/test/db/` and its hidden `db test` alias, `legacy/commands/db/test/`) | `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 is honored. `[images]` config override not modeled (documented divergence). | +| `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 @@ -207,13 +207,13 @@ These route-first equivalents are intentionally lower-level than the old Go comm ## Additional Commands -| Old command | TS status | TS command path or `missing` | Missing flags/params | Extra TS flags/params | Notes | -| ----------------------- | --------- | -------------------------------- | --------------------------------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `completion bash` | `ported` | `supabase completion bash` | `-` | `-` | Proxies verbatim to the Go binary so the emitted script is byte-identical to Cobra's output (CLI-1532). `--no-descriptions` added to match cobra's auto-registered flag (CLI-1858). | -| `completion fish` | `ported` | `supabase completion fish` | `-` | `-` | Proxies verbatim to the Go binary so the emitted script is byte-identical to Cobra's output (CLI-1532). `--no-descriptions` added to match cobra's auto-registered flag (CLI-1858). | -| `completion powershell` | `ported` | `supabase completion powershell` | `-` | `-` | Proxies verbatim to the Go binary so the emitted script is byte-identical to Cobra's output (CLI-1532). `--no-descriptions` added to match cobra's auto-registered flag (CLI-1858). | -| `completion zsh` | `ported` | `supabase completion zsh` | `-` | `-` | Proxies verbatim to the Go binary so the emitted script is byte-identical to Cobra's output (CLI-1532). `--no-descriptions` added to match cobra's auto-registered flag (CLI-1858). | -| `help` | `partial` | `supabase --help` | Go-style top-level `help` command shape | `-` | Feature parity exists via the framework-provided global `--help` flag instead of a dedicated `help` command. | +| Old command | TS status | TS command path or `missing` | Missing flags/params | Extra TS flags/params | Notes | +| ----------------------- | --------- | -------------------------------- | --------------------------------------- | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `completion bash` | `ported` | `supabase completion bash` | `-` | `-` | Generates the completion script natively in TS, byte-matching cobra v1.10.2's static template (CLI-1965); `--no-descriptions` selects the no-desc variant. Dynamic `__complete`/`__completeNoDesc` responder is also native (`legacy/cli/legacy-complete.ts`), reflecting over the live TS command tree; documented accepted gaps vs. real cobra (mutually-exclusive flag-group hiding, deprecated-command/flag filtering) live in that file's own doc comment. | +| `completion fish` | `ported` | `supabase completion fish` | `-` | `-` | Generates the completion script natively in TS, byte-matching cobra v1.10.2's static template (CLI-1965); `--no-descriptions` selects the no-desc variant. Dynamic `__complete`/`__completeNoDesc` responder is also native (`legacy/cli/legacy-complete.ts`), reflecting over the live TS command tree; documented accepted gaps vs. real cobra (mutually-exclusive flag-group hiding, deprecated-command/flag filtering) live in that file's own doc comment. | +| `completion powershell` | `ported` | `supabase completion powershell` | `-` | `-` | Generates the completion script natively in TS, byte-matching cobra v1.10.2's static template (CLI-1965); `--no-descriptions` selects the no-desc variant. Dynamic `__complete`/`__completeNoDesc` responder is also native (`legacy/cli/legacy-complete.ts`), reflecting over the live TS command tree; documented accepted gaps vs. real cobra (mutually-exclusive flag-group hiding, deprecated-command/flag filtering) live in that file's own doc comment. | +| `completion zsh` | `ported` | `supabase completion zsh` | `-` | `-` | Generates the completion script natively in TS, byte-matching cobra v1.10.2's static template (CLI-1965); `--no-descriptions` selects the no-desc variant. Dynamic `__complete`/`__completeNoDesc` responder is also native (`legacy/cli/legacy-complete.ts`), reflecting over the live TS command tree; documented accepted gaps vs. real cobra (mutually-exclusive flag-group hiding, deprecated-command/flag filtering) live in that file's own doc comment. | +| `help` | `partial` | `supabase --help` | Go-style top-level `help` command shape | `-` | Feature parity exists via the framework-provided global `--help` flag instead of a dedicated `help` command. | ## Legacy Shell Command Status @@ -304,7 +304,7 @@ Legend: | `migration fetch` | `ported` | [`../src/legacy/commands/migration/fetch/fetch.command.ts`](../src/legacy/commands/migration/fetch/fetch.command.ts) — native; writes history rows to `supabase/migrations/` | | `gen types` | `ported` | [`../src/legacy/commands/gen/types/types.command.ts`](../src/legacy/commands/gen/types/types.command.ts) — sanctioned intentional divergence (CLI-1988 parity ruling): non-TypeScript `--lang` on project-ref paths (`--linked`/`--project-id`/implicit) runs pg-meta locally with project credentials instead of Go's hard error `Unable to generate types for selected project. Try using --db-url flag instead.` (resolves CLI-1623). All Go flag guards are otherwise enforced byte-exactly: the `--postgrest-v9-compat must used together with --db-url` PreRun gate and all four cobra mutually-exclusive flag groups (`cmd/gen.go:153-162`) in cobra's sorted group order. | | `gen signing-key` | `ported` | [`../src/legacy/commands/gen/signing-key/signing-key.command.ts`](../src/legacy/commands/gen/signing-key/signing-key.command.ts) | -| `gen bearer-jwt` | `wrapped` | [`../src/legacy/commands/gen/bearer-jwt/bearer-jwt.command.ts`](../src/legacy/commands/gen/bearer-jwt/bearer-jwt.command.ts) | +| `gen bearer-jwt` | `ported` | [`../src/legacy/commands/gen/bearer-jwt/bearer-jwt.command.ts`](../src/legacy/commands/gen/bearer-jwt/bearer-jwt.command.ts) — native; fully local signing-key resolution (built-in default ES256 dev key, or `[auth].signing_keys_path` with stdin/TTY key selection), no Docker/network (CLI-1961) | | `gen keys` | `wrapped` | [`../src/legacy/commands/gen/keys/keys.command.ts`](../src/legacy/commands/gen/keys/keys.command.ts) | | `functions list` | `ported` | [`../src/legacy/commands/functions/list/list.command.ts`](../src/legacy/commands/functions/list/list.command.ts) | | `functions delete` | `ported` | [`../src/legacy/commands/functions/delete/delete.command.ts`](../src/legacy/commands/functions/delete/delete.command.ts) | diff --git a/apps/cli/src/legacy/cli/complete-passthrough.ts b/apps/cli/src/legacy/cli/complete-passthrough.ts deleted file mode 100644 index 313868bc2c..0000000000 --- a/apps/cli/src/legacy/cli/complete-passthrough.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { spawnSync, type SpawnSyncReturns } from "node:child_process"; -import process from "node:process"; -import { - type BinaryResolution, - formatGoBinaryNotFoundError, - resolveBinary, -} from "../../shared/legacy/go-proxy.layer.ts"; - -export interface CompletePassthroughDeps { - readonly argv: ReadonlyArray; - readonly resolveBinary: () => BinaryResolution; - readonly spawn: (cmd: string, args: ReadonlyArray) => SpawnSyncReturns; - readonly stderrWrite: (message: string) => void; - readonly exit: (code: number) => void; -} - -/** - * Cobra-generated completion scripts (`supabase completion {bash,zsh,fish,powershell}`) - * call back into `supabase __complete ` on every tab press — or - * `supabase __completeNoDesc ` when the script was generated with - * `--no-descriptions` (`__completeNoDesc` is cobra's alias for the same hidden - * command, `ShellCompNoDescRequestCmd` in `spf13/cobra@v1.10.2/completions.go`, - * baked into the generated script at generation time). The args may include - * partial flag tokens (e.g. `--de` while the user is mid-completion of a flag - * name) that Effect's structured parser would reject. Bypass Effect entirely - * for this code path and proxy the raw argv to the bundled Go binary, which is - * the authority on completion behavior for the legacy shell. - * - * Returns `true` when the call was intercepted (caller must not continue), `false` - * otherwise. - */ -export function tryCompletePassthrough(deps: CompletePassthroughDeps): boolean { - if (deps.argv[0] !== "__complete" && deps.argv[0] !== "__completeNoDesc") return false; - - const resolved = deps.resolveBinary(); - if (!("found" in resolved)) { - deps.stderrWrite(`${formatGoBinaryNotFoundError(resolved.notFound)}\n`); - deps.exit(1); - return true; - } - - const result = deps.spawn(resolved.found, deps.argv); - if (result.error) { - deps.stderrWrite(`${result.error.message}\n`); - deps.exit(1); - return true; - } - deps.exit(result.status ?? 1); - return true; -} - -export function defaultCompletePassthroughDeps(): CompletePassthroughDeps { - return { - argv: process.argv.slice(2), - resolveBinary, - spawn: (cmd, args) => spawnSync(cmd, [...args], { stdio: "inherit" }), - stderrWrite: (message) => { - process.stderr.write(message); - }, - exit: (code) => { - process.exit(code); - }, - }; -} diff --git a/apps/cli/src/legacy/cli/complete-passthrough.unit.test.ts b/apps/cli/src/legacy/cli/complete-passthrough.unit.test.ts deleted file mode 100644 index 09f578a801..0000000000 --- a/apps/cli/src/legacy/cli/complete-passthrough.unit.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import type { SpawnSyncReturns } from "node:child_process"; -import { describe, expect, it, vi } from "vitest"; -import { type BinaryResolution } from "../../shared/legacy/go-proxy.layer.ts"; -import { type CompletePassthroughDeps, tryCompletePassthrough } from "./complete-passthrough.ts"; - -function spawnResult(status: number | null, error?: Error): SpawnSyncReturns { - return { - pid: 1, - output: [], - stdout: Buffer.alloc(0), - stderr: Buffer.alloc(0), - status, - signal: null, - error, - }; -} - -function makeDeps(overrides: Partial = {}): { - deps: CompletePassthroughDeps; - spawnCalls: Array<{ cmd: string; args: ReadonlyArray }>; - stderr: Array; - exits: Array; -} { - const spawnCalls: Array<{ cmd: string; args: ReadonlyArray }> = []; - const stderr: Array = []; - const exits: Array = []; - const deps: CompletePassthroughDeps = { - argv: ["__complete", "migration", "li"], - resolveBinary: (): BinaryResolution => ({ found: "/path/to/supabase-go" }), - spawn: (cmd, args) => { - spawnCalls.push({ cmd, args }); - return spawnResult(0); - }, - stderrWrite: (msg) => { - stderr.push(msg); - }, - exit: (code) => { - exits.push(code); - }, - ...overrides, - }; - return { deps, spawnCalls, stderr, exits }; -} - -describe("tryCompletePassthrough", () => { - it("returns false and does nothing when first argv is not __complete", () => { - const { deps, spawnCalls, exits } = makeDeps({ argv: ["migration", "list"] }); - expect(tryCompletePassthrough(deps)).toBe(false); - expect(spawnCalls).toEqual([]); - expect(exits).toEqual([]); - }); - - it("returns false on empty argv (e.g. bare `supabase`)", () => { - const { deps, spawnCalls, exits } = makeDeps({ argv: [] }); - expect(tryCompletePassthrough(deps)).toBe(false); - expect(spawnCalls).toEqual([]); - expect(exits).toEqual([]); - }); - - it("forwards verbatim argv (including flag-like tokens) to the Go binary on __complete", () => { - const { deps, spawnCalls, exits } = makeDeps({ - argv: ["__complete", "--debug", "migration", "--de"], - }); - expect(tryCompletePassthrough(deps)).toBe(true); - expect(spawnCalls).toEqual([ - { cmd: "/path/to/supabase-go", args: ["__complete", "--debug", "migration", "--de"] }, - ]); - expect(exits).toEqual([0]); - }); - - it("forwards verbatim argv to the Go binary on __completeNoDesc (scripts generated with --no-descriptions)", () => { - const { deps, spawnCalls, exits } = makeDeps({ - argv: ["__completeNoDesc", "migration", "li"], - }); - expect(tryCompletePassthrough(deps)).toBe(true); - expect(spawnCalls).toEqual([ - { cmd: "/path/to/supabase-go", args: ["__completeNoDesc", "migration", "li"] }, - ]); - expect(exits).toEqual([0]); - }); - - it("propagates the child's non-zero exit code", () => { - const spawn = vi.fn(() => spawnResult(7)); - const { deps, exits } = makeDeps({ spawn }); - tryCompletePassthrough(deps); - expect(exits).toEqual([7]); - }); - - it("exits 1 when the child has a null status (e.g. signal-terminated)", () => { - const spawn = vi.fn(() => spawnResult(null)); - const { deps, exits } = makeDeps({ spawn }); - tryCompletePassthrough(deps); - expect(exits).toEqual([1]); - }); - - it("prints the diagnostic and exits 1 when the Go binary cannot be resolved", () => { - const { deps, spawnCalls, stderr, exits } = makeDeps({ - resolveBinary: () => ({ notFound: ["a", "b"] }), - }); - tryCompletePassthrough(deps); - expect(spawnCalls).toEqual([]); - expect(exits).toEqual([1]); - expect(stderr).toHaveLength(1); - expect(stderr[0]).toContain("Could not find the `supabase-go` binary."); - }); - - it("prints the spawn error and exits 1 when spawnSync surfaces an error", () => { - const spawn = vi.fn(() => spawnResult(0, new Error("ENOENT"))); - const { deps, stderr, exits } = makeDeps({ spawn }); - tryCompletePassthrough(deps); - expect(exits).toEqual([1]); - expect(stderr).toEqual(["ENOENT\n"]); - }); -}); diff --git a/apps/cli/src/legacy/cli/legacy-complete.e2e.test.ts b/apps/cli/src/legacy/cli/legacy-complete.e2e.test.ts new file mode 100644 index 0000000000..bbf56fe885 --- /dev/null +++ b/apps/cli/src/legacy/cli/legacy-complete.e2e.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from "vitest"; +import { runSupabase } from "../../../tests/helpers/cli.ts"; + +const E2E_TIMEOUT_MS = 30_000; + +describe("supabase __complete (legacy)", () => { + test( + "migration li completes to list with a description and the NoFileComp directive", + { timeout: E2E_TIMEOUT_MS }, + async () => { + const { exitCode, stdout } = await runSupabase(["__complete", "migration", "li"], { + entrypoint: "legacy", + }); + expect(exitCode).toBe(0); + const lines = stdout.trim().split("\n"); + expect(lines[0]).toBe("list\tList local and remote migrations"); + expect(lines.at(-1)).toBe(":4"); + }, + ); + + test( + "__completeNoDesc strips the description from the same candidate", + { timeout: E2E_TIMEOUT_MS }, + async () => { + const { exitCode, stdout } = await runSupabase(["__completeNoDesc", "migration", "li"], { + entrypoint: "legacy", + }); + expect(exitCode).toBe(0); + const lines = stdout.trim().split("\n"); + expect(lines[0]).toBe("list"); + expect(lines.at(-1)).toBe(":4"); + }, + ); + + test("root-level flag-name completion offers --debug", { timeout: E2E_TIMEOUT_MS }, async () => { + const { exitCode, stdout } = await runSupabase(["__complete", "--d"], { + entrypoint: "legacy", + }); + expect(exitCode).toBe(0); + expect(stdout).toContain("--debug\toutput debug logs to stderr"); + }); +}); diff --git a/apps/cli/src/legacy/cli/legacy-complete.integration.test.ts b/apps/cli/src/legacy/cli/legacy-complete.integration.test.ts new file mode 100644 index 0000000000..8e2bbec0a3 --- /dev/null +++ b/apps/cli/src/legacy/cli/legacy-complete.integration.test.ts @@ -0,0 +1,117 @@ +import { Effect, Layer } from "effect"; +import { describe, expect, it } from "vitest"; +import { CurrentAnalyticsContext } from "../../shared/telemetry/analytics-context.ts"; +import { Analytics } from "../../shared/telemetry/analytics.service.ts"; +import { EventCommandExecuted, PropExitCode } from "../../shared/telemetry/event-catalog.ts"; +import { + legacyCaptureCompleteTelemetryEffect, + legacyTryComplete, + type LegacyCompleteDeps, +} from "./legacy-complete.ts"; +import { legacyRoot } from "./root.ts"; + +// `mockAnalytics()` (`tests/helpers/mocks.ts`, the double `bash.integration.test.ts` +// uses for the same `cli_command_executed` assertion on the static completion +// leaves) records only the direct `capture(event, properties)` arguments — it +// never reads `CurrentAnalyticsContext`, so it can't see the `command` value +// `withAnalyticsContext` attaches. This local double mirrors the REAL +// `legacyAnalyticsLayer`'s own capture implementation just enough to merge +// that context in, so this file can assert on `command` the same way the +// review finding (CLI-1965) requires. +function mockAnalyticsWithContext() { + const captured: Array<{ + event: string; + properties: Record; + command: string | undefined; + }> = []; + return { + layer: Layer.succeed( + Analytics, + Analytics.of({ + capture: (event, properties = {}) => + Effect.gen(function* () { + const context = yield* CurrentAnalyticsContext; + captured.push({ event, properties, command: context.command }); + }), + identify: () => Effect.void, + alias: () => Effect.void, + groupIdentify: () => Effect.void, + }), + ), + captured, + }; +} + +function makeCaptureTelemetry( + analyticsLayer: Layer.Layer, +): LegacyCompleteDeps["captureTelemetry"] { + return (exitCode, durationMs) => + Effect.runPromise( + legacyCaptureCompleteTelemetryEffect(exitCode, durationMs).pipe( + Effect.provide(analyticsLayer), + ), + ); +} + +function makeDeps( + argv: ReadonlyArray, + captureTelemetry: LegacyCompleteDeps["captureTelemetry"], +) { + const stdoutWrites: Array = []; + const exits: Array = []; + const deps: LegacyCompleteDeps = { + root: legacyRoot, + argv, + env: {}, + stdoutWrite: (message) => { + stdoutWrites.push(message); + }, + exit: (code) => { + exits.push(code); + }, + captureTelemetry, + }; + return { deps, stdoutWrites, exits }; +} + +describe("legacy __complete telemetry (CLI-1965 review finding)", () => { + it("fires cli_command_executed with command: __complete and exit_code: 0 for a normal completion request", async () => { + const analytics = mockAnalyticsWithContext(); + const { deps } = makeDeps( + ["__complete", "migration", "li"], + makeCaptureTelemetry(analytics.layer), + ); + + expect(await legacyTryComplete(deps)).toBe(true); + + const event = analytics.captured.find((entry) => entry.event === EventCommandExecuted); + expect(event).toBeDefined(); + expect(event?.command).toBe("__complete"); + expect(event?.properties[PropExitCode]).toBe(0); + }); + + it("records exit_code: 1 for an unresolvable completion request (zero completion args)", async () => { + const analytics = mockAnalyticsWithContext(); + const { deps } = makeDeps(["__complete"], makeCaptureTelemetry(analytics.layer)); + + expect(await legacyTryComplete(deps)).toBe(true); + + const event = analytics.captured.find((entry) => entry.event === EventCommandExecuted); + expect(event).toBeDefined(); + expect(event?.properties[PropExitCode]).toBe(1); + }); + + it("records command: __complete — never __completeNoDesc — when invoked via the no-descriptions alias", async () => { + const analytics = mockAnalyticsWithContext(); + const { deps } = makeDeps( + ["__completeNoDesc", "migration", "li"], + makeCaptureTelemetry(analytics.layer), + ); + + await legacyTryComplete(deps); + + const event = analytics.captured.find((entry) => entry.event === EventCommandExecuted); + expect(event?.command).toBe("__complete"); + expect(event?.command).not.toBe("__completeNoDesc"); + }); +}); diff --git a/apps/cli/src/legacy/cli/legacy-complete.ts b/apps/cli/src/legacy/cli/legacy-complete.ts new file mode 100644 index 0000000000..431e9c472b --- /dev/null +++ b/apps/cli/src/legacy/cli/legacy-complete.ts @@ -0,0 +1,1828 @@ +import { BunServices } from "@effect/platform-bun"; +import { Effect, Layer, Option } from "effect"; +import { GlobalFlag } from "effect/unstable/cli"; +import type { Command, Param, Primitive } from "effect/unstable/cli"; +import process from "node:process"; +import { + LEGACY_QUERY_OUTPUT_FORMATS, + LEGACY_RESOURCE_OUTPUT_FORMATS, +} from "../shared/legacy-go-output-flag.ts"; +import { legacyUnwrapParam } from "../shared/legacy-param-introspection.ts"; +import { legacyIsValidBase0Int64, legacyParseUintBase0 } from "../shared/legacy-parse-uint.ts"; +import { legacyParseStringSliceFlag } from "../shared/legacy-string-slice-flag.ts"; +import { withAnalyticsContext } from "../../shared/telemetry/analytics-context.ts"; +import { Analytics } from "../../shared/telemetry/analytics.service.ts"; +import { + EventCommandExecuted, + PropDurationMs, + PropExitCode, + PropOutputFormat, +} from "../../shared/telemetry/event-catalog.ts"; +import { standaloneAnalyticsConfigLayer } from "../../shared/telemetry/standalone-analytics-config.layer.ts"; +import { legacyAnalyticsLayer } from "../telemetry/legacy-analytics.layer.ts"; + +/** + * Native TypeScript reimplementation of cobra's dynamic-completion protocol + * (`spf13/cobra@v1.10.2/completions.go`), replacing the old Go-binary + * passthrough (`complete-passthrough.ts`, deleted by CLI-1965). Cobra-generated + * completion scripts (`supabase completion {bash,zsh,fish,powershell}`) call + * back into `supabase __complete ` on every tab press — or + * `supabase __completeNoDesc ` when the script was generated with + * `--no-descriptions` (cobra's alias for the same hidden command). This module + * bypasses Effect's structured argv parser entirely for that path (the args may + * include partial/malformed flag tokens, e.g. `--de` mid-completion, that the + * parser would reject) and instead reflects directly over `legacyRoot` — the + * live Effect CLI command tree — to compute candidates. + * + * Deliberate, documented simplifications relative to real cobra (verified + * empirically against a real `apps/cli-go` build during CLI-1965 review — see + * that PR for the differential-testing detail): + * - No `--help`-style multi-paragraph usage error for zero completion args + * (`MinimumNArgs(1)` failure) — real generated shell scripts always pass at + * least one arg, so this path is realistically unreachable by real + * completion traffic. + * - No "Completion ended with directive: ..." trailer or `[Debug] [Error] ...` + * diagnostics — both are cobra-side stderr-only text every real generated + * completion script discards (`2>/dev/null` or equivalent), so reproducing + * them has zero observable effect on any user. + * - Mutually-exclusive flag-group hiding (cobra's `enforceFlagGroupsForCompletion`, + * ~45 `MarkFlagsMutuallyExclusive` call sites in `apps/cli-go/cmd/`) is not + * reproduced — there is no equivalent flag-group annotation anywhere in this + * TS tree to mirror, and hand-building a ~45-entry shadow table carries a + * materially higher transcription-error risk than the small, stable tables + * below. Accepted as a documented gap. + * - Deprecated commands/flags (cobra's `IsAvailableCommand()`/`MarkDeprecated`) + * are not filtered out of candidates — this TS tree has no "deprecated" + * concept distinct from `hidden` today (deprecation is only reflected in + * description text), so filtering it out here would require tree-level + * metadata this port doesn't own. Accepted as a documented gap, expected to + * shrink as the tree's own deprecated-alias cleanup lands separately. + */ + +/* ========================================================================== */ +/* Types */ +/* ========================================================================== */ + +export interface LegacyCompletionCandidate { + readonly name: string; + readonly description: string | undefined; +} + +export interface LegacyCompletionResult { + readonly candidates: ReadonlyArray; + readonly directive: number; +} + +/** + * The subset of cobra's `ShellCompDirective` bit flags this port ever emits. + */ +export const LegacyCompletionDirective = { + Default: 0, + NoFileComp: 4, + FilterFileExt: 8, +} as const; + +export interface LegacyFlagDescriptor { + readonly name: string; + readonly aliases: ReadonlyArray; + readonly hidden: boolean; + readonly description: string | undefined; + readonly isVariadic: boolean; + readonly isBoolean: boolean; + /** `Param.Single`'s underlying `Primitive._tag` (`"Boolean"`, `"Choice"`, `"Integer"`, ...). */ + readonly primitiveTag: string; + /** The valid value set for a `primitiveTag === "Choice"` flag; `undefined` for every other tag. */ + readonly choiceKeys: ReadonlyArray | undefined; +} + +export interface LegacyCommandPathResolution { + readonly commandChain: ReadonlyArray; + readonly matchedPath: ReadonlyArray; + readonly leftoverArgs: ReadonlyArray; +} + +export interface LegacyClassifyCompletionInput { + readonly finalCommand: Command.Command.Any; + readonly matchedPath: ReadonlyArray; + readonly leftoverArgs: ReadonlyArray; + readonly trimmedArgs: ReadonlyArray; + readonly toComplete: string; + readonly inScopeFlags: ReadonlyArray; +} + +export interface LegacyCompleteDeps { + readonly root: Command.Command.Any; + readonly argv: ReadonlyArray; + readonly env: Readonly>; + readonly stdoutWrite: (message: string) => void; + readonly exit: (code: number) => void; + /** + * Fires the `cli_command_executed` telemetry capture for this request — + * see `legacyCaptureCompleteTelemetryEffect`'s doc comment for what it + * records and why. Injected the same way `stdoutWrite`/`exit` already are, + * so tests can run `legacyCaptureCompleteTelemetryEffect` against a mocked + * `Analytics` boundary without spawning a real subprocess or touching the + * real, consent-gated production layer `legacyDefaultCompleteDeps` wires by + * default — see `legacy-complete.integration.test.ts`. + */ + readonly captureTelemetry: (exitCode: number, durationMs: number) => Promise; +} + +/* ========================================================================== */ +/* Internal command field access (`legacy-param-introspection.ts` precedent) */ +/* ========================================================================== */ + +/** + * `.config.flags` (a command's own declared flags), `.contextConfig.flags` + * (flags inherited via `Command.withSharedFlags`), and `.globalFlags` (a + * command's own declared global flags) are genuinely absent from the public + * `Command`/`Command.Any` TypeScript interface — only `name`, `description`, + * `shortDescription`, `alias`, `examples`, `subcommands`, `annotations`, and + * `hidden` are public — but they exist at runtime (`internal/command.ts`'s + * `makeCommand`, via `Object.assign`; that internal module is not importable + * — its package.json export map entry is `null` — so there is no type-safe + * import to reach for instead). + * + * A bare `as unknown as` here would silently paper over that gap (forbidden + * by this repo's typing rules — see `CLAUDE.md`), so this narrows through a + * runtime type guard instead, the same `"" in value` shape + * `legacy-param-introspection.ts`'s `legacyIsWrappedParam` already + * establishes for the identical problem (an internal-only field the public + * `effect/unstable/cli` types don't declare). If a future `effect` version + * ever drops one of these fields, this throws instead of silently completing + * against `undefined`. + */ +interface LegacyCommandInternal { + readonly config: { readonly flags: ReadonlyArray }; + readonly contextConfig: { readonly flags: ReadonlyArray }; + readonly globalFlags: ReadonlyArray>; +} + +function legacyHasCommandInternals( + command: Command.Command.Any, +): command is Command.Command.Any & LegacyCommandInternal { + return "config" in command && "contextConfig" in command && "globalFlags" in command; +} + +function legacyInternalCommand(command: Command.Command.Any): LegacyCommandInternal { + if (!legacyHasCommandInternals(command)) { + throw new Error( + `legacy-complete.ts: command "${command.name}" is missing the internal config/contextConfig/globalFlags fields shell completion relies on — effect's Command implementation shape may have changed.`, + ); + } + return command; +} + +function legacyFlattenSubcommands( + command: Command.Command.Any, +): ReadonlyArray { + return command.subcommands.flatMap((group) => group.commands); +} + +/* ========================================================================== */ +/* Flag descriptors */ +/* ========================================================================== */ + +/** + * `Flag.choice`/`Flag.choiceWithValue`'s `choiceKeys` (the valid value set) is + * attached to the `Choice`-tagged `Primitive` via `Object.assign` at + * runtime (`Primitive.choice`, + * `.repos/effect/packages/effect/src/unstable/cli/Primitive.ts`) but carries + * an `@internal` JSDoc tag and is absent from the public `Primitive` + * interface — the identical gap `LegacyCommandInternal` above already works + * around for `Command`, so this reuses the same runtime type-guard idiom + * instead of an `as` cast. + */ +interface LegacyChoicePrimitive { + readonly choiceKeys: ReadonlyArray; +} + +function legacyHasChoiceKeys( + primitive: Primitive.Primitive, +): primitive is Primitive.Primitive & LegacyChoicePrimitive { + return "choiceKeys" in primitive; +} + +function legacyChoiceKeysOf( + primitive: Primitive.Primitive, +): ReadonlyArray | undefined { + return legacyHasChoiceKeys(primitive) ? primitive.choiceKeys : undefined; +} + +function legacyFlagDescriptorFromParam(param: Param.AnyFlag): LegacyFlagDescriptor | undefined { + const unwrapped = legacyUnwrapParam(param); + if (unwrapped === undefined) return undefined; + const { single, isVariadic } = unwrapped; + return { + name: single.name, + aliases: single.aliases, + hidden: single.hidden, + description: Option.getOrUndefined(single.description), + isVariadic, + isBoolean: single.primitiveType._tag === "Boolean", + primitiveTag: single.primitiveType._tag, + choiceKeys: legacyChoiceKeysOf(single.primitiveType), + }; +} + +/** + * The full in-scope flag list for `commandChain`'s last element (the resolved + * command), ordered and grouped the way cobra's own completion path emits + * flag-name candidates: `InheritedFlags().VisitAll` (every ancestor's global + * and shared flags, as ONE pflag-alphabetically-sorted block), followed by + * `NonInheritedFlags().VisitAll` (the resolved command's own global flags, + * its own `--help`, root's own `--version`, and its own local flags, as a + * SECOND, separately-sorted block) — pflag's `FlagSet.VisitAll` walks + * `sortedFormalFlags`, which sorts strictly by each flag's canonical long + * name (verified empirically against a real `apps/cli-go` build: `db dump -` + * lists `--agent`, `--create-ticket`, `--debug`, ... alphabetically, THEN a + * second alphabetical run starting `--data-only`, `--db-url`, `--dry-run`, + * ... — not one merged alphabetical list and not this tree's own declaration + * order — CLI-1965 review finding). + * + * The resolved command's own local flags win on a canonical-name collision — + * e.g. a command's own local `--output` (`db diff`'s file-path flag) must + * shadow the global `--output` choice flag declared at root — by being + * excluded from the inherited block entirely, mirroring pflag's + * `InheritedFlags()`, which skips any persistent flag shadowed by a + * same-named local one (rather than being present in both and "last write + * wins": a `Map`'s insertion-order position does not move on a same-key + * `.set()`, so a naive later-overwrite would leave the shadowed entry sitting + * in the wrong (inherited) sort position instead of removing it). + */ +export function legacyCollectInScopeFlags( + root: Command.Command.Any, + commandChain: ReadonlyArray, +): ReadonlyArray { + const finalCommand = commandChain[commandChain.length - 1] ?? root; + const ancestors = commandChain.slice(0, -1); + + // `GlobalFlag.Completions`/`GlobalFlag.LogLevel` are TS-only framework + // additions with no Go/cobra equivalent. They are normally only injected + // via `GlobalFlag.BuiltIns` at parse time (never stored on a command's own + // `.globalFlags`), so this filter is a defensive guard rather than + // something that changes today's output — kept explicit so it stays true + // if that ever changes. + const globalFlagParamsOf = (command: Command.Command.Any): ReadonlyArray => + legacyInternalCommand(command) + .globalFlags.filter( + (entry) => entry !== GlobalFlag.Completions && entry !== GlobalFlag.LogLevel, + ) + .map((entry) => entry.flag); + + const inheritedParams: Array = [ + ...ancestors.flatMap(globalFlagParamsOf), + ...ancestors.flatMap((ancestor) => legacyInternalCommand(ancestor).contextConfig.flags), + ]; + const ownParams: Array = [ + ...globalFlagParamsOf(finalCommand), + GlobalFlag.Help.flag, + // Cobra's `InitDefaultVersionFlag` only registers `--version`, and only on + // the root command (gated on `c.Version != ""`, and non-persistent) — it + // is never inherited by subcommands the way `--help` is. + ...(commandChain.length === 1 ? [GlobalFlag.Version.flag] : []), + ...legacyInternalCommand(finalCommand).config.flags, + ]; + + const descriptorsOf = ( + params: ReadonlyArray, + ): ReadonlyArray => { + const byName = new Map(); + for (const param of params) { + const descriptor = legacyFlagDescriptorFromParam(param); + if (descriptor !== undefined) byName.set(descriptor.name, descriptor); + } + return Array.from(byName.values()).sort((a, b) => a.name.localeCompare(b.name)); + }; + + const own = descriptorsOf(ownParams); + const ownNames = new Set(own.map((descriptor) => descriptor.name)); + const inherited = descriptorsOf(inheritedParams).filter( + (descriptor) => !ownNames.has(descriptor.name), + ); + + return [...inherited, ...own]; +} + +/* ========================================================================== */ +/* Flag-token resolution */ +/* ========================================================================== */ + +/** + * Resolves a bare flag token (`--project-ref`, `-p`, or a shorthand cluster + * like `-po`, where cobra's rule is "the character immediately before the + * value/`=`", i.e. the last character) to its owning in-scope flag. Mirrors + * cobra's `checkIfFlagCompletion` heuristic (`completions.go:676-681,702-707`, + * the documented `-asd` => `d` quirk from cobra issue #1257) for guessing + * which flag the CURRENT or immediately PRECEDING token is mid-way through + * value-completing — deliberately NOT the same algorithm as + * `legacyResolveShortFlagCluster`, which mirrors the real, strict + * `ParseFlags()` parser instead (first character owns the value, not last). + * See that function's doc comment for why the two differ and where each is + * used. + */ +function legacyResolveFlagFromToken( + token: string, + inScopeFlags: ReadonlyArray, +): LegacyFlagDescriptor | undefined { + if (token.startsWith("--")) { + const name = token.slice(2); + return inScopeFlags.find((flag) => flag.name === name); + } + if (token.startsWith("-") && token.length > 1) { + const shorthand = token.charAt(token.length - 1); + return inScopeFlags.find((flag) => flag.aliases.includes(shorthand)); + } + return undefined; +} + +/* ========================================================================== */ +/* Command-path resolution */ +/* ========================================================================== */ + +/** + * Descends from `root` through `trimmedArgs`, matching each non-flag token + * against the current command's subcommand names/aliases (exact, + * case-sensitive — no prefix or fuzzy matching). Mirrors cobra's `Find()`, + * which strips flags before matching positional command names + * (`completions.go:340`) via its own heuristic `stripFlags` + * (`pflag@v1.0.9/flag.go`) — a cruder, command-tree-only pre-pass distinct + * from the real flag parser `legacyChangedFlagNames` mirrors: + * + * - A long flag (`--foo`) or a single-character short flag (`-f`) with no + * embedded `=` consumes the following token as its value UNLESS it's + * already known at this point in the descent to be boolean — this + * includes flags not yet in scope, e.g. a subcommand's own local flag + * typed before that subcommand is reached (`--db-url`, local to `db + * dump`, typed before `db`): `stripFlags`'s `hasNoOptDefVal` returns + * `false` for a name it can't find yet, so `!hasNoOptDefVal(...)` is + * `true` and it optimistically consumes a value anyway (verified + * empirically against a real `apps/cli-go` build: `__complete --db-url + * postgres:// db dump --s` still offers `db dump`'s `--schema`, which + * requires descending past `--db-url postgres://` to reach `db dump` at + * all). + * - Anything else flag-shaped — a multi-character shorthand cluster + * (`-rj`), a flag containing `=`, or a bare `--` — is skipped without + * consuming a value. A bare `--` additionally stops the descent + * entirely: it's pflag's end-of-flags sentinel, so no token at or after + * it can ever match a subcommand (verified empirically: `__complete -- + * db ""` returns zero candidates with the Default directive, not `db`'s + * subcommands). + * - A bare `-` is NOT flag-shaped at all — pflag's own `isFlagArg` + * (`command.go:750-753`) requires at least 2 characters, so cobra's + * `stripFlags` (`command.go:674-706`) silently drops it from its + * subcommand-name scan (it matches none of that function's `switch` + * cases) without either consuming a value OR stopping the descent, and — + * critically — WITHOUT removing it from the leftover args the way a + * matched command name is (`argsMinusFirstX` only ever strips the exact + * matched name). It therefore must stay in `leftoverArgs` here too, while + * still letting the descent continue past it (verified empirically + * against a real `apps/cli-go` build: `db - dump --da` still descends + * past the bare `-` into `dump` and offers `--data-only`, while `sso - + * --debug a` returns zero candidates with the Default directive — the + * surviving `-` keeps the `len(finalArgs) == 0` subcommand-listing gate + * below closed — CLI-1965 review finding). + * + * Descent stops at the first non-flag token that doesn't match a subcommand, + * or at a `--` sentinel; that token and everything after it becomes + * `leftoverArgs` — the *positional* leftover cobra's `finalArgs` represents + * (`completions.go:397-399`), used to gate subcommand-name completion + * (`len(finalArgs) == 0`). Flag tokens and their consumed values are never + * part of `leftoverArgs`; a bare `-` is the one exception, per above. + */ +export function legacyResolveCommandPath( + root: Command.Command.Any, + trimmedArgs: ReadonlyArray, +): LegacyCommandPathResolution { + const commandChain: Array = [root]; + const matchedPath: Array = []; + const consumedIndices = new Set(); + + let current = root; + let index = 0; + while (index < trimmedArgs.length) { + const token = trimmedArgs[index]; + if (token === undefined) { + index++; + continue; + } + + if (token === "--") break; // pflag's end-of-flags sentinel: nothing at or after this can match a subcommand. + + if (token === "-") { + // Not flag-shaped (pflag's `isFlagArg` requires length >= 2) and never + // a real subcommand name — skip it without consuming a value, without + // breaking the descent, and WITHOUT marking it consumed, so it survives + // into `leftoverArgs` exactly like real cobra's `finalArgs` does. See + // this function's doc comment for the empirical verification. + index++; + continue; + } + + if (token.startsWith("-")) { + consumedIndices.add(index); + const isLong = token.startsWith("--"); + const isSingleCharShort = !isLong && token.length === 2; + if (!token.includes("=") && (isLong || isSingleCharShort)) { + // The flags visible at this point of the descent are enough to tell + // whether this token consumes the next one as its value. + const inScopeSoFar = legacyCollectInScopeFlags(root, commandChain); + const resolved = legacyResolveFlagFromToken(token, inScopeSoFar); + // An unrecognized flag is optimistically assumed to take a value too + // (see the doc comment above) — only a flag already known here to be + // boolean is exempt. + const takesValue = resolved === undefined || !resolved.isBoolean; + if (takesValue && index + 1 < trimmedArgs.length) { + consumedIndices.add(index + 1); + index += 2; + continue; + } + } + index++; + continue; + } + + const match = legacyFlattenSubcommands(current).find( + (candidate) => candidate.name === token || candidate.alias === token, + ); + if (match === undefined) break; // stop descending; this and later tokens are leftover + current = match; + commandChain.push(match); + matchedPath.push(match.name); + consumedIndices.add(index); + index++; + } + + const leftoverArgs = trimmedArgs.filter((_, i) => !consumedIndices.has(i)); + return { commandChain, matchedPath, leftoverArgs }; +} + +/* ========================================================================== */ +/* Classification */ +/* ========================================================================== */ + +/** + * Mirrors cobra's `MarkFlagFilename` calls in `apps/cli-go/cmd/sso.go:166,167,181,182` + * — 4 individually hardcoded lines in Go, not derived from anything generic, + * so a small matching lookup table here is the right level of fidelity. Key = + * `:`. + */ +const LEGACY_COMPLETION_FLAG_FILE_EXTENSIONS: ReadonlyMap> = new Map([ + ["sso add:metadata-file", ["xml"]], + ["sso add:attribute-mapping-file", ["json"]], + ["sso update:metadata-file", ["xml"]], + ["sso update:attribute-mapping-file", ["json"]], +]); + +/** + * Mirrors cobra's unconditional, `init()`-time `MarkFlagRequired` calls — the + * ONLY ones active during `__complete`/`__completeNoDesc`, since cobra's + * `getCompletions` never runs `PreRun`/`PersistentPreRunE`/`RunE` + * (`completions.go` never calls `Execute()`). Several more `MarkFlagRequired` + * calls exist in `apps/cli-go/cmd/` but are scoped inside those hooks + * (conditional on other flags or TTY state) and therefore never apply to a + * real completion request — deliberately excluded here: `db dump:data-only` + * (`cmd/db.go:140`, inside `PreRun`), `init:experimental` (`cmd/init.go:34`, + * inside `PreRun`), `projects create:{org-id,db-password,region}` + * (`cmd/projects.go:64-66`, inside `PreRunE`), `link:project-ref` + * (`cmd/link.go:25`, inside `PreRunE`). + * + * Deliberately a hardcoded table, not derived from whether the TS flag is + * `Flag.optional`-wrapped: several of these TS flags are intentionally + * `Flag.optional` at parse time for validation-ordering reasons unrelated to + * completion (e.g. `vanity-subdomains activate --desired-subdomain` — see + * that command's own file comment), so "is this flag `Optional`-wrapped in + * TS" is not a faithful proxy for "does cobra mark it required." Key = + * `:`. + */ +const LEGACY_COMPLETION_REQUIRED_FLAGS: ReadonlySet = new Set([ + "domains create:custom-hostname", // cmd/domains.go:100 + "migration repair:status", // cmd/migration.go:122 + "gen bearer-jwt:role", // cmd/gen.go:175 + "sso add:type", // cmd/sso.go:165 + "vanity-subdomains activate:desired-subdomain", // cmd/vanitySubdomains.go:67 + "vanity-subdomains check-availability:desired-subdomain", // cmd/vanitySubdomains.go:69 +]); + +function legacyIsRequiredCompletionFlag( + matchedPath: ReadonlyArray, + flagName: string, +): boolean { + return LEGACY_COMPLETION_REQUIRED_FLAGS.has(`${matchedPath.join(" ")}:${flagName}`); +} + +/** + * Mirrors cobra's `InitDefaultCompletionCmd` (`completions.go:769-928`), + * which registers `ValidArgsFunction: NoFileCompletions` on the `completion` + * group command itself and each of its `bash`/`zsh`/`fish`/`powershell` + * leaves — the only `ValidArgsFunction`/`ValidArgs` usage anywhere relevant + * to this tree (`apps/cli-go/cmd/`, `apps/cli-go/internal/` register none of + * their own). `getCompletions` always calls a resolved command's own + * `ValidArgsFunction` when one is registered, and that call OVERWRITES the + * directive outright (`completions.go:564-579`) — for a leaf like + * `completion bash`, which has no subcommands of its own to otherwise set + * NoFileComp, this is the ONLY thing that sets it (verified empirically + * against a real `apps/cli-go` build: `completion bash ""` returns the + * NoFileComp directive with zero candidates, not Default — CLI-1965 review + * finding). Key = space-joined `matchedPath` (excluding "supabase"). + */ +const LEGACY_COMPLETION_NO_FILE_COMP_PATHS: ReadonlySet = new Set([ + "completion", + "completion bash", + "completion zsh", + "completion fish", + "completion powershell", +]); + +function legacyFlagNameCandidates( + flag: LegacyFlagDescriptor, + toComplete: string, +): ReadonlyArray { + const candidates: Array = []; + const long = `--${flag.name}`; + if (long.startsWith(toComplete)) candidates.push({ name: long, description: flag.description }); + for (const alias of flag.aliases) { + if (alias.length !== 1) continue; + const short = `-${alias}`; + if (short.startsWith(toComplete)) + candidates.push({ name: short, description: flag.description }); + } + return candidates; +} + +/** + * A lightweight, string-only approximation of "which in-scope flags have + * already been provided" (not a real flag parser, but close enough to mirror + * pflag's actual `Set`-time behavior for the shapes real completion input + * takes) — correct for the overwhelming majority of real completion inputs. + * + * Stops at a bare `--` the same way `legacyFindUnresolvedFlagToken` and + * `legacyResolveCommandPath` do — pflag's end-of-flags sentinel means + * nothing at or after it is ever parsed as a flag, so nothing past it can be + * "changed" (verified empirically against a real `apps/cli-go` build: `sso + * add -- --type --typ` still offers `--type`, since that token is + * positional, past the terminator, and never reaches pflag's flag parser at + * all — CLI-1965 review finding). + * + * A LONG flag with no `=` that resolves to a non-boolean in-scope flag + * consumes the immediately following token as its value — that token is + * skipped here entirely, exactly like pflag's `parseLongArg` + * (`pflag@v1.0.10/flag.go:1013-1023`), so a value that happens to look like a + * flag (e.g. `--domains --type foo`, where `--type` is `--domains`'s value) + * is never itself marked changed (CLI-1965 review finding, verified + * empirically against a real `apps/cli-go` build). + * + * A short-flag token walks its shorthand cluster exactly like + * `pflag@v1.0.10`'s `parseSingleShortArg`: each character that resolves to a + * boolean (`NoOptDefVal != ""`) flag is marked changed and the walk continues + * to the next character in the SAME token; the first non-boolean character + * (or a `=value` suffix) is also marked changed but ends the walk there, + * since the rest of the token (or the next arg) is that flag's value, not + * another shorthand (verified empirically against a real `apps/cli-go` + * build: after `storage cp -rj 2`, both `-r`/`--recursive` and `-j`/`--jobs` + * are "changed" — `--r` offers nothing further — whereas this function + * used to record only the cluster's last character). + * + * `legacyClassifyCompletion`'s `--help`/`--version` short-circuit reads THIS + * set (`changedFlagNames.has("help"/"version")`) rather than scanning raw + * tokens for a reason beyond DRY: pflag's `boolValue.Set` marks the flag + * `Changed` on an explicit-value spelling too (`--help=false`), and cobra's + * `helpOrVersionFlagPresent` (`completions.go:530-537`) checks `.Changed`, + * not the parsed value — so `--help=false`/`--version=false` short-circuit + * exactly like a bare `--help`/`--version` (verified empirically against a + * real `apps/cli-go` build: `--help=false --d` and `--version=false br` both + * return zero candidates with the NoFileComp directive) — and this + * function's name-collection above already marks a flag changed on ANY + * spelling, explicit-value included. A raw token scan misses the terminator- + * and value-consumption cases this function already handles instead (see + * `legacyClassifyCompletion`'s call site for the specific repros). + */ +function legacyChangedFlagNames( + trimmedArgs: ReadonlyArray, + inScopeFlags: ReadonlyArray, +): ReadonlySet { + const changed = new Set(); + let index = 0; + while (index < trimmedArgs.length) { + const token = trimmedArgs[index]; + index++; + if (token === undefined) continue; + if (token === "--") break; // pflag's end-of-flags sentinel: nothing at or after this is parsed as a flag. + + if (token.startsWith("--")) { + const rest = token.slice(2); + const equalsIndex = rest.indexOf("="); + const name = equalsIndex === -1 ? rest : rest.slice(0, equalsIndex); + if (name.length > 0) changed.add(name); + if (equalsIndex === -1 && index < trimmedArgs.length) { + const owner = inScopeFlags.find((flag) => flag.name === name); + if (owner !== undefined && !owner.isBoolean) index++; // consumes the next token as its value. + } + continue; + } + if (token.startsWith("-") && token !== "-") { + const consumesNextToken = legacyMarkChangedShorthandCluster(token, inScopeFlags, changed); + if (consumesNextToken && index < trimmedArgs.length) index++; + } + } + return changed; +} + +/** + * Walks a short-flag token's shorthand cluster (e.g. `-rj`, `-o=json`), + * marking every shorthand consumed before — and including — the + * value-consuming one as changed. Returns `true` when the cluster ends on a + * non-boolean shorthand with no attached value (`-f`, or `-rf` ending on + * `f`) — the caller must then skip the immediately following token, since + * pflag consumes it as that shorthand's value rather than parsing it as its + * own flag. See `legacyChangedFlagNames`'s doc comment for the pflag + * behavior this mirrors. + */ +function legacyMarkChangedShorthandCluster( + token: string, + inScopeFlags: ReadonlyArray, + changed: Set, +): boolean { + let shorthands = token.slice(1); + while (shorthands.length > 0) { + const owner = inScopeFlags.find((flag) => flag.aliases.includes(shorthands.charAt(0))); + if (owner === undefined) return false; // unresolved shorthand — defensive stop, already filtered upstream. + changed.add(owner.name); + if (shorthands.length > 1 && shorthands.charAt(1) === "=") return false; // "-f=value": cluster ends at the explicit value. + if (!owner.isBoolean) return shorthands.length === 1; // non-boolean: the rest of the token (if any) is its value; otherwise the next arg is. + shorthands = shorthands.slice(1); // boolean shorthand consumed no value — keep walking the cluster. + } + return false; +} + +/** + * Whether `trimmedArgs` contains a genuine, unconsumed pflag end-of-flags + * sentinel — a bare `--` token that is NOT itself the value a preceding + * value-taking flag already consumed. `--file --` consumes the `--` as + * `--file`'s string value (`pflag@v1.0.10/flag.go:1013-1023`'s + * `parseLongArg`, which grabs the very next token unconditionally); pflag's + * sentinel check only ever inspects the CURRENT token being parsed, never + * one already claimed as a preceding flag's value, so a consumed `--` + * never disables later flag completion. A naive `trimmedArgs.includes("--")` + * treats that consumed token as a terminator too, wrongly shutting off + * flag-name/flag-value completion for the rest of the request (verified + * empirically against a real `apps/cli-go` build: `db dump --file -- --s` + * still offers `--schema`, not zero candidates, while `db dump -- --s` — no + * preceding value flag to consume the `--` — correctly returns zero + * candidates — CLI-1965 review finding). Walks the same long/short + * consumption rules `legacyChangedFlagNames` does, reusing + * `legacyResolveShortFlagCluster` for the short-flag case. + */ +function legacyHasUnconsumedFlagTerminator( + trimmedArgs: ReadonlyArray, + inScopeFlags: ReadonlyArray, +): boolean { + let index = 0; + while (index < trimmedArgs.length) { + const token = trimmedArgs[index]; + index++; + if (token === undefined) continue; + if (token === "--") return true; // genuine, unconsumed sentinel. + + if (token.startsWith("--")) { + const rest = token.slice(2); + const equalsIndex = rest.indexOf("="); + const name = equalsIndex === -1 ? rest : rest.slice(0, equalsIndex); + if (equalsIndex === -1 && index < trimmedArgs.length) { + const owner = inScopeFlags.find((flag) => flag.name === name); + if (owner !== undefined && !owner.isBoolean) index++; // consumes the next token (possibly `--`) as its value. + } + continue; + } + if (token.startsWith("-") && token !== "-") { + const cluster = legacyResolveShortFlagCluster(token, inScopeFlags); + const consumesNextToken = + cluster !== undefined && !cluster.flag.isBoolean && cluster.attachedValue === undefined; + if (consumesNextToken && index < trimmedArgs.length) index++; + } + } + return false; +} + +/** + * Go registers `--jobs`/`--last` as `UintVarP`/`UintVar` pflag values + * (`apps/cli-go/cmd/functions.go:161` — `functions deploy`; + * `cmd/migration.go:152` — `migration down`; `cmd/db.go:717` — `db reset`; + * `cmd/storage.go:107` — `storage cp`, the same bug class), which reject a + * leading `-`/`+` outright (`strconv.ParseUint(s, 0, 64)`) — unlike this TS + * tree's plain signed `Flag.integer("jobs"/"last")`. `legacyIsValidFlagValue` + * checks this table BEFORE dispatching on `primitiveTag`, since it must + * catch `storage cp --jobs` too, which is `Flag.string("jobs")` in TS (its + * own handler already calls `legacyParseUintBase0` directly at parse time, + * `cp.command.ts`) rather than `Flag.integer` — a bare `primitiveTag` + * switch would never see it (verified empirically against a real + * `apps/cli-go` build: `functions deploy --jobs -1 --p`, `migration down + * --last -1 --d`, `db reset --last -1 --d`, and `storage cp --jobs -1 --r` + * all return zero candidates with the Default directive — CLI-1965 review + * finding). Key = `:`, matching + * `LEGACY_COMPLETION_REQUIRED_FLAGS`'s convention. + */ +const LEGACY_COMPLETION_UINT_FLAGS: ReadonlySet = new Set([ + "functions deploy:jobs", + "migration down:last", + "db reset:last", + "storage cp:jobs", +]); + +/** + * Go registers `--query-timeout` (`gen types`, `cmd/gen.go:161`) and + * `--valid-for` (`gen bearer-jwt`, `cmd/gen.go:179`) as `DurationVar` pflag + * values (`time.ParseDuration`), unlike this TS tree's plain + * `Flag.string("query-timeout"/"valid-for")` — same shape as + * `LEGACY_COMPLETION_UINT_FLAGS` above, keyed the same way (verified + * empirically against a real `apps/cli-go` build: `gen types + * --query-timeout bogus --l` and `gen bearer-jwt --role anon --valid-for + * bogus --p` both return zero candidates with the Default directive — + * CLI-1965 review finding). + */ +const LEGACY_COMPLETION_DURATION_FLAGS: ReadonlySet = new Set([ + "gen types:query-timeout", + "gen bearer-jwt:valid-for", +]); + +/** + * Go registers `--exp` (`gen bearer-jwt`, `cmd/gen.go:178`) as a `TimeVar` + * pflag value constrained to `time.RFC3339` (`time.Parse(time.RFC3339, s)`), + * unlike this TS tree's plain `Flag.string("exp")` (verified empirically + * against a real `apps/cli-go` build: `gen bearer-jwt --role anon --exp + * bogus --p` returns zero candidates with the Default directive, while + * `--exp 2024-01-02T15:04:05Z --p` still offers `--profile`/`--payload` — + * CLI-1965 review finding). + */ +const LEGACY_COMPLETION_RFC3339_FLAGS: ReadonlySet = new Set(["gen bearer-jwt:exp"]); + +/** Nanosecond scale for each unit `time.ParseDuration`'s `unitMap` accepts (`time/format.go`). */ +const GO_DURATION_UNIT_NANOS: ReadonlyMap = new Map([ + ["ns", 1n], + ["us", 1_000n], + ["µs", 1_000n], // U+00B5 micro sign + ["μs", 1_000n], // U+03BC Greek mu + ["ms", 1_000_000n], + ["s", 1_000_000_000n], + ["m", 60_000_000_000n], + ["h", 3_600_000_000_000n], +]); + +// `time.ParseDuration` accumulates into a `uint64` and range-checks against +// `1<<63` mid-parse, only narrowing to the `int64` max (`1<<63 - 1`) in the +// final non-negative check — see `legacyIsValidGoDuration` below. +const GO_DURATION_UINT64_OVERFLOW_BOUND = 1n << 63n; +const GO_DURATION_MAX_INT64 = (1n << 63n) - 1n; + +function legacyIsAsciiDigit(char: string | undefined): boolean { + return char !== undefined && char >= "0" && char <= "9"; +} + +/** + * Faithful port of Go's `time.ParseDuration` (`time/format.go`) — the exact + * parser pflag runs for a `DurationVar` flag's `Set` — as a syntax-and-range + * VERDICT (completion only needs a boolean, not the parsed `Duration`, + * mirroring `legacyIsValidBase0Int64`'s own shape). An optional sign, then + * either the literal `0` alone, or one or more `[.]` terms + * concatenated (`1h30m`, `1.5h`, `.5s`), accumulating nanoseconds as Go does: + * `BigInt` for the integer/fraction accumulators (Go's `uint64`, which this + * mirrors exactly — no JS `Number` precision loss), and a plain `Number` + * multiply-then-`Math.trunc` for the one step Go itself does in `float64` + * (`uint64(float64(f) * (float64(unit) / scale))`) — since a JS `number` IS + * an IEEE-754 double, `Number(bigIntValue)` round-trips through the exact + * same conversion Go's `float64(f)` does, so this step is bit-for-bit + * identical to Go's, not merely an approximation of it. + * + * This replaces an earlier regex-only grammar check whose own doc comment + * dismissed the overflow bound as "unreachable through any realistic + * completion input" — disputed and disproven on review (CLI-1965): Go's + * `int64` nanosecond range caps out at ~292 years, so `--query-timeout + * 2562048h` (one hour past the real max, a plausible fat-fingered value, not + * a contrived ~20-digit magnitude) is exactly the kind of input real + * completion traffic can produce, and the old check silently accepted it. + * Verified empirically against a real `apps/cli-go` build (`gen types + * --query-timeout 2562048h --l` returns zero candidates with the Default + * directive, matching `bogus`) and cross-checked this implementation against + * go1.26 `time.ParseDuration` across sign/fraction/multi-term/overflow edge + * cases, including the exact `int64` boundary (`2562047h47m16.854775807s` → + * valid, one ns more → invalid) and its negative-side asymmetry + * (`-2562047h47m16.854775808s`, `int64` min, valid — one ns more still + * invalid) — same two's-complement asymmetry `legacyIsValidBase0Int64` + * already encodes for `MAX_INT64_NEGATIVE_MAGNITUDE`. + */ +function legacyIsValidGoDuration(value: string): boolean { + let rest = value; + let negative = false; + if (rest.length > 0 && (rest[0] === "-" || rest[0] === "+")) { + negative = rest[0] === "-"; + rest = rest.slice(1); + } + if (rest === "0") return true; + if (rest === "") return false; + + let total = 0n; + while (rest.length > 0) { + if (!(rest[0] === "." || legacyIsAsciiDigit(rest[0]))) return false; + + // `leadingInt`: digits before the decimal point. Go returns an error + // immediately on overflow rather than continuing to consume digits. + let i = 0; + let intPart = 0n; + while (legacyIsAsciiDigit(rest[i])) { + if (intPart > GO_DURATION_UINT64_OVERFLOW_BOUND / 10n) return false; + intPart = intPart * 10n + BigInt(rest[i] as string); + if (intPart > GO_DURATION_UINT64_OVERFLOW_BOUND) return false; + i++; + } + const hasIntDigits = i > 0; + rest = rest.slice(i); + + // `leadingFraction`: digits after `.`. Go does NOT error on overflow + // here — it just stops accumulating precision and keeps consuming. + let fracPart = 0n; + let scale = 1n; + let hasFracDigits = false; + if (rest.length > 0 && rest[0] === ".") { + rest = rest.slice(1); + let j = 0; + let fracOverflowed = false; + while (legacyIsAsciiDigit(rest[j])) { + if (!fracOverflowed) { + if (fracPart > GO_DURATION_MAX_INT64 / 10n) { + fracOverflowed = true; + } else { + const next = fracPart * 10n + BigInt(rest[j] as string); + if (next > GO_DURATION_UINT64_OVERFLOW_BOUND) { + fracOverflowed = true; + } else { + fracPart = next; + scale *= 10n; + } + } + } + j++; + } + hasFracDigits = j > 0; + rest = rest.slice(j); + } + if (!hasIntDigits && !hasFracDigits) return false; + + // Consume the unit: every character up to the next digit/`.`. + let k = 0; + while (k < rest.length && !(rest[k] === "." || legacyIsAsciiDigit(rest[k]))) k++; + if (k === 0) return false; // missing unit + const unitNanos = GO_DURATION_UNIT_NANOS.get(rest.slice(0, k)); + rest = rest.slice(k); + if (unitNanos === undefined) return false; // unknown unit + + if (intPart > GO_DURATION_UINT64_OVERFLOW_BOUND / unitNanos) return false; + let termNanos = intPart * unitNanos; + if (fracPart > 0n) { + const fractional = Number(fracPart) * (Number(unitNanos) / Number(scale)); + termNanos += BigInt(Math.trunc(fractional)); + if (termNanos > GO_DURATION_UINT64_OVERFLOW_BOUND) return false; + } + total += termNanos; + if (total > GO_DURATION_UINT64_OVERFLOW_BOUND) return false; + } + + // Go's final range check only runs for the non-negative case — the + // negative side already got the one-larger `1<<63` bound above, matching + // `int64`'s two's-complement asymmetry (see the doc comment). + return negative || total <= GO_DURATION_MAX_INT64; +} + +/** + * Mirrors Go's `time.Parse(time.RFC3339, s)` — `2006-01-02T15:04:05Z07:00` + * — exact 4/2/2/2/2/2-digit date-time fields, a literal (case-sensitive) `T` + * separator, an optional fractional-seconds run of any length introduced by + * EITHER `.` or `,` (Go's `time` package accepts both spellings of the + * decimal mark per RFC 3339/ISO 8601 — verified empirically against go1.26 + * `time.Parse`: `"2024-01-02T15:04:05,5Z"` parses identically to + * `"...05.5Z"`; a bare separator with no following digit, e.g. `",Z"`, still + * fails, and mixing both separators in one timestamp, e.g. `".5,5Z"`, still + * fails too — CLI-1965 review finding), and a `Z` or `±HH:MM` offset. + * Hour/minute/second are bounded to `0-23`/`0-59`/`0-59` (Go rejects `":60"` + * — no leap-second allowance — and `"25:"` — verified empirically). The + * offset's own hour/minute fields are ALSO bounded, but not to that same + * 0-23/0-59 range: Go's zone-offset parser independently caps the offset + * hour at 24 (not 23) and the offset minute at 60 (not 59), each checked in + * isolation rather than as a combined "total offset <= 24h" (re-verified + * against go1.26 `time.Parse` after this was disputed on review: `"+24:00"`, + * `"+23:59"`, `"+24:60"`, and `"+00:60"` all parse successfully; `"+25:00"` + * and `"+00:61"` both fail with "time zone offset hour/minute out of range" + * — CLI-1965 review finding; the earlier claim here that Go "never + * range-checks the offset" was wrong). Month/day validity (including leap + * years, and short months like April's 30 days) is checked by round-tripping + * the parsed year/month/day through `Date#setUTCFullYear` and comparing what + * comes back — that method (unlike the `Date` constructor or `Date.UTC`) + * does NOT special-case a 0-99 year into 1900+year, so it stays correct for + * Go's own accepted `"0000-01-02T15:04:05Z"`, and its normal + * calendar-overflow behavior (Feb 29 rolling to Mar 1 in a non-leap year, + * day 32 rolling into the next month, month 13 rolling into the next year) + * exactly reproduces Go's own leap-year and day/month-bounds rejections + * without hand-rolling the calendar math. + */ +const GO_RFC3339_PATTERN = + /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:[.,]\d+)?(?:Z|[+-](\d{2}):(\d{2}))$/; + +function legacyIsValidGoRfc3339(value: string): boolean { + const match = GO_RFC3339_PATTERN.exec(value); + if (match === null) return false; + const [, year, month, day, hour, minute, second, offsetHour, offsetMinute] = match; + const y = Number(year); + const mo = Number(month); + const d = Number(day); + if (Number(hour) > 23 || Number(minute) > 59 || Number(second) > 59) return false; + if (offsetHour !== undefined && (Number(offsetHour) > 24 || Number(offsetMinute) > 60)) + return false; + + const roundTrip = new Date(0); + roundTrip.setUTCFullYear(y, mo - 1, d); + return ( + roundTrip.getUTCFullYear() === y && + roundTrip.getUTCMonth() === mo - 1 && + roundTrip.getUTCDate() === d + ); +} + +/** + * Go registers `--sql-paths` (`db reset`, `cmd/db.go:714`) as a plain + * `StringArrayVar` — pflag stores each repeated occurrence verbatim, with NO + * CSV parsing — unlike every OTHER variadic (`isVariadic`) string flag + * reachable from this tree, which Go declares `StringSliceVar`/ + * `StringSliceVarP` (CSV-split per occurrence): `--domains` (sso + * add/update), `--schema`/`--exclude` (db dump/diff/pull/lint, gen types, db + * schema declarative generate/sync), `--config` (postgres-config + * delete/update), `--db-unban-ip` (network-bans remove), `--db-allow-cidr` + * (network-restrictions update), and `--exclude`/`--override-name` + * (start/status). This is the one, small exception — kept as an exclusion + * set rather than an inclusion table, since the inclusion side is the much + * longer list. Key = `:`. + */ +const LEGACY_COMPLETION_NON_CSV_VARIADIC_FLAGS: ReadonlySet = new Set([ + "db reset:sql-paths", +]); + +/** + * Validates a CSV-per-occurrence (`isVariadic`, pflag `StringSliceVar`) + * flag's value the same way `legacyParseStringSliceFlag` does at real parse + * time — reused here directly rather than re-implemented, so the two never + * drift (verified empirically against a real `apps/cli-go` build: `sso add + * --domains 'a,"b' --type` — an unterminated quote — returns zero + * candidates with the Default directive, not `--type` — CLI-1965 review + * finding). + */ +function legacyIsValidCsvFlagValue(value: string): boolean { + try { + legacyParseStringSliceFlag([value]); + return true; + } catch { + return false; + } +} + +/** + * Go registers `--output`/`-o` as a command-scoped enum: the root persistent + * flag accepts `env|pretty|json|toml|yaml` (`internal/utils/output.go:30-38`) + * while `db query`'s own local flag accepts `json|table|csv` (`cmd/db.go:285- + * 288`) — two value sets that only overlap on `json`, on what this TS tree + * models as a single global `LegacyOutputFlag` whose `choiceKeys` is the + * union of both (`legacy-go-output-flag.ts`), so `flag.choiceKeys` alone + * can't tell which enum applies at the resolved command. This restores Go's + * per-command validation (verified empirically against a real `apps/cli-go` + * build: `--output table ""` outside `db query`, and `db query --output env + * ""`, are BOTH rejected with zero candidates and the Default directive, + * even though each value is accepted on the OTHER side — CLI-1965 review + * finding). + */ +function legacyOutputFlagChoiceKeys(matchedPath: ReadonlyArray): ReadonlyArray { + return matchedPath.length === 2 && matchedPath[0] === "db" && matchedPath[1] === "query" + ? LEGACY_QUERY_OUTPUT_FORMATS + : LEGACY_RESOURCE_OUTPUT_FORMATS; +} + +/** + * Validates a flag's value the way pflag's typed `Value.Set` does inside + * `finalCmd.ParseFlags()` — e.g. `-o not-a-format` (a `Choice`-typed + * `--output`) or `--debug=maybe` (a `Boolean`-typed `--debug`) fail to parse + * in real pflag, and cobra reports the parse error instead of generating any + * completions (verified empirically against a real `apps/cli-go` build: both + * return zero candidates with the Default directive, exactly like an + * unresolved flag name). The command-dependent overrides + * (`LEGACY_COMPLETION_UINT_FLAGS`, `LEGACY_COMPLETION_DURATION_FLAGS`, + * `LEGACY_COMPLETION_RFC3339_FLAGS`, `LEGACY_COMPLETION_NON_CSV_VARIADIC_FLAGS`) + * are checked BEFORE the `primitiveTag` dispatch — a bare `LegacyFlagDescriptor` + * can't express any of them on its own (all need `matchedPath`, and the uint + * one specifically needs to catch a flag whose TS `primitiveTag` isn't + * `"Integer"` at all, e.g. `storage cp --jobs`; the duration/RFC3339 ones + * catch flags that are plain `Flag.string` in TS but a Go `Duration`/`Time` + * pflag value). Every other primitive shape pflag can actually reject is + * checked in the switch; `String`/`Path`/`Date`/etc. flags accept any string + * in Go too, so the default case is unconditionally valid. + */ +function legacyIsValidFlagValue( + matchedPath: ReadonlyArray, + flag: LegacyFlagDescriptor, + value: string, +): boolean { + const key = `${matchedPath.join(" ")}:${flag.name}`; + if (LEGACY_COMPLETION_UINT_FLAGS.has(key)) { + return "value" in legacyParseUintBase0(value); + } + if (LEGACY_COMPLETION_DURATION_FLAGS.has(key)) { + return legacyIsValidGoDuration(value); + } + if (LEGACY_COMPLETION_RFC3339_FLAGS.has(key)) { + return legacyIsValidGoRfc3339(value); + } + if ( + flag.isVariadic && + flag.primitiveTag === "String" && + !LEGACY_COMPLETION_NON_CSV_VARIADIC_FLAGS.has(key) + ) { + return legacyIsValidCsvFlagValue(value); + } + switch (flag.primitiveTag) { + case "Boolean": + return legacyParseGoBool(value) !== undefined; + case "Choice": + if (flag.name === "output") { + return legacyOutputFlagChoiceKeys(matchedPath).includes(value); + } + return flag.choiceKeys !== undefined && flag.choiceKeys.includes(value); + case "Integer": + return legacyIsValidBase0Int64(value); + case "Float": + return value.trim().length > 0 && !Number.isNaN(Number(value)); + default: + return true; + } +} + +/** + * Walks a short-flag cluster (`-o`, `-ojson`, `-rj`, `-o=json`) the same way + * pflag's `parseSingleShortArg` does (`pflag@v1.0.10/flag.go:1040-1114`) — + * first character owns the value, not last. This is deliberately a + * DIFFERENT algorithm from `legacyResolveFlagFromToken`'s last-character + * resolution: that function mirrors cobra's OWN separate, narrower + * `checkIfFlagCompletion` heuristic, used only to guess "is the CURRENT or + * PRECEDING token mid-way through being value-completed" — not to strictly + * parse a token that's already fully typed. This function mirrors the real + * strict parser (`finalCmd.ParseFlags()`) instead, used by + * `legacyFindUnresolvedFlagToken` (verified empirically against a real + * `apps/cli-go` build: `functions deploy -j4 --p` still offers + * `--profile`/`--project-ref`/`--prune` — `-j4` is a fully valid, already- + * resolved `--jobs=4`, not an unknown flag — CLI-1965 review finding). + * + * Returns `undefined` if any character in the cluster doesn't resolve to an + * in-scope flag shorthand. Otherwise returns the flag that ultimately owns + * the cluster's (possibly absent) attached value — the first non-boolean + * shorthand encountered, or the cluster's last shorthand if every character + * in it is boolean — plus that attached value, which is `undefined` only + * when there is nothing left in the token to attach (`-o` alone, or an + * all-boolean cluster like `-rf`), meaning a following token supplies it + * instead. + */ +function legacyResolveShortFlagCluster( + token: string, + inScopeFlags: ReadonlyArray, +): { readonly flag: LegacyFlagDescriptor; readonly attachedValue: string | undefined } | undefined { + let shorthands = token.slice(1); + let lastResolved: LegacyFlagDescriptor | undefined; + while (shorthands.length > 0) { + const owner = inScopeFlags.find((flag) => flag.aliases.includes(shorthands.charAt(0))); + if (owner === undefined) return undefined; + lastResolved = owner; + if (shorthands.length > 1 && shorthands.charAt(1) === "=") { + return { flag: owner, attachedValue: shorthands.slice(2) }; + } + if (!owner.isBoolean) { + return { + flag: owner, + attachedValue: shorthands.length > 1 ? shorthands.slice(1) : undefined, + }; + } + shorthands = shorthands.slice(1); // boolean shorthand consumed no value — keep walking the cluster. + } + return lastResolved === undefined ? undefined : { flag: lastResolved, attachedValue: undefined }; +} + +/** + * Finds the first token in `trimmedArgs` that either (a) looks like a flag + * (starts with `-`, excluding the bare `-` positional pflag itself treats as + * a non-flag argument) but does not resolve to anything in `inScopeFlags`, + * (b) resolves to a real flag whose value `legacyIsValidFlagValue` rejects, + * or (c) resolves to a real, non-boolean flag with NO value available at all + * — no attached suffix and no following token — AND `toComplete` itself is a + * bare flag-shaped token (starts with `-`). + * + * That last condition mirrors a real two-part cobra/pflag interaction: + * cobra's `checkIfFlagCompletion` only rescues a trailing incomplete flag + * from `ParseFlags()` (treating it as "the flag currently being + * value-completed" instead of a parse error) when `toComplete` is EMPTY or + * otherwise not itself flag-shaped (`completions.go:666-687`, the `prevArg` + * branch, which strips the dangling flag out of `finalArgs` before + * `ParseFlags` ever sees it). When `toComplete` IS flag-shaped, that rescue + * never happens — `checkIfFlagCompletion` either returns immediately without + * touching `finalArgs` (no `=`, `completions.go`'s "Normal flag completion" + * early return) or extracts a flag name from `toComplete`'s OWN prefix + * before its `=` (`completions.go`'s `flagWithEqual` branch) — neither path + * strips a DIFFERENT, already-dangling flag earlier in `finalArgs`, so the + * real `finalCmd.ParseFlags()` call (`completions.go:373-375`) fails + * outright on it. Whether `toComplete` itself contains `=` is irrelevant: + * that `=` only ever resolves `toComplete`'s own flag name, never rescues an + * earlier dangling one (verified empirically against a real `apps/cli-go` + * build: `__complete -o --d` returns zero candidates with the Default + * directive — Go's `ParseFlags` error is "flag needs an argument: 'o' in + * -o" — while `__complete -o ''` and `__complete -o pre` both instead fall + * through to flag-VALUE completion for `--output`, per + * `legacyClassifyCompletion`'s Case 2; `__complete sso add --type saml + * --metadata-file --attribute-mapping-file=` ALSO returns zero candidates + * with the Default directive — Go's `ParseFlags` error is "flag needs an + * argument: --metadata-file" — even though the current token has an `=` and + * identifies a wholly separate flag, not `--metadata-file`'s value — + * CLI-1965 review finding). + * + * Long flags (`--foo`, `--foo=bar`) resolve via `legacyResolveFlagFromToken` + * (no first/last-character ambiguity for a `--name` token). Short flags + * resolve via `legacyResolveShortFlagCluster` instead — see that function's + * doc comment for why this deliberately does NOT reuse + * `legacyResolveFlagFromToken`'s last-character heuristic here. + * + * Consumes a following token as a non-boolean flag's value the same way + * `legacyResolveCommandPath` does. A bare `--` ends the scan entirely without + * itself counting as unresolved — pflag's own end-of-flags sentinel, after + * which everything is positional, not a flag to validate (`pflag@v1.0.9`'s + * `parseArgs`: `if s[1] == '-' { if len(s) == 2 { ... terminates the flags`). + * Returns the offending token, or `undefined` if every flag-shaped token + * resolves to a real flag with a valid, available value. + * + * Mirrors cobra's real two-phase design: `Find()` tolerantly skips flags it + * doesn't recognize while walking for a subcommand name (see + * `legacyResolveCommandPath`, which only needs to know "does this consume a + * value", not "is this real"), but the later `finalCmd.ParseFlags()` strictly + * validates every remaining flag token — both that it resolves AND that its + * value parses — against the fully-resolved command's complete flag set, and + * fails outright on the first one that doesn't (`completions.go:373-375`) — a + * failure so early it wins even over the `--help`/`--version` short-circuit + * below (verified empirically against a real `apps/cli-go` build: both + * `__complete --bogus --help ""` and `__complete --help --bogus ""` report + * the unknown flag, not help; a bare `__complete --bogus ""` returns zero + * candidates with the Default directive, not the root subcommand list; + * `__complete -- ""` is unaffected and still lists every root subcommand). + */ +function legacyFindUnresolvedFlagToken( + trimmedArgs: ReadonlyArray, + toComplete: string, + inScopeFlags: ReadonlyArray, + matchedPath: ReadonlyArray, +): string | undefined { + // See this function's doc comment: only a `toComplete` that's itself a + // bare flag-shaped token blocks cobra's "rescue" of a trailing, + // value-less flag — whether that token also contains `=` is irrelevant, + // since the `=` only ever resolves `toComplete`'s OWN flag name, never an + // earlier, different dangling flag. Every non-flag-shaped `toComplete` + // leaves the trailing flag for flag-VALUE completion instead, so a + // missing value at the end of `trimmedArgs` is not, by itself, unresolved + // in that case. + const trailingMissingValueIsFatal = toComplete.startsWith("-"); + + let index = 0; + while (index < trimmedArgs.length) { + const token = trimmedArgs[index]; + index++; + if (token === undefined || token === "-" || !token.startsWith("-")) continue; + if (token === "--") break; + + if (token.startsWith("--")) { + const equalsIndex = token.indexOf("="); + const bareToken = equalsIndex === -1 ? token : token.slice(0, equalsIndex); + const resolved = legacyResolveFlagFromToken(bareToken, inScopeFlags); + if (resolved === undefined) return token; + + if (equalsIndex !== -1) { + if (!legacyIsValidFlagValue(matchedPath, resolved, token.slice(equalsIndex + 1))) + return token; + continue; + } + if (resolved.isBoolean) continue; + if (index >= trimmedArgs.length) { + if (trailingMissingValueIsFatal) return token; + continue; + } + const value = trimmedArgs[index]; + index++; // skip the consumed value token + if (value !== undefined && !legacyIsValidFlagValue(matchedPath, resolved, value)) + return value; + continue; + } + + const cluster = legacyResolveShortFlagCluster(token, inScopeFlags); + if (cluster === undefined) return token; + // An attached value (`-o=json`, or a non-boolean's `-ojson`) must be + // validated BEFORE the boolean short-circuit below — pflag treats + // `-f=value` as an explicit value for a boolean shorthand too + // (`pflag@v1.0.10/flag.go:1005-1033`), so a boolean owner does not, on + // its own, mean "nothing to validate" (verified empirically against a + // real `apps/cli-go` build: `storage cp -r=maybe --j` returns zero + // candidates with the Default directive, not `--jobs` — CLI-1965 review + // finding). + if (cluster.attachedValue !== undefined) { + if (!legacyIsValidFlagValue(matchedPath, cluster.flag, cluster.attachedValue)) return token; + continue; + } + if (cluster.flag.isBoolean) continue; + if (index >= trimmedArgs.length) { + if (trailingMissingValueIsFatal) return token; + continue; + } + const value = trimmedArgs[index]; + index++; // skip the consumed value token + if (value !== undefined && !legacyIsValidFlagValue(matchedPath, cluster.flag, value)) + return value; + } + return undefined; +} + +function legacyFlagValueCompletion( + matchedPath: ReadonlyArray, + flagName: string | undefined, +): LegacyCompletionResult { + const key = flagName === undefined ? undefined : `${matchedPath.join(" ")}:${flagName}`; + const extensions = + key === undefined ? undefined : LEGACY_COMPLETION_FLAG_FILE_EXTENSIONS.get(key); + if (extensions !== undefined) { + return { + candidates: extensions.map((extension) => ({ name: extension, description: undefined })), + directive: LegacyCompletionDirective.FilterFileExt, + }; + } + return { candidates: [], directive: LegacyCompletionDirective.Default }; +} + +/** + * Mirrors cobra's auto-registered `help` command's own `ValidArgsFunction` + * (`command.go:1263-1310`, `InitDefaultHelpCmd`): `help` is a REAL subcommand + * of root, and its `ValidArgsFunction` re-resolves everything typed after it + * from root — via `c.Root().Find(args)` — then lists THAT resolved command's + * own visible subcommands, filtered by `toComplete`'s prefix. `help db d` + * therefore completes as if `d` were being completed inside `db` (`diff`, + * `dump`), not as an argument to `help` itself. + * + * Mirrors cobra's `legacyArgs` validator (`args.go:28-37`) for the "unknown + * command" error `Find` surfaces through `e`: it fires ONLY when the + * resolved command is root itself (no real descent happened at all) AND a + * token is left over — a token left over under any OTHER resolved command is + * never an error there (subcommands "will always accept arbitrary + * arguments"). On that error path cobra returns zero candidates, but still + * with the NoFileComp directive, same as the success path (verified + * empirically against a real `apps/cli-go` build: `help bogus d` -> no + * candidates; `help db bogus d` -> still `db`'s subcommands, since `db` is + * not root — CLI-1965 review finding). + * + * `root` here is always `finalCommand` from the outer resolution: `help` has + * no node anywhere in this TS tree (it is a synthesized candidate, not a + * real `Command.Command.Any` — see the comment where Case 3 pushes it + * below), so the outer `legacyResolveCommandPath` call always stops at root + * immediately when `trimmedArgs[0] === "help"`, making `finalCommand` and + * real cobra's `c.Root()` the same command. + */ +function legacyHelpArgumentCandidates( + root: Command.Command.Any, + argsAfterHelp: ReadonlyArray, + toComplete: string, +): LegacyCompletionResult { + const { matchedPath, leftoverArgs, commandChain } = legacyResolveCommandPath(root, argsAfterHelp); + if (matchedPath.length === 0 && leftoverArgs.length > 0) { + return { candidates: [], directive: LegacyCompletionDirective.NoFileComp }; + } + + const resolved = commandChain[commandChain.length - 1] ?? root; + const visibleSubcommands = legacyFlattenSubcommands(resolved).filter((sub) => !sub.hidden); + const candidates: Array = visibleSubcommands.map((sub) => ({ + name: sub.name, + description: sub.shortDescription ?? sub.description, + })); + // Cobra's help command is itself one of root's `Commands()`, so re-resolving + // to root also re-lists `help` (verified empirically: `help h` -> `help`). + if (matchedPath.length === 0) { + candidates.push({ name: "help", description: "Help about any command" }); + } + candidates.sort((a, b) => a.name.localeCompare(b.name)); + + return { + candidates: candidates.filter((candidate) => candidate.name.startsWith(toComplete)), + directive: LegacyCompletionDirective.NoFileComp, + }; +} + +/** + * Classifies a single completion request into candidates + directive, + * mirroring cobra's `checkIfFlagCompletion` and the branch in + * `getCompletions` that follows it: + * + * 0. A flag-shaped token that doesn't resolve to any in-scope flag + * short-circuits to no candidates with the Default directive — mirrors + * `finalCmd.ParseFlags()` failing outright on an unrecognized flag, which + * wins even over `--help`/`--version` below. + * 0.5. An unmatched ROOT-level positional (`matchedPath.length === 0` — no + * real descent happened at all — with a genuine leftover positional token) + * ALSO short-circuits to no candidates with the Default directive, and + * wins over `--help`/`--version` too — mirrors `Command.Find`'s own + * `legacyArgs` validator (`cobra@v1.10.2/args.go:28-37`), which returns + * `unknown command %q` precisely when the resolved command is root, has + * subcommands (root always does), and has a leftover non-flag positional + * — an error `getCompletions` surfaces as zero candidates before doing + * anything else with `finalCmd`. + * 1. `--help`/`-h` anywhere in `trimmedArgs` (or `--version`/`-v`, only when + * resolved to the root command) short-circuits to no candidates — these + * exit before any real completion runs. + * 2. A genuine, unconsumed bare `--` anywhere in `trimmedArgs` disables ALL + * flag-name and flag-value completion (Cases 3/4 below) for the rest of + * this request — mirrors cobra's `flagCompletion` gate, which goes false + * the moment a previous `--` is already present (`completions.go:364- + * 381`; see `legacyHasUnconsumedFlagTerminator` below for why "unconsumed" + * matters). + * 3. `toComplete` is a bare flag with no `=` → flag-NAME completion. + * 4. `toComplete` (or the immediately preceding token) identifies a + * non-boolean flag's value slot → flag-VALUE completion. + * 5. Otherwise → subcommand-name + required-flag (noun) completion; five + * specific leaf paths (`completion[ bash|zsh|fish|powershell]`) force the + * directive to NoFileComp regardless of what the subcommand walk above + * computed — see `LEGACY_COMPLETION_NO_FILE_COMP_PATHS`. + */ +export function legacyClassifyCompletion( + input: LegacyClassifyCompletionInput, +): LegacyCompletionResult { + const { finalCommand, matchedPath, leftoverArgs, trimmedArgs, toComplete, inScopeFlags } = input; + const isAtRoot = matchedPath.length === 0; + + if ( + legacyFindUnresolvedFlagToken(trimmedArgs, toComplete, inScopeFlags, matchedPath) !== undefined + ) { + return { candidates: [], directive: LegacyCompletionDirective.Default }; + } + + // Mirrors cobra's `stripFlags` (`command.go:674-710`), which the + // `legacyArgs` validator below runs its leftover-count check against — a + // bare `-` (and an empty string) is dropped from consideration, NOT + // counted as a genuine leftover positional, even though + // `legacyResolveCommandPath` deliberately leaves a bare `-` IN + // `leftoverArgs` for other purposes (verified empirically against a real + // `apps/cli-go` build: `__complete - --d` still offers root's own + // `--debug`/`--dns-resolver`, since `stripFlags` drops the lone `-` and + // leaves zero real leftover commands to error on — CLI-1965 review + // finding, root cause shared with the `nosuch --d` finding below). + // + // Exempts `trimmedArgs[0] === "help"`: real cobra's `help` is a REAL child + // node of root (`InitDefaultHelpCmd`), so `Find(["help", ...])` resolves + // INTO the help command itself rather than stopping at root — this TS + // tree has no such node (see `legacyHelpArgumentCandidates`'s doc + // comment), so the outer resolution below always sees "help" as an + // immediate non-match and would otherwise misfire this same root-level + // check for every legitimate `help ...` request. Case 3's own + // `isAtRoot && trimmedArgs[0] === "help"` branch re-resolves `help`'s own + // arguments from root separately and already reproduces cobra's real + // unknown-command handling for THAT inner resolution + // (`legacyHelpArgumentCandidates`'s `matchedPath.length === 0 && + // leftoverArgs.length > 0` check). + const rootLeftoverPositionals = leftoverArgs.filter((arg) => arg !== "" && !arg.startsWith("-")); + if (isAtRoot && trimmedArgs[0] !== "help" && rootLeftoverPositionals.length > 0) { + // Mirrors cobra's `Command.Find` -> `legacyArgs` (`args.go:28-37`): + // resolving to root itself (no descent at all) with a leftover + // positional is an "unknown command" error there, unlike a leftover + // positional under any OTHER resolved command, which is never an error + // (verified empirically against a real `apps/cli-go` build: `nosuch + // --d` returns zero candidates with the Default directive — even ahead + // of the `--help`/`--version` short-circuit below, i.e. `nosuch --help` + // is ALSO zero candidates, not the help short-circuit's NoFileComp — + // while `db bogus --d`, where `db` itself resolves, still offers `db`'s + // own `--debug`/`--dns-resolver` normally — CLI-1965 review finding). + return { candidates: [], directive: LegacyCompletionDirective.Default }; + } + + // `legacyChangedFlagNames` (not a raw token scan) is load-bearing here: it + // already stops at a genuine, unconsumed `--` terminator and already skips + // a token consumed as a PRECEDING non-boolean flag's value — exactly the + // two cases pflag's own `Changed` tracking respects and a bare + // `trimmedArgs.some(...)` token scan does not (verified empirically against + // a real `apps/cli-go` build: `db dump -- --help ""` still offers `db + // dump`'s own completions, not the help short-circuit — `--help` is + // positional, past the terminator; `--workdir --version br` still + // completes `branches`, not the version short-circuit — `--version` is + // consumed as `--workdir`'s string value, never parsed as a flag at all — + // CLI-1965 review finding). + const changedFlagNames = legacyChangedFlagNames(trimmedArgs, inScopeFlags); + + // `version` is gated on `isAtRoot`: cobra's `--version` flag lives on the + // root command only (see `legacyCollectInScopeFlags`'s comment) — `help` is + // NOT gated the same way since every command registers its own local + // `--help` (present in `inScopeFlags`/`changedFlagNames` at every depth). + if (changedFlagNames.has("help") || (isAtRoot && changedFlagNames.has("version"))) { + return { candidates: [], directive: LegacyCompletionDirective.NoFileComp }; + } + + const requiredFlags = inScopeFlags.filter( + (flag) => + legacyIsRequiredCompletionFlag(matchedPath, flag.name) && !changedFlagNames.has(flag.name), + ); + + const toCompleteIsFlag = toComplete.startsWith("-"); + const toCompleteEqualsIndex = toComplete.indexOf("="); + // Once a genuine, unconsumed bare `--` sentinel has already appeared, + // cobra never does flag-name or flag-value completion again for the rest + // of the request (verified empirically against a real `apps/cli-go` + // build: `db dump -- --s` returns zero candidates with the Default + // directive, not `--schema` — CLI-1965 review finding). See + // `legacyHasUnconsumedFlagTerminator`'s doc comment for why a raw + // `trimmedArgs.includes("--")` over-triggers when a preceding value-taking + // flag consumed that `--` as its own value instead. + const hasFlagTerminator = legacyHasUnconsumedFlagTerminator(trimmedArgs, inScopeFlags); + + // Case 1: flag-NAME completion. + if (!hasFlagTerminator && toCompleteIsFlag && toCompleteEqualsIndex === -1) { + const requiredCandidates = requiredFlags.flatMap((flag) => + legacyFlagNameCandidates(flag, toComplete), + ); + // Once ANY required flag is still unset, ONLY required flags are + // offered — this exactly mirrors cobra. + if (requiredCandidates.length > 0) { + return { candidates: requiredCandidates, directive: LegacyCompletionDirective.NoFileComp }; + } + const candidates = inScopeFlags + .filter((flag) => !flag.hidden && (!changedFlagNames.has(flag.name) || flag.isVariadic)) + .flatMap((flag) => legacyFlagNameCandidates(flag, toComplete)); + return { candidates, directive: LegacyCompletionDirective.NoFileComp }; + } + + // Case 2: flag-VALUE completion. + if (!hasFlagTerminator) { + if (toCompleteIsFlag) { + // toCompleteEqualsIndex !== -1 here — the no-`=` branch above returns. + // Cobra's checkIfFlagCompletion treats ANY `--flag=value` token + // (including a boolean's) as flag-value completion — the "reset to + // noun completion for a boolean" only applies in the separate no-`=` + // two-token case handled by the `else` branch below + // (`completions.go`'s `!flagWithEqual` guard around that reset; + // verified empirically against a real `apps/cli-go` build: + // `--debug=maybe` returns zero candidates with the Default directive, + // not the root command list — CLI-1965 review finding). + const resolved = legacyResolveFlagFromToken( + toComplete.slice(0, toCompleteEqualsIndex), + inScopeFlags, + ); + return legacyFlagValueCompletion(matchedPath, resolved?.name); + } + const precedingToken = trimmedArgs[trimmedArgs.length - 1]; + if ( + precedingToken !== undefined && + precedingToken.startsWith("-") && + // A bare `-` is excluded — pflag's `isFlagArg` (`command.go:750-753`) + // requires at least 2 characters, so real cobra's own equivalent + // "preceding token is flag-shaped" check never fires for it either, + // and this must fall through to Case 3 instead of hard-stopping + // (verified empirically against a real `apps/cli-go` build: `help db + // - d` still lists `db`'s subcommands `diff`/`dump`, not zero + // candidates — CLI-1965 review finding). + precedingToken !== "-" && + !precedingToken.includes("=") + ) { + const resolved = legacyResolveFlagFromToken(precedingToken, inScopeFlags); + if (resolved === undefined) { + // Cobra's checkIfFlagCompletion errors out here (a `flagCompError` + // short-circuits `getCompletions` outright) rather than falling + // through to noun completion — an unresolved trailing flag (per + // that function's OWN last-character heuristic, not + // `legacyFindUnresolvedFlagToken`'s strict first-character parse) + // before an empty/non-flag toComplete is a hard stop (verified + // empirically against a real `apps/cli-go` build: `-ojson ""` + // returns zero candidates with the Default directive, even though + // `-ojson` is a perfectly valid `-o=json` under real pflag parsing + // — cobra's own heuristic looks at `-ojson`'s LAST character, `n`, + // which resolves to nothing). + return { candidates: [], directive: LegacyCompletionDirective.Default }; + } + if (!resolved.isBoolean) { + return legacyFlagValueCompletion(matchedPath, resolved.name); + } + // A resolved BOOLEAN precedingToken falls through to Case 3 — it + // never consumed a value, so this wasn't really flag-value + // completion. + } + } + + // Case 3: subcommand-name + required-flag (bare noun) completion. + const candidates: Array = []; + let directive: number = LegacyCompletionDirective.Default; + + // `help` is a real cobra subcommand with its own `ValidArgsFunction` that + // completes a SECOND, independent command-path lookup from root — see + // `legacyHelpArgumentCandidates`'s doc comment. `isAtRoot && + // trimmedArgs[0] === "help"` exactly identifies "this request is `help + // ...`": `help` isn't a node anywhere in this tree, so the outer + // `legacyResolveCommandPath` call always stops at root immediately when + // it's the first token (CLI-1965 review finding). + if (isAtRoot && trimmedArgs[0] === "help") { + return legacyHelpArgumentCandidates(finalCommand, trimmedArgs.slice(1), toComplete); + } + + // Once any flag or extra positional token has already appeared before this + // position, subcommand-name completion is suppressed entirely (cobra's + // `len(finalArgs) == 0` gate) — including the directive it would otherwise + // set, which stays at `Default` in that case (`completions.go:489,499-522`). + if (leftoverArgs.length === 0) { + const visibleSubcommands = legacyFlattenSubcommands(finalCommand).filter((sub) => !sub.hidden); + if (visibleSubcommands.length > 0) { + directive = LegacyCompletionDirective.NoFileComp; + const subcommandCandidates: Array = visibleSubcommands.map( + (sub) => ({ name: sub.name, description: sub.shortDescription ?? sub.description }), + ); + // Cobra's `InitDefaultHelpCmd` (`command.go:1100,1263-1266`) auto-registers a + // `help` subcommand on whichever command `ExecuteC()` is called against — + // here, always the root — but never recursively on descendants (verified + // empirically against a real `apps/cli-go` build: `__complete db ""` does + // NOT surface it, only `__complete ""` does). This TS tree has no explicit + // `help` command node to walk, so synthesize the one candidate cobra would + // otherwise contribute, matching its literal `Short` text. + if (isAtRoot) { + subcommandCandidates.push({ name: "help", description: "Help about any command" }); + } + // Cobra's own `Commands()` — what its subcommand-name completion walks — + // sorts alphabetically by name whenever `EnableCommandSorting` (the + // default) is on. This tree's subcommand declarations already happen to + // be listed alphabetically, so this sort is a no-op everywhere except at + // the root, where it places the synthetic "help" entry above in its + // correct alphabetical position. + subcommandCandidates.sort((a, b) => a.name.localeCompare(b.name)); + for (const candidate of subcommandCandidates) { + if (candidate.name.startsWith(toComplete)) { + candidates.push(candidate); + } + } + } + } + + // Unconditional append in cobra — not gated on `leftoverArgs`. + for (const flag of requiredFlags) { + candidates.push(...legacyFlagNameCandidates(flag, toComplete)); + } + + // Cobra always invokes a resolved command's own `ValidArgsFunction` (when + // registered) at the very end of `getCompletions`, and that call + // OVERWRITES whatever directive the subcommand walk above already set + // (`completions.go:564-579`) — see `LEGACY_COMPLETION_NO_FILE_COMP_PATHS`'s + // doc comment for which paths this applies to and why. + if (LEGACY_COMPLETION_NO_FILE_COMP_PATHS.has(matchedPath.join(" "))) { + directive = LegacyCompletionDirective.NoFileComp; + } + + return { candidates, directive }; +} + +/* ========================================================================== */ +/* Orchestration */ +/* ========================================================================== */ + +/** + * The pure, deps-free completion algorithm: resolves the command path, + * collects in-scope flags, and classifies the request. Returns `undefined` + * when `argv[0]` isn't a completion request, or when cobra's `args` (i.e. + * `argv.slice(1)`) is empty — mirroring cobra's own `MinimumNArgs(1)` failure + * (see the module doc comment for why that case isn't otherwise reproduced). + */ +export function legacyRespondToComplete( + root: Command.Command.Any, + argv: ReadonlyArray, +): LegacyCompletionResult | undefined { + if (argv[0] !== "__complete" && argv[0] !== "__completeNoDesc") return undefined; + + const args = argv.slice(1); + if (args.length === 0) return undefined; + + const toComplete = args[args.length - 1] ?? ""; + const trimmedArgs = args.slice(0, -1); + + const { commandChain, matchedPath, leftoverArgs } = legacyResolveCommandPath(root, trimmedArgs); + const finalCommand = commandChain[commandChain.length - 1] ?? root; + const inScopeFlags = legacyCollectInScopeFlags(root, commandChain); + + return legacyClassifyCompletion({ + finalCommand, + matchedPath, + leftoverArgs, + trimmedArgs, + toComplete, + inScopeFlags, + }); +} + +/* ========================================================================== */ +/* Response formatting */ +/* ========================================================================== */ + +const GO_TRUE_BOOL_SPELLINGS: ReadonlySet = new Set([ + "1", + "t", + "T", + "TRUE", + "true", + "True", +]); +const GO_FALSE_BOOL_SPELLINGS: ReadonlySet = new Set([ + "0", + "f", + "F", + "FALSE", + "false", + "False", +]); + +function legacyParseGoBool(value: string): boolean | undefined { + if (GO_TRUE_BOOL_SPELLINGS.has(value)) return true; + if (GO_FALSE_BOOL_SPELLINGS.has(value)) return false; + return undefined; +} + +/** + * Cobra's real, undocumented-to-users-but-real `getEnvConfig` behavior: + * `argv[0] === "__completeNoDesc"` always wins; otherwise + * `SUPABASE_COMPLETION_DESCRIPTIONS` (program-specific) is checked first, + * falling back to the generic `COBRA_COMPLETION_DESCRIPTIONS` when unset or + * empty. An unparseable value (per Go's `strconv.ParseBool` accepted + * spellings) is ignored, leaving the `argv[0]`-derived default in place. + */ +export function legacyResolveIncludeDescriptions( + argv0: string | undefined, + env: Readonly>, +): boolean { + let includeDescriptions = argv0 !== "__completeNoDesc"; + if (includeDescriptions) { + const raw = env.SUPABASE_COMPLETION_DESCRIPTIONS || env.COBRA_COMPLETION_DESCRIPTIONS || ""; + const parsed = legacyParseGoBool(raw); + if (parsed !== undefined) includeDescriptions = parsed; + } + return includeDescriptions; +} + +function legacyFormatCompletionLine( + candidate: LegacyCompletionCandidate, + includeDescriptions: boolean, +): string { + if (!includeDescriptions) return candidate.name.trim(); + const firstDescriptionLine = (candidate.description ?? "").split("\n")[0] ?? ""; + // `.trim()` on the whole joined string (not just the description) is what + // makes a candidate with no description end up as a bare name with no + // trailing tab, not `"name\t"` — reproduces cobra's exact `TrimSpace` step. + return `${candidate.name}\t${firstDescriptionLine}`.trim(); +} + +/** + * Formats a completion result the way cobra's generated shell scripts expect: + * one line per candidate (`name` or `name\tdescription`), then a final + * `:` line. Every line ends with `\n`. + */ +export function legacyFormatCompletionResponse( + response: LegacyCompletionResult, + includeDescriptions: boolean, +): string { + const lines = response.candidates.map((candidate) => + legacyFormatCompletionLine(candidate, includeDescriptions), + ); + lines.push(`:${response.directive}`); + return lines.map((line) => `${line}\n`).join(""); +} + +/* ========================================================================== */ +/* Entry point */ +/* ========================================================================== */ + +/** + * The `cli_command_executed` capture for a `__complete`/`__completeNoDesc` + * request, matching Go's `Execute()` (`apps/cli-go/cmd/root.go:168-204`), + * which captures this event for every resolved command — including cobra's + * hidden `__complete` — regardless of the handler's own outcome (CLI-1965 + * review finding: the deleted Go passthrough fired this on every tab press; + * this native interceptor silently stopped firing it). `command` is always + * the literal `"__complete"`, never `"__completeNoDesc"`: cobra registers + * `__completeNoDesc` as an ALIAS of `__complete` (`completions.go:234`), and + * Go's `commandName()` derives the recorded value from `cmd.Name()` — the + * command's own primary name, not the alias it was invoked as. `output_format` + * is the fixed literal `"text"`: `__complete` is `DisableFlagParsing: true`, + * so there is no resolved `--output`/`-o` value to mirror here. + * + * Deliberately narrower than `withLegacyCommandInstrumentation` + * (`legacy/telemetry/legacy-command-instrumentation.ts`): that wrapper is + * shaped for a real Effect `Command` handler running inside `runCli`'s full + * runtime (`CommandRuntime`/`Output`/`ProcessControl`/`Stdio`), none of which + * exist here — this interceptor runs before Effect's argv parser, before + * `runCli` ever bootstraps. This also deliberately does NOT reproduce the + * rest of Go's `Execute()`/`PersistentPreRunE` — profile loading, the workdir + * change, or the GitHub upgrade check — none of that has any bearing on the + * analytics contract, and real generated completion scripts discard this + * process's stderr outright, so reproducing the upgrade message would be a + * pure regression. Do not "fix" this back toward full `Execute()` parity. + * + * Only requires `Analytics` from context (not the concrete production + * layer) so tests can provide `mockAnalytics()` directly instead of the + * real, consent-gated `legacyAnalyticsLayer` — see + * `legacyCaptureCompleteTelemetry` below for the production wiring, and + * `legacy-complete.integration.test.ts` for the test double usage. + */ +export function legacyCaptureCompleteTelemetryEffect( + exitCode: number, + durationMs: number, +): Effect.Effect { + return Effect.gen(function* () { + const analytics = yield* Analytics; + yield* analytics.capture(EventCommandExecuted, { + [PropExitCode]: exitCode, + [PropDurationMs]: durationMs, + [PropOutputFormat]: "text", + }); + }).pipe( + withAnalyticsContext({ + command_run_id: crypto.randomUUID(), + command: "__complete", + flags: undefined, + }), + ); +} + +const LEGACY_COMPLETE_TELEMETRY_TIMEOUT = "2 seconds"; + +// `legacyAnalyticsLayer` on its own still needs `CliConfig | RuntimeInfo | Tty` +// (via the `telemetryRuntimeLayer` it folds in) on top of the `FileSystem`/ +// `Path` platform layer — `shared/cli/run.ts` normally supplies those as part +// of its own much larger composed tree. `standaloneAnalyticsConfigLayer` +// packages the same small set for a caller running outside that tree. +const legacyCompleteAnalyticsLayer = legacyAnalyticsLayer.pipe( + Layer.provide(standaloneAnalyticsConfigLayer), + Layer.provide(BunServices.layer), +); + +/** + * Production default for `LegacyCompleteDeps.captureTelemetry`: runs + * `legacyCaptureCompleteTelemetryEffect` against the real, consent-gated + * `legacyAnalyticsLayer`. + * + * Best-effort and bounded: a missing consent, network hiccup, or DNS failure + * must never hang or fail a user's tab press. `deps.exit` (see + * `legacyTryComplete` below) ultimately calls `process.exit`, which kills the + * process immediately without waiting for pending async work — callers must + * `await` this BEFORE exiting, or the capture will very likely never reach + * PostHog. + */ +function legacyCaptureCompleteTelemetry(exitCode: number, durationMs: number): Promise { + return Effect.runPromise( + legacyCaptureCompleteTelemetryEffect(exitCode, durationMs).pipe( + Effect.provide(legacyCompleteAnalyticsLayer), + Effect.timeout(LEGACY_COMPLETE_TELEMETRY_TIMEOUT), + Effect.ignore, + ), + ); +} + +/** + * Entry-point interceptor with the same shape/contract as the old + * `tryCompletePassthrough`: runs before Effect's CLI argv parser, returns + * `false` immediately (no side effects) when `deps.argv[0]` isn't a + * completion request, otherwise fully handles it and returns `true`. Async + * only because of `deps.captureTelemetry` above — every other helper in this + * file (`legacyRespondToComplete` and everything it calls) stays pure and + * synchronous; awaiting the capture here, before `deps.exit(...)`, is what + * lets it actually reach PostHog (see `legacyCaptureCompleteTelemetry`'s doc + * comment). + */ +export async function legacyTryComplete(deps: LegacyCompleteDeps): Promise { + if (deps.argv[0] !== "__complete" && deps.argv[0] !== "__completeNoDesc") return false; + + const startedAt = Date.now(); + const response = legacyRespondToComplete(deps.root, deps.argv); + if (response === undefined) { + await deps.captureTelemetry(1, Date.now() - startedAt); + deps.exit(1); + return true; + } + + const includeDescriptions = legacyResolveIncludeDescriptions(deps.argv[0], deps.env); + deps.stdoutWrite(legacyFormatCompletionResponse(response, includeDescriptions)); + await deps.captureTelemetry(0, Date.now() - startedAt); + deps.exit(0); + return true; +} + +export function legacyDefaultCompleteDeps(root: Command.Command.Any): LegacyCompleteDeps { + return { + root, + argv: process.argv.slice(2), + env: process.env, + stdoutWrite: (message) => { + process.stdout.write(message); + }, + exit: (code) => { + process.exit(code); + }, + captureTelemetry: legacyCaptureCompleteTelemetry, + }; +} diff --git a/apps/cli/src/legacy/cli/legacy-complete.unit.test.ts b/apps/cli/src/legacy/cli/legacy-complete.unit.test.ts new file mode 100644 index 0000000000..4bb7ec1cf3 --- /dev/null +++ b/apps/cli/src/legacy/cli/legacy-complete.unit.test.ts @@ -0,0 +1,1594 @@ +import { describe, expect, it, vi } from "vitest"; + +import { legacyRoot } from "./root.ts"; +import { + LegacyCompletionDirective, + type LegacyClassifyCompletionInput, + type LegacyCommandPathResolution, + type LegacyCompleteDeps, + type LegacyCompletionCandidate, + type LegacyCompletionResult, + type LegacyFlagDescriptor, + legacyClassifyCompletion, + legacyCollectInScopeFlags, + legacyDefaultCompleteDeps, + legacyFormatCompletionResponse, + legacyResolveCommandPath, + legacyResolveIncludeDescriptions, + legacyRespondToComplete, + legacyTryComplete, +} from "./legacy-complete.ts"; + +describe("legacyRespondToComplete", () => { + describe("subcommand-name completion", () => { + it("completes a subcommand-name prefix nested under a parent command", () => { + const result = legacyRespondToComplete(legacyRoot, ["__complete", "migration", "li"]); + expect(result?.directive).toBe(LegacyCompletionDirective.NoFileComp); + expect(result?.candidates.map((c) => c.name)).toContain("list"); + }); + + it("completes a subcommand-name prefix at the root", () => { + const result = legacyRespondToComplete(legacyRoot, ["__complete", "br"]); + expect(result?.directive).toBe(LegacyCompletionDirective.NoFileComp); + expect(result?.candidates.map((c) => c.name)).toContain("branches"); + }); + }); + + it("returns no candidates and the Default directive for a leaf command with no subcommands and no unset required flags", () => { + // `migration list` has no subcommands; `--db-url`/`--password` are optional, + // `--linked` defaults to true, and `--local` is a plain boolean, so nothing + // is left "required" once resolved (verified against migration/list/list.command.ts). + const result = legacyRespondToComplete(legacyRoot, ["__complete", "migration", "list", ""]); + expect(result).toEqual({ candidates: [], directive: LegacyCompletionDirective.Default }); + }); + + it("offers a global flag declared once at legacyRoot from a nested command path", () => { + // `--debug` is declared exactly once, via LEGACY_GLOBAL_FLAGS -> + // Command.withGlobalFlags on legacyRoot (shared/legacy/global-flags.ts, + // legacy/cli/root.ts) — it must still resolve from a resolved subcommand path. + const result = legacyRespondToComplete(legacyRoot, ["__complete", "branches", "--d"]); + expect(result?.directive).toBe(LegacyCompletionDirective.NoFileComp); + expect(result?.candidates.map((c) => c.name)).toContain("--debug"); + }); + + it("offers an ancestor's shared flag (Command.withSharedFlags) from a resolved leaf command", () => { + // `--no-cache` is declared once on the `db schema declarative` group via + // Command.withSharedFlags (declarative.shared.ts) and must be visible from + // its `generate` leaf. + const result = legacyRespondToComplete(legacyRoot, [ + "__complete", + "db", + "schema", + "declarative", + "generate", + "--no-c", + ]); + expect(result?.candidates.map((c) => c.name)).toContain("--no-cache"); + }); + + it("offers a non-root command's own declared global flags from a nested subcommand (Command.withGlobalFlags)", () => { + // seed.command.ts declares --linked/--local as scoped global flags via + // Command.withGlobalFlags on the `seed` group itself (Go's + // seedCmd.PersistentFlags()), not at legacyRoot — a collector that only + // reads root.globalFlags misses these entirely (CLI-1965 review finding). + const atGroup = legacyRespondToComplete(legacyRoot, ["__complete", "seed", "--l"]); + expect(atGroup?.candidates.map((c) => c.name)).toEqual( + expect.arrayContaining(["--linked", "--local"]), + ); + + const atLeaf = legacyRespondToComplete(legacyRoot, ["__complete", "seed", "buckets", "--l"]); + expect(atLeaf?.candidates.map((c) => c.name)).toEqual( + expect.arrayContaining(["--linked", "--local"]), + ); + }); + + it("does not duplicate a flag name that exists both globally and as a command's own local flag", () => { + // db diff declares its own local `output`/`-o` (a file path), shadowing + // the global `--output`/`-o` choice flag declared at root — pflag's + // InheritedFlags() skips a persistent flag shadowed by a same-named local + // one, so exactly one `--output` candidate (the local one) must appear, + // not two with contradictory descriptions. + const result = legacyRespondToComplete(legacyRoot, ["__complete", "db", "diff", "--o"]); + const outputCandidates = result?.candidates.filter((c) => c.name === "--output"); + expect(outputCandidates).toHaveLength(1); + expect(outputCandidates?.[0]?.description).toBe("Write explicit diff output to a file path."); + }); + + describe("subcommand completion is not blocked by a preceding global flag", () => { + it("lists root subcommands after a bare global flag with no value", () => { + // `--debug ""` used to return zero candidates entirely: the leftover-args + // computation counted `--debug` itself as "positional leftover," gating + // out subcommand-name completion the way cobra never does for a + // persistent flag (CLI-1965 review finding, empirically confirmed + // against a real apps/cli-go build: `__complete --debug ''` lists all 36 + // root commands there). + const result = legacyRespondToComplete(legacyRoot, ["__complete", "--debug", ""]); + expect(result?.directive).toBe(LegacyCompletionDirective.NoFileComp); + expect(result?.candidates.map((c) => c.name)).toContain("branches"); + }); + + it("lists subcommands after a value-taking global flag and its value", () => { + // `-o json ""` — "json" is `-o`'s consumed value, not a genuine extra + // positional argument, so it must not count as leftover either. + const result = legacyRespondToComplete(legacyRoot, ["__complete", "-o", "json", ""]); + expect(result?.directive).toBe(LegacyCompletionDirective.NoFileComp); + expect(result?.candidates.map((c) => c.name)).toContain("migration"); + }); + + it("still resolves and lists subcommands when the global flag appears before the group", () => { + const result = legacyRespondToComplete(legacyRoot, ["__complete", "--debug", "db", ""]); + expect(result?.directive).toBe(LegacyCompletionDirective.NoFileComp); + expect(result?.candidates.map((c) => c.name)).toContain("diff"); + }); + }); + + it("does not offer --version on any command other than the root", () => { + // Cobra's InitDefaultVersionFlag registers --version non-persistently on + // the root command only — it is never inherited by subcommands the way + // --help is (CLI-1965 review finding). + const atRoot = legacyRespondToComplete(legacyRoot, ["__complete", "--v"]); + expect(atRoot?.candidates.map((c) => c.name)).toContain("--version"); + + const atSubcommand = legacyRespondToComplete(legacyRoot, ["__complete", "db", "dump", "--v"]); + expect(atSubcommand?.candidates.map((c) => c.name)).not.toContain("--version"); + }); + + it("returns Default (not NoFileComp) when the resolved command doesn't match a real subcommand", () => { + // Mirrors cobra: the NoFileComp directive is only set INSIDE the + // `len(finalArgs) == 0` gate, alongside the subcommand loop — a bogus + // trailing token (which becomes non-empty leftover) must leave the + // directive at Default, not force NoFileComp just because `db` itself has + // subcommands (CLI-1965 review finding; verified against a real + // apps/cli-go build: `__complete db bogus ''` → `:0`). + const result = legacyRespondToComplete(legacyRoot, ["__complete", "db", "bogus", ""]); + expect(result).toEqual({ candidates: [], directive: LegacyCompletionDirective.Default }); + }); + + describe("help/version short-circuit", () => { + it("short-circuits to no candidates once --help/-h appears anywhere in the args", () => { + const result = legacyRespondToComplete(legacyRoot, [ + "__complete", + "branches", + "--help", + "li", + ]); + expect(result).toEqual({ + candidates: [], + directive: LegacyCompletionDirective.NoFileComp, + }); + }); + + it("short-circuits on --version/-v only when resolved to the root command", () => { + const result = legacyRespondToComplete(legacyRoot, ["__complete", "--version", "br"]); + expect(result).toEqual({ + candidates: [], + directive: LegacyCompletionDirective.NoFileComp, + }); + }); + + it("does not short-circuit on a subcommand's own local --version flag away from the root", () => { + // migration/squash/squash.command.ts declares its own `--version` + // (a target migration version string) — unrelated to cobra's built-in + // root-only version flag. Typing it while completing a *different* + // flag on the same command must behave like normal flag-name + // completion, not trip the root-only help/version short-circuit + // (CLI-1965 review finding). + const result = legacyRespondToComplete(legacyRoot, [ + "__complete", + "migration", + "squash", + "--version", + "20240101000000", + "--l", + ]); + expect(result?.candidates.map((c) => c.name)).toContain("--linked"); + }); + + it("does not short-circuit on --help positioned after a genuine `--` terminator (it is positional, not a flag)", () => { + // A raw token scan for the literal string "--help" over-triggers once + // `--help` appears anywhere, even past an unconsumed `--` sentinel, + // where pflag never parses it as a flag at all (verified empirically + // against a real apps/cli-go build: `db dump -- --help ""` returns + // zero candidates with the DEFAULT directive — `db dump`'s own file + // completion — not the help short-circuit's NoFileComp — CLI-1965 + // review finding). + const result = legacyRespondToComplete(legacyRoot, [ + "__complete", + "db", + "dump", + "--", + "--help", + "", + ]); + expect(result).toEqual({ candidates: [], directive: LegacyCompletionDirective.Default }); + }); + + it("does not short-circuit on --version consumed as a PRECEDING flag's own string value", () => { + // `--workdir` is a value-taking global flag; pflag consumes the very + // next token as its value regardless of what that token looks like, + // so `--version` here is `--workdir`'s value, never parsed as a flag + // occurrence (verified empirically against a real apps/cli-go build: + // `--workdir --version br` still completes `branches`, not the + // version short-circuit — CLI-1965 review finding). + const result = legacyRespondToComplete(legacyRoot, [ + "__complete", + "--workdir", + "--version", + "br", + ]); + expect(result?.candidates.map((c) => c.name)).toContain("branches"); + expect(result?.directive).toBe(LegacyCompletionDirective.NoFileComp); + }); + }); + + describe("unmatched root-level command (CLI-1965 review)", () => { + it("returns Default with zero candidates for a flag typed after an unmatched ROOT-level positional", () => { + // Mirrors cobra's Command.Find -> legacyArgs (args.go:28-37): resolving + // to root itself (no descent at all) with a leftover positional is an + // "unknown command" error there, wins even over the --help/--version + // short-circuit, and is stricter than the same situation under any + // OTHER resolved command (verified empirically against a real + // apps/cli-go build: `nosuch --d` and `nosuch --help` both return zero + // candidates with the Default directive, while `db bogus --d` — `db` + // itself resolves — still offers `db`'s own --debug/--dns-resolver + // normally — CLI-1965 review finding). + const unknownRoot = legacyRespondToComplete(legacyRoot, ["__complete", "nosuch", "--d"]); + expect(unknownRoot).toEqual({ candidates: [], directive: LegacyCompletionDirective.Default }); + + const unknownRootWithHelp = legacyRespondToComplete(legacyRoot, [ + "__complete", + "nosuch", + "--help", + ]); + expect(unknownRootWithHelp).toEqual({ + candidates: [], + directive: LegacyCompletionDirective.Default, + }); + + const knownCommandWithLeftover = legacyRespondToComplete(legacyRoot, [ + "__complete", + "db", + "bogus", + "--d", + ]); + expect(knownCommandWithLeftover?.candidates.map((c) => c.name)).toEqual( + expect.arrayContaining(["--debug", "--dns-resolver"]), + ); + }); + + it("does not treat a surviving bare `-` leftover as an unmatched command (pflag's stripFlags drops it)", () => { + // cobra's own stripFlags (command.go:674-710) — which legacyArgs' error + // check runs against — drops a lone `-` from its leftover count + // entirely, unlike legacyResolveCommandPath's own leftoverArgs (which + // deliberately keeps it for other purposes) (verified empirically + // against a real apps/cli-go build: `__complete - --d` still offers + // root's own --debug/--dns-resolver — CLI-1965 review finding). + const result = legacyRespondToComplete(legacyRoot, ["__complete", "-", "--d"]); + expect(result?.candidates.map((c) => c.name)).toEqual( + expect.arrayContaining(["--debug", "--dns-resolver"]), + ); + }); + + it("does not apply the unmatched-root check to a genuine `help ...` request", () => { + // `help` is not a real node in this tree (see + // legacyHelpArgumentCandidates's doc comment), so the outer resolution + // always sees it as an immediate non-match at root — this must not be + // mistaken for cobra's real "unknown command" error, which never fires + // for `help` since real cobra's help command IS a real child of root. + const result = legacyRespondToComplete(legacyRoot, ["__complete", "help", "db", "d"]); + expect(result?.candidates.map((c) => c.name)).toEqual( + expect.arrayContaining(["diff", "dump"]), + ); + }); + }); + + describe("required-flag short-circuit", () => { + it("offers exactly the one required flag and nothing else for a command with a single required flag", () => { + // domains/create/create.command.ts: `customHostname: Flag.string("custom-hostname")` + // has no `.pipe(Flag.optional)`/`.pipe(Flag.withDefault(...))`, so it genuinely + // fails to parse when omitted — `projectRef` is optional and + // `includeRawOutput` is boolean, so neither is offered alongside it. + const result = legacyRespondToComplete(legacyRoot, ["__complete", "domains", "create", ""]); + expect(result).toEqual({ + candidates: [ + { + name: "--custom-hostname", + description: "The custom hostname to use for your Supabase project.", + }, + ], + directive: LegacyCompletionDirective.Default, + }); + }); + + it("short-circuits on a flag Go marks required even though this port made it optional at parse time", () => { + // vanity-subdomains/activate/activate.command.ts wraps `desiredSubdomain` + // in `.pipe(Flag.optional)` on purpose (presence is enforced later, in + // the handler, to let the --experimental gate and login check run + // first) — but cobra's completion-time required-flag annotation + // (`MarkFlagRequired`, `cmd/vanitySubdomains.go:67`) is independent of + // TS's parse-time validation ordering, so completion must still offer + // exactly this flag, matching real cobra (CLI-1965 review finding: + // structural inference from `Flag.optional` is not a faithful proxy for + // "does cobra mark it required" — `LEGACY_COMPLETION_REQUIRED_FLAGS` is + // the explicit table that fixes this). + const result = legacyRespondToComplete(legacyRoot, [ + "__complete", + "vanity-subdomains", + "activate", + "", + ]); + expect(result).toEqual({ + candidates: [ + { + name: "--desired-subdomain", + description: "The desired vanity subdomain to use for your Supabase project.", + }, + ], + directive: LegacyCompletionDirective.Default, + }); + }); + + it("does not treat a zero-minimum variadic flag (Flag.atLeast(0)) as required", () => { + // sso/add/add.command.ts: `domains: legacySsoAddDomainsFlag` builds on + // `legacyStringSliceFlag`, which is `Flag.string(...).pipe(..., Flag.atLeast(0))` + // — a `Variadic` param with `min: 0`. Go never calls + // `MarkFlagRequired("domains")` (only `type`), so `--domains` must not be + // force-offered here — only the genuinely required `--type`/`-t` should + // appear. (Required-ness now comes from the explicit + // `LEGACY_COMPLETION_REQUIRED_FLAGS` table, not structural inference, but + // this scenario is worth keeping as its own regression case.) + const result = legacyRespondToComplete(legacyRoot, ["__complete", "sso", "add", ""]); + expect(result?.candidates.map((c) => c.name)).not.toContain("--domains"); + expect(result).toEqual({ + candidates: [ + { name: "--type", description: expect.any(String) }, + { name: "-t", description: expect.any(String) }, + ], + directive: LegacyCompletionDirective.Default, + }); + }); + }); + + describe("flag-value completion", () => { + it.each([ + { command: "add", flag: "metadata-file", extension: "xml" }, + { command: "add", flag: "attribute-mapping-file", extension: "json" }, + { command: "update", flag: "metadata-file", extension: "xml" }, + { command: "update", flag: "attribute-mapping-file", extension: "json" }, + ])( + "restricts $flag on sso $command to the $extension file extension", + ({ command, flag, extension }) => { + // sso/add/add.command.ts and sso/update/update.command.ts both declare + // --metadata-file/--attribute-mapping-file; the Go CLI's cmd/sso.go + // MarkFlagFilename calls are ported as a small lookup table + // (LEGACY_COMPLETION_FLAG_FILE_EXTENSIONS) rather than derived generically. + const result = legacyRespondToComplete(legacyRoot, [ + "__complete", + "sso", + command, + `--${flag}=`, + ]); + expect(result).toEqual({ + candidates: [{ name: extension, description: undefined }], + directive: LegacyCompletionDirective.FilterFileExt, + }); + }, + ); + + it("never completes a choice flag's value (sso add --type )", () => { + // sso/add/add.command.ts: `type: Flag.choice("type", ["saml"])` has no + // registered ValidArgsFunction equivalent in Go, so its value slot must + // resolve to empty candidates + Default, not an enumeration of "saml". + const result = legacyRespondToComplete(legacyRoot, [ + "__complete", + "sso", + "add", + "--type", + "", + ]); + expect(result).toEqual({ candidates: [], directive: LegacyCompletionDirective.Default }); + }); + + it("does not treat a boolean flag as consuming a following value", () => { + // sso/add/add.command.ts: `skipUrlValidation: Flag.boolean("skip-url-validation")`. + // A boolean flag must fall through to Case 3 (bare noun completion) with the + // unchanged toComplete/trimmedArgs, so this must equal the bare `sso add ""` + // response exactly rather than resolve to some flag-value result. + const withBooleanFlag = legacyRespondToComplete(legacyRoot, [ + "__complete", + "sso", + "add", + "--skip-url-validation", + "", + ]); + const bareNoun = legacyRespondToComplete(legacyRoot, ["__complete", "sso", "add", ""]); + expect(withBooleanFlag).toEqual(bareNoun); + expect(withBooleanFlag?.candidates.length).toBeGreaterThan(0); + }); + }); + + describe("changed-flag exclusion and the variadic exception", () => { + it("excludes an already-supplied, non-repeatable flag's own name from further completion", () => { + // sso/add/add.command.ts: `metadataUrl: Flag.string("metadata-url")` is not + // variadic, so once supplied it must not be offered again. + const result = legacyRespondToComplete(legacyRoot, [ + "__complete", + "sso", + "add", + "--metadata-url", + "https://x", + "--m", + ]); + const names = result?.candidates.map((c) => c.name); + expect(names).toContain("--metadata-file"); + expect(names).not.toContain("--metadata-url"); + }); + + it("keeps offering a variadic flag's own name even after it has already been supplied", () => { + // sso/add/add.command.ts: `domains: legacySsoAddDomainsFlag` is built on + // `Flag.atLeast(0)` (repeatable) — Go's real doCompleteFlags keeps a + // Slice/Array-typed flag in the completion list even once `flag.Changed`. + const result = legacyRespondToComplete(legacyRoot, [ + "__complete", + "sso", + "add", + "--domains", + "example.com", + "--d", + ]); + expect(result?.candidates.map((c) => c.name)).toContain("--domains"); + }); + }); + + describe("flag terminator (`--`) disables flag completion (CLI-1965 review)", () => { + it("does not offer a flag-name candidate for a positional operand after `--`", () => { + // Cobra's flagCompletion gate goes false once a `--` is already present + // in the args (completions.go:364-381), so a positional operand that + // happens to start with `-` after the terminator must not be treated as + // a flag-name completion (verified empirically against a real + // apps/cli-go build: `db dump -- --s` returns zero candidates with the + // Default directive, not `--schema`). + const result = legacyRespondToComplete(legacyRoot, ["__complete", "db", "dump", "--", "--s"]); + expect(result).toEqual({ candidates: [], directive: LegacyCompletionDirective.Default }); + }); + + it("still offers a required flag that appears (as a positional) after `--`", () => { + // completeRequireFlags is called unconditionally in cobra's noun- + // completion branch, even past the terminator — and a token past `--` + // is never parsed as a flag at all, so it must not be marked "changed" + // either (verified empirically against a real apps/cli-go build: `sso + // add -- --type --typ` still offers `--type` with the Default + // directive). + const result = legacyRespondToComplete(legacyRoot, [ + "__complete", + "sso", + "add", + "--", + "--type", + "--typ", + ]); + expect(result).toEqual({ + candidates: [{ name: "--type", description: expect.any(String) }], + directive: LegacyCompletionDirective.Default, + }); + }); + }); + + describe("attached shorthand values resolve via pflag's real strict parser (CLI-1965 review)", () => { + it("parses a non-boolean shorthand's attached value instead of treating the token as unknown", () => { + // pflag's parseSingleShortArg resolves a shorthand cluster's value by + // its FIRST character, not its last — `-j4` is a fully valid, + // already-resolved `--jobs=4`, so it must not suppress completion of a + // later flag name (verified empirically against a real apps/cli-go + // build: `functions deploy -j4 --p` still offers + // --profile/--project-ref/--prune). + const result = legacyRespondToComplete(legacyRoot, [ + "__complete", + "functions", + "deploy", + "-j4", + "--p", + ]); + expect(result?.candidates.map((c) => c.name)).toEqual( + expect.arrayContaining(["--profile", "--project-ref", "--prune"]), + ); + expect(result?.directive).toBe(LegacyCompletionDirective.NoFileComp); + }); + }); + + describe("a trailing incomplete flag is a hard parse error only when toComplete is itself flag-shaped (CLI-1965 review)", () => { + it("rejects a dangling value-taking flag when toComplete is a bare flag-shaped token", () => { + // Cobra's checkIfFlagCompletion only rescues a trailing incomplete flag + // from ParseFlags() when toComplete is empty or not flag-shaped + // (completions.go:666-687); `-o --d` leaves `-o` dangling with no + // rescue, so the real ParseFlags() call fails outright (verified + // empirically against a real apps/cli-go build: zero candidates with + // the Default directive — Go's error is "flag needs an argument: 'o' + // in -o"). + const result = legacyRespondToComplete(legacyRoot, ["__complete", "-o", "--d"]); + expect(result).toEqual({ candidates: [], directive: LegacyCompletionDirective.Default }); + }); + + it.each([ + { toComplete: "", label: "empty toComplete" }, + { toComplete: "pre", label: "non-flag-shaped toComplete" }, + ])( + "still falls through to flag-VALUE completion for the same dangling flag given $label", + ({ toComplete }) => { + const result = legacyRespondToComplete(legacyRoot, ["__complete", "-o", toComplete]); + expect(result).toEqual({ candidates: [], directive: LegacyCompletionDirective.Default }); + }, + ); + + it("rejects a dangling value-taking flag even when toComplete is a DIFFERENT flag's attached-value token", () => { + // A `toComplete` containing `=` still identifies its OWN flag name + // (checkIfFlagCompletion's flagWithEqual branch) — that never rescues + // an earlier, different dangling flag out of finalArgs, so + // ParseFlags() still fails on it (verified empirically against a real + // apps/cli-go build: `sso add --type saml --metadata-file + // --attribute-mapping-file=` returns zero candidates with the Default + // directive — Go's error is "flag needs an argument: --metadata-file" + // — even though `--attribute-mapping-file` is itself a real flag with + // a registered file-extension completion). + const result = legacyRespondToComplete(legacyRoot, [ + "__complete", + "sso", + "add", + "--type", + "saml", + "--metadata-file", + "--attribute-mapping-file=", + ]); + expect(result).toEqual({ candidates: [], directive: LegacyCompletionDirective.Default }); + }); + }); + + describe("changed-flag tracking honors a long flag's real value consumption (CLI-1965 review)", () => { + it("does not mark a value token as its own changed flag, so a still-required flag stays offered", () => { + // pflag's parseLongArg consumes the immediately following token as a + // non-boolean flag's value regardless of its shape — `--domains` (a + // string flag) consumes `--type` here, so `--type` itself was never + // parsed as a flag and must still be offered as required (verified + // empirically against a real apps/cli-go build: `sso add --domains + // --type foo --typ` still offers `--type` with the NoFileComp + // directive). + const result = legacyRespondToComplete(legacyRoot, [ + "__complete", + "sso", + "add", + "--domains", + "--type", + "foo", + "--typ", + ]); + expect(result).toEqual({ + candidates: [{ name: "--type", description: expect.any(String) }], + directive: LegacyCompletionDirective.NoFileComp, + }); + }); + }); + + describe("a boolean flag with an explicit `=` is still flag-VALUE completion (CLI-1965 review)", () => { + it("does not fall through to noun completion for `--boolFlag=value`", () => { + // Cobra's checkIfFlagCompletion only resets a boolean flag back to noun + // completion in the no-`=` two-token case (`!flagWithEqual` guard); + // `--debug=maybe` keeps `flag` set and goes to flag-VALUE completion, + // which resolves to zero candidates (verified empirically against a + // real apps/cli-go build: the Default directive, not the root command + // list). + const result = legacyRespondToComplete(legacyRoot, ["__complete", "--debug=maybe"]); + expect(result).toEqual({ candidates: [], directive: LegacyCompletionDirective.Default }); + }); + }); + + describe("completion script leaves force NoFileComp (CLI-1965 review)", () => { + it.each(["bash", "zsh", "fish", "powershell"])( + "returns zero candidates with the NoFileComp directive for `completion %s`", + (shell) => { + // Cobra's InitDefaultCompletionCmd registers ValidArgsFunction: + // NoFileCompletions on the completion group and each of its shell + // leaves; getCompletions always calls a resolved command's own + // ValidArgsFunction, overwriting the directive outright (verified + // empirically against a real apps/cli-go build). + const result = legacyRespondToComplete(legacyRoot, ["__complete", "completion", shell, ""]); + expect(result).toEqual({ candidates: [], directive: LegacyCompletionDirective.NoFileComp }); + }, + ); + }); + + describe("help's own ValidArgsFunction resolves a second command path from root (CLI-1965 review)", () => { + it("completes root subcommand names after `help`", () => { + // Cobra's auto-registered help command has its own ValidArgsFunction + // (command.go:1274-1290) that re-resolves everything after `help` from + // root — `help d` completes as if `d` were being completed at the + // root itself, not as an argument to `help` (verified empirically + // against a real apps/cli-go build: `db`, `domains`). + const result = legacyRespondToComplete(legacyRoot, ["__complete", "help", "d"]); + expect(result?.directive).toBe(LegacyCompletionDirective.NoFileComp); + expect(result?.candidates.map((c) => c.name)).toEqual( + expect.arrayContaining(["db", "domains"]), + ); + }); + + it("completes a resolved subcommand's own children after `help `", () => { + // `help db d` completes as `db d` would — db's own subcommands, not + // help's (verified empirically against a real apps/cli-go build: + // `diff`, `dump`). + const result = legacyRespondToComplete(legacyRoot, ["__complete", "help", "db", "d"]); + expect(result?.directive).toBe(LegacyCompletionDirective.NoFileComp); + expect(result?.candidates.map((c) => c.name)).toEqual( + expect.arrayContaining(["diff", "dump"]), + ); + }); + + it("returns no candidates for a leaf command with no subcommands of its own", () => { + // `db dump` is a leaf; cobra's ValidArgsFunction loops over its empty + // Commands() and finds nothing, but still sets NoFileComp (verified + // empirically against a real apps/cli-go build). + const result = legacyRespondToComplete(legacyRoot, ["__complete", "help", "db", "dump", "s"]); + expect(result).toEqual({ candidates: [], directive: LegacyCompletionDirective.NoFileComp }); + }); + + it("returns no candidates for an unresolved token directly under root", () => { + // Cobra's legacyArgs validator (args.go:28-37) only errors the + // "unknown command" case when the resolved command IS root and a + // token is left over (verified empirically against a real + // apps/cli-go build: `help bogus d` -> zero candidates). + const result = legacyRespondToComplete(legacyRoot, ["__complete", "help", "bogus", "d"]); + expect(result).toEqual({ candidates: [], directive: LegacyCompletionDirective.NoFileComp }); + }); + + it("still lists a resolved non-root command's subcommands past an unresolved token", () => { + // The same legacyArgs validator never errors for a non-root resolved + // command, even with leftover args — subcommands "will always accept + // arbitrary arguments" (verified empirically against a real + // apps/cli-go build: `help db bogus d` still offers db's own + // subcommands). + const result = legacyRespondToComplete(legacyRoot, [ + "__complete", + "help", + "db", + "bogus", + "d", + ]); + expect(result?.directive).toBe(LegacyCompletionDirective.NoFileComp); + expect(result?.candidates.map((c) => c.name)).toEqual( + expect.arrayContaining(["diff", "dump"]), + ); + }); + + it("includes the synthetic `help` candidate itself when resolved back to root", () => { + // Cobra's help command is one of root's own Commands(), so completing + // help's arguments back at root re-lists help too (verified + // empirically against a real apps/cli-go build: `help h` -> `help`). + const result = legacyRespondToComplete(legacyRoot, ["__complete", "help", "h"]); + expect(result).toEqual({ + candidates: [{ name: "help", description: "Help about any command" }], + directive: LegacyCompletionDirective.NoFileComp, + }); + }); + }); + + describe("uint-backed flags reject a leading sign like real pflag's ParseUint (CLI-1965 review)", () => { + it.each([ + { path: ["functions", "deploy"], flag: "jobs" }, + { path: ["migration", "down"], flag: "last" }, + { path: ["db", "reset"], flag: "last" }, + ])("rejects a negative value for $path --$flag", ({ path, flag }) => { + // Go registers these as UintVarP/UintVar (strconv.ParseUint(s, 0, 64)), + // which rejects any sign prefix outright — unlike this tree's plain + // signed Flag.integer, whose generic regex accepts one (verified + // empirically against a real apps/cli-go build: zero candidates with + // the Default directive for all three). + const result = legacyRespondToComplete(legacyRoot, [ + "__complete", + ...path, + `--${flag}`, + "-1", + "", + ]); + expect(result).toEqual({ candidates: [], directive: LegacyCompletionDirective.Default }); + }); + + it("still accepts a valid uint value, including the zero boundary", () => { + // Regression guard: the stricter check must not reject what real Go + // accepts (verified empirically against a real apps/cli-go build: + // `db reset --last 0 --d` still offers --debug/--dns-resolver/--db-url). + const result = legacyRespondToComplete(legacyRoot, [ + "__complete", + "db", + "reset", + "--last", + "0", + "--d", + ]); + expect(result?.directive).toBe(LegacyCompletionDirective.NoFileComp); + expect(result?.candidates.map((c) => c.name)).toEqual( + expect.arrayContaining(["--debug", "--dns-resolver", "--db-url"]), + ); + }); + }); + + describe("--output's choice values are validated per-command, not the widened global union (CLI-1965 review)", () => { + it("rejects db query's own local values (table/csv) everywhere else", () => { + // The global LegacyOutputFlag's choiceKeys is the UNION of root's + // 5-value enum and db query's own 3-value enum (legacy-go-output- + // flag.ts), but real Go's root persistent --output only accepts + // env|pretty|json|toml|yaml (verified empirically against a real + // apps/cli-go build: `--output table ""` -> zero candidates with the + // Default directive). + const result = legacyRespondToComplete(legacyRoot, ["__complete", "--output", "table", ""]); + expect(result).toEqual({ candidates: [], directive: LegacyCompletionDirective.Default }); + }); + + it("rejects the resource-command values (env/pretty/toml/yaml) under db query", () => { + // The reverse direction of the same defect: db query's own Go enum is + // json|table|csv only (verified empirically against a real + // apps/cli-go build: `db query --output env ""` -> zero candidates + // with the Default directive, even though `env` is valid everywhere + // else). + const result = legacyRespondToComplete(legacyRoot, [ + "__complete", + "db", + "query", + "--output", + "env", + "", + ]); + expect(result).toEqual({ candidates: [], directive: LegacyCompletionDirective.Default }); + }); + + it("accepts db query's own values (table/csv) under db query", () => { + const result = legacyRespondToComplete(legacyRoot, [ + "__complete", + "db", + "query", + "--output", + "table", + "--li", + ]); + expect(result?.candidates.map((c) => c.name)).toContain("--linked"); + }); + + it("accepts the resource-command values (env/pretty/toml/yaml) outside db query", () => { + const result = legacyRespondToComplete(legacyRoot, ["__complete", "--output", "env", ""]); + expect(result?.directive).toBe(LegacyCompletionDirective.NoFileComp); + expect(result?.candidates.map((c) => c.name)).toContain("branches"); + }); + + it("still accepts json everywhere — the one value both Go enums share", () => { + const result = legacyRespondToComplete(legacyRoot, ["__complete", "--output", "json", ""]); + expect(result?.directive).toBe(LegacyCompletionDirective.NoFileComp); + expect(result?.candidates.map((c) => c.name)).toContain("branches"); + }); + }); + + describe("flag values are validated the way real pflag parses them (CLI-1965 review)", () => { + it("accepts a base-0 hex value for a plain (non-uint) integer flag", () => { + // Go's plain int64 flags parse via strconv.ParseInt(s, 0, 64) — base 0, + // so a `0x`-prefixed value is valid, unlike a decimal-only regex + // (verified empirically against a real apps/cli-go build: `backups + // restore --timestamp 0x10 --p` still offers --profile/--project-ref). + const result = legacyRespondToComplete(legacyRoot, [ + "__complete", + "backups", + "restore", + "--timestamp", + "0x10", + "--p", + ]); + expect(result?.directive).toBe(LegacyCompletionDirective.NoFileComp); + expect(result?.candidates.map((c) => c.name)).toEqual( + expect.arrayContaining(["--profile", "--project-ref"]), + ); + }); + + it("rejects a value one past int64 max for a plain (non-uint) integer flag", () => { + // Go's plain int64 flags parse via strconv.ParseInt(s, 0, 64), a + // NARROWER signed range than the uint64 bound `Flag.integer` alone + // would suggest — 9223372036854775808 is a syntactically valid uint64 + // but exceeds int64 max by one (verified empirically against a real + // apps/cli-go build: `backups restore --timestamp 9223372036854775808 + // --p` returns zero candidates with the Default directive, while + // int64's real bounds, 9223372036854775807 and -9223372036854775808, + // both still offer --profile/--project-ref — CLI-1965 review finding). + const overflow = legacyRespondToComplete(legacyRoot, [ + "__complete", + "backups", + "restore", + "--timestamp", + "9223372036854775808", + "--p", + ]); + expect(overflow).toEqual({ candidates: [], directive: LegacyCompletionDirective.Default }); + + const max = legacyRespondToComplete(legacyRoot, [ + "__complete", + "backups", + "restore", + "--timestamp", + "9223372036854775807", + "--p", + ]); + expect(max?.candidates.map((c) => c.name)).toContain("--profile"); + + const min = legacyRespondToComplete(legacyRoot, [ + "__complete", + "backups", + "restore", + "--timestamp", + "-9223372036854775808", + "--p", + ]); + expect(min?.candidates.map((c) => c.name)).toContain("--profile"); + }); + + it("rejects a malformed value for Go's DurationVar flags (gen types --query-timeout, gen bearer-jwt --valid-for)", () => { + // Both are declared Flag.string in TS but DurationVar in Go + // (cmd/gen.go:161,179), parsed via time.ParseDuration before Cobra + // generates completions (verified empirically against a real + // apps/cli-go build: both `bogus` values return zero candidates with + // the Default directive, while `5s`/`1h` still complete normally — + // CLI-1965 review finding). + const queryTimeout = legacyRespondToComplete(legacyRoot, [ + "__complete", + "gen", + "types", + "--query-timeout", + "bogus", + "--l", + ]); + expect(queryTimeout).toEqual({ + candidates: [], + directive: LegacyCompletionDirective.Default, + }); + + const queryTimeoutValid = legacyRespondToComplete(legacyRoot, [ + "__complete", + "gen", + "types", + "--query-timeout", + "5s", + "--l", + ]); + expect(queryTimeoutValid?.candidates.map((c) => c.name)).toContain("--local"); + + const validFor = legacyRespondToComplete(legacyRoot, [ + "__complete", + "gen", + "bearer-jwt", + "--role", + "anon", + "--valid-for", + "bogus", + "--p", + ]); + expect(validFor).toEqual({ candidates: [], directive: LegacyCompletionDirective.Default }); + + const validForValid = legacyRespondToComplete(legacyRoot, [ + "__complete", + "gen", + "bearer-jwt", + "--role", + "anon", + "--valid-for", + "1h", + "--p", + ]); + expect(validForValid?.candidates.map((c) => c.name)).toContain("--profile"); + }); + + it("rejects a duration one unit past Go's int64 nanosecond range for Go's DurationVar flags", () => { + // Go's DurationVar parses via time.ParseDuration, which accumulates + // into an int64 nanosecond count — a syntactically well-formed + // duration can still overflow that range (verified empirically + // against a real apps/cli-go build: `gen types --query-timeout + // 2562048h --l` — one hour past the real max — returns zero + // candidates with the Default directive, matching `bogus`, while the + // exact int64 max, `2562047h47m16.854775807s`, still completes + // normally — CLI-1965 review finding). + const overflow = legacyRespondToComplete(legacyRoot, [ + "__complete", + "gen", + "types", + "--query-timeout", + "2562048h", + "--l", + ]); + expect(overflow).toEqual({ candidates: [], directive: LegacyCompletionDirective.Default }); + + const max = legacyRespondToComplete(legacyRoot, [ + "__complete", + "gen", + "types", + "--query-timeout", + "2562047h47m16.854775807s", + "--l", + ]); + expect(max?.candidates.map((c) => c.name)).toContain("--local"); + }); + + it("rejects a malformed value for Go's TimeVar flag (gen bearer-jwt --exp, RFC3339 only)", () => { + // Declared Flag.string in TS but a TimeVar constrained to time.RFC3339 + // in Go (cmd/gen.go:178) (verified empirically against a real + // apps/cli-go build: `bogus` returns zero candidates with the Default + // directive, while a real RFC3339 timestamp still completes normally + // — CLI-1965 review finding). + const invalid = legacyRespondToComplete(legacyRoot, [ + "__complete", + "gen", + "bearer-jwt", + "--role", + "anon", + "--exp", + "bogus", + "--p", + ]); + expect(invalid).toEqual({ candidates: [], directive: LegacyCompletionDirective.Default }); + + const valid = legacyRespondToComplete(legacyRoot, [ + "__complete", + "gen", + "bearer-jwt", + "--role", + "anon", + "--exp", + "2024-01-02T15:04:05Z", + "--p", + ]); + expect(valid?.candidates.map((c) => c.name)).toContain("--profile"); + }); + + it("rejects an out-of-range RFC3339 zone offset for gen bearer-jwt --exp", () => { + // Go's time.Parse(time.RFC3339, s) independently caps the offset hour + // at 24 (not 23) and the offset minute at 60 (not 59) — verified + // empirically against go1.26 time.Parse: "+24:00" and "+00:60" parse + // successfully, while "+25:00" and "+00:61" both fail with "time zone + // offset hour/minute out of range" (CLI-1965 review finding). + const outOfRangeHour = legacyRespondToComplete(legacyRoot, [ + "__complete", + "gen", + "bearer-jwt", + "--role", + "anon", + "--exp", + "2024-01-02T15:04:05+25:00", + "--p", + ]); + expect(outOfRangeHour).toEqual({ + candidates: [], + directive: LegacyCompletionDirective.Default, + }); + + const outOfRangeMinute = legacyRespondToComplete(legacyRoot, [ + "__complete", + "gen", + "bearer-jwt", + "--role", + "anon", + "--exp", + "2024-01-02T15:04:05+00:61", + "--p", + ]); + expect(outOfRangeMinute).toEqual({ + candidates: [], + directive: LegacyCompletionDirective.Default, + }); + + const boundaryValid = legacyRespondToComplete(legacyRoot, [ + "__complete", + "gen", + "bearer-jwt", + "--role", + "anon", + "--exp", + "2024-01-02T15:04:05+24:00", + "--p", + ]); + expect(boundaryValid?.candidates.map((c) => c.name)).toContain("--profile"); + }); + + it("accepts a comma-separated fractional second for gen bearer-jwt --exp", () => { + // Go's time.Parse(time.RFC3339, s) accepts either `.` or `,` before the + // fractional-seconds digits (verified empirically against go1.26 + // time.Parse: "2024-01-02T15:04:05,5Z" parses identically to + // "...05.5Z"; a bare "," with no following digit, or mixing both + // separators in one timestamp, both still fail — CLI-1965 review + // finding). + const commaFraction = legacyRespondToComplete(legacyRoot, [ + "__complete", + "gen", + "bearer-jwt", + "--role", + "anon", + "--exp", + "2024-01-02T15:04:05,5Z", + "--p", + ]); + expect(commaFraction?.candidates.map((c) => c.name)).toContain("--profile"); + + const emptyCommaFraction = legacyRespondToComplete(legacyRoot, [ + "__complete", + "gen", + "bearer-jwt", + "--role", + "anon", + "--exp", + "2024-01-02T15:04:05,Z", + "--p", + ]); + expect(emptyCommaFraction).toEqual({ + candidates: [], + directive: LegacyCompletionDirective.Default, + }); + + const mixedSeparators = legacyRespondToComplete(legacyRoot, [ + "__complete", + "gen", + "bearer-jwt", + "--role", + "anon", + "--exp", + "2024-01-02T15:04:05.5,5Z", + "--p", + ]); + expect(mixedSeparators).toEqual({ + candidates: [], + directive: LegacyCompletionDirective.Default, + }); + }); + + it("rejects a negative value for storage cp --jobs even though it's a string-typed flag in TS", () => { + // Go registers --jobs as a UintVarP (cmd/storage.go:107), the same as + // functions deploy/migration down/db reset above — but storage cp + // models it as Flag.string in TS, so the uint override must be + // consulted regardless of primitiveTag (verified empirically against + // a real apps/cli-go build: `storage cp --jobs -1 --r` returns zero + // candidates with the Default directive, not --recursive). + const result = legacyRespondToComplete(legacyRoot, [ + "__complete", + "storage", + "cp", + "--jobs", + "-1", + "--r", + ]); + expect(result).toEqual({ candidates: [], directive: LegacyCompletionDirective.Default }); + }); + + it("rejects malformed CSV for a StringSliceVar-backed flag", () => { + // Go's --domains is a StringSliceVarP (cmd/sso.go:158), CSV-split via + // encoding/csv at parse time; an unterminated quote fails that parse + // (verified empirically against a real apps/cli-go build: `sso add + // --domains 'a,"b' --type` returns zero candidates with the Default + // directive, not --type). + const result = legacyRespondToComplete(legacyRoot, [ + "__complete", + "sso", + "add", + "--domains", + 'a,"b', + "--type", + ]); + expect(result).toEqual({ candidates: [], directive: LegacyCompletionDirective.Default }); + }); + + it("still accepts well-formed CSV (a quoted comma) for the same flag", () => { + const result = legacyRespondToComplete(legacyRoot, [ + "__complete", + "sso", + "add", + "--domains", + '"example.com,example.org"', + "--type", + ]); + expect(result?.candidates.map((c) => c.name)).toContain("--type"); + }); + + it("does not apply CSV validation to db reset --sql-paths (a plain StringArrayVar, not StringSliceVar)", () => { + // Go registers --sql-paths as a plain StringArrayVar (cmd/db.go:714) — + // no CSV parsing — unlike every other variadic string flag in this + // tree, so a value containing an unbalanced quote must still be + // accepted. + const result = legacyRespondToComplete(legacyRoot, [ + "__complete", + "db", + "reset", + "--sql-paths", + 'a"b', + "--d", + ]); + expect(result?.candidates.map((c) => c.name)).toEqual( + expect.arrayContaining(["--debug", "--dns-resolver"]), + ); + }); + }); + + describe("an attached-value shorthand cluster is validated even when the owning flag is boolean (CLI-1965 review)", () => { + it("rejects an invalid boolean value attached via `=` to a boolean shorthand", () => { + // pflag treats `-f=value` as an explicit value for a boolean shorthand + // too (pflag@v1.0.10/flag.go:1005-1033) — an owning flag being boolean + // does not, on its own, mean there is nothing to validate (verified + // empirically against a real apps/cli-go build: `storage cp -r=maybe + // --j` returns zero candidates with the Default directive, not + // --jobs). + const result = legacyRespondToComplete(legacyRoot, [ + "__complete", + "storage", + "cp", + "-r=maybe", + "--j", + ]); + expect(result).toEqual({ candidates: [], directive: LegacyCompletionDirective.Default }); + }); + + it("still accepts a valid boolean value attached the same way", () => { + const result = legacyRespondToComplete(legacyRoot, [ + "__complete", + "storage", + "cp", + "-r=true", + "--j", + ]); + expect(result?.directive).toBe(LegacyCompletionDirective.NoFileComp); + expect(result?.candidates.map((c) => c.name)).toContain("--jobs"); + }); + }); + + describe("the built-in help/version flags short-circuit on Changed, not on exact token spelling (CLI-1965 review)", () => { + it.each(["--help=false", "--help=true", "-h=false"])( + "treats %s the same as a bare --help", + (token) => { + // pflag's boolValue.Set marks the flag Changed on either spelling, + // and cobra's helpOrVersionFlagPresent checks .Changed, not the + // parsed value (completions.go:530-537) — verified empirically + // against a real apps/cli-go build: `--help=false --d` and + // `--help=true --d` both return zero candidates with the + // NoFileComp directive. + const result = legacyRespondToComplete(legacyRoot, ["__complete", token, "--d"]); + expect(result).toEqual({ candidates: [], directive: LegacyCompletionDirective.NoFileComp }); + }, + ); + + it("treats --version=false the same as a bare --version, at the root", () => { + // verified empirically against a real apps/cli-go build: `--version=false + // br` returns zero candidates with the NoFileComp directive, not + // `branches`. + const result = legacyRespondToComplete(legacyRoot, ["__complete", "--version=false", "br"]); + expect(result).toEqual({ candidates: [], directive: LegacyCompletionDirective.NoFileComp }); + }); + + it("does not treat --help=maybe as Changed — an invalid boolean value is an unresolved-flag parse error instead", () => { + // An invalid value fails pflag's own Set() before Changed is ever + // examined, so this is Case 0 (Default), not the help short-circuit + // (NoFileComp). + const result = legacyRespondToComplete(legacyRoot, ["__complete", "--help=maybe", "--d"]); + expect(result).toEqual({ candidates: [], directive: LegacyCompletionDirective.Default }); + }); + }); + + describe("a `--` consumed as a preceding flag's value is not a genuine terminator (CLI-1965 review)", () => { + it("still offers a flag name after `--` was consumed as a value-taking flag's own value", () => { + // pflag's parseLongArg consumes the very next token unconditionally as + // a non-boolean flag's value — including a literal `--` — so it never + // reaches pflag's own end-of-flags sentinel check (pflag@v1.0.10/ + // flag.go:949-952) (verified empirically against a real apps/cli-go + // build: `db dump --file -- --s` still offers --schema, not zero + // candidates). + const result = legacyRespondToComplete(legacyRoot, [ + "__complete", + "db", + "dump", + "--file", + "--", + "--s", + ]); + expect(result?.directive).toBe(LegacyCompletionDirective.NoFileComp); + expect(result?.candidates.map((c) => c.name)).toContain("--schema"); + }); + + it("still disables flag completion for a genuine, unconsumed `--` sentinel", () => { + // Regression guard: only a `--` that isn't claimed as a preceding + // flag's value is a real terminator (verified empirically against a + // real apps/cli-go build: `db dump -- --s` returns zero candidates + // with the Default directive). + const result = legacyRespondToComplete(legacyRoot, ["__complete", "db", "dump", "--", "--s"]); + expect(result).toEqual({ candidates: [], directive: LegacyCompletionDirective.Default }); + }); + }); + + describe("a bare `-` is a positional argument, not a flag (CLI-1965 review)", () => { + it("keeps the subcommand-listing gate closed when a bare `-` survives as leftover", () => { + // pflag's isFlagArg requires at least 2 characters, so a bare `-` is + // never flag-shaped — but it also never removes itself from cobra's + // leftover finalArgs the way a matched command name does, so it keeps + // the `len(finalArgs) == 0` subcommand-listing gate closed (verified + // empirically against a real apps/cli-go build: `sso - --debug a` + // returns zero candidates with the Default directive, not `add`). + const result = legacyRespondToComplete(legacyRoot, [ + "__complete", + "sso", + "-", + "--debug", + "a", + ]); + expect(result).toEqual({ candidates: [], directive: LegacyCompletionDirective.Default }); + }); + + it("still lets the descent continue past a bare `-` to a real subcommand match", () => { + // Regression guard: a bare `-` must not stop the descent the way an + // unmatched real token does (verified empirically against a real + // apps/cli-go build: `db - dump --da` still offers --data-only). + const result = legacyRespondToComplete(legacyRoot, ["__complete", "db", "-", "dump", "--da"]); + expect(result?.directive).toBe(LegacyCompletionDirective.NoFileComp); + expect(result?.candidates.map((c) => c.name)).toContain("--data-only"); + }); + + it("still resolves help's own second command-path lookup past a bare `-`", () => { + // The Case-2 "preceding token is flag-shaped" check must also exclude + // a bare `-`, or it hard-stops before ever reaching help's dispatch + // (verified empirically against a real apps/cli-go build: `help db - + // d` still lists db's own subcommands diff/dump). + const result = legacyRespondToComplete(legacyRoot, ["__complete", "help", "db", "-", "d"]); + expect(result?.directive).toBe(LegacyCompletionDirective.NoFileComp); + expect(result?.candidates.map((c) => c.name)).toEqual( + expect.arrayContaining(["diff", "dump"]), + ); + }); + }); + + it("returns undefined for zero completion args (mirrors cobra's MinimumNArgs(1) failure)", () => { + expect(legacyRespondToComplete(legacyRoot, ["__complete"])).toBeUndefined(); + }); + + it("returns undefined for non-completion argv", () => { + expect(legacyRespondToComplete(legacyRoot, ["migration", "list"])).toBeUndefined(); + }); +}); + +describe("legacyResolveCommandPath", () => { + it("resolves a nested subcommand path with no leftover args", () => { + const result: LegacyCommandPathResolution = legacyResolveCommandPath(legacyRoot, [ + "branches", + "list", + ]); + expect(result.matchedPath).toEqual(["branches", "list"]); + expect(result.leftoverArgs).toEqual([]); + expect(result.commandChain.map((command) => command.name)).toEqual([ + "supabase", + "branches", + "list", + ]); + }); + + it("stops descending at the first unmatched token and treats it and everything after as leftover", () => { + const result = legacyResolveCommandPath(legacyRoot, ["migration", "bogus", "--x"]); + expect(result.matchedPath).toEqual(["migration"]); + expect(result.leftoverArgs).toEqual(["bogus", "--x"]); + }); + + it("skips flag-shaped tokens without stopping descent, and excludes them from leftoverArgs", () => { + // `--debug` is a boolean global flag — it and every genuinely-consumed + // flag token are excluded from `leftoverArgs` entirely (not just skipped + // during subcommand matching), since `leftoverArgs` represents cobra's + // *positional* `finalArgs`, used to gate subcommand-name completion. + const result = legacyResolveCommandPath(legacyRoot, ["--debug", "migration", "list"]); + expect(result.matchedPath).toEqual(["migration", "list"]); + expect(result.leftoverArgs).toEqual([]); + }); + + it("also excludes a value-taking flag's consumed value token from leftoverArgs", () => { + // `-o` (the global --output choice flag) is non-boolean, so it consumes + // "json" as its value — "json" must not be treated as an extra + // positional token even though it isn't itself flag-shaped. + const result = legacyResolveCommandPath(legacyRoot, ["-o", "json", "migration", "list"]); + expect(result.matchedPath).toEqual(["migration", "list"]); + expect(result.leftoverArgs).toEqual([]); + }); + + it("returns just the root for an empty args list", () => { + const result = legacyResolveCommandPath(legacyRoot, []); + expect(result.matchedPath).toEqual([]); + expect(result.leftoverArgs).toEqual([]); + expect(result.commandChain.map((command) => command.name)).toEqual(["supabase"]); + }); +}); + +describe("legacyCollectInScopeFlags", () => { + it("merges root global flags with the resolved command's own local flags", () => { + const { commandChain } = legacyResolveCommandPath(legacyRoot, ["branches", "list"]); + const flags: ReadonlyArray = legacyCollectInScopeFlags( + legacyRoot, + commandChain, + ); + const names = flags.map((flag) => flag.name); + expect(names).toContain("debug"); // root global flag + expect(names).toContain("project-ref"); // branches list's own local flag + + const debugFlag = flags.find((flag) => flag.name === "debug"); + expect(debugFlag).toEqual({ + name: "debug", + aliases: [], + hidden: false, + description: "output debug logs to stderr", + isVariadic: false, + isBoolean: true, + primitiveTag: "Boolean", + choiceKeys: undefined, + }); + }); + + it("includes an ancestor's shared flags (Command.withSharedFlags)", () => { + const { commandChain } = legacyResolveCommandPath(legacyRoot, [ + "db", + "schema", + "declarative", + "generate", + ]); + const flags = legacyCollectInScopeFlags(legacyRoot, commandChain); + expect(flags.map((flag) => flag.name)).toContain("no-cache"); + }); + + it("includes a non-root command's own declared global flags across the whole chain", () => { + const { commandChain } = legacyResolveCommandPath(legacyRoot, ["seed", "buckets"]); + const flags = legacyCollectInScopeFlags(legacyRoot, commandChain); + expect(flags.map((flag) => flag.name)).toEqual(expect.arrayContaining(["linked", "local"])); + }); + + it("includes --version only when the chain resolves to the root command alone", () => { + const atRoot = legacyCollectInScopeFlags( + legacyRoot, + legacyResolveCommandPath(legacyRoot, []).commandChain, + ); + expect(atRoot.map((flag) => flag.name)).toContain("version"); + + const atSubcommand = legacyCollectInScopeFlags( + legacyRoot, + legacyResolveCommandPath(legacyRoot, ["db", "dump"]).commandChain, + ); + expect(atSubcommand.map((flag) => flag.name)).not.toContain("version"); + }); + + it("lets a command's own local flag shadow a same-named global flag (local wins, no duplicate)", () => { + const { commandChain } = legacyResolveCommandPath(legacyRoot, ["db", "diff"]); + const flags = legacyCollectInScopeFlags(legacyRoot, commandChain); + const outputFlags = flags.filter((flag) => flag.name === "output"); + expect(outputFlags).toHaveLength(1); + expect(outputFlags[0]?.description).toBe("Write explicit diff output to a file path."); + }); + + it("orders flags like cobra's InheritedFlags().VisitAll then NonInheritedFlags().VisitAll — alphabetical within each block, not declaration order (CLI-1965 review)", () => { + // pflag's VisitAll sorts by canonical (long) flag name; cobra's + // completion path walks the inherited (ancestor) set first, then the + // resolved command's own set — TWO separately-sorted runs, not one + // merged alphabetical list (verified empirically against a real + // apps/cli-go build: `db dump -` lists --agent, --create-ticket, + // --debug, ... alphabetically, THEN a second alphabetical run starting + // --data-only, --db-url, --dry-run, ... — CLI-1965 review finding). + const { commandChain } = legacyResolveCommandPath(legacyRoot, ["db", "dump"]); + const names = legacyCollectInScopeFlags(legacyRoot, commandChain).map((flag) => flag.name); + + const inheritedEnd = names.indexOf("yes"); // last inherited flag, alphabetically + const ownStart = names.indexOf("data-only"); // first own/local flag, alphabetically + expect(inheritedEnd).toBeGreaterThanOrEqual(0); + expect(ownStart).toBeGreaterThan(inheritedEnd); + + const inheritedBlock = names.slice(0, ownStart); + const ownBlock = names.slice(ownStart); + expect(inheritedBlock).toEqual([...inheritedBlock].sort((a, b) => a.localeCompare(b))); + expect(ownBlock).toEqual([...ownBlock].sort((a, b) => a.localeCompare(b))); + // --help is db dump's own NonInherited flag (every command registers its + // own), not part of the shared inherited block. + expect(inheritedBlock).not.toContain("help"); + expect(ownBlock).toContain("help"); + }); + + it("orders root's own flags alphabetically end-to-end (InheritedFlags() is empty at root)", () => { + const atRoot = legacyCollectInScopeFlags( + legacyRoot, + legacyResolveCommandPath(legacyRoot, []).commandChain, + ); + // `output-format` is TS-only surface with no Go equivalent, so it isn't + // asserted here — every OTHER root flag name must still come out sorted. + const names = atRoot.map((flag) => flag.name).filter((name) => name !== "output-format"); + expect(names).toEqual([...names].sort((a, b) => a.localeCompare(b))); + }); +}); + +describe("legacyClassifyCompletion", () => { + it("produces the same result legacyRespondToComplete does for the equivalent resolved input", () => { + const trimmedArgs = ["migration", "li"].slice(0, -1); + const { commandChain, matchedPath, leftoverArgs } = legacyResolveCommandPath( + legacyRoot, + trimmedArgs, + ); + const inScopeFlags = legacyCollectInScopeFlags(legacyRoot, commandChain); + const input: LegacyClassifyCompletionInput = { + finalCommand: commandChain[commandChain.length - 1] ?? legacyRoot, + matchedPath, + leftoverArgs, + trimmedArgs, + toComplete: "li", + inScopeFlags, + }; + const direct = legacyClassifyCompletion(input); + const viaRespondToComplete = legacyRespondToComplete(legacyRoot, [ + "__complete", + "migration", + "li", + ]); + expect(direct).toEqual(viaRespondToComplete); + }); +}); + +describe("legacyResolveIncludeDescriptions", () => { + it("defaults to true for __complete with no relevant env vars", () => { + expect(legacyResolveIncludeDescriptions("__complete", {})).toBe(true); + }); + + it("is always false for __completeNoDesc, regardless of env vars", () => { + expect(legacyResolveIncludeDescriptions("__completeNoDesc", {})).toBe(false); + expect( + legacyResolveIncludeDescriptions("__completeNoDesc", { + SUPABASE_COMPLETION_DESCRIPTIONS: "true", + COBRA_COMPLETION_DESCRIPTIONS: "true", + }), + ).toBe(false); + }); + + it("honors SUPABASE_COMPLETION_DESCRIPTIONS=false for __complete", () => { + expect( + legacyResolveIncludeDescriptions("__complete", { SUPABASE_COMPLETION_DESCRIPTIONS: "false" }), + ).toBe(false); + }); + + it("falls back to the generic COBRA_COMPLETION_DESCRIPTIONS when the program-specific var is unset", () => { + expect( + legacyResolveIncludeDescriptions("__complete", { COBRA_COMPLETION_DESCRIPTIONS: "0" }), + ).toBe(false); + }); + + it("ignores an unparseable value and preserves the argv0-derived default", () => { + expect( + legacyResolveIncludeDescriptions("__complete", { + SUPABASE_COMPLETION_DESCRIPTIONS: "nonsense", + }), + ).toBe(true); + }); + + it("prioritizes the program-specific var over the generic one when both are set and conflict", () => { + expect( + legacyResolveIncludeDescriptions("__complete", { + SUPABASE_COMPLETION_DESCRIPTIONS: "true", + COBRA_COMPLETION_DESCRIPTIONS: "false", + }), + ).toBe(true); + }); +}); + +describe("legacyFormatCompletionResponse", () => { + it("tab-joins a description when present and prints a bare name otherwise, followed by the directive line", () => { + const response: LegacyCompletionResult = { + candidates: [ + { name: "list", description: "List things" }, + { name: "new", description: undefined }, + ], + directive: LegacyCompletionDirective.NoFileComp, + }; + expect(legacyFormatCompletionResponse(response, true)).toBe("list\tList things\nnew\n:4\n"); + }); + + it("strips descriptions from every candidate when includeDescriptions is false", () => { + const response: LegacyCompletionResult = { + candidates: [ + { name: "list", description: "List things" }, + { name: "new", description: undefined }, + ], + directive: LegacyCompletionDirective.NoFileComp, + }; + expect(legacyFormatCompletionResponse(response, false)).toBe("list\nnew\n:4\n"); + }); + + it("keeps only the first line of a multi-line description", () => { + const candidate: LegacyCompletionCandidate = { + name: "flag", + description: "first line\nsecond line", + }; + const response: LegacyCompletionResult = { + candidates: [candidate], + directive: LegacyCompletionDirective.Default, + }; + expect(legacyFormatCompletionResponse(response, true)).toBe("flag\tfirst line\n:0\n"); + }); + + it("emits just the directive line for zero candidates", () => { + const response: LegacyCompletionResult = { + candidates: [], + directive: LegacyCompletionDirective.Default, + }; + expect(legacyFormatCompletionResponse(response, true)).toBe(":0\n"); + }); +}); + +describe("legacyTryComplete", () => { + function makeDeps(overrides: Partial = {}) { + const stdoutWrites: Array = []; + const exits: Array = []; + const deps: LegacyCompleteDeps = { + root: legacyRoot, + argv: ["__complete", "migration", "li"], + env: {}, + stdoutWrite: (message) => { + stdoutWrites.push(message); + }, + exit: (code) => { + exits.push(code); + }, + // A no-op here keeps this suite's own focus (candidate-computation + // wiring, not telemetry) pure and synchronous-fast — the real + // production capture (`legacyCaptureCompleteTelemetryEffect`, + // `legacyDefaultCompleteDeps`'s own default) is covered separately in + // `legacy-complete.integration.test.ts`. + captureTelemetry: async () => {}, + ...overrides, + }; + return { deps, stdoutWrites, exits }; + } + + // `legacyTryComplete` returns `Promise` (CLI-1965 review finding — + // it now awaits `deps.captureTelemetry` before calling `deps.exit`). + it("returns false and does nothing for non-__complete argv", async () => { + const { deps, stdoutWrites, exits } = makeDeps({ argv: ["migration", "list"] }); + expect(await legacyTryComplete(deps)).toBe(false); + expect(stdoutWrites).toEqual([]); + expect(exits).toEqual([]); + }); + + it("writes the formatted response to stdout and exits 0 for a real completion request", async () => { + const { deps, stdoutWrites, exits } = makeDeps(); + expect(await legacyTryComplete(deps)).toBe(true); + expect(stdoutWrites).toHaveLength(1); + expect(stdoutWrites[0]).toContain("list\t"); + expect(stdoutWrites[0]).toMatch(/:4\n$/); + expect(exits).toEqual([0]); + }); + + it("respects __completeNoDesc by stripping descriptions from the written response", async () => { + const { deps, stdoutWrites } = makeDeps({ argv: ["__completeNoDesc", "migration", "li"] }); + await legacyTryComplete(deps); + expect(stdoutWrites[0]).toBe("list\n:4\n"); + }); + + it("exits 1 and does not write anything to stdout for zero completion args", async () => { + const { deps, stdoutWrites, exits } = makeDeps({ argv: ["__complete"] }); + expect(await legacyTryComplete(deps)).toBe(true); + expect(stdoutWrites).toEqual([]); + expect(exits).toEqual([1]); + }); +}); + +describe("legacyDefaultCompleteDeps", () => { + it("wires argv/env from the real process and delegates stdoutWrite/exit to process.stdout.write/process.exit", () => { + const originalArgv = process.argv; + process.argv = [...originalArgv.slice(0, 2), "__complete", "migration", "li"]; + const stdoutWrite = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + const exit = vi.spyOn(process, "exit").mockImplementation(() => undefined as never); + + try { + const deps = legacyDefaultCompleteDeps(legacyRoot); + expect(deps.root).toBe(legacyRoot); + expect(deps.argv).toEqual(["__complete", "migration", "li"]); + expect(deps.env).toBe(process.env); + + deps.stdoutWrite("hello"); + expect(stdoutWrite).toHaveBeenCalledWith("hello"); + + deps.exit(3); + expect(exit).toHaveBeenCalledWith(3); + } finally { + process.argv = originalArgv; + stdoutWrite.mockRestore(); + exit.mockRestore(); + } + }); +}); diff --git a/apps/cli/src/legacy/cli/main.ts b/apps/cli/src/legacy/cli/main.ts index 1866084cdc..d8916ce423 100644 --- a/apps/cli/src/legacy/cli/main.ts +++ b/apps/cli/src/legacy/cli/main.ts @@ -1,9 +1,9 @@ #!/usr/bin/env bun import { runCli } from "../../shared/cli/run.ts"; import { legacyAnalyticsLayer } from "../telemetry/legacy-analytics.layer.ts"; -import { defaultCompletePassthroughDeps, tryCompletePassthrough } from "./complete-passthrough.ts"; +import { legacyDefaultCompleteDeps, legacyTryComplete } from "./legacy-complete.ts"; import { legacyRoot } from "./root.ts"; -if (!tryCompletePassthrough(defaultCompletePassthroughDeps())) { +if (!(await legacyTryComplete(legacyDefaultCompleteDeps(legacyRoot)))) { await runCli(legacyRoot, { analyticsLayer: legacyAnalyticsLayer }); } diff --git a/apps/cli/src/legacy/commands/completion/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/completion/SIDE_EFFECTS.md index ed1b76d1c2..a4e4b3e880 100644 --- a/apps/cli/src/legacy/commands/completion/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/completion/SIDE_EFFECTS.md @@ -8,9 +8,15 @@ ## Files Written -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | +These are written by the dynamic `__complete`/`__completeNoDesc` responder +(`legacy/cli/legacy-complete.ts`), not by `supabase completion ` itself — +documented here for the same reason the Environment Variables section below +covers that responder's own env vars: this is the only `SIDE_EFFECTS.md` for +the completion family. + +| Path | Format | When | +| ----------------------------------------------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/telemetry.json` | JSON | Best-effort, on every `__complete`/`__completeNoDesc` request — written by the shared `TelemetryRuntime`/consent bootstrap the `cli_command_executed` capture below runs through (`legacy/telemetry/legacy-telemetry-state.layer.ts`'s file, same path/format), regardless of whether the PostHog delivery itself succeeds. | ## API Routes @@ -20,41 +26,99 @@ ## Environment Variables -| Variable | Purpose | Required? | -| -------- | ------- | --------- | -| — | — | — | +These two are consumed by the dynamic `__complete`/`__completeNoDesc` responder +(`legacy/cli/legacy-complete.ts`, `legacyResolveIncludeDescriptions`), not by +`supabase completion ` itself — documented here because this is the only +`SIDE_EFFECTS.md` for the completion family, and the two hidden commands are only +ever reached via a script this family generates. + +| Variable | Purpose | Required? | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------- | +| `SUPABASE_COMPLETION_DESCRIPTIONS` | Program-specific override for whether `__complete` includes descriptions (Go `strconv.ParseBool` spellings: `1/t/T/TRUE/true/True` = include, `0/f/F/FALSE/false/False` = omit; anything else ignored). Checked before the generic var below. Has no effect on `__completeNoDesc`, which always omits descriptions regardless. | No | +| `COBRA_COMPLETION_DESCRIPTIONS` | Generic fallback for the above, checked only when `SUPABASE_COMPLETION_DESCRIPTIONS` is unset or empty (cobra's real `getEnvConfig` precedence). | No | + +### Telemetry + +Every `__complete`/`__completeNoDesc` request also fires the same +`cli_command_executed` PostHog event Go's `Execute()` fired for every resolved +command, including cobra's hidden `__complete` (`apps/cli-go/cmd/root.go:168-204`; +CLI-1965 review finding — the deleted Go binary passthrough fired this on every +tab press, and the native TS interceptor silently stopped doing so until this +was added). `command` is always the literal `"__complete"`, never +`"__completeNoDesc"` (cobra registers the latter as an alias of the former, and +Go's own telemetry records the alias-invariant primary name); `exit_code` is +`0` for a normal response (even with zero matching candidates) and `1` for an +unresolvable request (no completion args at all, see Exit Codes above); +`output_format` is always the fixed literal `"text"`, since `__complete` never +parses `--output`/`-o`. The capture is best-effort and bounded by a short +timeout (`legacy/cli/legacy-complete.ts`'s `legacyCaptureCompleteTelemetry`) — +a missing consent, network hiccup, or DNS failure never blocks or fails the +completion response itself, only adds a small delay to the process's own exit +while it's awaited. + +This deliberately does **not** reproduce the rest of Go's +`Execute()`/`PersistentPreRunE` for `__complete`: profile loading, the workdir +change, and the GitHub upgrade-version check (a real HTTP GET to +`api.github.com`, throttled to roughly once per 10h by Go's own cache file) are +all out of scope. None of those have any bearing on the analytics contract, and +real generated completion shell scripts always discard this process's stderr, +so reproducing Go's upgrade message would be a pure regression, not a parity +fix. ## Exit Codes -| Code | Condition | -| ---- | -------------------------------------------------------------------------------------------------------------------- | -| `0` | success — completion script for the chosen shell printed to stdout | -| `1` | unknown shell subcommand, or bare `completion` with no shell subcommand — **known divergence, see Notes (CLI-1906)** | +| Code | Condition | +| ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success — completion script for the chosen shell printed to stdout; also `__complete`/`__completeNoDesc`'s normal case (candidates + `:` line, even when zero candidates match) | +| `1` | unknown shell subcommand, or bare `completion` with no shell subcommand — **known divergence, see Notes (CLI-1906)**; also `__complete`/`__completeNoDesc` invoked with no completion args at all (`supabase __complete` alone) — realistically unreachable, since every generated script always appends at least an empty trailing arg | ## Output `supabase completion ` prints a shell-specific autocompletion script to stdout. The subcommand tree mirrors the Go CLI exactly: `bash`, `fish`, `powershell`, `zsh`. -In the legacy shell every subcommand proxies verbatim to the bundled Go binary via -`LegacyGoProxy`, so the emitted scripts are byte-for-byte identical to what the Go -CLI produced. This matters because users who installed completions with the Go CLI -have those exact bytes cached in their `~/.zshrc` (`eval "$(supabase completion zsh)"`), +As of CLI-1965, each leaf is generated **natively in TypeScript** — no Go binary is +involved at all. `legacy/commands/completion/legacy-completion-scripts.ts` (`legacyGenerateCompletionScript`) +transcribes cobra v1.10.2's own static script templates byte-for-byte, read directly from +the vendored cobra source rather than reconstructed from memory: + +- `spf13/cobra@v1.10.2/bash_completionsV2.go` (`genBashComp`) +- `spf13/cobra@v1.10.2/zsh_completions.go` (`genZshComp`) +- `spf13/cobra@v1.10.2/fish_completions.go` (`genFishComp`) +- `spf13/cobra@v1.10.2/powershell_completions.go` (`genPowerShellComp`) + +This is safe to do byte-for-byte because cobra's completion scripts for all four +shells are 100% generic string templates that do **not** bake in the command tree — +the only variables are the program name (always the literal `"supabase"`, hardcoded +as `PROGRAM_NAME` — cobra itself derives it from a compile-time `Use: "supabase"` +constant, not `os.Argv[0]`), which hidden command the script calls back into +(`__complete` vs `__completeNoDesc`), the six `ShellCompDirective` bit values, and +the two activeHelp constants. Each handler prints the generated script verbatim via +`Output.raw` (no framing, spinner, or JSON envelope), matching what the Go binary's +raw stdout used to be piped through. The scripts are byte-for-byte identical to what +the Go CLI produced (verified via a scripted round-trip against Go's own +`fmt.Sprintf` semantics while porting), so users who installed completions with the +Go CLI — cached bytes in their `~/.zshrc` (`eval "$(supabase completion zsh)"`), brew-managed `_supabase` files in their `fpath`, or analogous bash/fish/powershell -artifacts. Drift would break tab completion for those users. +artifacts — see no behavior change. The generated scripts call back to `supabase __complete ` on every tab press to fetch dynamic completion candidates, or `supabase __completeNoDesc ` when the script was generated with `--no-descriptions` (cobra's alias for the same hidden -command) — see `apps/cli/src/legacy/cli/complete-passthrough.ts`, which intercepts -both `__complete` and `__completeNoDesc` before Effect's argv parser and proxies them -straight to the Go binary. +command) — see `apps/cli/src/legacy/cli/legacy-complete.ts`, which intercepts both +`__complete` and `__completeNoDesc` before Effect's argv parser and natively +reimplements cobra's dynamic-completion protocol by reflecting over `legacyRoot` +(this repo's own Effect CLI command tree) rather than proxying to the Go binary +(CLI-1965, separate port; its internal candidate/directive algorithm is out of scope +for this doc — see that file's own doc comments — but its externally-visible wire +format, env vars, and exit codes are documented here since it has no `SIDE_EFFECTS.md` +of its own). The wire format written to stdout: one line per candidate (`name` or, when +descriptions are enabled and present, `name\t`), followed by +a final `:` line (an integer — `0` default, `4` "no file completion", `8` +"filter by file extension", matching a subset of cobra's `ShellCompDirective` bits). ## Notes -- No native TS reimplementation is attempted. Effect's `Completions.generate` API - emits a static `_arguments`-based zsh function that diverges from Cobra's runtime- - callback shape; using it here would break the existing user setups described above. - Effect CLI's `--completions` global flag remains exposed at the root for `next/` users; it does not satisfy the legacy parity contract and is not what this subcommand routes through. @@ -72,6 +136,15 @@ bogus-shell` both exit `0`). The legacy TS shell currently exits `1` for the fix; this doc describes current (buggy) behavior, not the intended target. - Each of `bash`/`zsh`/`fish`/`powershell` declares `--no-descriptions` (cobra's - auto-registered flag, `completions.go` in `spf13/cobra`) and forwards it to the - Go binary, so the emitted script omits completion descriptions exactly as it - would with the Go CLI. + auto-registered flag, `completions.go` in `spf13/cobra`) and forwards it into the + native generator (selecting the `__completeNoDesc` token instead of `__complete`), + so the emitted script omits completion descriptions exactly as it would with the + Go CLI. +- **Accepted `__complete` divergences from real cobra** (see `legacy-complete.ts`'s + module doc comment for the full, current list and rationale): mutually-exclusive + flag-group hiding (`MarkFlagsMutuallyExclusive`, ~45 sites in `apps/cli-go/cmd/`) + is not reproduced — hand-building a shadow table at that scale was judged + higher-risk than the small, stable tables this module does maintain (file + extensions, required flags). Deprecated commands/flags are not filtered out of + candidates either — this TS tree has no "deprecated" concept distinct from + `hidden` today. diff --git a/apps/cli/src/legacy/commands/completion/__fixtures__/bash.desc.txt b/apps/cli/src/legacy/commands/completion/__fixtures__/bash.desc.txt new file mode 100644 index 0000000000..d041d44223 --- /dev/null +++ b/apps/cli/src/legacy/commands/completion/__fixtures__/bash.desc.txt @@ -0,0 +1,426 @@ +# bash completion V2 for supabase -*- shell-script -*- + +__supabase_debug() +{ + if [[ -n ${BASH_COMP_DEBUG_FILE-} ]]; then + echo "$*" >> "${BASH_COMP_DEBUG_FILE}" + fi +} + +# Macs have bash3 for which the bash-completion package doesn't include +# _init_completion. This is a minimal version of that function. +__supabase_init_completion() +{ + COMPREPLY=() + _get_comp_words_by_ref "$@" cur prev words cword +} + +# This function calls the supabase program to obtain the completion +# results and the directive. It fills the 'out' and 'directive' vars. +__supabase_get_completion_results() { + local requestComp lastParam lastChar args + + # Prepare the command to request completions for the program. + # Calling ${words[0]} instead of directly supabase allows handling aliases + args=("${words[@]:1}") + requestComp="${words[0]} __complete ${args[*]}" + + lastParam=${words[$((${#words[@]}-1))]} + lastChar=${lastParam:$((${#lastParam}-1)):1} + __supabase_debug "lastParam ${lastParam}, lastChar ${lastChar}" + + if [[ -z ${cur} && ${lastChar} != = ]]; then + # If the last parameter is complete (there is a space following it) + # We add an extra empty parameter so we can indicate this to the go method. + __supabase_debug "Adding extra empty parameter" + requestComp="${requestComp} ''" + fi + + # When completing a flag with an = (e.g., supabase -n=) + # bash focuses on the part after the =, so we need to remove + # the flag part from $cur + if [[ ${cur} == -*=* ]]; then + cur="${cur#*=}" + fi + + __supabase_debug "Calling ${requestComp}" + # Use eval to handle any environment variables and such + out=$(eval "${requestComp}" 2>/dev/null) + + # Extract the directive integer at the very end of the output following a colon (:) + directive=${out##*:} + # Remove the directive + out=${out%:*} + if [[ ${directive} == "${out}" ]]; then + # There is not directive specified + directive=0 + fi + __supabase_debug "The completion directive is: ${directive}" + __supabase_debug "The completions are: ${out}" +} + +__supabase_process_completion_results() { + local shellCompDirectiveError=1 + local shellCompDirectiveNoSpace=2 + local shellCompDirectiveNoFileComp=4 + local shellCompDirectiveFilterFileExt=8 + local shellCompDirectiveFilterDirs=16 + local shellCompDirectiveKeepOrder=32 + + if (((directive & shellCompDirectiveError) != 0)); then + # Error code. No completion. + __supabase_debug "Received error from custom completion go code" + return + else + if (((directive & shellCompDirectiveNoSpace) != 0)); then + if [[ $(type -t compopt) == builtin ]]; then + __supabase_debug "Activating no space" + compopt -o nospace + else + __supabase_debug "No space directive not supported in this version of bash" + fi + fi + if (((directive & shellCompDirectiveKeepOrder) != 0)); then + if [[ $(type -t compopt) == builtin ]]; then + # no sort isn't supported for bash less than < 4.4 + if [[ ${BASH_VERSINFO[0]} -lt 4 || ( ${BASH_VERSINFO[0]} -eq 4 && ${BASH_VERSINFO[1]} -lt 4 ) ]]; then + __supabase_debug "No sort directive not supported in this version of bash" + else + __supabase_debug "Activating keep order" + compopt -o nosort + fi + else + __supabase_debug "No sort directive not supported in this version of bash" + fi + fi + if (((directive & shellCompDirectiveNoFileComp) != 0)); then + if [[ $(type -t compopt) == builtin ]]; then + __supabase_debug "Activating no file completion" + compopt +o default + else + __supabase_debug "No file completion directive not supported in this version of bash" + fi + fi + fi + + # Separate activeHelp from normal completions + local completions=() + local activeHelp=() + __supabase_extract_activeHelp + + if (((directive & shellCompDirectiveFilterFileExt) != 0)); then + # File extension filtering + local fullFilter="" filter filteringCmd + + # Do not use quotes around the $completions variable or else newline + # characters will be kept. + for filter in ${completions[*]}; do + fullFilter+="$filter|" + done + + filteringCmd="_filedir $fullFilter" + __supabase_debug "File filtering command: $filteringCmd" + $filteringCmd + elif (((directive & shellCompDirectiveFilterDirs) != 0)); then + # File completion for directories only + + local subdir + subdir=${completions[0]} + if [[ -n $subdir ]]; then + __supabase_debug "Listing directories in $subdir" + pushd "$subdir" >/dev/null 2>&1 && _filedir -d && popd >/dev/null 2>&1 || return + else + __supabase_debug "Listing directories in ." + _filedir -d + fi + else + __supabase_handle_completion_types + fi + + __supabase_handle_special_char "$cur" : + __supabase_handle_special_char "$cur" = + + # Print the activeHelp statements before we finish + __supabase_handle_activeHelp +} + +__supabase_handle_activeHelp() { + # Print the activeHelp statements + if ((${#activeHelp[*]} != 0)); then + if [ -z $COMP_TYPE ]; then + # Bash v3 does not set the COMP_TYPE variable. + printf "\n"; + printf "%s\n" "${activeHelp[@]}" + printf "\n" + __supabase_reprint_commandLine + return + fi + + # Only print ActiveHelp on the second TAB press + if [ $COMP_TYPE -eq 63 ]; then + printf "\n" + printf "%s\n" "${activeHelp[@]}" + + if ((${#COMPREPLY[*]} == 0)); then + # When there are no completion choices from the program, file completion + # may kick in if the program has not disabled it; in such a case, we want + # to know if any files will match what the user typed, so that we know if + # there will be completions presented, so that we know how to handle ActiveHelp. + # To find out, we actually trigger the file completion ourselves; + # the call to _filedir will fill COMPREPLY if files match. + if (((directive & shellCompDirectiveNoFileComp) == 0)); then + __supabase_debug "Listing files" + _filedir + fi + fi + + if ((${#COMPREPLY[*]} != 0)); then + # If there are completion choices to be shown, print a delimiter. + # Re-printing the command-line will automatically be done + # by the shell when it prints the completion choices. + printf -- "--" + else + # When there are no completion choices at all, we need + # to re-print the command-line since the shell will + # not be doing it itself. + __supabase_reprint_commandLine + fi + elif [ $COMP_TYPE -eq 37 ] || [ $COMP_TYPE -eq 42 ]; then + # For completion type: menu-complete/menu-complete-backward and insert-completions + # the completions are immediately inserted into the command-line, so we first + # print the activeHelp message and reprint the command-line since the shell won't. + printf "\n" + printf "%s\n" "${activeHelp[@]}" + + __supabase_reprint_commandLine + fi + fi +} + +__supabase_reprint_commandLine() { + # The prompt format is only available from bash 4.4. + # We test if it is available before using it. + if (x=${PS1@P}) 2> /dev/null; then + printf "%s" "${PS1@P}${COMP_LINE[@]}" + else + # Can't print the prompt. Just print the + # text the user had typed, it is workable enough. + printf "%s" "${COMP_LINE[@]}" + fi +} + +# Separate activeHelp lines from real completions. +# Fills the $activeHelp and $completions arrays. +__supabase_extract_activeHelp() { + local activeHelpMarker="_activeHelp_ " + local endIndex=${#activeHelpMarker} + + while IFS='' read -r comp; do + [[ -z $comp ]] && continue + + if [[ ${comp:0:endIndex} == $activeHelpMarker ]]; then + comp=${comp:endIndex} + __supabase_debug "ActiveHelp found: $comp" + if [[ -n $comp ]]; then + activeHelp+=("$comp") + fi + else + # Not an activeHelp line but a normal completion + completions+=("$comp") + fi + done <<<"${out}" +} + +__supabase_handle_completion_types() { + __supabase_debug "__supabase_handle_completion_types: COMP_TYPE is $COMP_TYPE" + + case $COMP_TYPE in + 37|42) + # Type: menu-complete/menu-complete-backward and insert-completions + # If the user requested inserting one completion at a time, or all + # completions at once on the command-line we must remove the descriptions. + # https://github.com/spf13/cobra/issues/1508 + + # If there are no completions, we don't need to do anything + (( ${#completions[@]} == 0 )) && return 0 + + local tab=$'\t' + + # Strip any description and escape the completion to handled special characters + IFS=$'\n' read -ra completions -d '' < <(printf "%q\n" "${completions[@]%%$tab*}") + + # Only consider the completions that match + IFS=$'\n' read -ra COMPREPLY -d '' < <(IFS=$'\n'; compgen -W "${completions[*]}" -- "${cur}") + + # compgen looses the escaping so we need to escape all completions again since they will + # all be inserted on the command-line. + IFS=$'\n' read -ra COMPREPLY -d '' < <(printf "%q\n" "${COMPREPLY[@]}") + ;; + + *) + # Type: complete (normal completion) + __supabase_handle_standard_completion_case + ;; + esac +} + +__supabase_handle_standard_completion_case() { + local tab=$'\t' + + # If there are no completions, we don't need to do anything + (( ${#completions[@]} == 0 )) && return 0 + + # Short circuit to optimize if we don't have descriptions + if [[ "${completions[*]}" != *$tab* ]]; then + # First, escape the completions to handle special characters + IFS=$'\n' read -ra completions -d '' < <(printf "%q\n" "${completions[@]}") + # Only consider the completions that match what the user typed + IFS=$'\n' read -ra COMPREPLY -d '' < <(IFS=$'\n'; compgen -W "${completions[*]}" -- "${cur}") + + # compgen looses the escaping so, if there is only a single completion, we need to + # escape it again because it will be inserted on the command-line. If there are multiple + # completions, we don't want to escape them because they will be printed in a list + # and we don't want to show escape characters in that list. + if (( ${#COMPREPLY[@]} == 1 )); then + COMPREPLY[0]=$(printf "%q" "${COMPREPLY[0]}") + fi + return 0 + fi + + local longest=0 + local compline + # Look for the longest completion so that we can format things nicely + while IFS='' read -r compline; do + [[ -z $compline ]] && continue + + # Before checking if the completion matches what the user typed, + # we need to strip any description and escape the completion to handle special + # characters because those escape characters are part of what the user typed. + # Don't call "printf" in a sub-shell because it will be much slower + # since we are in a loop. + printf -v comp "%q" "${compline%%$tab*}" &>/dev/null || comp=$(printf "%q" "${compline%%$tab*}") + + # Only consider the completions that match + [[ $comp == "$cur"* ]] || continue + + # The completions matches. Add it to the list of full completions including + # its description. We don't escape the completion because it may get printed + # in a list if there are more than one and we don't want show escape characters + # in that list. + COMPREPLY+=("$compline") + + # Strip any description before checking the length, and again, don't escape + # the completion because this length is only used when printing the completions + # in a list and we don't want show escape characters in that list. + comp=${compline%%$tab*} + if ((${#comp}>longest)); then + longest=${#comp} + fi + done < <(printf "%s\n" "${completions[@]}") + + # If there is a single completion left, remove the description text and escape any special characters + if ((${#COMPREPLY[*]} == 1)); then + __supabase_debug "COMPREPLY[0]: ${COMPREPLY[0]}" + COMPREPLY[0]=$(printf "%q" "${COMPREPLY[0]%%$tab*}") + __supabase_debug "Removed description from single completion, which is now: ${COMPREPLY[0]}" + else + # Format the descriptions + __supabase_format_comp_descriptions $longest + fi +} + +__supabase_handle_special_char() +{ + local comp="$1" + local char=$2 + if [[ "$comp" == *${char}* && "$COMP_WORDBREAKS" == *${char}* ]]; then + local word=${comp%"${comp##*${char}}"} + local idx=${#COMPREPLY[*]} + while ((--idx >= 0)); do + COMPREPLY[idx]=${COMPREPLY[idx]#"$word"} + done + fi +} + +__supabase_format_comp_descriptions() +{ + local tab=$'\t' + local comp desc maxdesclength + local longest=$1 + + local i ci + for ci in ${!COMPREPLY[*]}; do + comp=${COMPREPLY[ci]} + # Properly format the description string which follows a tab character if there is one + if [[ "$comp" == *$tab* ]]; then + __supabase_debug "Original comp: $comp" + desc=${comp#*$tab} + comp=${comp%%$tab*} + + # $COLUMNS stores the current shell width. + # Remove an extra 4 because we add 2 spaces and 2 parentheses. + maxdesclength=$(( COLUMNS - longest - 4 )) + + # Make sure we can fit a description of at least 8 characters + # if we are to align the descriptions. + if ((maxdesclength > 8)); then + # Add the proper number of spaces to align the descriptions + for ((i = ${#comp} ; i < longest ; i++)); do + comp+=" " + done + else + # Don't pad the descriptions so we can fit more text after the completion + maxdesclength=$(( COLUMNS - ${#comp} - 4 )) + fi + + # If there is enough space for any description text, + # truncate the descriptions that are too long for the shell width + if ((maxdesclength > 0)); then + if ((${#desc} > maxdesclength)); then + desc=${desc:0:$(( maxdesclength - 1 ))} + desc+="…" + fi + comp+=" ($desc)" + fi + COMPREPLY[ci]=$comp + __supabase_debug "Final comp: $comp" + fi + done +} + +__start_supabase() +{ + local cur prev words cword split + + COMPREPLY=() + + # Call _init_completion from the bash-completion package + # to prepare the arguments properly + if declare -F _init_completion >/dev/null 2>&1; then + _init_completion -n =: || return + else + __supabase_init_completion -n =: || return + fi + + __supabase_debug + __supabase_debug "========= starting completion logic ==========" + __supabase_debug "cur is ${cur}, words[*] is ${words[*]}, #words[@] is ${#words[@]}, cword is $cword" + + # The user could have moved the cursor backwards on the command-line. + # We need to trigger completion from the $cword location, so we need + # to truncate the command-line ($words) up to the $cword location. + words=("${words[@]:0:$cword+1}") + __supabase_debug "Truncated words[*]: ${words[*]}," + + local out directive + __supabase_get_completion_results + __supabase_process_completion_results +} + +if [[ $(type -t compopt) = "builtin" ]]; then + complete -o default -F __start_supabase supabase +else + complete -o default -o nospace -F __start_supabase supabase +fi + +# ex: ts=4 sw=4 et filetype=sh diff --git a/apps/cli/src/legacy/commands/completion/__fixtures__/bash.nodesc.txt b/apps/cli/src/legacy/commands/completion/__fixtures__/bash.nodesc.txt new file mode 100644 index 0000000000..1306d33dd2 --- /dev/null +++ b/apps/cli/src/legacy/commands/completion/__fixtures__/bash.nodesc.txt @@ -0,0 +1,426 @@ +# bash completion V2 for supabase -*- shell-script -*- + +__supabase_debug() +{ + if [[ -n ${BASH_COMP_DEBUG_FILE-} ]]; then + echo "$*" >> "${BASH_COMP_DEBUG_FILE}" + fi +} + +# Macs have bash3 for which the bash-completion package doesn't include +# _init_completion. This is a minimal version of that function. +__supabase_init_completion() +{ + COMPREPLY=() + _get_comp_words_by_ref "$@" cur prev words cword +} + +# This function calls the supabase program to obtain the completion +# results and the directive. It fills the 'out' and 'directive' vars. +__supabase_get_completion_results() { + local requestComp lastParam lastChar args + + # Prepare the command to request completions for the program. + # Calling ${words[0]} instead of directly supabase allows handling aliases + args=("${words[@]:1}") + requestComp="${words[0]} __completeNoDesc ${args[*]}" + + lastParam=${words[$((${#words[@]}-1))]} + lastChar=${lastParam:$((${#lastParam}-1)):1} + __supabase_debug "lastParam ${lastParam}, lastChar ${lastChar}" + + if [[ -z ${cur} && ${lastChar} != = ]]; then + # If the last parameter is complete (there is a space following it) + # We add an extra empty parameter so we can indicate this to the go method. + __supabase_debug "Adding extra empty parameter" + requestComp="${requestComp} ''" + fi + + # When completing a flag with an = (e.g., supabase -n=) + # bash focuses on the part after the =, so we need to remove + # the flag part from $cur + if [[ ${cur} == -*=* ]]; then + cur="${cur#*=}" + fi + + __supabase_debug "Calling ${requestComp}" + # Use eval to handle any environment variables and such + out=$(eval "${requestComp}" 2>/dev/null) + + # Extract the directive integer at the very end of the output following a colon (:) + directive=${out##*:} + # Remove the directive + out=${out%:*} + if [[ ${directive} == "${out}" ]]; then + # There is not directive specified + directive=0 + fi + __supabase_debug "The completion directive is: ${directive}" + __supabase_debug "The completions are: ${out}" +} + +__supabase_process_completion_results() { + local shellCompDirectiveError=1 + local shellCompDirectiveNoSpace=2 + local shellCompDirectiveNoFileComp=4 + local shellCompDirectiveFilterFileExt=8 + local shellCompDirectiveFilterDirs=16 + local shellCompDirectiveKeepOrder=32 + + if (((directive & shellCompDirectiveError) != 0)); then + # Error code. No completion. + __supabase_debug "Received error from custom completion go code" + return + else + if (((directive & shellCompDirectiveNoSpace) != 0)); then + if [[ $(type -t compopt) == builtin ]]; then + __supabase_debug "Activating no space" + compopt -o nospace + else + __supabase_debug "No space directive not supported in this version of bash" + fi + fi + if (((directive & shellCompDirectiveKeepOrder) != 0)); then + if [[ $(type -t compopt) == builtin ]]; then + # no sort isn't supported for bash less than < 4.4 + if [[ ${BASH_VERSINFO[0]} -lt 4 || ( ${BASH_VERSINFO[0]} -eq 4 && ${BASH_VERSINFO[1]} -lt 4 ) ]]; then + __supabase_debug "No sort directive not supported in this version of bash" + else + __supabase_debug "Activating keep order" + compopt -o nosort + fi + else + __supabase_debug "No sort directive not supported in this version of bash" + fi + fi + if (((directive & shellCompDirectiveNoFileComp) != 0)); then + if [[ $(type -t compopt) == builtin ]]; then + __supabase_debug "Activating no file completion" + compopt +o default + else + __supabase_debug "No file completion directive not supported in this version of bash" + fi + fi + fi + + # Separate activeHelp from normal completions + local completions=() + local activeHelp=() + __supabase_extract_activeHelp + + if (((directive & shellCompDirectiveFilterFileExt) != 0)); then + # File extension filtering + local fullFilter="" filter filteringCmd + + # Do not use quotes around the $completions variable or else newline + # characters will be kept. + for filter in ${completions[*]}; do + fullFilter+="$filter|" + done + + filteringCmd="_filedir $fullFilter" + __supabase_debug "File filtering command: $filteringCmd" + $filteringCmd + elif (((directive & shellCompDirectiveFilterDirs) != 0)); then + # File completion for directories only + + local subdir + subdir=${completions[0]} + if [[ -n $subdir ]]; then + __supabase_debug "Listing directories in $subdir" + pushd "$subdir" >/dev/null 2>&1 && _filedir -d && popd >/dev/null 2>&1 || return + else + __supabase_debug "Listing directories in ." + _filedir -d + fi + else + __supabase_handle_completion_types + fi + + __supabase_handle_special_char "$cur" : + __supabase_handle_special_char "$cur" = + + # Print the activeHelp statements before we finish + __supabase_handle_activeHelp +} + +__supabase_handle_activeHelp() { + # Print the activeHelp statements + if ((${#activeHelp[*]} != 0)); then + if [ -z $COMP_TYPE ]; then + # Bash v3 does not set the COMP_TYPE variable. + printf "\n"; + printf "%s\n" "${activeHelp[@]}" + printf "\n" + __supabase_reprint_commandLine + return + fi + + # Only print ActiveHelp on the second TAB press + if [ $COMP_TYPE -eq 63 ]; then + printf "\n" + printf "%s\n" "${activeHelp[@]}" + + if ((${#COMPREPLY[*]} == 0)); then + # When there are no completion choices from the program, file completion + # may kick in if the program has not disabled it; in such a case, we want + # to know if any files will match what the user typed, so that we know if + # there will be completions presented, so that we know how to handle ActiveHelp. + # To find out, we actually trigger the file completion ourselves; + # the call to _filedir will fill COMPREPLY if files match. + if (((directive & shellCompDirectiveNoFileComp) == 0)); then + __supabase_debug "Listing files" + _filedir + fi + fi + + if ((${#COMPREPLY[*]} != 0)); then + # If there are completion choices to be shown, print a delimiter. + # Re-printing the command-line will automatically be done + # by the shell when it prints the completion choices. + printf -- "--" + else + # When there are no completion choices at all, we need + # to re-print the command-line since the shell will + # not be doing it itself. + __supabase_reprint_commandLine + fi + elif [ $COMP_TYPE -eq 37 ] || [ $COMP_TYPE -eq 42 ]; then + # For completion type: menu-complete/menu-complete-backward and insert-completions + # the completions are immediately inserted into the command-line, so we first + # print the activeHelp message and reprint the command-line since the shell won't. + printf "\n" + printf "%s\n" "${activeHelp[@]}" + + __supabase_reprint_commandLine + fi + fi +} + +__supabase_reprint_commandLine() { + # The prompt format is only available from bash 4.4. + # We test if it is available before using it. + if (x=${PS1@P}) 2> /dev/null; then + printf "%s" "${PS1@P}${COMP_LINE[@]}" + else + # Can't print the prompt. Just print the + # text the user had typed, it is workable enough. + printf "%s" "${COMP_LINE[@]}" + fi +} + +# Separate activeHelp lines from real completions. +# Fills the $activeHelp and $completions arrays. +__supabase_extract_activeHelp() { + local activeHelpMarker="_activeHelp_ " + local endIndex=${#activeHelpMarker} + + while IFS='' read -r comp; do + [[ -z $comp ]] && continue + + if [[ ${comp:0:endIndex} == $activeHelpMarker ]]; then + comp=${comp:endIndex} + __supabase_debug "ActiveHelp found: $comp" + if [[ -n $comp ]]; then + activeHelp+=("$comp") + fi + else + # Not an activeHelp line but a normal completion + completions+=("$comp") + fi + done <<<"${out}" +} + +__supabase_handle_completion_types() { + __supabase_debug "__supabase_handle_completion_types: COMP_TYPE is $COMP_TYPE" + + case $COMP_TYPE in + 37|42) + # Type: menu-complete/menu-complete-backward and insert-completions + # If the user requested inserting one completion at a time, or all + # completions at once on the command-line we must remove the descriptions. + # https://github.com/spf13/cobra/issues/1508 + + # If there are no completions, we don't need to do anything + (( ${#completions[@]} == 0 )) && return 0 + + local tab=$'\t' + + # Strip any description and escape the completion to handled special characters + IFS=$'\n' read -ra completions -d '' < <(printf "%q\n" "${completions[@]%%$tab*}") + + # Only consider the completions that match + IFS=$'\n' read -ra COMPREPLY -d '' < <(IFS=$'\n'; compgen -W "${completions[*]}" -- "${cur}") + + # compgen looses the escaping so we need to escape all completions again since they will + # all be inserted on the command-line. + IFS=$'\n' read -ra COMPREPLY -d '' < <(printf "%q\n" "${COMPREPLY[@]}") + ;; + + *) + # Type: complete (normal completion) + __supabase_handle_standard_completion_case + ;; + esac +} + +__supabase_handle_standard_completion_case() { + local tab=$'\t' + + # If there are no completions, we don't need to do anything + (( ${#completions[@]} == 0 )) && return 0 + + # Short circuit to optimize if we don't have descriptions + if [[ "${completions[*]}" != *$tab* ]]; then + # First, escape the completions to handle special characters + IFS=$'\n' read -ra completions -d '' < <(printf "%q\n" "${completions[@]}") + # Only consider the completions that match what the user typed + IFS=$'\n' read -ra COMPREPLY -d '' < <(IFS=$'\n'; compgen -W "${completions[*]}" -- "${cur}") + + # compgen looses the escaping so, if there is only a single completion, we need to + # escape it again because it will be inserted on the command-line. If there are multiple + # completions, we don't want to escape them because they will be printed in a list + # and we don't want to show escape characters in that list. + if (( ${#COMPREPLY[@]} == 1 )); then + COMPREPLY[0]=$(printf "%q" "${COMPREPLY[0]}") + fi + return 0 + fi + + local longest=0 + local compline + # Look for the longest completion so that we can format things nicely + while IFS='' read -r compline; do + [[ -z $compline ]] && continue + + # Before checking if the completion matches what the user typed, + # we need to strip any description and escape the completion to handle special + # characters because those escape characters are part of what the user typed. + # Don't call "printf" in a sub-shell because it will be much slower + # since we are in a loop. + printf -v comp "%q" "${compline%%$tab*}" &>/dev/null || comp=$(printf "%q" "${compline%%$tab*}") + + # Only consider the completions that match + [[ $comp == "$cur"* ]] || continue + + # The completions matches. Add it to the list of full completions including + # its description. We don't escape the completion because it may get printed + # in a list if there are more than one and we don't want show escape characters + # in that list. + COMPREPLY+=("$compline") + + # Strip any description before checking the length, and again, don't escape + # the completion because this length is only used when printing the completions + # in a list and we don't want show escape characters in that list. + comp=${compline%%$tab*} + if ((${#comp}>longest)); then + longest=${#comp} + fi + done < <(printf "%s\n" "${completions[@]}") + + # If there is a single completion left, remove the description text and escape any special characters + if ((${#COMPREPLY[*]} == 1)); then + __supabase_debug "COMPREPLY[0]: ${COMPREPLY[0]}" + COMPREPLY[0]=$(printf "%q" "${COMPREPLY[0]%%$tab*}") + __supabase_debug "Removed description from single completion, which is now: ${COMPREPLY[0]}" + else + # Format the descriptions + __supabase_format_comp_descriptions $longest + fi +} + +__supabase_handle_special_char() +{ + local comp="$1" + local char=$2 + if [[ "$comp" == *${char}* && "$COMP_WORDBREAKS" == *${char}* ]]; then + local word=${comp%"${comp##*${char}}"} + local idx=${#COMPREPLY[*]} + while ((--idx >= 0)); do + COMPREPLY[idx]=${COMPREPLY[idx]#"$word"} + done + fi +} + +__supabase_format_comp_descriptions() +{ + local tab=$'\t' + local comp desc maxdesclength + local longest=$1 + + local i ci + for ci in ${!COMPREPLY[*]}; do + comp=${COMPREPLY[ci]} + # Properly format the description string which follows a tab character if there is one + if [[ "$comp" == *$tab* ]]; then + __supabase_debug "Original comp: $comp" + desc=${comp#*$tab} + comp=${comp%%$tab*} + + # $COLUMNS stores the current shell width. + # Remove an extra 4 because we add 2 spaces and 2 parentheses. + maxdesclength=$(( COLUMNS - longest - 4 )) + + # Make sure we can fit a description of at least 8 characters + # if we are to align the descriptions. + if ((maxdesclength > 8)); then + # Add the proper number of spaces to align the descriptions + for ((i = ${#comp} ; i < longest ; i++)); do + comp+=" " + done + else + # Don't pad the descriptions so we can fit more text after the completion + maxdesclength=$(( COLUMNS - ${#comp} - 4 )) + fi + + # If there is enough space for any description text, + # truncate the descriptions that are too long for the shell width + if ((maxdesclength > 0)); then + if ((${#desc} > maxdesclength)); then + desc=${desc:0:$(( maxdesclength - 1 ))} + desc+="…" + fi + comp+=" ($desc)" + fi + COMPREPLY[ci]=$comp + __supabase_debug "Final comp: $comp" + fi + done +} + +__start_supabase() +{ + local cur prev words cword split + + COMPREPLY=() + + # Call _init_completion from the bash-completion package + # to prepare the arguments properly + if declare -F _init_completion >/dev/null 2>&1; then + _init_completion -n =: || return + else + __supabase_init_completion -n =: || return + fi + + __supabase_debug + __supabase_debug "========= starting completion logic ==========" + __supabase_debug "cur is ${cur}, words[*] is ${words[*]}, #words[@] is ${#words[@]}, cword is $cword" + + # The user could have moved the cursor backwards on the command-line. + # We need to trigger completion from the $cword location, so we need + # to truncate the command-line ($words) up to the $cword location. + words=("${words[@]:0:$cword+1}") + __supabase_debug "Truncated words[*]: ${words[*]}," + + local out directive + __supabase_get_completion_results + __supabase_process_completion_results +} + +if [[ $(type -t compopt) = "builtin" ]]; then + complete -o default -F __start_supabase supabase +else + complete -o default -o nospace -F __start_supabase supabase +fi + +# ex: ts=4 sw=4 et filetype=sh diff --git a/apps/cli/src/legacy/commands/completion/__fixtures__/fish.desc.txt b/apps/cli/src/legacy/commands/completion/__fixtures__/fish.desc.txt new file mode 100644 index 0000000000..b051026cc9 --- /dev/null +++ b/apps/cli/src/legacy/commands/completion/__fixtures__/fish.desc.txt @@ -0,0 +1,235 @@ +# fish completion for supabase -*- shell-script -*- + +function __supabase_debug + set -l file "$BASH_COMP_DEBUG_FILE" + if test -n "$file" + echo "$argv" >> $file + end +end + +function __supabase_perform_completion + __supabase_debug "Starting __supabase_perform_completion" + + # Extract all args except the last one + set -l args (commandline -opc) + # Extract the last arg and escape it in case it is a space + set -l lastArg (string escape -- (commandline -ct)) + + __supabase_debug "args: $args" + __supabase_debug "last arg: $lastArg" + + # Disable ActiveHelp which is not supported for fish shell + set -l requestComp "SUPABASE_ACTIVE_HELP=0 $args[1] __complete $args[2..-1] $lastArg" + + __supabase_debug "Calling $requestComp" + set -l results (eval $requestComp 2> /dev/null) + + # Some programs may output extra empty lines after the directive. + # Let's ignore them or else it will break completion. + # Ref: https://github.com/spf13/cobra/issues/1279 + for line in $results[-1..1] + if test (string trim -- $line) = "" + # Found an empty line, remove it + set results $results[1..-2] + else + # Found non-empty line, we have our proper output + break + end + end + + set -l comps $results[1..-2] + set -l directiveLine $results[-1] + + # For Fish, when completing a flag with an = (e.g., -n=) + # completions must be prefixed with the flag + set -l flagPrefix (string match -r -- '-.*=' "$lastArg") + + __supabase_debug "Comps: $comps" + __supabase_debug "DirectiveLine: $directiveLine" + __supabase_debug "flagPrefix: $flagPrefix" + + for comp in $comps + printf "%s%s\n" "$flagPrefix" "$comp" + end + + printf "%s\n" "$directiveLine" +end + +# this function limits calls to __supabase_perform_completion, by caching the result behind $__supabase_perform_completion_once_result +function __supabase_perform_completion_once + __supabase_debug "Starting __supabase_perform_completion_once" + + if test -n "$__supabase_perform_completion_once_result" + __supabase_debug "Seems like a valid result already exists, skipping __supabase_perform_completion" + return 0 + end + + set --global __supabase_perform_completion_once_result (__supabase_perform_completion) + if test -z "$__supabase_perform_completion_once_result" + __supabase_debug "No completions, probably due to a failure" + return 1 + end + + __supabase_debug "Performed completions and set __supabase_perform_completion_once_result" + return 0 +end + +# this function is used to clear the $__supabase_perform_completion_once_result variable after completions are run +function __supabase_clear_perform_completion_once_result + __supabase_debug "" + __supabase_debug "========= clearing previously set __supabase_perform_completion_once_result variable ==========" + set --erase __supabase_perform_completion_once_result + __supabase_debug "Successfully erased the variable __supabase_perform_completion_once_result" +end + +function __supabase_requires_order_preservation + __supabase_debug "" + __supabase_debug "========= checking if order preservation is required ==========" + + __supabase_perform_completion_once + if test -z "$__supabase_perform_completion_once_result" + __supabase_debug "Error determining if order preservation is required" + return 1 + end + + set -l directive (string sub --start 2 $__supabase_perform_completion_once_result[-1]) + __supabase_debug "Directive is: $directive" + + set -l shellCompDirectiveKeepOrder 32 + set -l keeporder (math (math --scale 0 $directive / $shellCompDirectiveKeepOrder) % 2) + __supabase_debug "Keeporder is: $keeporder" + + if test $keeporder -ne 0 + __supabase_debug "This does require order preservation" + return 0 + end + + __supabase_debug "This doesn't require order preservation" + return 1 +end + + +# This function does two things: +# - Obtain the completions and store them in the global __supabase_comp_results +# - Return false if file completion should be performed +function __supabase_prepare_completions + __supabase_debug "" + __supabase_debug "========= starting completion logic ==========" + + # Start fresh + set --erase __supabase_comp_results + + __supabase_perform_completion_once + __supabase_debug "Completion results: $__supabase_perform_completion_once_result" + + if test -z "$__supabase_perform_completion_once_result" + __supabase_debug "No completion, probably due to a failure" + # Might as well do file completion, in case it helps + return 1 + end + + set -l directive (string sub --start 2 $__supabase_perform_completion_once_result[-1]) + set --global __supabase_comp_results $__supabase_perform_completion_once_result[1..-2] + + __supabase_debug "Completions are: $__supabase_comp_results" + __supabase_debug "Directive is: $directive" + + set -l shellCompDirectiveError 1 + set -l shellCompDirectiveNoSpace 2 + set -l shellCompDirectiveNoFileComp 4 + set -l shellCompDirectiveFilterFileExt 8 + set -l shellCompDirectiveFilterDirs 16 + + if test -z "$directive" + set directive 0 + end + + set -l compErr (math (math --scale 0 $directive / $shellCompDirectiveError) % 2) + if test $compErr -eq 1 + __supabase_debug "Received error directive: aborting." + # Might as well do file completion, in case it helps + return 1 + end + + set -l filefilter (math (math --scale 0 $directive / $shellCompDirectiveFilterFileExt) % 2) + set -l dirfilter (math (math --scale 0 $directive / $shellCompDirectiveFilterDirs) % 2) + if test $filefilter -eq 1; or test $dirfilter -eq 1 + __supabase_debug "File extension filtering or directory filtering not supported" + # Do full file completion instead + return 1 + end + + set -l nospace (math (math --scale 0 $directive / $shellCompDirectiveNoSpace) % 2) + set -l nofiles (math (math --scale 0 $directive / $shellCompDirectiveNoFileComp) % 2) + + __supabase_debug "nospace: $nospace, nofiles: $nofiles" + + # If we want to prevent a space, or if file completion is NOT disabled, + # we need to count the number of valid completions. + # To do so, we will filter on prefix as the completions we have received + # may not already be filtered so as to allow fish to match on different + # criteria than the prefix. + if test $nospace -ne 0; or test $nofiles -eq 0 + set -l prefix (commandline -t | string escape --style=regex) + __supabase_debug "prefix: $prefix" + + set -l completions (string match -r -- "^$prefix.*" $__supabase_comp_results) + set --global __supabase_comp_results $completions + __supabase_debug "Filtered completions are: $__supabase_comp_results" + + # Important not to quote the variable for count to work + set -l numComps (count $__supabase_comp_results) + __supabase_debug "numComps: $numComps" + + if test $numComps -eq 1; and test $nospace -ne 0 + # We must first split on \t to get rid of the descriptions to be + # able to check what the actual completion will be. + # We don't need descriptions anyway since there is only a single + # real completion which the shell will expand immediately. + set -l split (string split --max 1 \t $__supabase_comp_results[1]) + + # Fish won't add a space if the completion ends with any + # of the following characters: @=/:., + set -l lastChar (string sub -s -1 -- $split) + if not string match -r -q "[@=/:.,]" -- "$lastChar" + # In other cases, to support the "nospace" directive we trick the shell + # by outputting an extra, longer completion. + __supabase_debug "Adding second completion to perform nospace directive" + set --global __supabase_comp_results $split[1] $split[1]. + __supabase_debug "Completions are now: $__supabase_comp_results" + end + end + + if test $numComps -eq 0; and test $nofiles -eq 0 + # To be consistent with bash and zsh, we only trigger file + # completion when there are no other completions + __supabase_debug "Requesting file completion" + return 1 + end + end + + return 0 +end + +# Since Fish completions are only loaded once the user triggers them, we trigger them ourselves +# so we can properly delete any completions provided by another script. +# Only do this if the program can be found, or else fish may print some errors; besides, +# the existing completions will only be loaded if the program can be found. +if type -q "supabase" + # The space after the program name is essential to trigger completion for the program + # and not completion of the program name itself. + # Also, we use '> /dev/null 2>&1' since '&>' is not supported in older versions of fish. + complete --do-complete "supabase " > /dev/null 2>&1 +end + +# Remove any pre-existing completions for the program since we will be handling all of them. +complete -c supabase -e + +# this will get called after the two calls below and clear the $__supabase_perform_completion_once_result global +complete -c supabase -n '__supabase_clear_perform_completion_once_result' +# The call to __supabase_prepare_completions will setup __supabase_comp_results +# which provides the program's completion choices. +# If this doesn't require order preservation, we don't use the -k flag +complete -c supabase -n 'not __supabase_requires_order_preservation && __supabase_prepare_completions' -f -a '$__supabase_comp_results' +# otherwise we use the -k flag +complete -k -c supabase -n '__supabase_requires_order_preservation && __supabase_prepare_completions' -f -a '$__supabase_comp_results' diff --git a/apps/cli/src/legacy/commands/completion/__fixtures__/fish.nodesc.txt b/apps/cli/src/legacy/commands/completion/__fixtures__/fish.nodesc.txt new file mode 100644 index 0000000000..40e78e579a --- /dev/null +++ b/apps/cli/src/legacy/commands/completion/__fixtures__/fish.nodesc.txt @@ -0,0 +1,235 @@ +# fish completion for supabase -*- shell-script -*- + +function __supabase_debug + set -l file "$BASH_COMP_DEBUG_FILE" + if test -n "$file" + echo "$argv" >> $file + end +end + +function __supabase_perform_completion + __supabase_debug "Starting __supabase_perform_completion" + + # Extract all args except the last one + set -l args (commandline -opc) + # Extract the last arg and escape it in case it is a space + set -l lastArg (string escape -- (commandline -ct)) + + __supabase_debug "args: $args" + __supabase_debug "last arg: $lastArg" + + # Disable ActiveHelp which is not supported for fish shell + set -l requestComp "SUPABASE_ACTIVE_HELP=0 $args[1] __completeNoDesc $args[2..-1] $lastArg" + + __supabase_debug "Calling $requestComp" + set -l results (eval $requestComp 2> /dev/null) + + # Some programs may output extra empty lines after the directive. + # Let's ignore them or else it will break completion. + # Ref: https://github.com/spf13/cobra/issues/1279 + for line in $results[-1..1] + if test (string trim -- $line) = "" + # Found an empty line, remove it + set results $results[1..-2] + else + # Found non-empty line, we have our proper output + break + end + end + + set -l comps $results[1..-2] + set -l directiveLine $results[-1] + + # For Fish, when completing a flag with an = (e.g., -n=) + # completions must be prefixed with the flag + set -l flagPrefix (string match -r -- '-.*=' "$lastArg") + + __supabase_debug "Comps: $comps" + __supabase_debug "DirectiveLine: $directiveLine" + __supabase_debug "flagPrefix: $flagPrefix" + + for comp in $comps + printf "%s%s\n" "$flagPrefix" "$comp" + end + + printf "%s\n" "$directiveLine" +end + +# this function limits calls to __supabase_perform_completion, by caching the result behind $__supabase_perform_completion_once_result +function __supabase_perform_completion_once + __supabase_debug "Starting __supabase_perform_completion_once" + + if test -n "$__supabase_perform_completion_once_result" + __supabase_debug "Seems like a valid result already exists, skipping __supabase_perform_completion" + return 0 + end + + set --global __supabase_perform_completion_once_result (__supabase_perform_completion) + if test -z "$__supabase_perform_completion_once_result" + __supabase_debug "No completions, probably due to a failure" + return 1 + end + + __supabase_debug "Performed completions and set __supabase_perform_completion_once_result" + return 0 +end + +# this function is used to clear the $__supabase_perform_completion_once_result variable after completions are run +function __supabase_clear_perform_completion_once_result + __supabase_debug "" + __supabase_debug "========= clearing previously set __supabase_perform_completion_once_result variable ==========" + set --erase __supabase_perform_completion_once_result + __supabase_debug "Successfully erased the variable __supabase_perform_completion_once_result" +end + +function __supabase_requires_order_preservation + __supabase_debug "" + __supabase_debug "========= checking if order preservation is required ==========" + + __supabase_perform_completion_once + if test -z "$__supabase_perform_completion_once_result" + __supabase_debug "Error determining if order preservation is required" + return 1 + end + + set -l directive (string sub --start 2 $__supabase_perform_completion_once_result[-1]) + __supabase_debug "Directive is: $directive" + + set -l shellCompDirectiveKeepOrder 32 + set -l keeporder (math (math --scale 0 $directive / $shellCompDirectiveKeepOrder) % 2) + __supabase_debug "Keeporder is: $keeporder" + + if test $keeporder -ne 0 + __supabase_debug "This does require order preservation" + return 0 + end + + __supabase_debug "This doesn't require order preservation" + return 1 +end + + +# This function does two things: +# - Obtain the completions and store them in the global __supabase_comp_results +# - Return false if file completion should be performed +function __supabase_prepare_completions + __supabase_debug "" + __supabase_debug "========= starting completion logic ==========" + + # Start fresh + set --erase __supabase_comp_results + + __supabase_perform_completion_once + __supabase_debug "Completion results: $__supabase_perform_completion_once_result" + + if test -z "$__supabase_perform_completion_once_result" + __supabase_debug "No completion, probably due to a failure" + # Might as well do file completion, in case it helps + return 1 + end + + set -l directive (string sub --start 2 $__supabase_perform_completion_once_result[-1]) + set --global __supabase_comp_results $__supabase_perform_completion_once_result[1..-2] + + __supabase_debug "Completions are: $__supabase_comp_results" + __supabase_debug "Directive is: $directive" + + set -l shellCompDirectiveError 1 + set -l shellCompDirectiveNoSpace 2 + set -l shellCompDirectiveNoFileComp 4 + set -l shellCompDirectiveFilterFileExt 8 + set -l shellCompDirectiveFilterDirs 16 + + if test -z "$directive" + set directive 0 + end + + set -l compErr (math (math --scale 0 $directive / $shellCompDirectiveError) % 2) + if test $compErr -eq 1 + __supabase_debug "Received error directive: aborting." + # Might as well do file completion, in case it helps + return 1 + end + + set -l filefilter (math (math --scale 0 $directive / $shellCompDirectiveFilterFileExt) % 2) + set -l dirfilter (math (math --scale 0 $directive / $shellCompDirectiveFilterDirs) % 2) + if test $filefilter -eq 1; or test $dirfilter -eq 1 + __supabase_debug "File extension filtering or directory filtering not supported" + # Do full file completion instead + return 1 + end + + set -l nospace (math (math --scale 0 $directive / $shellCompDirectiveNoSpace) % 2) + set -l nofiles (math (math --scale 0 $directive / $shellCompDirectiveNoFileComp) % 2) + + __supabase_debug "nospace: $nospace, nofiles: $nofiles" + + # If we want to prevent a space, or if file completion is NOT disabled, + # we need to count the number of valid completions. + # To do so, we will filter on prefix as the completions we have received + # may not already be filtered so as to allow fish to match on different + # criteria than the prefix. + if test $nospace -ne 0; or test $nofiles -eq 0 + set -l prefix (commandline -t | string escape --style=regex) + __supabase_debug "prefix: $prefix" + + set -l completions (string match -r -- "^$prefix.*" $__supabase_comp_results) + set --global __supabase_comp_results $completions + __supabase_debug "Filtered completions are: $__supabase_comp_results" + + # Important not to quote the variable for count to work + set -l numComps (count $__supabase_comp_results) + __supabase_debug "numComps: $numComps" + + if test $numComps -eq 1; and test $nospace -ne 0 + # We must first split on \t to get rid of the descriptions to be + # able to check what the actual completion will be. + # We don't need descriptions anyway since there is only a single + # real completion which the shell will expand immediately. + set -l split (string split --max 1 \t $__supabase_comp_results[1]) + + # Fish won't add a space if the completion ends with any + # of the following characters: @=/:., + set -l lastChar (string sub -s -1 -- $split) + if not string match -r -q "[@=/:.,]" -- "$lastChar" + # In other cases, to support the "nospace" directive we trick the shell + # by outputting an extra, longer completion. + __supabase_debug "Adding second completion to perform nospace directive" + set --global __supabase_comp_results $split[1] $split[1]. + __supabase_debug "Completions are now: $__supabase_comp_results" + end + end + + if test $numComps -eq 0; and test $nofiles -eq 0 + # To be consistent with bash and zsh, we only trigger file + # completion when there are no other completions + __supabase_debug "Requesting file completion" + return 1 + end + end + + return 0 +end + +# Since Fish completions are only loaded once the user triggers them, we trigger them ourselves +# so we can properly delete any completions provided by another script. +# Only do this if the program can be found, or else fish may print some errors; besides, +# the existing completions will only be loaded if the program can be found. +if type -q "supabase" + # The space after the program name is essential to trigger completion for the program + # and not completion of the program name itself. + # Also, we use '> /dev/null 2>&1' since '&>' is not supported in older versions of fish. + complete --do-complete "supabase " > /dev/null 2>&1 +end + +# Remove any pre-existing completions for the program since we will be handling all of them. +complete -c supabase -e + +# this will get called after the two calls below and clear the $__supabase_perform_completion_once_result global +complete -c supabase -n '__supabase_clear_perform_completion_once_result' +# The call to __supabase_prepare_completions will setup __supabase_comp_results +# which provides the program's completion choices. +# If this doesn't require order preservation, we don't use the -k flag +complete -c supabase -n 'not __supabase_requires_order_preservation && __supabase_prepare_completions' -f -a '$__supabase_comp_results' +# otherwise we use the -k flag +complete -k -c supabase -n '__supabase_requires_order_preservation && __supabase_prepare_completions' -f -a '$__supabase_comp_results' diff --git a/apps/cli/src/legacy/commands/completion/__fixtures__/powershell.desc.txt b/apps/cli/src/legacy/commands/completion/__fixtures__/powershell.desc.txt new file mode 100644 index 0000000000..9ae33607e4 --- /dev/null +++ b/apps/cli/src/legacy/commands/completion/__fixtures__/powershell.desc.txt @@ -0,0 +1,270 @@ +# powershell completion for supabase -*- shell-script -*- + +function __supabase_debug { + if ($env:BASH_COMP_DEBUG_FILE) { + "$args" | Out-File -Append -FilePath "$env:BASH_COMP_DEBUG_FILE" + } +} + +filter __supabase_escapeStringWithSpecialChars { + $_ -replace '\s|#|@|\$|;|,|''|\{|\}|\(|\)|"|`|\||<|>|&','`$&' +} + +[scriptblock]${__supabaseCompleterBlock} = { + param( + $WordToComplete, + $CommandAst, + $CursorPosition + ) + + # Get the current command line and convert into a string + $Command = $CommandAst.CommandElements + $Command = "$Command" + + __supabase_debug "" + __supabase_debug "========= starting completion logic ==========" + __supabase_debug "WordToComplete: $WordToComplete Command: $Command CursorPosition: $CursorPosition" + + # The user could have moved the cursor backwards on the command-line. + # We need to trigger completion from the $CursorPosition location, so we need + # to truncate the command-line ($Command) up to the $CursorPosition location. + # Make sure the $Command is longer then the $CursorPosition before we truncate. + # This happens because the $Command does not include the last space. + if ($Command.Length -gt $CursorPosition) { + $Command=$Command.Substring(0,$CursorPosition) + } + __supabase_debug "Truncated command: $Command" + + $ShellCompDirectiveError=1 + $ShellCompDirectiveNoSpace=2 + $ShellCompDirectiveNoFileComp=4 + $ShellCompDirectiveFilterFileExt=8 + $ShellCompDirectiveFilterDirs=16 + $ShellCompDirectiveKeepOrder=32 + + # Prepare the command to request completions for the program. + # Split the command at the first space to separate the program and arguments. + $Program,$Arguments = $Command.Split(" ",2) + + $RequestComp="$Program __complete $Arguments" + __supabase_debug "RequestComp: $RequestComp" + + # we cannot use $WordToComplete because it + # has the wrong values if the cursor was moved + # so use the last argument + if ($WordToComplete -ne "" ) { + $WordToComplete = $Arguments.Split(" ")[-1] + } + __supabase_debug "New WordToComplete: $WordToComplete" + + + # Check for flag with equal sign + $IsEqualFlag = ($WordToComplete -Like "--*=*" ) + if ( $IsEqualFlag ) { + __supabase_debug "Completing equal sign flag" + # Remove the flag part + $Flag,$WordToComplete = $WordToComplete.Split("=",2) + } + + if ( $WordToComplete -eq "" -And ( -Not $IsEqualFlag )) { + # If the last parameter is complete (there is a space following it) + # We add an extra empty parameter so we can indicate this to the go method. + __supabase_debug "Adding extra empty parameter" + # PowerShell 7.2+ changed the way how the arguments are passed to executables, + # so for pre-7.2 or when Legacy argument passing is enabled we need to use + # `"`" to pass an empty argument, a "" or '' does not work!!! + if ($PSVersionTable.PsVersion -lt [version]'7.2.0' -or + ($PSVersionTable.PsVersion -lt [version]'7.3.0' -and -not [ExperimentalFeature]::IsEnabled("PSNativeCommandArgumentPassing")) -or + (($PSVersionTable.PsVersion -ge [version]'7.3.0' -or [ExperimentalFeature]::IsEnabled("PSNativeCommandArgumentPassing")) -and + $PSNativeCommandArgumentPassing -eq 'Legacy')) { + $RequestComp="$RequestComp" + ' `"`"' + } else { + $RequestComp="$RequestComp" + ' ""' + } + } + + __supabase_debug "Calling $RequestComp" + # First disable ActiveHelp which is not supported for Powershell + ${env:SUPABASE_ACTIVE_HELP}=0 + + #call the command store the output in $out and redirect stderr and stdout to null + # $Out is an array contains each line per element + Invoke-Expression -OutVariable out "$RequestComp" 2>&1 | Out-Null + + # get directive from last line + [int]$Directive = $Out[-1].TrimStart(':') + if ($Directive -eq "") { + # There is no directive specified + $Directive = 0 + } + __supabase_debug "The completion directive is: $Directive" + + # remove directive (last element) from out + $Out = $Out | Where-Object { $_ -ne $Out[-1] } + __supabase_debug "The completions are: $Out" + + if (($Directive -band $ShellCompDirectiveError) -ne 0 ) { + # Error code. No completion. + __supabase_debug "Received error from custom completion go code" + return + } + + $Longest = 0 + [Array]$Values = $Out | ForEach-Object { + #Split the output in name and description + $Name, $Description = $_.Split("`t",2) + __supabase_debug "Name: $Name Description: $Description" + + # Look for the longest completion so that we can format things nicely + if ($Longest -lt $Name.Length) { + $Longest = $Name.Length + } + + # Set the description to a one space string if there is none set. + # This is needed because the CompletionResult does not accept an empty string as argument + if (-Not $Description) { + $Description = " " + } + New-Object -TypeName PSCustomObject -Property @{ + Name = "$Name" + Description = "$Description" + } + } + + + $Space = " " + if (($Directive -band $ShellCompDirectiveNoSpace) -ne 0 ) { + # remove the space here + __supabase_debug "ShellCompDirectiveNoSpace is called" + $Space = "" + } + + if ((($Directive -band $ShellCompDirectiveFilterFileExt) -ne 0 ) -or + (($Directive -band $ShellCompDirectiveFilterDirs) -ne 0 )) { + __supabase_debug "ShellCompDirectiveFilterFileExt ShellCompDirectiveFilterDirs are not supported" + + # return here to prevent the completion of the extensions + return + } + + $Values = $Values | Where-Object { + # filter the result + $_.Name -like "$WordToComplete*" + + # Join the flag back if we have an equal sign flag + if ( $IsEqualFlag ) { + __supabase_debug "Join the equal sign flag back to the completion value" + $_.Name = $Flag + "=" + $_.Name + } + } + + # we sort the values in ascending order by name if keep order isn't passed + if (($Directive -band $ShellCompDirectiveKeepOrder) -eq 0 ) { + $Values = $Values | Sort-Object -Property Name + } + + if (($Directive -band $ShellCompDirectiveNoFileComp) -ne 0 ) { + __supabase_debug "ShellCompDirectiveNoFileComp is called" + + if ($Values.Length -eq 0) { + # Just print an empty string here so the + # shell does not start to complete paths. + # We cannot use CompletionResult here because + # it does not accept an empty string as argument. + "" + return + } + } + + # Get the current mode + $Mode = (Get-PSReadLineKeyHandler | Where-Object {$_.Key -eq "Tab" }).Function + __supabase_debug "Mode: $Mode" + + $Values | ForEach-Object { + + # store temporary because switch will overwrite $_ + $comp = $_ + + # PowerShell supports three different completion modes + # - TabCompleteNext (default windows style - on each key press the next option is displayed) + # - Complete (works like bash) + # - MenuComplete (works like zsh) + # You set the mode with Set-PSReadLineKeyHandler -Key Tab -Function + + # CompletionResult Arguments: + # 1) CompletionText text to be used as the auto completion result + # 2) ListItemText text to be displayed in the suggestion list + # 3) ResultType type of completion result + # 4) ToolTip text for the tooltip with details about the object + + switch ($Mode) { + + # bash like + "Complete" { + + if ($Values.Length -eq 1) { + __supabase_debug "Only one completion left" + + # insert space after value + $CompletionText = $($comp.Name | __supabase_escapeStringWithSpecialChars) + $Space + if ($ExecutionContext.SessionState.LanguageMode -eq "FullLanguage"){ + [System.Management.Automation.CompletionResult]::new($CompletionText, "$($comp.Name)", 'ParameterValue', "$($comp.Description)") + } else { + $CompletionText + } + + } else { + # Add the proper number of spaces to align the descriptions + while($comp.Name.Length -lt $Longest) { + $comp.Name = $comp.Name + " " + } + + # Check for empty description and only add parentheses if needed + if ($($comp.Description) -eq " " ) { + $Description = "" + } else { + $Description = " ($($comp.Description))" + } + + $CompletionText = "$($comp.Name)$Description" + if ($ExecutionContext.SessionState.LanguageMode -eq "FullLanguage"){ + [System.Management.Automation.CompletionResult]::new($CompletionText, "$($comp.Name)$Description", 'ParameterValue', "$($comp.Description)") + } else { + $CompletionText + } + } + } + + # zsh like + "MenuComplete" { + # insert space after value + # MenuComplete will automatically show the ToolTip of + # the highlighted value at the bottom of the suggestions. + + $CompletionText = $($comp.Name | __supabase_escapeStringWithSpecialChars) + $Space + if ($ExecutionContext.SessionState.LanguageMode -eq "FullLanguage"){ + [System.Management.Automation.CompletionResult]::new($CompletionText, "$($comp.Name)", 'ParameterValue', "$($comp.Description)") + } else { + $CompletionText + } + } + + # TabCompleteNext and in case we get something unknown + Default { + # Like MenuComplete but we don't want to add a space here because + # the user need to press space anyway to get the completion. + # Description will not be shown because that's not possible with TabCompleteNext + + $CompletionText = $($comp.Name | __supabase_escapeStringWithSpecialChars) + if ($ExecutionContext.SessionState.LanguageMode -eq "FullLanguage"){ + [System.Management.Automation.CompletionResult]::new($CompletionText, "$($comp.Name)", 'ParameterValue', "$($comp.Description)") + } else { + $CompletionText + } + } + } + + } +} + +Register-ArgumentCompleter -CommandName 'supabase' -ScriptBlock ${__supabaseCompleterBlock} diff --git a/apps/cli/src/legacy/commands/completion/__fixtures__/powershell.nodesc.txt b/apps/cli/src/legacy/commands/completion/__fixtures__/powershell.nodesc.txt new file mode 100644 index 0000000000..f023822b54 --- /dev/null +++ b/apps/cli/src/legacy/commands/completion/__fixtures__/powershell.nodesc.txt @@ -0,0 +1,270 @@ +# powershell completion for supabase -*- shell-script -*- + +function __supabase_debug { + if ($env:BASH_COMP_DEBUG_FILE) { + "$args" | Out-File -Append -FilePath "$env:BASH_COMP_DEBUG_FILE" + } +} + +filter __supabase_escapeStringWithSpecialChars { + $_ -replace '\s|#|@|\$|;|,|''|\{|\}|\(|\)|"|`|\||<|>|&','`$&' +} + +[scriptblock]${__supabaseCompleterBlock} = { + param( + $WordToComplete, + $CommandAst, + $CursorPosition + ) + + # Get the current command line and convert into a string + $Command = $CommandAst.CommandElements + $Command = "$Command" + + __supabase_debug "" + __supabase_debug "========= starting completion logic ==========" + __supabase_debug "WordToComplete: $WordToComplete Command: $Command CursorPosition: $CursorPosition" + + # The user could have moved the cursor backwards on the command-line. + # We need to trigger completion from the $CursorPosition location, so we need + # to truncate the command-line ($Command) up to the $CursorPosition location. + # Make sure the $Command is longer then the $CursorPosition before we truncate. + # This happens because the $Command does not include the last space. + if ($Command.Length -gt $CursorPosition) { + $Command=$Command.Substring(0,$CursorPosition) + } + __supabase_debug "Truncated command: $Command" + + $ShellCompDirectiveError=1 + $ShellCompDirectiveNoSpace=2 + $ShellCompDirectiveNoFileComp=4 + $ShellCompDirectiveFilterFileExt=8 + $ShellCompDirectiveFilterDirs=16 + $ShellCompDirectiveKeepOrder=32 + + # Prepare the command to request completions for the program. + # Split the command at the first space to separate the program and arguments. + $Program,$Arguments = $Command.Split(" ",2) + + $RequestComp="$Program __completeNoDesc $Arguments" + __supabase_debug "RequestComp: $RequestComp" + + # we cannot use $WordToComplete because it + # has the wrong values if the cursor was moved + # so use the last argument + if ($WordToComplete -ne "" ) { + $WordToComplete = $Arguments.Split(" ")[-1] + } + __supabase_debug "New WordToComplete: $WordToComplete" + + + # Check for flag with equal sign + $IsEqualFlag = ($WordToComplete -Like "--*=*" ) + if ( $IsEqualFlag ) { + __supabase_debug "Completing equal sign flag" + # Remove the flag part + $Flag,$WordToComplete = $WordToComplete.Split("=",2) + } + + if ( $WordToComplete -eq "" -And ( -Not $IsEqualFlag )) { + # If the last parameter is complete (there is a space following it) + # We add an extra empty parameter so we can indicate this to the go method. + __supabase_debug "Adding extra empty parameter" + # PowerShell 7.2+ changed the way how the arguments are passed to executables, + # so for pre-7.2 or when Legacy argument passing is enabled we need to use + # `"`" to pass an empty argument, a "" or '' does not work!!! + if ($PSVersionTable.PsVersion -lt [version]'7.2.0' -or + ($PSVersionTable.PsVersion -lt [version]'7.3.0' -and -not [ExperimentalFeature]::IsEnabled("PSNativeCommandArgumentPassing")) -or + (($PSVersionTable.PsVersion -ge [version]'7.3.0' -or [ExperimentalFeature]::IsEnabled("PSNativeCommandArgumentPassing")) -and + $PSNativeCommandArgumentPassing -eq 'Legacy')) { + $RequestComp="$RequestComp" + ' `"`"' + } else { + $RequestComp="$RequestComp" + ' ""' + } + } + + __supabase_debug "Calling $RequestComp" + # First disable ActiveHelp which is not supported for Powershell + ${env:SUPABASE_ACTIVE_HELP}=0 + + #call the command store the output in $out and redirect stderr and stdout to null + # $Out is an array contains each line per element + Invoke-Expression -OutVariable out "$RequestComp" 2>&1 | Out-Null + + # get directive from last line + [int]$Directive = $Out[-1].TrimStart(':') + if ($Directive -eq "") { + # There is no directive specified + $Directive = 0 + } + __supabase_debug "The completion directive is: $Directive" + + # remove directive (last element) from out + $Out = $Out | Where-Object { $_ -ne $Out[-1] } + __supabase_debug "The completions are: $Out" + + if (($Directive -band $ShellCompDirectiveError) -ne 0 ) { + # Error code. No completion. + __supabase_debug "Received error from custom completion go code" + return + } + + $Longest = 0 + [Array]$Values = $Out | ForEach-Object { + #Split the output in name and description + $Name, $Description = $_.Split("`t",2) + __supabase_debug "Name: $Name Description: $Description" + + # Look for the longest completion so that we can format things nicely + if ($Longest -lt $Name.Length) { + $Longest = $Name.Length + } + + # Set the description to a one space string if there is none set. + # This is needed because the CompletionResult does not accept an empty string as argument + if (-Not $Description) { + $Description = " " + } + New-Object -TypeName PSCustomObject -Property @{ + Name = "$Name" + Description = "$Description" + } + } + + + $Space = " " + if (($Directive -band $ShellCompDirectiveNoSpace) -ne 0 ) { + # remove the space here + __supabase_debug "ShellCompDirectiveNoSpace is called" + $Space = "" + } + + if ((($Directive -band $ShellCompDirectiveFilterFileExt) -ne 0 ) -or + (($Directive -band $ShellCompDirectiveFilterDirs) -ne 0 )) { + __supabase_debug "ShellCompDirectiveFilterFileExt ShellCompDirectiveFilterDirs are not supported" + + # return here to prevent the completion of the extensions + return + } + + $Values = $Values | Where-Object { + # filter the result + $_.Name -like "$WordToComplete*" + + # Join the flag back if we have an equal sign flag + if ( $IsEqualFlag ) { + __supabase_debug "Join the equal sign flag back to the completion value" + $_.Name = $Flag + "=" + $_.Name + } + } + + # we sort the values in ascending order by name if keep order isn't passed + if (($Directive -band $ShellCompDirectiveKeepOrder) -eq 0 ) { + $Values = $Values | Sort-Object -Property Name + } + + if (($Directive -band $ShellCompDirectiveNoFileComp) -ne 0 ) { + __supabase_debug "ShellCompDirectiveNoFileComp is called" + + if ($Values.Length -eq 0) { + # Just print an empty string here so the + # shell does not start to complete paths. + # We cannot use CompletionResult here because + # it does not accept an empty string as argument. + "" + return + } + } + + # Get the current mode + $Mode = (Get-PSReadLineKeyHandler | Where-Object {$_.Key -eq "Tab" }).Function + __supabase_debug "Mode: $Mode" + + $Values | ForEach-Object { + + # store temporary because switch will overwrite $_ + $comp = $_ + + # PowerShell supports three different completion modes + # - TabCompleteNext (default windows style - on each key press the next option is displayed) + # - Complete (works like bash) + # - MenuComplete (works like zsh) + # You set the mode with Set-PSReadLineKeyHandler -Key Tab -Function + + # CompletionResult Arguments: + # 1) CompletionText text to be used as the auto completion result + # 2) ListItemText text to be displayed in the suggestion list + # 3) ResultType type of completion result + # 4) ToolTip text for the tooltip with details about the object + + switch ($Mode) { + + # bash like + "Complete" { + + if ($Values.Length -eq 1) { + __supabase_debug "Only one completion left" + + # insert space after value + $CompletionText = $($comp.Name | __supabase_escapeStringWithSpecialChars) + $Space + if ($ExecutionContext.SessionState.LanguageMode -eq "FullLanguage"){ + [System.Management.Automation.CompletionResult]::new($CompletionText, "$($comp.Name)", 'ParameterValue', "$($comp.Description)") + } else { + $CompletionText + } + + } else { + # Add the proper number of spaces to align the descriptions + while($comp.Name.Length -lt $Longest) { + $comp.Name = $comp.Name + " " + } + + # Check for empty description and only add parentheses if needed + if ($($comp.Description) -eq " " ) { + $Description = "" + } else { + $Description = " ($($comp.Description))" + } + + $CompletionText = "$($comp.Name)$Description" + if ($ExecutionContext.SessionState.LanguageMode -eq "FullLanguage"){ + [System.Management.Automation.CompletionResult]::new($CompletionText, "$($comp.Name)$Description", 'ParameterValue', "$($comp.Description)") + } else { + $CompletionText + } + } + } + + # zsh like + "MenuComplete" { + # insert space after value + # MenuComplete will automatically show the ToolTip of + # the highlighted value at the bottom of the suggestions. + + $CompletionText = $($comp.Name | __supabase_escapeStringWithSpecialChars) + $Space + if ($ExecutionContext.SessionState.LanguageMode -eq "FullLanguage"){ + [System.Management.Automation.CompletionResult]::new($CompletionText, "$($comp.Name)", 'ParameterValue', "$($comp.Description)") + } else { + $CompletionText + } + } + + # TabCompleteNext and in case we get something unknown + Default { + # Like MenuComplete but we don't want to add a space here because + # the user need to press space anyway to get the completion. + # Description will not be shown because that's not possible with TabCompleteNext + + $CompletionText = $($comp.Name | __supabase_escapeStringWithSpecialChars) + if ($ExecutionContext.SessionState.LanguageMode -eq "FullLanguage"){ + [System.Management.Automation.CompletionResult]::new($CompletionText, "$($comp.Name)", 'ParameterValue', "$($comp.Description)") + } else { + $CompletionText + } + } + } + + } +} + +Register-ArgumentCompleter -CommandName 'supabase' -ScriptBlock ${__supabaseCompleterBlock} diff --git a/apps/cli/src/legacy/commands/completion/__fixtures__/zsh.desc.txt b/apps/cli/src/legacy/commands/completion/__fixtures__/zsh.desc.txt new file mode 100644 index 0000000000..68ab1ac253 --- /dev/null +++ b/apps/cli/src/legacy/commands/completion/__fixtures__/zsh.desc.txt @@ -0,0 +1,212 @@ +#compdef supabase +compdef _supabase supabase + +# zsh completion for supabase -*- shell-script -*- + +__supabase_debug() +{ + local file="$BASH_COMP_DEBUG_FILE" + if [[ -n ${file} ]]; then + echo "$*" >> "${file}" + fi +} + +_supabase() +{ + local shellCompDirectiveError=1 + local shellCompDirectiveNoSpace=2 + local shellCompDirectiveNoFileComp=4 + local shellCompDirectiveFilterFileExt=8 + local shellCompDirectiveFilterDirs=16 + local shellCompDirectiveKeepOrder=32 + + local lastParam lastChar flagPrefix requestComp out directive comp lastComp noSpace keepOrder + local -a completions + + __supabase_debug "\n========= starting completion logic ==========" + __supabase_debug "CURRENT: ${CURRENT}, words[*]: ${words[*]}" + + # The user could have moved the cursor backwards on the command-line. + # We need to trigger completion from the $CURRENT location, so we need + # to truncate the command-line ($words) up to the $CURRENT location. + # (We cannot use $CURSOR as its value does not work when a command is an alias.) + words=("${=words[1,CURRENT]}") + __supabase_debug "Truncated words[*]: ${words[*]}," + + lastParam=${words[-1]} + lastChar=${lastParam[-1]} + __supabase_debug "lastParam: ${lastParam}, lastChar: ${lastChar}" + + # For zsh, when completing a flag with an = (e.g., supabase -n=) + # completions must be prefixed with the flag + setopt local_options BASH_REMATCH + if [[ "${lastParam}" =~ '-.*=' ]]; then + # We are dealing with a flag with an = + flagPrefix="-P ${BASH_REMATCH}" + fi + + # Prepare the command to obtain completions + requestComp="${words[1]} __complete ${words[2,-1]}" + if [ "${lastChar}" = "" ]; then + # If the last parameter is complete (there is a space following it) + # We add an extra empty parameter so we can indicate this to the go completion code. + __supabase_debug "Adding extra empty parameter" + requestComp="${requestComp} \"\"" + fi + + __supabase_debug "About to call: eval ${requestComp}" + + # Use eval to handle any environment variables and such + out=$(eval ${requestComp} 2>/dev/null) + __supabase_debug "completion output: ${out}" + + # Extract the directive integer following a : from the last line + local lastLine + while IFS='\n' read -r line; do + lastLine=${line} + done < <(printf "%s\n" "${out[@]}") + __supabase_debug "last line: ${lastLine}" + + if [ "${lastLine[1]}" = : ]; then + directive=${lastLine[2,-1]} + # Remove the directive including the : and the newline + local suffix + (( suffix=${#lastLine}+2)) + out=${out[1,-$suffix]} + else + # There is no directive specified. Leave $out as is. + __supabase_debug "No directive found. Setting do default" + directive=0 + fi + + __supabase_debug "directive: ${directive}" + __supabase_debug "completions: ${out}" + __supabase_debug "flagPrefix: ${flagPrefix}" + + if [ $((directive & shellCompDirectiveError)) -ne 0 ]; then + __supabase_debug "Completion received error. Ignoring completions." + return + fi + + local activeHelpMarker="_activeHelp_ " + local endIndex=${#activeHelpMarker} + local startIndex=$((${#activeHelpMarker}+1)) + local hasActiveHelp=0 + while IFS='\n' read -r comp; do + # Check if this is an activeHelp statement (i.e., prefixed with $activeHelpMarker) + if [ "${comp[1,$endIndex]}" = "$activeHelpMarker" ];then + __supabase_debug "ActiveHelp found: $comp" + comp="${comp[$startIndex,-1]}" + if [ -n "$comp" ]; then + compadd -x "${comp}" + __supabase_debug "ActiveHelp will need delimiter" + hasActiveHelp=1 + fi + + continue + fi + + if [ -n "$comp" ]; then + # If requested, completions are returned with a description. + # The description is preceded by a TAB character. + # For zsh's _describe, we need to use a : instead of a TAB. + # We first need to escape any : as part of the completion itself. + comp=${comp//:/\\:} + + local tab="$(printf '\t')" + comp=${comp//$tab/:} + + __supabase_debug "Adding completion: ${comp}" + completions+=${comp} + lastComp=$comp + fi + done < <(printf "%s\n" "${out[@]}") + + # Add a delimiter after the activeHelp statements, but only if: + # - there are completions following the activeHelp statements, or + # - file completion will be performed (so there will be choices after the activeHelp) + if [ $hasActiveHelp -eq 1 ]; then + if [ ${#completions} -ne 0 ] || [ $((directive & shellCompDirectiveNoFileComp)) -eq 0 ]; then + __supabase_debug "Adding activeHelp delimiter" + compadd -x "--" + hasActiveHelp=0 + fi + fi + + if [ $((directive & shellCompDirectiveNoSpace)) -ne 0 ]; then + __supabase_debug "Activating nospace." + noSpace="-S ''" + fi + + if [ $((directive & shellCompDirectiveKeepOrder)) -ne 0 ]; then + __supabase_debug "Activating keep order." + keepOrder="-V" + fi + + if [ $((directive & shellCompDirectiveFilterFileExt)) -ne 0 ]; then + # File extension filtering + local filteringCmd + filteringCmd='_files' + for filter in ${completions[@]}; do + if [ ${filter[1]} != '*' ]; then + # zsh requires a glob pattern to do file filtering + filter="\*.$filter" + fi + filteringCmd+=" -g $filter" + done + filteringCmd+=" ${flagPrefix}" + + __supabase_debug "File filtering command: $filteringCmd" + _arguments '*:filename:'"$filteringCmd" + elif [ $((directive & shellCompDirectiveFilterDirs)) -ne 0 ]; then + # File completion for directories only + local subdir + subdir="${completions[1]}" + if [ -n "$subdir" ]; then + __supabase_debug "Listing directories in $subdir" + pushd "${subdir}" >/dev/null 2>&1 + else + __supabase_debug "Listing directories in ." + fi + + local result + _arguments '*:dirname:_files -/'" ${flagPrefix}" + result=$? + if [ -n "$subdir" ]; then + popd >/dev/null 2>&1 + fi + return $result + else + __supabase_debug "Calling _describe" + if eval _describe $keepOrder "completions" completions $flagPrefix $noSpace; then + __supabase_debug "_describe found some completions" + + # Return the success of having called _describe + return 0 + else + __supabase_debug "_describe did not find completions." + __supabase_debug "Checking if we should do file completion." + if [ $((directive & shellCompDirectiveNoFileComp)) -ne 0 ]; then + __supabase_debug "deactivating file completion" + + # We must return an error code here to let zsh know that there were no + # completions found by _describe; this is what will trigger other + # matching algorithms to attempt to find completions. + # For example zsh can match letters in the middle of words. + return 1 + else + # Perform file completion + __supabase_debug "Activating file completion" + + # We must return the result of this command, so it must be the + # last command, or else we must store its result to return it. + _arguments '*:filename:_files'" ${flagPrefix}" + fi + fi + fi +} + +# don't run the completion function when being source-ed or eval-ed +if [ "$funcstack[1]" = "_supabase" ]; then + _supabase +fi diff --git a/apps/cli/src/legacy/commands/completion/__fixtures__/zsh.nodesc.txt b/apps/cli/src/legacy/commands/completion/__fixtures__/zsh.nodesc.txt new file mode 100644 index 0000000000..1b4be7db2e --- /dev/null +++ b/apps/cli/src/legacy/commands/completion/__fixtures__/zsh.nodesc.txt @@ -0,0 +1,212 @@ +#compdef supabase +compdef _supabase supabase + +# zsh completion for supabase -*- shell-script -*- + +__supabase_debug() +{ + local file="$BASH_COMP_DEBUG_FILE" + if [[ -n ${file} ]]; then + echo "$*" >> "${file}" + fi +} + +_supabase() +{ + local shellCompDirectiveError=1 + local shellCompDirectiveNoSpace=2 + local shellCompDirectiveNoFileComp=4 + local shellCompDirectiveFilterFileExt=8 + local shellCompDirectiveFilterDirs=16 + local shellCompDirectiveKeepOrder=32 + + local lastParam lastChar flagPrefix requestComp out directive comp lastComp noSpace keepOrder + local -a completions + + __supabase_debug "\n========= starting completion logic ==========" + __supabase_debug "CURRENT: ${CURRENT}, words[*]: ${words[*]}" + + # The user could have moved the cursor backwards on the command-line. + # We need to trigger completion from the $CURRENT location, so we need + # to truncate the command-line ($words) up to the $CURRENT location. + # (We cannot use $CURSOR as its value does not work when a command is an alias.) + words=("${=words[1,CURRENT]}") + __supabase_debug "Truncated words[*]: ${words[*]}," + + lastParam=${words[-1]} + lastChar=${lastParam[-1]} + __supabase_debug "lastParam: ${lastParam}, lastChar: ${lastChar}" + + # For zsh, when completing a flag with an = (e.g., supabase -n=) + # completions must be prefixed with the flag + setopt local_options BASH_REMATCH + if [[ "${lastParam}" =~ '-.*=' ]]; then + # We are dealing with a flag with an = + flagPrefix="-P ${BASH_REMATCH}" + fi + + # Prepare the command to obtain completions + requestComp="${words[1]} __completeNoDesc ${words[2,-1]}" + if [ "${lastChar}" = "" ]; then + # If the last parameter is complete (there is a space following it) + # We add an extra empty parameter so we can indicate this to the go completion code. + __supabase_debug "Adding extra empty parameter" + requestComp="${requestComp} \"\"" + fi + + __supabase_debug "About to call: eval ${requestComp}" + + # Use eval to handle any environment variables and such + out=$(eval ${requestComp} 2>/dev/null) + __supabase_debug "completion output: ${out}" + + # Extract the directive integer following a : from the last line + local lastLine + while IFS='\n' read -r line; do + lastLine=${line} + done < <(printf "%s\n" "${out[@]}") + __supabase_debug "last line: ${lastLine}" + + if [ "${lastLine[1]}" = : ]; then + directive=${lastLine[2,-1]} + # Remove the directive including the : and the newline + local suffix + (( suffix=${#lastLine}+2)) + out=${out[1,-$suffix]} + else + # There is no directive specified. Leave $out as is. + __supabase_debug "No directive found. Setting do default" + directive=0 + fi + + __supabase_debug "directive: ${directive}" + __supabase_debug "completions: ${out}" + __supabase_debug "flagPrefix: ${flagPrefix}" + + if [ $((directive & shellCompDirectiveError)) -ne 0 ]; then + __supabase_debug "Completion received error. Ignoring completions." + return + fi + + local activeHelpMarker="_activeHelp_ " + local endIndex=${#activeHelpMarker} + local startIndex=$((${#activeHelpMarker}+1)) + local hasActiveHelp=0 + while IFS='\n' read -r comp; do + # Check if this is an activeHelp statement (i.e., prefixed with $activeHelpMarker) + if [ "${comp[1,$endIndex]}" = "$activeHelpMarker" ];then + __supabase_debug "ActiveHelp found: $comp" + comp="${comp[$startIndex,-1]}" + if [ -n "$comp" ]; then + compadd -x "${comp}" + __supabase_debug "ActiveHelp will need delimiter" + hasActiveHelp=1 + fi + + continue + fi + + if [ -n "$comp" ]; then + # If requested, completions are returned with a description. + # The description is preceded by a TAB character. + # For zsh's _describe, we need to use a : instead of a TAB. + # We first need to escape any : as part of the completion itself. + comp=${comp//:/\\:} + + local tab="$(printf '\t')" + comp=${comp//$tab/:} + + __supabase_debug "Adding completion: ${comp}" + completions+=${comp} + lastComp=$comp + fi + done < <(printf "%s\n" "${out[@]}") + + # Add a delimiter after the activeHelp statements, but only if: + # - there are completions following the activeHelp statements, or + # - file completion will be performed (so there will be choices after the activeHelp) + if [ $hasActiveHelp -eq 1 ]; then + if [ ${#completions} -ne 0 ] || [ $((directive & shellCompDirectiveNoFileComp)) -eq 0 ]; then + __supabase_debug "Adding activeHelp delimiter" + compadd -x "--" + hasActiveHelp=0 + fi + fi + + if [ $((directive & shellCompDirectiveNoSpace)) -ne 0 ]; then + __supabase_debug "Activating nospace." + noSpace="-S ''" + fi + + if [ $((directive & shellCompDirectiveKeepOrder)) -ne 0 ]; then + __supabase_debug "Activating keep order." + keepOrder="-V" + fi + + if [ $((directive & shellCompDirectiveFilterFileExt)) -ne 0 ]; then + # File extension filtering + local filteringCmd + filteringCmd='_files' + for filter in ${completions[@]}; do + if [ ${filter[1]} != '*' ]; then + # zsh requires a glob pattern to do file filtering + filter="\*.$filter" + fi + filteringCmd+=" -g $filter" + done + filteringCmd+=" ${flagPrefix}" + + __supabase_debug "File filtering command: $filteringCmd" + _arguments '*:filename:'"$filteringCmd" + elif [ $((directive & shellCompDirectiveFilterDirs)) -ne 0 ]; then + # File completion for directories only + local subdir + subdir="${completions[1]}" + if [ -n "$subdir" ]; then + __supabase_debug "Listing directories in $subdir" + pushd "${subdir}" >/dev/null 2>&1 + else + __supabase_debug "Listing directories in ." + fi + + local result + _arguments '*:dirname:_files -/'" ${flagPrefix}" + result=$? + if [ -n "$subdir" ]; then + popd >/dev/null 2>&1 + fi + return $result + else + __supabase_debug "Calling _describe" + if eval _describe $keepOrder "completions" completions $flagPrefix $noSpace; then + __supabase_debug "_describe found some completions" + + # Return the success of having called _describe + return 0 + else + __supabase_debug "_describe did not find completions." + __supabase_debug "Checking if we should do file completion." + if [ $((directive & shellCompDirectiveNoFileComp)) -ne 0 ]; then + __supabase_debug "deactivating file completion" + + # We must return an error code here to let zsh know that there were no + # completions found by _describe; this is what will trigger other + # matching algorithms to attempt to find completions. + # For example zsh can match letters in the middle of words. + return 1 + else + # Perform file completion + __supabase_debug "Activating file completion" + + # We must return the result of this command, so it must be the + # last command, or else we must store its result to return it. + _arguments '*:filename:_files'" ${flagPrefix}" + fi + fi + fi +} + +# don't run the completion function when being source-ed or eval-ed +if [ "$funcstack[1]" = "_supabase" ]; then + _supabase +fi diff --git a/apps/cli/src/legacy/commands/completion/bash/bash.command.ts b/apps/cli/src/legacy/commands/completion/bash/bash.command.ts index a2810b19b7..b14d7a14be 100644 --- a/apps/cli/src/legacy/commands/completion/bash/bash.command.ts +++ b/apps/cli/src/legacy/commands/completion/bash/bash.command.ts @@ -1,5 +1,8 @@ import { Command } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; import { LegacyCompletionNoDescriptionsFlagDef } from "../completion.flags.ts"; import { legacyCompletionBash } from "./bash.handler.ts"; @@ -9,7 +12,25 @@ const config = { export type LegacyCompletionBashFlags = CliCommand.Command.Config.Infer; export const legacyCompletionBashCommand = Command.make("bash", config).pipe( - Command.withDescription("Generate the autocompletion script for bash"), + Command.withDescription( + "Generate the autocompletion script for the bash shell.\n\n" + + "This script depends on the 'bash-completion' package.\n" + + "If it is not installed already, you can install it via your OS's package manager.\n\n" + + "To load completions in your current shell session:\n\n" + + "\tsource <(supabase completion bash)\n\n" + + "To load completions for every new session, execute once:\n\n" + + "#### Linux:\n\n" + + "\tsupabase completion bash > /etc/bash_completion.d/supabase\n\n" + + "#### macOS:\n\n" + + "\tsupabase completion bash > $(brew --prefix)/etc/bash_completion.d/supabase\n\n" + + "You will need to start a new shell for this setup to take effect.", + ), Command.withShortDescription("Generate the autocompletion script for bash"), - Command.withHandler((flags) => legacyCompletionBash(flags)), + Command.withHandler((flags) => + legacyCompletionBash(flags).pipe( + withLegacyCommandInstrumentation({ flags }), + withJsonErrorHandling, + ), + ), + Command.provide(commandRuntimeLayer(["completion", "bash"])), ); diff --git a/apps/cli/src/legacy/commands/completion/bash/bash.handler.ts b/apps/cli/src/legacy/commands/completion/bash/bash.handler.ts index 9ff371dd44..e28ae7e975 100644 --- a/apps/cli/src/legacy/commands/completion/bash/bash.handler.ts +++ b/apps/cli/src/legacy/commands/completion/bash/bash.handler.ts @@ -1,12 +1,13 @@ import { Effect } from "effect"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { legacyGenerateCompletionScript } from "../legacy-completion-scripts.ts"; import type { LegacyCompletionBashFlags } from "./bash.command.ts"; export const legacyCompletionBash = Effect.fn("legacy.completion.bash")(function* ( flags: LegacyCompletionBashFlags, ) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["completion", "bash"]; - if (flags.noDescriptions) args.push("--no-descriptions"); - yield* proxy.exec(args); + const output = yield* Output; + yield* output.raw( + legacyGenerateCompletionScript("bash", { noDescriptions: flags.noDescriptions }), + ); }); diff --git a/apps/cli/src/legacy/commands/completion/bash/bash.integration.test.ts b/apps/cli/src/legacy/commands/completion/bash/bash.integration.test.ts index 3bb8f31137..243f153be2 100644 --- a/apps/cli/src/legacy/commands/completion/bash/bash.integration.test.ts +++ b/apps/cli/src/legacy/commands/completion/bash/bash.integration.test.ts @@ -1,20 +1,15 @@ import { describe, expect, it } from "@effect/vitest"; +import { BunServices } from "@effect/platform-bun"; import { Effect, Layer } from "effect"; import { Command } from "effect/unstable/cli"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { mockAnalytics, mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { processControlLayer } from "../../../../shared/runtime/process-control.layer.ts"; +import { EventCommandExecuted } from "../../../../shared/telemetry/event-catalog.ts"; import { legacyCompletionBashCommand } from "./bash.command.ts"; import { legacyCompletionBash } from "./bash.handler.ts"; function setupLegacyCompletionBash() { - const calls: Array> = []; - const layer = Layer.succeed(LegacyGoProxy, { - exec: (args) => - Effect.sync(() => { - calls.push(args); - }), - execCapture: () => Effect.succeed(""), - }); - return { layer, calls }; + return mockOutput(); } function legacyTestRoot() { @@ -22,30 +17,70 @@ function legacyTestRoot() { } describe("legacy completion bash", () => { - it.live("forwards `completion bash` to the Go binary", () => { - const { layer, calls } = setupLegacyCompletionBash(); + it.live("prints the native bash completion script", () => { + const out = setupLegacyCompletionBash(); return Effect.gen(function* () { yield* legacyCompletionBash({ noDescriptions: false }); - expect(calls).toEqual([["completion", "bash"]]); - }).pipe(Effect.provide(layer)); + expect(out.stdoutText).toContain("# bash completion V2 for supabase"); + expect(out.stdoutText).not.toContain("__completeNoDesc"); + expect(out.stdoutText).toContain("__complete"); + }).pipe(Effect.provide(out.layer)); }); - it.live("forwards --no-descriptions when set", () => { - const { layer, calls } = setupLegacyCompletionBash(); - return Effect.gen(function* () { - yield* legacyCompletionBash({ noDescriptions: true }); - expect(calls).toEqual([["completion", "bash", "--no-descriptions"]]); - }).pipe(Effect.provide(layer)); - }); + it.live( + "prints the native bash completion script without descriptions when --no-descriptions is set", + () => { + const out = setupLegacyCompletionBash(); + return Effect.gen(function* () { + yield* legacyCompletionBash({ noDescriptions: true }); + expect(out.stdoutText).toContain("__completeNoDesc"); + }).pipe(Effect.provide(out.layer)); + }, + ); - it.live("accepts --no-descriptions from real argv via the command parser", () => { - const { layer, calls } = setupLegacyCompletionBash(); - return Effect.gen(function* () { - yield* Command.runWith(legacyTestRoot(), { version: "0.0.0-test" })([ - "bash", - "--no-descriptions", - ]); - expect(calls).toEqual([["completion", "bash", "--no-descriptions"]]); - }).pipe(Effect.provide(layer)) as Effect.Effect; - }); + it.live( + "accepts --no-descriptions from real argv via the command parser and still prints the no-desc script", + () => { + const out = setupLegacyCompletionBash(); + // Running through the real command (rather than calling the handler + // directly, as the two tests above do) also runs + // `withLegacyCommandInstrumentation` (CLI-1965 review finding: telemetry + // parity with the Go CLI's `cli_command_executed` event), which needs + // `Analytics`/`ProcessControl`/`Stdio` alongside `Output` — the same + // minimal layer set `telemetry.integration.test.ts` uses for its own + // local-only (no Management API) native command. + const layer = Layer.mergeAll( + out.layer, + mockAnalytics().layer, + BunServices.layer, + processControlLayer, + ); + return Effect.gen(function* () { + yield* Command.runWith(legacyTestRoot(), { version: "0.0.0-test" })([ + "bash", + "--no-descriptions", + ]); + expect(out.stdoutText).toContain("__completeNoDesc"); + }).pipe(Effect.provide(layer)) as Effect.Effect; + }, + ); + + it.live( + "fires the cli_command_executed telemetry event, matching Go's PersistentPostRun (CLI-1965 review finding)", + () => { + const out = setupLegacyCompletionBash(); + const analytics = mockAnalytics(); + const layer = Layer.mergeAll( + out.layer, + analytics.layer, + BunServices.layer, + processControlLayer, + ); + return Effect.gen(function* () { + yield* Command.runWith(legacyTestRoot(), { version: "0.0.0-test" })(["bash"]); + const event = analytics.captured.find((entry) => entry.event === EventCommandExecuted); + expect(event).toBeDefined(); + }).pipe(Effect.provide(layer)) as Effect.Effect; + }, + ); }); diff --git a/apps/cli/src/legacy/commands/completion/completion.command.ts b/apps/cli/src/legacy/commands/completion/completion.command.ts index 38b7b53f88..056f62c3f9 100644 --- a/apps/cli/src/legacy/commands/completion/completion.command.ts +++ b/apps/cli/src/legacy/commands/completion/completion.command.ts @@ -9,7 +9,7 @@ export const legacyCompletionCommand = Command.make("completion").pipe( "Generate the autocompletion script for supabase for the specified shell.\n" + "See each sub-command's help for details on how to use the generated script.", ), - Command.withShortDescription("Generate autocompletion scripts"), + Command.withShortDescription("Generate the autocompletion script for the specified shell"), Command.withSubcommands([ legacyCompletionBashCommand, legacyCompletionFishCommand, diff --git a/apps/cli/src/legacy/commands/completion/completion.e2e.test.ts b/apps/cli/src/legacy/commands/completion/completion.e2e.test.ts index 56c721e5f3..3e23959686 100644 --- a/apps/cli/src/legacy/commands/completion/completion.e2e.test.ts +++ b/apps/cli/src/legacy/commands/completion/completion.e2e.test.ts @@ -4,15 +4,14 @@ import { runSupabase } from "../../../../tests/helpers/cli.ts"; const E2E_TIMEOUT_MS = 30_000; describe("supabase completion (legacy)", () => { - // Golden-path e2e for CLI-1858: `--no-descriptions` used to be rejected by - // Effect's argv parser (`UnrecognizedOption`) before the request ever - // reached the Go binary, because the flag wasn't declared on the TS leaf - // command. Only a real subprocess run proves both halves of the fix: the - // TS parser accepts the flag, and the Go binary actually receives it — it - // switches the generated script's completion callback from `__complete` to - // `__completeNoDesc` only when the flag is forwarded. + // Golden-path e2e for CLI-1858 / CLI-1965: `--no-descriptions` used to be + // rejected by Effect's argv parser (`UnrecognizedOption`) before the flag + // reached the completion command at all. As of CLI-1965 the script is + // generated natively in TS (no Go binary involved) — only a real + // subprocess run proves the TS parser accepts the flag AND that the + // handler actually selects the no-desc variant of the native template. test( - "bash --no-descriptions is accepted and forwarded to the Go binary", + "bash --no-descriptions is accepted and produces the native no-descriptions script", { timeout: E2E_TIMEOUT_MS }, async () => { const { exitCode, stdout } = await runSupabase(["completion", "bash", "--no-descriptions"], { @@ -22,4 +21,20 @@ describe("supabase completion (legacy)", () => { expect(stdout).toContain("__completeNoDesc"); }, ); + + // Minimal cross-shell smoke coverage: proves the default (with-descriptions) + // code path also works end-to-end through a real subprocess, for a shell + // other than bash. + test( + "zsh with no flags produces the native default script", + { timeout: E2E_TIMEOUT_MS }, + async () => { + const { exitCode, stdout } = await runSupabase(["completion", "zsh"], { + entrypoint: "legacy", + }); + expect(exitCode).toBe(0); + expect(stdout).toContain("#compdef supabase"); + expect(stdout).toContain("__complete"); + }, + ); }); diff --git a/apps/cli/src/legacy/commands/completion/fish/fish.command.ts b/apps/cli/src/legacy/commands/completion/fish/fish.command.ts index 33ef664d5f..e4332845ff 100644 --- a/apps/cli/src/legacy/commands/completion/fish/fish.command.ts +++ b/apps/cli/src/legacy/commands/completion/fish/fish.command.ts @@ -1,5 +1,8 @@ import { Command } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; import { LegacyCompletionNoDescriptionsFlagDef } from "../completion.flags.ts"; import { legacyCompletionFish } from "./fish.handler.ts"; @@ -9,7 +12,20 @@ const config = { export type LegacyCompletionFishFlags = CliCommand.Command.Config.Infer; export const legacyCompletionFishCommand = Command.make("fish", config).pipe( - Command.withDescription("Generate the autocompletion script for fish"), + Command.withDescription( + "Generate the autocompletion script for the fish shell.\n\n" + + "To load completions in your current shell session:\n\n" + + "\tsupabase completion fish | source\n\n" + + "To load completions for every new session, execute once:\n\n" + + "\tsupabase completion fish > ~/.config/fish/completions/supabase.fish\n\n" + + "You will need to start a new shell for this setup to take effect.", + ), Command.withShortDescription("Generate the autocompletion script for fish"), - Command.withHandler((flags) => legacyCompletionFish(flags)), + Command.withHandler((flags) => + legacyCompletionFish(flags).pipe( + withLegacyCommandInstrumentation({ flags }), + withJsonErrorHandling, + ), + ), + Command.provide(commandRuntimeLayer(["completion", "fish"])), ); diff --git a/apps/cli/src/legacy/commands/completion/fish/fish.handler.ts b/apps/cli/src/legacy/commands/completion/fish/fish.handler.ts index 0157d9cc6a..5deabc1557 100644 --- a/apps/cli/src/legacy/commands/completion/fish/fish.handler.ts +++ b/apps/cli/src/legacy/commands/completion/fish/fish.handler.ts @@ -1,12 +1,13 @@ import { Effect } from "effect"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { legacyGenerateCompletionScript } from "../legacy-completion-scripts.ts"; import type { LegacyCompletionFishFlags } from "./fish.command.ts"; export const legacyCompletionFish = Effect.fn("legacy.completion.fish")(function* ( flags: LegacyCompletionFishFlags, ) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["completion", "fish"]; - if (flags.noDescriptions) args.push("--no-descriptions"); - yield* proxy.exec(args); + const output = yield* Output; + yield* output.raw( + legacyGenerateCompletionScript("fish", { noDescriptions: flags.noDescriptions }), + ); }); diff --git a/apps/cli/src/legacy/commands/completion/fish/fish.integration.test.ts b/apps/cli/src/legacy/commands/completion/fish/fish.integration.test.ts index ba22430863..a6c5b970a7 100644 --- a/apps/cli/src/legacy/commands/completion/fish/fish.integration.test.ts +++ b/apps/cli/src/legacy/commands/completion/fish/fish.integration.test.ts @@ -1,20 +1,15 @@ import { describe, expect, it } from "@effect/vitest"; +import { BunServices } from "@effect/platform-bun"; import { Effect, Layer } from "effect"; import { Command } from "effect/unstable/cli"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { mockAnalytics, mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { processControlLayer } from "../../../../shared/runtime/process-control.layer.ts"; +import { EventCommandExecuted } from "../../../../shared/telemetry/event-catalog.ts"; import { legacyCompletionFishCommand } from "./fish.command.ts"; import { legacyCompletionFish } from "./fish.handler.ts"; function setupLegacyCompletionFish() { - const calls: Array> = []; - const layer = Layer.succeed(LegacyGoProxy, { - exec: (args) => - Effect.sync(() => { - calls.push(args); - }), - execCapture: () => Effect.succeed(""), - }); - return { layer, calls }; + return mockOutput(); } function legacyTestRoot() { @@ -22,30 +17,70 @@ function legacyTestRoot() { } describe("legacy completion fish", () => { - it.live("forwards `completion fish` to the Go binary", () => { - const { layer, calls } = setupLegacyCompletionFish(); + it.live("prints the native fish completion script", () => { + const out = setupLegacyCompletionFish(); return Effect.gen(function* () { yield* legacyCompletionFish({ noDescriptions: false }); - expect(calls).toEqual([["completion", "fish"]]); - }).pipe(Effect.provide(layer)); + expect(out.stdoutText).toContain("# fish completion for supabase"); + expect(out.stdoutText).not.toContain("__completeNoDesc"); + expect(out.stdoutText).toContain("__complete"); + }).pipe(Effect.provide(out.layer)); }); - it.live("forwards --no-descriptions when set", () => { - const { layer, calls } = setupLegacyCompletionFish(); - return Effect.gen(function* () { - yield* legacyCompletionFish({ noDescriptions: true }); - expect(calls).toEqual([["completion", "fish", "--no-descriptions"]]); - }).pipe(Effect.provide(layer)); - }); + it.live( + "prints the native fish completion script without descriptions when --no-descriptions is set", + () => { + const out = setupLegacyCompletionFish(); + return Effect.gen(function* () { + yield* legacyCompletionFish({ noDescriptions: true }); + expect(out.stdoutText).toContain("__completeNoDesc"); + }).pipe(Effect.provide(out.layer)); + }, + ); - it.live("accepts --no-descriptions from real argv via the command parser", () => { - const { layer, calls } = setupLegacyCompletionFish(); - return Effect.gen(function* () { - yield* Command.runWith(legacyTestRoot(), { version: "0.0.0-test" })([ - "fish", - "--no-descriptions", - ]); - expect(calls).toEqual([["completion", "fish", "--no-descriptions"]]); - }).pipe(Effect.provide(layer)) as Effect.Effect; - }); + it.live( + "accepts --no-descriptions from real argv via the command parser and still prints the no-desc script", + () => { + const out = setupLegacyCompletionFish(); + // Running through the real command (rather than calling the handler + // directly, as the two tests above do) also runs + // `withLegacyCommandInstrumentation` (CLI-1965 review finding: telemetry + // parity with the Go CLI's `cli_command_executed` event), which needs + // `Analytics`/`ProcessControl`/`Stdio` alongside `Output` — the same + // minimal layer set `telemetry.integration.test.ts` uses for its own + // local-only (no Management API) native command. + const layer = Layer.mergeAll( + out.layer, + mockAnalytics().layer, + BunServices.layer, + processControlLayer, + ); + return Effect.gen(function* () { + yield* Command.runWith(legacyTestRoot(), { version: "0.0.0-test" })([ + "fish", + "--no-descriptions", + ]); + expect(out.stdoutText).toContain("__completeNoDesc"); + }).pipe(Effect.provide(layer)) as Effect.Effect; + }, + ); + + it.live( + "fires the cli_command_executed telemetry event, matching Go's PersistentPostRun (CLI-1965 review finding)", + () => { + const out = setupLegacyCompletionFish(); + const analytics = mockAnalytics(); + const layer = Layer.mergeAll( + out.layer, + analytics.layer, + BunServices.layer, + processControlLayer, + ); + return Effect.gen(function* () { + yield* Command.runWith(legacyTestRoot(), { version: "0.0.0-test" })(["fish"]); + const event = analytics.captured.find((entry) => entry.event === EventCommandExecuted); + expect(event).toBeDefined(); + }).pipe(Effect.provide(layer)) as Effect.Effect; + }, + ); }); diff --git a/apps/cli/src/legacy/commands/completion/legacy-completion-scripts.ts b/apps/cli/src/legacy/commands/completion/legacy-completion-scripts.ts new file mode 100644 index 0000000000..9d0bdb30e9 --- /dev/null +++ b/apps/cli/src/legacy/commands/completion/legacy-completion-scripts.ts @@ -0,0 +1,1258 @@ +/** + * Native, byte-for-byte reproductions of cobra v1.10.2's static shell + * completion script templates. + * + * cobra's `bash`/`zsh`/`fish`/`powershell` completion scripts are 100% + * generic string templates — they do NOT bake in the command tree. Every + * tab press, the generated script shells back out to the running + * `supabase` binary's hidden `__complete`/`__completeNoDesc` command (see + * `legacy/cli/legacy-complete.ts`, CLI-1965) to get live candidates. The + * only variables in the whole template are the program name (always the + * literal `"supabase"` — cobra derives it from `Use: "supabase"` in + * `apps/cli-go/cmd/root.go`, a compile-time constant, not `os.Argv[0]`), + * which hidden command the script calls back into (`__complete` by + * default, `__completeNoDesc` when generated with `--no-descriptions`), + * the six `ShellCompDirective` bit values, and the two activeHelp + * constants. + * + * Transcribed directly from the cobra v1.10.2 source (verified byte-exact + * via a scripted round-trip against Go's own `fmt.Sprintf` semantics): + * - bash_completionsV2.go (genBashComp) + * - zsh_completions.go (genZshComp) + * - fish_completions.go (genFishComp) + * - powershell_completions.go (genPowerShellComp) + * - completions.go (ShellCompDirective / ShellCompRequestCmd constants) + */ + +const PROGRAM_NAME = "supabase"; + +const SHELL_COMP_REQUEST_CMD = "__complete"; +const SHELL_COMP_NO_DESC_REQUEST_CMD = "__completeNoDesc"; +type CompletionRequestCmd = typeof SHELL_COMP_REQUEST_CMD | typeof SHELL_COMP_NO_DESC_REQUEST_CMD; + +/** `ShellCompDirective` bit values (`spf13/cobra@v1.10.2/completions.go:56-96`). */ +const SHELL_COMP_DIRECTIVE_ERROR = 1; +const SHELL_COMP_DIRECTIVE_NO_SPACE = 2; +const SHELL_COMP_DIRECTIVE_NO_FILE_COMP = 4; +const SHELL_COMP_DIRECTIVE_FILTER_FILE_EXT = 8; +const SHELL_COMP_DIRECTIVE_FILTER_DIRS = 16; +const SHELL_COMP_DIRECTIVE_KEEP_ORDER = 32; + +/** `activeHelpMarker` (`spf13/cobra@v1.10.2/active_help.go:23`). */ +const ACTIVE_HELP_MARKER = "_activeHelp_ "; +/** `activeHelpEnvVar("supabase")` (`spf13/cobra@v1.10.2/active_help.go:58`). */ +const ACTIVE_HELP_ENV_VAR = "SUPABASE_ACTIVE_HELP"; + +/** + * Transcribed from `genBashComp` (`spf13/cobra@v1.10.2/bash_completionsV2.go:31-467`). + * Backing both `GenBashCompletionV2(w, true)` and `GenBashCompletionV2(w, false)` — + * cobra funnels both through the same template; only the `compCmd` token differs. + */ +function genBashCompletionScript(programName: string, compCmd: CompletionRequestCmd): string { + return `# bash completion V2 for ${programName.padEnd(36)} -*- shell-script -*- + +__${programName}_debug() +{ + if [[ -n \${BASH_COMP_DEBUG_FILE-} ]]; then + echo "$*" >> "\${BASH_COMP_DEBUG_FILE}" + fi +} + +# Macs have bash3 for which the bash-completion package doesn't include +# _init_completion. This is a minimal version of that function. +__${programName}_init_completion() +{ + COMPREPLY=() + _get_comp_words_by_ref "$@" cur prev words cword +} + +# This function calls the ${programName} program to obtain the completion +# results and the directive. It fills the 'out' and 'directive' vars. +__${programName}_get_completion_results() { + local requestComp lastParam lastChar args + + # Prepare the command to request completions for the program. + # Calling \${words[0]} instead of directly ${programName} allows handling aliases + args=("\${words[@]:1}") + requestComp="\${words[0]} ${compCmd} \${args[*]}" + + lastParam=\${words[$((\${#words[@]}-1))]} + lastChar=\${lastParam:$((\${#lastParam}-1)):1} + __${programName}_debug "lastParam \${lastParam}, lastChar \${lastChar}" + + if [[ -z \${cur} && \${lastChar} != = ]]; then + # If the last parameter is complete (there is a space following it) + # We add an extra empty parameter so we can indicate this to the go method. + __${programName}_debug "Adding extra empty parameter" + requestComp="\${requestComp} ''" + fi + + # When completing a flag with an = (e.g., ${programName} -n=) + # bash focuses on the part after the =, so we need to remove + # the flag part from $cur + if [[ \${cur} == -*=* ]]; then + cur="\${cur#*=}" + fi + + __${programName}_debug "Calling \${requestComp}" + # Use eval to handle any environment variables and such + out=$(eval "\${requestComp}" 2>/dev/null) + + # Extract the directive integer at the very end of the output following a colon (:) + directive=\${out##*:} + # Remove the directive + out=\${out%:*} + if [[ \${directive} == "\${out}" ]]; then + # There is not directive specified + directive=0 + fi + __${programName}_debug "The completion directive is: \${directive}" + __${programName}_debug "The completions are: \${out}" +} + +__${programName}_process_completion_results() { + local shellCompDirectiveError=${SHELL_COMP_DIRECTIVE_ERROR} + local shellCompDirectiveNoSpace=${SHELL_COMP_DIRECTIVE_NO_SPACE} + local shellCompDirectiveNoFileComp=${SHELL_COMP_DIRECTIVE_NO_FILE_COMP} + local shellCompDirectiveFilterFileExt=${SHELL_COMP_DIRECTIVE_FILTER_FILE_EXT} + local shellCompDirectiveFilterDirs=${SHELL_COMP_DIRECTIVE_FILTER_DIRS} + local shellCompDirectiveKeepOrder=${SHELL_COMP_DIRECTIVE_KEEP_ORDER} + + if (((directive & shellCompDirectiveError) != 0)); then + # Error code. No completion. + __${programName}_debug "Received error from custom completion go code" + return + else + if (((directive & shellCompDirectiveNoSpace) != 0)); then + if [[ $(type -t compopt) == builtin ]]; then + __${programName}_debug "Activating no space" + compopt -o nospace + else + __${programName}_debug "No space directive not supported in this version of bash" + fi + fi + if (((directive & shellCompDirectiveKeepOrder) != 0)); then + if [[ $(type -t compopt) == builtin ]]; then + # no sort isn't supported for bash less than < 4.4 + if [[ \${BASH_VERSINFO[0]} -lt 4 || ( \${BASH_VERSINFO[0]} -eq 4 && \${BASH_VERSINFO[1]} -lt 4 ) ]]; then + __${programName}_debug "No sort directive not supported in this version of bash" + else + __${programName}_debug "Activating keep order" + compopt -o nosort + fi + else + __${programName}_debug "No sort directive not supported in this version of bash" + fi + fi + if (((directive & shellCompDirectiveNoFileComp) != 0)); then + if [[ $(type -t compopt) == builtin ]]; then + __${programName}_debug "Activating no file completion" + compopt +o default + else + __${programName}_debug "No file completion directive not supported in this version of bash" + fi + fi + fi + + # Separate activeHelp from normal completions + local completions=() + local activeHelp=() + __${programName}_extract_activeHelp + + if (((directive & shellCompDirectiveFilterFileExt) != 0)); then + # File extension filtering + local fullFilter="" filter filteringCmd + + # Do not use quotes around the $completions variable or else newline + # characters will be kept. + for filter in \${completions[*]}; do + fullFilter+="$filter|" + done + + filteringCmd="_filedir $fullFilter" + __${programName}_debug "File filtering command: $filteringCmd" + $filteringCmd + elif (((directive & shellCompDirectiveFilterDirs) != 0)); then + # File completion for directories only + + local subdir + subdir=\${completions[0]} + if [[ -n $subdir ]]; then + __${programName}_debug "Listing directories in $subdir" + pushd "$subdir" >/dev/null 2>&1 && _filedir -d && popd >/dev/null 2>&1 || return + else + __${programName}_debug "Listing directories in ." + _filedir -d + fi + else + __${programName}_handle_completion_types + fi + + __${programName}_handle_special_char "$cur" : + __${programName}_handle_special_char "$cur" = + + # Print the activeHelp statements before we finish + __${programName}_handle_activeHelp +} + +__${programName}_handle_activeHelp() { + # Print the activeHelp statements + if ((\${#activeHelp[*]} != 0)); then + if [ -z $COMP_TYPE ]; then + # Bash v3 does not set the COMP_TYPE variable. + printf "\\n"; + printf "%s\\n" "\${activeHelp[@]}" + printf "\\n" + __${programName}_reprint_commandLine + return + fi + + # Only print ActiveHelp on the second TAB press + if [ $COMP_TYPE -eq 63 ]; then + printf "\\n" + printf "%s\\n" "\${activeHelp[@]}" + + if ((\${#COMPREPLY[*]} == 0)); then + # When there are no completion choices from the program, file completion + # may kick in if the program has not disabled it; in such a case, we want + # to know if any files will match what the user typed, so that we know if + # there will be completions presented, so that we know how to handle ActiveHelp. + # To find out, we actually trigger the file completion ourselves; + # the call to _filedir will fill COMPREPLY if files match. + if (((directive & shellCompDirectiveNoFileComp) == 0)); then + __${programName}_debug "Listing files" + _filedir + fi + fi + + if ((\${#COMPREPLY[*]} != 0)); then + # If there are completion choices to be shown, print a delimiter. + # Re-printing the command-line will automatically be done + # by the shell when it prints the completion choices. + printf -- "--" + else + # When there are no completion choices at all, we need + # to re-print the command-line since the shell will + # not be doing it itself. + __${programName}_reprint_commandLine + fi + elif [ $COMP_TYPE -eq 37 ] || [ $COMP_TYPE -eq 42 ]; then + # For completion type: menu-complete/menu-complete-backward and insert-completions + # the completions are immediately inserted into the command-line, so we first + # print the activeHelp message and reprint the command-line since the shell won't. + printf "\\n" + printf "%s\\n" "\${activeHelp[@]}" + + __${programName}_reprint_commandLine + fi + fi +} + +__${programName}_reprint_commandLine() { + # The prompt format is only available from bash 4.4. + # We test if it is available before using it. + if (x=\${PS1@P}) 2> /dev/null; then + printf "%s" "\${PS1@P}\${COMP_LINE[@]}" + else + # Can't print the prompt. Just print the + # text the user had typed, it is workable enough. + printf "%s" "\${COMP_LINE[@]}" + fi +} + +# Separate activeHelp lines from real completions. +# Fills the $activeHelp and $completions arrays. +__${programName}_extract_activeHelp() { + local activeHelpMarker="${ACTIVE_HELP_MARKER}" + local endIndex=\${#activeHelpMarker} + + while IFS='' read -r comp; do + [[ -z $comp ]] && continue + + if [[ \${comp:0:endIndex} == $activeHelpMarker ]]; then + comp=\${comp:endIndex} + __${programName}_debug "ActiveHelp found: $comp" + if [[ -n $comp ]]; then + activeHelp+=("$comp") + fi + else + # Not an activeHelp line but a normal completion + completions+=("$comp") + fi + done <<<"\${out}" +} + +__${programName}_handle_completion_types() { + __${programName}_debug "__${programName}_handle_completion_types: COMP_TYPE is $COMP_TYPE" + + case $COMP_TYPE in + 37|42) + # Type: menu-complete/menu-complete-backward and insert-completions + # If the user requested inserting one completion at a time, or all + # completions at once on the command-line we must remove the descriptions. + # https://github.com/spf13/cobra/issues/1508 + + # If there are no completions, we don't need to do anything + (( \${#completions[@]} == 0 )) && return 0 + + local tab=$'\\t' + + # Strip any description and escape the completion to handled special characters + IFS=$'\\n' read -ra completions -d '' < <(printf "%q\\n" "\${completions[@]%%$tab*}") + + # Only consider the completions that match + IFS=$'\\n' read -ra COMPREPLY -d '' < <(IFS=$'\\n'; compgen -W "\${completions[*]}" -- "\${cur}") + + # compgen looses the escaping so we need to escape all completions again since they will + # all be inserted on the command-line. + IFS=$'\\n' read -ra COMPREPLY -d '' < <(printf "%q\\n" "\${COMPREPLY[@]}") + ;; + + *) + # Type: complete (normal completion) + __${programName}_handle_standard_completion_case + ;; + esac +} + +__${programName}_handle_standard_completion_case() { + local tab=$'\\t' + + # If there are no completions, we don't need to do anything + (( \${#completions[@]} == 0 )) && return 0 + + # Short circuit to optimize if we don't have descriptions + if [[ "\${completions[*]}" != *$tab* ]]; then + # First, escape the completions to handle special characters + IFS=$'\\n' read -ra completions -d '' < <(printf "%q\\n" "\${completions[@]}") + # Only consider the completions that match what the user typed + IFS=$'\\n' read -ra COMPREPLY -d '' < <(IFS=$'\\n'; compgen -W "\${completions[*]}" -- "\${cur}") + + # compgen looses the escaping so, if there is only a single completion, we need to + # escape it again because it will be inserted on the command-line. If there are multiple + # completions, we don't want to escape them because they will be printed in a list + # and we don't want to show escape characters in that list. + if (( \${#COMPREPLY[@]} == 1 )); then + COMPREPLY[0]=$(printf "%q" "\${COMPREPLY[0]}") + fi + return 0 + fi + + local longest=0 + local compline + # Look for the longest completion so that we can format things nicely + while IFS='' read -r compline; do + [[ -z $compline ]] && continue + + # Before checking if the completion matches what the user typed, + # we need to strip any description and escape the completion to handle special + # characters because those escape characters are part of what the user typed. + # Don't call "printf" in a sub-shell because it will be much slower + # since we are in a loop. + printf -v comp "%q" "\${compline%%$tab*}" &>/dev/null || comp=$(printf "%q" "\${compline%%$tab*}") + + # Only consider the completions that match + [[ $comp == "$cur"* ]] || continue + + # The completions matches. Add it to the list of full completions including + # its description. We don't escape the completion because it may get printed + # in a list if there are more than one and we don't want show escape characters + # in that list. + COMPREPLY+=("$compline") + + # Strip any description before checking the length, and again, don't escape + # the completion because this length is only used when printing the completions + # in a list and we don't want show escape characters in that list. + comp=\${compline%%$tab*} + if ((\${#comp}>longest)); then + longest=\${#comp} + fi + done < <(printf "%s\\n" "\${completions[@]}") + + # If there is a single completion left, remove the description text and escape any special characters + if ((\${#COMPREPLY[*]} == 1)); then + __${programName}_debug "COMPREPLY[0]: \${COMPREPLY[0]}" + COMPREPLY[0]=$(printf "%q" "\${COMPREPLY[0]%%$tab*}") + __${programName}_debug "Removed description from single completion, which is now: \${COMPREPLY[0]}" + else + # Format the descriptions + __${programName}_format_comp_descriptions $longest + fi +} + +__${programName}_handle_special_char() +{ + local comp="$1" + local char=$2 + if [[ "$comp" == *\${char}* && "$COMP_WORDBREAKS" == *\${char}* ]]; then + local word=\${comp%"\${comp##*\${char}}"} + local idx=\${#COMPREPLY[*]} + while ((--idx >= 0)); do + COMPREPLY[idx]=\${COMPREPLY[idx]#"$word"} + done + fi +} + +__${programName}_format_comp_descriptions() +{ + local tab=$'\\t' + local comp desc maxdesclength + local longest=$1 + + local i ci + for ci in \${!COMPREPLY[*]}; do + comp=\${COMPREPLY[ci]} + # Properly format the description string which follows a tab character if there is one + if [[ "$comp" == *$tab* ]]; then + __${programName}_debug "Original comp: $comp" + desc=\${comp#*$tab} + comp=\${comp%%$tab*} + + # $COLUMNS stores the current shell width. + # Remove an extra 4 because we add 2 spaces and 2 parentheses. + maxdesclength=$(( COLUMNS - longest - 4 )) + + # Make sure we can fit a description of at least 8 characters + # if we are to align the descriptions. + if ((maxdesclength > 8)); then + # Add the proper number of spaces to align the descriptions + for ((i = \${#comp} ; i < longest ; i++)); do + comp+=" " + done + else + # Don't pad the descriptions so we can fit more text after the completion + maxdesclength=$(( COLUMNS - \${#comp} - 4 )) + fi + + # If there is enough space for any description text, + # truncate the descriptions that are too long for the shell width + if ((maxdesclength > 0)); then + if ((\${#desc} > maxdesclength)); then + desc=\${desc:0:$(( maxdesclength - 1 ))} + desc+="…" + fi + comp+=" ($desc)" + fi + COMPREPLY[ci]=$comp + __${programName}_debug "Final comp: $comp" + fi + done +} + +__start_${programName}() +{ + local cur prev words cword split + + COMPREPLY=() + + # Call _init_completion from the bash-completion package + # to prepare the arguments properly + if declare -F _init_completion >/dev/null 2>&1; then + _init_completion -n =: || return + else + __${programName}_init_completion -n =: || return + fi + + __${programName}_debug + __${programName}_debug "========= starting completion logic ==========" + __${programName}_debug "cur is \${cur}, words[*] is \${words[*]}, #words[@] is \${#words[@]}, cword is $cword" + + # The user could have moved the cursor backwards on the command-line. + # We need to trigger completion from the $cword location, so we need + # to truncate the command-line ($words) up to the $cword location. + words=("\${words[@]:0:$cword+1}") + __${programName}_debug "Truncated words[*]: \${words[*]}," + + local out directive + __${programName}_get_completion_results + __${programName}_process_completion_results +} + +if [[ $(type -t compopt) = "builtin" ]]; then + complete -o default -F __start_${programName} ${programName} +else + complete -o default -o nospace -F __start_${programName} ${programName} +fi + +# ex: ts=4 sw=4 et filetype=sh +`; +} + +/** + * Transcribed from `genZshComp` (`spf13/cobra@v1.10.2/zsh_completions.go:87-308`). + * `GenZshCompletion` and `GenZshCompletionNoDesc` both call this exact function — + * verified there is no other divergence between the desc/no-desc variants beyond + * the `compCmd` token. + */ +function genZshCompletionScript(programName: string, compCmd: CompletionRequestCmd): string { + return `#compdef ${programName} +compdef _${programName} ${programName} + +# zsh completion for ${programName.padEnd(36)} -*- shell-script -*- + +__${programName}_debug() +{ + local file="$BASH_COMP_DEBUG_FILE" + if [[ -n \${file} ]]; then + echo "$*" >> "\${file}" + fi +} + +_${programName}() +{ + local shellCompDirectiveError=${SHELL_COMP_DIRECTIVE_ERROR} + local shellCompDirectiveNoSpace=${SHELL_COMP_DIRECTIVE_NO_SPACE} + local shellCompDirectiveNoFileComp=${SHELL_COMP_DIRECTIVE_NO_FILE_COMP} + local shellCompDirectiveFilterFileExt=${SHELL_COMP_DIRECTIVE_FILTER_FILE_EXT} + local shellCompDirectiveFilterDirs=${SHELL_COMP_DIRECTIVE_FILTER_DIRS} + local shellCompDirectiveKeepOrder=${SHELL_COMP_DIRECTIVE_KEEP_ORDER} + + local lastParam lastChar flagPrefix requestComp out directive comp lastComp noSpace keepOrder + local -a completions + + __${programName}_debug "\\n========= starting completion logic ==========" + __${programName}_debug "CURRENT: \${CURRENT}, words[*]: \${words[*]}" + + # The user could have moved the cursor backwards on the command-line. + # We need to trigger completion from the $CURRENT location, so we need + # to truncate the command-line ($words) up to the $CURRENT location. + # (We cannot use $CURSOR as its value does not work when a command is an alias.) + words=("\${=words[1,CURRENT]}") + __${programName}_debug "Truncated words[*]: \${words[*]}," + + lastParam=\${words[-1]} + lastChar=\${lastParam[-1]} + __${programName}_debug "lastParam: \${lastParam}, lastChar: \${lastChar}" + + # For zsh, when completing a flag with an = (e.g., ${programName} -n=) + # completions must be prefixed with the flag + setopt local_options BASH_REMATCH + if [[ "\${lastParam}" =~ '-.*=' ]]; then + # We are dealing with a flag with an = + flagPrefix="-P \${BASH_REMATCH}" + fi + + # Prepare the command to obtain completions + requestComp="\${words[1]} ${compCmd} \${words[2,-1]}" + if [ "\${lastChar}" = "" ]; then + # If the last parameter is complete (there is a space following it) + # We add an extra empty parameter so we can indicate this to the go completion code. + __${programName}_debug "Adding extra empty parameter" + requestComp="\${requestComp} \\"\\"" + fi + + __${programName}_debug "About to call: eval \${requestComp}" + + # Use eval to handle any environment variables and such + out=$(eval \${requestComp} 2>/dev/null) + __${programName}_debug "completion output: \${out}" + + # Extract the directive integer following a : from the last line + local lastLine + while IFS='\\n' read -r line; do + lastLine=\${line} + done < <(printf "%s\\n" "\${out[@]}") + __${programName}_debug "last line: \${lastLine}" + + if [ "\${lastLine[1]}" = : ]; then + directive=\${lastLine[2,-1]} + # Remove the directive including the : and the newline + local suffix + (( suffix=\${#lastLine}+2)) + out=\${out[1,-$suffix]} + else + # There is no directive specified. Leave $out as is. + __${programName}_debug "No directive found. Setting do default" + directive=0 + fi + + __${programName}_debug "directive: \${directive}" + __${programName}_debug "completions: \${out}" + __${programName}_debug "flagPrefix: \${flagPrefix}" + + if [ $((directive & shellCompDirectiveError)) -ne 0 ]; then + __${programName}_debug "Completion received error. Ignoring completions." + return + fi + + local activeHelpMarker="${ACTIVE_HELP_MARKER}" + local endIndex=\${#activeHelpMarker} + local startIndex=$((\${#activeHelpMarker}+1)) + local hasActiveHelp=0 + while IFS='\\n' read -r comp; do + # Check if this is an activeHelp statement (i.e., prefixed with $activeHelpMarker) + if [ "\${comp[1,$endIndex]}" = "$activeHelpMarker" ];then + __${programName}_debug "ActiveHelp found: $comp" + comp="\${comp[$startIndex,-1]}" + if [ -n "$comp" ]; then + compadd -x "\${comp}" + __${programName}_debug "ActiveHelp will need delimiter" + hasActiveHelp=1 + fi + + continue + fi + + if [ -n "$comp" ]; then + # If requested, completions are returned with a description. + # The description is preceded by a TAB character. + # For zsh's _describe, we need to use a : instead of a TAB. + # We first need to escape any : as part of the completion itself. + comp=\${comp//:/\\\\:} + + local tab="$(printf '\\t')" + comp=\${comp//$tab/:} + + __${programName}_debug "Adding completion: \${comp}" + completions+=\${comp} + lastComp=$comp + fi + done < <(printf "%s\\n" "\${out[@]}") + + # Add a delimiter after the activeHelp statements, but only if: + # - there are completions following the activeHelp statements, or + # - file completion will be performed (so there will be choices after the activeHelp) + if [ $hasActiveHelp -eq 1 ]; then + if [ \${#completions} -ne 0 ] || [ $((directive & shellCompDirectiveNoFileComp)) -eq 0 ]; then + __${programName}_debug "Adding activeHelp delimiter" + compadd -x "--" + hasActiveHelp=0 + fi + fi + + if [ $((directive & shellCompDirectiveNoSpace)) -ne 0 ]; then + __${programName}_debug "Activating nospace." + noSpace="-S ''" + fi + + if [ $((directive & shellCompDirectiveKeepOrder)) -ne 0 ]; then + __${programName}_debug "Activating keep order." + keepOrder="-V" + fi + + if [ $((directive & shellCompDirectiveFilterFileExt)) -ne 0 ]; then + # File extension filtering + local filteringCmd + filteringCmd='_files' + for filter in \${completions[@]}; do + if [ \${filter[1]} != '*' ]; then + # zsh requires a glob pattern to do file filtering + filter="\\*.$filter" + fi + filteringCmd+=" -g $filter" + done + filteringCmd+=" \${flagPrefix}" + + __${programName}_debug "File filtering command: $filteringCmd" + _arguments '*:filename:'"$filteringCmd" + elif [ $((directive & shellCompDirectiveFilterDirs)) -ne 0 ]; then + # File completion for directories only + local subdir + subdir="\${completions[1]}" + if [ -n "$subdir" ]; then + __${programName}_debug "Listing directories in $subdir" + pushd "\${subdir}" >/dev/null 2>&1 + else + __${programName}_debug "Listing directories in ." + fi + + local result + _arguments '*:dirname:_files -/'" \${flagPrefix}" + result=$? + if [ -n "$subdir" ]; then + popd >/dev/null 2>&1 + fi + return $result + else + __${programName}_debug "Calling _describe" + if eval _describe $keepOrder "completions" completions $flagPrefix $noSpace; then + __${programName}_debug "_describe found some completions" + + # Return the success of having called _describe + return 0 + else + __${programName}_debug "_describe did not find completions." + __${programName}_debug "Checking if we should do file completion." + if [ $((directive & shellCompDirectiveNoFileComp)) -ne 0 ]; then + __${programName}_debug "deactivating file completion" + + # We must return an error code here to let zsh know that there were no + # completions found by _describe; this is what will trigger other + # matching algorithms to attempt to find completions. + # For example zsh can match letters in the middle of words. + return 1 + else + # Perform file completion + __${programName}_debug "Activating file completion" + + # We must return the result of this command, so it must be the + # last command, or else we must store its result to return it. + _arguments '*:filename:_files'" \${flagPrefix}" + fi + fi + fi +} + +# don't run the completion function when being source-ed or eval-ed +if [ "$funcstack[1]" = "_${programName}" ]; then + _${programName} +fi +`; +} + +/** + * Transcribed from `genFishComp` (`spf13/cobra@v1.10.2/fish_completions.go:25-273`). + * cobra emits the header comment via a separate `fmt.Sprintf` call before the main + * template; reproduced here as a plain string concatenation of the two pieces. + */ +function genFishCompletionScript(programName: string, compCmd: CompletionRequestCmd): string { + return ( + `# fish completion for ${programName.padEnd(36)} -*- shell-script -*-\n` + + ` +function __${programName}_debug + set -l file "$BASH_COMP_DEBUG_FILE" + if test -n "$file" + echo "$argv" >> $file + end +end + +function __${programName}_perform_completion + __${programName}_debug "Starting __${programName}_perform_completion" + + # Extract all args except the last one + set -l args (commandline -opc) + # Extract the last arg and escape it in case it is a space + set -l lastArg (string escape -- (commandline -ct)) + + __${programName}_debug "args: $args" + __${programName}_debug "last arg: $lastArg" + + # Disable ActiveHelp which is not supported for fish shell + set -l requestComp "${ACTIVE_HELP_ENV_VAR}=0 $args[1] ${compCmd} $args[2..-1] $lastArg" + + __${programName}_debug "Calling $requestComp" + set -l results (eval $requestComp 2> /dev/null) + + # Some programs may output extra empty lines after the directive. + # Let's ignore them or else it will break completion. + # Ref: https://github.com/spf13/cobra/issues/1279 + for line in $results[-1..1] + if test (string trim -- $line) = "" + # Found an empty line, remove it + set results $results[1..-2] + else + # Found non-empty line, we have our proper output + break + end + end + + set -l comps $results[1..-2] + set -l directiveLine $results[-1] + + # For Fish, when completing a flag with an = (e.g., -n=) + # completions must be prefixed with the flag + set -l flagPrefix (string match -r -- '-.*=' "$lastArg") + + __${programName}_debug "Comps: $comps" + __${programName}_debug "DirectiveLine: $directiveLine" + __${programName}_debug "flagPrefix: $flagPrefix" + + for comp in $comps + printf "%s%s\\n" "$flagPrefix" "$comp" + end + + printf "%s\\n" "$directiveLine" +end + +# this function limits calls to __${programName}_perform_completion, by caching the result behind $__${programName}_perform_completion_once_result +function __${programName}_perform_completion_once + __${programName}_debug "Starting __${programName}_perform_completion_once" + + if test -n "$__${programName}_perform_completion_once_result" + __${programName}_debug "Seems like a valid result already exists, skipping __${programName}_perform_completion" + return 0 + end + + set --global __${programName}_perform_completion_once_result (__${programName}_perform_completion) + if test -z "$__${programName}_perform_completion_once_result" + __${programName}_debug "No completions, probably due to a failure" + return 1 + end + + __${programName}_debug "Performed completions and set __${programName}_perform_completion_once_result" + return 0 +end + +# this function is used to clear the $__${programName}_perform_completion_once_result variable after completions are run +function __${programName}_clear_perform_completion_once_result + __${programName}_debug "" + __${programName}_debug "========= clearing previously set __${programName}_perform_completion_once_result variable ==========" + set --erase __${programName}_perform_completion_once_result + __${programName}_debug "Successfully erased the variable __${programName}_perform_completion_once_result" +end + +function __${programName}_requires_order_preservation + __${programName}_debug "" + __${programName}_debug "========= checking if order preservation is required ==========" + + __${programName}_perform_completion_once + if test -z "$__${programName}_perform_completion_once_result" + __${programName}_debug "Error determining if order preservation is required" + return 1 + end + + set -l directive (string sub --start 2 $__${programName}_perform_completion_once_result[-1]) + __${programName}_debug "Directive is: $directive" + + set -l shellCompDirectiveKeepOrder ${SHELL_COMP_DIRECTIVE_KEEP_ORDER} + set -l keeporder (math (math --scale 0 $directive / $shellCompDirectiveKeepOrder) % 2) + __${programName}_debug "Keeporder is: $keeporder" + + if test $keeporder -ne 0 + __${programName}_debug "This does require order preservation" + return 0 + end + + __${programName}_debug "This doesn't require order preservation" + return 1 +end + + +# This function does two things: +# - Obtain the completions and store them in the global __${programName}_comp_results +# - Return false if file completion should be performed +function __${programName}_prepare_completions + __${programName}_debug "" + __${programName}_debug "========= starting completion logic ==========" + + # Start fresh + set --erase __${programName}_comp_results + + __${programName}_perform_completion_once + __${programName}_debug "Completion results: $__${programName}_perform_completion_once_result" + + if test -z "$__${programName}_perform_completion_once_result" + __${programName}_debug "No completion, probably due to a failure" + # Might as well do file completion, in case it helps + return 1 + end + + set -l directive (string sub --start 2 $__${programName}_perform_completion_once_result[-1]) + set --global __${programName}_comp_results $__${programName}_perform_completion_once_result[1..-2] + + __${programName}_debug "Completions are: $__${programName}_comp_results" + __${programName}_debug "Directive is: $directive" + + set -l shellCompDirectiveError ${SHELL_COMP_DIRECTIVE_ERROR} + set -l shellCompDirectiveNoSpace ${SHELL_COMP_DIRECTIVE_NO_SPACE} + set -l shellCompDirectiveNoFileComp ${SHELL_COMP_DIRECTIVE_NO_FILE_COMP} + set -l shellCompDirectiveFilterFileExt ${SHELL_COMP_DIRECTIVE_FILTER_FILE_EXT} + set -l shellCompDirectiveFilterDirs ${SHELL_COMP_DIRECTIVE_FILTER_DIRS} + + if test -z "$directive" + set directive 0 + end + + set -l compErr (math (math --scale 0 $directive / $shellCompDirectiveError) % 2) + if test $compErr -eq 1 + __${programName}_debug "Received error directive: aborting." + # Might as well do file completion, in case it helps + return 1 + end + + set -l filefilter (math (math --scale 0 $directive / $shellCompDirectiveFilterFileExt) % 2) + set -l dirfilter (math (math --scale 0 $directive / $shellCompDirectiveFilterDirs) % 2) + if test $filefilter -eq 1; or test $dirfilter -eq 1 + __${programName}_debug "File extension filtering or directory filtering not supported" + # Do full file completion instead + return 1 + end + + set -l nospace (math (math --scale 0 $directive / $shellCompDirectiveNoSpace) % 2) + set -l nofiles (math (math --scale 0 $directive / $shellCompDirectiveNoFileComp) % 2) + + __${programName}_debug "nospace: $nospace, nofiles: $nofiles" + + # If we want to prevent a space, or if file completion is NOT disabled, + # we need to count the number of valid completions. + # To do so, we will filter on prefix as the completions we have received + # may not already be filtered so as to allow fish to match on different + # criteria than the prefix. + if test $nospace -ne 0; or test $nofiles -eq 0 + set -l prefix (commandline -t | string escape --style=regex) + __${programName}_debug "prefix: $prefix" + + set -l completions (string match -r -- "^$prefix.*" $__${programName}_comp_results) + set --global __${programName}_comp_results $completions + __${programName}_debug "Filtered completions are: $__${programName}_comp_results" + + # Important not to quote the variable for count to work + set -l numComps (count $__${programName}_comp_results) + __${programName}_debug "numComps: $numComps" + + if test $numComps -eq 1; and test $nospace -ne 0 + # We must first split on \\t to get rid of the descriptions to be + # able to check what the actual completion will be. + # We don't need descriptions anyway since there is only a single + # real completion which the shell will expand immediately. + set -l split (string split --max 1 \\t $__${programName}_comp_results[1]) + + # Fish won't add a space if the completion ends with any + # of the following characters: @=/:., + set -l lastChar (string sub -s -1 -- $split) + if not string match -r -q "[@=/:.,]" -- "$lastChar" + # In other cases, to support the "nospace" directive we trick the shell + # by outputting an extra, longer completion. + __${programName}_debug "Adding second completion to perform nospace directive" + set --global __${programName}_comp_results $split[1] $split[1]. + __${programName}_debug "Completions are now: $__${programName}_comp_results" + end + end + + if test $numComps -eq 0; and test $nofiles -eq 0 + # To be consistent with bash and zsh, we only trigger file + # completion when there are no other completions + __${programName}_debug "Requesting file completion" + return 1 + end + end + + return 0 +end + +# Since Fish completions are only loaded once the user triggers them, we trigger them ourselves +# so we can properly delete any completions provided by another script. +# Only do this if the program can be found, or else fish may print some errors; besides, +# the existing completions will only be loaded if the program can be found. +if type -q "${programName}" + # The space after the program name is essential to trigger completion for the program + # and not completion of the program name itself. + # Also, we use '> /dev/null 2>&1' since '&>' is not supported in older versions of fish. + complete --do-complete "${programName} " > /dev/null 2>&1 +end + +# Remove any pre-existing completions for the program since we will be handling all of them. +complete -c ${programName} -e + +# this will get called after the two calls below and clear the $__${programName}_perform_completion_once_result global +complete -c ${programName} -n '__${programName}_clear_perform_completion_once_result' +# The call to __${programName}_prepare_completions will setup __${programName}_comp_results +# which provides the program's completion choices. +# If this doesn't require order preservation, we don't use the -k flag +complete -c ${programName} -n 'not __${programName}_requires_order_preservation && __${programName}_prepare_completions' -f -a '$__${programName}_comp_results' +# otherwise we use the -k flag +complete -k -c ${programName} -n '__${programName}_requires_order_preservation && __${programName}_prepare_completions' -f -a '$__${programName}_comp_results' +` + ); +} + +/** + * Transcribed from `genPowerShellComp` (`spf13/cobra@v1.10.2/powershell_completions.go:28-311`). + * `GenPowerShellCompletion` (no desc) and `GenPowerShellCompletionWithDesc` both call + * this exact function — verified there is no other divergence between the desc/no-desc + * variants beyond the `compCmd` token. The Go source builds this template by + * concatenating raw-string segments with a handful of interpreted (`"..."`) + * segments so it can embed literal PowerShell backticks (Go raw strings cannot + * contain a backtick); reproduced here as one TS template literal with those + * backticks escaped directly, which TS supports natively. + */ +function genPowerShellCompletionScript(programName: string, compCmd: CompletionRequestCmd): string { + return `# powershell completion for ${programName.padEnd(36)} -*- shell-script -*- + +function __${programName}_debug { + if ($env:BASH_COMP_DEBUG_FILE) { + "$args" | Out-File -Append -FilePath "$env:BASH_COMP_DEBUG_FILE" + } +} + +filter __${programName}_escapeStringWithSpecialChars { + $_ -replace '\\s|#|@|\\$|;|,|''|\\{|\\}|\\(|\\)|"|\`|\\||<|>|&','\`$&' +} + +[scriptblock]\${__${programName}CompleterBlock} = { + param( + $WordToComplete, + $CommandAst, + $CursorPosition + ) + + # Get the current command line and convert into a string + $Command = $CommandAst.CommandElements + $Command = "$Command" + + __${programName}_debug "" + __${programName}_debug "========= starting completion logic ==========" + __${programName}_debug "WordToComplete: $WordToComplete Command: $Command CursorPosition: $CursorPosition" + + # The user could have moved the cursor backwards on the command-line. + # We need to trigger completion from the $CursorPosition location, so we need + # to truncate the command-line ($Command) up to the $CursorPosition location. + # Make sure the $Command is longer then the $CursorPosition before we truncate. + # This happens because the $Command does not include the last space. + if ($Command.Length -gt $CursorPosition) { + $Command=$Command.Substring(0,$CursorPosition) + } + __${programName}_debug "Truncated command: $Command" + + $ShellCompDirectiveError=${SHELL_COMP_DIRECTIVE_ERROR} + $ShellCompDirectiveNoSpace=${SHELL_COMP_DIRECTIVE_NO_SPACE} + $ShellCompDirectiveNoFileComp=${SHELL_COMP_DIRECTIVE_NO_FILE_COMP} + $ShellCompDirectiveFilterFileExt=${SHELL_COMP_DIRECTIVE_FILTER_FILE_EXT} + $ShellCompDirectiveFilterDirs=${SHELL_COMP_DIRECTIVE_FILTER_DIRS} + $ShellCompDirectiveKeepOrder=${SHELL_COMP_DIRECTIVE_KEEP_ORDER} + + # Prepare the command to request completions for the program. + # Split the command at the first space to separate the program and arguments. + $Program,$Arguments = $Command.Split(" ",2) + + $RequestComp="$Program ${compCmd} $Arguments" + __${programName}_debug "RequestComp: $RequestComp" + + # we cannot use $WordToComplete because it + # has the wrong values if the cursor was moved + # so use the last argument + if ($WordToComplete -ne "" ) { + $WordToComplete = $Arguments.Split(" ")[-1] + } + __${programName}_debug "New WordToComplete: $WordToComplete" + + + # Check for flag with equal sign + $IsEqualFlag = ($WordToComplete -Like "--*=*" ) + if ( $IsEqualFlag ) { + __${programName}_debug "Completing equal sign flag" + # Remove the flag part + $Flag,$WordToComplete = $WordToComplete.Split("=",2) + } + + if ( $WordToComplete -eq "" -And ( -Not $IsEqualFlag )) { + # If the last parameter is complete (there is a space following it) + # We add an extra empty parameter so we can indicate this to the go method. + __${programName}_debug "Adding extra empty parameter" + # PowerShell 7.2+ changed the way how the arguments are passed to executables, + # so for pre-7.2 or when Legacy argument passing is enabled we need to use + # \`"\`" to pass an empty argument, a "" or '' does not work!!! + if ($PSVersionTable.PsVersion -lt [version]'7.2.0' -or + ($PSVersionTable.PsVersion -lt [version]'7.3.0' -and -not [ExperimentalFeature]::IsEnabled("PSNativeCommandArgumentPassing")) -or + (($PSVersionTable.PsVersion -ge [version]'7.3.0' -or [ExperimentalFeature]::IsEnabled("PSNativeCommandArgumentPassing")) -and + $PSNativeCommandArgumentPassing -eq 'Legacy')) { + $RequestComp="$RequestComp" + ' \`"\`"' + } else { + $RequestComp="$RequestComp" + ' ""' + } + } + + __${programName}_debug "Calling $RequestComp" + # First disable ActiveHelp which is not supported for Powershell + \${env:${ACTIVE_HELP_ENV_VAR}}=0 + + #call the command store the output in $out and redirect stderr and stdout to null + # $Out is an array contains each line per element + Invoke-Expression -OutVariable out "$RequestComp" 2>&1 | Out-Null + + # get directive from last line + [int]$Directive = $Out[-1].TrimStart(':') + if ($Directive -eq "") { + # There is no directive specified + $Directive = 0 + } + __${programName}_debug "The completion directive is: $Directive" + + # remove directive (last element) from out + $Out = $Out | Where-Object { $_ -ne $Out[-1] } + __${programName}_debug "The completions are: $Out" + + if (($Directive -band $ShellCompDirectiveError) -ne 0 ) { + # Error code. No completion. + __${programName}_debug "Received error from custom completion go code" + return + } + + $Longest = 0 + [Array]$Values = $Out | ForEach-Object { + #Split the output in name and description + $Name, $Description = $_.Split("\`t",2) + __${programName}_debug "Name: $Name Description: $Description" + + # Look for the longest completion so that we can format things nicely + if ($Longest -lt $Name.Length) { + $Longest = $Name.Length + } + + # Set the description to a one space string if there is none set. + # This is needed because the CompletionResult does not accept an empty string as argument + if (-Not $Description) { + $Description = " " + } + New-Object -TypeName PSCustomObject -Property @{ + Name = "$Name" + Description = "$Description" + } + } + + + $Space = " " + if (($Directive -band $ShellCompDirectiveNoSpace) -ne 0 ) { + # remove the space here + __${programName}_debug "ShellCompDirectiveNoSpace is called" + $Space = "" + } + + if ((($Directive -band $ShellCompDirectiveFilterFileExt) -ne 0 ) -or + (($Directive -band $ShellCompDirectiveFilterDirs) -ne 0 )) { + __${programName}_debug "ShellCompDirectiveFilterFileExt ShellCompDirectiveFilterDirs are not supported" + + # return here to prevent the completion of the extensions + return + } + + $Values = $Values | Where-Object { + # filter the result + $_.Name -like "$WordToComplete*" + + # Join the flag back if we have an equal sign flag + if ( $IsEqualFlag ) { + __${programName}_debug "Join the equal sign flag back to the completion value" + $_.Name = $Flag + "=" + $_.Name + } + } + + # we sort the values in ascending order by name if keep order isn't passed + if (($Directive -band $ShellCompDirectiveKeepOrder) -eq 0 ) { + $Values = $Values | Sort-Object -Property Name + } + + if (($Directive -band $ShellCompDirectiveNoFileComp) -ne 0 ) { + __${programName}_debug "ShellCompDirectiveNoFileComp is called" + + if ($Values.Length -eq 0) { + # Just print an empty string here so the + # shell does not start to complete paths. + # We cannot use CompletionResult here because + # it does not accept an empty string as argument. + "" + return + } + } + + # Get the current mode + $Mode = (Get-PSReadLineKeyHandler | Where-Object {$_.Key -eq "Tab" }).Function + __${programName}_debug "Mode: $Mode" + + $Values | ForEach-Object { + + # store temporary because switch will overwrite $_ + $comp = $_ + + # PowerShell supports three different completion modes + # - TabCompleteNext (default windows style - on each key press the next option is displayed) + # - Complete (works like bash) + # - MenuComplete (works like zsh) + # You set the mode with Set-PSReadLineKeyHandler -Key Tab -Function + + # CompletionResult Arguments: + # 1) CompletionText text to be used as the auto completion result + # 2) ListItemText text to be displayed in the suggestion list + # 3) ResultType type of completion result + # 4) ToolTip text for the tooltip with details about the object + + switch ($Mode) { + + # bash like + "Complete" { + + if ($Values.Length -eq 1) { + __${programName}_debug "Only one completion left" + + # insert space after value + $CompletionText = $($comp.Name | __${programName}_escapeStringWithSpecialChars) + $Space + if ($ExecutionContext.SessionState.LanguageMode -eq "FullLanguage"){ + [System.Management.Automation.CompletionResult]::new($CompletionText, "$($comp.Name)", 'ParameterValue', "$($comp.Description)") + } else { + $CompletionText + } + + } else { + # Add the proper number of spaces to align the descriptions + while($comp.Name.Length -lt $Longest) { + $comp.Name = $comp.Name + " " + } + + # Check for empty description and only add parentheses if needed + if ($($comp.Description) -eq " " ) { + $Description = "" + } else { + $Description = " ($($comp.Description))" + } + + $CompletionText = "$($comp.Name)$Description" + if ($ExecutionContext.SessionState.LanguageMode -eq "FullLanguage"){ + [System.Management.Automation.CompletionResult]::new($CompletionText, "$($comp.Name)$Description", 'ParameterValue', "$($comp.Description)") + } else { + $CompletionText + } + } + } + + # zsh like + "MenuComplete" { + # insert space after value + # MenuComplete will automatically show the ToolTip of + # the highlighted value at the bottom of the suggestions. + + $CompletionText = $($comp.Name | __${programName}_escapeStringWithSpecialChars) + $Space + if ($ExecutionContext.SessionState.LanguageMode -eq "FullLanguage"){ + [System.Management.Automation.CompletionResult]::new($CompletionText, "$($comp.Name)", 'ParameterValue', "$($comp.Description)") + } else { + $CompletionText + } + } + + # TabCompleteNext and in case we get something unknown + Default { + # Like MenuComplete but we don't want to add a space here because + # the user need to press space anyway to get the completion. + # Description will not be shown because that's not possible with TabCompleteNext + + $CompletionText = $($comp.Name | __${programName}_escapeStringWithSpecialChars) + if ($ExecutionContext.SessionState.LanguageMode -eq "FullLanguage"){ + [System.Management.Automation.CompletionResult]::new($CompletionText, "$($comp.Name)", 'ParameterValue', "$($comp.Description)") + } else { + $CompletionText + } + } + } + + } +} + +Register-ArgumentCompleter -CommandName '${programName}' -ScriptBlock \${__${programName}CompleterBlock} +`; +} + +export type LegacyCompletionShell = "bash" | "zsh" | "fish" | "powershell"; + +/** + * Generates the exact script cobra v1.10.2's `supabase completion ` + * would have produced, without shelling out to (or otherwise depending on) + * the Go binary. + */ +export function legacyGenerateCompletionScript( + shell: LegacyCompletionShell, + options: { readonly noDescriptions: boolean }, +): string { + const compCmd: CompletionRequestCmd = options.noDescriptions + ? SHELL_COMP_NO_DESC_REQUEST_CMD + : SHELL_COMP_REQUEST_CMD; + + switch (shell) { + case "bash": + return genBashCompletionScript(PROGRAM_NAME, compCmd); + case "zsh": + return genZshCompletionScript(PROGRAM_NAME, compCmd); + case "fish": + return genFishCompletionScript(PROGRAM_NAME, compCmd); + case "powershell": + return genPowerShellCompletionScript(PROGRAM_NAME, compCmd); + } +} diff --git a/apps/cli/src/legacy/commands/completion/legacy-completion-scripts.unit.test.ts b/apps/cli/src/legacy/commands/completion/legacy-completion-scripts.unit.test.ts new file mode 100644 index 0000000000..122b73bd1a --- /dev/null +++ b/apps/cli/src/legacy/commands/completion/legacy-completion-scripts.unit.test.ts @@ -0,0 +1,145 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +import { + type LegacyCompletionShell, + legacyGenerateCompletionScript, +} from "./legacy-completion-scripts.ts"; + +const fixturesDir = fileURLToPath(new URL("./__fixtures__", import.meta.url)); + +function readFixture(shell: LegacyCompletionShell, variant: "desc" | "nodesc"): string { + return readFileSync(`${fixturesDir}/${shell}.${variant}.txt`, "utf8"); +} + +describe("legacyGenerateCompletionScript", () => { + describe("bash", () => { + it("contains the bash completion V2 header", () => { + const script = legacyGenerateCompletionScript("bash", { noDescriptions: false }); + expect(script).toContain("# bash completion V2 for supabase"); + }); + + it("calls back into __complete by default and never mentions __completeNoDesc", () => { + const script = legacyGenerateCompletionScript("bash", { noDescriptions: false }); + expect(script).toContain("__complete"); + expect(script).not.toContain("__completeNoDesc"); + }); + + it("calls back into __completeNoDesc when noDescriptions is true", () => { + const script = legacyGenerateCompletionScript("bash", { noDescriptions: true }); + expect(script).toContain("__completeNoDesc"); + }); + + it("differs from the with-descriptions variant only by the __complete/__completeNoDesc token", () => { + const withDescriptions = legacyGenerateCompletionScript("bash", { noDescriptions: false }); + const noDescriptions = legacyGenerateCompletionScript("bash", { noDescriptions: true }); + expect(noDescriptions.replaceAll("__completeNoDesc", "__complete")).toBe(withDescriptions); + }); + }); + + describe("zsh", () => { + it("contains the #compdef header and the trailing compdef invocation", () => { + const script = legacyGenerateCompletionScript("zsh", { noDescriptions: false }); + expect(script).toContain("#compdef supabase"); + expect(script).toContain("compdef _supabase supabase"); + }); + + it("calls back into __complete by default and never mentions __completeNoDesc", () => { + const script = legacyGenerateCompletionScript("zsh", { noDescriptions: false }); + expect(script).toContain("__complete"); + expect(script).not.toContain("__completeNoDesc"); + }); + + it("calls back into __completeNoDesc when noDescriptions is true", () => { + const script = legacyGenerateCompletionScript("zsh", { noDescriptions: true }); + expect(script).toContain("__completeNoDesc"); + }); + + it("differs from the with-descriptions variant only by the __complete/__completeNoDesc token", () => { + const withDescriptions = legacyGenerateCompletionScript("zsh", { noDescriptions: false }); + const noDescriptions = legacyGenerateCompletionScript("zsh", { noDescriptions: true }); + expect(noDescriptions.replaceAll("__completeNoDesc", "__complete")).toBe(withDescriptions); + }); + }); + + describe("fish", () => { + it("contains the fish completion header and disables activeHelp via SUPABASE_ACTIVE_HELP=0", () => { + const script = legacyGenerateCompletionScript("fish", { noDescriptions: false }); + expect(script).toContain("# fish completion for supabase"); + expect(script).toContain("SUPABASE_ACTIVE_HELP=0"); + }); + + it("calls back into __complete by default and never mentions __completeNoDesc", () => { + const script = legacyGenerateCompletionScript("fish", { noDescriptions: false }); + expect(script).toContain("__complete"); + expect(script).not.toContain("__completeNoDesc"); + }); + + it("calls back into __completeNoDesc when noDescriptions is true", () => { + const script = legacyGenerateCompletionScript("fish", { noDescriptions: true }); + expect(script).toContain("__completeNoDesc"); + }); + + it("differs from the with-descriptions variant only by the __complete/__completeNoDesc token", () => { + const withDescriptions = legacyGenerateCompletionScript("fish", { noDescriptions: false }); + const noDescriptions = legacyGenerateCompletionScript("fish", { noDescriptions: true }); + expect(noDescriptions.replaceAll("__completeNoDesc", "__complete")).toBe(withDescriptions); + }); + }); + + describe("powershell", () => { + it("registers the argument completer for the supabase command", () => { + const script = legacyGenerateCompletionScript("powershell", { noDescriptions: false }); + expect(script).toContain("Register-ArgumentCompleter -CommandName 'supabase'"); + }); + + it("calls back into __complete by default and never mentions __completeNoDesc", () => { + const script = legacyGenerateCompletionScript("powershell", { noDescriptions: false }); + expect(script).toContain("__complete"); + expect(script).not.toContain("__completeNoDesc"); + }); + + it("calls back into __completeNoDesc when noDescriptions is true", () => { + const script = legacyGenerateCompletionScript("powershell", { noDescriptions: true }); + expect(script).toContain("__completeNoDesc"); + }); + + it("differs from the with-descriptions variant only by the __complete/__completeNoDesc token", () => { + const withDescriptions = legacyGenerateCompletionScript("powershell", { + noDescriptions: false, + }); + const noDescriptions = legacyGenerateCompletionScript("powershell", { + noDescriptions: true, + }); + expect(noDescriptions.replaceAll("__completeNoDesc", "__complete")).toBe(withDescriptions); + }); + }); + + // The substring/self-consistency checks above prove structural facts, but + // "byte-for-byte transcription of cobra v1.10.2" is the module's entire + // contract, and every one of the hundreds of hand-escaped `${…}`/backtick/ + // `$'\t'` sequences in the four templates is otherwise unguarded — a + // well-intentioned "cleanup" of an escape could ship silently. These + // fixtures are the literal stdout of a real `apps/cli-go` binary (pinned to + // `spf13/cobra v1.10.2`, same version as `go.mod`) running + // `supabase completion [--no-descriptions]`, captured once and + // checked in — see `apps/cli/src/legacy/commands/completion/__fixtures__/`. + // Regenerate them (and re-verify byte equality by hand) only if cobra is + // ever upgraded. + describe("byte-exact parity with real cobra v1.10.2 output", () => { + const shells: ReadonlyArray = ["bash", "zsh", "fish", "powershell"]; + + for (const shell of shells) { + it(`matches the real cobra ${shell} completion script byte-for-byte (with descriptions)`, () => { + const generated = legacyGenerateCompletionScript(shell, { noDescriptions: false }); + expect(generated).toBe(readFixture(shell, "desc")); + }); + + it(`matches the real cobra ${shell} completion script byte-for-byte (--no-descriptions)`, () => { + const generated = legacyGenerateCompletionScript(shell, { noDescriptions: true }); + expect(generated).toBe(readFixture(shell, "nodesc")); + }); + } + }); +}); diff --git a/apps/cli/src/legacy/commands/completion/powershell/powershell.command.ts b/apps/cli/src/legacy/commands/completion/powershell/powershell.command.ts index 72dd479f3f..704f831cf7 100644 --- a/apps/cli/src/legacy/commands/completion/powershell/powershell.command.ts +++ b/apps/cli/src/legacy/commands/completion/powershell/powershell.command.ts @@ -1,5 +1,8 @@ import { Command } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; import { LegacyCompletionNoDescriptionsFlagDef } from "../completion.flags.ts"; import { legacyCompletionPowershell } from "./powershell.handler.ts"; @@ -9,7 +12,19 @@ const config = { export type LegacyCompletionPowershellFlags = CliCommand.Command.Config.Infer; export const legacyCompletionPowershellCommand = Command.make("powershell", config).pipe( - Command.withDescription("Generate the autocompletion script for powershell"), + Command.withDescription( + "Generate the autocompletion script for powershell.\n\n" + + "To load completions in your current shell session:\n\n" + + "\tsupabase completion powershell | Out-String | Invoke-Expression\n\n" + + "To load completions for every new session, add the output of the above command\n" + + "to your powershell profile.", + ), Command.withShortDescription("Generate the autocompletion script for powershell"), - Command.withHandler((flags) => legacyCompletionPowershell(flags)), + Command.withHandler((flags) => + legacyCompletionPowershell(flags).pipe( + withLegacyCommandInstrumentation({ flags }), + withJsonErrorHandling, + ), + ), + Command.provide(commandRuntimeLayer(["completion", "powershell"])), ); diff --git a/apps/cli/src/legacy/commands/completion/powershell/powershell.handler.ts b/apps/cli/src/legacy/commands/completion/powershell/powershell.handler.ts index 8b1d056431..e21645ebb3 100644 --- a/apps/cli/src/legacy/commands/completion/powershell/powershell.handler.ts +++ b/apps/cli/src/legacy/commands/completion/powershell/powershell.handler.ts @@ -1,12 +1,13 @@ import { Effect } from "effect"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { legacyGenerateCompletionScript } from "../legacy-completion-scripts.ts"; import type { LegacyCompletionPowershellFlags } from "./powershell.command.ts"; export const legacyCompletionPowershell = Effect.fn("legacy.completion.powershell")(function* ( flags: LegacyCompletionPowershellFlags, ) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["completion", "powershell"]; - if (flags.noDescriptions) args.push("--no-descriptions"); - yield* proxy.exec(args); + const output = yield* Output; + yield* output.raw( + legacyGenerateCompletionScript("powershell", { noDescriptions: flags.noDescriptions }), + ); }); diff --git a/apps/cli/src/legacy/commands/completion/powershell/powershell.integration.test.ts b/apps/cli/src/legacy/commands/completion/powershell/powershell.integration.test.ts index da678deeb2..a59916e072 100644 --- a/apps/cli/src/legacy/commands/completion/powershell/powershell.integration.test.ts +++ b/apps/cli/src/legacy/commands/completion/powershell/powershell.integration.test.ts @@ -1,20 +1,15 @@ import { describe, expect, it } from "@effect/vitest"; +import { BunServices } from "@effect/platform-bun"; import { Effect, Layer } from "effect"; import { Command } from "effect/unstable/cli"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { mockAnalytics, mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { processControlLayer } from "../../../../shared/runtime/process-control.layer.ts"; +import { EventCommandExecuted } from "../../../../shared/telemetry/event-catalog.ts"; import { legacyCompletionPowershellCommand } from "./powershell.command.ts"; import { legacyCompletionPowershell } from "./powershell.handler.ts"; function setupLegacyCompletionPowershell() { - const calls: Array> = []; - const layer = Layer.succeed(LegacyGoProxy, { - exec: (args) => - Effect.sync(() => { - calls.push(args); - }), - execCapture: () => Effect.succeed(""), - }); - return { layer, calls }; + return mockOutput(); } function legacyTestRoot() { @@ -24,30 +19,70 @@ function legacyTestRoot() { } describe("legacy completion powershell", () => { - it.live("forwards `completion powershell` to the Go binary", () => { - const { layer, calls } = setupLegacyCompletionPowershell(); + it.live("prints the native powershell completion script", () => { + const out = setupLegacyCompletionPowershell(); return Effect.gen(function* () { yield* legacyCompletionPowershell({ noDescriptions: false }); - expect(calls).toEqual([["completion", "powershell"]]); - }).pipe(Effect.provide(layer)); + expect(out.stdoutText).toContain("# powershell completion for supabase"); + expect(out.stdoutText).not.toContain("__completeNoDesc"); + expect(out.stdoutText).toContain("__complete"); + }).pipe(Effect.provide(out.layer)); }); - it.live("forwards --no-descriptions when set", () => { - const { layer, calls } = setupLegacyCompletionPowershell(); - return Effect.gen(function* () { - yield* legacyCompletionPowershell({ noDescriptions: true }); - expect(calls).toEqual([["completion", "powershell", "--no-descriptions"]]); - }).pipe(Effect.provide(layer)); - }); + it.live( + "prints the native powershell completion script without descriptions when --no-descriptions is set", + () => { + const out = setupLegacyCompletionPowershell(); + return Effect.gen(function* () { + yield* legacyCompletionPowershell({ noDescriptions: true }); + expect(out.stdoutText).toContain("__completeNoDesc"); + }).pipe(Effect.provide(out.layer)); + }, + ); - it.live("accepts --no-descriptions from real argv via the command parser", () => { - const { layer, calls } = setupLegacyCompletionPowershell(); - return Effect.gen(function* () { - yield* Command.runWith(legacyTestRoot(), { version: "0.0.0-test" })([ - "powershell", - "--no-descriptions", - ]); - expect(calls).toEqual([["completion", "powershell", "--no-descriptions"]]); - }).pipe(Effect.provide(layer)) as Effect.Effect; - }); + it.live( + "accepts --no-descriptions from real argv via the command parser and still prints the no-desc script", + () => { + const out = setupLegacyCompletionPowershell(); + // Running through the real command (rather than calling the handler + // directly, as the two tests above do) also runs + // `withLegacyCommandInstrumentation` (CLI-1965 review finding: telemetry + // parity with the Go CLI's `cli_command_executed` event), which needs + // `Analytics`/`ProcessControl`/`Stdio` alongside `Output` — the same + // minimal layer set `telemetry.integration.test.ts` uses for its own + // local-only (no Management API) native command. + const layer = Layer.mergeAll( + out.layer, + mockAnalytics().layer, + BunServices.layer, + processControlLayer, + ); + return Effect.gen(function* () { + yield* Command.runWith(legacyTestRoot(), { version: "0.0.0-test" })([ + "powershell", + "--no-descriptions", + ]); + expect(out.stdoutText).toContain("__completeNoDesc"); + }).pipe(Effect.provide(layer)) as Effect.Effect; + }, + ); + + it.live( + "fires the cli_command_executed telemetry event, matching Go's PersistentPostRun (CLI-1965 review finding)", + () => { + const out = setupLegacyCompletionPowershell(); + const analytics = mockAnalytics(); + const layer = Layer.mergeAll( + out.layer, + analytics.layer, + BunServices.layer, + processControlLayer, + ); + return Effect.gen(function* () { + yield* Command.runWith(legacyTestRoot(), { version: "0.0.0-test" })(["powershell"]); + const event = analytics.captured.find((entry) => entry.event === EventCommandExecuted); + expect(event).toBeDefined(); + }).pipe(Effect.provide(layer)) as Effect.Effect; + }, + ); }); diff --git a/apps/cli/src/legacy/commands/completion/zsh/zsh.command.ts b/apps/cli/src/legacy/commands/completion/zsh/zsh.command.ts index 8951d805f6..4f380dd4f5 100644 --- a/apps/cli/src/legacy/commands/completion/zsh/zsh.command.ts +++ b/apps/cli/src/legacy/commands/completion/zsh/zsh.command.ts @@ -1,5 +1,8 @@ import { Command } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; import { LegacyCompletionNoDescriptionsFlagDef } from "../completion.flags.ts"; import { legacyCompletionZsh } from "./zsh.handler.ts"; @@ -9,7 +12,26 @@ const config = { export type LegacyCompletionZshFlags = CliCommand.Command.Config.Infer; export const legacyCompletionZshCommand = Command.make("zsh", config).pipe( - Command.withDescription("Generate the autocompletion script for zsh"), + Command.withDescription( + "Generate the autocompletion script for the zsh shell.\n\n" + + "If shell completion is not already enabled in your environment you will need\n" + + "to enable it. You can execute the following once:\n\n" + + '\techo "autoload -U compinit; compinit" >> ~/.zshrc\n\n' + + "To load completions in your current shell session:\n\n" + + "\tsource <(supabase completion zsh)\n\n" + + "To load completions for every new session, execute once:\n\n" + + "#### Linux:\n\n" + + '\tsupabase completion zsh > "${fpath[1]}/_supabase"\n\n' + + "#### macOS:\n\n" + + "\tsupabase completion zsh > $(brew --prefix)/share/zsh/site-functions/_supabase\n\n" + + "You will need to start a new shell for this setup to take effect.", + ), Command.withShortDescription("Generate the autocompletion script for zsh"), - Command.withHandler((flags) => legacyCompletionZsh(flags)), + Command.withHandler((flags) => + legacyCompletionZsh(flags).pipe( + withLegacyCommandInstrumentation({ flags }), + withJsonErrorHandling, + ), + ), + Command.provide(commandRuntimeLayer(["completion", "zsh"])), ); diff --git a/apps/cli/src/legacy/commands/completion/zsh/zsh.handler.ts b/apps/cli/src/legacy/commands/completion/zsh/zsh.handler.ts index 472f1918bc..dbcb1c1858 100644 --- a/apps/cli/src/legacy/commands/completion/zsh/zsh.handler.ts +++ b/apps/cli/src/legacy/commands/completion/zsh/zsh.handler.ts @@ -1,12 +1,13 @@ import { Effect } from "effect"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { legacyGenerateCompletionScript } from "../legacy-completion-scripts.ts"; import type { LegacyCompletionZshFlags } from "./zsh.command.ts"; export const legacyCompletionZsh = Effect.fn("legacy.completion.zsh")(function* ( flags: LegacyCompletionZshFlags, ) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["completion", "zsh"]; - if (flags.noDescriptions) args.push("--no-descriptions"); - yield* proxy.exec(args); + const output = yield* Output; + yield* output.raw( + legacyGenerateCompletionScript("zsh", { noDescriptions: flags.noDescriptions }), + ); }); diff --git a/apps/cli/src/legacy/commands/completion/zsh/zsh.integration.test.ts b/apps/cli/src/legacy/commands/completion/zsh/zsh.integration.test.ts index 9745c1a1e4..02c6596c1c 100644 --- a/apps/cli/src/legacy/commands/completion/zsh/zsh.integration.test.ts +++ b/apps/cli/src/legacy/commands/completion/zsh/zsh.integration.test.ts @@ -1,20 +1,15 @@ import { describe, expect, it } from "@effect/vitest"; +import { BunServices } from "@effect/platform-bun"; import { Effect, Layer } from "effect"; import { Command } from "effect/unstable/cli"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { mockAnalytics, mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { processControlLayer } from "../../../../shared/runtime/process-control.layer.ts"; +import { EventCommandExecuted } from "../../../../shared/telemetry/event-catalog.ts"; import { legacyCompletionZshCommand } from "./zsh.command.ts"; import { legacyCompletionZsh } from "./zsh.handler.ts"; function setupLegacyCompletionZsh() { - const calls: Array> = []; - const layer = Layer.succeed(LegacyGoProxy, { - exec: (args) => - Effect.sync(() => { - calls.push(args); - }), - execCapture: () => Effect.succeed(""), - }); - return { layer, calls }; + return mockOutput(); } function legacyTestRoot() { @@ -22,30 +17,70 @@ function legacyTestRoot() { } describe("legacy completion zsh", () => { - it.live("forwards `completion zsh` to the Go binary", () => { - const { layer, calls } = setupLegacyCompletionZsh(); + it.live("prints the native zsh completion script", () => { + const out = setupLegacyCompletionZsh(); return Effect.gen(function* () { yield* legacyCompletionZsh({ noDescriptions: false }); - expect(calls).toEqual([["completion", "zsh"]]); - }).pipe(Effect.provide(layer)); + expect(out.stdoutText).toContain("#compdef supabase"); + expect(out.stdoutText).not.toContain("__completeNoDesc"); + expect(out.stdoutText).toContain("__complete"); + }).pipe(Effect.provide(out.layer)); }); - it.live("forwards --no-descriptions when set", () => { - const { layer, calls } = setupLegacyCompletionZsh(); - return Effect.gen(function* () { - yield* legacyCompletionZsh({ noDescriptions: true }); - expect(calls).toEqual([["completion", "zsh", "--no-descriptions"]]); - }).pipe(Effect.provide(layer)); - }); + it.live( + "prints the native zsh completion script without descriptions when --no-descriptions is set", + () => { + const out = setupLegacyCompletionZsh(); + return Effect.gen(function* () { + yield* legacyCompletionZsh({ noDescriptions: true }); + expect(out.stdoutText).toContain("__completeNoDesc"); + }).pipe(Effect.provide(out.layer)); + }, + ); - it.live("accepts --no-descriptions from real argv via the command parser", () => { - const { layer, calls } = setupLegacyCompletionZsh(); - return Effect.gen(function* () { - yield* Command.runWith(legacyTestRoot(), { version: "0.0.0-test" })([ - "zsh", - "--no-descriptions", - ]); - expect(calls).toEqual([["completion", "zsh", "--no-descriptions"]]); - }).pipe(Effect.provide(layer)) as Effect.Effect; - }); + it.live( + "accepts --no-descriptions from real argv via the command parser and still prints the no-desc script", + () => { + const out = setupLegacyCompletionZsh(); + // Running through the real command (rather than calling the handler + // directly, as the two tests above do) also runs + // `withLegacyCommandInstrumentation` (CLI-1965 review finding: telemetry + // parity with the Go CLI's `cli_command_executed` event), which needs + // `Analytics`/`ProcessControl`/`Stdio` alongside `Output` — the same + // minimal layer set `telemetry.integration.test.ts` uses for its own + // local-only (no Management API) native command. + const layer = Layer.mergeAll( + out.layer, + mockAnalytics().layer, + BunServices.layer, + processControlLayer, + ); + return Effect.gen(function* () { + yield* Command.runWith(legacyTestRoot(), { version: "0.0.0-test" })([ + "zsh", + "--no-descriptions", + ]); + expect(out.stdoutText).toContain("__completeNoDesc"); + }).pipe(Effect.provide(layer)) as Effect.Effect; + }, + ); + + it.live( + "fires the cli_command_executed telemetry event, matching Go's PersistentPostRun (CLI-1965 review finding)", + () => { + const out = setupLegacyCompletionZsh(); + const analytics = mockAnalytics(); + const layer = Layer.mergeAll( + out.layer, + analytics.layer, + BunServices.layer, + processControlLayer, + ); + return Effect.gen(function* () { + yield* Command.runWith(legacyTestRoot(), { version: "0.0.0-test" })(["zsh"]); + const event = analytics.captured.find((entry) => entry.event === EventCommandExecuted); + expect(event).toBeDefined(); + }).pipe(Effect.provide(layer)) as Effect.Effect; + }, + ); }); diff --git a/apps/cli/src/legacy/commands/db/diff/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/diff/SIDE_EFFECTS.md index 5aa9fed9bd..814e5c4a47 100644 --- a/apps/cli/src/legacy/commands/db/diff/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/diff/SIDE_EFFECTS.md @@ -110,8 +110,9 @@ than a pending port because: The decision record is Linear issue CLI-1960 and the pull request that introduced this deprecation notice; re-open only if a TS/WASM binding for `stripe/pg-schema-diff` ships. It will become the CLI's sole remaining Go delegation -once `--use-pgadmin`'s delegation, the `db __shadow`/`db __db-bootstrap` seams, and -the rest of the M9 milestone's in-flight issues are done — it is not there yet. +once `--use-pgadmin`'s delegation, the `db __shadow` seam (the sibling `db +__db-bootstrap` seam was already removed outright by CLI-1955), and the rest of +the M9 milestone's in-flight issues are done — it is not there yet. Given that, the flag is now deprecated rather than ported: diff --git a/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts b/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts index 1b784983bc..b73557e9b0 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts @@ -69,7 +69,6 @@ function setup(workdir: string, opts: SetupOpts = {}) { exportCatalogCalls.push({ mode, projectRef }); return Effect.succeed("supabase/.temp/pgdelta/migrations.json"); }, - execInherit: () => Effect.succeed(0), ensureLocalDatabaseStarted: () => Effect.void, ensureLocalPostgresImageCurrent: () => Effect.void, provisionShadow: ({ mode, targetLocal, usePgDelta, projectRef }) => { diff --git a/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts b/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts index f22dd4e230..128205371d 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts @@ -117,7 +117,6 @@ function setup(workdir: string, opts: SetupOpts = {}) { const removedContainers: string[] = []; const seam = Layer.succeed(LegacyDeclarativeSeam, { exportCatalog: () => Effect.succeed("supabase/.temp/pgdelta/x.json"), - execInherit: () => Effect.succeed(0), ensureLocalDatabaseStarted: () => Effect.void, ensureLocalPostgresImageCurrent: () => Effect.void, provisionShadow: ({ mode, usePgDelta, targetLocal, projectRef }) => { diff --git a/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md index fd5cf2f93b..8591bad33f 100644 --- a/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md @@ -4,22 +4,42 @@ Native TypeScript port of `apps/cli-go/internal/db/reset/reset.go`. Reinitialise database from local migrations (plus seed). The **remote** path (`--linked`, or a remote `--db-url`) is native: drop all user schemas, upsert vault secrets, then re-apply migrations and seed. The **local** path (`--local`/default, or a `--db-url` -pointing at the local stack) is also native: TS orchestrates the running check, -messages, bucket seeding, and git-branch line, while the container-recreate -primitives run behind the hidden Go `db __db-bootstrap` seam. Only the niche -**`--experimental`** remote schema-files path still delegates to the Go binary. +pointing at the local stack) is ALSO fully native (CLI-1955 removed the hidden Go +`db __db-bootstrap` seam this used to delegate to): the running check, the PG14/PG15 +container-recreate composition (`legacy/shared/db-bootstrap/recreate-local-database.ts`, +reusing the same container-bootstrap primitives `db start` uses — see that command's +own `SIDE_EFFECTS.md`), the post-recreate satellite-restart + Kong reload +(`legacy/shared/db-bootstrap/restart-services.ts`), the storage-health gate +(`legacy/shared/db-bootstrap/await-storage-ready.ts`), bucket seeding, and the +git-branch line are all native TS. Only the niche **`--experimental`** schema-files +path with no resolved version still delegates to the Go binary, and only for the +**remote** target — the local target's `--experimental` path is fully native (see +"Notes"). + +The whole local-reset composition is hoisted into `legacy/shared/db-bootstrap/ +reset-local-database.ts`'s `legacyResetLocalDatabase` (CLI-2062), which this +handler's own `cfg.isLocal` branch calls as a thin wrapper (keeping only version/ +seed-flags resolution and the JSON envelope, which are specific to this top-level +command). `db schema declarative`'s smart-target local-reset prompt and `db schema +sync`'s failed-apply recovery reset both call the SAME function in-process now, +instead of shelling out to a second `supabase-go` child through the previously +removed `LegacyDeclarativeSeam.execInherit` seam — see those commands' own +`SIDE_EFFECTS.md`. ## Files Read -| Path | Format | When | -| ------------------------------------------------------ | ---------- | --------------------------------------------------------------------------------------------------------------------- | -| `/supabase/migrations/` | directory | to validate `--version` / resolve `--last`, and to load migrations | -| `/supabase/config.toml` | TOML | always, parsed up front before any destructive work (embedded defaults when absent); re-read for local bucket seeding | -| `/.git/HEAD` (walked upward) | plain text | local path, for the `Finished … on branch .` line | -| `~/.supabase//project-ref` | plain text | `--linked`, to resolve the ref | -| `~/.supabase/access-token` | plain text | `--linked`, when `SUPABASE_ACCESS_TOKEN` unset and a temp role is minted | -| seed files from `--sql-paths` or `[db.seed].sql_paths` | SQL | when seeding is enabled (not `--no-seed`); `--sql-paths` overrides config | -| `/supabase/buckets/` | files | local path, when storage is up and `[storage.buckets]` configure objects | +| Path | Format | When | +| ----------------------------------------------------------------------------------------- | ---------- | --------------------------------------------------------------------------------------------------------------------- | +| `/supabase/migrations/` | directory | to validate `--version` / resolve `--last`, and to load migrations | +| `/supabase/config.toml` | TOML | always, parsed up front before any destructive work (embedded defaults when absent); re-read for local bucket seeding | +| `/supabase/.env`, `.env.local`, project-root/`SUPABASE_ENV`-selected dotenv file | dotenv | always, resolved before the local prelude (config values, bootstrap config) | +| `/.git/HEAD` (walked upward) | plain text | local path, for the `Finished … on branch .` line | +| `~/.supabase//project-ref` | plain text | `--linked`, to resolve the ref | +| `~/.supabase/access-token` | plain text | `--linked`, when `SUPABASE_ACCESS_TOKEN` unset and a temp role is minted | +| seed files from `--sql-paths` or `[db.seed].sql_paths` | SQL | when seeding is enabled (not `--no-seed`); `--sql-paths` overrides config | +| `/supabase/buckets/` | files | local path, when storage is up and `[storage.buckets]` configure objects | +| `/supabase/roles.sql` | SQL | local PG15 path only, via the reused `legacyStartSetupLocalDatabase` pipeline — missing file tolerated | +| `~/.docker/config.json` | JSON | via the `docker`/`podman` CLI itself, for registry auth — never read directly by this process | ## Files Written @@ -28,21 +48,25 @@ primitives run behind the hidden Go `db __db-bootstrap` seam. Only the niche | `~/.supabase//linked-project.json` | JSON | `--linked` (post-run cache) | | `~/.supabase/telemetry.json` | JSON | always (post-run telemetry flush) | -On the local path the Go seam additionally recreates the `supabase_db_` -container/volume and applies the initial schema (`SetupLocalDatabase`); the -`--experimental` remote path produces whatever the delegated Go binary writes. +On the local path, the native recreate additionally recreates the +`supabase_db_` container/volume (PG15) or the `postgres`/`_supabase` +databases in place (PG14), and applies the initial schema (`SetupLocalDatabase` +equivalent, PG15) or `InitSchema14`/`ApplyApiPrivileges` (PG14); the `--experimental` +remote path produces whatever the delegated Go binary writes. ## Subprocesses -| Command | When | Purpose | -| --------------------------------------------------------------------------- | ----------------------------------- | ----------------------------------------------------------------------- | -| `docker container inspect supabase_db_` | local path | `AssertSupabaseDbIsRunning` probe (Podman fallback) | -| `supabase-go db __db-bootstrap --mode recreate [--version ] [--no-seed]` | local path | recreate container + init schema + migrate + seed + restart services | -| `supabase-go db __db-bootstrap --mode await-storage` | local path | storage health gate before bucket seeding (`ready` / `absent`) | -| `supabase-go db reset --linked\|--db-url … [--no-seed]` | `--experimental` remote, no version | the un-ported experimental schema-files apply path (telemetry disabled) | - -The seam subprocesses run with `SUPABASE_TELEMETRY_DISABLED=1`, stderr inherited; -`--network-id` / a flag-selected `--profile` are forwarded. +| Command | When | Purpose | +| ----------------------------------------------------------------------------------- | ------------------------------------- | ------------------------------------------------------------------------------- | +| `docker container inspect supabase_db_` | local path | `AssertSupabaseDbIsRunning` probe (Podman fallback) | +| `docker container rm -f supabase_db_` / `docker volume rm -f ` | local path, PG15 | remove the existing container/volume before recreating (Podman fallback) | +| `docker network create` / `docker volume create` / `docker create` / `docker start` | local path, PG15 | recreate the Postgres container (same primitives `db start` uses) | +| `docker run --rm ` | local path, PG15, per enabled service | the one-shot `initSchema15` migrate jobs (`legacyStartSetupLocalDatabase`) | +| `docker restart ` | local path, PG14 | `RestartDatabase` — pg_cron must restart after `pg_terminate_backend` | +| `docker restart ` | local path, both PG14 and PG15 | concurrent satellite-container restart, not-found tolerated per service | +| `docker container inspect ` + `docker exec kong reload` | local path, both PG14 and PG15 | reload Kong so it re-resolves the restarted containers' addresses (issue #6016) | +| `docker container inspect supabase_storage_` | local path | storage-health gate before bucket seeding | +| `supabase-go db reset --linked\|--db-url … [--no-seed]` | `--experimental` remote, no version | the un-ported experimental schema-files apply path (telemetry disabled) | ## Database Mutations @@ -55,18 +79,38 @@ The seam subprocesses run with `SUPABASE_TELEMETRY_DISABLED=1`, stderr inherited | migration statements + `schema_migrations` history insert (per file, transactional; pipeline-incompatible statements run standalone — see Notes) | when `[db.migrations].enabled`, for migrations `≤ --version` | | seed statements + `seed_files` hash upsert | when `[db.seed].enabled` and not `--no-seed` | -### Local path (inside the Go seam) - -The recreate seam drops & recreates the `postgres`/`_supabase` databases (PG≤14) or -removes & recreates the db container/volume (PG15), applies the initial schema + -roles, then runs `MigrateAndSeed` (migrations `≤ --version`, seed unless `--no-seed`) -and restarts the storage/auth/realtime/pooler containers, then reloads Kong -(`kong reload`, skipped when the gateway is absent or stopped) so its nginx +### Local path (native, in TS) + +**PG15+:** the container/volume are removed and recreated (see "Subprocesses"), then +the reused `legacyStartSetupLocalDatabase` pipeline runs the initial schema (as +one-shot Docker jobs, not SQL over a session), `ApplyApiPrivileges`, a vault upsert, +a `roles.sql` seed, and `MigrateAndSeed` (migrations `≤ --version`, seed unless +`--no-seed`) — over a fresh host-facing Postgres connection. + +**PG14:** connects as `supabase_admin` to `template1` and disconnects other clients +(`ALTER DATABASE ... ALLOW_CONNECTIONS false` ×2, `pg_terminate_backend`, then polls +`pg_replication_slots` on a 1-second backoff up to 10 times — a failure here is +swallowed unless it's a PgError whose code isn't `3D000`/`invalid_catalog_name`), then +runs four unwrapped statements: `DROP`/`CREATE DATABASE postgres`, `DROP`/`CREATE +DATABASE _supabase`. Reconnects as `supabase_admin` to `postgres` for the schema SQL +(`InitSchema14`, no `globals.sql` — deliberately different from `db start`'s own PG14 +path) + `ApplyApiPrivileges`. After the container itself is restarted (see below), +reconnects as `postgres`/`postgres` for `MigrateAndSeed` (migrations `≤ --version`, +seed unless `--no-seed`). + +**Both branches** then restart the storage/auth/realtime/pooler containers +concurrently (per-service "not found" tolerated, no health wait afterward — "those +services may be excluded from starting"), then reload Kong (`docker exec kong +reload`; skipped, not failed, when the gateway is absent or stopped) so its nginx re-resolves the restarted containers' addresses — otherwise routes to a moved -container keep returning 502 after the reset succeeds (issue #6016). Bucket -objects are then seeded over the Storage gateway (reusing the `seed buckets` -local path); the in-place reload keeps Kong serving throughout, so this never -races a restarting gateway. +container keep returning 502 after the reset succeeds (issue #6016). **A Kong reload +failure fails the WHOLE command** (unlike `functions serve`'s best-effort reload), +with an actionable `Suggestion:` line (`docker restart ` / `docker logs `). +Bucket objects are then seeded over the Storage gateway (reusing the `seed buckets` +local path), gated on a native storage-health check: absent (any inspect error, not +just "not found") skips buckets without failing; present-but-unhealthy waits up to a +**hardcoded 30 seconds** (independent of `db.health_timeout`) and, on timeout, **fails +the whole reset** (not just "skip buckets"). ## API Routes @@ -76,33 +120,36 @@ races a restarting gateway. ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ----------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token for the `--linked` resolver path | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_DB_PASSWORD` | password for the linked/remote connection | no | -| `SUPABASE_YES` | auto-confirm the reset prompt | no (also `--yes`) | -| `SUPABASE_EXPERIMENTAL` | routes the experimental schema-files path to Go | no (also `--experimental`) | -| `SUPABASE_PROJECT_ID` | overrides the local container id (`utils.DbId`) | no | +| Variable | Purpose | Required? | +| ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token for the `--linked` resolver path | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_DB_PASSWORD` | password for the linked/remote connection | no | +| `SUPABASE_YES` | auto-confirm the reset prompt | no (also `--yes`) | +| `SUPABASE_EXPERIMENTAL` | routes the remote experimental schema-files path to Go; on the local path, applies `db.migrations.schema_paths` files instead of `migrations/*.sql` (native) | no (also `--experimental`) | +| `SUPABASE_PROJECT_ID` | overrides the local container id (`utils.DbId`) | no | +| `SUPABASE_DB_PORT` / `SUPABASE_DB_MAJOR_VERSION` / `SUPABASE_DB_HEALTH_TIMEOUT` / `SUPABASE_DB_SETTINGS_*` | local-path container-recreate config overrides, same as `db start` | no | +| `SUPABASE_NETWORK_ID` (`--network-id`) | forces the recreated container/network onto an existing Docker network | no | ## Exit Codes -| Code | Condition | -| -------------------- | -------------------------------------------------------------------------------------------------------------------------- | -| `0` | success | -| `1` | mutually exclusive target flags (`[db-url linked local]`) | -| `1` | `--version` + `--last` together (`[last version]`) | -| `1` | `--version` not an integer (`invalid version number`) | -| `1` | `--version` has no matching migration file | -| `1` | local: database not running (`supabase start is not running.`) | -| `1` | user declined the reset confirmation (`context canceled`) | -| `1` | `config.toml` parse failure | -| `1` | drop / migrate / seed / vault apply failure, or connection error | -| child's exact code\* | local: container recreate / storage health-gate failure (seam), or `--experimental`/`--linked` delegate (proxy) child exit | - -\* The `db __db-bootstrap` seam and the `--experimental` remote delegate both -propagate the spawned `supabase-go` child's real exit code (e.g. `130` after a -Ctrl-C mid-recreate) instead of collapsing every failure to `1` — in every -`--output-format` (CLI-1879). +| Code | Condition | +| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| `0` | success | +| `1` | mutually exclusive target flags (`[db-url linked local]`) | +| `1` | `--version` + `--last` together (`[last version]`) | +| `1` | `--version` not an integer (`invalid version number`) | +| `1` | `--version` has no matching migration file | +| `1` | local: database not running (`supabase start is not running.`) | +| `1` | user declined the reset confirmation (`context canceled`) | +| `1` | `config.toml` parse failure | +| `1` | drop / migrate / seed / vault apply failure, or connection error | +| `1` | local: container/volume remove, network/volume/container create, health-check timeout, PG14 SQL, satellite-restart, or Kong-reload failure | +| child's exact code\* | `--experimental`/`--linked` remote delegate (proxy) child exit | + +\* The `--experimental` remote delegate propagates the spawned `supabase-go` child's +real exit code (e.g. `130` after a Ctrl-C) instead of collapsing every failure to `1` +— in every `--output-format` (CLI-1879). The local path has no Go child at all +anymore (CLI-1955) — every local failure is a native, typed TS error. ## Output @@ -111,10 +158,10 @@ drop/migrate/seed progress (`Applying migration …`, `Seeding data from …`). connects with `io.Discard`, so there is **no** `Connecting to … database…` line and **no** `Finished …` line on the remote path. -The local path prints `Resetting local database…` to **stderr**, then the seam's -`Recreating database...` / `Restarting containers...` progress, and finally -`Finished supabase db reset on branch .` (`supabase db reset` and `` -in Aqua). +The local path prints `Resetting local database…` to **stderr**, then +`Recreating database...` (PG15) or nothing extra (PG14, until the restart step) / +`Restarting containers...` progress, and finally `Finished supabase db reset on +branch .` (`supabase db reset` and `` in Aqua). ### `--output-format text` (Go CLI compatible) @@ -143,15 +190,34 @@ path has no confirmation prompt. behaviour as `db push` — see `db push`'s SIDE_EFFECTS Notes (supabase/cli#5139, closed Go PR supabase/cli#5156, CLI-1989 parity ruling). - `--no-seed` forces seeding off (Go sets `Config.Db.Seed.Enabled = false`); on the - local path it is forwarded to the recreate seam so `MigrateAndSeed` skips the seed. + local path it feeds `legacyResolveResetSeedConfig`, applied on top of the loaded + `[db.seed]` config inside the recreate's own `MigrateAndSeed` step (same override + logic on both PG14 and PG15). - `--sql-paths` overrides `[db.seed].sql_paths` for one reset and force-enables seeding even when `[db.seed].enabled = false`; repeat it to seed multiple files or glob patterns (supabase-relative). Mutually exclusive with `--no-seed`. On the local path - it is forwarded to the recreate seam; on the remote path it seeds the selected - database after migrations (Go warns when paired with `--linked` / `--db-url`). + it is applied the same way as `--no-seed` above; on the remote path it seeds the + selected database after migrations (Go warns when paired with `--linked` / `--db-url`). - `--last n` reverts the most recent `n` migrations; if `n ≥ total`, the reset target version becomes `-` (revert everything). Mutually exclusive with `--version`. - `--db-url`, `--linked`, and `--local` (default true) are mutually exclusive. -- **Known interim**: only `--experimental` remote resets run via the Go binary; the - best-effort pg-delta catalog cache (inside the seam) is not surfaced (no output - impact). `encrypted:` vault secrets are skipped on the remote path. +- The local target's `--experimental` schema-files path (no resolved version, no + pg-delta) is fully native: it was never actually delegated even before this port + (the removed seam forwarded `--experimental` straight through to its own Go child), + and `legacyMigrateAndSeed` (reused by both PG14 and PG15) already implements Go's + `apply.MigrateAndSeed` declarative-schema-files branch. +- The best-effort pg-delta migrations-catalog cache write + (`pgcache.TryCacheMigrationsCatalog`, reachable from the PG15 recreate via + `SetupLocalDatabase`) IS reached on the local PG15 path, same as `db start` — + `reset.layers.ts` composes `legacyEdgeRuntimeScriptLayer`/`legacyPgDeltaSslProbeLayer` + for it (see `db-setup.ts`'s own header for the exact gate). The write is silent on + success; a failure only warns on stderr and never fails the reset, matching Go. +- `encrypted:` vault secrets are skipped on the remote path. +- `db schema declarative`/`db schema sync`'s own local-reset paths now call + `legacyResetLocalDatabase` in-process too (CLI-2062) — the previous scope boundary + (those two commands shelling out to a second `supabase-go` child via the now-removed + `LegacyDeclarativeSeam.execInherit`) is closed. That in-process call collapses to a + single telemetry/linked-project-cache finalizer cycle (the outer `db schema +declarative`/`sync` command's own), matching Go's single-process `reset.Run` call — + the removed subprocess design used to fire a second, independent one from the child + process's own `Execute()`. diff --git a/apps/cli/src/legacy/commands/db/reset/reset.errors.ts b/apps/cli/src/legacy/commands/db/reset/reset.errors.ts index 80d2cba332..43dba0f603 100644 --- a/apps/cli/src/legacy/commands/db/reset/reset.errors.ts +++ b/apps/cli/src/legacy/commands/db/reset/reset.errors.ts @@ -56,16 +56,6 @@ export class LegacyDbResetApplyError extends Data.TaggedError("LegacyDbResetAppl readonly message: string; }> {} -/** - * The local database container is not running. Byte-matches Go's - * `utils.ErrNotRunning` (`internal/utils/misc.go:116`), `"supabase start - * is not running."`, returned by `AssertSupabaseDbIsRunning` before the local - * reset (`internal/db/reset/reset.go:57`). - */ -export class LegacyDbResetNotRunningError extends Data.TaggedError("LegacyDbResetNotRunningError")<{ - readonly message: string; -}> {} - /** * `--last` was given a negative value. Go declares `--last` as an unsigned flag * (`UintVar`, `cmd/db.go`), so cobra rejects a negative at parse time. Byte-matches diff --git a/apps/cli/src/legacy/commands/db/reset/reset.handler.ts b/apps/cli/src/legacy/commands/db/reset/reset.handler.ts index 29ccc17783..d00c8ff428 100644 --- a/apps/cli/src/legacy/commands/db/reset/reset.handler.ts +++ b/apps/cli/src/legacy/commands/db/reset/reset.handler.ts @@ -1,8 +1,6 @@ import { Effect, FileSystem, Option, Path } from "effect"; -import { ChildProcessSpawner } from "effect/unstable/process"; import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; -import { detectGitBranch } from "../../../../shared/git/git-branch.ts"; import { LegacyDnsResolverFlag } from "../../../../shared/legacy/global-flags.ts"; import { legacyResolveExperimentalWithProjectEnv, @@ -14,11 +12,12 @@ import { Output } from "../../../../shared/output/output.service.ts"; import { legacyAqua, legacyYellow } from "../../../shared/legacy-colors.ts"; import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { legacyResolveResetSeedConfig } from "../../../shared/db-bootstrap/db-setup.ts"; +import { legacyResetLocalDatabase } from "../../../shared/db-bootstrap/reset-local-database.ts"; import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; import { legacyCheckDbToml, legacyLoadProjectEnv, - legacyResolveSeedSqlPath, } from "../../../shared/legacy-db-config.toml-read.ts"; import { LegacyDbConnection } from "../../../shared/legacy-db-connection.service.ts"; import { legacyApplyMigrations } from "../../../shared/legacy-migration-apply.ts"; @@ -31,13 +30,10 @@ import { import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; import { legacyDropUserSchemas } from "../shared/legacy-drop-schemas.ts"; -import { LegacyDbBootstrapSeam } from "../shared/legacy-db-bootstrap.seam.service.ts"; -import { legacyIsLocalDbRunning } from "../../../shared/db-bootstrap/local-db-running.ts"; import { legacyListLocalMigrations } from "../../../shared/legacy-pgdelta.cache.ts"; import { legacyPathMatch } from "../../../shared/legacy-path-match.ts"; import { legacyGetPendingSeeds, legacySeedData } from "../../../shared/legacy-seed-ops.ts"; import { legacyUpsertVaultSecrets } from "../../../shared/legacy-vault.ts"; -import { legacySeedBucketsRun } from "../../../shared/legacy-seed-buckets.ts"; import type { LegacyDbResetFlags } from "./reset.command.ts"; import { LegacyDbResetApplyError, @@ -45,7 +41,6 @@ import { LegacyDbResetInvalidVersionError, LegacyDbResetLastFlagError, LegacyDbResetMigrationFileError, - LegacyDbResetNotRunningError, LegacyDbResetSeedFlagsError, LegacyDbResetTargetFlagsError, LegacyDbResetVersionFlagsError, @@ -97,22 +92,30 @@ const buildResetArgs = ( * `supabase db reset` — reinitialise a database from local migrations (+ seed). * * Strict 1:1 port of `apps/cli-go/internal/db/reset/reset.go`. The remote path - * (`--linked` / a remote `--db-url`) is native. The local path (and the niche - * `--experimental` schema-files path) delegate to the Go binary as a documented - * interim until the container-bootstrap seam is ported (CLI-1325 Stage 3). + * (`--linked` / a remote `--db-url`) is native. The local path's container-recreate + * primitives are ALSO native now — the hidden `db __db-bootstrap` Go seam this used to + * delegate to (CLI-1325 Stage 3's documented interim) is gone (CLI-1955), and the + * local-reset composition itself is hoisted into `legacyResetLocalDatabase` + * (`legacy/shared/db-bootstrap/reset-local-database.ts`, CLI-2062) so `db schema + * declarative`'s smart-target/sync recovery reset can call it in-process too, instead + * of shelling out to a second `supabase-go` child. Only the REMOTE target's niche + * `--experimental` schema-files path with NO resolved version still delegates to the + * Go binary (`shouldDelegateExperimental`) — the LOCAL target never delegated this at + * all (the removed seam forwarded `--experimental` straight through to its own Go + * child), and stays fully native on this path too: `legacyMigrateAndSeed` (reused by + * both the PG14 and PG15 recreate branches) already implements Go's + * `apply.MigrateAndSeed` experimental-schema-files branch. */ export const legacyDbReset = Effect.fn("legacy.db.reset")(function* (flags: LegacyDbResetFlags) { const output = yield* Output; const resolver = yield* LegacyDbConfigResolver; const dbConn = yield* LegacyDbConnection; const proxy = yield* LegacyGoProxy; - const seam = yield* LegacyDbBootstrapSeam; const cliConfig = yield* LegacyCliConfig; const telemetryState = yield* LegacyTelemetryState; const linkedProjectCache = yield* LegacyLinkedProjectCache; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const cliArgs = yield* CliArgs; const dnsResolver = yield* LegacyDnsResolverFlag; @@ -294,97 +297,18 @@ export const legacyDbReset = Effect.fn("legacy.db.reset")(function* (flags: Lega const cfg = yield* resolver.resolve({ dbUrl: flags.dbUrl, connType, dnsResolver }); - // Local target → native local reset. The container-recreate primitives live - // behind the hidden Go `db __db-bootstrap` seam; TS orchestrates the rest - // (running check, messages, bucket seeding, git-branch line, output shaping). - // Mirrors `internal/db/reset/reset.go:57-77`. + // Local target → native local reset. Mirrors `internal/db/reset/reset.go:57-77`; + // the actual composition (running check, container recreate, storage-health gate, + // bucket seeding, git-branch line) is hoisted into `legacyResetLocalDatabase` + // (CLI-2062) — shared with `db schema declarative`'s in-process recovery reset — + // so this call site stays a thin wrapper around it, keeping only the version/ + // seed-flags plumbing and the JSON envelope, which belong to this top-level + // command alone (see that function's own header for why). if (cfg.isLocal) { - // Go's `flags.LoadConfig` (root `PersistentPreRunE` → the local target's - // per-connType `LoadConfig`, `internal/utils/flags/db_url.go:77-80`) runs full - // config validation before `reset.Run` ever reaches `AssertSupabaseDbIsRunning` - // / the destructive `resetDatabase` (`internal/db/reset/reset.go:57-61`). The - // resolver's own local read (above, line 239) already performs the identical - // validation and would already reject a broken config before this point is - // reached — so today this re-validates for its own sake. Repeat it here anyway, - // as an explicit, independent gate (the same pattern `db start` and `db push` - // use), so the "malformed config aborts before the local database is recreated" - // guarantee is enforced by this handler directly and stays covered by a - // handler-level test even if the resolver's own internal read is ever mocked, - // relaxed, or refactored to stop validating. - yield* legacyCheckDbToml(fs, path, workdir); - - // AssertSupabaseDbIsRunning — error if the local db container is down. Native TS, - // hoisted out of the seam by CLI-1954 (see `legacyIsLocalDbRunning`'s own header). - const running = yield* legacyIsLocalDbRunning( - spawner, - fs, - path, - workdir, - Option.getOrUndefined(cliConfig.projectId), - ); - if (!running) { - return yield* Effect.fail( - new LegacyDbResetNotRunningError({ - message: `${legacyAqua("supabase start")} is not running.`, - }), - ); - } - // resetDatabase: "Resetting local database…" then recreate + migrate + seed. - yield* output.raw(`Resetting local database${toLogMessage(resolvedVersion)}\n`, "stderr"); - yield* seam.recreateDatabase({ + yield* legacyResetLocalDatabase({ version: resolvedVersion, - noSeed: flags.noSeed, - sqlPaths: flags.sqlPaths, + seedFlags: { noSeed: flags.noSeed, sqlPaths: flags.sqlPaths }, }); - - // Seed objects from supabase/buckets when storage is up (Go gates buckets on - // an existing, healthy storage container). Reuses the ported seed-buckets - // local path; its summary is suppressed (reset emits its own result). - const storageReady = yield* seam.awaitStorageReady(); - if (storageReady) { - // Go's `buckets.Run(ctx, "", false, fsys)` — non-interactive: overwrite/prune - // confirmations take their defaults instead of blocking on input. - // - // `legacyCheckDbToml` above resolves `env(VAR)` via `legacyLoadProjectEnv`, - // which mirrors Go's full nested-env walk (`.env..local`, - // `.env.local`, `.env.`, `.env`, across both `supabase/` and the - // project root — `pkg/config/config.go:1220-1257`). This reload instead goes - // through `@supabase/config`'s `loadProjectConfig` → `loadProjectEnvironment`, - // which only ever reads `supabase/.env`/`.env.local` plus ambient env - // (`packages/config/src/project.ts:209-245`) — regardless of `goViperCompat`, - // which only widens `env(VAR)` matching, not the file set consulted. So a - // config whose `env(VAR)` reference is backed by e.g. `supabase/.env.development` - // is genuinely Go-valid (Go's `godotenv.Load` calls `os.Setenv`, so the value is - // real ambient env by the time Go resolves it — `config.go:1260-1261`) and - // already passed `legacyCheckDbToml` and the real recreate above, but this - // narrower reload can still reject it. A `LegacySeedConfigLoadError` here is - // that env-file-set gap, not a genuinely invalid config — and recreate already - // dropped/rebuilt the DB, so aborting now would leave the reset half-done; warn - // and skip buckets so `db reset` finishes like Go instead. - yield* legacySeedBucketsRun({ - projectRef: "", - emitSummary: false, - interactive: false, - // Go loads nested env before `buckets.Run`, so `SUPABASE_YES` in `supabase/.env` - // auto-confirms bucket/vector/analytics prune prompts. Pass the project-env-resolved - // `yes` (the shared runner's own `legacyResolveYes` only sees the shell env). - yes, - }).pipe( - Effect.catchTag("LegacySeedConfigLoadError", (error) => - output.raw( - `${legacyYellow("WARNING:")} skipped seeding storage buckets: ${error.message}\n`, - "stderr", - ), - ), - ); - } - - // "Finished supabase db reset on branch ." (both Aqua). - const branch = Option.getOrElse(yield* detectGitBranch(workdir), () => "main"); - yield* output.raw( - `Finished ${legacyAqua("supabase db reset")} on branch ${legacyAqua(branch)}.\n`, - "stderr", - ); if (output.format !== "text") { yield* output.success("Reset local database.", { target: "local", @@ -460,19 +384,23 @@ export const legacyDbReset = Effect.fn("legacy.db.reset")(function* (flags: Lega // `--no-seed` disables seeding; `--sql-paths` overrides [db.seed].sql_paths // and force-enables it (Go's applyDbResetSeedFlags). The two are mutually - // exclusive (validated above). - const overrideSeed = flags.sqlPaths.length > 0; - // `--sql-paths` force-enables seeding (Go's applyDbResetSeedFlags); otherwise - // honor `db.seed.enabled` (already `SUPABASE_DB_SEED_ENABLED`-resolved by the reader). - const seedEnabled = overrideSeed || (toml.seed.enabled && !flags.noSeed); - if (seedEnabled) { - // `[db.seed].sql_paths` is already Go-config-resolved (supabase/-joined) by the - // reader; the `--sql-paths` override is resolved here the same way Go's - // `resolveSeedSqlPaths` does, so both feed the glob identical paths. - const seedPaths = overrideSeed - ? flags.sqlPaths.map((p) => legacyResolveSeedSqlPath(path, p)) - : toml.seed.sqlPaths; - const seeds = yield* legacyGetPendingSeeds(session, fs, path, seedPaths, workdir); + // exclusive (validated above). Same single home as the local path's identical + // override (`legacyResolveResetSeedConfig`, `db-setup.ts`) — one implementation + // of Go's `applyDbResetSeedFlags` for both targets, per "Hoist Before You + // Duplicate" (`apps/cli/CLAUDE.md`). + const resolvedSeed = legacyResolveResetSeedConfig( + toml.seed, + { noSeed: flags.noSeed, sqlPaths: flags.sqlPaths }, + path, + ); + if (resolvedSeed.enabled) { + const seeds = yield* legacyGetPendingSeeds( + session, + fs, + path, + resolvedSeed.sqlPaths, + workdir, + ); yield* legacySeedData(session, fs, workdir, path, seeds, applyError); } // Go's best-effort pgcache catalog warning is not ported (no output impact). diff --git a/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts b/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts index 79b1bc935d..fd703e747d 100644 --- a/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts @@ -10,6 +10,7 @@ import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import { mockOutput, + mockProcessControl, mockRuntimeInfo, mockStdin, mockTty, @@ -27,13 +28,18 @@ import { LegacyPlatformApiFactory } from "../../../auth/legacy-platform-api-fact import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; import { + LegacyDebugFlag, LegacyDnsResolverFlag, LegacyExperimentalFlag, + LegacyNetworkIdFlag, LegacyYesFlag, } from "../../../../shared/legacy/global-flags.ts"; import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; import { LegacyGoChildExitError } from "../../../../shared/legacy/legacy-go-child-exit.error.ts"; import type { OutputFormat } from "../../../../shared/output/types.ts"; +import { legacyDockerRunLayer } from "../../../shared/legacy-docker-run.layer.ts"; +import { LegacyEdgeRuntimeScript } from "../../../shared/legacy-edge-runtime-script.service.ts"; +import { LegacyPgDeltaSslProbe } from "../../../shared/legacy-pgdelta-ssl-probe.service.ts"; import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; import type { LegacyDbConfigFlags, @@ -45,13 +51,14 @@ import { LegacyDbConnection, type LegacyPgConnInput, } from "../../../shared/legacy-db-connection.service.ts"; -import { LegacyDbBootstrapSeam } from "../shared/legacy-db-bootstrap.seam.service.ts"; import { legacyDbReset } from "./reset.handler.ts"; import type { LegacyDbResetFlags } from "./reset.command.ts"; const LIST_MIGRATIONS = "SELECT version FROM supabase_migrations.schema_migrations ORDER BY version"; const SELECT_SEEDS = "SELECT path, hash FROM supabase_migrations.seed_files"; +const COUNT_REPLICATION_SLOTS = + "SELECT COUNT(*) FROM pg_replication_slots WHERE database IN ('postgres', '_supabase')"; const CONN: LegacyPgConnInput = { host: "db.example.supabase.co", @@ -117,9 +124,28 @@ function mockResolver(opts: { }; } -function mockConnection(opts: { remoteSeeds?: Readonly> }) { +/** + * A single `LegacyDbConnection` mock shared by BOTH the remote path (tracks + * `execs`/`queries` for the drop-schema/migrate/seed assertions) and the native + * local recreate path (the PG14 branch's `session.exec`/`.query` calls) — + * `legacyDbReset` composes exactly one `LegacyDbConnection` layer, so tests must + * not register two competing ones (the second would silently shadow the first + * in `Layer.mergeAll`). + */ +function mockConnection( + opts: { + remoteSeeds?: Readonly>; + /** Sequence of `pg_replication_slots` counts returned on successive polls (defaults to `[0]` — drains immediately). */ + replicationSlotCounts?: ReadonlyArray; + /** Makes the `pg_replication_slots` COUNT query itself fail (permanent, non-retryable). */ + replicationSlotQueryFails?: boolean; + /** Fails one exact statement with the given SQLSTATE `code` (or no code, for a non-PgError failure). */ + failStatement?: { readonly sql: string; readonly code?: string; readonly message: string }; + } = {}, +) { const execs: Array = []; const queries: Array<{ sql: string; params?: ReadonlyArray }> = []; + let replicationCallIndex = 0; const layer = Layer.succeed(LegacyDbConnection, { connect: () => Effect.succeed({ @@ -127,8 +153,17 @@ function mockConnection(opts: { remoteSeeds?: Readonly> } copyToCsv: () => Effect.succeed(new Uint8Array()), queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), exec: (sql: string): Effect.Effect => - Effect.sync(() => { + Effect.suspend((): Effect.Effect => { execs.push(sql); + if (opts.failStatement !== undefined && sql === opts.failStatement.sql) { + return Effect.fail( + new LegacyDbExecError({ + message: opts.failStatement.message, + code: opts.failStatement.code, + }), + ); + } + return Effect.void; }), query: ( sql: string, @@ -143,6 +178,15 @@ function mockConnection(opts: { remoteSeeds?: Readonly> } ); } if (sql === LIST_MIGRATIONS) return Effect.succeed([]); + if (sql === COUNT_REPLICATION_SLOTS) { + if (opts.replicationSlotQueryFails === true) { + return Effect.fail(new LegacyDbExecError({ message: "connection reset" })); + } + const counts = opts.replicationSlotCounts ?? [0]; + const count = counts[Math.min(replicationCallIndex, counts.length - 1)] ?? 0; + replicationCallIndex++; + return Effect.succeed([{ count: String(count) }]); + } return Effect.succeed([]); }, ), @@ -160,73 +204,78 @@ function mockConnection(opts: { remoteSeeds?: Readonly> } } /** - * Stateful mock of the container-bootstrap seam. `storageReady` drives the - * bucket-seed gate. Records the recreate args so tests can assert version / - * `--no-seed` propagation. `awaitStorageReadyExitCode`, when set, fails - * `awaitStorageReady` with a `LegacyGoChildExitError` carrying that code — - * simulating the seam's real `captureStdout` bootstrap-child path exiting - * non-zero (CLI-1879). `AssertSupabaseDbIsRunning` no longer lives on this seam — - * see `mockRunningCheckSpawner` below (CLI-1954 hoisted it to - * `legacyIsLocalDbRunning`, a native `docker container inspect`). + * `execCaptureExitCode`, when set, makes `execCapture` fail with a + * `LegacyGoChildExitError` carrying that code instead of succeeding — simulating + * a delegated Go child exiting non-zero under a machine-output mode (CLI-1879). */ -function mockBootstrapSeam(opts: { storageReady?: boolean; awaitStorageReadyExitCode?: number }) { - const recreateCalls: Array<{ - version: string; - noSeed: boolean; - sqlPaths: ReadonlyArray; - }> = []; - let storageChecked = false; - const layer = Layer.succeed(LegacyDbBootstrapSeam, { - recreateDatabase: (args: { - version: string; - noSeed: boolean; - sqlPaths: ReadonlyArray; - }) => +function mockProxy(opts: { execCaptureExitCode?: number } = {}) { + const calls: Array<{ args: ReadonlyArray; env?: Record }> = []; + const layer = Layer.succeed(LegacyGoProxy, { + exec: (args, execOpts) => Effect.sync(() => { - recreateCalls.push(args); + calls.push({ args, env: execOpts?.env }); }), - awaitStorageReady: () => + execCapture: (args, execOpts) => Effect.sync(() => { - storageChecked = true; + calls.push({ args, env: execOpts?.env }); }).pipe( Effect.flatMap(() => - opts.awaitStorageReadyExitCode !== undefined + opts.execCaptureExitCode !== undefined ? Effect.fail( new LegacyGoChildExitError({ - exitCode: opts.awaitStorageReadyExitCode, - message: `failed to bootstrap the local database: exit ${opts.awaitStorageReadyExitCode}`, + exitCode: opts.execCaptureExitCode, + message: `supabase-go exited with code ${opts.execCaptureExitCode}`, }), ) - : Effect.succeed(opts.storageReady ?? false), + : Effect.succeed(""), ), ), }); return { layer, - get recreateCalls() { - return recreateCalls; - }, - get storageChecked() { - return storageChecked; + get calls() { + return calls; }, }; } -/** - * Mock `ChildProcessSpawner` backing `legacyIsLocalDbRunning`'s `docker container - * inspect` — the local reset path's only real subprocess call (the recreate / - * storage-health primitives stay behind the mocked seam above). `running` (default - * `true`, matching the seam-hosted mock's own former default) drives - * `AssertSupabaseDbIsRunning`: a healthy inspect when `true`, a "no such container" - * failure when `false`. - */ -function mockRunningCheckSpawner(opts: { running?: boolean } = {}) { - const running = opts.running ?? true; +// --------------------------------------------------------------------------- +// Native local-reset harness — mirrors `db/start/start.integration.test.ts`'s own +// `mockContainerCliSpawner`/`defaultRoute`/`fakeDbSession`, adapted for reset's +// container-REMOVE-then-recreate flow (rather than start's volume-existence probe) +// and its post-recreate satellite-restart + Kong-reload step. +// --------------------------------------------------------------------------- + +const PROJECT_ID = "test"; +const DB_ID = `supabase_db_${PROJECT_ID}`; +const KONG_ID = `supabase_kong_${PROJECT_ID}`; +const STORAGE_ID = `supabase_storage_${PROJECT_ID}`; + +const HEALTHY_STATE = '{"Running":true,"Status":"running","Health":{"Status":"healthy"}}'; +const STARTING_STATE = '{"Running":true,"Status":"running","Health":{"Status":"starting"}}'; +const STOPPED_STATE = '{"Running":false,"Status":"exited"}'; + +interface SpawnRecord { + readonly args: ReadonlyArray; +} + +type RouteResult = { + readonly exitCode?: number; + readonly stdout?: ReadonlyArray; + readonly stderr?: ReadonlyArray; +}; + +function mockContainerCliSpawner(route: (args: ReadonlyArray) => RouteResult) { + const spawned: Array = []; const encoder = new TextEncoder(); + const layer = Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, ChildProcessSpawner.make((command) => Effect.gen(function* () { + const args = command._tag === "StandardCommand" ? command.args : []; + spawned.push({ args }); + if (command._tag !== "StandardCommand") { return yield* Effect.fail( PlatformError.systemError({ @@ -237,13 +286,16 @@ function mockRunningCheckSpawner(opts: { running?: boolean } = {}) { }), ); } - const stderrLines = running ? [] : ["Error: No such container: supabase_db_test"]; + + const result = route(args); + const stdoutBytes = (result.stdout ?? []).map((line) => encoder.encode(`${line}\n`)); + const stderrBytes = (result.stderr ?? []).map((line) => encoder.encode(`${line}\n`)); return ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(7000), - stdout: Stream.empty, - stderr: Stream.fromIterable(stderrLines.map((line) => encoder.encode(`${line}\n`))), + pid: ChildProcessSpawner.ProcessId(6000 + spawned.length), + stdout: Stream.fromIterable(stdoutBytes), + stderr: Stream.fromIterable(stderrBytes), all: Stream.empty, - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(running ? 0 : 1)), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(result.exitCode ?? 0)), isRunning: Effect.succeed(false), stdin: Sink.drain, kill: () => Effect.void, @@ -254,55 +306,117 @@ function mockRunningCheckSpawner(opts: { running?: boolean } = {}) { }), ), ); - return { layer }; -} - -// Dummy HTTP client; the local-reset bucket-seed core only reaches it when storage -// is ready AND buckets are configured (no reset test configures buckets, so the -// gateway is never actually called). Present to satisfy the handler's R. -const mockStorageHttp = Layer.succeed( - HttpClient.HttpClient, - HttpClient.make((request) => - Effect.succeed(HttpClientResponse.fromWeb(request, new Response("{}", { status: 404 }))), - ), -); -/** - * `execCaptureExitCode`, when set, makes `execCapture` fail with a - * `LegacyGoChildExitError` carrying that code instead of succeeding — simulating - * a delegated Go child exiting non-zero under a machine-output mode (CLI-1879). - */ -function mockProxy(opts: { execCaptureExitCode?: number } = {}) { - const calls: Array<{ args: ReadonlyArray; env?: Record }> = []; - const layer = Layer.succeed(LegacyGoProxy, { - exec: (args, execOpts) => - Effect.sync(() => { - calls.push({ args, env: execOpts?.env }); - }), - execCapture: (args, execOpts) => - Effect.sync(() => { - calls.push({ args, env: execOpts?.env }); - }).pipe( - Effect.flatMap(() => - opts.execCaptureExitCode !== undefined - ? Effect.fail( - new LegacyGoChildExitError({ - exitCode: opts.execCaptureExitCode, - message: `supabase-go exited with code ${opts.execCaptureExitCode}`, - }), - ) - : Effect.succeed(""), - ), - ), - }); return { layer, - get calls() { - return calls; + get spawned() { + return spawned; }, }; } +function containerNameFromCreateArgs(args: ReadonlyArray): string { + const nameIndex = args.indexOf("--name"); + return nameIndex !== -1 ? (args[nameIndex + 1] ?? "unknown") : "unknown"; +} + +function fakeContainerId(name: string): string { + return [...name] + .map((char) => (char.codePointAt(0) ?? 0).toString(16).padStart(2, "0")) + .join("") + .padEnd(64, "0") + .slice(0, 64); +} + +const createArgs = (spawned: ReadonlyArray): ReadonlyArray | undefined => + spawned.find((s) => s.args[0] === "create")?.args; + +// `docker container rm -f ` / `docker volume rm -f ` — the target is +// argv[3] (after the `-f` flag at argv[2]), not argv[2] itself. +const removedContainers = (spawned: ReadonlyArray): ReadonlyArray => + spawned + .filter((s) => s.args[0] === "container" && s.args[1] === "rm") + .map((s) => s.args[3] ?? ""); + +const removedVolumes = (spawned: ReadonlyArray): ReadonlyArray => + spawned.filter((s) => s.args[0] === "volume" && s.args[1] === "rm").map((s) => s.args[3] ?? ""); + +const restartedContainers = (spawned: ReadonlyArray): ReadonlyArray => + spawned.filter((s) => s.args[0] === "restart").map((s) => s.args[1] ?? ""); + +const kongReloadCalls = (spawned: ReadonlyArray): ReadonlyArray => + spawned.filter((s) => s.args[0] === "exec" && s.args[1] === KONG_ID); + +/** The three PG15+ one-shot migrate jobs (`legacyStartSetupLocalDatabase`'s `LegacyDockerRun` calls). */ +const dbSetupJobCalls = (spawned: ReadonlyArray): ReadonlyArray => + spawned.filter((s) => s.args[0] === "run" && s.args[1] === "--rm"); + +interface DefaultRouteOpts { + readonly running?: boolean; + readonly neverHealthy?: boolean; + readonly kongMissing?: boolean; + readonly kongNotRunning?: boolean; + readonly kongReloadFails?: boolean; + readonly storageMissing?: boolean; + readonly storageUnhealthy?: boolean; + readonly restartFails?: ReadonlyArray; +} + +function defaultLocalResetRoute(opts: DefaultRouteOpts = {}) { + return (args: ReadonlyArray): RouteResult => { + if (args[0] === "image" && args[1] === "inspect") return { exitCode: 0 }; + if (args[0] === "context" && args[1] === "inspect") return { exitCode: 1 }; + if (args[0] === "container" && args[1] === "rm") return { exitCode: 0 }; + if (args[0] === "volume" && args[1] === "rm") return { exitCode: 0 }; + if (args[0] === "network" && args[1] === "create") return { exitCode: 0 }; + if (args[0] === "volume" && args[1] === "create") return { exitCode: 0 }; + if (args[0] === "create") { + const name = containerNameFromCreateArgs(args); + return { stdout: [fakeContainerId(name)] }; + } + if (args[0] === "start") return { exitCode: 0 }; + if (args[0] === "restart") { + const id = args[1] ?? ""; + if (opts.restartFails?.includes(id) === true) { + return { exitCode: 1, stderr: [`Error: failed to restart ${id}`] }; + } + return { exitCode: 0 }; + } + if (args[0] === "exec" && args[1] === KONG_ID) { + return opts.kongReloadFails === true + ? { exitCode: 1, stderr: ["reload failed"] } + : { exitCode: 0 }; + } + if (args[0] === "container" && args[1] === "inspect") { + const id = args[2] ?? ""; + if (id === KONG_ID) { + if (opts.kongMissing === true) + return { exitCode: 1, stderr: [`Error: No such container: ${id}`] }; + return { stdout: [opts.kongNotRunning === true ? STOPPED_STATE : HEALTHY_STATE] }; + } + if (id === STORAGE_ID) { + if (opts.storageMissing === true) + return { exitCode: 1, stderr: [`Error: No such container: ${id}`] }; + return { stdout: [opts.storageUnhealthy === true ? STARTING_STATE : HEALTHY_STATE] }; + } + if (opts.running === false) + return { exitCode: 1, stderr: [`Error: No such container: ${id}`] }; + if (opts.neverHealthy === true) return { stdout: [STARTING_STATE] }; + return { stdout: [HEALTHY_STATE] }; + } + if (args[0] === "logs") return { exitCode: 0 }; + if (args[0] === "ps") return { stdout: [] }; + return { exitCode: 0 }; + }; +} + +const alwaysReadyHttpClientLayer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status: 200 }))), + ), +); + function setup( workdir: string, opts: { @@ -314,14 +428,19 @@ function setup( isLocal?: boolean; ref?: string; experimental?: boolean; + /** `--debug`. Defaults to `false`. */ + debug?: boolean; remoteSeeds?: Readonly>; yes?: boolean; omitRef?: boolean; resolveFails?: boolean; - running?: boolean; - storageReady?: boolean; - awaitStorageReadyExitCode?: number; execCaptureExitCode?: number; + // Local-reset-only knobs. + route?: (args: ReadonlyArray) => RouteResult; + routeOpts?: DefaultRouteOpts; + replicationSlotCounts?: ReadonlyArray; + replicationSlotQueryFails?: boolean; + failStatement?: { readonly sql: string; readonly code?: string; readonly message: string }; }, ) { if (opts.toml !== undefined) { @@ -337,11 +456,6 @@ function setup( const out = mockOutput({ format: opts.format ?? "text", promptConfirmResponses: opts.confirm }); const conn = mockConnection(opts); const proxy = mockProxy({ execCaptureExitCode: opts.execCaptureExitCode }); - const seam = mockBootstrapSeam({ - storageReady: opts.storageReady, - awaitStorageReadyExitCode: opts.awaitStorageReadyExitCode, - }); - const runningCheck = mockRunningCheckSpawner({ running: opts.running }); const telemetry = mockLegacyTelemetryStateTracked(); const linkedCache = mockLegacyLinkedProjectCacheTracked(); // The local-reset bucket-seed core statically requires the (lazy) Management-API @@ -353,17 +467,39 @@ function setup( omitRef: opts.omitRef, resolveFails: opts.resolveFails, }); + const route = opts.route ?? defaultLocalResetRoute(opts.routeOpts); + const child = mockContainerCliSpawner(route); + // Never actually invoked by the tests in this file — the pg-delta migrations-catalog + // warmup `legacyStartSetupLocalDatabase` reaches on a PG15 recreate (`db-setup.ts`) gates + // on `[experimental.pgdelta] enabled`/`SUPABASE_EXPERIMENTAL_PG_DELTA`, neither of which + // any config here sets — present only to satisfy the effect's widened requirements, same + // as `db push`'s own integration tests (`push.integration.test.ts`). + const edgeRuntime = Layer.succeed(LegacyEdgeRuntimeScript, { + run: () => Effect.succeed({ stdout: '{"version":1}', stderr: "" }), + }); + const pgDeltaSslProbe = Layer.succeed(LegacyPgDeltaSslProbe, { + requireSsl: () => Effect.succeed(false), + requireSslForHost: () => Effect.succeed(false), + }); const layer = Layer.mergeAll( out.layer, conn.layer, proxy.layer, - seam.layer, resolver.layer, mockLegacyCliConfig({ workdir }), BunServices.layer, - runningCheck.layer, - mockRuntimeInfo(), + child.layer, + mockRuntimeInfo({ platform: "linux" }), + mockProcessControl().layer, + alwaysReadyHttpClientLayer, + legacyDockerRunLayer.pipe( + Layer.provide(child.layer), + Layer.provide(mockProcessControl().layer), + ), + edgeRuntime, + pgDeltaSslProbe, + Layer.succeed(LegacyNetworkIdFlag, Option.none()), // The remote-reset confirmation is answered through mockOutput's // `promptConfirmResponses` (the TTY/clack path), so mark stdin a TTY. Stdin is // only referenced by legacyPromptYesNo's non-TTY branch (unreached here) but must @@ -379,7 +515,6 @@ function setup( loadProjectRef: () => Effect.succeed(opts.ref ?? LEGACY_VALID_REF), promptProjectRef: () => Effect.succeed(opts.ref ?? LEGACY_VALID_REF), }), - mockStorageHttp, Layer.succeed(LegacyPlatformApiFactory, { make: LegacyPlatformApi.pipe(Effect.provide(platformApi.layer)), }), @@ -387,1101 +522,1407 @@ function setup( Layer.succeed(LegacyYesFlag, opts.yes ?? false), Layer.succeed(LegacyDnsResolverFlag, "native"), Layer.succeed(LegacyExperimentalFlag, opts.experimental ?? false), + Layer.succeed(LegacyDebugFlag, opts.debug ?? false), telemetry.layer, linkedCache.layer, ); - return { layer, out, conn, proxy, seam, telemetry, linkedCache, resolver }; + return { layer, out, conn, proxy, telemetry, linkedCache, resolver, child }; } const migrationFile = (version: string, body = "create table t ();") => ({ [`supabase/migrations/${version}_test.sql`]: body, }); +const PG14_TOML = 'project_id = "test"\n[db]\nmajor_version = 14\n'; +const FAST_HEALTH_TOML = '[db]\nhealth_timeout = "1s"\n'; + describe("legacy db reset", () => { const tmp = useLegacyTempWorkdir("supabase-db-reset-"); - it.live("resets the local database via the bootstrap seam", () => { - const { layer, out, seam, proxy } = setup(tmp.current, { - toml: 'project_id = "test"\n', - args: ["db", "reset"], - isLocal: true, - running: true, - }); - return Effect.gen(function* () { - yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); - // Native path — no Go delegation. - expect(proxy.calls).toHaveLength(0); - expect(out.stderrText).toContain("Resetting local database..."); - expect(seam.recreateCalls).toEqual([{ version: "", noSeed: false, sqlPaths: [] }]); - // Storage gate checked; with no buckets configured nothing is seeded. - expect(seam.storageChecked).toBe(true); - expect(out.stderrText).toContain("Finished "); - expect(out.stderrText).toContain("on branch "); + describe("local reset — PG15+", () => { + it.live("recreates the container, waits healthy, and runs the setup pipeline", () => { + const { layer, out, child, telemetry } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset", "--local"], + isLocal: true, + }); + return Effect.gen(function* () { + yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Resetting local database..."); + expect(out.stderrText).toContain("Recreating database...\n"); + expect(removedContainers(child.spawned)).toContain(DB_ID); + expect(removedVolumes(child.spawned)).toContain(DB_ID); + expect(createArgs(child.spawned)).not.toBeUndefined(); + // Default config: realtime, storage, and auth are all enabled (PG >= 15 default). + expect(dbSetupJobCalls(child.spawned)).toHaveLength(3); + expect(out.stderrText).toContain("Restarting containers...\n"); + // Satellite restarts (storage/auth/realtime/pooler), then Kong reload. + expect(restartedContainers(child.spawned)).toEqual( + expect.arrayContaining([ + "supabase_storage_test", + "supabase_auth_test", + "supabase_realtime_test", + "supabase_pooler_test", + ]), + ); + expect(kongReloadCalls(child.spawned)).toHaveLength(1); + expect(out.stderrText).toContain("Finished "); + expect(out.stderrText).toContain("on branch "); + // The local-reset composition now lives in the shared + // `legacyResetLocalDatabase` (CLI-2062) — confirm this handler's own + // single `Effect.ensuring` finalizer still fires exactly once through it. + expect(telemetry.flushCount).toBe(1); + }); }); - }); - it.live("fails a local reset when the database is not running", () => { - const { layer, seam } = setup(tmp.current, { - toml: 'project_id = "test"\n', - args: ["db", "reset"], - isLocal: true, - running: false, - }); - return Effect.gen(function* () { - const exit = yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) expect(JSON.stringify(exit.cause)).toContain("is not running."); - expect(seam.recreateCalls).toHaveLength(0); - }); - }); + it.live( + "passes the resolved --version through to the setup pipeline's seed/migrate step", + () => { + const { layer, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { + ...migrationFile("20240101000000", "create table version_one_marker ();"), + ...migrationFile("20240202000000", "create table version_two_marker ();"), + }, + args: ["db", "reset", "--local"], + isLocal: true, + }); + return Effect.gen(function* () { + yield* legacyDbReset({ + ...DEFAULT_FLAGS, + local: true, + version: Option.some("20240101000000"), + }).pipe(Effect.provide(layer)); + // The migration up to (and including) the resolved version IS re-applied through + // the recreated database's own session (positive assertion — proves MigrateAndSeed + // actually ran, not just that the cutoff excluded something)... + expect(conn.execs.some((sql) => sql.includes("create table version_one_marker ()"))).toBe( + true, + ); + // ...but the second migration must not be applied at all. + expect(conn.execs.some((sql) => sql.includes("create table version_two_marker ()"))).toBe( + false, + ); + }); + }, + ); - it.live("proceeds with a local reset when no config file is present", () => { - // Go's `Config.Load` tolerates a missing `config.toml`: `Eject` defaults an empty - // `project_id` to the cwd basename (`pkg/config/config.go:563-570`), so `Validate` - // never sees an empty required field and the CLI proceeds — exactly the mechanism - // the cli-e2e parity suite relies on when it runs `db reset --local` from a project - // with no config.toml. A missing config must not become a hard failure here. - const { layer, seam } = setup(tmp.current, { - args: ["db", "reset"], - isLocal: true, - running: true, - }); - return Effect.gen(function* () { - yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); - expect(seam.recreateCalls).toHaveLength(1); + it.live("reapplies migrations and seeds after a default local reset (PG15)", () => { + const { layer, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { + ...migrationFile("20240101000000", "create table pg15_marker ();"), + "supabase/seed.sql": "insert into pg15_seed_marker values (1);", + }, + args: ["db", "reset", "--local"], + isLocal: true, + }); + return Effect.gen(function* () { + yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(conn.execs.some((sql) => sql.includes("create table pg15_marker ()"))).toBe(true); + expect( + conn.execs.some((sql) => sql.includes("insert into pg15_seed_marker values (1)")), + ).toBe(true); + }); }); - }); - it.live("fails a local reset before the destructive recreate on a malformed config.toml", () => { - // Go's `flags.LoadConfig` (the local target's `LoadConfig`, `db_url.go:77-80`) runs - // full config validation before `reset.Run` reaches `AssertSupabaseDbIsRunning` / - // `resetDatabase` (`internal/db/reset/reset.go:57-61`). A broken config.toml must - // abort before the local database is ever recreated. - const { layer, seam } = setup(tmp.current, { - toml: 'project_id = "unterminated\n', - args: ["db", "reset"], - isLocal: true, - running: true, - }); - return Effect.gen(function* () { - const exit = yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("failed to load config"); - } - expect(seam.recreateCalls).toHaveLength(0); + it.live("skips seeding with --no-seed on a local reset", () => { + const { layer, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { "supabase/seed.sql": "insert into t values (1);" }, + args: ["db", "reset", "--local"], + isLocal: true, + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, local: true, noSeed: true }).pipe( + Effect.provide(layer), + ); + expect(conn.execs.some((sql) => sql.includes("insert into t values (1)"))).toBe(false); + }); }); - }); - it.live( - "fails a local reset on a malformed config.toml even when the database is not running", - () => { - // Pins Go's exact ordering: `flags.LoadConfig` runs in the root `PersistentPreRunE`, - // strictly before `reset.Run` ever calls `AssertSupabaseDbIsRunning` - // (`internal/db/reset/reset.go:57`). So a broken config must surface as a config - // error even when the local database is ALSO not running — the config check must - // win the race, not the "is not running" check. - const { layer, seam } = setup(tmp.current, { - toml: 'project_id = "unterminated\n', - args: ["db", "reset"], + it.live("seeds from --sql-paths overriding config on a local reset", () => { + const { layer, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n\n[db.seed]\nenabled = false\n', + files: { "supabase/custom-seed.sql": "insert into t values (2);" }, + args: ["db", "reset", "--local"], isLocal: true, - running: false, }); return Effect.gen(function* () { - const exit = yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const cause = JSON.stringify(exit.cause); - expect(cause).toContain("failed to load config"); - expect(cause).not.toContain("is not running."); - } - expect(seam.recreateCalls).toHaveLength(0); + yield* legacyDbReset({ + ...DEFAULT_FLAGS, + local: true, + sqlPaths: ["custom-seed.sql"], + }).pipe(Effect.provide(layer)); + expect(conn.execs.some((sql) => sql.includes("insert into t values (2)"))).toBe(true); }); - }, - ); - - it.live("fails a local reset before the destructive recreate on an undecryptable secret", () => { - // Regression: Go's `flags.LoadConfig` decrypts every `encrypted:` secret before - // `reset.Run` recreates the local database, so an undecryptable secret must abort - // before the destructive recreate, not surface later (or never) during bucket - // seeding. - const { layer, seam } = setup(tmp.current, { - toml: 'project_id = "test"\n\n[db.vault]\nmy_secret = "encrypted:anything"\n', - args: ["db", "reset"], - isLocal: true, - running: true, }); - return Effect.gen(function* () { - const exit = yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - // Assert on the stable "failed to parse config:" prefix rather than the exact - // decrypt-failure tail, which depends on whether an ambient `DOTENV_PRIVATE_KEY*` - // is set (missing key vs. a base64/decrypt failure) — either way, the config - // load must fail before the destructive recreate. - if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("failed to parse config:"); - } - expect(seam.recreateCalls).toHaveLength(0); - }); - }); - it.live("fails a local reset before the destructive recreate on an empty project_id", () => { - // Go's `config.Validate` rejects an explicit `project_id = ""` (a present override - // that resolved to empty, unlike an absent field) before the local recreate. - const { layer, seam } = setup(tmp.current, { - toml: 'project_id = ""\n', - args: ["db", "reset"], - isLocal: true, - running: true, - }); - return Effect.gen(function* () { - const exit = yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain( - "Missing required field in config: project_id", - ); - } - expect(seam.recreateCalls).toHaveLength(0); - }); - }); + it.live( + "fails a local reset when the database is not running, before any recreate work", + () => { + const { layer, child } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset", "--local"], + isLocal: true, + routeOpts: { running: false }, + }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) expect(JSON.stringify(exit.cause)).toContain("is not running."); + expect(child.spawned.some((s) => s.args[0] === "container" && s.args[1] === "rm")).toBe( + false, + ); + }); + }, + ); - it.live("seeds buckets after a local reset when storage is ready", () => { - const { layer, seam } = setup(tmp.current, { - toml: 'project_id = "test"\n', - args: ["db", "reset"], - isLocal: true, - running: true, - storageReady: true, - }); - return Effect.gen(function* () { - // No buckets configured → the seed-buckets core short-circuits, but the - // storage gate is still consulted (Go inspects storage before buckets.Run). - yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); - expect(seam.storageChecked).toBe(true); - expect(seam.recreateCalls).toHaveLength(1); - }); - }); + it.live( + "fails a local reset before the destructive recreate on a malformed config.toml", + () => { + const { layer, child } = setup(tmp.current, { + toml: 'project_id = "unterminated\n', + args: ["db", "reset", "--local"], + isLocal: true, + }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("failed to load config"); + } + expect(child.spawned.some((s) => s.args[0] === "container" && s.args[1] === "rm")).toBe( + false, + ); + }); + }, + ); - it.live("fails a local reset before the destructive recreate on an unparseable boolean", () => { - // `SEED_ENABLED=maybe` cannot be resolved by Go's `strconv.ParseBool`, so - // `flags.LoadConfig` aborts on this config before `reset.Run` ever reaches - // `AssertSupabaseDbIsRunning`/`resetDatabase`. Previously this surfaced only much - // later (if at all) via the bucket-seeding core's own reload, AFTER the local - // database had already been recreated — this must now abort up front instead, - // via the pre-recreate `legacyCheckDbToml` gate. - const previous = process.env["SEED_ENABLED"]; - process.env["SEED_ENABLED"] = "maybe"; - const { layer, seam } = setup(tmp.current, { - toml: 'project_id = "test"\n\n[db.seed]\nenabled = "env(SEED_ENABLED)"\n', - args: ["db", "reset"], - isLocal: true, - running: true, - storageReady: true, + it.live("seeds buckets after a local reset when storage is ready", () => { + const { layer, child } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset", "--local"], + isLocal: true, + }); + return Effect.gen(function* () { + // No buckets configured -> the seed-buckets core short-circuits, but the + // storage gate is still consulted (Go inspects storage before buckets.Run). + yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect( + child.spawned.some( + (s) => s.args[0] === "container" && s.args[1] === "inspect" && s.args[2] === STORAGE_ID, + ), + ).toBe(true); + }); }); - return Effect.gen(function* () { - const exit = yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("invalid db.seed.enabled"); - } - expect(seam.recreateCalls).toHaveLength(0); - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (previous === undefined) delete process.env["SEED_ENABLED"]; - else process.env["SEED_ENABLED"] = previous; - }), - ), - ); - }); - it.live( - "finishes a local reset when bucket seeding can't see an env(VAR) value the pre-recreate gate saw", - () => { - // `legacyCheckDbToml` (the pre-recreate gate) resolves `env(VAR)` via - // `legacyLoadProjectEnv`, which mirrors Go's full nested-env walk and sees - // `supabase/.env.development` — a real, Go-valid env source - // (`pkg/config/config.go:1220-1257`; `godotenv.Load` calls `os.Setenv`, so this - // is genuinely ambient env by the time Go itself resolves `env(VAR)`, - // `config.go:1260-1261`). The post-recreate bucket-seed reload instead goes - // through `@supabase/config`'s `loadProjectEnvironment`, which only ever reads - // `supabase/.env`/`.env.local` + ambient env (`packages/config/src/project.ts: - // 209-245) — it can't see `.env.development` at all. So this Go-valid config - // passes the gate and the real recreate, then can't be re-resolved by the - // reload; the reset must still finish (warn-and-skip), not hard-fail after the - // local database has already been dropped and rebuilt. - const { layer, out, seam } = setup(tmp.current, { - toml: 'project_id = "test"\n\n[db.seed]\nenabled = "env(SEED_ENABLED)"\n', - files: { "supabase/.env.development": "SEED_ENABLED=true\n" }, - args: ["db", "reset"], + it.live("skips bucket seeding when storage is absent (any inspect error)", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset", "--local"], isLocal: true, - running: true, - storageReady: true, + routeOpts: { storageMissing: true }, }); return Effect.gen(function* () { yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); - expect(seam.recreateCalls).toHaveLength(1); - expect(out.stderrText).toContain("skipped seeding storage buckets"); expect(out.stderrText).toContain("Finished "); }); - }, - ); - - it.live("uses the detected git branch in the Finished line", () => { - const { layer, out } = setup(tmp.current, { - toml: 'project_id = "test"\n', - args: ["db", "reset"], - isLocal: true, - running: true, }); - // `detectGitBranch` checks `$GITHUB_HEAD_REF` first (matching Go's - // `GetGitBranchOrDefault`). Set it explicitly so the test is deterministic in - // both a plain checkout and a GitHub Actions PR run (where it is preset to the - // PR branch); restore it afterwards. - const previous = process.env["GITHUB_HEAD_REF"]; - process.env["GITHUB_HEAD_REF"] = "feature-x"; - return Effect.gen(function* () { - yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); - // The branch name is wrapped in ANSI (legacyAqua), so assert on the token. - expect(out.stderrText).toContain("on branch "); - expect(out.stderrText).toContain("feature-x"); - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (previous === undefined) delete process.env["GITHUB_HEAD_REF"]; - else process.env["GITHUB_HEAD_REF"] = previous; - }), - ), - ); - }); - it.live("fails a remote reset on a malformed config.toml", () => { - const { layer } = setup(tmp.current, { toml: 'project_id = "unterminated\n' }); - return Effect.gen(function* () { - const exit = yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe( - Effect.provide(layer), - Effect.exit, + it.live("uses the detected git branch in the Finished line", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset", "--local"], + isLocal: true, + }); + const previous = process.env["GITHUB_HEAD_REF"]; + process.env["GITHUB_HEAD_REF"] = "feature-x"; + return Effect.gen(function* () { + yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("on branch "); + expect(out.stderrText).toContain("feature-x"); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) delete process.env["GITHUB_HEAD_REF"]; + else process.env["GITHUB_HEAD_REF"] = previous; + }), + ), ); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - // Config now loads through the Go-parity reader (`legacyCheckDbToml`), so a malformed - // config aborts with Go's `failed to load config` message, same as the other db - // commands (diff/dump/pull/migration). - expect(JSON.stringify(exit.cause)).toContain("failed to load config"); - } }); - }); - it.live("loads a Go-style env() boolean in config for a remote reset", () => { - // Regression: `enabled = "env(VAR)"` must load via Go's env-expansion + ParseBool - // (`legacyCheckDbToml`) instead of the strict @supabase/config loader rejecting it. - const previous = process.env["MIGRATIONS_ENABLED"]; - process.env["MIGRATIONS_ENABLED"] = "true"; - const { layer, out } = setup(tmp.current, { - toml: 'project_id = "test"\n\n[db.migrations]\nenabled = "env(MIGRATIONS_ENABLED)"\n', - files: migrationFile("20240101000000"), - confirm: [true], + it.live("emits a json result for a local reset", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset", "--local"], + isLocal: true, + format: "json", + }); + return Effect.gen(function* () { + yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.data?.["target"]).toBe("local"); + }); }); - return Effect.gen(function* () { - yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); - expect(out.stderrText).toContain("Applying migration 20240101000000_test.sql..."); - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (previous === undefined) delete process.env["MIGRATIONS_ENABLED"]; - else process.env["MIGRATIONS_ENABLED"] = previous; - }), - ), - ); - }); - it.live("emits a json result for a local reset", () => { - const { layer, out } = setup(tmp.current, { - toml: 'project_id = "test"\n', - args: ["db", "reset"], - isLocal: true, - running: true, - format: "json", - }); - return Effect.gen(function* () { - yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); - const success = out.messages.find((m) => m.type === "success"); - expect(success?.data?.["target"]).toBe("local"); + it.live("still flushes telemetry when the recreate itself fails", () => { + const { layer, telemetry } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset", "--local"], + isLocal: true, + route: (args) => { + if (args[0] === "container" && args[1] === "rm") { + return { exitCode: 1, stderr: ["Error: permission denied"] }; + } + return defaultLocalResetRoute()(args); + }, + }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("failed to remove container"); + } + expect(telemetry.flushed).toBe(true); + }); }); }); - it.live("rejects mutually exclusive target flags", () => { - const { layer } = setup(tmp.current, { - toml: 'project_id = "test"\n', - args: ["db", "reset", "--linked", "--local"], - }); - return Effect.gen(function* () { - const exit = yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); + describe("local reset — Kong reload", () => { + it.live("fails the whole command with the exact suggestion when Kong reload fails", () => { + const { layer } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset", "--local"], + isLocal: true, + routeOpts: { kongReloadFails: true }, + }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const error = Cause.squash(exit.cause) as { message: string; suggestion?: string }; + // Byte-matches Go's `DockerExecOnceWithStream` fixed error text (`docker.go:646-648`), + // not the raw exit code. + expect(error.message).toContain("failed to reload kong: error executing command"); + expect(error.suggestion).toContain( + "Local services restarted, but API routes may return 502", + ); + expect(error.suggestion).toContain(`docker restart ${KONG_ID}`); + } + }); }); - }); - it.live("rejects --version together with --last", () => { - const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n' }); - return Effect.gen(function* () { - const exit = yield* legacyDbReset({ - ...DEFAULT_FLAGS, - linked: true, - version: Option.some("20240101000000"), - last: Option.some(1), - }).pipe(Effect.provide(layer), Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) expect(JSON.stringify(exit.cause)).toContain("[last version]"); + it.live("skips the reload without failing when Kong is excluded from the stack", () => { + const { layer, out, child } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset", "--local"], + isLocal: true, + routeOpts: { kongMissing: true }, + }); + return Effect.gen(function* () { + yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Finished "); + expect(kongReloadCalls(child.spawned)).toHaveLength(0); + }); }); - }); - it.live("rejects a non-integer --version", () => { - const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n' }); - return Effect.gen(function* () { - const exit = yield* legacyDbReset({ - ...DEFAULT_FLAGS, - linked: true, - version: Option.some("not-a-number"), - }).pipe(Effect.provide(layer), Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const failure = Cause.findErrorOption(exit.cause); - expect(Option.isSome(failure) && failure.value._tag).toBe( - "LegacyDbResetInvalidVersionError", - ); - // Go's reset.Run returns the bare repair.ErrInvalidVersion (reset.go:35-36) — - // no `failed to parse :` wrapper (that belongs to `migration repair`). - expect(Option.isSome(failure) && failure.value.message).toBe("invalid version number"); - } + it.live("skips the reload without failing when Kong is present but stopped", () => { + const { layer, out, child } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset", "--local"], + isLocal: true, + routeOpts: { kongNotRunning: true }, + }); + return Effect.gen(function* () { + yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Finished "); + expect(kongReloadCalls(child.spawned)).toHaveLength(0); + }); }); - }); - it.live("fails when --version has no matching migration file", () => { - const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n' }); - return Effect.gen(function* () { - const exit = yield* legacyDbReset({ - ...DEFAULT_FLAGS, - linked: true, - version: Option.some("20240101000000"), - }).pipe(Effect.provide(layer), Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain( - "glob supabase/migrations/20240101000000_*.sql: file does not exist", - ); - } + it.live("fails the command when a satellite restart fails", () => { + const { layer } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset", "--local"], + isLocal: true, + routeOpts: { restartFails: ["supabase_storage_test"] }, + }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("failed to restart supabase_storage_test"); + } + }); }); }); - it.live("rejects an out-of-int64-range --version", () => { - // Go's `strconv.Atoi` == `ParseInt(s, 10, 0)`, which rejects magnitudes outside the - // int64 range even though the text is all digits. `INTEGER_PATTERN` alone would have - // accepted this and fallen through to the glob check instead. - const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n' }); - return Effect.gen(function* () { - const exit = yield* legacyDbReset({ - ...DEFAULT_FLAGS, - linked: true, - version: Option.some("99999999999999999999"), - }).pipe(Effect.provide(layer), Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const failure = Cause.findErrorOption(exit.cause); - expect(Option.isSome(failure) && failure.value._tag).toBe( - "LegacyDbResetInvalidVersionError", - ); - expect(Option.isSome(failure) && failure.value.message).toBe("invalid version number"); - } - }); - }); + describe("local reset — PG14", () => { + it.live( + "recreates via the four-statement DROP/CREATE sequence, then initDatabase + RestartDatabase", + () => { + const { layer, out, child, conn } = setup(tmp.current, { + toml: PG14_TOML, + args: ["db", "reset", "--local"], + isLocal: true, + }); + return Effect.gen(function* () { + yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + // recreateDatabase: no container/volume removal at all on this branch. + expect(removedContainers(child.spawned)).toHaveLength(0); + expect( + conn.execs.some((sql) => sql === "DROP DATABASE IF EXISTS postgres WITH (FORCE)"), + ).toBe(true); + expect( + conn.execs.some((sql) => sql === "CREATE DATABASE postgres WITH OWNER postgres"), + ).toBe(true); + expect( + conn.execs.some((sql) => sql === "DROP DATABASE IF EXISTS _supabase WITH (FORCE)"), + ).toBe(true); + expect( + conn.execs.some((sql) => sql === "CREATE DATABASE _supabase WITH OWNER postgres"), + ).toBe(true); + // initDatabase: schema SQL execs directly over the session — no PG15+ one-shot jobs. + expect(dbSetupJobCalls(child.spawned)).toHaveLength(0); + expect(conn.execs.length).toBeGreaterThan(4); + // RestartDatabase: "Restarting containers..." then a real `docker restart` of `db`, + // THEN the satellite restarts + Kong reload (RestartDatabase-then-restartServices). + expect(out.stderrText).toContain("Restarting containers...\n"); + const dbRestartIndex = child.spawned.findIndex( + (s) => s.args[0] === "restart" && s.args[1] === DB_ID, + ); + const kongReloadIndex = child.spawned.findIndex( + (s) => s.args[0] === "exec" && s.args[1] === KONG_ID, + ); + expect(dbRestartIndex).toBeGreaterThanOrEqual(0); + expect(kongReloadIndex).toBeGreaterThan(dbRestartIndex); + }); + }, + ); - it.live("treats an empty --version like no version at all", () => { - // Go's `len(version) > 0` guard (reset.go:34) skips validation entirely for an empty - // --version, so it must fall through to a full reset rather than glob-checking "" or - // rejecting it as an invalid version. - const { layer, out, conn } = setup(tmp.current, { - toml: 'project_id = "test"\n', - confirm: [true], - }); - return Effect.gen(function* () { - yield* legacyDbReset({ - ...DEFAULT_FLAGS, - linked: true, - version: Option.some(""), - }).pipe(Effect.provide(layer)); - expect(out.stderrText).toContain("Resetting remote database..."); - expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(true); + it.live("swallows a disconnect-clients failure when the code is invalid_catalog_name", () => { + const { layer, conn } = setup(tmp.current, { + toml: PG14_TOML, + args: ["db", "reset", "--local"], + isLocal: true, + failStatement: { + sql: "ALTER DATABASE postgres ALLOW_CONNECTIONS false", + code: "3D000", + message: 'database "postgres" does not exist', + }, + }); + return Effect.gen(function* () { + yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + // The reset still completes: the swallowed failure does not abort the recreate. + expect( + conn.execs.some((sql) => sql === "CREATE DATABASE postgres WITH OWNER postgres"), + ).toBe(true); + }); }); - }); - it.live("returns context canceled when the reset prompt is declined", () => { - const { layer, conn } = setup(tmp.current, { - toml: 'project_id = "test"\n', - confirm: [false], + it.live("surfaces a disconnect-clients failure for any other error code", () => { + const { layer } = setup(tmp.current, { + toml: PG14_TOML, + args: ["db", "reset", "--local"], + isLocal: true, + failStatement: { + sql: "ALTER DATABASE postgres ALLOW_CONNECTIONS false", + code: "42501", + message: "permission denied", + }, + }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("failed to disconnect clients"); + } + }); }); - return Effect.gen(function* () { - const exit = yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe( - Effect.provide(layer), - Effect.exit, - ); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) expect(JSON.stringify(exit.cause)).toContain("context canceled"); - expect(conn.execs).toHaveLength(0); + + it.live("swallows a disconnect-clients failure that is not a PgError at all", () => { + // A non-PgError failure (network blip) is swallowed too — only a genuine PgError + // whose code differs from 3D000 surfaces. + const { layer, conn } = setup(tmp.current, { + toml: PG14_TOML, + args: ["db", "reset", "--local"], + isLocal: true, + failStatement: { + sql: "ALTER DATABASE postgres ALLOW_CONNECTIONS false", + message: "connection reset by peer", + }, + }); + return Effect.gen(function* () { + yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + // Swallowed: no PgError code at all -> the reset still completes. + expect( + conn.execs.some((sql) => sql === "CREATE DATABASE postgres WITH OWNER postgres"), + ).toBe(true); + }); }); - }); - it.live("drops schemas and applies migrations + seed on a confirmed remote reset", () => { - const { layer, out, conn, linkedCache } = setup(tmp.current, { - toml: 'project_id = "test"\n', - files: { - ...migrationFile("20240101000000"), - "supabase/seed.sql": "insert into t values (1);", + it.live( + "swallows a disconnect-clients failure carrying a node system errno, not a real SQLSTATE", + () => { + // `legacyToExecError`'s fallback (`legacy-db-connection.sql-pg.layer.ts`) sets `code` + // from `legacyExtractSqlState`, which returns ANY string `code` found in the cause + // chain — including a bare node system errno like `ECONNRESET`/`ETIMEDOUT`, which is + // NOT a Postgres SQLSTATE. Go's `errors.As(err, &pgErr)` never matches a socket error, + // so Go swallows this too — the discriminator must check `legacyIsSqlState(code)` + // before comparing against `3D000`, not just `code !== undefined`. + const { layer, conn } = setup(tmp.current, { + toml: PG14_TOML, + args: ["db", "reset", "--local"], + isLocal: true, + failStatement: { + sql: "ALTER DATABASE postgres ALLOW_CONNECTIONS false", + code: "ECONNRESET", + message: "socket hang up", + }, + }); + return Effect.gen(function* () { + yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + // Swallowed: a node errno is not a SQLSTATE -> the reset still completes. + expect( + conn.execs.some((sql) => sql === "CREATE DATABASE postgres WITH OWNER postgres"), + ).toBe(true); + }); }, - confirm: [true], - }); - return Effect.gen(function* () { - yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); - expect(out.stderrText).toContain("Resetting remote database..."); - // No "Connecting to ... database..." line (Go uses io.Discard). - expect(out.stderrText).not.toContain("Connecting to"); - // Drop block ran, then the migration applied. - expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(true); - expect(out.stderrText).toContain("Applying migration 20240101000000_test.sql..."); - expect(out.stderrText).toContain("Seeding data from supabase/seed.sql..."); - expect(linkedCache.cached).toBe(true); - }); - }); + ); - it.live("fails a remote reset before dropping schemas on an undecryptable secret", () => { - // Regression: the old point-of-use vault decryption ran AFTER `legacyDropUserSchemas`, - // so an undecryptable `encrypted:` secret dropped the schemas before failing. Go runs - // `flags.LoadConfig` (which decrypts every secret) before ResetAll, so the reset must - // abort before any destructive work — matched here by `legacyCheckDbToml` at load time. - const { layer, conn } = setup(tmp.current, { - toml: 'project_id = "test"\n\n[db.vault]\nmy_secret = "encrypted:anything"\n', - confirm: [true], - }); - return Effect.gen(function* () { - const exit = yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe( - Effect.provide(layer), - Effect.exit, - ); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("failed to parse config: missing private key"); - } - // Config load failed before ResetAll → schemas were never dropped. - expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(false); - }); - }); + it.live( + "retries the replication-slot drain on a constant 1-second backoff", + () => { + const { layer, conn } = setup(tmp.current, { + toml: PG14_TOML, + args: ["db", "reset", "--local"], + isLocal: true, + replicationSlotCounts: [2, 1, 0], + }); + return Effect.gen(function* () { + yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + const countCalls = conn.queries.filter((q) => q.sql === COUNT_REPLICATION_SLOTS); + expect(countCalls).toHaveLength(3); + }); + }, + 10_000, + ); - it.live("fails a remote reset before dropping schemas on an empty project_id", () => { - // Go's config.Validate rejects an explicit `project_id = ""` before the reset prompt, so - // the native remote reset must abort before `legacyDropUserSchemas`. - const { layer, conn } = setup(tmp.current, { - toml: 'project_id = ""\n', - confirm: [true], + it.live("fails permanently (no retry) when counting replication slots itself fails", () => { + const { layer, conn } = setup(tmp.current, { + toml: PG14_TOML, + args: ["db", "reset", "--local"], + isLocal: true, + replicationSlotQueryFails: true, + }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("failed to count replication slots"); + } + // A single attempt — the permanent failure never retries. + const countCalls = conn.queries.filter((q) => q.sql === COUNT_REPLICATION_SLOTS); + expect(countCalls).toHaveLength(1); + }); }); - return Effect.gen(function* () { - const exit = yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe( - Effect.provide(layer), - Effect.exit, - ); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain( - "Missing required field in config: project_id", + + it.live( + "exhausts all 10 retries and fails when replication slots never drain", + () => { + const { layer } = setup(tmp.current, { + toml: PG14_TOML, + args: ["db", "reset", "--local"], + isLocal: true, + replicationSlotCounts: [1], + }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("replication slots still active"); + } + }); + }, + 20_000, + ); + + it.live("passes --no-seed and the resolved version to the final MigrateAndSeed step", () => { + const { layer, conn } = setup(tmp.current, { + toml: PG14_TOML, + files: { "supabase/seed.sql": "insert into t values (9);" }, + args: ["db", "reset", "--local"], + isLocal: true, + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, local: true, noSeed: true }).pipe( + Effect.provide(layer), ); - } - expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(false); + expect(conn.execs.some((sql) => sql.includes("insert into t values (9)"))).toBe(false); + }); }); - }); - it.live("auto-confirms a remote reset via SUPABASE_YES set only in the project .env", () => { - // Go's loadNestedEnv sets project-.env keys before the reset prompt reads viper YES, so - // a `SUPABASE_YES` in supabase/.env auto-confirms the destructive prompt (default false). - const { layer, conn } = setup(tmp.current, { - toml: 'project_id = "test"\n', - files: { "supabase/.env": "SUPABASE_YES=true\n" }, - // Deliberately no `confirm` responses — the prompt must be auto-confirmed. - }); - return Effect.gen(function* () { - yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); - expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(true); + it.live("reapplies migrations and seeds after a default local reset (PG14)", () => { + // Positive assertion: proves the final MigrateAndSeed step actually runs and + // re-applies the user's migrations/seed — this step is currently deletable with + // every OTHER PG14 assertion (DROP/CREATE statements, restart ordering, + // disconnect/replication-slot behavior) staying green. + const { layer, conn } = setup(tmp.current, { + toml: PG14_TOML, + files: { + ...migrationFile("20240101000000", "create table pg14_marker ();"), + "supabase/seed.sql": "insert into pg14_seed_marker values (1);", + }, + args: ["db", "reset", "--local"], + isLocal: true, + }); + return Effect.gen(function* () { + yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(conn.execs.some((sql) => sql.includes("create table pg14_marker ()"))).toBe(true); + expect( + conn.execs.some((sql) => sql.includes("insert into pg14_seed_marker values (1)")), + ).toBe(true); + }); }); - }); - it.live("still caches the linked ref when DB-config resolution fails", () => { - // Go's Execute() runs ensureProjectGroupsCached after ExecuteC returns even on - // error (root.go:171-181), and ParseDatabaseConfig sets ProjectRef via - // LoadProjectRef BEFORE the fallible temp-role/connection step — so a failed - // linked resolve must not skip the post-run linked-project cache write. - const { layer, linkedCache } = setup(tmp.current, { - toml: 'project_id = "test"\n', - resolveFails: true, - }); - return Effect.gen(function* () { - const exit = yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe( - Effect.provide(layer), - Effect.exit, - ); - expect(Exit.isFailure(exit)).toBe(true); - expect(linkedCache.cached).toBe(true); - expect(linkedCache.cachedRef).toBe(LEGACY_VALID_REF); - }); - }); + it.live( + "passes the resolved --version cutoff through to the final MigrateAndSeed step (PG14)", + () => { + const { layer, conn } = setup(tmp.current, { + toml: PG14_TOML, + files: { + ...migrationFile("20240101000000", "create table version_one_marker ();"), + ...migrationFile("20240202000000", "create table version_two_marker ();"), + }, + args: ["db", "reset", "--local"], + isLocal: true, + }); + return Effect.gen(function* () { + yield* legacyDbReset({ + ...DEFAULT_FLAGS, + local: true, + version: Option.some("20240101000000"), + }).pipe(Effect.provide(layer)); + // Positive: the migration up to (and including) the resolved version IS re-applied. + expect(conn.execs.some((sql) => sql.includes("create table version_one_marker ()"))).toBe( + true, + ); + // The second migration must not be applied at all. + expect(conn.execs.some((sql) => sql.includes("create table version_two_marker ()"))).toBe( + false, + ); + }); + }, + ); - it.live("resets to a specific version, applying only migrations up to it", () => { - const { layer, out, conn } = setup(tmp.current, { - toml: 'project_id = "test"\n', - files: { - ...migrationFile("20240101000000"), - ...migrationFile("20240202000000"), + it.live( + "does NOT run globals.sql on the PG14 reset path (deliberately different from db start's PG14 path)", + () => { + const { layer, conn } = setup(tmp.current, { + toml: PG14_TOML, + args: ["db", "reset", "--local"], + isLocal: true, + }); + return Effect.gen(function* () { + yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + // Go's reset.go `initDatabase` calls the EXPORTED `InitSchema14` directly — unlike + // `db start`'s own PG14 path, which execs globals.sql first. A fingerprint unique + // to `LEGACY_START_DB_GLOBALS_SQL` (see `templates/db-globals.sql.ts`) must never + // appear in this reset's execs. + expect(conn.execs.some((sql) => sql.includes("CREATE ROLE anon"))).toBe(false); + }); }, - confirm: [true], - }); - return Effect.gen(function* () { - yield* legacyDbReset({ - ...DEFAULT_FLAGS, - linked: true, - version: Option.some("20240101000000"), - }).pipe(Effect.provide(layer)); - expect(out.stderrText).toContain("Resetting remote database to version: 20240101000000"); - expect(out.stderrText).toContain("Applying migration 20240101000000_test.sql..."); - expect(out.stderrText).not.toContain("Applying migration 20240202000000_test.sql..."); - expect(conn).toBeDefined(); - }); - }); + ); - it.live("resolves --last to a version prefix", () => { - const { layer, out } = setup(tmp.current, { - toml: 'project_id = "test"\n', - files: { - ...migrationFile("20240101000000"), - ...migrationFile("20240202000000"), + it.live( + "resolves db.migrations.schema_paths against supabase/ before applying it on an experimental PG14 reset", + () => { + // `legacyRecreateLocalDatabase14` must pass the NORMALIZED `toml.schemaPaths` + // (`supabase/`-prefix-resolved by `legacyCheckDbToml`) into the final + // `legacyMigrateAndSeed` call, not the raw, unresolved config value — the raw + // `["schema.sql"]` pattern would glob-match against the WORKDIR root (where no + // such file exists), failing the whole reset, instead of `supabase/schema.sql` + // (where this test actually places the file). + const { layer, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n[db]\nmajor_version = 14\n[db.migrations]\nschema_paths = ["schema.sql"]\n', + files: { "supabase/schema.sql": "create table schema_paths_marker ();" }, + args: ["db", "reset", "--local"], + isLocal: true, + experimental: true, + }); + return Effect.gen(function* () { + yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect( + conn.execs.some((sql) => sql.includes("create table schema_paths_marker ()")), + ).toBe(true); + }); }, - confirm: [true], - }); - return Effect.gen(function* () { - // last=1 → revert the most recent → reset to version 20240101000000. - yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true, last: Option.some(1) }).pipe( - Effect.provide(layer), - ); - expect(out.stderrText).toContain("Resetting remote database to version: 20240101000000"); - }); + ); }); - it.live("reverts all migrations when --last covers the full history", () => { - const { layer, out } = setup(tmp.current, { - toml: 'project_id = "test"\n', - files: { ...migrationFile("20240101000000"), ...migrationFile("20240202000000") }, - confirm: [true], - }); - return Effect.gen(function* () { - // last=2 with 2 local migrations → revert all → version "-". - yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true, last: Option.some(2) }).pipe( - Effect.provide(layer), - ); - expect(out.stderrText).toContain("Resetting remote database to version: -"); + describe("local reset — health timeouts", () => { + it.live("a container health-check timeout fails the whole recreate", () => { + const { layer } = setup(tmp.current, { + toml: `project_id = "test"\n${FAST_HEALTH_TOML}`, + args: ["db", "reset", "--local"], + isLocal: true, + routeOpts: { neverHealthy: true }, + }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + }); }); }); - it.live("skips seeding with --no-seed", () => { - const { layer, out } = setup(tmp.current, { - toml: 'project_id = "test"\n', - files: { - ...migrationFile("20240101000000"), - "supabase/seed.sql": "insert into t values (1);", - }, - confirm: [true], + describe("remote reset", () => { + it.live("fails a remote reset on a malformed config.toml", () => { + const { layer } = setup(tmp.current, { toml: 'project_id = "unterminated\n' }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe( + Effect.provide(layer), + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + // Config now loads through the Go-parity reader (`legacyCheckDbToml`), so a malformed + // config aborts with Go's `failed to load config` message, same as the other db + // commands (diff/dump/pull/migration). + expect(JSON.stringify(exit.cause)).toContain("failed to load config"); + } + }); }); - return Effect.gen(function* () { - yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true, noSeed: true }).pipe( - Effect.provide(layer), + + it.live("loads a Go-style env() boolean in config for a remote reset", () => { + // Regression: `enabled = "env(VAR)"` must load via Go's env-expansion + ParseBool + // (`legacyCheckDbToml`) instead of the strict @supabase/config loader rejecting it. + const previous = process.env["MIGRATIONS_ENABLED"]; + process.env["MIGRATIONS_ENABLED"] = "true"; + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n\n[db.migrations]\nenabled = "env(MIGRATIONS_ENABLED)"\n', + files: migrationFile("20240101000000"), + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Applying migration 20240101000000_test.sql..."); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) delete process.env["MIGRATIONS_ENABLED"]; + else process.env["MIGRATIONS_ENABLED"] = previous; + }), + ), ); - expect(out.stderrText).not.toContain("Seeding data from"); }); - }); - it.live("delegates an experimental remote reset to the Go binary", () => { - const { layer, proxy } = setup(tmp.current, { - toml: 'project_id = "test"\n', - experimental: true, + it.live("rejects mutually exclusive target flags", () => { + const { layer } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset", "--linked", "--local"], + }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + }); }); - return Effect.gen(function* () { - yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); - expect(proxy.calls).toHaveLength(1); - expect(proxy.calls[0]!.args).toEqual(["db", "reset", "--linked", "--yes=false"]); - expect(proxy.calls[0]!.env).toEqual({ SUPABASE_TELEMETRY_DISABLED: "1" }); + + it.live("rejects --version together with --last", () => { + const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n' }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset({ + ...DEFAULT_FLAGS, + linked: true, + version: Option.some("20240101000000"), + last: Option.some(1), + }).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) expect(JSON.stringify(exit.cause)).toContain("[last version]"); + }); }); - }); - it.live("does not resolve a linked DB connection before delegating an experimental reset", () => { - const { layer, proxy, resolver } = setup(tmp.current, { - toml: 'project_id = "test"\n', - experimental: true, + it.live("rejects a non-integer --version", () => { + const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n' }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset({ + ...DEFAULT_FLAGS, + linked: true, + version: Option.some("not-a-number"), + }).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const failure = Cause.findErrorOption(exit.cause); + expect(Option.isSome(failure) && failure.value._tag).toBe( + "LegacyDbResetInvalidVersionError", + ); + // Go's reset.Run returns the bare repair.ErrInvalidVersion (reset.go:35-36) — + // no `failed to parse :` wrapper (that belongs to `migration repair`). + expect(Option.isSome(failure) && failure.value.message).toBe("invalid version number"); + } + }); + }); + + it.live("fails when --version has no matching migration file", () => { + const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n' }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset({ + ...DEFAULT_FLAGS, + linked: true, + version: Option.some("20240101000000"), + }).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain( + "glob supabase/migrations/20240101000000_*.sql: file does not exist", + ); + } + }); }); - return Effect.gen(function* () { - yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); - expect(proxy.calls).toHaveLength(1); - // The delegated Go child re-runs its own connection resolution (including - // minting/verifying the temp login role) once it starts — the TS wrapper - // must not do that same Management-API work first only to discard it (CLI-1879). - expect(resolver.calls).toBe(0); + + it.live("rejects an out-of-int64-range --version", () => { + // Go's `strconv.Atoi` == `ParseInt(s, 10, 0)`, which rejects magnitudes outside the + // int64 range even though the text is all digits. `INTEGER_PATTERN` alone would have + // accepted this and fallen through to the glob check instead. + const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n' }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset({ + ...DEFAULT_FLAGS, + linked: true, + version: Option.some("99999999999999999999"), + }).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const failure = Cause.findErrorOption(exit.cause); + expect(Option.isSome(failure) && failure.value._tag).toBe( + "LegacyDbResetInvalidVersionError", + ); + expect(Option.isSome(failure) && failure.value.message).toBe("invalid version number"); + } + }); }); - }); - it.live("still caches the linked ref when delegating an experimental reset", () => { - // `linkedRefForCache` is pre-loaded via `LegacyProjectRefResolver.loadProjectRef` - // separately from `resolver.resolve()`, specifically so the post-run - // linked-project-cache finalizer still fires on this path even though - // `resolve()` itself is skipped entirely (CLI-1879). - const { layer, linkedCache } = setup(tmp.current, { - toml: 'project_id = "test"\n', - experimental: true, - ref: LEGACY_VALID_REF, + it.live("treats an empty --version like no version at all", () => { + // Go's `len(version) > 0` guard (reset.go:34) skips validation entirely for an empty + // --version, so it must fall through to a full reset rather than glob-checking "" or + // rejecting it as an invalid version. + const { layer, out, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n', + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbReset({ + ...DEFAULT_FLAGS, + linked: true, + version: Option.some(""), + }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Resetting remote database..."); + expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(true); + }); }); - return Effect.gen(function* () { - yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); - expect(linkedCache.cached).toBe(true); - expect(linkedCache.cachedRef).toBe(LEGACY_VALID_REF); + + it.live("returns context canceled when the reset prompt is declined", () => { + const { layer, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n', + confirm: [false], + }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe( + Effect.provide(layer), + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) expect(JSON.stringify(exit.cause)).toContain("context canceled"); + expect(conn.execs).toHaveLength(0); + }); }); - }); - it.live( - "surfaces a delegated experimental-reset child failure as a LegacyGoChildExitError under json output", - () => { - const { layer } = setup(tmp.current, { + it.live("drops schemas and applies migrations + seed on a confirmed remote reset", () => { + const { layer, out, conn, linkedCache } = setup(tmp.current, { toml: 'project_id = "test"\n', - experimental: true, - format: "json", - execCaptureExitCode: 3, + files: { + ...migrationFile("20240101000000"), + "supabase/seed.sql": "insert into t values (1);", + }, + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Resetting remote database..."); + // No "Connecting to ... database..." line (Go uses io.Discard). + expect(out.stderrText).not.toContain("Connecting to"); + // Drop block ran, then the migration applied. + expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(true); + expect(out.stderrText).toContain("Applying migration 20240101000000_test.sql..."); + expect(out.stderrText).toContain("Seeding data from supabase/seed.sql..."); + expect(linkedCache.cached).toBe(true); + }); + }); + + it.live("fails a remote reset before dropping schemas on an undecryptable secret", () => { + // Regression: the old point-of-use vault decryption ran AFTER `legacyDropUserSchemas`, + // so an undecryptable `encrypted:` secret dropped the schemas before failing. Go runs + // `flags.LoadConfig` (which decrypts every secret) before ResetAll, so the reset must + // abort before any destructive work — matched here by `legacyCheckDbToml` at load time. + const { layer, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n\n[db.vault]\nmy_secret = "encrypted:anything"\n', + confirm: [true], }); return Effect.gen(function* () { - // Under json/stream-json, the delegated path uses `execCapture` (non-text - // branch of `delegateExperimentalReset`) — this must flow through the normal - // Effect failure channel (reachable by `withJsonErrorHandling` at the - // command-wiring layer) instead of an immediate `ProcessControl.exit()` that a - // handler-level test could never observe (CLI-1879). const exit = yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe( Effect.provide(layer), Effect.exit, ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const error = Cause.squash(exit.cause); - expect(error).toBeInstanceOf(LegacyGoChildExitError); - expect((error as LegacyGoChildExitError).exitCode).toBe(3); + expect(JSON.stringify(exit.cause)).toContain( + "failed to parse config: missing private key", + ); } + // Config load failed before ResetAll → schemas were never dropped. + expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(false); }); - }, - ); + }); - it.live( - "propagates the storage-ready check's exact exit code and still flushes telemetry on a local reset", - () => { - // The bootstrap seam's `awaitStorageReady` (the `captureStdout` bootstrap-child - // path) failing non-zero must reach the handler as the exact `LegacyGoChildExitError` - // it fails with, and the handler's own `Effect.ensuring(telemetryState.flush)` - // finalizer must still run despite the typed failure (CLI-1879). - const { layer, telemetry } = setup(tmp.current, { - toml: 'project_id = "test"\n', - args: ["db", "reset"], - isLocal: true, - running: true, - awaitStorageReadyExitCode: 4, + it.live("fails a remote reset before dropping schemas on an empty project_id", () => { + // Go's config.Validate rejects an explicit `project_id = ""` before the reset prompt, so + // the native remote reset must abort before `legacyDropUserSchemas`. + const { layer, conn } = setup(tmp.current, { + toml: 'project_id = ""\n', + confirm: [true], }); return Effect.gen(function* () { - const exit = yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + const exit = yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe( + Effect.provide(layer), + Effect.exit, + ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const error = Cause.squash(exit.cause); - expect(error).toBeInstanceOf(LegacyGoChildExitError); - expect((error as LegacyGoChildExitError).exitCode).toBe(4); + expect(JSON.stringify(exit.cause)).toContain( + "Missing required field in config: project_id", + ); } - expect(telemetry.flushed).toBe(true); + expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(false); }); - }, - ); + }); - it.live("forwards the linked selector to the delegate even for --linked=false", () => { - // Cobra `Changed` semantics: `--linked=false` still selects the linked/remote target in - // the parent, so the delegated argv must carry `--linked` — otherwise the Go child falls - // back to its local default and resets the wrong database. - const { layer, proxy } = setup(tmp.current, { - toml: 'project_id = "test"\n', - experimental: true, - args: ["db", "reset", "--linked=false"], + it.live("auto-confirms a remote reset via SUPABASE_YES set only in the project .env", () => { + // Go's loadNestedEnv sets project-.env keys before the reset prompt reads viper YES, so + // a `SUPABASE_YES` in supabase/.env auto-confirms the destructive prompt (default false). + const { layer, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { "supabase/.env": "SUPABASE_YES=true\n" }, + // Deliberately no `confirm` responses — the prompt must be auto-confirmed. + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(true); + }); }); - return Effect.gen(function* () { - yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: false }).pipe(Effect.provide(layer)); - expect(proxy.calls).toHaveLength(1); - expect(proxy.calls[0]!.args).toEqual(["db", "reset", "--linked", "--yes=false"]); + + it.live("still caches the linked ref when DB-config resolution fails", () => { + // Go's Execute() runs ensureProjectGroupsCached after ExecuteC returns even on + // error (root.go:171-181), and ParseDatabaseConfig sets ProjectRef via + // LoadProjectRef BEFORE the fallible temp-role/connection step — so a failed + // linked resolve must not skip the post-run linked-project cache write. + const { layer, linkedCache } = setup(tmp.current, { + toml: 'project_id = "test"\n', + resolveFails: true, + }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe( + Effect.provide(layer), + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + expect(linkedCache.cached).toBe(true); + expect(linkedCache.cachedRef).toBe(LEGACY_VALID_REF); + }); }); - }); - it.live("forwards --yes=false to the delegate even when SUPABASE_YES is set", () => { - // Explicit `--yes=false` beats `AutomaticEnv` in Go; the delegated child must receive the - // bound false flag so an inherited `SUPABASE_YES=true` doesn't auto-confirm the reset and - // drop the remote schemas the user tried to protect. - const previous = process.env["SUPABASE_YES"]; - process.env["SUPABASE_YES"] = "true"; - const { layer, proxy } = setup(tmp.current, { - toml: 'project_id = "test"\n', - experimental: true, - args: ["db", "reset", "--linked", "--yes=false"], + it.live("resets to a specific version, applying only migrations up to it", () => { + const { layer, out, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { + ...migrationFile("20240101000000"), + ...migrationFile("20240202000000"), + }, + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbReset({ + ...DEFAULT_FLAGS, + linked: true, + version: Option.some("20240101000000"), + }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Resetting remote database to version: 20240101000000"); + expect(out.stderrText).toContain("Applying migration 20240101000000_test.sql..."); + expect(out.stderrText).not.toContain("Applying migration 20240202000000_test.sql..."); + expect(conn).toBeDefined(); + }); }); - return Effect.gen(function* () { - yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); - expect(proxy.calls[0]!.args).toContain("--yes=false"); - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_YES"]; - else process.env["SUPABASE_YES"] = previous; - }), - ), - ); - }); - it.live("forwards --yes=true to the delegate when --yes is set", () => { - const { layer, proxy } = setup(tmp.current, { - toml: 'project_id = "test"\n', - experimental: true, - args: ["db", "reset", "--linked", "--yes"], - yes: true, + it.live("resolves --last to a version prefix", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { + ...migrationFile("20240101000000"), + ...migrationFile("20240202000000"), + }, + confirm: [true], + }); + return Effect.gen(function* () { + // last=1 → revert the most recent → reset to version 20240101000000. + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true, last: Option.some(1) }).pipe( + Effect.provide(layer), + ); + expect(out.stderrText).toContain("Resetting remote database to version: 20240101000000"); + }); }); - return Effect.gen(function* () { - yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); - expect(proxy.calls[0]!.args).toContain("--yes=true"); + + it.live("reverts all migrations when --last covers the full history", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { ...migrationFile("20240101000000"), ...migrationFile("20240202000000") }, + confirm: [true], + }); + return Effect.gen(function* () { + // last=2 with 2 local migrations → revert all → version "-". + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true, last: Option.some(2) }).pipe( + Effect.provide(layer), + ); + expect(out.stderrText).toContain("Resetting remote database to version: -"); + }); }); - }); - it.live( - "takes the experimental delegate path via SUPABASE_EXPERIMENTAL in the project .env", - () => { - // Go loads nested env before reset.Run reads viper EXPERIMENTAL, so the versionless remote - // reset delegates to the Go binary rather than replaying migrations natively. - const previous = process.env["SUPABASE_EXPERIMENTAL"]; - delete process.env["SUPABASE_EXPERIMENTAL"]; - const { layer, proxy, conn } = setup(tmp.current, { + it.live("skips seeding with --no-seed", () => { + const { layer, out } = setup(tmp.current, { toml: 'project_id = "test"\n', - files: { "supabase/.env": "SUPABASE_EXPERIMENTAL=true\n" }, - // No experimental flag / shell env — only the project .env sets it. + files: { + ...migrationFile("20240101000000"), + "supabase/seed.sql": "insert into t values (1);", + }, + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true, noSeed: true }).pipe( + Effect.provide(layer), + ); + expect(out.stderrText).not.toContain("Seeding data from"); + }); + }); + + it.live("delegates an experimental remote reset to the Go binary", () => { + const { layer, proxy } = setup(tmp.current, { + toml: 'project_id = "test"\n', + experimental: true, }); return Effect.gen(function* () { yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); expect(proxy.calls).toHaveLength(1); - // Delegated, so the native remote path never dropped schemas. - expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(false); + expect(proxy.calls[0]!.args).toEqual(["db", "reset", "--linked", "--yes=false"]); + expect(proxy.calls[0]!.env).toEqual({ SUPABASE_TELEMETRY_DISABLED: "1" }); + }); + }); + + it.live( + "does not resolve a linked DB connection before delegating an experimental reset", + () => { + const { layer, proxy, resolver } = setup(tmp.current, { + toml: 'project_id = "test"\n', + experimental: true, + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + expect(proxy.calls).toHaveLength(1); + // The delegated Go child re-runs its own connection resolution (including + // minting/verifying the temp login role) once it starts — the TS wrapper + // must not do that same Management-API work first only to discard it (CLI-1879). + expect(resolver.calls).toBe(0); + }); + }, + ); + + it.live("still caches the linked ref when delegating an experimental reset", () => { + // `linkedRefForCache` is pre-loaded via `LegacyProjectRefResolver.loadProjectRef` + // separately from `resolver.resolve()`, specifically so the post-run + // linked-project-cache finalizer still fires on this path even though + // `resolve()` itself is skipped entirely (CLI-1879). + const { layer, linkedCache } = setup(tmp.current, { + toml: 'project_id = "test"\n', + experimental: true, + ref: LEGACY_VALID_REF, + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + expect(linkedCache.cached).toBe(true); + expect(linkedCache.cachedRef).toBe(LEGACY_VALID_REF); + }); + }); + + it.live( + "surfaces a delegated experimental-reset child failure as a LegacyGoChildExitError under json output", + () => { + const { layer } = setup(tmp.current, { + toml: 'project_id = "test"\n', + experimental: true, + format: "json", + execCaptureExitCode: 3, + }); + return Effect.gen(function* () { + // Under json/stream-json, the delegated path uses `execCapture` (non-text + // branch of `delegateExperimentalReset`) — this must flow through the normal + // Effect failure channel (reachable by `withJsonErrorHandling` at the + // command-wiring layer) instead of an immediate `ProcessControl.exit()` that a + // handler-level test could never observe (CLI-1879). + const exit = yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe( + Effect.provide(layer), + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const error = Cause.squash(exit.cause); + expect(error).toBeInstanceOf(LegacyGoChildExitError); + expect((error as LegacyGoChildExitError).exitCode).toBe(3); + } + }); + }, + ); + + it.live("forwards the linked selector to the delegate even for --linked=false", () => { + // Cobra `Changed` semantics: `--linked=false` still selects the linked/remote target in + // the parent, so the delegated argv must carry `--linked` — otherwise the Go child falls + // back to its local default and resets the wrong database. + const { layer, proxy } = setup(tmp.current, { + toml: 'project_id = "test"\n', + experimental: true, + args: ["db", "reset", "--linked=false"], + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: false }).pipe(Effect.provide(layer)); + expect(proxy.calls).toHaveLength(1); + expect(proxy.calls[0]!.args).toEqual(["db", "reset", "--linked", "--yes=false"]); + }); + }); + + it.live("forwards --yes=false to the delegate even when SUPABASE_YES is set", () => { + // Explicit `--yes=false` beats `AutomaticEnv` in Go; the delegated child must receive the + // bound false flag so an inherited `SUPABASE_YES=true` doesn't auto-confirm the reset and + // drop the remote schemas the user tried to protect. + const previous = process.env["SUPABASE_YES"]; + process.env["SUPABASE_YES"] = "true"; + const { layer, proxy } = setup(tmp.current, { + toml: 'project_id = "test"\n', + experimental: true, + args: ["db", "reset", "--linked", "--yes=false"], + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + expect(proxy.calls[0]!.args).toContain("--yes=false"); }).pipe( Effect.ensuring( Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_EXPERIMENTAL"]; - else process.env["SUPABASE_EXPERIMENTAL"] = previous; + if (previous === undefined) delete process.env["SUPABASE_YES"]; + else process.env["SUPABASE_YES"] = previous; }), ), ); - }, - ); - - it.live("attaches the Go seed-flag conflict suggestion to --no-seed + --sql-paths", () => { - const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n' }); - return Effect.gen(function* () { - const exit = yield* legacyDbReset({ - ...DEFAULT_FLAGS, - noSeed: true, - sqlPaths: ["seed.sql"], - }).pipe(Effect.provide(layer), Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("--no-seed cannot be used with --sql-paths"); - // Go's validateDbResetSeedFlags CmdSuggestion, rendered as a Suggestion: line. - expect(JSON.stringify(exit.cause)).toContain("Use either"); - } }); - }); - it.live("forwards --db-url and --no-seed on an experimental remote db-url reset", () => { - const { layer, proxy, resolver } = setup(tmp.current, { - toml: 'project_id = "test"\n', - experimental: true, - args: ["db", "reset", "--db-url", "postgresql://db.example.com:5432/postgres"], - }); - return Effect.gen(function* () { - yield* legacyDbReset({ - ...DEFAULT_FLAGS, - dbUrl: Option.some("postgresql://db.example.com:5432/postgres"), - noSeed: true, - }).pipe(Effect.provide(layer)); - expect(proxy.calls[0]!.args).toEqual([ - "db", - "reset", - "--db-url", - "postgresql://db.example.com:5432/postgres", - "--no-seed", - "--yes=false", - ]); - // Unlike the `connType === "linked"` branch above, a `--db-url` target still - // resolves a connection before delegating — the pre-delegation skip (CLI-1879) - // is scoped to the linked branch only, not "never call resolve when delegating". - expect(resolver.calls).toBe(1); + it.live("forwards --yes=true to the delegate when --yes is set", () => { + const { layer, proxy } = setup(tmp.current, { + toml: 'project_id = "test"\n', + experimental: true, + args: ["db", "reset", "--linked", "--yes"], + yes: true, + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + expect(proxy.calls[0]!.args).toContain("--yes=true"); + }); }); - }); - it.live("passes --no-seed and the resolved --last version to the recreate seam", () => { - const { layer, seam } = setup(tmp.current, { - toml: 'project_id = "test"\n', - files: { ...migrationFile("20240101000000"), ...migrationFile("20240202000000") }, - args: ["db", "reset", "--local"], - isLocal: true, - running: true, - }); - return Effect.gen(function* () { - // last=1 with 2 local migrations → recreate up to version 20240101000000. - yield* legacyDbReset({ - ...DEFAULT_FLAGS, - local: true, - noSeed: true, - last: Option.some(1), - }).pipe(Effect.provide(layer)); - expect(seam.recreateCalls).toEqual([ - { version: "20240101000000", noSeed: true, sqlPaths: [] }, - ]); - }); - }); + it.live( + "takes the experimental delegate path via SUPABASE_EXPERIMENTAL in the project .env", + () => { + // Go loads nested env before reset.Run reads viper EXPERIMENTAL, so the versionless remote + // reset delegates to the Go binary rather than replaying migrations natively. + const previous = process.env["SUPABASE_EXPERIMENTAL"]; + delete process.env["SUPABASE_EXPERIMENTAL"]; + const { layer, proxy, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { "supabase/.env": "SUPABASE_EXPERIMENTAL=true\n" }, + // No experimental flag / shell env — only the project .env sets it. + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + expect(proxy.calls).toHaveLength(1); + // Delegated, so the native remote path never dropped schemas. + expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(false); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) delete process.env["SUPABASE_EXPERIMENTAL"]; + else process.env["SUPABASE_EXPERIMENTAL"] = previous; + }), + ), + ); + }, + ); - it.live("recreates to a specific --version on a local db-url reset", () => { - const { layer, out, seam } = setup(tmp.current, { - toml: 'project_id = "test"\n', - files: migrationFile("20240101000000"), - args: ["db", "reset", "--db-url", "postgresql://localhost:54322/postgres"], - isLocal: true, - running: true, - }); - return Effect.gen(function* () { - yield* legacyDbReset({ - ...DEFAULT_FLAGS, - dbUrl: Option.some("postgresql://localhost:54322/postgres"), - version: Option.some("20240101000000"), - }).pipe(Effect.provide(layer)); - expect(out.stderrText).toContain("Resetting local database to version: 20240101000000"); - expect(seam.recreateCalls).toEqual([ - { version: "20240101000000", noSeed: false, sqlPaths: [] }, - ]); + it.live("attaches the Go seed-flag conflict suggestion to --no-seed + --sql-paths", () => { + const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n' }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset({ + ...DEFAULT_FLAGS, + noSeed: true, + sqlPaths: ["seed.sql"], + }).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("--no-seed cannot be used with --sql-paths"); + // Go's validateDbResetSeedFlags CmdSuggestion, rendered as a Suggestion: line. + expect(JSON.stringify(exit.cause)).toContain("Use either"); + } + }); }); - }); - it.live("resets a remote --db-url target without loading a remote config override", () => { - const { layer, out, conn } = setup(tmp.current, { - // No config file → embedded defaults (migrations + seed enabled). - files: migrationFile("20240101000000"), - args: ["db", "reset", "--db-url", "postgresql://db.example.com:5432/postgres"], - isLocal: false, - omitRef: true, - confirm: [true], - }); - return Effect.gen(function* () { - yield* legacyDbReset({ - ...DEFAULT_FLAGS, - dbUrl: Option.some("postgresql://db.example.com:5432/postgres"), - }).pipe(Effect.provide(layer)); - expect(out.stderrText).toContain("Resetting remote database..."); - expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(true); + it.live("forwards --db-url and --no-seed on an experimental remote db-url reset", () => { + const { layer, proxy, resolver } = setup(tmp.current, { + toml: 'project_id = "test"\n', + experimental: true, + args: ["db", "reset", "--db-url", "postgresql://db.example.com:5432/postgres"], + }); + return Effect.gen(function* () { + yield* legacyDbReset({ + ...DEFAULT_FLAGS, + dbUrl: Option.some("postgresql://db.example.com:5432/postgres"), + noSeed: true, + }).pipe(Effect.provide(layer)); + expect(proxy.calls[0]!.args).toEqual([ + "db", + "reset", + "--db-url", + "postgresql://db.example.com:5432/postgres", + "--no-seed", + "--yes=false", + ]); + // Unlike the `connType === "linked"` branch above, a `--db-url` target still + // resolves a connection before delegating — the pre-delegation skip (CLI-1879) + // is scoped to the linked branch only, not "never call resolve when delegating". + expect(resolver.calls).toBe(1); + }); }); - }); - it.live("announces a matching [remotes.*] override", () => { - const { layer, out } = setup(tmp.current, { - toml: `project_id = "base"\n\n[remotes.preview]\nproject_id = "${LEGACY_VALID_REF}"\n`, - confirm: [true], - ref: LEGACY_VALID_REF, - }); - return Effect.gen(function* () { - yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); - expect(out.stderrText).toContain("Loading config override: [remotes.preview]"); + it.live("recreates to a specific --version on a local db-url reset", () => { + const { layer, out, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: migrationFile("20240101000000"), + args: ["db", "reset", "--db-url", "postgresql://localhost:54322/postgres"], + isLocal: true, + }); + return Effect.gen(function* () { + yield* legacyDbReset({ + ...DEFAULT_FLAGS, + dbUrl: Option.some("postgresql://localhost:54322/postgres"), + version: Option.some("20240101000000"), + }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Resetting local database to version: 20240101000000"); + expect(conn.execs.some((sql) => sql.includes("insert into"))).toBe(false); + }); }); - }); - it.live("skips migrations and seed when both are disabled in config", () => { - const { layer, out, conn } = setup(tmp.current, { - toml: 'project_id = "test"\n\n[db.migrations]\nenabled = false\n\n[db.seed]\nenabled = false\n', - files: { - ...migrationFile("20240101000000"), - "supabase/seed.sql": "insert into t values (1);", - }, - confirm: [true], - }); - return Effect.gen(function* () { - yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); - // Schemas are still dropped, but nothing is applied or seeded. - expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(true); - expect(out.stderrText).not.toContain("Applying migration"); - expect(out.stderrText).not.toContain("Seeding data from"); + it.live("resets a remote --db-url target without loading a remote config override", () => { + const { layer, out, conn } = setup(tmp.current, { + // No config file → embedded defaults (migrations + seed enabled). + files: migrationFile("20240101000000"), + args: ["db", "reset", "--db-url", "postgresql://db.example.com:5432/postgres"], + isLocal: false, + omitRef: true, + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbReset({ + ...DEFAULT_FLAGS, + dbUrl: Option.some("postgresql://db.example.com:5432/postgres"), + }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Resetting remote database..."); + expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(true); + }); }); - }); - it.live("emits a json result for a confirmed remote reset (--yes)", () => { - const { layer, out } = setup(tmp.current, { - toml: 'project_id = "test"\n', - files: migrationFile("20240101000000"), - format: "json", - yes: true, - }); - return Effect.gen(function* () { - yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); - const success = out.messages.find((m) => m.type === "success"); - expect(success?.data?.["target"]).toBe("remote"); + it.live("announces a matching [remotes.*] override", () => { + const { layer, out } = setup(tmp.current, { + toml: `project_id = "base"\n\n[remotes.preview]\nproject_id = "${LEGACY_VALID_REF}"\n`, + confirm: [true], + ref: LEGACY_VALID_REF, + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Loading config override: [remotes.preview]"); + }); }); - }); - it.live("emits a json result for a confirmed remote reset", () => { - const { layer, out } = setup(tmp.current, { - toml: 'project_id = "test"\n', - files: migrationFile("20240101000000"), - format: "json", - }); - return Effect.gen(function* () { - // json mode is non-interactive → prompt takes the default (false) → cancel. - const exit = yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe( - Effect.provide(layer), - Effect.exit, - ); - // default-false prompt in non-text mode declines → context canceled. - expect(Exit.isFailure(exit)).toBe(true); - expect(out).toBeDefined(); + it.live("skips migrations and seed when both are disabled in config", () => { + const { layer, out, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n\n[db.migrations]\nenabled = false\n\n[db.seed]\nenabled = false\n', + files: { + ...migrationFile("20240101000000"), + "supabase/seed.sql": "insert into t values (1);", + }, + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + // Schemas are still dropped, but nothing is applied or seeded. + expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(true); + expect(out.stderrText).not.toContain("Applying migration"); + expect(out.stderrText).not.toContain("Seeding data from"); + }); }); - }); - it.live("rejects --no-seed together with --sql-paths", () => { - const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n' }); - return Effect.gen(function* () { - const exit = yield* legacyDbReset({ - ...DEFAULT_FLAGS, - linked: true, - noSeed: true, - sqlPaths: ["seed.sql"], - }).pipe(Effect.provide(layer), Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("--no-seed cannot be used with --sql-paths"); - } + it.live("emits a json result for a confirmed remote reset (--yes)", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: migrationFile("20240101000000"), + format: "json", + yes: true, + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.data?.["target"]).toBe("remote"); + }); }); - }); - it.live("rejects an empty --sql-paths value", () => { - const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n' }); - return Effect.gen(function* () { - const exit = yield* legacyDbReset({ - ...DEFAULT_FLAGS, - linked: true, - sqlPaths: [""], - }).pipe(Effect.provide(layer), Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain( - "--sql-paths requires a non-empty path or glob pattern", + it.live("emits a json result for a confirmed remote reset", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: migrationFile("20240101000000"), + format: "json", + }); + return Effect.gen(function* () { + // json mode is non-interactive → prompt takes the default (false) → cancel. + const exit = yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe( + Effect.provide(layer), + Effect.exit, ); - } + // default-false prompt in non-text mode declines → context canceled. + expect(Exit.isFailure(exit)).toBe(true); + expect(out).toBeDefined(); + }); }); - }); - it.live("rejects a negative --last value", () => { - const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n' }); - return Effect.gen(function* () { - const exit = yield* legacyDbReset({ - ...DEFAULT_FLAGS, - linked: true, - last: Option.some(-1), - }).pipe(Effect.provide(layer), Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const cause = JSON.stringify(exit.cause); - expect(cause).toContain("invalid argument"); - expect(cause).toContain("strconv.ParseUint"); - } + it.live("rejects --no-seed together with --sql-paths", () => { + const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n' }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset({ + ...DEFAULT_FLAGS, + linked: true, + noSeed: true, + sqlPaths: ["seed.sql"], + }).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("--no-seed cannot be used with --sql-paths"); + } + }); }); - }); - it.live("seeds an absolute --sql-paths file on a remote reset", () => { - const absSeed = join(tmp.current, "external-seed.sql"); - writeFileSync(absSeed, "insert into t values (3);"); - const { layer, out } = setup(tmp.current, { - toml: 'project_id = "test"\n', - files: migrationFile("20240101000000"), - confirm: [true], - }); - return Effect.gen(function* () { - yield* legacyDbReset({ - ...DEFAULT_FLAGS, - linked: true, - sqlPaths: [absSeed], - }).pipe(Effect.provide(layer)); - // Absolute paths are preserved (not prefixed with supabase/) and seeded. - expect(out.stderrText).toContain(`Seeding data from ${absSeed}...`); + it.live("rejects an empty --sql-paths value", () => { + const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n' }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset({ + ...DEFAULT_FLAGS, + linked: true, + sqlPaths: [""], + }).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain( + "--sql-paths requires a non-empty path or glob pattern", + ); + } + }); }); - }); - it.live("warns and seeds from --sql-paths overriding config on a remote reset", () => { - const { layer, out } = setup(tmp.current, { - // Seed disabled in config — --sql-paths must force-enable it. - toml: 'project_id = "test"\n\n[db.seed]\nenabled = false\n', - files: { - ...migrationFile("20240101000000"), - "supabase/custom-seed.sql": "insert into t values (2);", - }, - confirm: [true], - }); - return Effect.gen(function* () { - yield* legacyDbReset({ - ...DEFAULT_FLAGS, - linked: true, - sqlPaths: ["custom-seed.sql"], - }).pipe(Effect.provide(layer)); - expect(out.stderrText).toContain("--sql-paths overrides [db.seed].sql_paths"); - expect(out.stderrText).toContain("Seeding data from supabase/custom-seed.sql..."); + it.live("rejects a negative --last value", () => { + const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n' }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset({ + ...DEFAULT_FLAGS, + linked: true, + last: Option.some(-1), + }).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const cause = JSON.stringify(exit.cause); + expect(cause).toContain("invalid argument"); + expect(cause).toContain("strconv.ParseUint"); + } + }); }); - }); - it.live("forwards --sql-paths to the recreate seam on a local reset", () => { - const { layer, seam } = setup(tmp.current, { - toml: 'project_id = "test"\n', - args: ["db", "reset", "--local"], - isLocal: true, - running: true, - }); - return Effect.gen(function* () { - yield* legacyDbReset({ - ...DEFAULT_FLAGS, - local: true, - sqlPaths: ["custom-seed.sql", "demo/*.sql"], - }).pipe(Effect.provide(layer)); - expect(seam.recreateCalls).toEqual([ - { version: "", noSeed: false, sqlPaths: ["custom-seed.sql", "demo/*.sql"] }, - ]); + it.live("seeds an absolute --sql-paths file on a remote reset", () => { + const absSeed = join(tmp.current, "external-seed.sql"); + writeFileSync(absSeed, "insert into t values (3);"); + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: migrationFile("20240101000000"), + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbReset({ + ...DEFAULT_FLAGS, + linked: true, + sqlPaths: [absSeed], + }).pipe(Effect.provide(layer)); + // Absolute paths are preserved (not prefixed with supabase/) and seeded. + expect(out.stderrText).toContain(`Seeding data from ${absSeed}...`); + }); }); - }); - it.live("forwards --sql-paths to the Go binary on an experimental remote reset", () => { - const { layer, proxy } = setup(tmp.current, { - toml: 'project_id = "test"\n', - experimental: true, + it.live("warns and seeds from --sql-paths overriding config on a remote reset", () => { + const { layer, out } = setup(tmp.current, { + // Seed disabled in config — --sql-paths must force-enable it. + toml: 'project_id = "test"\n\n[db.seed]\nenabled = false\n', + files: { + ...migrationFile("20240101000000"), + "supabase/custom-seed.sql": "insert into t values (2);", + }, + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbReset({ + ...DEFAULT_FLAGS, + linked: true, + sqlPaths: ["custom-seed.sql"], + }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("--sql-paths overrides [db.seed].sql_paths"); + expect(out.stderrText).toContain("Seeding data from supabase/custom-seed.sql..."); + }); }); - return Effect.gen(function* () { - yield* legacyDbReset({ - ...DEFAULT_FLAGS, - linked: true, - sqlPaths: ["custom-seed.sql"], - }).pipe(Effect.provide(layer)); - expect(proxy.calls[0]!.args).toEqual([ - "db", - "reset", - "--linked", - "--sql-paths", - "custom-seed.sql", - "--yes=false", - ]); + + it.live("forwards --sql-paths to the Go binary on an experimental remote reset", () => { + const { layer, proxy } = setup(tmp.current, { + toml: 'project_id = "test"\n', + experimental: true, + }); + return Effect.gen(function* () { + yield* legacyDbReset({ + ...DEFAULT_FLAGS, + linked: true, + sqlPaths: ["custom-seed.sql"], + }).pipe(Effect.provide(layer)); + expect(proxy.calls[0]!.args).toEqual([ + "db", + "reset", + "--linked", + "--sql-paths", + "custom-seed.sql", + "--yes=false", + ]); + }); }); }); }); diff --git a/apps/cli/src/legacy/commands/db/reset/reset.layers.ts b/apps/cli/src/legacy/commands/db/reset/reset.layers.ts index 7cf648f3fe..65e5ce64c1 100644 --- a/apps/cli/src/legacy/commands/db/reset/reset.layers.ts +++ b/apps/cli/src/legacy/commands/db/reset/reset.layers.ts @@ -9,21 +9,37 @@ import { legacyProjectRefLayer } from "../../../config/legacy-project-ref.layer. import { legacyDbConfigLayer } from "../../../shared/legacy-db-config.layer.ts"; import { legacyDbConnectionLayer } from "../../../shared/legacy-db-connection.layer.ts"; import { legacyDebugLoggerLayer } from "../../../shared/legacy-debug-logger.layer.ts"; +import { legacyDockerRunLayer } from "../../../shared/legacy-docker-run.layer.ts"; +import { legacyEdgeRuntimeScriptLayer } from "../../../shared/legacy-edge-runtime-script.layer.ts"; +import { legacyPgDeltaSslProbeLayer } from "../../../shared/legacy-pgdelta-ssl-probe.layer.ts"; import { stdinLayer } from "../../../../shared/runtime/stdin.layer.ts"; import { legacyIdentityStitchLayer } from "../../../shared/legacy-identity-stitch.ts"; import { legacyLinkedProjectCacheLayer } from "../../../telemetry/legacy-linked-project-cache.layer.ts"; import { legacyTelemetryStateLayer } from "../../../telemetry/legacy-telemetry-state.layer.ts"; -import { legacyDbBootstrapSeamLayer } from "../shared/legacy-db-bootstrap.seam.layer.ts"; /** * Runtime layer for `supabase db reset`. Same composition as `db push` / `db lint`: * the Postgres connection, the db-config resolver, project-ref resolution, and the * linked-project cache, all over the lazy management-API factory so the local / * `--db-url` paths never resolve an access token at layer-build time. `LegacyGoProxy` - * (used to delegate the local / experimental reset paths) is ambient from the root. + * (used to delegate the remaining `--experimental` reset path) is ambient from the + * root. `legacyDockerRunLayer` backs the native local recreate's PG15+ one-shot + * migrate jobs (`legacyStartSetupLocalDatabase`, reused via + * `legacyRecreateLocalDatabase`) — same reasoning as `db start`'s own + * `start.layers.ts`. `legacyEdgeRuntimeScriptLayer`/`legacyPgDeltaSslProbeLayer` back + * that same shared setup pipeline's best-effort pg-delta migrations-catalog warmup + * (`db-setup.ts`'s `legacyTryCacheMigrationsCatalog` call, reachable from `db reset`'s + * PG15 recreate too) — the exact same pair `db start`/`db push` already compose for + * their own calls to that function (`db/start/start.layers.ts`, `push.layers.ts`). + * `LegacyCliConfig`/`ChildProcessSpawner`/`FileSystem`/`Path`/`RuntimeInfo` are + * ambient from the root runtime (`shared/cli/run.ts`). */ const cliConfig = legacyCliConfigLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); const httpClient = legacyHttpClientLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); +const edgeRuntime = legacyEdgeRuntimeScriptLayer.pipe( + Layer.provide(legacyDockerRunLayer), + Layer.provide(cliConfig), +); const credentials = legacyCredentialsLayer.pipe( Layer.provide(cliConfig), Layer.provide(legacyDebugLoggerLayer), @@ -74,7 +90,9 @@ export const legacyDbResetRuntimeLayer = Layer.mergeAll( // `console.ReadLine`); without it a CI/piped remote `db reset` that reaches the // confirmation prompt fails with a missing-service defect instead of the default. stdinLayer, - // Container-recreate / storage-health primitives for the native local reset. - legacyDbBootstrapSeamLayer.pipe(Layer.provide(cliConfig)), + // Backs the native local recreate's PG15+ one-shot migrate jobs. + legacyDockerRunLayer, + edgeRuntime, + legacyPgDeltaSslProbeLayer, commandRuntimeLayer(["db", "reset"]), ); diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.integration.test.ts b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.integration.test.ts index 600a4f4d39..00455e786d 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.integration.test.ts @@ -44,7 +44,6 @@ function mockSeam(paths: Record) { calls.push({ mode, noCache }); return Effect.succeed(paths[mode]); }, - execInherit: () => Effect.succeed(0), ensureLocalDatabaseStarted: () => Effect.void, ensureLocalPostgresImageCurrent: () => Effect.void, // The migrations-catalog source now resolves natively (CLI-1959) via diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.smart-target.ts b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.smart-target.ts index 2a01c96912..86823af931 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.smart-target.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.smart-target.ts @@ -2,11 +2,11 @@ import { Effect, type FileSystem, Option, type Path } from "effect"; import { LegacyDnsResolverFlag, - LegacyNetworkIdFlag, legacyResolveYesWithProjectEnv, } from "../../../../../shared/legacy/global-flags.ts"; import { legacyPromptYesNo } from "../../../../../shared/legacy/legacy-prompt-yes-no.ts"; import { Output } from "../../../../../shared/output/output.service.ts"; +import { legacyResetLocalDatabase } from "../../../../shared/db-bootstrap/reset-local-database.ts"; import { PROJECT_REF_PATTERN } from "../../../../config/legacy-project-ref.service.ts"; import { LegacyDbConfigResolver } from "../../../../shared/legacy-db-config.service.ts"; import { legacyLoadProjectEnv } from "../../../../shared/legacy-db-config.toml-read.ts"; @@ -102,7 +102,6 @@ export const legacyResolveSmartTargetUrl = Effect.fnUntraced(function* ( // project `.env` — must auto-confirm too, not just the flag (CLI-1974). const projectEnv = yield* legacyLoadProjectEnv(fs, path, workdir); const yes = yield* legacyResolveYesWithProjectEnv(projectEnv); - const networkId = yield* LegacyNetworkIdFlag; // Insert "Linked project" between local and custom (Go's choice order) when the // workdir is linked with a valid ref. Go gates this on `LoadProjectRef`, which // validates the ref (`project_ref.go:75`), so an invalid on-disk ref hides the @@ -174,25 +173,17 @@ export const legacyResolveSmartTargetUrl = Effect.fnUntraced(function* ( } if (shouldReset) { // Go runs reset in-process and returns the error (`cmd/db_schema_declarative.go:262-267`). - // `execInherit` (not `LegacyGoProxy.exec`) returns the child's exit code as a - // catchable value rather than exiting the host process — the same - // typed-failure design CLI-1879 gave `LegacyGoProxy.exec` itself, predating - // it here as its own seam. Propagate a failure on a non-zero reset exit. - const seam = yield* LegacyDeclarativeSeam; - // Forward --network-id: Go's in-process reset.Run honors the root viper - // network-id (`apps/cli-go/internal/utils/docker.go:267-271`), so the - // seam-spawned reset must carry it to stay on a custom Docker network. - const code = yield* seam.execInherit([ - "db", - "reset", - "--local", - ...(Option.isSome(networkId) ? ["--network-id", networkId.value] : []), - ]); - if (code !== 0) { - return yield* Effect.fail( - new LegacyDeclarativeApplyError({ message: `database reset failed (exit ${code})` }), - ); - } + // `legacyResetLocalDatabase` now runs the same way — in-process, sharing this + // command's own context — rather than shelling out to a second `supabase-go` child + // (CLI-2062): it resolves `LegacyNetworkIdFlag` itself, so no argv-forwarding is + // needed to stay on a custom Docker network, and a real failure propagates through + // the effect's own failure channel instead of a synthesized exit code. + yield* legacyResetLocalDatabase().pipe( + Effect.mapError( + (error) => + new LegacyDeclarativeApplyError({ message: `database reset failed: ${error.message}` }), + ), + ); } return legacyLocalUrl(local); }); diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/generate/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/schema/declarative/generate/SIDE_EFFECTS.md index bd009a4af3..3e99f70643 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/generate/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/schema/declarative/generate/SIDE_EFFECTS.md @@ -28,7 +28,7 @@ pg-delta catalog (source) against the target database's catalog (target). | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | | `supabase-go db schema declarative __catalog --mode baseline --experimental` (hidden seam) — provisions a shadow Postgres + `start.SetupDatabase`, exports the baseline catalog | always | | Edge-runtime container (`supabase/edge-runtime`) running the pg-delta declarative-export Deno script (host network, deno-cache volume `supabase_edge_runtime_`) | always | -| `supabase-go db reset --local` | smart-mode Local choice when reset is confirmed (or `--reset`) | +| `docker`/`podman` container recreate for the local `db` (+ satellite restarts, Kong reload) — the same primitives `db start`/`db reset` use, via `legacyResetLocalDatabase` | smart-mode Local choice when reset is confirmed (or `--reset`) | ## Environment Variables diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.integration.test.ts b/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.integration.test.ts index 002fc2bc49..389df21455 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.integration.test.ts @@ -6,22 +6,41 @@ import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, Layer, Option } from "effect"; import { stripAnsi } from "../../../../../../../tests/helpers/ansi.ts"; -import { mockOutput, mockStdin, mockTty } from "../../../../../../../tests/helpers/mocks.ts"; +import { + alwaysReadyHttpClientLayer, + defaultLocalResetRoute, + legacyLocalResetCreateArgs, + legacyLocalResetRemovedContainers, + mockContainerCliSpawner, +} from "../../../../../../../tests/helpers/legacy-local-reset.ts"; +import { + mockOutput, + mockProcessControl, + mockRuntimeInfo, + mockStdin, + mockTty, +} from "../../../../../../../tests/helpers/mocks.ts"; import { mockLegacyCliConfig, mockLegacyLinkedProjectCacheTracked, + mockLegacyPlatformApiService, mockLegacyTelemetryStateTracked, useLegacyTempWorkdir, } from "../../../../../../../tests/helpers/legacy-mocks.ts"; import { CliArgs } from "../../../../../../shared/cli/cli-args.service.ts"; import { + LegacyDebugFlag, LegacyDnsResolverFlag, LegacyExperimentalFlag, LegacyNetworkIdFlag, LegacyYesFlag, } from "../../../../../../shared/legacy/global-flags.ts"; import { LegacyGoProxy } from "../../../../../../shared/legacy/go-proxy.service.ts"; +import { LegacyPlatformApi } from "../../../../../auth/legacy-platform-api.service.ts"; +import { LegacyPlatformApiFactory } from "../../../../../auth/legacy-platform-api-factory.service.ts"; +import { legacyDockerRunLayer } from "../../../../../shared/legacy-docker-run.layer.ts"; import { LegacyDbConfigResolver } from "../../../../../shared/legacy-db-config.service.ts"; +import { LegacyDbConnection } from "../../../../../shared/legacy-db-connection.service.ts"; import { type LegacyEdgeRuntimeRunOpts, LegacyEdgeRuntimeScript, @@ -57,7 +76,12 @@ interface SetupOpts { promptSelectResponses?: ReadonlyArray; promptTextResponses?: ReadonlyArray; exportJson?: string; - resetExitCode?: number; + /** + * Makes the local-reset prompt's `legacyResetLocalDatabase` fail immediately + * with `LegacyResetLocalDbNotRunningError` (the local `db` container reports as + * not running) instead of completing a real recreate. + */ + resetShouldFail?: boolean; networkId?: Option.Option; projectId?: Option.Option; exportFailsForMode?: LegacyCatalogMode; @@ -74,9 +98,33 @@ function setup(workdir: string, opts: SetupOpts = {}) { const cache = mockLegacyLinkedProjectCacheTracked(); const seamCalls: LegacyCatalogMode[] = []; const seamExportCalls: Array<{ mode: LegacyCatalogMode; projectRef?: string }> = []; - const execInheritCalls: ReadonlyArray[] = []; const localPostgresImageChecks: Array = []; let ensureStartedCalls = 0; + const platformApi = mockLegacyPlatformApiService({}); + // Backs `legacyResetLocalDatabase`'s real, native container-recreate — reached + // when the smart-target local-reset prompt is confirmed (CLI-2062: it now runs + // in-process instead of shelling out to a second `supabase-go` child). + const child = mockContainerCliSpawner( + defaultLocalResetRoute("test", { running: opts.resetShouldFail !== true }), + ); + const dbExec: string[] = []; + const dbConn = Layer.succeed(LegacyDbConnection, { + connect: () => + Effect.succeed({ + exec: (sql: string) => + Effect.sync(() => { + dbExec.push(sql); + }), + query: (sql: string) => + Effect.sync(() => { + dbExec.push(sql); + return []; + }), + extensionExists: () => Effect.succeed(false), + copyToCsv: () => Effect.succeed(new Uint8Array()), + queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), + }), + }); const seam = Layer.succeed(LegacyDeclarativeSeam, { exportCatalog: ({ mode, projectRef }) => { seamCalls.push(mode); @@ -85,10 +133,6 @@ function setup(workdir: string, opts: SetupOpts = {}) { ? Effect.fail(new LegacyDeclarativeShadowDbError({ message: `export failed for ${mode}` })) : Effect.succeed("supabase/.temp/pgdelta/base.json"); }, - execInherit: (args) => { - execInheritCalls.push(args); - return Effect.succeed(opts.resetExitCode ?? 0); - }, ensureLocalDatabaseStarted: () => Effect.sync(() => { ensureStartedCalls += 1; @@ -147,6 +191,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { edge, resolver, proxy, + dbConn, mockLegacyCliConfig({ workdir, projectId: opts.projectId ?? Option.some("test") }), mockTty({ stdinIsTty: opts.stdinIsTty ?? false, stdoutIsTty: false }), mockStdin(opts.stdinIsTty ?? false), @@ -155,20 +200,39 @@ function setup(workdir: string, opts: SetupOpts = {}) { Layer.succeed(LegacyYesFlag, opts.yes ?? false), Layer.succeed(LegacyNetworkIdFlag, opts.networkId ?? Option.none()), Layer.succeed(LegacyDnsResolverFlag, "native"), + Layer.succeed(LegacyDebugFlag, false), // The remote ref is a non-Supabase host that refuses TLS → no SSL env. Layer.succeed(LegacyPgDeltaSslProbe, { requireSsl: () => Effect.succeed(false), requireSslForHost: () => Effect.succeed(false), }), + // The local-reset bucket-seed core statically requires the (lazy) Management-API + // factory; never invoked on the local reset (projectRef === ""). + Layer.succeed(LegacyPlatformApiFactory, { + make: LegacyPlatformApi.pipe(Effect.provide(platformApi.layer)), + }), BunServices.layer, + // `child.layer` must be listed AFTER `BunServices.layer` — `Layer.mergeAll` + // resolves a duplicate service tag to whichever layer is listed LAST, so this + // mock overrides Bun's real `ChildProcessSpawner` instead of the reverse. + child.layer, + mockRuntimeInfo({ platform: "linux" }), + mockProcessControl().layer, + alwaysReadyHttpClientLayer, + legacyDockerRunLayer.pipe( + Layer.provide(child.layer), + Layer.provide(mockProcessControl().layer), + ), ); return { layer, out, cache, + telemetry, + child, + dbExec, seamCalls, seamExportCalls, - execInheritCalls, edgeCalls, resolverCalls, proxyCalls, @@ -722,21 +786,25 @@ describe("legacy db schema declarative generate integration", () => { }); it.effect("smart mode: propagates a reset failure instead of exiting the process", () => { - // Go runs reset in-process and returns the error; using the non-exiting seam, - // a non-zero reset must fail the effect (so telemetry flush / error handling run) - // rather than process.exit via LegacyGoProxy. + // Go runs reset in-process and returns the error; `legacyResetLocalDatabase` now + // runs the same way (CLI-2062), so its real failure must fail the effect (so + // telemetry flush / error handling run) rather than process.exit via LegacyGoProxy. mkdirSync(join(tmp.current, "supabase", "migrations"), { recursive: true }); writeFileSync(join(tmp.current, "supabase", "migrations", "0001_init.sql"), "select 1;"); const s = setup(tmp.current, { experimental: true, stdinIsTty: true, promptSelectResponses: ["local"], - resetExitCode: 1, + resetShouldFail: true, }); return Effect.gen(function* () { const exit = yield* Effect.exit(legacyDbSchemaDeclarativeGenerate(flags({ reset: true }))); expect(Exit.isFailure(exit)).toBe(true); - expect(failError(exit)).toMatchObject({ message: "database reset failed (exit 1)" }); + expect(failError(exit)).toMatchObject({ + message: "database reset failed: supabase start is not running.", + }); + // Failed before any destructive container work. + expect(legacyLocalResetRemovedContainers(s.child.spawned)).toEqual([]); }).pipe(Effect.provide(s.layer)); }); @@ -803,6 +871,14 @@ describe("legacy db schema declarative generate integration", () => { return Effect.gen(function* () { yield* legacyDbSchemaDeclarativeGenerate(flags()); expect(s.cache.cached).toBe(true); + // This scenario also runs a real in-process local reset + // (`legacyResetLocalDatabase`, CLI-2062) — its own body never touches the + // linked-project cache or telemetry, so the outer command's single + // `Effect.ensuring` finalizer must still fire EXACTLY once each, not + // twice, matching Go's single-process `reset.Run` (no second + // `PersistentPostRun` from a separate child process). + expect(s.cache.cacheCount).toBe(1); + expect(s.telemetry.flushCount).toBe(1); }).pipe(Effect.provide(s.layer)); }, ); @@ -889,6 +965,11 @@ describe("legacy db schema declarative generate integration", () => { // reset must run. No promptConfirmResponses are supplied, so a prompt would throw. mkdirSync(join(tmp.current, "supabase", "migrations"), { recursive: true }); writeFileSync(join(tmp.current, "supabase", "migrations", "0001_init.sql"), "select 1;"); + // `legacyResetLocalDatabase`'s container-recreate resolves its own project id from + // `@supabase/config` (config.toml / real env), independently of the mocked + // `LegacyCliConfig.projectId` — pin it to "test" so the recreated container name + // matches the spawner route's assumption. + writeFileSync(join(tmp.current, "supabase", "config.toml"), 'project_id = "test"\n'); const s = setup(tmp.current, { experimental: true, stdinIsTty: true, @@ -897,15 +978,21 @@ describe("legacy db schema declarative generate integration", () => { }); return Effect.gen(function* () { yield* legacyDbSchemaDeclarativeGenerate(flags()); - expect(s.execInheritCalls).toEqual([["db", "reset", "--local"]]); + // The reset actually ran — recreated the local `db` container in-process + // (CLI-2062: no `supabase-go` child) — proving it's a real effect. + expect(legacyLocalResetRemovedContainers(s.child.spawned)).toContain("supabase_db_test"); + expect(legacyLocalResetCreateArgs(s.child.spawned)).not.toBeUndefined(); + expect(s.out.rawChunks.some((c) => c.text.includes("Resetting local database"))).toBe(true); }).pipe(Effect.provide(s.layer)); }); it.effect("smart mode: forwards --network-id to the local reset", () => { - // Go's in-process reset.Run honors the root viper network-id, so the spawned - // reset must carry `--network-id` to stay on a custom Docker network. + // `legacyResetLocalDatabase` resolves `LegacyNetworkIdFlag` itself from the + // shared context (CLI-2062) — no argv-forwarding needed — so the recreated + // container must land on the custom network directly. mkdirSync(join(tmp.current, "supabase", "migrations"), { recursive: true }); writeFileSync(join(tmp.current, "supabase", "migrations", "0001_init.sql"), "select 1;"); + writeFileSync(join(tmp.current, "supabase", "config.toml"), 'project_id = "test"\n'); const s = setup(tmp.current, { experimental: true, stdinIsTty: true, @@ -915,7 +1002,10 @@ describe("legacy db schema declarative generate integration", () => { }); return Effect.gen(function* () { yield* legacyDbSchemaDeclarativeGenerate(flags()); - expect(s.execInheritCalls).toEqual([["db", "reset", "--local", "--network-id", "my-net"]]); + const createArgs = legacyLocalResetCreateArgs(s.child.spawned); + const networkIndex = createArgs?.indexOf("--network") ?? -1; + expect(networkIndex).toBeGreaterThanOrEqual(0); + expect(createArgs?.[networkIndex + 1]).toBe("my-net"); }).pipe(Effect.provide(s.layer)); }); diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.layers.ts b/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.layers.ts index 6f2429fe57..61eaf96544 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.layers.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.layers.ts @@ -22,7 +22,11 @@ import { legacyDeclarativeSeamLayer } from "../../../shared/legacy-pgdelta.seam. * `runCli`. This layer adds the declarative-specific services: the edge-runtime * pg-delta runner and the Go shadow-database seam, plus the db-config resolver * for `--linked` / `--db-url`. Per the "provide doesn't share to siblings" rule, - * `LegacyCliConfig` is provided to every layer that needs it. + * `LegacyCliConfig` is provided to every layer that needs it. `legacyDockerRunLayer` + * is ALSO exposed directly (not just provided to `edgeRuntime`): the smart-target + * local-reset prompt now calls `legacyResetLocalDatabase` in-process (CLI-2062), + * whose PG15+ recreate reuses the same one-shot migrate jobs `db start`/`db reset` + * back with this same layer (see those commands' own `*.layers.ts`). */ const cliConfig = legacyCliConfigLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); @@ -46,6 +50,7 @@ const seam = legacyDeclarativeSeamLayer.pipe(Layer.provide(cliConfig)); export const legacyDbSchemaDeclarativeGenerateRuntimeLayer = Layer.mergeAll( dbConfig, legacyDbConnectionLayer, + legacyDockerRunLayer, edgeRuntime, legacyPgDeltaSslProbeLayer, seam, diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/sync/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/schema/declarative/sync/SIDE_EFFECTS.md index 4a17e22697..35b3ac0534 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/sync/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/schema/declarative/sync/SIDE_EFFECTS.md @@ -30,7 +30,7 @@ as a new timestamped migration. | `supabase-go db __shadow --mode diff` (seam, unchanged) — shadow Postgres + `SetupDatabase` + apply migrations; the catalog itself is exported natively via edge-runtime (CLI-1959 — no longer the hidden `db schema declarative __catalog --mode migrations` subprocess) | migrations-catalog cache miss only | | `supabase-go db schema declarative __catalog --mode declarative --experimental` (seam) — shadow Postgres + `SetupDatabase` + apply declarative → catalog | always | | Edge-runtime container running the pg-delta diff Deno script, and (on a migrations-catalog cache miss) the pg-delta catalog-export Deno script | always / cache miss | -| `supabase-go db reset --local [--network-id ]` (seam) — only on the failed-apply recovery path; `db reset` is still Go-proxied (`wrapped`), so the reset itself shells out to the bundled binary | TTY only, apply failed, and the user confirms "reset and reapply" | +| `docker`/`podman` container recreate for the local `db` (+ satellite restarts, Kong reload) — the same primitives `db start`/`db reset` use, via `legacyResetLocalDatabase` (CLI-2062: in-process, no `supabase-go` child) — only on the failed-apply recovery path | TTY only, apply failed, and the user confirms "reset and reapply" | ## Environment Variables @@ -79,8 +79,9 @@ are mutually exclusive. - The migration apply is native (connects to the local DB and records migration history). On apply failure a debug bundle is written under `supabase/.temp/pgdelta/debug/` and, in a TTY, a reset-and-reapply is offered - (the reset itself runs the bundled `supabase-go db reset --local`, since - `db reset` is still `wrapped`). + (the reset itself is native too — `legacyResetLocalDatabase`, CLI-2062 — run + in-process, sharing this command's own telemetry/linked-project-cache finalizer + cycle rather than firing a second one from a `supabase-go` child). - **Architecture:** the migrations-catalog diff source resolves natively (CLI-1959): the setup-inputs-folded cache key, the zero-local-migrations → platform-baseline reuse, and the pg-delta catalog export are all native TS; only the shadow-database diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.handler.ts b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.handler.ts index d142ea3b3d..c7f0ac387a 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.handler.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.handler.ts @@ -2,7 +2,6 @@ import { Cause, Clock, Effect, Exit, FileSystem, Option, Path } from "effect"; import { LegacyDnsResolverFlag, - LegacyNetworkIdFlag, legacyResolveExperimentalWithProjectEnv, legacyResolveYesWithProjectEnv, } from "../../../../../../shared/legacy/global-flags.ts"; @@ -10,6 +9,7 @@ import { legacyPromptYesNo } from "../../../../../../shared/legacy/legacy-prompt import { Output } from "../../../../../../shared/output/output.service.ts"; import { Tty } from "../../../../../../shared/runtime/tty.service.ts"; import { LegacyCliConfig } from "../../../../../config/legacy-cli-config.service.ts"; +import { legacyResetLocalDatabase } from "../../../../../shared/db-bootstrap/reset-local-database.ts"; import { legacyBold, legacyRed, legacyYellow } from "../../../../../shared/legacy-colors.ts"; import { LegacyDbConnection } from "../../../../../shared/legacy-db-connection.service.ts"; import { legacyGetHostname } from "../../../../../shared/legacy-hostname.ts"; @@ -90,7 +90,6 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara // read `viper.GetBool("YES")` after `loadNestedEnv`, so the env var must // auto-confirm too, not just the flag (CLI-1974). const yes = yield* legacyResolveYesWithProjectEnv(projectEnv); - const networkId = yield* LegacyNetworkIdFlag; const dnsResolver = yield* LegacyDnsResolverFlag; const seam = yield* LegacyDeclarativeSeam; const linkedProjectCache = yield* LegacyLinkedProjectCache; @@ -390,22 +389,20 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara { defaultValue: false }, ); if (shouldReset) { - // Forward --network-id: Go's in-process reset.Run honors the root viper - // network-id (`apps/cli-go/internal/utils/docker.go:267-271`), so the - // seam-spawned reset must carry it to stay on a custom network. - const code = yield* seam.execInherit([ - "db", - "reset", - "--local", - ...(Option.isSome(networkId) ? ["--network-id", networkId.value] : []), - ]); - if (code !== 0) { - // Go returns `resetErr` here (`apps/cli-go/cmd/db_schema_declarative.go:414-423`), - // surfacing the failure that actually blocked recovery — not the original - // apply error. The seam yields only an exit code, so build the reset error - // from it and use that one value for the message, debug bundle, and return. + // Go runs reset in-process (`cmd/db_schema_declarative.go:414-423`). + // `legacyResetLocalDatabase` now runs the same way — in-process, sharing this + // command's own context — rather than shelling out to a second `supabase-go` + // child (CLI-2062): it resolves `LegacyNetworkIdFlag` itself, so no + // argv-forwarding is needed to stay on a custom network. + const resetExit = yield* legacyResetLocalDatabase().pipe(Effect.exit); + if (Exit.isFailure(resetExit)) { + // Go returns `resetErr` here, surfacing the failure that actually blocked + // recovery — not the original apply error. Build the reset error from the + // real typed failure and use that one value for the message, debug bundle, + // and return. + const resetFailure = resetExit.cause.reasons.find(Cause.isFailReason)?.error; const resetError = new LegacyDeclarativeApplyError({ - message: `database reset failed (exit ${code})`, + message: `database reset failed: ${resetFailure?.message ?? "unknown error"}`, }); yield* output.raw( `${legacyRed(`Database reset also failed: ${resetError.message}`)}\n`, diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.integration.test.ts b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.integration.test.ts index 72b4d43a6d..c4995d51df 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.integration.test.ts @@ -5,20 +5,38 @@ import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, Layer, Option } from "effect"; import { stripAnsi } from "../../../../../../../tests/helpers/ansi.ts"; -import { mockOutput, mockStdin, mockTty } from "../../../../../../../tests/helpers/mocks.ts"; +import { + alwaysReadyHttpClientLayer, + defaultLocalResetRoute, + legacyLocalResetCreateArgs, + legacyLocalResetRemovedContainers, + mockContainerCliSpawner, +} from "../../../../../../../tests/helpers/legacy-local-reset.ts"; +import { + mockOutput, + mockProcessControl, + mockRuntimeInfo, + mockStdin, + mockTty, +} from "../../../../../../../tests/helpers/mocks.ts"; import { mockLegacyCliConfig, mockLegacyLinkedProjectCacheTracked, + mockLegacyPlatformApiService, mockLegacyTelemetryStateTracked, useLegacyTempWorkdir, } from "../../../../../../../tests/helpers/legacy-mocks.ts"; import { CliArgs } from "../../../../../../shared/cli/cli-args.service.ts"; import { + LegacyDebugFlag, LegacyDnsResolverFlag, LegacyExperimentalFlag, LegacyNetworkIdFlag, LegacyYesFlag, } from "../../../../../../shared/legacy/global-flags.ts"; +import { LegacyPlatformApi } from "../../../../../auth/legacy-platform-api.service.ts"; +import { LegacyPlatformApiFactory } from "../../../../../auth/legacy-platform-api-factory.service.ts"; +import { legacyDockerRunLayer } from "../../../../../shared/legacy-docker-run.layer.ts"; import { LegacyDbConfigResolver } from "../../../../../shared/legacy-db-config.service.ts"; import { LegacyDbConnection } from "../../../../../shared/legacy-db-connection.service.ts"; import { @@ -51,7 +69,12 @@ interface SetupOpts { stdinIsTty?: boolean; diffSql?: string; applyFails?: boolean; - resetExitCode?: number; + /** + * Makes the recovery reset's `legacyResetLocalDatabase` fail immediately with + * `LegacyResetLocalDbNotRunningError` (the local `db` container reports as not + * running) instead of completing a real recreate. + */ + resetShouldFail?: boolean; promptConfirmResponses?: ReadonlyArray; promptSelectResponses?: ReadonlyArray; promptTextResponses?: ReadonlyArray; @@ -69,8 +92,14 @@ function setup(workdir: string, opts: SetupOpts = {}) { }); const telemetry = mockLegacyTelemetryStateTracked(); const cache = mockLegacyLinkedProjectCacheTracked(); - const execInheritCalls: ReadonlyArray[] = []; const localPostgresImageChecks: Array = []; + const platformApi = mockLegacyPlatformApiService({}); + // Backs `legacyResetLocalDatabase`'s real, native container-recreate — reached + // when the recovery-reset offer is accepted (CLI-2062: it now runs in-process + // instead of shelling out to a second `supabase-go` child). + const child = mockContainerCliSpawner( + defaultLocalResetRoute("test", { running: opts.resetShouldFail !== true }), + ); // Each catalog export records how many raw chunks had been emitted when it fired, // so tests can assert output ordering relative to the exports (e.g. the bootstrap's // written-to line lands after the declarative warm, before the diff's exports). @@ -88,11 +117,6 @@ function setup(workdir: string, opts: SetupOpts = {}) { exportCatalogCalls.push({ mode, rawChunksAt: out.rawChunks.length }); return `supabase/.temp/pgdelta/${mode}.json`; }), - execInherit: (args) => - Effect.sync(() => { - execInheritCalls.push(args); - return opts.resetExitCode ?? 0; - }), ensureLocalDatabaseStarted: () => Effect.void, ensureLocalPostgresImageCurrent: () => Effect.sync(() => { @@ -209,19 +233,37 @@ function setup(workdir: string, opts: SetupOpts = {}) { opts.networkId === undefined ? Option.none() : Option.some(opts.networkId), ), Layer.succeed(LegacyDnsResolverFlag, "native"), + Layer.succeed(LegacyDebugFlag, false), // Sync diffs against the local DB, which refuses TLS → no SSL env injected. Layer.succeed(LegacyPgDeltaSslProbe, { requireSsl: () => Effect.succeed(false), requireSslForHost: () => Effect.succeed(false), }), + // The local-reset bucket-seed core statically requires the (lazy) Management-API + // factory; never invoked on the local recovery reset (projectRef === ""). + Layer.succeed(LegacyPlatformApiFactory, { + make: LegacyPlatformApi.pipe(Effect.provide(platformApi.layer)), + }), BunServices.layer, + // `child.layer` must be listed AFTER `BunServices.layer` — `Layer.mergeAll` + // resolves a duplicate service tag to whichever layer is listed LAST, so this + // mock overrides Bun's real `ChildProcessSpawner` instead of the reverse. + child.layer, + mockRuntimeInfo({ platform: "linux" }), + mockProcessControl().layer, + alwaysReadyHttpClientLayer, + legacyDockerRunLayer.pipe( + Layer.provide(child.layer), + Layer.provide(mockProcessControl().layer), + ), ); return { layer, out, - execInheritCalls, + child, dbExec, cache, + telemetry, localPostgresImageChecks, exportCatalogCalls, provisionShadowCalls, @@ -819,7 +861,8 @@ describe("legacy db schema declarative sync integration", () => { expect(s.dbExec.some((q) => q.includes("supabase_migrations.schema_migrations"))).toBe( true, ); - expect(s.execInheritCalls).toEqual([]); // no reset on success + // No reset on success — the recovery reset's container-remove never ran. + expect(legacyLocalResetRemovedContainers(s.child.spawned)).toEqual([]); expect(s.out.rawChunks.some((c) => c.text.includes("Migration applied successfully"))).toBe( true, ); @@ -843,29 +886,43 @@ describe("legacy db schema declarative sync integration", () => { }); it.effect( - "apply failure in a TTY offers reset+reapply and delegates reset to the Go binary", + "apply failure in a TTY offers reset+reapply and runs the reset natively in-process", () => { seedDeclarative(tmp.current); + // `legacyResetLocalDatabase`'s container-recreate resolves its own project id + // from `@supabase/config` (config.toml / real env), independently of the + // mocked `LegacyCliConfig.projectId` — pin it to "test" so the recreated + // container name matches the spawner route's assumption. + writeFileSync(join(tmp.current, "supabase", "config.toml"), 'project_id = "test"\n'); const s = setup(tmp.current, { experimental: true, diffSql: "ALTER TABLE a ADD COLUMN b int;\n", applyFails: true, stdinIsTty: true, promptConfirmResponses: [true], // accept the reset offer - resetExitCode: 0, }); return Effect.gen(function* () { yield* legacyDbSchemaDeclarativeSync(flags({ apply: Option.some(true) })); expect(s.out.rawChunks.some((c) => c.text.includes("Migration failed to apply"))).toBe( true, ); - expect(s.execInheritCalls).toEqual([["db", "reset", "--local"]]); + // The recovery reset actually ran — recreated the local `db` container + // (CLI-2062: in-process, not a `supabase-go` child) — proving it's a real + // effect, not just a tracked call. + expect(legacyLocalResetRemovedContainers(s.child.spawned)).toContain("supabase_db_test"); + expect(legacyLocalResetCreateArgs(s.child.spawned)).not.toBeUndefined(); + expect(s.out.rawChunks.some((c) => c.text.includes("Resetting local database"))).toBe(true); expect( s.out.rawChunks.some((c) => c.text.includes("Database reset and all migrations applied successfully"), ), ).toBe(true); expect(existsSync(join(tmp.current, "supabase", ".temp", "pgdelta", "debug"))).toBe(true); + // `legacyResetLocalDatabase`'s own body never touches telemetry — the outer + // `sync` command's single `Effect.ensuring` finalizer must still fire + // EXACTLY once, not twice, matching Go's single-process `reset.Run` (no + // second `PersistentPostRun` from a separate child process) (CLI-2062). + expect(s.telemetry.flushCount).toBe(1); }).pipe(Effect.provide(s.layer)); }, ); @@ -880,44 +937,48 @@ describe("legacy db schema declarative sync integration", () => { applyFails: true, stdinIsTty: true, promptConfirmResponses: [true], // accept the reset offer - resetExitCode: 1, // …and the reset itself fails + resetShouldFail: true, // …and the reset itself fails (local db not running) }); return Effect.gen(function* () { const exit = yield* Effect.exit( legacyDbSchemaDeclarativeSync(flags({ apply: Option.some(true) })), ); expect(Exit.isFailure(exit)).toBe(true); - expect(failError(exit)).toMatchObject({ message: "database reset failed (exit 1)" }); + expect(failError(exit)).toMatchObject({ + message: "database reset failed: supabase start is not running.", + }); expect( s.out.rawChunks.some((c) => - c.text.includes("Database reset also failed: database reset failed (exit 1)"), + c.text.includes( + "Database reset also failed: database reset failed: supabase start is not running.", + ), ), ).toBe(true); + // A real failure, before any destructive container work. + expect(legacyLocalResetRemovedContainers(s.child.spawned)).toEqual([]); }).pipe(Effect.provide(s.layer)); }); it.effect("forwards --network-id to the recovery reset", () => { - // Go's in-process reset.Run honors the root viper network-id, so the - // seam-spawned reset must carry --network-id to stay on a custom network. + // `legacyResetLocalDatabase` resolves `LegacyNetworkIdFlag` itself from the + // shared context (CLI-2062) — no argv-forwarding needed — so the recreated + // container must land on the custom network directly. seedDeclarative(tmp.current); + writeFileSync(join(tmp.current, "supabase", "config.toml"), 'project_id = "test"\n'); const s = setup(tmp.current, { experimental: true, diffSql: "ALTER TABLE a ADD COLUMN b int;\n", applyFails: true, stdinIsTty: true, promptConfirmResponses: [true], // accept the reset offer - resetExitCode: 0, networkId: "my_net", }); return Effect.gen(function* () { yield* legacyDbSchemaDeclarativeSync(flags({ apply: Option.some(true) })); - expect(s.execInheritCalls).toContainEqual([ - "db", - "reset", - "--local", - "--network-id", - "my_net", - ]); + const createArgs = legacyLocalResetCreateArgs(s.child.spawned); + const networkIndex = createArgs?.indexOf("--network") ?? -1; + expect(networkIndex).toBeGreaterThanOrEqual(0); + expect(createArgs?.[networkIndex + 1]).toBe("my_net"); }).pipe(Effect.provide(s.layer)); }); }); diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.layers.ts b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.layers.ts index 0eb4fc8592..a54b47476d 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.layers.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.layers.ts @@ -20,7 +20,12 @@ import { legacyDeclarativeSeamLayer } from "../../../shared/legacy-pgdelta.seam. * smart-generate flow (Go's `runDeclarativeGenerate`), which can target local / * linked / custom — so it needs the db-config resolver too. `Output` / * `LegacyGoProxy` / global flags + the Bun platform come from the legacy root / - * `runCli`. + * `runCli`. `legacyDockerRunLayer` is ALSO exposed directly (not just provided to + * `edgeRuntime`): both the smart-target bootstrap's local-reset prompt and the + * failed-apply recovery reset now call `legacyResetLocalDatabase` in-process + * (CLI-2062), whose PG15+ recreate reuses the same one-shot migrate jobs `db + * start`/`db reset` back with this same layer (see those commands' own + * `*.layers.ts`). */ const cliConfig = legacyCliConfigLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); @@ -43,6 +48,7 @@ const seam = legacyDeclarativeSeamLayer.pipe(Layer.provide(cliConfig)); export const legacyDbSchemaDeclarativeSyncRuntimeLayer = Layer.mergeAll( dbConfig, + legacyDockerRunLayer, edgeRuntime, legacyPgDeltaSslProbeLayer, seam, diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.errors.ts b/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.errors.ts deleted file mode 100644 index 1eac829601..0000000000 --- a/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.errors.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Data } from "effect"; - -/** - * Driving the bundled Go binary's hidden `db __db-bootstrap` seam failed — the - * container-lifecycle primitives that back native `db reset --local` (recreate the - * local Postgres container, apply the initial schema, the storage health gate) are - * not yet ported to TypeScript. Wraps a missing `supabase-go` binary or a non-zero - * seam exit. The seam tees its own progress to stderr, so this message is the - * fallback shown when the subprocess dies without surfacing a more specific Go - * error. `db start` no longer composes this seam at all (CLI-1954): its own - * already-running check is {@link LegacyLocalDbRunningError} from - * `legacy/shared/db-bootstrap/local-db-running.ts`. - */ -export class LegacyDbBootstrapError extends Data.TaggedError("LegacyDbBootstrapError")<{ - readonly message: string; - /** - * Optional actionable hint rendered as a separate "Suggestion:" line, mirroring - * Go's `utils.CmdSuggestion` — set to the Docker-install hint when the container - * runtime's daemon is unreachable (`AssertServiceIsRunning`, `misc.go:148-154`). - */ - readonly suggestion?: string; -}> {} diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.seam.layer.ts b/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.seam.layer.ts deleted file mode 100644 index 3060be9e64..0000000000 --- a/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.seam.layer.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { Effect, Layer, Option, Stream } from "effect"; -import * as ChildProcess from "effect/unstable/process/ChildProcess"; -import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; - -import { - LegacyNetworkIdFlag, - LegacyProfileFlag, - legacyResolveExperimental, -} from "../../../../shared/legacy/global-flags.ts"; -import { resolveBinary } from "../../../../shared/legacy/go-proxy.layer.ts"; -import { LegacyGoChildExitError } from "../../../../shared/legacy/legacy-go-child-exit.error.ts"; -import { ProcessControl } from "../../../../shared/runtime/process-control.service.ts"; -import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; -import { LegacyDbBootstrapError } from "./legacy-db-bootstrap.errors.ts"; -import { LegacyDbBootstrapSeam } from "./legacy-db-bootstrap.seam.service.ts"; - -const seamFailure = (message: string) => new LegacyDbBootstrapError({ message }); - -const decodeChunks = (chunks: ReadonlyArray): string => { - const total = chunks.reduce((size, chunk) => size + chunk.length, 0); - const bytes = new Uint8Array(total); - let offset = 0; - for (const chunk of chunks) { - bytes.set(chunk, offset); - offset += chunk.length; - } - return new TextDecoder().decode(bytes); -}; - -/** - * Real {@link LegacyDbBootstrapSeam}: drives the bundled `supabase-go`'s hidden - * `db __db-bootstrap --mode ` command. The binary is resolved exactly like - * `LegacyGoProxy` (`resolveBinary`); the child's telemetry is disabled and its - * progress teed to stderr, matching the `db __shadow` seam. `--network-id` and a - * flag-selected `--profile` are forwarded so the spawned containers land on the - * same network and the child re-runs Go's identical config resolution. - */ -export const legacyDbBootstrapSeamLayer = Layer.effect( - LegacyDbBootstrapSeam, - Effect.gen(function* () { - const cliConfig = yield* LegacyCliConfig; - const networkId = yield* LegacyNetworkIdFlag; - const profile = yield* LegacyProfileFlag; - const profileArgs = profile !== "supabase" ? ["--profile", profile] : []; - const networkArgs = Option.isSome(networkId) ? ["--network-id", networkId.value] : []; - // Forward `--experimental` (env-aware) so the seam's `SetupLocalDatabase` / - // `apply.MigrateAndSeed` takes Go's experimental schema-file path on a - // versionless reset/start, matching `viper.GetBool("EXPERIMENTAL")`. - const experimental = yield* legacyResolveExperimental; - const experimentalArgs = experimental ? ["--experimental"] : []; - const spawner = yield* ChildProcessSpawner; - const processControl = yield* ProcessControl; - const resolved = resolveBinary(); - - /** - * Run `db __db-bootstrap` with the given mode args. `captureStdout` pipes - * stdout (for the `await-storage` marker); otherwise stdout is inherited. - * Returns the captured stdout (empty when inherited). - */ - const runBootstrap = (modeArgs: ReadonlyArray, captureStdout: boolean) => - Effect.scoped( - Effect.gen(function* () { - if (!("found" in resolved)) { - return yield* Effect.fail( - seamFailure( - "Could not find the supabase-go binary required to bootstrap the local database.", - ), - ); - } - // `runCli` treats `db start`/`db reset` as self-managed and installs no - // global signal handler, and this direct child spawn (unlike - // `LegacyGoProxy.exec`) inherits the foreground process group. Hold - // SIGINT/SIGTERM/SIGHUP with no-op listeners so an interactive Ctrl-C - // during container startup/restore does not default-terminate the TS - // parent out from under the Go child's docker-cleanup path — the parent - // stays blocked on the child's exit and propagates its real status. - // Scoped, so the listeners are removed on completion/failure/interrupt. - yield* processControl.holdSignals(["SIGINT", "SIGTERM", "SIGHUP"]); - const args = [ - "db", - "__db-bootstrap", - ...modeArgs, - ...networkArgs, - ...profileArgs, - ...experimentalArgs, - ]; - const command = ChildProcess.make(resolved.found, args, { - cwd: cliConfig.workdir, - stdin: "inherit", - stdout: captureStdout ? "pipe" : "inherit", - stderr: "inherit", - extendEnv: true, - // Disable the child's telemetry so the hidden seam never records its - // own `cli_command_executed` on top of the user's TS command, matching - // the `db __shadow` seam and the explicit LegacyGoProxy delegates. - env: { SUPABASE_TELEMETRY_DISABLED: "1" }, - detached: false, - }); - if (!captureStdout) { - const exitCode = yield* spawner - .exitCode(command) - .pipe(Effect.mapError(() => seamFailure("failed to run supabase-go."))); - if (exitCode !== 0) { - // `LegacyGoChildExitError` (not `seamFailure`/`processControl.exit`) so the - // handler's finalizers — `Effect.ensuring(telemetryState.flush)` + the legacy - // command instrumentation — still run (an immediate `process.exit` would skip - // them), AND the child's exact exit code (e.g. 130 after Ctrl-C cleanup) reaches - // `runCli`'s `processControl.exit()` instead of collapsing to a generic 1. The - // child's detailed failure is already on the inherited stderr, so `runCli` - // special-cases this error class to suppress its own normally-would-print - // generic stderr line — Go itself never prints a second line here. CLI-1879. - return yield* Effect.fail( - new LegacyGoChildExitError({ - exitCode, - message: `failed to bootstrap the local database: exit ${exitCode}`, - }), - ); - } - return ""; - } - const handle = yield* spawner - .spawn(command) - .pipe(Effect.mapError(() => seamFailure("failed to run supabase-go."))); - const chunks: Array = []; - yield* Stream.runForEach(handle.stdout, (chunk) => - Effect.sync(() => { - chunks.push(chunk); - }), - ).pipe(Effect.mapError(() => seamFailure("failed to bootstrap the local database."))); - const exitCode = yield* handle.exitCode.pipe( - Effect.mapError(() => seamFailure("failed to bootstrap the local database.")), - ); - if (exitCode !== 0) { - // See the `!captureStdout` branch above for why `LegacyGoChildExitError` - // replaces `seamFailure` here — same exact-code + finalizer + no-duplicate-line - // reasoning (CLI-1879). - return yield* Effect.fail( - new LegacyGoChildExitError({ - exitCode, - message: `failed to bootstrap the local database: exit ${exitCode}`, - }), - ); - } - return decodeChunks(chunks); - }), - ); - - return LegacyDbBootstrapSeam.of({ - recreateDatabase: ({ version, noSeed, sqlPaths }) => - runBootstrap( - [ - "--mode", - "recreate", - ...(version !== "" ? ["--version", version] : []), - ...(noSeed ? ["--no-seed"] : []), - ...sqlPaths.flatMap((p) => ["--sql-paths", p]), - ], - false, - ).pipe(Effect.asVoid), - awaitStorageReady: () => - runBootstrap(["--mode", "await-storage"], true).pipe( - Effect.map((stdout) => stdout.trim() === "ready"), - ), - }); - }), -); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.seam.service.ts b/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.seam.service.ts deleted file mode 100644 index 3f5a08dcee..0000000000 --- a/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.seam.service.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { Context, type Effect } from "effect"; - -import type { LegacyGoChildExitError } from "../../../../shared/legacy/legacy-go-child-exit.error.ts"; -import type { LegacyDbBootstrapError } from "./legacy-db-bootstrap.errors.ts"; - -/** - * Seam over the bundled Go binary's hidden `db __db-bootstrap` command, exposing - * the container-bootstrap primitives that native `db reset --local` still needs - * but that are not ported to TypeScript: the database container recreate flow and - * the storage health gate before bucket seeding. The TS handlers orchestrate - * everything else (user-facing messages, version resolution, bucket seeding, the - * git-branch line, telemetry, and `--output-format` shaping); only the Docker - * lifecycle lives behind here. - * - * `db start`'s own container bootstrap (`start.StartDatabase`) was removed from - * this seam by CLI-1954 — it is now a fully native TS implementation - * (`commands/db/start/start.handler.ts`), reusing `commands/start/`'s already-ported - * container-bootstrap primitives instead of shelling out to the Go binary. The - * local-stack "is running?" probe (`legacyIsLocalDbRunning`) was already a native - * TS implementation before CLI-1954 — that same change also hoisted it out of this - * seam into `legacy/shared/db-bootstrap/local-db-running.ts`, since it never shelled - * out to Go and is shared by both `db start` and `db reset`. - * - * Mirrors {@link LegacyDeclarativeSeam} (`db __shadow`): each method shells out to - * the same resolved `supabase-go`, with the child's telemetry disabled so the - * hidden seam never double-counts the user's command, and its progress teed to - * stderr. - */ -interface LegacyDbBootstrapSeamShape { - /** - * The PG14/PG15 container-recreate half of local `db reset` - * (`reset.RecreateLocalDatabase`): recreate the db container/volume, init schema, - * migrate + seed up to `version`, restart the satellite containers - * (storage/auth/realtime/pooler), and reload Kong so its nginx re-resolves - * the restarted containers' addresses — otherwise routes to a container that - * moved keep returning 502 after the reset succeeds (issue #6016). The - * caller has already printed `Resetting local database…`; the seam tees the - * remaining progress (`Recreating database...`, `Restarting containers...`) to - * stderr. `version` is the resolved migration version ("" for all migrations); - * `noSeed` disables the seed and `sqlPaths` overrides `[db.seed].sql_paths` - * inside the recreate's MigrateAndSeed, mirroring the `db reset` - * `--no-seed` / `--sql-paths` handling (`cmd/db.go` `dbResetCmd`). - */ - readonly recreateDatabase: (opts: { - readonly version: string; - readonly noSeed: boolean; - readonly sqlPaths: ReadonlyArray; - }) => Effect.Effect; - /** - * The storage health gate local `db reset` runs before seeding buckets - * (`reset.AwaitStorageReady`): if the storage container exists but is unhealthy, - * wait up to 30s for it. Resolves `true` when the storage container exists (so - * the caller should run the ported bucket seeding) and `false` when it does not - * — matching Go, which silently skips buckets when storage is absent. - */ - readonly awaitStorageReady: () => Effect.Effect< - boolean, - LegacyDbBootstrapError | LegacyGoChildExitError - >; -} - -export class LegacyDbBootstrapSeam extends Context.Service< - LegacyDbBootstrapSeam, - LegacyDbBootstrapSeamShape ->()("supabase/legacy/DbBootstrapSeam") {} diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts index 9c3923ed0a..9e97f7105e 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts @@ -125,31 +125,6 @@ export const legacyDeclarativeSeamLayer = Layer.effect( return new TextDecoder().decode(bytes).trim(); }), ), - execInherit: (args) => - Effect.gen(function* () { - if (!("found" in resolved)) { - return yield* Effect.fail( - new LegacyDeclarativeShadowDbError({ - message: "Could not find the supabase-go binary.", - }), - ); - } - const command = ChildProcess.make(resolved.found, args, { - cwd: cliConfig.workdir, - stdin: "inherit", - stdout: "inherit", - stderr: "inherit", - extendEnv: true, - detached: false, - }); - return yield* spawner - .exitCode(command) - .pipe( - Effect.mapError( - () => new LegacyDeclarativeShadowDbError({ message: "failed to run supabase-go." }), - ), - ); - }), ensureLocalDatabaseStarted: () => Effect.scoped( Effect.gen(function* () { @@ -510,8 +485,8 @@ export const legacyDeclarativeSeamLayer = Layer.effect( }), ); -// Intentionally NOT `LegacyGoChildExitError` (contrast `legacy-db-bootstrap.seam.layer.ts`, -// fixed under CLI-1879): this seam's failure is a TS-authored domain summary over noisy +// Intentionally NOT `LegacyGoChildExitError` (contrast the now-removed `db __db-bootstrap` +// seam, fixed under CLI-1879): this seam's failure is a TS-authored domain summary over noisy // docker/pgdelta child stderr, not a passthrough of a real Go-CLI child the user invoked // directly — Go itself wraps every shadow-DB failure into a generic error that `cmd/root.go`'s // `recoverAndExit` exits `1` for, so propagating THIS child's exact exit code would itself diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.service.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.service.ts index 1662d85696..4f5409c3a6 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.service.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.service.ts @@ -69,20 +69,6 @@ interface LegacyDeclarativeSeamShape { */ readonly projectRef?: string; }) => Effect.Effect; - /** - * Runs the bundled Go binary with the given args, inheriting stdio (so the - * user sees its output) and returning its exit code — without exiting the - * host process. Used for the sync apply-failure recovery, which shells out - * to the Go binary's own `db reset --local` (`declarative.smart-target.ts`) - * rather than calling the native TS `legacyDbReset` handler in-process — - * `db reset` itself is `ported`, but its handler isn't yet structured to be - * invoked from other TS commands rather than the CLI's own dispatch. Known, - * documented scope-leak (not a porting-status gap): two live `db reset` - * implementations remain until `legacyDbReset` is made in-process-callable. - */ - readonly execInherit: ( - args: ReadonlyArray, - ) => Effect.Effect; /** * Go's `ensureLocalDatabaseStarted` for the `--local` declarative paths * (`apps/cli-go/cmd/db_schema_declarative.go:190,249,291`): inspects the local diff --git a/apps/cli/src/legacy/commands/db/start/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/start/SIDE_EFFECTS.md index 7970524470..b71dc6f63a 100644 --- a/apps/cli/src/legacy/commands/db/start/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/start/SIDE_EFFECTS.md @@ -2,19 +2,18 @@ Fully native TypeScript port of `apps/cli-go/internal/db/start/start.go`'s `Run` + `StartDatabase` (CLI-1954 removed the last Go delegation — the hidden `db __db-bootstrap ---mode start` case no longer exists; that command still exists for `db reset --local`'s -`--mode recreate`/`--mode await-storage`, see the "Notes" section). This is `db start`, +--mode start` case no longer exists; CLI-1955 removed the REST of that hidden command too +— see `db reset --local`'s own `SIDE_EFFECTS.md`). This is `db start`, **not** the top-level `supabase start`: no status table, no `cli_stack_started` event, no `Finished` line, no `--exclude`, no `--ignore-health-check`. The handler validates config, checks whether the local Postgres container is already running (`legacyIsLocalDbRunning` — a native `docker container inspect`, hoisted to `legacy/shared/db-bootstrap/local-db-running.ts` and shared with `db reset --local`'s -own running-check; `db start` composes no `LegacyDbBootstrapSeam` at all anymore — that -seam still exists only for `db reset --local`'s own, still-Go-delegated -`recreateDatabase`/`awaitStorageReady` methods, see CLI-1955), and otherwise natively -brings up the container itself, reusing `legacy/shared/db-bootstrap/`'s container-bootstrap -primitives (the same ones `supabase start` uses for its own Postgres bring-up): +own running-check), and otherwise natively brings up the container itself, reusing +`legacy/shared/db-bootstrap/`'s container-bootstrap primitives (the same ones `supabase +start` uses for its own Postgres bring-up, and `db reset --local`'s own recreate +composition reuses too — see that command's `SIDE_EFFECTS.md`): 1. Ensure the Docker network exists (`--network-id` override or `supabase_network_`). 2. Probe whether the Postgres data volume (`supabase_db_`) already exists — @@ -111,25 +110,25 @@ native container command in this codebase — never `supabase-go`. ## Environment Variables -| Variable | Purpose | Required? | -| -------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | -| `SUPABASE_PROJECT_ID` | overrides the local container id (`utils.DbId`) | no | -| `SUPABASE_DB_PORT` | overrides `db.port` (the published host port) | no | -| `SUPABASE_DB_MAJOR_VERSION` | overrides `db.major_version` (image selection, schema branch) | no | -| `SUPABASE_DB_HEALTH_TIMEOUT` | overrides `db.health_timeout` | no | -| `SUPABASE_DB_SETTINGS_*` | overrides individual `[db.settings]` fields | no | -| `SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION` | overrides `experimental.orioledb_version` (image + env) | no | -| `SUPABASE_EXPERIMENTAL_S3_{HOST,REGION,ACCESS_KEY,SECRET_KEY}` | OrioleDB S3 env overrides | no | -| `SUPABASE_REALTIME_ENABLED` | gates the fresh-volume realtime migrate job | no | -| `SUPABASE_REALTIME_IP_VERSION` / `_MAX_HEADER_LENGTH` | realtime migrate job env overrides | no | -| `SUPABASE_STORAGE_ENABLED` | gates the fresh-volume storage migrate job | no | -| `SUPABASE_STORAGE_FILE_SIZE_LIMIT` | storage migrate job env override | no | -| `SUPABASE_AUTH_ENABLED` | gates the fresh-volume auth migrate job | no | -| `SUPABASE_AUTH_EXTERNAL_URL` / `SUPABASE_AUTH_SITE_URL` | auth migrate job env overrides | no | -| `SUPABASE_AUTH_JWT_EXPIRY` | Postgres's `JWT_EXP` env / signing | no | -| `SUPABASE_EXPERIMENTAL` (or `--experimental`) | fresh volume + no pg-delta: applies `db.migrations.schema_paths` files instead of `migrations/*.sql` | no | -| `SUPABASE_EXPERIMENTAL_PG_DELTA` | enables the post-`MigrateAndSeed` migrations-catalog cache warmup when `[experimental.pgdelta].enabled` is unset | no | -| `DOCKER_HOST` / `DOCKER_CONTEXT` / `DOCKER_TLS_VERIFY` / `DOCKER_CERT_PATH` / `DOCKER_API_VERSION` | Read (ambient shell OR a project `.env`/`.env.`/`.env.local` file — matching Go's `godotenv.Load`, which installs these into the process environment before any Docker work) to pick the Docker daemon this whole command talks to | no | +| Variable | Purpose | Required? | +| -------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | +| `SUPABASE_PROJECT_ID` | overrides the local container id (`utils.DbId`) | no | +| `SUPABASE_DB_PORT` | overrides `db.port` (the published host port) | no | +| `SUPABASE_DB_MAJOR_VERSION` | overrides `db.major_version` (image selection, schema branch) | no | +| `SUPABASE_DB_HEALTH_TIMEOUT` | overrides `db.health_timeout` | no | +| `SUPABASE_DB_SETTINGS_*` | overrides individual `[db.settings]` fields | no | +| `SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION` | overrides `experimental.orioledb_version` (image + env) | no | +| `SUPABASE_EXPERIMENTAL_S3_{HOST,REGION,ACCESS_KEY,SECRET_KEY}` | OrioleDB S3 env overrides | no | +| `SUPABASE_REALTIME_ENABLED` | gates the fresh-volume realtime migrate job | no | +| `SUPABASE_REALTIME_IP_VERSION` / `_MAX_HEADER_LENGTH` | realtime migrate job env overrides | no | +| `SUPABASE_STORAGE_ENABLED` | gates the fresh-volume storage migrate job | no | +| `SUPABASE_STORAGE_FILE_SIZE_LIMIT` | storage migrate job env override | no | +| `SUPABASE_AUTH_ENABLED` | gates the fresh-volume auth migrate job | no | +| `SUPABASE_AUTH_EXTERNAL_URL` / `SUPABASE_AUTH_SITE_URL` | auth migrate job env overrides | no | +| `SUPABASE_AUTH_JWT_EXPIRY` | Postgres's `JWT_EXP` env / signing | no | +| `SUPABASE_EXPERIMENTAL` (or `--experimental`) | fresh volume + no pg-delta: applies `db.migrations.schema_paths` files instead of `migrations/*.sql` | no | +| `SUPABASE_EXPERIMENTAL_PG_DELTA` | enables the post-`MigrateAndSeed` migrations-catalog cache warmup when `[experimental.pgdelta].enabled` is unset | no | +| `DOCKER_HOST` / `DOCKER_CONTEXT` / `DOCKER_TLS_VERIFY` / `DOCKER_CERT_PATH` / `DOCKER_API_VERSION` / `DOCKER_CONFIG` | Read (ambient shell OR a project `.env`/`.env.`/`.env.local` file — matching Go's `godotenv.Load`, which installs these into the process environment before any Docker work) to pick the Docker daemon this whole command talks to | no | `--network-id` (a global CLI flag, not an environment variable — `shared/legacy/global-flags.ts`) forces every created container/network onto that Docker network instead of the generated @@ -184,6 +183,7 @@ Same result object as the terminal `result` event; progress on stderr. `db.health_timeout`. - No `cli_stack_started` telemetry — that event belongs to `supabase start`, not `db start`. The only event is the standard `cli_command_executed`. -- `db reset --local` (a different command) still delegates its container-recreate flow to - the bundled Go binary's hidden `db __db-bootstrap --mode recreate` seam — that is - CLI-1955's scope, not this one. +- `db reset --local` (a different command) is ALSO fully native now (CLI-1955) — it + reuses this same `legacy/shared/db-bootstrap/` primitive set, but through its own + composition (`legacy/shared/db-bootstrap/recreate-local-database.ts`), not through + `legacyStartDatabase`/this command's own handler — see that command's `SIDE_EFFECTS.md`. diff --git a/apps/cli/src/legacy/commands/db/start/start.handler.ts b/apps/cli/src/legacy/commands/db/start/start.handler.ts index 7b81263723..866993c135 100644 --- a/apps/cli/src/legacy/commands/db/start/start.handler.ts +++ b/apps/cli/src/legacy/commands/db/start/start.handler.ts @@ -60,7 +60,7 @@ import { legacyEnsureImagesCached } from "../../../shared/db-bootstrap/image-pre import { legacyIsLocalDbRunning } from "../../../shared/db-bootstrap/local-db-running.ts"; import { legacyRollbackStart } from "../../../shared/db-bootstrap/rollback.ts"; import { legacyStartDatabase } from "../../../shared/db-bootstrap/start-database.ts"; -import type { LegacyStartContainerOpts } from "../../../shared/db-bootstrap/container-lifecycle.ts"; +import type { LegacyContainerOpts } from "../../../shared/db-bootstrap/container-lifecycle.ts"; import type { LegacyDbStartFlags } from "./start.command.ts"; function asRecord(value: unknown): Record | undefined { @@ -1002,7 +1002,7 @@ export const legacyDbStart = Effect.fn("legacy.db.start")(function* (flags: Lega const extraHosts = runtimeInfo.platform === "linux" ? ["host.docker.internal:host-gateway"] : []; const isBitbucketPipeline = legacyIsBitbucketPipeline(); - const startOpts: LegacyStartContainerOpts = { + const startOpts: LegacyContainerOpts = { projectId, isBitbucketPipeline, workdir: cliConfig.workdir, diff --git a/apps/cli/src/legacy/commands/db/start/start.layers.ts b/apps/cli/src/legacy/commands/db/start/start.layers.ts index 7b2657c2cf..c51ab9af6e 100644 --- a/apps/cli/src/legacy/commands/db/start/start.layers.ts +++ b/apps/cli/src/legacy/commands/db/start/start.layers.ts @@ -15,12 +15,12 @@ import { legacyTelemetryStateLayer } from "../../../telemetry/legacy-telemetry-s * `FileSystem`/`Path` are ambient from the root runtime (`shared/cli/run.ts`), matching * `supabase start`'s own layer composition (`start.command.ts`). * - * No `LegacyDbBootstrapSeam` composition — `db start` no longer calls into the `db - * __db-bootstrap` Go seam at all after CLI-1954: `legacyIsLocalDbRunning` (the - * already-running check) and `legacyStartDatabase` (the container bring-up itself) are - * both native TS, hoisted to `legacy/shared/db-bootstrap/`. `db reset --local` still - * composes `legacyDbBootstrapSeamLayer` for its own container-recreate + storage-health - * primitives (`reset.layers.ts`). + * No `LegacyDbBootstrapSeam` composition — that hidden `db __db-bootstrap` Go seam no + * longer exists at all (CLI-1954 removed its `start` dispatch, CLI-1955 removed the + * rest): `legacyIsLocalDbRunning` (the already-running check) and `legacyStartDatabase` + * (the container bring-up itself) are both native TS, hoisted to + * `legacy/shared/db-bootstrap/`. `db reset --local` is ALSO fully native now, via its + * own composition over the same primitives (`reset.layers.ts`). * * `legacyDockerRunLayer`/`legacyDbConnectionLayer`/`legacyHttpClientLayer` back the native * container bootstrap itself (`start.handler.ts`): the fresh-volume `SetupLocalDatabase`- diff --git a/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts b/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts index 3877a4fdf1..f7820c3313 100644 --- a/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts +++ b/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts @@ -1,5 +1,5 @@ import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs"; -import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { chmod, mkdir, readFile, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { describe, expect, it } from "@effect/vitest"; @@ -139,6 +139,9 @@ vi.mock("../../../../shared/functions/deploy.ts", async () => { const tempRoot = useLegacyTempWorkdir("supabase-functions-serve-int-"); +// Root bypasses POSIX permission bits, so chmod-based failure tests can't run there. +const isRoot = typeof process.getuid === "function" && process.getuid() === 0; + const { legacyFunctionsServe } = await import("./serve.handler.ts"); interface LogProcessBehavior { @@ -2748,4 +2751,72 @@ describe("legacy functions serve integration", () => { ).toHaveLength(0); }); }); + + it.live("surfaces the real filesystem error when the fallback env file is unreadable", () => { + return Effect.gen(function* () { + yield* Effect.promise(() => + writeProjectConfig(['project_id = "test-project"', ""].join("\n")), + ); + yield* Effect.promise(() => + writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), + ); + // A directory at the fallback path makes the read fail with a non-ENOENT error (EISDIR). + yield* Effect.promise(() => + mkdir(join(tempRoot.current, "supabase", "functions", ".env"), { recursive: true }), + ); + + const { layer } = setupServe(); + const error = yield* legacyFunctionsServe(baseFlags()).pipe( + Effect.provide(layer), + Effect.flip, + ); + + expect(error).toBeInstanceOf(Error); + if (error instanceof Error) { + expect(error.message).toContain("EISDIR"); + expect(error.message).not.toContain("An error occurred in Effect.tryPromise"); + } + expect( + deployMockState.runCalls.filter( + (call) => call.command === "docker" && call.args[0] === "run", + ), + ).toHaveLength(0); + }); + }); + + it.live.skipIf(isRoot)( + "surfaces the real filesystem error when the env staging dir cannot be created", + () => { + return Effect.gen(function* () { + yield* Effect.promise(() => + writeProjectConfig(['project_id = "test-project"', ""].join("\n")), + ); + yield* Effect.promise(() => + writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), + ); + // A read-only parent makes the per-container staging-dir mkdir fail with EACCES. + const stagingRoot = join(tempRoot.current, "supabase", ".temp", "start-secrets"); + yield* Effect.promise(() => mkdir(stagingRoot, { recursive: true })); + yield* Effect.promise(() => chmod(stagingRoot, 0o555)); + + const { layer } = setupServe(); + const error = yield* legacyFunctionsServe(baseFlags()).pipe( + Effect.provide(layer), + Effect.flip, + Effect.ensuring(Effect.promise(() => chmod(stagingRoot, 0o755))), + ); + + expect(error).toBeInstanceOf(Error); + if (error instanceof Error) { + expect(error.message).toContain("EACCES"); + expect(error.message).not.toContain("An error occurred in Effect.tryPromise"); + } + expect( + deployMockState.runCalls.filter( + (call) => call.command === "docker" && call.args[0] === "run", + ), + ).toHaveLength(0); + }); + }, + ); }); diff --git a/apps/cli/src/legacy/commands/gen/bearer-jwt/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/gen/bearer-jwt/SIDE_EFFECTS.md index 8d7503a03a..b25f742e31 100644 --- a/apps/cli/src/legacy/commands/gen/bearer-jwt/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/gen/bearer-jwt/SIDE_EFFECTS.md @@ -2,9 +2,12 @@ ## Files Read -| Path | Format | When | -| -------------------------------- | ------ | ------------------------------ | -| `/supabase/config.toml` | TOML | to read JWT secret for signing | +| Path | Format | When | +| ------------------------------------------------ | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/config.toml` / `config.json` | TOML / JSON | always when present in the active workdir; used to discover `[auth].enabled` and `[auth].signing_keys_path` | +| `/supabase/.env*`, `/.env*` | dotenv | always, mirroring Go's `flags.LoadConfig`/`Config.Load`'s `loadNestedEnv` step (no `--yes`-style prompt of this command's own reads it, but the load still runs and can itself fail on a malformed file) | +| `` | JSON array of JWKs | when `[auth].signing_keys_path` is configured AND `[auth].enabled` is `true` (default) — see Notes for the `auth.enabled = false` quirk | +| stdin | plain text / JSON | interactive/piped prompt for a raw JWK (unconfigured `signing_keys_path`) or a signing-key `kid` (configured, non-TTY) | ## Files Written @@ -26,31 +29,85 @@ ## Exit Codes -| Code | Condition | -| ---- | ------------------------------------- | -| `0` | success — JWT printed to stdout | -| `1` | missing required `--role` flag | -| `1` | failed to parse claims or JWT signing | +| Code | Condition | +| ---- | -------------------------------------------------------------------------------------------- | +| `0` | success — the signed JWT is printed to stdout | +| `1` | missing required `--role` flag (`required flag(s) "role" not set`, no usage block) | +| `1` | malformed `--exp` (not valid RFC3339) or `--valid-for` (not a valid Go duration) | +| `1` | malformed `--payload` (`failed to parse payload: ...`) | +| `1` | `supabase/config.toml` itself is malformed | +| `1` | `[auth].signing_keys_path` is configured but the file is missing/unreadable | +| `1` | `[auth].signing_keys_path`'s file is not valid JSON, or not a JSON array of objects | +| `1` | the pasted stdin JWK (unconfigured `signing_keys_path`) is not valid JSON / not an object | +| `1` | the entered `kid` (configured `signing_keys_path`, non-TTY) matches no key | +| `1` | the resolved JWK has an unsupported key type/curve/algorithm, or a kty-vs-algorithm mismatch | ## Output ### `--output-format text` (Go CLI compatible) -Prints the generated Bearer JWT token to stdout. +Prints the signed JWT to stdout, followed by exactly one trailing newline — nothing +else ever reaches stdout. Every prompt, echo, and error goes to stderr. Unconditional +on `--output-format` — like the sibling `gen signing-key`, this command's own stdout +IS the machine-readable payload, so `json`/`stream-json` behave identically to `text`. ### `--output-format json` -Not applicable. +Same as `text` above (this command has no structured JSON envelope; see Notes). ### `--output-format stream-json` -Not applicable. +Same as `text` above. ## Notes -- `--role` flag is required (e.g., `anon`, `authenticated`, `service_role`). -- `--sub` flag sets the user ID to impersonate (defaults to `anonymous`). -- `--exp` sets an explicit expiry timestamp (RFC3339 format). -- `--valid-for` sets the validity duration (default 30 minutes). -- `--payload` accepts a JSON string of custom claims. -- Takes no positional arguments. +- `--role` is **required** (Postgres role to embed in the token, e.g. `anon`, + `authenticated`, `service_role`, or any custom role name — no validation against a + fixed set). +- `--sub` sets the `sub` (subject/user ID) claim. Its Go help text cosmetically shows + `(default "anonymous")`, but the real default is unset — an omitted `--sub` never + puts a `sub` claim in the token at all. +- When `--role authenticated` is used with no `--sub`, OR with `--sub ""` (an + explicitly-passed EMPTY value — Go's gate is `len(claims.Subject) == 0`, which an + empty string also satisfies), the token gets `is_anonymous: true`. Any other role, + or `authenticated` with a NON-EMPTY `--sub`, never sets it. +- `--exp` (RFC3339, e.g. `2030-01-01T00:00:00Z`) sets an explicit expiry; `iat` is then + computed as `exp - --valid-for`. Without `--exp`, `iat` is "now" and `exp` is `iat + +--valid-for`. +- `--valid-for` (Go duration syntax, e.g. `30m`, `1h`) defaults to 30 minutes. +- `--payload` (default `"{}"`) is arbitrary JSON merged ON TOP of the computed claims — + any key it sets (including `role`, `exp`, `iat`) overrides the computed value. +- The final claims object is a Go `jwt.MapClaims` (a real map), so its JSON keys are + serialized in **alphabetical order**, not flag/insertion order — byte-matches Go's + `encoding/json` map marshalling (including HTML-escaping `<`/`>`/`&`). +- **Signing-key resolution** (Go's `getSigningKey`, fully local, no Docker/network): + - `[auth].signing_keys_path` **not configured**: prompts `Enter your signing key in +JWK format (or leave blank to use local default): ` on stderr. A blank answer uses + the built-in default ES256 dev key (kid `b81269f1-21d8-4f2e-b719-c2240a840d90`, + the same default GoTrue itself signs local dev tokens with). A non-blank answer is + parsed as a single JWK object. + - `[auth].signing_keys_path` **configured**, **non-TTY** stdin: prompts `Enter the +kid of your signing key (or leave blank to use the first one): ` on stderr, echoing + the piped answer back. An exact `kid` match wins (checked before the blank-input + fallback, so a stored key whose own `kid` is `""` can still match a blank answer); + otherwise a blank answer uses the first key; otherwise `signing key not found: +`. + - `[auth].signing_keys_path` **configured**, **real TTY**: presents an interactive + picker (`Select a signing key:`) built from each key's `kid`/`alg`/`key_ops`, then + prints `Selected key ID: ` to stderr. Does not byte-match Go's bubbletea list + UI (an accepted divergence — see `legacy-project-ref.layer.ts` for the established + precedent of only matching the observable "Selected ..." line). + - **`[auth].enabled = false` quirk** (verified against the real binary): Go's + `Config.Validate` only reads the `signing_keys_path` file when `auth.enabled` is + `true` — but `getSigningKey` decides which prompt to show purely on whether the + path STRING is configured, independent of `auth.enabled`. So with auth disabled + and a path configured, the kid-prompt still runs, but the available keys stay the + built-in default (the file is never read) — a real kid from the file is reported + `signing key not found`. +- Byte-matches Go's asymmetric-signing error family exactly: kty/curve failures wrap as + `failed to convert JWK to private key: ...`; an unsupported algorithm is unwrapped + (`unsupported algorithm: ...`); a kty-vs-algorithm mismatch (e.g. an EC key signed as + `RS256`) is caught at sign time and wraps as `failed to sign JWT: key is of invalid +type: ...` — Go has no explicit cross-check between kty and algorithm; this failure + comes from golang-jwt's own signing method. +- No network or Management API calls, no Docker — fully local, matching Go. diff --git a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.claims.ts b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.claims.ts new file mode 100644 index 0000000000..b03466a251 --- /dev/null +++ b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.claims.ts @@ -0,0 +1,387 @@ +import { Option } from "effect"; +import { encodeGoStructJsonBody } from "../../../shared/legacy-go-output.encoders.ts"; +import { legacyGoJsonKindName } from "../../../shared/legacy-go-json.ts"; +import { legacyAddSecondsAndFloor, type LegacyBearerJwtInstant } from "./bearer-jwt.flags.ts"; + +/** + * Pure claims-building logic for `gen bearer-jwt`, ported from Go's `parseClaims` + * (`apps/cli-go/cmd/gen.go:185-213`). Kept out of the handler/service tree (no `Effect`, + * no service dependencies) per `apps/cli/CLAUDE.md`'s `.format.ts`/`.encoders.ts` + * guidance — this is exactly that shape of file, just named `.claims.ts` for this command. + * + * Go's real call site (`cmd/gen.go:137-141`) ALWAYS builds a `jwt.MapClaims` (a genuine Go + * map) and hands it to `bearerjwt.Run` — never a `CustomClaims` struct. `encoding/json` + * serializes a map's keys in SORTED (alphabetical) order, unlike a struct's + * declaration order — see {@link legacyEncodeBearerJwtClaims}, which every caller MUST use + * to serialize the object this module builds (a plain `JSON.stringify` would preserve + * insertion order instead, which is Go-correct for `legacyGenerateAsymmetricGoJwt`'s + * struct-shaped claims but wrong here). + */ + +export interface LegacyBearerJwtClaimsInput { + readonly role: string; + readonly sub: Option.Option; + /** + * The parsed `--exp` instant (RFC3339), WITHOUT flooring — `Option.none()` when the + * flag was not given. An exact {@link LegacyBearerJwtInstant}, not a single float — + * see that type's own doc comment for why a single `number` cannot carry an + * epoch-scale whole-second count and nanosecond precision together without silent + * rounding (CLI-1961 Codex review finding). + */ + readonly expiresAt: Option.Option; + /** + * `--valid-for`, parsed from Go-duration syntax into seconds WITHOUT flooring — + * see {@link legacyParseBearerJwtValidFor}'s own doc comment for why sub-second + * precision must survive until the final `exp`/`iat` computation below. + */ + readonly validForSeconds: number; + /** + * `Date.now()`-derived instant, injected so callers (and tests) control "now" — an + * exact {@link LegacyBearerJwtInstant} built directly from `Date.now()`'s integer + * milliseconds (see `bearer-jwt.handler.ts`), NOT pre-floored to whole seconds. + * Flooring it before this module ever sees it would compute `exp = now + validFor` + * from an already-truncated `now`, shortening the token's lifetime by up to a second + * whenever `--valid-for` has a sub-second component (CLI-1961 Codex review finding: + * a run at `HH:MM:SS.900` with `--valid-for 200ms` must land in the NEXT second, + * matching Go's `now.Add(validFor)` on the raw fractional time followed by truncating + * `iat`/`exp` separately — verified against the real binary via `golang-jwt/jwt/v5`'s + * `NewNumericDate`/`time.Time.Add`). + */ + readonly nowInstant: LegacyBearerJwtInstant; +} + +/** + * Go's time/role computation (`cmd/gen.go:187-198`): + * - `--exp` unset (zero `time.Time`): `iat = now`, `exp = now + validFor`. + * - `--exp` set: `exp = `, `iat = exp - validFor` (validFor is SUBTRACTED + * from the explicit expiry to derive `iat`, not added to `now`). + * - Both arithmetic branches use Go's exact-nanosecond `time.Time` math and only floor + * the FINAL `exp`/`iat` to whole seconds via `jwt.NewNumericDate`'s `Truncate` + * (`golang-jwt/jwt/v5`'s `types.go:38-40`) — so `validForSeconds` (which may carry + * sub-second precision, see {@link LegacyBearerJwtClaimsInput.validForSeconds}) must + * be applied BEFORE flooring, not floored first and then applied. Verified against + * the real binary (CLI-1961): `--exp 2030-01-01T00:00:00Z --valid-for 1.5s` yields + * `iat=1893455998` — flooring the 1.5s duration to 1s first (as this port previously + * did) would wrongly yield `1893455999`. + * - `expiresAt`/`nowInstant` are exact {@link LegacyBearerJwtInstant}s, not floats (see + * that type's own doc comment) — `legacyAddSecondsAndFloor` combines an instant with + * `validForSeconds` using exact integer nanosecond arithmetic and returns the + * correctly-floored whole-second result in one step, so neither branch below ever + * adds an epoch-scale whole-second count directly to a sub-second float (the CLI-1961 + * Codex review finding that plain float addition can do: `--exp + * 2030-01-01T00:00:00.999999999Z` must floor to `1893456000`, not round UP to + * `1893456001`). Verified against the real binary (CLI-1961): `--exp + * 2030-01-01T00:00:00.9Z --valid-for 1.2s` yields `iat=1893455999`, not the + * `1893455998` that flooring `expiresAt` before the subtraction would produce. + * - `role` is ALWAYS present (`json:"role"`, no `omitempty`), even `--role ""`. + * - `is_anonymous` is set only when `role` case-insensitively equals `"authenticated"` + * AND `--sub` was not given (`strings.EqualFold` + `len(claims.Subject) == 0`) — an + * explicitly-passed EMPTY `--sub ""` still counts as "not given" for this specific + * check (`len("") == 0`), even though the `sub` claim omission below is governed by + * the SAME emptiness check, not by whether the flag was passed at all; the `role` + * claim keeps its original casing regardless. + * - `sub`/`exp`/`iat` all carry Go's embedded `jwt.RegisteredClaims` `omitempty` tags — + * `sub` only when non-empty, `exp`/`iat` always (both are always-set `*NumericDate`s + * here, matching mapstructure's non-nil-pointer handling — see `legacy-go-jwt.ts`'s + * sibling doc comments for the general `omitempty`-in-mapstructure background). + * - `iss`/`ref`/`aud`/`nbf`/`jti` never appear — bearer-jwt has no flag that sets any of + * them, so they stay at their Go zero value and get `omitempty`-dropped. + */ +export function legacyBuildBearerJwtClaims( + input: LegacyBearerJwtClaimsInput, +): Record { + let exp: number; + let iat: number; + if (Option.isNone(input.expiresAt)) { + iat = input.nowInstant.wholeSeconds; + exp = legacyAddSecondsAndFloor(input.nowInstant, input.validForSeconds); + } else { + const rawExp = input.expiresAt.value; + exp = rawExp.wholeSeconds; + iat = legacyAddSecondsAndFloor(rawExp, -input.validForSeconds); + } + + const claims: Record = { + role: input.role, + }; + const sub = Option.getOrUndefined(input.sub); + const subIsEmpty = sub === undefined || sub.length === 0; + if (input.role.toLowerCase() === "authenticated" && subIsEmpty) { + claims["is_anonymous"] = true; + } + if (!subIsEmpty) { + claims["sub"] = sub; + } + claims["exp"] = exp; + claims["iat"] = iat; + return claims; +} + +const GO_JSON_WHITESPACE = new Set([" ", "\t", "\n", "\r"]); + +function skipGoJsonWhitespace(value: string, index: number): number { + let i = index; + while (i < value.length && GO_JSON_WHITESPACE.has(value[i]!)) i++; + return i; +} + +/** + * Index right after the closing (unescaped) `"` of the JSON string starting at + * `value[start]` (assumed to be `"`), or `undefined` when it never closes — + * Go's scanner likewise just bails out to `"unexpected end of JSON input"` on a + * truncated string, so escape-sequence CORRECTNESS doesn't need validating here. + */ +function findJsonStringEnd(value: string, start: number): number | undefined { + let i = start + 1; + while (i < value.length) { + if (value[i] === "\\") { + i += 2; + continue; + } + if (value[i] === '"') { + return i + 1; + } + i++; + } + return undefined; +} + +/** + * Index right after the closing `}`/`]` that matches the `{`/`[` at `value[start]`, + * tracking bracket depth while skipping over string literals (so a bracket + * character inside a string never perturbs the count) — or `undefined` when depth + * never returns to zero (truncated/unterminated). + */ +function findJsonContainerEnd(value: string, start: number): number | undefined { + let depth = 0; + let i = start; + while (i < value.length) { + const ch = value[i]; + if (ch === '"') { + const stringEnd = findJsonStringEnd(value, i); + if (stringEnd === undefined) return undefined; + i = stringEnd; + continue; + } + if (ch === "{" || ch === "[") { + depth++; + } else if (ch === "}" || ch === "]") { + depth--; + if (depth === 0) return i + 1; + } + i++; + } + return undefined; +} + +const JSON_NUMBER_PATTERN = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/; + +/** Go's literal keywords, keyed by their first byte — matched char-by-char below, same as `encoding/json`'s scanner. */ +const GO_JSON_LITERALS: Record = { n: "null", t: "true", f: "false" }; + +/** + * Scans an ALREADY syntactically-valid JSON document (this only ever runs after + * `JSON.parse` on `value` has itself succeeded, so every string is properly closed + * and every number token is well-formed) for the first number literal that overflows + * a float64, e.g. `1e309` — returns that literal's exact source text, or `undefined` + * if every number in the document is finite. + * + * `JSON.parse` silently converts an overflowing literal to `Infinity`/`-Infinity` + * (verified: `JSON.parse('{"extra":1e309}')` yields `{ extra: Infinity }`, which + * {@link legacyEncodeBearerJwtClaims}'s Go-compatible encoder then serializes as + * `null`, per `encoding/json`'s own float64-to-JSON-number behavior for non-finite + * values) — but Go's `json.Unmarshal` into `jwt.MapClaims` (which decodes every JSON + * number as a Go `float64`) fails outright: `strconv.ParseFloat` returns a range + * error for the same literal, and `encoding/json` surfaces that as `"json: cannot + * unmarshal number 1e309 into Go value of type float64"`. Verified against the real + * binary (CLI-1961): `--payload '{"extra":1e309}'` exits 1 with exactly that message + * (wrapped by the caller's `"failed to parse payload: %w"`), rather than silently + * signing a token with a `null` custom claim. + * + * A left-to-right scan that skips over string contents (object keys and string + * values, via {@link findJsonStringEnd}) and matches a full number token at every + * other digit/`-` position reproduces Go's own decode order (a single-pass, + * depth-first token scan) closely enough to report the same FIRST offending literal + * Go's decoder would stop at, without needing a full recursive-descent parse. + */ +function findFirstNonFiniteJsonNumberLiteral(value: string): string | undefined { + let i = 0; + while (i < value.length) { + const ch = value[i]!; + if (ch === '"') { + // `value` is known-valid JSON, so every string closes; skip over it whole so + // digits inside a string value are never mistaken for a number token. + i = findJsonStringEnd(value, i)!; + continue; + } + if (ch === "-" || (ch >= "0" && ch <= "9")) { + const literal = JSON_NUMBER_PATTERN.exec(value.slice(i))![0]; + if (!Number.isFinite(Number(literal))) { + return literal; + } + i += literal.length; + continue; + } + i++; + } + return undefined; +} + +/** + * Reports Go's exact `"invalid character '' after top-level value"` when + * `trimmed` has non-whitespace content after its first `validPrefixLength` + * characters, or the generic fallback when there is none — reachable only via a + * leading byte JS's `\s` regex strips but Go's scanner does not treat as + * whitespace (e.g. a vertical tab), so the ORIGINAL `JSON.parse(payload)` call + * still failed even though `trimmed` alone is one complete, valid JSON value. + */ +function reportGoJsonTrailingGarbage(trimmed: string, validPrefixLength: number): string { + const restStart = skipGoJsonWhitespace(trimmed, validPrefixLength); + if (restStart >= trimmed.length) { + return "invalid character looking for beginning of value"; + } + return `invalid character '${trimmed[restStart]}' after top-level value`; +} + +/** + * Best-effort, single-pass (no repeated whole-string `JSON.parse` retries — see + * below) reproduction of Go's `encoding/json` scanner syntax-error text for a + * malformed `--payload` value, verified against the real binary (CLI-1961) for + * every shape covered here: an empty/whitespace-only payload or a genuinely + * truncated value (`"unexpected end of JSON input"`), a byte that can never start + * a JSON value (`"invalid character '' looking for beginning of value"`), a + * partial keyword match (`"invalid character '' in literal (expecting + * '')"`, e.g. `--payload 'not-json-at-all'` starts with `n` looking like + * `null`), and valid JSON followed by trailing garbage (`"invalid character '' + * after top-level value"`, e.g. `--payload '{}{}'`). + * + * Dispatches on the first non-whitespace byte rather than building a full + * recursive-descent parser — each of the five shapes above only needs to know + * "where does the first top-level value end, and how did the byte after it (or + * the byte that broke a literal/number) fail" — and every helper below scans + * forward only, so a large malformed payload cannot make this pathological the + * way retrying `JSON.parse` on shrinking prefixes could. Genuinely malformed + * JSON with no recognizable failure shape above (e.g. a lone `-` with no digits) + * falls back to a generic message that is NOT verified byte-for-byte against + * Go's own scanner (accepted gap — `bearerjwt_test.go` has no fixture for + * `--payload` parsing at all; see this command's own audit notes). + */ +function legacyGoJsonSyntaxErrorMessage(raw: string): string { + const trimmed = raw.replace(/^\s+/, ""); + if (trimmed.length === 0) { + return "unexpected end of JSON input"; + } + + const first = trimmed[0]!; + + const literal = GO_JSON_LITERALS[first]; + if (literal !== undefined) { + for (let i = 0; i < literal.length; i++) { + if (i >= trimmed.length) { + return "unexpected end of JSON input"; + } + if (trimmed[i] !== literal[i]) { + return `invalid character '${trimmed[i]}' in literal ${literal} (expecting '${literal[i]}')`; + } + } + return reportGoJsonTrailingGarbage(trimmed, literal.length); + } + + if (first === '"') { + const end = findJsonStringEnd(trimmed, 0); + return end === undefined + ? "unexpected end of JSON input" + : reportGoJsonTrailingGarbage(trimmed, end); + } + + if (first === "{" || first === "[") { + const end = findJsonContainerEnd(trimmed, 0); + return end === undefined + ? "unexpected end of JSON input" + : reportGoJsonTrailingGarbage(trimmed, end); + } + + if (first === "-" || (first >= "0" && first <= "9")) { + const match = JSON_NUMBER_PATTERN.exec(trimmed); + if (match === null || match[0].length === 0) { + // Only reachable for a lone, digit-less `-` — Go's scanner is still + // mid-number waiting for a digit when the input runs out. + return "unexpected end of JSON input"; + } + return reportGoJsonTrailingGarbage(trimmed, match[0].length); + } + + return `invalid character '${first}' looking for beginning of value`; +} + +/** + * Go's final `--payload` merge (`cmd/gen.go:209-211`): + * `json.Unmarshal([]byte(payload), &custom)`. `encoding/json` reuses the existing + * non-nil map and overwrites/adds keys from the payload on top — so this merges + * `JSON.parse(payload)`'s own keys OVER `claims` (payload wins on any collision, + * e.g. `--payload '{"role":"override"}'` replaces the flag-derived `role`). + * + * A JSON `null` payload is a no-op — verified against the real binary — rather than + * clearing `claims` (Go's own map-into-map unmarshal semantics for a `null` source + * leave the destination untouched here in practice). A non-object, non-null, + * non-array top-level scalar (string/number/bool) or an array raises Go's own + * `"json: cannot unmarshal into Go value of type jwt.MapClaims"` runtime + * type-mismatch text — checked BEFORE any number-overflow scan below, since Go + * rejects the top-level kind before ever attempting to decode a number inside it + * (verified against the real binary: a top-level overflowing scalar payload like + * `--payload '1e309'` still reports "cannot unmarshal number into Go value of type + * jwt.MapClaims", WITHOUT the literal). Once the top level genuinely is an object, an + * overflowing number ANYWHERE inside it, at any depth (e.g. `{"extra":1e309}` or + * `{"a":{"b":[1e309]}}`), raises Go's `"json: cannot unmarshal number into + * Go value of type float64"` instead — see + * {@link findFirstNonFiniteJsonNumberLiteral}. Throws a bare `Error`; the caller + * (`bearer-jwt.handler.ts`) wraps it with Go's `"failed to parse payload: %w"` prefix. + */ +export function legacyMergeBearerJwtPayload( + claims: Record, + payload: string, +): Record { + let parsed: unknown; + try { + parsed = JSON.parse(payload); + } catch { + throw new Error(legacyGoJsonSyntaxErrorMessage(payload)); + } + if (parsed === null) { + return claims; + } + if (Array.isArray(parsed) || typeof parsed !== "object") { + throw new Error( + `json: cannot unmarshal ${legacyGoJsonKindName(parsed)} into Go value of type jwt.MapClaims`, + ); + } + // Only reachable once the top-level shape is already a map — verified against the + // real binary: a top-level scalar/array payload gets the structural mismatch above + // even when it overflows (e.g. `--payload '1e309'` still reports "cannot unmarshal + // number into Go value of type jwt.MapClaims", WITHOUT the literal, because Go's + // decoder rejects the top-level kind before ever attempting to decode the number + // itself), whereas `--payload '[1e309]'` reports the array-kind mismatch. Once the + // top level genuinely is an object, Go recurses into every value at any depth + // (including inside nested arrays/objects) as `interface{}`, which is where an + // overflowing number actually gets decoded as `float64` and fails. + const overflowingLiteral = findFirstNonFiniteJsonNumberLiteral(payload); + if (overflowingLiteral !== undefined) { + throw new Error( + `json: cannot unmarshal number ${overflowingLiteral} into Go value of type float64`, + ); + } + return { ...claims, ...(parsed as Record) }; +} + +/** + * Serializes the final claims object the way Go's `jwt.MapClaims` (a real Go map) + * marshals via `encoding/json`: alphabetically key-sorted at every level, Go's HTML + + * control-character escaping, no indentation, no trailing newline. Reuses + * `encodeGoStructJsonBody` (originally written for outbound API request bodies) — + * its behavior is Go's generic `json.Marshal`-of-a-map shape, which is exactly what a + * `jwt.MapClaims` payload needs too; introducing a second identical encoder under a + * different name would just be a rename, not a behavior difference. + */ +export function legacyEncodeBearerJwtClaims(claims: Record): string { + return encodeGoStructJsonBody(claims); +} diff --git a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.claims.unit.test.ts b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.claims.unit.test.ts new file mode 100644 index 0000000000..9b207b60ce --- /dev/null +++ b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.claims.unit.test.ts @@ -0,0 +1,412 @@ +import { Option } from "effect"; +import { describe, expect, it } from "vitest"; +import { + legacyBuildBearerJwtClaims, + legacyEncodeBearerJwtClaims, + legacyMergeBearerJwtPayload, +} from "./bearer-jwt.claims.ts"; + +const NOW = 1_700_000_000; +const NOW_INSTANT = { wholeSeconds: NOW, nanos: 0 }; + +describe("legacyBuildBearerJwtClaims", () => { + it("always includes role, even an empty string, with no omitempty", () => { + const claims = legacyBuildBearerJwtClaims({ + role: "", + sub: Option.none(), + expiresAt: Option.none(), + validForSeconds: 1800, + nowInstant: NOW_INSTANT, + }); + expect(claims["role"]).toBe(""); + }); + + it("computes iat = now and exp = now + validFor when --exp is not given", () => { + const claims = legacyBuildBearerJwtClaims({ + role: "anon", + sub: Option.none(), + expiresAt: Option.none(), + validForSeconds: 1800, + nowInstant: NOW_INSTANT, + }); + expect(claims["iat"]).toBe(NOW); + expect(claims["exp"]).toBe(NOW + 1800); + }); + + it("computes exp = --exp and iat = exp - validFor when --exp is given", () => { + const claims = legacyBuildBearerJwtClaims({ + role: "anon", + sub: Option.none(), + expiresAt: Option.some({ wholeSeconds: 2_000_000_000, nanos: 0 }), + validForSeconds: 1800, + nowInstant: NOW_INSTANT, + }); + expect(claims["exp"]).toBe(2_000_000_000); + expect(claims["iat"]).toBe(2_000_000_000 - 1800); + }); + + it("floors only the FINAL iat, applying a sub-second --valid-for before truncating (CLI-1961)", () => { + // Verified against the real binary: `--exp 2030-01-01T00:00:00Z --valid-for 1.5s` + // yields Go `iat=1893455998` — flooring the 1.5s duration to 1s BEFORE subtracting + // (this port's previous behavior) would wrongly yield 1893455999. + const claims = legacyBuildBearerJwtClaims({ + role: "anon", + sub: Option.none(), + expiresAt: Option.some({ wholeSeconds: 1_893_456_000, nanos: 0 }), + validForSeconds: 1.5, + nowInstant: NOW_INSTANT, + }); + expect(claims["exp"]).toBe(1_893_456_000); + expect(claims["iat"]).toBe(1_893_455_998); + }); + + it("preserves a fractional --exp through the iat subtraction, flooring only the final result (CLI-1961 Codex review finding)", () => { + // Verified against the real binary: `--exp 2030-01-01T00:00:00.9Z --valid-for + // 1.2s` yields `iat=1893455999`. Flooring `expiresAt` BEFORE the subtraction + // (this port's previous behavior) would wrongly yield `1893455998`. + const claims = legacyBuildBearerJwtClaims({ + role: "anon", + sub: Option.none(), + expiresAt: Option.some({ wholeSeconds: 1_893_456_000, nanos: 900_000_000 }), + validForSeconds: 1.2, + nowInstant: NOW_INSTANT, + }); + expect(claims["exp"]).toBe(1_893_456_000); + expect(claims["iat"]).toBe(1_893_455_999); + }); + + it("floors exp down (never rounds up) for a near-second nanosecond --exp fraction (CLI-1961 Codex review finding)", () => { + // Verified against the Go standard library + `golang-jwt/jwt/v5`'s + // `NewNumericDate`: `--exp 2030-01-01T00:00:00.999999999Z` yields Go `exp=1893456000` + // — a naive float addition of `wholeSeconds + 0.999999999` (this port's previous + // behavior) rounds UP to the exact integer `1893456001` in plain JS arithmetic. + const claims = legacyBuildBearerJwtClaims({ + role: "anon", + sub: Option.none(), + expiresAt: Option.some({ wholeSeconds: 1_893_456_000, nanos: 999_999_999 }), + validForSeconds: 1800, + nowInstant: NOW_INSTANT, + }); + expect(claims["exp"]).toBe(1_893_456_000); + }); + + it("adds a sub-second --valid-for to the unfloored current time when --exp is omitted (CLI-1961 Codex review finding)", () => { + // Verified against the real binary via `golang-jwt/jwt/v5`'s `NewNumericDate` + + // `time.Time.Add`: Go computes `exp = now.Add(validFor)` on the RAW fractional + // `now`, then truncates `iat`/`exp` separately — a run at `X.900` with + // `--valid-for 200ms` must land in the NEXT second (`exp = X + 1`), not stay in the + // current one. Computing `exp` from an already-floored `iat` (this port's previous + // behavior) would wrongly keep `exp = X`, shortening the token's lifetime to + // effectively zero. + const claims = legacyBuildBearerJwtClaims({ + role: "anon", + sub: Option.none(), + expiresAt: Option.none(), + validForSeconds: 0.2, + nowInstant: { wholeSeconds: NOW, nanos: 900_000_000 }, + }); + expect(claims["iat"]).toBe(NOW); + expect(claims["exp"]).toBe(NOW + 1); + }); + + it("sets is_anonymous when --sub is explicitly passed as an empty string (CLI-1961)", () => { + // Go's gate is `len(claims.Subject) == 0` (`cmd/gen.go:195`) — an explicitly-passed + // EMPTY `--sub ""` still counts as "no subject", not just an omitted flag. + const claims = legacyBuildBearerJwtClaims({ + role: "authenticated", + sub: Option.some(""), + expiresAt: Option.none(), + validForSeconds: 1800, + nowInstant: NOW_INSTANT, + }); + expect(claims["is_anonymous"]).toBe(true); + expect("sub" in claims).toBe(false); + }); + + it("sets is_anonymous when role is 'authenticated' (case-insensitive) and sub is absent", () => { + const claims = legacyBuildBearerJwtClaims({ + role: "AUTHENTICATED", + sub: Option.none(), + expiresAt: Option.none(), + validForSeconds: 1800, + nowInstant: NOW_INSTANT, + }); + expect(claims["is_anonymous"]).toBe(true); + // Role keeps its original casing. + expect(claims["role"]).toBe("AUTHENTICATED"); + }); + + it("does not set is_anonymous when role is authenticated but sub is given", () => { + const claims = legacyBuildBearerJwtClaims({ + role: "authenticated", + sub: Option.some("user-1"), + expiresAt: Option.none(), + validForSeconds: 1800, + nowInstant: NOW_INSTANT, + }); + expect(claims["is_anonymous"]).toBeUndefined(); + expect(claims["sub"]).toBe("user-1"); + }); + + it("does not set is_anonymous for a non-authenticated role", () => { + const claims = legacyBuildBearerJwtClaims({ + role: "postgres", + sub: Option.none(), + expiresAt: Option.none(), + validForSeconds: 1800, + nowInstant: NOW_INSTANT, + }); + expect(claims["is_anonymous"]).toBeUndefined(); + }); + + it("omits sub entirely when not given (omitempty)", () => { + const claims = legacyBuildBearerJwtClaims({ + role: "service_role", + sub: Option.none(), + expiresAt: Option.none(), + validForSeconds: 1800, + nowInstant: NOW_INSTANT, + }); + expect("sub" in claims).toBe(false); + }); +}); + +describe("legacyMergeBearerJwtPayload", () => { + it("is a no-op for the default '{}' payload", () => { + const claims = { role: "anon" }; + expect(legacyMergeBearerJwtPayload(claims, "{}")).toEqual({ role: "anon" }); + }); + + it("merges payload keys on top of (overriding) existing claims", () => { + const claims = { role: "postgres", exp: 1, iat: 2 }; + const merged = legacyMergeBearerJwtPayload( + claims, + '{"role":"override","sb-role":"mgmt-api","aud":"x"}', + ); + expect(merged).toEqual({ + role: "override", + exp: 1, + iat: 2, + "sb-role": "mgmt-api", + aud: "x", + }); + }); + + it("treats a JSON null payload as a no-op", () => { + const claims = { role: "anon" }; + expect(legacyMergeBearerJwtPayload(claims, "null")).toEqual({ role: "anon" }); + }); + + it("rejects an array payload with Go's unmarshal-type-mismatch message", () => { + expect(() => legacyMergeBearerJwtPayload({ role: "anon" }, "[]")).toThrow( + "json: cannot unmarshal array into Go value of type jwt.MapClaims", + ); + }); + + it("rejects a scalar number payload with Go's unmarshal-type-mismatch message", () => { + expect(() => legacyMergeBearerJwtPayload({ role: "anon" }, "123")).toThrow( + "json: cannot unmarshal number into Go value of type jwt.MapClaims", + ); + }); + + it("rejects a scalar string payload with Go's unmarshal-type-mismatch message", () => { + expect(() => legacyMergeBearerJwtPayload({ role: "anon" }, '"str"')).toThrow( + "json: cannot unmarshal string into Go value of type jwt.MapClaims", + ); + }); + + it("rejects a scalar boolean payload with Go's unmarshal-type-mismatch message", () => { + expect(() => legacyMergeBearerJwtPayload({ role: "anon" }, "true")).toThrow( + "json: cannot unmarshal bool into Go value of type jwt.MapClaims", + ); + }); + + it("rejects an empty payload with Go's exact 'unexpected end of JSON input'", () => { + expect(() => legacyMergeBearerJwtPayload({ role: "anon" }, "")).toThrow( + "unexpected end of JSON input", + ); + }); + + it("rejects an overflowing number nested in an object payload instead of silently signing Infinity-as-null (CLI-1961 Codex review finding)", () => { + // Verified against the real binary: `--payload '{"extra":1e309}'` exits 1 with + // this exact message. `JSON.parse` alone would accept `1e309` as `Infinity`, + // which `legacyEncodeBearerJwtClaims`'s Go-compatible encoder then serializes as + // `null` — silently changing the claim instead of failing the command. + expect(() => legacyMergeBearerJwtPayload({ role: "anon" }, '{"extra":1e309}')).toThrow( + "json: cannot unmarshal number 1e309 into Go value of type float64", + ); + }); + + it("rejects an overflowing number nested arbitrarily deep (inside an array, inside an object)", () => { + expect(() => legacyMergeBearerJwtPayload({ role: "anon" }, '{"a":{"b":[1,2,1e309]}}')).toThrow( + "json: cannot unmarshal number 1e309 into Go value of type float64", + ); + }); + + it("reports the FIRST overflowing literal in document order when multiple numbers overflow", () => { + expect(() => legacyMergeBearerJwtPayload({ role: "anon" }, '{"a":1e400,"b":1e309}')).toThrow( + "json: cannot unmarshal number 1e400 into Go value of type float64", + ); + }); + + it("does not misreport a non-overflowing number as overflowing", () => { + const merged = legacyMergeBearerJwtPayload({ role: "anon" }, '{"extra":123.456}'); + expect(merged["extra"]).toBe(123.456); + }); + + it("prioritizes the top-level type-mismatch message over an overflowing scalar payload", () => { + // Verified against the real binary: a bare top-level overflowing scalar payload + // (`--payload '1e309'`) still reports the STRUCTURAL mismatch, WITHOUT the + // literal, because Go's decoder rejects the top-level kind before ever + // attempting to decode the number itself — the array/scalar top-level check + // must run BEFORE the overflow scan. + expect(() => legacyMergeBearerJwtPayload({ role: "anon" }, "1e309")).toThrow( + "json: cannot unmarshal number into Go value of type jwt.MapClaims", + ); + }); + + it("prioritizes the top-level array-mismatch message over an overflowing number nested inside the array", () => { + expect(() => legacyMergeBearerJwtPayload({ role: "anon" }, "[1e309]")).toThrow( + "json: cannot unmarshal array into Go value of type jwt.MapClaims", + ); + }); + + it("rejects trailing garbage after a valid value with Go's exact 'after top-level value' text", () => { + expect(() => legacyMergeBearerJwtPayload({ role: "anon" }, "{}{}")).toThrow( + "invalid character '{' after top-level value", + ); + }); + + it("accepts a payload value of null for an individual key (distinct from a null top-level payload)", () => { + const merged = legacyMergeBearerJwtPayload({ role: "anon", sub: "x" }, '{"sub":null}'); + expect(merged["sub"]).toBeNull(); + }); + + it("reports a partial keyword match against Go's exact 'in literal' wording", () => { + // `not-json-at-all` starts with `n`, which Go's scanner reads as the start of the + // `null` literal; the second byte `o` mismatches `null`'s `u` — Go's own scanner + // text for this is `invalid character 'o' in literal null (expecting 'u')`. + expect(() => legacyMergeBearerJwtPayload({ role: "anon" }, "not-json-at-all")).toThrow( + "invalid character 'o' in literal null (expecting 'u')", + ); + }); + + it("rejects a truncated object with Go's exact 'unexpected end of JSON input'", () => { + expect(() => legacyMergeBearerJwtPayload({ role: "anon" }, '{"a":1')).toThrow( + "unexpected end of JSON input", + ); + }); + + it("rejects a truncated array with Go's exact 'unexpected end of JSON input'", () => { + expect(() => legacyMergeBearerJwtPayload({ role: "anon" }, "[1,2")).toThrow( + "unexpected end of JSON input", + ); + }); + + it("rejects an object truncated inside a nested unterminated string", () => { + expect(() => legacyMergeBearerJwtPayload({ role: "anon" }, '{"a')).toThrow( + "unexpected end of JSON input", + ); + }); + + it("reports trailing garbage after a validly nested container, not on the inner close", () => { + expect(() => legacyMergeBearerJwtPayload({ role: "anon" }, "{[]}x")).toThrow( + "invalid character 'x' after top-level value", + ); + }); + + it("rejects an unterminated string with Go's exact 'unexpected end of JSON input'", () => { + expect(() => legacyMergeBearerJwtPayload({ role: "anon" }, '"unterminated')).toThrow( + "unexpected end of JSON input", + ); + }); + + it("reports trailing garbage after a valid string value", () => { + expect(() => legacyMergeBearerJwtPayload({ role: "anon" }, '"abc"def')).toThrow( + "invalid character 'd' after top-level value", + ); + }); + + it("skips over an escaped quote inside a string when finding where it closes", () => { + // The actual payload bytes are: `"` `a` `\` `"` `b` `"` `c` — a string containing an + // escaped quote (`a"b`), followed by trailing garbage `c`. + expect(() => legacyMergeBearerJwtPayload({ role: "anon" }, '"a\\"b"c')).toThrow( + "invalid character 'c' after top-level value", + ); + }); + + it("rejects a truncated literal (a strict prefix of a keyword) as truncated, not a mismatch", () => { + expect(() => legacyMergeBearerJwtPayload({ role: "anon" }, "tru")).toThrow( + "unexpected end of JSON input", + ); + }); + + it("reports trailing garbage after a FULLY matched literal keyword", () => { + // "null" matches completely; JSC's own tokenizer reports this generically (no + // position), unlike a partial mismatch — this exercises that specific fall-through. + expect(() => legacyMergeBearerJwtPayload({ role: "anon" }, "nullx")).toThrow( + "invalid character 'x' after top-level value", + ); + }); + + it("rejects a lone digit-less minus sign with Go's exact 'unexpected end of JSON input'", () => { + expect(() => legacyMergeBearerJwtPayload({ role: "anon" }, "-")).toThrow( + "unexpected end of JSON input", + ); + }); + + it("reports trailing garbage after a valid number value", () => { + expect(() => legacyMergeBearerJwtPayload({ role: "anon" }, "123abc")).toThrow( + "invalid character 'a' after top-level value", + ); + }); + + it("reports the actual first invalid character for a byte that can never start a value", () => { + expect(() => legacyMergeBearerJwtPayload({ role: "anon" }, "@")).toThrow( + "invalid character '@' looking for beginning of value", + ); + }); + + it("falls back to the generic message when the only valid prefix is the WHOLE trimmed string", () => { + // A leading vertical tab is in JS's `\s` regex class (so `trimmed` strips it) but is + // NOT valid JSON whitespace (so the original `JSON.parse(payload)` call still fails + // on it) — `trimmed` alone ("{}") is then a single complete, valid JSON value with + // nothing left over, which must fall through to the generic message rather than + // reporting on empty leftover content. Built via `String.fromCharCode` rather than a + // literal escape so no raw control byte sits in the source file. + const verticalTab = String.fromCharCode(11); + expect(() => legacyMergeBearerJwtPayload({ role: "anon" }, `${verticalTab}{}`)).toThrow( + "invalid character looking for beginning of value", + ); + }); +}); + +describe("legacyEncodeBearerJwtClaims", () => { + it("serializes claims with alphabetically sorted keys, matching Go's jwt.MapClaims", () => { + const claims = { role: "authenticated", is_anonymous: true, exp: 200, iat: 100 }; + expect(legacyEncodeBearerJwtClaims(claims)).toBe( + '{"exp":200,"iat":100,"is_anonymous":true,"role":"authenticated"}', + ); + }); + + it("HTML-escapes special characters like Go's default json.Marshal", () => { + const claims = { role: "a&c" }; + expect(legacyEncodeBearerJwtClaims(claims)).toBe('{"role":"a\\u003cb\\u003e\\u0026c"}'); + }); + + it("sorts nested object keys recursively too", () => { + const claims = { role: "anon", custom: { z: 1, a: 2 } }; + expect(legacyEncodeBearerJwtClaims(claims)).toBe('{"custom":{"a":2,"z":1},"role":"anon"}'); + }); + + it("keeps Go's true lexicographic order for numeric-looking custom claim keys (CLI-1961 Codex review finding)", () => { + // Go signs a real `jwt.MapClaims` map via `encoding/json`, which sorts string keys + // purely lexicographically ("10" before "2") — verified directly against the Go + // standard library. A plain JS object always reorders integer-like string keys into + // ascending NUMERIC order on enumeration regardless of insertion order, which would + // otherwise silently re-sort "10"/"2" back to "2" before "10" and change the signed + // bytes for these inputs. + const claims = { role: "anon", 10: "a", 2: "b" }; + expect(legacyEncodeBearerJwtClaims(claims)).toBe('{"10":"a","2":"b","role":"anon"}'); + }); +}); diff --git a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.command.ts b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.command.ts index f41e3d6725..6ed3cc47de 100644 --- a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.command.ts +++ b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.command.ts @@ -1,28 +1,77 @@ +import { Layer } from "effect"; import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; +import { stdinLayer } from "../../../../shared/runtime/stdin.layer.ts"; +import { legacyCliConfigLayer } from "../../../config/legacy-cli-config.layer.ts"; +import { legacyDebugLoggerLayer } from "../../../shared/legacy-debug-logger.layer.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; +import { legacyTelemetryStateLayer } from "../../../telemetry/legacy-telemetry-state.layer.ts"; import { legacyGenBearerJwt } from "./bearer-jwt.handler.ts"; +import { legacyParseBearerJwtExp, legacyParseBearerJwtValidFor } from "./bearer-jwt.flags.ts"; const config = { + // Go: `cobra.CheckErr(genJWTCmd.MarkFlagRequired("role"))` (`cmd/gen.go:175`) — but + // cobra's `ValidateRequiredFlags` runs AFTER `PersistentPreRunE` + // (`cobra@v1.10.2/command.go:985,1007`), which is where Go's telemetry service gets + // constructed and later flushed to `telemetry.json` on Execute()'s return path. A + // missing `--role` must still flush telemetry (verified against the real binary: + // CI-1961 e2e parity run showed `telemetry.json` written on this exact failure). + // The framework's own `MissingOption` parse-time rejection (`normalize-error.ts`) + // would short-circuit before this command's handler — and its + // `Effect.ensuring(telemetryState.flush)` — ever runs, so `role` stays optional at + // parse time and presence is enforced in the handler instead, same established + // pattern as `vanity-subdomains activate`'s `--desired-subdomain` + // (`activate.command.ts`/`activate.handler.ts`). role: Flag.string("role").pipe(Flag.withDescription("Postgres role to use."), Flag.optional), + // Go's `DefValue` is cosmetically overwritten to "anonymous" (`cmd/gen.go:177`) but the + // bound variable's real default stays "" — verified against the real binary, an + // omitted `--sub` never puts a `sub` claim in the token at all. sub: Flag.string("sub").pipe(Flag.withDescription("User ID to impersonate."), Flag.optional), exp: Flag.string("exp").pipe( - Flag.withDescription("Expiry timestamp for this token (RFC3339 format)."), + Flag.withDescription("Expiry timestamp for this token."), + Flag.mapTryCatch( + (value) => legacyParseBearerJwtExp(value), + (err) => (err instanceof Error ? err.message : String(err)), + ), Flag.optional, ), validFor: Flag.string("valid-for").pipe( Flag.withDescription("Validity duration for this token."), - Flag.optional, + Flag.withDefault("30m"), + Flag.mapTryCatch( + (value) => legacyParseBearerJwtValidFor(value), + (err) => (err instanceof Error ? err.message : String(err)), + ), ), payload: Flag.string("payload").pipe( Flag.withDescription("Custom claims in JSON format."), - Flag.optional, + Flag.withDefault("{}"), ), } as const; export type LegacyGenBearerJwtFlags = CliCommand.Command.Config.Infer; +const legacyGenBearerJwtRuntimeLayer = Layer.mergeAll( + legacyDebugLoggerLayer, + legacyCliConfigLayer.pipe(Layer.provide(legacyDebugLoggerLayer)), + legacyTelemetryStateLayer, + commandRuntimeLayer(["gen", "bearer-jwt"]), + // Branch A's stdin JWK prompt and Branch B's stdin kid prompt (`getSigningKey`, + // `bearerjwt.go:37-68`) both read piped stdin even on a non-TTY, same as + // `gen signing-key`'s overwrite confirmation. + stdinLayer, +); + export const legacyGenBearerJwtCommand = Command.make("bearer-jwt", config).pipe( - Command.withDescription("Generate a Bearer Auth JWT for accessing Data API."), + Command.withDescription("Generate a Bearer Auth JWT for accessing Data API"), Command.withShortDescription("Generate a Bearer Auth JWT for accessing Data API"), - Command.withHandler((flags) => legacyGenBearerJwt(flags)), + Command.withHandler((flags) => + legacyGenBearerJwt(flags).pipe( + withLegacyCommandInstrumentation({ flags }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyGenBearerJwtRuntimeLayer), ); diff --git a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.errors.ts b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.errors.ts new file mode 100644 index 0000000000..d2522fa035 --- /dev/null +++ b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.errors.ts @@ -0,0 +1,103 @@ +import { Data } from "effect"; + +/** + * Extracts a display message from a thrown `cause`. Every `Effect.try` catch in this + * command's handler/signing-key resolver wraps a function that only ever throws a real + * `Error` (never a plain string/object) — but `catch` still types `cause` as `unknown`, + * so the `instanceof` check stays. Pulled out here (rather than inlined at each call + * site) so neither `bearer-jwt.handler.ts` nor `bearer-jwt.signing-key.ts` carries this + * branch itself for coverage purposes — it's exercised directly by this file's own + * unit tests instead. + */ +export function legacyBearerJwtErrorMessage(cause: unknown): string { + return cause instanceof Error ? cause.message : String(cause); +} + +/** + * Go marks `--role` required (`cmd/gen.go:175`), but cobra's `ValidateRequiredFlags` + * runs only AFTER `PersistentPreRunE` — which is where telemetry gets set up and later + * flushed (`cobra@v1.10.2/command.go:985,1007`). Enforced in the handler (after the + * telemetry-flushing wrapper is already active) rather than at parse time, so this + * failure still flushes `telemetry.json` like Go does. Byte-matches cobra's exact + * `required flag(s) "role" not set` wording, with no usage block (`SilenceUsage` is + * already set by the time `ValidateRequiredFlags` runs) and no `"Error: "` prefix + * (`cmd/root.go`'s `SilenceErrors: true` means cobra never prints its own prefix; + * `recoverAndExit` prints the bare message) — verified against the real binary. + */ +export class LegacyGenBearerJwtRoleRequiredError extends Data.TaggedError( + "LegacyGenBearerJwtRoleRequiredError", +)<{ + readonly message: string; +}> {} + +/** `supabase/config.toml` itself is malformed. Mirrors `gen signing-key`'s own error shape. */ +export class LegacyGenBearerJwtConfigParseError extends Data.TaggedError( + "LegacyGenBearerJwtConfigParseError", +)<{ + readonly message: string; +}> {} + +/** `[auth].signing_keys_path` is configured but the file could not be read. */ +export class LegacyGenBearerJwtReadError extends Data.TaggedError("LegacyGenBearerJwtReadError")<{ + readonly message: string; +}> {} + +/** `[auth].signing_keys_path`'s file is not valid JSON / not a JWK array. */ +export class LegacyGenBearerJwtDecodeError extends Data.TaggedError( + "LegacyGenBearerJwtDecodeError", +)<{ + readonly message: string; +}> {} + +/** + * Go's `getSigningKey` Branch A (`bearerjwt.go:46-50`): the pasted stdin JWK is not + * valid JSON. Byte-matches `"failed to parse JWK: %w"`. + */ +export class LegacyGenBearerJwtKeyParseError extends Data.TaggedError( + "LegacyGenBearerJwtKeyParseError", +)<{ + readonly message: string; +}> {} + +/** + * Go's `getSigningKey` Branch B (`bearerjwt.go:67`): the entered kid matched no + * configured signing key. Byte-matches `"signing key not found: %s"`. + */ +export class LegacyGenBearerJwtKeyNotFoundError extends Data.TaggedError( + "LegacyGenBearerJwtKeyNotFoundError", +)<{ + readonly message: string; +}> {} + +/** + * Go's `getSigningKey` Branch C (`bearerjwt.go:70-79`): the TTY key picker + * (`utils.PromptChoice`, `internal/utils/prompt.go:120-140`) given ZERO available + * keys quits immediately without ever letting the user select anything. Byte-matches + * Go's bare, unwrapped `"user aborted"` — `getSigningKey` returns `PromptChoice`'s + * error as-is, with no additional wrapping. + */ +export class LegacyGenBearerJwtKeyPickerAbortedError extends Data.TaggedError( + "LegacyGenBearerJwtKeyPickerAbortedError", +)<{ + readonly message: string; +}> {} + +/** + * Go's `parseClaims` payload merge (`cmd/gen.go:209-211`). Byte-matches + * `"failed to parse payload: %w"`. + */ +export class LegacyGenBearerJwtPayloadError extends Data.TaggedError( + "LegacyGenBearerJwtPayloadError", +)<{ + readonly message: string; +}> {} + +/** + * Go's `config.GenerateAsymmetricJWT` (`pkg/config/apikeys.go:88-113`) — unsupported + * key type/curve/algorithm, or a kty-vs-alg mismatch caught at sign time. The message + * is `legacySignJwtWithJwk`'s own text, surfaced verbatim (Go's `bearerjwt.Run` + * returns this error bare, with no additional wrapping — `bearerjwt.go:27-30`). + */ +export class LegacyGenBearerJwtSignError extends Data.TaggedError("LegacyGenBearerJwtSignError")<{ + readonly message: string; +}> {} diff --git a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.errors.unit.test.ts b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.errors.unit.test.ts new file mode 100644 index 0000000000..0af9e9e85e --- /dev/null +++ b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.errors.unit.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; +import { legacyBearerJwtErrorMessage } from "./bearer-jwt.errors.ts"; + +describe("legacyBearerJwtErrorMessage", () => { + it("extracts .message from a real Error instance", () => { + expect(legacyBearerJwtErrorMessage(new Error("boom"))).toBe("boom"); + }); + + it("stringifies a non-Error cause", () => { + expect(legacyBearerJwtErrorMessage("plain string cause")).toBe("plain string cause"); + expect(legacyBearerJwtErrorMessage(42)).toBe("42"); + }); +}); diff --git a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.flags.ts b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.flags.ts new file mode 100644 index 0000000000..cdbd1459db --- /dev/null +++ b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.flags.ts @@ -0,0 +1,259 @@ +import { legacyParseGoDuration } from "../../../shared/legacy-go-duration.ts"; +import { legacyBearerJwtErrorMessage } from "./bearer-jwt.errors.ts"; + +// The fractional-seconds separator accepts EITHER `.` or `,` — Go's `time.Parse` +// (`time/format.go`'s `nextStdChunk`/digit-parsing loop) treats both as introducing a +// fractional second for any layout element, verified directly against the Go standard +// library: `time.Parse(time.RFC3339, "2030-01-01T00:00:00,5Z")` succeeds with the same +// nanosecond result as the `.5` spelling (CLI-1961 Codex review finding). +const RFC3339_PATTERN = + /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:[.,](\d+))?(?:(Z)|([+-])(\d{2}):(\d{2}))$/; + +/** + * A Unix instant split into an exact whole-second floor and a non-negative + * nanosecond remainder in `[0, 1e9)` — mirrors Go's own `time.Time` (a + * whole-seconds field plus a separate nanoseconds field) instead of + * collapsing both into a single `number`. A single float CANNOT hold both an + * epoch-scale whole-second count (~2e9, already ~31 bits) and full + * nanosecond precision (9 decimal digits) without silent rounding: verified + * directly (CLI-1961 Codex review finding, `bearer-jwt.flags.ts:154`), + * `1_893_456_000 + 0.999999999` rounds UP to the exact integer + * `1_893_456_001` in plain JS float addition — a full second later than + * Go's `jwt.NewNumericDate`, which truncates the real (nanosecond-preserving) + * `time.Time` DOWN to `1_893_456_000`. Both fields here are plain safe + * integers (`wholeSeconds` is many orders of magnitude below + * `Number.MAX_SAFE_INTEGER`; `nanos` is always `< 1e9`), so every operation on + * this type — see {@link legacyAddSecondsAndFloor} — is exact integer + * arithmetic, never float rounding. + */ +export interface LegacyBearerJwtInstant { + readonly wholeSeconds: number; + readonly nanos: number; +} + +const NANOS_PER_SECOND = 1_000_000_000; + +/** + * Adds a (possibly fractional, possibly negative) duration in seconds to an + * exact {@link LegacyBearerJwtInstant} and returns the correctly-floored + * whole-second result — mirrors Go's exact nanosecond-precision `time.Time` + * arithmetic followed by `jwt.NewNumericDate`'s truncate-to-seconds + * (`golang-jwt/jwt/v5`'s `types.go:38`), without ever adding an epoch-scale + * whole-second count directly to a sub-second float (see + * {@link LegacyBearerJwtInstant}'s own doc comment for why that rounds + * incorrectly). `deltaSeconds` itself (`--valid-for`, parsed by + * {@link legacyParseBearerJwtValidFor}) stays a plain float — its own + * magnitude is never epoch-scale, so splitting it into whole/fractional parts + * here is exact enough — only the addition against an epoch-scale instant + * needs the exact-integer treatment. + */ +export function legacyAddSecondsAndFloor( + instant: LegacyBearerJwtInstant, + deltaSeconds: number, +): number { + const deltaWhole = Math.floor(deltaSeconds); + const deltaNanos = Math.round((deltaSeconds - deltaWhole) * NANOS_PER_SECOND); + let wholeSeconds = instant.wholeSeconds + deltaWhole; + let nanos = instant.nanos + deltaNanos; + if (nanos < 0) { + const borrow = Math.ceil(-nanos / NANOS_PER_SECOND); + wholeSeconds -= borrow; + nanos += borrow * NANOS_PER_SECOND; + } else if (nanos >= NANOS_PER_SECOND) { + const carry = Math.floor(nanos / NANOS_PER_SECOND); + wholeSeconds += carry; + nanos -= carry * NANOS_PER_SECOND; + } + return wholeSeconds; +} + +const DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + +function isLeapYear(year: number): boolean { + return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0; +} + +function daysInMonth(year: number, month: number): number { + return month === 2 && isLeapYear(year) ? 29 : DAYS_IN_MONTH[month - 1]!; +} + +/** + * Validates the calendar/clock components a syntactically-RFC3339 string decodes to, + * matching Go's `time.Parse(time.RFC3339, ...)` — which, verified directly against + * the Go standard library (CLI-1961), genuinely REJECTS an out-of-range month, day + * (including per-month/leap-year day-of-month bounds), hour, minute, or second, + * rather than normalizing them the way `time.Date`/JS's own `Date.parse` would (e.g. + * `2030-02-30T...` silently rolling over to March 2nd). Go's `time.Parse` itself + * raises a component-specific error (`"day out of range"`, etc.), but pflag's + * `timeValue.Set` (`github.com/spf13/pflag@v1.0.10/time.go:24-44`) discards that text + * entirely and falls through to the SAME generic wrapped message used for a + * syntactically-malformed value — so this only needs a boolean, not Go's per-field + * error text. + */ +function isValidRfc3339Calendar( + year: number, + month: number, + day: number, + hour: number, + minute: number, + second: number, +): boolean { + return ( + month >= 1 && + month <= 12 && + day >= 1 && + day <= daysInMonth(year, month) && + hour <= 23 && + minute <= 59 && + second <= 59 + ); +} + +function rfc3339FlagError(trimmedValue: string): Error { + return new Error( + `invalid argument "${trimmedValue}" for "--exp" flag: invalid time format \`${trimmedValue}\` must be one of: \`2006-01-02T15:04:05Z07:00\``, + ); +} + +/** + * Go's `--exp` flag (`TimeVar(&expiry, "exp", time.Time{}, []string{time.RFC3339}, ...)`, + * `apps/cli-go/cmd/gen.go:178`) calls `time.Parse(time.RFC3339, val)` inside pflag's + * `Value.Set`, which runs during `cmd.ParseFlags` — BEFORE `RunE` (verified against the + * real binary, CLI-1961). Behaviors verified directly against pflag's/Go's own source + * rather than assumed: + * - `Value.Set` trims the input with `strings.TrimSpace` BEFORE ever calling + * `time.Parse`, so `--exp " 2030-01-01T00:00:00Z "` (surrounding whitespace) + * parses successfully — the trimmed value is used both for parsing and for the + * error message below (Go re-embeds ITS OWN already-trimmed `s`, never the + * original untrimmed argument). + * - A parse failure — whether syntactic, a Go-rejected out-of-range calendar + * component (`isValidRfc3339Calendar` above), or an out-of-range zone offset + * (below) — raises the exact same wrapped message: `invalid argument "" for + * "--exp" flag: invalid time format \`\` must be one of: + * \`2006-01-02T15:04:05Z07:00\`` (a single format, since `--exp` registers only + * RFC3339). + * - `time.Parse`'s own numeric zone-offset range check (`time/format.go:1267-1278`) + * rejects an offset hour/minute that OVERFLOWS a 2-digit field's max plausible + * value using `>` rather than `>=` ("as some people do write offsets of 24 hours + * or 60 minutes", per Go's own comment) — so `+24:00` parses successfully but + * `+99:99` (Codex review finding, CLI-1961) does not. Verified directly against + * the Go standard library. `Date.parse` cannot be reused for the final + * timestamp once an offset is present: it rejects `+24:00`/`+60`-minute offsets + * that Go accepts, and — more importantly — silently returns `NaN` for a + * genuinely-invalid offset like `+99:99` instead of throwing, which would let a + * malformed `--exp` mint a token with `exp`/`iat` claims that JSON-serialize as + * `null` (`JSON.stringify(NaN) === "null"`) rather than failing the command. + * Reimplements Go's `t.addSec(-zoneOffset)` (`format.go:1392`) directly instead: + * the local wall-clock components, interpreted as UTC, minus the signed offset + * in seconds. + * - Go's `time.Parse` accepts fractional seconds after the whole-seconds field EVEN + * THOUGH `time.RFC3339`'s own layout has no fractional-seconds directive — this is + * a documented parse-only extension ("in the absence of a fractional second in the + * format, the fractional part will still be parsed if it is present"), verified + * directly against the Go standard library. Extra digits beyond nanosecond (9-digit) + * precision are TRUNCATED, not rounded — verified directly against the Go standard + * library: `.9999999995` (10 digits) parses to nanosecond `999999999`, not a + * rounded-up `1000000000` that would carry into the next second. The parsed instant + * carries that fraction at full (nanosecond) precision into the `iat = exp - + * validFor` arithmetic in `legacyBuildBearerJwtClaims`, which floors only the FINAL + * `exp`/`iat` — so the fraction must survive this function's return value rather + * than being discarded here, matching `legacyParseBearerJwtValidFor`'s own + * no-early-flooring rule below. Verified against the real binary (CLI-1961): + * `--exp 2030-01-01T00:00:00.9Z --valid-for 1.2s` yields `iat=1893455999`, not the + * `1893455998` that dropping the `.9` fraction during parsing would produce. + * Returns the parsed instant as an exact {@link LegacyBearerJwtInstant} — NOT a single + * float — on success. See that type's own doc comment for why a single `number` cannot + * hold both an epoch-scale whole-second count and nanosecond precision without silent + * rounding (CLI-1961 Codex review finding, this file's own former line 154). + */ +export function legacyParseBearerJwtExp(value: string): LegacyBearerJwtInstant { + const trimmedValue = value.trim(); + const match = RFC3339_PATTERN.exec(trimmedValue); + if (match === null) { + throw rfc3339FlagError(trimmedValue); + } + const [ + , + year, + month, + day, + hour, + minute, + second, + fraction, + isUtc, + offsetSign, + offsetHourStr, + offsetMinuteStr, + ] = match; + if ( + !isValidRfc3339Calendar( + Number(year), + Number(month), + Number(day), + Number(hour), + Number(minute), + Number(second), + ) + ) { + throw rfc3339FlagError(trimmedValue); + } + + let offsetSeconds = 0; + if (isUtc === undefined) { + const offsetHour = Number(offsetHourStr); + const offsetMinute = Number(offsetMinuteStr); + if (offsetHour > 24 || offsetMinute > 60) { + throw rfc3339FlagError(trimmedValue); + } + offsetSeconds = (offsetHour * 60 + offsetMinute) * 60; + if (offsetSign === "-") offsetSeconds = -offsetSeconds; + } + + // `setUTCFullYear`/`setUTCHours` here, NOT `Date.UTC(...)`/`new Date(...)` — those + // two apply JS's legacy two-digit-year remapping (`Date.UTC(1, 0, 1, ...)` silently + // becomes 1901, not year 1) to any year in `[0, 99]`, which is a genuinely valid + // 4-digit RFC3339 year Go's `time.Parse` accepts LITERALLY (verified against the Go + // standard library: `0001-01-01T00:00:00Z` parses to Go year 1, Unix + // `-62135596800`) — CLI-1961 Codex review finding. `Date.prototype.setUTCFullYear` + // has no such special case at any year, per ECMA-262 (unlike the `Date.UTC`/`Date` + // constructor forms), so building the instant via the epoch `Date` and setter calls + // avoids the remapping entirely while still being exact (always a multiple of 1000, + // since only whole-second components are passed in) — and `offsetSeconds` above is + // always an exact integer number of seconds (a whole hour/minute offset), so + // `wholeSeconds` needs no fractional handling at all. Only the fractional-second + // digits themselves need a dedicated integer field: truncate (not round) to 9 + // digits, matching Go's own truncation of excess fractional digits verified above. + const parsedDate = new Date(0); + parsedDate.setUTCFullYear(Number(year), Number(month) - 1, Number(day)); + parsedDate.setUTCHours(Number(hour), Number(minute), Number(second), 0); + const wholeSeconds = parsedDate.getTime() / 1000 - offsetSeconds; + const nanos = fraction === undefined ? 0 : Number(fraction.slice(0, 9).padEnd(9, "0")); + return { wholeSeconds, nanos }; +} + +/** + * Go's `--valid-for` flag (`DurationVar(&validFor, "valid-for", time.Minute*30, ...)`, + * `apps/cli-go/cmd/gen.go:179`) — same parse-time-failure shape as `--exp` above, + * wrapping `legacyParseGoDuration`'s own Go-format `time: invalid duration "..."` text. + * `time.Duration`'s own pflag `Value.Set` does NOT trim its input (unlike `--exp`'s + * `timeValue.Set` above) — verified against pflag's source — so no `.trim()` here. + * + * Returns SECONDS WITHOUT FLOORING — Go computes `exp`/`iat` via exact-nanosecond + * `time.Time` arithmetic on the parsed `time.Duration` and only floors the FINAL + * timestamps (`jwt.NewNumericDate`'s `Truncate`, see `legacyBuildBearerJwtClaims`). + * Flooring the duration itself here, before that arithmetic runs, would produce an + * off-by-one-second result whenever the truncated fraction pushes the final sum/ + * difference across a second boundary — verified against the real binary (CLI-1961): + * `--exp 2030-01-01T00:00:00Z --valid-for 1.5s` yields Go `iat=1893455998`, not the + * `1893455999` a floor-first implementation would produce. + */ +export function legacyParseBearerJwtValidFor(value: string): number { + try { + return legacyParseGoDuration(value) / 1_000_000_000; + } catch (cause) { + throw new Error( + `invalid argument "${value}" for "--valid-for" flag: ${legacyBearerJwtErrorMessage(cause)}`, + ); + } +} diff --git a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.flags.unit.test.ts b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.flags.unit.test.ts new file mode 100644 index 0000000000..6a803a251b --- /dev/null +++ b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.flags.unit.test.ts @@ -0,0 +1,259 @@ +import { describe, expect, it } from "vitest"; +import { + legacyAddSecondsAndFloor, + legacyParseBearerJwtExp, + legacyParseBearerJwtValidFor, +} from "./bearer-jwt.flags.ts"; + +describe("legacyParseBearerJwtExp", () => { + it("parses a UTC RFC3339 timestamp to Unix seconds", () => { + expect(legacyParseBearerJwtExp("2020-01-01T00:00:00Z")).toEqual({ + wholeSeconds: 1_577_836_800, + nanos: 0, + }); + }); + + it("honors a non-zero numeric offset", () => { + // "+05:00" means local wall-clock time is 5 hours AHEAD of UTC, so the same wall + // time is an EARLIER instant than at "Z" — verified against the real binary + // (CLI-1961): 2030-01-01T00:00:00+05:00 -> exp 1893438000, ...Z -> exp 1893456000. + const withOffset = legacyParseBearerJwtExp("2030-01-01T00:00:00+05:00"); + const atZ = legacyParseBearerJwtExp("2030-01-01T00:00:00Z"); + expect(withOffset).toEqual({ wholeSeconds: atZ.wholeSeconds - 5 * 60 * 60, nanos: 0 }); + }); + + it("rejects a malformed value with pflag's exact wrapped message", () => { + expect(() => legacyParseBearerJwtExp("notatime")).toThrow( + 'invalid argument "notatime" for "--exp" flag: invalid time format `notatime` must be one of: `2006-01-02T15:04:05Z07:00`', + ); + }); + + it("rejects a value missing the required timezone offset", () => { + expect(() => legacyParseBearerJwtExp("2020-01-01T00:00:00")).toThrow( + 'invalid argument "2020-01-01T00:00:00" for "--exp" flag:', + ); + }); + + it("rejects an invalid calendar date instead of silently rolling it over (CLI-1961)", () => { + // Verified directly against Go's `time.Parse`: `2030-02-30` genuinely errors + // ("day out of range"), it does NOT roll over to March 2nd the way `Date.parse` + // does — Go's pflag wrapper discards that specific error text and falls back to + // the same generic message used for a syntactically-malformed value. + expect(() => legacyParseBearerJwtExp("2030-02-30T03:04:05Z")).toThrow( + 'invalid argument "2030-02-30T03:04:05Z" for "--exp" flag: invalid time format `2030-02-30T03:04:05Z` must be one of: `2006-01-02T15:04:05Z07:00`', + ); + }); + + it("rejects February 29th in a non-leap year but accepts it in a leap year", () => { + expect(() => legacyParseBearerJwtExp("1900-02-29T00:00:00Z")).toThrow( + 'invalid argument "1900-02-29T00:00:00Z" for "--exp" flag:', + ); + expect(legacyParseBearerJwtExp("2000-02-29T00:00:00Z")).toEqual({ + wholeSeconds: 951782400, + nanos: 0, + }); + }); + + it("rejects an out-of-range hour/minute/second the same way Go's time.Parse does", () => { + expect(() => legacyParseBearerJwtExp("2030-01-01T25:00:00Z")).toThrow( + '"--exp" flag: invalid time format', + ); + expect(() => legacyParseBearerJwtExp("2030-01-01T00:60:00Z")).toThrow( + '"--exp" flag: invalid time format', + ); + expect(() => legacyParseBearerJwtExp("2030-01-01T00:00:60Z")).toThrow( + '"--exp" flag: invalid time format', + ); + }); + + it("trims surrounding whitespace before parsing, matching pflag's strings.TrimSpace", () => { + expect(legacyParseBearerJwtExp(" 2030-01-01T00:00:00Z ")).toEqual( + legacyParseBearerJwtExp("2030-01-01T00:00:00Z"), + ); + }); + + it("embeds the TRIMMED value (not the raw argument) in the error message", () => { + expect(() => legacyParseBearerJwtExp(" notatime ")).toThrow( + 'invalid argument "notatime" for "--exp" flag: invalid time format `notatime` must be one of: `2006-01-02T15:04:05Z07:00`', + ); + }); + + it("rejects an out-of-range zone offset instead of silently signing a null exp/iat (CLI-1961 Codex review finding)", () => { + // Before this fix: the calendar check never looked at the offset at all, so + // `+99:99` passed validation, `Date.parse` returned `NaN`, and the caller signed + // a token whose `exp`/`iat` claims serialized as JSON `null` + // (`JSON.stringify(NaN) === "null"`) instead of failing the command — verified + // against the real binary, which rejects this exact input during flag parsing. + expect(() => legacyParseBearerJwtExp("2030-01-01T00:00:00+99:99")).toThrow( + 'invalid argument "2030-01-01T00:00:00+99:99" for "--exp" flag: invalid time format `2030-01-01T00:00:00+99:99` must be one of: `2006-01-02T15:04:05Z07:00`', + ); + }); + + it("tolerates a 24-hour/60-minute offset the same way Go's time.Parse does (`>` not `>=`)", () => { + // Go's own comment (`time/format.go:1267-1269`): "The range test use > rather + // than >=, as some people do write offsets of 24 hours or 60 minutes" — verified + // directly against the Go standard library. + expect(legacyParseBearerJwtExp("2030-01-01T00:00:00+24:00")).toEqual({ + wholeSeconds: legacyParseBearerJwtExp("2030-01-01T00:00:00Z").wholeSeconds - 24 * 60 * 60, + nanos: 0, + }); + expect(legacyParseBearerJwtExp("2030-01-01T00:00:00+00:60")).toEqual({ + wholeSeconds: legacyParseBearerJwtExp("2030-01-01T00:00:00Z").wholeSeconds - 60 * 60, + nanos: 0, + }); + }); + + it("rejects an offset that overflows even Go's 24-hour/60-minute tolerance", () => { + expect(() => legacyParseBearerJwtExp("2030-01-01T00:00:00+25:00")).toThrow( + '"--exp" flag: invalid time format', + ); + expect(() => legacyParseBearerJwtExp("2030-01-01T00:00:00+00:61")).toThrow( + '"--exp" flag: invalid time format', + ); + }); + + it("preserves fractional seconds instead of dropping them during parsing (CLI-1961 Codex review finding)", () => { + // Go's `time.Parse(time.RFC3339, ...)` accepts (and preserves at full precision) + // fractional seconds even though `time.RFC3339`'s own layout has no fractional + // directive — verified against the Go standard library. Dropping the `.9` here + // (this port's previous behavior) would produce nanos `0` instead of `900_000_000`. + expect(legacyParseBearerJwtExp("2030-01-01T00:00:00.9Z")).toEqual({ + wholeSeconds: 1_893_456_000, + nanos: 900_000_000, + }); + }); + + it("preserves a fractional offset the same way for a non-UTC zone", () => { + const withFraction = legacyParseBearerJwtExp("2030-01-01T00:00:00.5+05:00"); + const atZ = legacyParseBearerJwtExp("2030-01-01T00:00:00Z"); + expect(withFraction).toEqual({ + wholeSeconds: atZ.wholeSeconds - 5 * 60 * 60, + nanos: 500_000_000, + }); + }); + + it("preserves a near-second nanosecond fraction as an exact integer instead of rounding it into the next second (CLI-1961 Codex review finding)", () => { + // Verified directly: a naive `wholeSeconds + Number('0.999999999')` float addition + // rounds UP to the exact integer `wholeSeconds + 1` in plain JS float arithmetic — + // Go's `time.Time` keeps the nanoseconds in a separate integer field and + // `jwt.NewNumericDate`'s `Truncate` floors DOWN, so the true parsed instant must + // report `nanos: 999_999_999` here, not silently become `wholeSeconds + 1, nanos: 0`. + expect(legacyParseBearerJwtExp("2030-01-01T00:00:00.999999999Z")).toEqual({ + wholeSeconds: 1_893_456_000, + nanos: 999_999_999, + }); + }); + + it("truncates (not rounds) fractional digits beyond nanosecond precision, matching Go's time.Parse", () => { + // Verified directly against the Go standard library: `.9999999995` (10 digits) + // parses to nanosecond `999999999`, not a rounded-up `1000000000` that would carry + // into the next second. + expect(legacyParseBearerJwtExp("2030-01-01T00:00:00.9999999995Z")).toEqual({ + wholeSeconds: 1_893_456_000, + nanos: 999_999_999, + }); + }); + + it("accepts a comma as the fractional-seconds separator, matching Go's time.Parse (CLI-1961 Codex review finding)", () => { + // Verified directly against the Go standard library: `time.Parse(time.RFC3339, + // "2030-01-01T00:00:00,5Z")` succeeds with the same nanosecond result as the `.5` + // spelling — Go's parser accepts either `.` or `,` as the fractional-seconds + // separator for any layout element. + expect(legacyParseBearerJwtExp("2030-01-01T00:00:00,5Z")).toEqual( + legacyParseBearerJwtExp("2030-01-01T00:00:00.5Z"), + ); + }); + + it("accepts a comma fraction alongside a non-UTC zone offset too", () => { + expect(legacyParseBearerJwtExp("2030-01-01T00:00:00,5+05:00")).toEqual( + legacyParseBearerJwtExp("2030-01-01T00:00:00.5+05:00"), + ); + }); + + it("parses an early (0000-0099) RFC3339 year literally instead of applying JS's two-digit-year remapping (CLI-1961 Codex review finding)", () => { + // Verified directly against the Go standard library: `0001-01-01T00:00:00Z` parses + // to Go year 1, Unix `-62135596800` — `Date.UTC`/`new Date(...)`'s legacy + // two-digit-year special case (year `1` silently becomes `1901`) does NOT apply + // here, since this is a genuine 4-digit RFC3339 year, not a 2-digit shorthand. + expect(legacyParseBearerJwtExp("0001-01-01T00:00:00Z")).toEqual({ + wholeSeconds: -62_135_596_800, + nanos: 0, + }); + expect(legacyParseBearerJwtExp("0099-01-01T00:00:00Z")).toEqual({ + wholeSeconds: -59_042_995_200, + nanos: 0, + }); + expect(legacyParseBearerJwtExp("0000-01-01T00:00:00Z")).toEqual({ + wholeSeconds: -62_167_219_200, + nanos: 0, + }); + }); + + it("still parses years at and above 0100 the same way as before (no regression from the year-remapping fix)", () => { + expect(legacyParseBearerJwtExp("0100-01-01T00:00:00Z")).toEqual({ + wholeSeconds: -59_011_459_200, + nanos: 0, + }); + }); +}); + +describe("legacyParseBearerJwtValidFor", () => { + it("parses a Go duration string to whole seconds", () => { + expect(legacyParseBearerJwtValidFor("30m")).toBe(1800); + expect(legacyParseBearerJwtValidFor("1h")).toBe(3600); + }); + + it("accepts a negative duration, matching Go's unchecked arithmetic", () => { + expect(legacyParseBearerJwtValidFor("-5m")).toBe(-300); + }); + + it("preserves sub-second precision instead of flooring it away (CLI-1961)", () => { + // Flooring here (this port's previous behavior) would silently discard the 0.5s + // fraction before it ever reaches `legacyBuildBearerJwtClaims`'s final truncation. + expect(legacyParseBearerJwtValidFor("1.5s")).toBe(1.5); + }); + + it("rejects a malformed value with pflag's exact wrapped message", () => { + expect(() => legacyParseBearerJwtValidFor("xyz")).toThrow( + 'invalid argument "xyz" for "--valid-for" flag: time: invalid duration "xyz"', + ); + }); + + it("does NOT trim surrounding whitespace, unlike --exp (pflag's duration Value.Set has no TrimSpace)", () => { + expect(() => legacyParseBearerJwtValidFor(" 30m ")).toThrow( + 'invalid argument " 30m " for "--valid-for" flag: time: invalid duration " 30m "', + ); + }); + + it("accepts the Greek-mu microsecond spelling, matching Go's time.ParseDuration (CLI-1961 Codex review finding)", () => { + // Go's `unitMap` accepts "us", "µs" (U+00B5), and "μs" (U+03BC Greek mu) alike — + // verified directly against the Go standard library. + expect(legacyParseBearerJwtValidFor("1μs")).toBe(0.000_001); + }); +}); + +describe("legacyAddSecondsAndFloor", () => { + it("adds a whole-second delta with no carry", () => { + expect(legacyAddSecondsAndFloor({ wholeSeconds: 100, nanos: 0 }, 5)).toBe(105); + }); + + it("carries into the next second when nanos overflow 1e9", () => { + // Mirrors the CLI-1961 Codex review finding (`--exp` omitted, sub-second + // `--valid-for`): a `now` of `X.900` plus a `0.2s` delta must land in the NEXT + // second (`X + 1`), not stay in the current one. + expect(legacyAddSecondsAndFloor({ wholeSeconds: 100, nanos: 900_000_000 }, 0.2)).toBe(101); + }); + + it("borrows from the previous second when the combined nanos go negative", () => { + expect(legacyAddSecondsAndFloor({ wholeSeconds: 100, nanos: 200_000_000 }, -0.5)).toBe(99); + }); + + it("never rounds an epoch-scale whole-second count up via float addition (CLI-1961 Codex review finding)", () => { + // The exact regression this helper exists to prevent: naive + // `wholeSeconds + fraction` float addition at epoch scale rounds a near-second + // fraction UP into the next integer. + expect(legacyAddSecondsAndFloor({ wholeSeconds: 1_893_456_000, nanos: 999_999_999 }, 0)).toBe( + 1_893_456_000, + ); + }); +}); diff --git a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.handler.ts b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.handler.ts index 24dd26251a..b439b612f0 100644 --- a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.handler.ts +++ b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.handler.ts @@ -1,16 +1,106 @@ -import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { Effect, FileSystem, Option, Path } from "effect"; +import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; +import { legacyLoadProjectEnv } from "../../../shared/legacy-db-config.toml-read.ts"; +import { legacySignJwtWithJwk } from "../../../shared/legacy-go-jwt.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; import type { LegacyGenBearerJwtFlags } from "./bearer-jwt.command.ts"; +import { + legacyBuildBearerJwtClaims, + legacyEncodeBearerJwtClaims, + legacyMergeBearerJwtPayload, +} from "./bearer-jwt.claims.ts"; +import { + legacyBearerJwtErrorMessage, + LegacyGenBearerJwtPayloadError, + LegacyGenBearerJwtRoleRequiredError, + LegacyGenBearerJwtSignError, +} from "./bearer-jwt.errors.ts"; +import { legacyResolveBearerJwtSigningKey } from "./bearer-jwt.signing-key.ts"; +/** + * Go's `gen bearer-jwt` (`apps/cli-go/cmd/gen.go:132-143` + `internal/gen/bearerjwt/bearerjwt.go`): + * fully local, no Docker, no network. Order matches Go exactly: + * + * 0. `ValidateRequiredFlags` (`cobra@v1.10.2/command.go:1007`, ported as the + * `flags.role` check just below) — runs after cobra's `PersistentPreRunE` + * (telemetry setup) but before `RunE`/`parseClaims`, so a missing `--role` still + * flushes `telemetry.json` (see {@link LegacyGenBearerJwtRoleRequiredError}). + * 1. `parseClaims` (`cmd/gen.go:136-141`, ported as {@link legacyBuildBearerJwtClaims} + + * {@link legacyMergeBearerJwtPayload}) — runs entirely BEFORE `bearerjwt.Run` is even + * called, so a malformed `--payload` fails before any config load or signing-key + * prompt ever happens. + * 2. `bearerjwt.Run`'s `flags.LoadConfig` (`bearerjwt.go:20`) — loads the project `.env` + * cascade as part of `Config.Load` (see SIDE_EFFECTS.md); ported via + * `legacyLoadProjectEnv` for the same failure mode, even though this command has no + * `.env`-sourced prompt of its own to gate. + * 3. `getSigningKey` (`bearerjwt.go:23`, ported as {@link legacyResolveBearerJwtSigningKey} + * in `bearer-jwt.signing-key.ts`) — resolves a JWK, prompting interactively when + * needed. + * 4. `config.GenerateAsymmetricJWT` (`bearerjwt.go:27`, ported as + * `legacySignJwtWithJwk` in `legacy-go-jwt.ts`) — signs the claims. + * 5. `fmt.Fprintln(w, token)` (`bearerjwt.go:31`) — the token, then exactly one + * trailing newline, on stdout. Nothing else ever reaches stdout; every prompt and + * error goes to stderr. + * + * Unconditional on `--output-format`, matching `gen signing-key`'s own established + * precedent (`signing-key.handler.ts`): the raw token IS the payload — there is no + * separate human/machine shape to choose between, and Go's own command has no + * `-o`/`--output-format` concept at all. + */ export const legacyGenBearerJwt = Effect.fn("legacy.gen.bearer-jwt")(function* ( flags: LegacyGenBearerJwtFlags, ) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["gen", "bearer-jwt"]; - if (Option.isSome(flags.role)) args.push("--role", flags.role.value); - if (Option.isSome(flags.sub)) args.push("--sub", flags.sub.value); - if (Option.isSome(flags.exp)) args.push("--exp", flags.exp.value); - if (Option.isSome(flags.validFor)) args.push("--valid-for", flags.validFor.value); - if (Option.isSome(flags.payload)) args.push("--payload", flags.payload.value); - yield* proxy.exec(args); + const cliConfig = yield* LegacyCliConfig; + const telemetryState = yield* LegacyTelemetryState; + const output = yield* Output; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + return yield* Effect.gen(function* () { + if (Option.isNone(flags.role)) { + return yield* Effect.fail( + new LegacyGenBearerJwtRoleRequiredError({ + message: `required flag(s) "role" not set`, + }), + ); + } + const role = flags.role.value; + + // Built directly from `Date.now()`'s integer milliseconds, NOT floored to whole + // seconds — see `LegacyBearerJwtClaimsInput.nowInstant`'s own doc comment for why + // pre-flooring here would shorten a sub-second `--valid-for`'s effective lifetime + // (CLI-1961 Codex review finding). + const nowMs = Date.now(); + const nowInstant = { + wholeSeconds: Math.floor(nowMs / 1000), + nanos: (nowMs % 1000) * 1_000_000, + }; + const baseClaims = legacyBuildBearerJwtClaims({ + role, + sub: flags.sub, + expiresAt: flags.exp, + validForSeconds: flags.validFor, + nowInstant, + }); + const claims = yield* Effect.try({ + try: () => legacyMergeBearerJwtPayload(baseClaims, flags.payload), + catch: (cause) => + new LegacyGenBearerJwtPayloadError({ + message: `failed to parse payload: ${legacyBearerJwtErrorMessage(cause)}`, + }), + }); + + yield* legacyLoadProjectEnv(fs, path, cliConfig.workdir); + const jwk = yield* legacyResolveBearerJwtSigningKey(cliConfig.workdir); + + const payloadJson = legacyEncodeBearerJwtClaims(claims); + const token = yield* Effect.try({ + try: () => legacySignJwtWithJwk(jwk, payloadJson), + catch: (cause) => + new LegacyGenBearerJwtSignError({ message: legacyBearerJwtErrorMessage(cause) }), + }); + + yield* output.raw(`${token}\n`, "stdout"); + }).pipe(Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.integration.test.ts b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.integration.test.ts new file mode 100644 index 0000000000..4386d80b45 --- /dev/null +++ b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.integration.test.ts @@ -0,0 +1,1457 @@ +import { generateKeyPairSync } from "node:crypto"; +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, Layer, Option } from "effect"; +import { CliOutput, Command } from "effect/unstable/cli"; +import { importJWK, jwtVerify } from "jose"; + +import { + mockAnalytics, + mockOutput, + mockRuntimeInfo, + mockStdin, + mockTty, +} from "../../../../../tests/helpers/mocks.ts"; +import { + buildLegacyTestRuntime, + mockLegacyCliConfig, + mockLegacyPlatformApi, + mockLegacyTelemetryStateTracked, + useLegacyTempWorkdir, +} from "../../../../../tests/helpers/legacy-mocks.ts"; +import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; +import { LEGACY_GLOBAL_FLAGS } from "../../../../shared/legacy/global-flags.ts"; +import { textCliOutputFormatter } from "../../../../shared/output/text-formatter.ts"; +import { processControlLayer } from "../../../../shared/runtime/process-control.layer.ts"; +import { TelemetryRuntime } from "../../../../shared/telemetry/runtime.service.ts"; +import { makeTelemetryIdentity } from "../../../../shared/telemetry/identity.ts"; +import { LegacyDebugLogger } from "../../../shared/legacy-debug-logger.service.ts"; +import { legacyGenCommand } from "../gen.command.ts"; +import type { LegacyGenBearerJwtFlags } from "./bearer-jwt.command.ts"; +import { legacyGenBearerJwt } from "./bearer-jwt.handler.ts"; + +const tempRoot = useLegacyTempWorkdir("supabase-gen-bearer-jwt-int-"); + +const LEGACY_DEFAULT_SIGNING_KEY_PUBLIC = { + kty: "EC", + crv: "P-256", + x: "M5Sjqn5zwC9Kl1zVfUUGvv9boQjCGd45G8sdopBExB4", + y: "P6IXMvA2WYXSHSOMTBH2jsw_9rrzGy89FjPf6oOsIxQ", +}; + +function generateEcJwk(kid: string): Record { + const { privateKey } = generateKeyPairSync("ec", { namedCurve: "P-256" }); + const jwk = privateKey.export({ format: "jwk" }) as Record; + return { ...jwk, kty: "EC", alg: "ES256", kid }; +} + +function generateRsaJwk(kid: string) { + const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); + const jwk = privateKey.export({ format: "jwk" }) as Record; + return { ...jwk, kty: "RSA", alg: "RS256", kid }; +} + +function publicJwkOf(jwk: Record): Record { + const { d: _d, p: _p, q: _q, dp: _dp, dq: _dq, qi: _qi, ...publicJwk } = jwk; + return publicJwk; +} + +interface SetupOptions { + readonly stdinIsTty?: boolean; + readonly pipedAnswer?: string; + readonly promptSelectResponses?: ReadonlyArray; + readonly trackTelemetry?: boolean; + /** Overrides `cliConfig.workdir` — defaults to `tempRoot.current`. */ + readonly workdir?: string; +} + +function setup(options: SetupOptions = {}) { + const out = mockOutput({ + format: "text", + interactive: options.stdinIsTty ?? false, + promptSelectResponses: options.promptSelectResponses, + }); + const api = mockLegacyPlatformApi(); + const cliConfig = mockLegacyCliConfig({ + workdir: options.workdir ?? tempRoot.current, + projectId: Option.none(), + }); + const tty = mockTty({ + stdinIsTty: options.stdinIsTty ?? false, + stdoutIsTty: options.stdinIsTty ?? false, + }); + const telemetry = options.trackTelemetry ? mockLegacyTelemetryStateTracked() : undefined; + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ out, api, cliConfig, tty, telemetry: telemetry?.layer }), + Layer.succeed(CliArgs, { args: [] }), + mockStdin(options.stdinIsTty ?? false, options.pipedAnswer), + Layer.succeed(LegacyDebugLogger, { debug: () => Effect.void, http: () => Effect.void }), + ); + return { layer, out, telemetry }; +} + +async function writeConfig(contents: string) { + await mkdir(join(tempRoot.current, "supabase"), { recursive: true }); + await writeFile(join(tempRoot.current, "supabase", "config.toml"), contents); +} + +async function writeSigningKeys(contents: string) { + await mkdir(join(tempRoot.current, "supabase"), { recursive: true }); + await writeFile(join(tempRoot.current, "supabase", "signing_keys.json"), contents); +} + +/** + * Writes `supabase/.env.development` — a file Go's `loadNestedEnv` reads (selected by + * `SUPABASE_ENV`, defaulting to `"development"`) but `@supabase/config`'s OWN default + * env resolution does NOT (`legacyResolveSigningKeysConfigPaths` must resolve a + * Go-accurate `ProjectEnvironment` and thread it through explicitly — CLI-1961 Codex + * review finding). + */ +async function writeSupabaseEnvDevelopment(contents: string) { + await mkdir(join(tempRoot.current, "supabase"), { recursive: true }); + await writeFile(join(tempRoot.current, "supabase", ".env.development"), contents); +} + +const baseFlags: LegacyGenBearerJwtFlags = { + role: Option.some("anon"), + sub: Option.none(), + exp: Option.none(), + validFor: 1800, + payload: "{}", +}; + +function decodeSegment(segment: string): unknown { + return JSON.parse(Buffer.from(segment, "base64url").toString("utf8")); +} + +function tokenFrom(out: { stdoutText: string }): string { + return out.stdoutText.trimEnd(); +} + +const legacyTestRoot = Command.make("supabase").pipe( + Command.withGlobalFlags(LEGACY_GLOBAL_FLAGS), + Command.withSubcommands([legacyGenCommand]), +); + +describe("legacy gen bearer-jwt integration", () => { + it.live("mints a token with the built-in default ES256 key when no config exists", () => { + const { layer, out } = setup(); + return Effect.gen(function* () { + yield* legacyGenBearerJwt(baseFlags); + + // Go: `fmt.Fprintln(w, token)` — the token, then exactly one trailing newline, + // nothing else on stdout. + expect(out.stdoutText.endsWith("\n")).toBe(true); + expect(out.stdoutText.indexOf("\n")).toBe(out.stdoutText.length - 1); + + const token = tokenFrom(out); + const [header, payload] = token.split("."); + expect(decodeSegment(header ?? "")).toEqual({ + alg: "ES256", + kid: "b81269f1-21d8-4f2e-b719-c2240a840d90", + typ: "JWT", + }); + const claims = decodeSegment(payload ?? "") as Record; + expect(claims["role"]).toBe("anon"); + expect(typeof claims["exp"]).toBe("number"); + expect(typeof claims["iat"]).toBe("number"); + expect((claims["exp"] as number) - (claims["iat"] as number)).toBe(1800); + + const publicKey = yield* Effect.promise(() => + importJWK(LEGACY_DEFAULT_SIGNING_KEY_PUBLIC, "ES256"), + ); + const verified = yield* Effect.promise(() => jwtVerify(token, publicKey)); + expect(verified.payload).toMatchObject({ role: "anon" }); + }).pipe(Effect.provide(layer)); + }); + + it.live( + "mints a token with the default key when config.toml exists but signing_keys_path is not set", + () => { + const { layer, out } = setup(); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => writeConfig("[auth]\nenabled = true\n")); + + yield* legacyGenBearerJwt(baseFlags); + const token = tokenFrom(out); + const [header] = token.split("."); + expect(decodeSegment(header ?? "")).toEqual({ + alg: "ES256", + kid: "b81269f1-21d8-4f2e-b719-c2240a840d90", + typ: "JWT", + }); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("sets is_anonymous when role is authenticated and --sub is not given", () => { + const { layer, out } = setup(); + return Effect.gen(function* () { + yield* legacyGenBearerJwt({ ...baseFlags, role: Option.some("authenticated") }); + const [, payload] = tokenFrom(out).split("."); + const claims = decodeSegment(payload ?? "") as Record; + expect(claims["is_anonymous"]).toBe(true); + expect("sub" in claims).toBe(false); + }).pipe(Effect.provide(layer)); + }); + + it.live("does not set is_anonymous when role is authenticated and --sub is given", () => { + const { layer, out } = setup(); + return Effect.gen(function* () { + yield* legacyGenBearerJwt({ + ...baseFlags, + role: Option.some("authenticated"), + sub: Option.some("user-1"), + }); + const [, payload] = tokenFrom(out).split("."); + const claims = decodeSegment(payload ?? "") as Record; + expect(claims["is_anonymous"]).toBeUndefined(); + expect(claims["sub"]).toBe("user-1"); + }).pipe(Effect.provide(layer)); + }); + + it.live("computes exp from an explicit --exp, with iat = exp - validFor", () => { + const { layer, out } = setup(); + return Effect.gen(function* () { + yield* legacyGenBearerJwt({ + ...baseFlags, + exp: Option.some({ wholeSeconds: 2_000_000_000, nanos: 0 }), + }); + const [, payload] = tokenFrom(out).split("."); + const claims = decodeSegment(payload ?? "") as Record; + expect(claims["exp"]).toBe(2_000_000_000); + expect(claims["iat"]).toBe(2_000_000_000 - 1800); + }).pipe(Effect.provide(layer)); + }); + + it.live( + "computes iat with a sub-second --valid-for, truncating only the final timestamp (CLI-1961)", + () => { + // Verified against the real binary: `--exp 2030-01-01T00:00:00Z --valid-for 1.5s` + // (unix 1893456000) yields Go `iat=1893455998` — flooring the 1.5s duration to 1s + // BEFORE subtracting (this port's previous behavior) would wrongly yield + // 1893455999. + const { layer, out } = setup(); + return Effect.gen(function* () { + yield* legacyGenBearerJwt({ + ...baseFlags, + exp: Option.some({ wholeSeconds: 1_893_456_000, nanos: 0 }), + validFor: 1.5, + }); + const [, payload] = tokenFrom(out).split("."); + const claims = decodeSegment(payload ?? "") as Record; + expect(claims["exp"]).toBe(1_893_456_000); + expect(claims["iat"]).toBe(1_893_455_998); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("merges --payload on top of the computed claims", () => { + const { layer, out } = setup(); + return Effect.gen(function* () { + yield* legacyGenBearerJwt({ + ...baseFlags, + role: Option.some("postgres"), + payload: '{"role":"override","sb-role":"mgmt-api"}', + }); + const [, payload] = tokenFrom(out).split("."); + const claims = decodeSegment(payload ?? "") as Record; + expect(claims["role"]).toBe("override"); + expect(claims["sb-role"]).toBe("mgmt-api"); + }).pipe(Effect.provide(layer)); + }); + + // Go marks --role required (`cmd/gen.go:175`) but cobra validates required flags only + // AFTER `PersistentPreRunE` (`cobra@v1.10.2/command.go:985,1007`) — which is where Go's + // telemetry service is constructed and later flushed to `telemetry.json`. Verified + // against the real binary (CLI-1961 e2e parity run): a missing `--role` still writes + // `telemetry.json`, so the handler enforces the flag itself (after the telemetry-flushing + // wrapper is already active) instead of relying on the framework's parse-time rejection. + it.live( + "fails with cobra's required-flag error, and still flushes telemetry, when --role is omitted", + () => { + const { layer, out, telemetry } = setup({ trackTelemetry: true }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyGenBearerJwt({ ...baseFlags, role: Option.none() })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyGenBearerJwtRoleRequiredError"); + expect(json).toContain('required flag(s) \\"role\\" not set'); + } + expect(out.stdoutText).toBe(""); + expect(telemetry?.flushed).toBe(true); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "ignores an ancestor project's signing_keys_path when the resolved workdir has no config.toml of its own (CLI-1961)", + () => { + // Go's `Config.Load("")` (`pkg/config/utils.go:43-48`) resolves ONLY + // `/supabase/config.toml` — no ancestor climb (once `cliConfig.workdir` + // is already resolved, matching an explicit `--workdir` pointing at a + // subdirectory below another project's root — Go's own `ChangeWorkDir` does not + // climb when `--workdir`/`SUPABASE_WORKDIR` is explicit either, + // `internal/utils/misc.go:246-249`). Verified against the real binary (Codex + // review finding, CLI-1961): without `{ tomlOnly: true, search: false }` in + // `gen.signing-keys-config.ts`, the TS port picked up the PARENT directory's + // `signing_keys_path` and prompted for a kid instead of falling back to the + // unconfigured-default branch, like Go does. + const nestedWorkdir = join(tempRoot.current, "nested", "deeper"); + const { layer, out } = setup({ workdir: nestedWorkdir, pipedAnswer: "" }); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => mkdir(nestedWorkdir, { recursive: true })); + // `writeConfig`/`writeSigningKeys` target `tempRoot.current` — the ANCESTOR of + // `nestedWorkdir` — never `nestedWorkdir` itself. + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => writeSigningKeys(JSON.stringify([generateEcJwk("ec-kid")]))); + + yield* legacyGenBearerJwt(baseFlags); + const token = tokenFrom(out); + const [header] = token.split("."); + // The unconfigured-default branch's prompt was answered blank, so this must be + // the built-in default dev key, NOT the ancestor's `ec-kid`. + expect(decodeSegment(header ?? "")).toEqual({ + alg: "ES256", + kid: "b81269f1-21d8-4f2e-b719-c2240a840d90", + typ: "JWT", + }); + expect(out.stderrText).toContain( + "Enter your signing key in JWK format (or leave blank to use local default): ", + ); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "fails with Go's exact wrapping for a malformed --payload, before any signing-key prompt", + () => { + const { layer, out } = setup(); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyGenBearerJwt({ ...baseFlags, payload: "not json" })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyGenBearerJwtPayloadError"); + expect(json).toContain("failed to parse payload:"); + } + // No signing-key prompt should have been reached — the payload merge runs first. + expect(out.stderrText).toBe(""); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("Branch A: accepts a pasted RS256 JWK from stdin", () => { + const jwk = generateRsaJwk("rsa-kid"); + const { layer, out } = setup({ pipedAnswer: JSON.stringify(jwk) }); + return Effect.gen(function* () { + yield* legacyGenBearerJwt(baseFlags); + const token = tokenFrom(out); + const [header] = token.split("."); + expect(decodeSegment(header ?? "")).toEqual({ alg: "RS256", kid: "rsa-kid", typ: "JWT" }); + + const publicKey = yield* Effect.promise(() => importJWK(publicJwkOf(jwk), "RS256")); + const verified = yield* Effect.promise(() => jwtVerify(token, publicKey)); + expect(verified.payload).toMatchObject({ role: "anon" }); + expect(out.stderrText).toContain( + "Enter your signing key in JWK format (or leave blank to use local default): ", + ); + }).pipe(Effect.provide(layer)); + }); + + it.live("Branch A: on a real TTY, still prompts via stdin but does not echo the answer", () => { + // Go's Branch A ALWAYS uses plain `PromptText` regardless of TTY-ness — only + // Branches B/C (a configured `signing_keys_path`) fork on interactivity. On a + // real TTY the terminal's own line-editing already echoes what was typed, so + // `legacyConsolePromptText` must not double-echo it itself. + const jwk = generateEcJwk("ec-kid"); + const { layer, out } = setup({ stdinIsTty: true, pipedAnswer: JSON.stringify(jwk) }); + return Effect.gen(function* () { + yield* legacyGenBearerJwt(baseFlags); + const token = tokenFrom(out); + const [header] = token.split("."); + expect(decodeSegment(header ?? "")).toEqual({ alg: "ES256", kid: "ec-kid", typ: "JWT" }); + expect(out.stderrText).toBe( + "Enter your signing key in JWK format (or leave blank to use local default): ", + ); + }).pipe(Effect.provide(layer)); + }); + + it.live("Branch A: rejects malformed JSON pasted at the stdin JWK prompt", () => { + const { layer } = setup({ pipedAnswer: "not-json-at-all" }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyGenBearerJwtKeyParseError"); + expect(json).toContain("failed to parse JWK:"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("Branch A: rejects a JSON array pasted at the stdin JWK prompt", () => { + const { layer } = setup({ pipedAnswer: "[1,2,3]" }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyGenBearerJwtKeyParseError"); + expect(json).toContain("cannot unmarshal array into Go value of type config.JWK"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("Branch A: rejects a scalar number pasted at the stdin JWK prompt", () => { + const { layer } = setup({ pipedAnswer: "123" }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain( + "cannot unmarshal number into Go value of type config.JWK", + ); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("Branch A: rejects a scalar string pasted at the stdin JWK prompt", () => { + const { layer } = setup({ pipedAnswer: '"a string"' }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain( + "cannot unmarshal string into Go value of type config.JWK", + ); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("Branch A: rejects a scalar boolean pasted at the stdin JWK prompt", () => { + const { layer } = setup({ pipedAnswer: "true" }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain( + "cannot unmarshal bool into Go value of type config.JWK", + ); + } + }).pipe(Effect.provide(layer)); + }); + + it.live( + "Branch A: a literal 'null' pasted at the stdin JWK prompt is rejected, NOT the default key", + () => { + // Verified against the real binary (CLI-1961): Go's `json.Unmarshal([]byte("null"), + // &key)` is a documented no-op for a non-pointer struct target — it leaves `key` at + // its zero value rather than erroring, and rather than falling back to the default + // key. That zero-value JWK (empty `kty`) then fails downstream at SIGN time. + const { layer } = setup({ pipedAnswer: "null" }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyGenBearerJwtSignError"); + expect(json).toContain("failed to convert JWK to private key: unsupported key type: "); + } + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "Branch A: a truly blank answer at the stdin JWK prompt still falls back to the default key", + () => { + // Distinct from the literal-'null' case above: an EMPTY answer never reaches + // `JSON.parse` at all (Go: `len(kid) == 0` gate), so it's the only input that + // legitimately falls back to the built-in default key. + const { layer, out } = setup({ pipedAnswer: "" }); + return Effect.gen(function* () { + yield* legacyGenBearerJwt(baseFlags); + const token = tokenFrom(out); + const [header] = token.split("."); + expect(decodeSegment(header ?? "")).toEqual({ + alg: "ES256", + kid: "b81269f1-21d8-4f2e-b719-c2240a840d90", + typ: "JWT", + }); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "Branch A: rejects a pasted JWK with an unsupported alg at decode time, not sign time", + () => { + // Go's `config.Algorithm.UnmarshalText` (`pkg/config/auth.go:80-86`) rejects + // anything other than RS256/ES256 DURING JSON decode, before the JWK ever + // reaches signing — verified against the real binary (CLI-1961). + const { layer } = setup({ pipedAnswer: JSON.stringify({ kty: "oct", alg: "HS256" }) }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyGenBearerJwtKeyParseError"); + expect(json).toContain("failed to parse JWK: must be one of [RS256 ES256]"); + } + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "Branch A: accepts a pasted JWK missing alg entirely (validated later, at sign time, not decode time)", + () => { + const { alg: _alg, ...jwkWithoutAlg } = generateEcJwk("no-alg-kid"); + const { layer } = setup({ pipedAnswer: JSON.stringify(jwkWithoutAlg) }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyGenBearerJwtSignError"); + expect(json).toContain("unsupported algorithm: "); + } + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "Branch A: rejects a pasted JWK with a non-string key_ops element (CLI-1961 Codex review finding)", + () => { + // Verified against the real binary: `{"kty":"oct","alg":"ES256","key_ops":["sign",1]}` + // exits 1 with this exact message — Go's `json.Unmarshal` into `config.JWK`'s + // `KeyOps []string` field fails outright on a non-string element rather than + // silently dropping the field the way this normalizer previously did. + const { layer } = setup({ + pipedAnswer: JSON.stringify({ kty: "oct", alg: "ES256", key_ops: ["sign", 1] }), + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyGenBearerJwtKeyParseError"); + expect(json).toContain( + "failed to parse JWK: json: cannot unmarshal number into Go struct field JWK.key_ops of type string", + ); + } + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "Branch A: rejects a pasted JWK with ext given as a string instead of a bool (CLI-1961 Codex review finding)", + () => { + // Verified against the real binary: `{"kty":"oct","alg":"ES256","ext":"true"}` + // exits 1 with this exact message — Go's `json.Unmarshal` into `config.JWK`'s + // `Extractable *bool` field fails outright rather than silently dropping it. + const { layer } = setup({ + pipedAnswer: JSON.stringify({ kty: "oct", alg: "ES256", ext: "true" }), + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyGenBearerJwtKeyParseError"); + expect(json).toContain( + "failed to parse JWK: json: cannot unmarshal string into Go struct field JWK.ext of type bool", + ); + } + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("Branch A: rejects a pasted JWK with a non-string kid", () => { + const { layer } = setup({ + pipedAnswer: JSON.stringify({ kty: "oct", alg: "ES256", kid: 123 }), + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain( + "failed to parse JWK: json: cannot unmarshal number into Go struct field JWK.kid of type string", + ); + } + }).pipe(Effect.provide(layer)); + }); + + it.live( + "Branch A: rejects a pasted JWK with a duplicate kid where the earlier occurrence is malformed (CLI-1961 Codex review finding)", + () => { + // Verified against the real binary: `json.Unmarshal` into `config.JWK` decodes + // struct fields in the object's OWN source order and errors on the FIRST + // type-mismatch it finds — even though `JSON.parse` alone would silently keep + // only the LAST occurrence (a valid `"k1"`) and never see the earlier `1` at all. + const { layer } = setup({ + pipedAnswer: '{"kty":"oct","alg":"ES256","kid":1,"kid":"k1"}', + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain( + "failed to parse JWK: json: cannot unmarshal number into Go struct field JWK.kid of type string", + ); + } + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "Branch A: rejects a pasted JWK with a duplicate alg where the earlier occurrence fails the allowlist, even though the later one is allowed (CLI-1961 Codex review finding)", + () => { + // Verified against the real binary: `config.Algorithm`'s `UnmarshalText` (the + // RS256/ES256 allowlist) returning an error for the FIRST `alg` occurrence + // ("HS256") stops Go's decoder from ever attempting the second ("ES256") — the + // overall `json.Unmarshal` still fails with the allowlist error, even though + // `JSON.parse` alone would keep only the later, individually-valid "ES256". + const { layer } = setup({ + pipedAnswer: '{"kty":"oct","alg":"HS256","alg":"ES256"}', + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain( + "failed to parse JWK: must be one of [RS256 ES256]", + ); + } + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "Branch A: accepts a pasted JWK with a duplicate alg where EVERY occurrence is individually valid and allowed", + () => { + // Contrast with the previous test: Go's decoder only stops attempting later + // occurrences once an EARLIER one fails — when every occurrence independently + // succeeds, the LAST one wins normally, same as any other duplicated field. + const { layer, out } = setup({ + pipedAnswer: JSON.stringify({ ...generateEcJwk("dup-alg-kid"), alg: "ES256" }).replace( + '"alg":"ES256"', + '"alg":"RS256","alg":"ES256"', + ), + }); + return Effect.gen(function* () { + yield* legacyGenBearerJwt(baseFlags); + const token = tokenFrom(out); + const [header] = token.split("."); + expect(decodeSegment(header ?? "")).toEqual({ + alg: "ES256", + kid: "dup-alg-kid", + typ: "JWT", + }); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "Branch A: accepts a pasted JWK with Go-decodable case-variant field names (CLI-1961 Codex review finding)", + () => { + // Go's `encoding/json` matches `config.JWK`'s struct fields case-insensitively — + // verified against the real binary: `{"KTY":"EC","ALG":"ES256",...}` decodes + // identically to the all-lowercase spelling, including `alg` despite its extra + // `encoding.TextUnmarshaler` allowlist hook. A previous version of this + // normalizer only read exact lowercase property names, silently treating a + // case-variant field as absent and rejecting a key Go's real decode accepts. + const jwk = generateEcJwk("case-variant-kid"); + const caseVariantJwk = { + KTY: jwk.kty, + ALG: jwk.alg, + KID: jwk.kid, + CRV: jwk.crv, + X: jwk.x, + Y: jwk.y, + D: jwk.d, + }; + const { layer, out } = setup({ pipedAnswer: JSON.stringify(caseVariantJwk) }); + return Effect.gen(function* () { + yield* legacyGenBearerJwt(baseFlags); + const token = tokenFrom(out); + const [header] = token.split("."); + expect(decodeSegment(header ?? "")).toEqual({ + alg: "ES256", + kid: "case-variant-kid", + typ: "JWT", + }); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "Branch A: rejects a pasted JWK with a case-variant duplicate kid where the earlier occurrence is malformed (CLI-1961 Codex review finding)", + () => { + // Same mechanism as the exact-case duplicate-kid test above, but the earlier + // malformed occurrence spells the field "KID" while the later, valid one spells + // it "kid" — Go's case-insensitive struct-field matching means both feed the + // SAME `config.JWK.KeyID` field, so the earlier malformed occurrence still fails + // the overall decode, exactly like a same-case duplicate does. Verified against + // the real binary: `{"kty":"oct","alg":"ES256","KID":1,"kid":"k1"}` fails with + // this exact message even though `kid`'s own later, valid occurrence is "k1". + const { layer } = setup({ + pipedAnswer: '{"kty":"oct","alg":"ES256","KID":1,"kid":"k1"}', + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain( + "failed to parse JWK: json: cannot unmarshal number into Go struct field JWK.kid of type string", + ); + } + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "Branch A: a null field value is treated as absent, not a type mismatch (Go's encoding/json no-op)", + () => { + const { layer, out } = setup({ + pipedAnswer: JSON.stringify({ ...generateEcJwk("null-ext-kid"), ext: null }), + }); + return Effect.gen(function* () { + yield* legacyGenBearerJwt(baseFlags); + const token = tokenFrom(out); + const [header] = token.split("."); + expect(decodeSegment(header ?? "")).toEqual({ + alg: "ES256", + kid: "null-ext-kid", + typ: "JWT", + }); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "Branch B: rejects a stored signing key with a non-string key_ops element (CLI-1961 Codex review finding)", + () => { + // Verified against the real binary: a `signing_keys_path` file entry with a + // malformed `key_ops` fails during config load with THIS wrap (matching the + // sibling `alg`-allowlist check's own wrap for the same call site), not + // silently dropping the field. + const { layer } = setup(); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => + writeSigningKeys( + JSON.stringify([{ kty: "oct", alg: "ES256", kid: "k1", key_ops: ["sign", 1] }]), + ), + ); + + const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyGenBearerJwtDecodeError"); + expect(json).toContain( + "failed to decode signing keys: failed to parse response body: json: cannot unmarshal number into Go struct field JWK.key_ops of type string", + ); + } + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "Branch B: rejects a stored signing key with a duplicate kid where the earlier occurrence is malformed (CLI-1961 Codex review finding)", + () => { + // Same gap as Branch A's pasted-JWK duplicate-kid test, but for a + // `signing_keys_path` file entry: `legacyReadSigningKeysFile` must check each + // element's OWN raw source text, not the already-`JSON.parse`d (duplicate-key + // collapsed) record. + const { layer } = setup(); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => + writeSigningKeys('[{"kty":"oct","alg":"ES256","kid":1,"kid":"k1"}]'), + ); + + const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyGenBearerJwtDecodeError"); + expect(json).toContain( + "failed to decode signing keys: failed to parse response body: json: cannot unmarshal number into Go struct field JWK.kid of type string", + ); + } + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "Branch B: rejects a stored signing key with a duplicate alg where the earlier occurrence fails the allowlist (CLI-1961 Codex review finding)", + () => { + const { layer } = setup(); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => + writeSigningKeys('[{"kty":"oct","kid":"k1","alg":"HS256","alg":"ES256"}]'), + ); + + const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyGenBearerJwtDecodeError"); + expect(json).toContain( + "failed to decode signing keys: failed to parse response body: must be one of [RS256 ES256]", + ); + } + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "Branch B: mints a token from the configured signing_keys_path's only key on a blank kid answer", + () => { + const jwk = generateEcJwk("ec-kid"); + const { layer, out } = setup(); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => writeSigningKeys(JSON.stringify([jwk]))); + + yield* legacyGenBearerJwt(baseFlags); + const token = tokenFrom(out); + const [header] = token.split("."); + expect(decodeSegment(header ?? "")).toEqual({ alg: "ES256", kid: "ec-kid", typ: "JWT" }); + expect(out.stderrText).toContain( + "Enter the kid of your signing key (or leave blank to use the first one): ", + ); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "Branch B: accepts a stored signing key with Go-decodable case-variant field names (CLI-1961 Codex review finding)", + () => { + // Same fix as Branch A's pasted-JWK case-variant test, but for a + // `signing_keys_path` file entry — `normalizeStoredJwk`'s field lookups go + // through the SAME case-insensitive `resolveJwkFieldValue` helper, and the + // `alg` allowlist pre-check in `legacyReadSigningKeysFile` needs the identical + // fix (CLI-1961 Codex review finding). + const jwk = generateEcJwk("case-variant-stored-kid"); + const caseVariantJwk = { + KTY: jwk.kty, + ALG: jwk.alg, + KID: jwk.kid, + CRV: jwk.crv, + X: jwk.x, + Y: jwk.y, + D: jwk.d, + }; + const { layer, out } = setup(); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => writeSigningKeys(JSON.stringify([caseVariantJwk]))); + + yield* legacyGenBearerJwt(baseFlags); + const token = tokenFrom(out); + const [header] = token.split("."); + expect(decodeSegment(header ?? "")).toEqual({ + alg: "ES256", + kid: "case-variant-stored-kid", + typ: "JWT", + }); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "Branch B: resolves signing_keys_path = env(KEYS_PATH) from supabase/.env.development, a file @supabase/config's own default env resolution doesn't read (CLI-1961 Codex review finding)", + () => { + // Go's `Config.Load` runs `loadNestedEnv` (which selects `.env.`, + // defaulting to "development") BEFORE its TOML decoder ever resolves `env(...)` + // references — so a `KEYS_PATH` set only in `supabase/.env.development` is visible + // to Go's `signing_keys_path = "env(KEYS_PATH)"` resolution. `@supabase/config`'s + // own default env loader (used whenever no `projectEnv` is explicitly threaded + // through) only reads plain `supabase/.env`/`.env.local` and would otherwise leave + // the literal string "env(KEYS_PATH)" unexpanded, and this call would fail trying + // to open a file with THAT literal name. + const jwk = generateEcJwk("ec-kid"); + const { layer, out } = setup(); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "env(KEYS_PATH)"\n'), + ); + yield* Effect.tryPromise(() => + writeSupabaseEnvDevelopment("KEYS_PATH=./signing_keys.json\n"), + ); + yield* Effect.tryPromise(() => writeSigningKeys(JSON.stringify([jwk]))); + + yield* legacyGenBearerJwt(baseFlags); + const token = tokenFrom(out); + const [header] = token.split("."); + expect(decodeSegment(header ?? "")).toEqual({ alg: "ES256", kid: "ec-kid", typ: "JWT" }); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "Branch B: fails with Go's exact wrapped message for an unsupported key type (bearerjwt_test.go parity)", + () => { + const { layer } = setup(); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => writeSigningKeys(JSON.stringify([{ kty: "oct" }]))); + + const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyGenBearerJwtSignError"); + expect(json).toContain("failed to convert JWK to private key: unsupported key type: oct"); + } + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("Branch B: fails with an empty key type when the stored key omits kty entirely", () => { + const { layer } = setup(); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => writeSigningKeys(JSON.stringify([{ alg: "ES256" }]))); + + const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain( + "failed to convert JWK to private key: unsupported key type: ", + ); + } + }).pipe(Effect.provide(layer)); + }); + + it.live( + "Branch B: rejects a configured signing key with an unsupported alg at decode time, not sign time", + () => { + // Go's `fetcher.ParseJSON[[]JWK]` (`pkg/fetcher/http.go:144-151`) decodes straight + // into `[]config.JWK`, running `config.Algorithm.UnmarshalText`'s RS256/ES256 + // allowlist DURING that decode — verified against the real binary (CLI-1961). + const { layer } = setup(); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => + writeSigningKeys(JSON.stringify([{ kty: "oct", alg: "HS256" }])), + ); + + const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyGenBearerJwtDecodeError"); + expect(json).toContain( + "failed to decode signing keys: failed to parse response body: must be one of [RS256 ES256]", + ); + } + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "Branch B: rejects a nested array entry in signing_keys_path instead of partially accepting a later valid key (CLI-1961 Codex review finding)", + () => { + // Go decodes `signing_keys_path` straight into `[]config.JWK` + // (`fetcher.ParseJSON[[]JWK]`) — an array-shaped element can never unmarshal into + // the `config.JWK` struct, so the WHOLE decode fails, verified directly against + // `encoding/json`: `json.Unmarshal([]byte('[[], {"kty":"EC","kid":"k2"}]'), + // &[]JWK{})` returns `"json: cannot unmarshal array into Go value of type + // config.JWK"`. Before this fix, `isRecord`'s `typeof value === "object"` check + // also matched arrays (arrays are `typeof "object"` in JS), so `[]` passed as a + // "record" and a later valid key (`k2`) could still be selected and signed. + const { layer } = setup(); + return Effect.gen(function* () { + const validKey = generateEcJwk("k2"); + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => writeSigningKeys(JSON.stringify([[], validKey]))); + + const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyGenBearerJwtDecodeError"); + expect(json).toContain("failed to decode signing keys: expected a JSON array of objects"); + } + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "Branch B: accepts a null entry AFTER a valid key in signing_keys_path, signing with an exact kid match (CLI-1961 Codex review finding)", + () => { + // Distinct from the earlier-rejected null-BEFORE-valid-key finding on this PR: + // there, `SigningKeys[0]` is the null-decoded zero-value JWK, so Go's own + // `generateAPIKeys` fails signing before kid selection is ever reached. Here + // the valid key is FIRST, so `generateAPIKeys` succeeds — but a BLANK kid + // answer still fails, because Go's own exact-KeyID-match loop runs BEFORE the + // blank-input fallback and the null-decoded second entry's OWN kid is `""`, + // an exact match for a blank answer (same quirk this file's "an exact kid + // match on a key with an empty kid wins ahead of the blank-input + // fallback-to-first" test already covers) — verified against the real binary: + // a blank answer against `[validKey, null]` fails identically to this port + // with `"failed to convert JWK to private key: unsupported key type: "`. An + // EXPLICIT exact-kid answer for the valid key still signs successfully in + // both, which is what the finding's "a non-TTY user can still select + // validKey" actually depends on. Verified against the real binary: + // `json.Unmarshal` accepts `[validKey, null]`, decoding the trailing `null` + // into a zero-value `config.JWK` rather than failing the whole array. + const validKey = generateEcJwk("valid-kid"); + const { layer, out } = setup({ pipedAnswer: "valid-kid" }); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => writeSigningKeys(JSON.stringify([validKey, null]))); + + yield* legacyGenBearerJwt(baseFlags); + const token = tokenFrom(out); + const [header] = token.split("."); + expect(decodeSegment(header ?? "")).toEqual({ + alg: "ES256", + kid: "valid-kid", + typ: "JWT", + }); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "Branch B: ignores trailing bytes after the first JSON value in signing_keys_path, matching Go's single Decode (CLI-1961 Codex review finding)", + () => { + // Go's `fetcher.ParseJSON[[]JWK]` (`pkg/fetcher/http.go:144-151`) is a single + // `json.Decoder.Decode` call, which reads exactly one JSON value and never + // checks for trailing bytes. Verified against the real binary: a + // `signing_keys_path` file containing a valid array followed by a second, + // syntactically-valid JSON value still lets Go sign with the first array's key. + const validKey = generateEcJwk("valid-kid"); + const { layer, out } = setup(); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => writeSigningKeys(`${JSON.stringify([validKey])} []`)); + + yield* legacyGenBearerJwt(baseFlags); + const token = tokenFrom(out); + const [header] = token.split("."); + expect(decodeSegment(header ?? "")).toEqual({ + alg: "ES256", + kid: "valid-kid", + typ: "JWT", + }); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "Branch B: accepts a stored signing key with a null key_ops element, matching Go's zero-value decode (CLI-1961 Codex review finding)", + () => { + // `key_ops` is never read by Go's `GenerateAsymmetricJWT` (it only inspects + // `kty`/`Algorithm`/the key-material fields), and `json.Unmarshal` decodes a + // `null` element of a `[]string` as that element's zero value (`""`), not a + // type mismatch — verified against the real binary (CLI-1961 Codex review + // finding). + const jwk = { ...generateEcJwk("null-key-ops-kid"), key_ops: ["sign", null] }; + const { layer, out } = setup(); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => writeSigningKeys(JSON.stringify([jwk]))); + + yield* legacyGenBearerJwt(baseFlags); + const token = tokenFrom(out); + const [header] = token.split("."); + expect(decodeSegment(header ?? "")).toEqual({ + alg: "ES256", + kid: "null-key-ops-kid", + typ: "JWT", + }); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "Branch C: TTY with zero configured signing keys fails with Go's exact 'user aborted' text", + () => { + // Go's bubbletea `PromptChoice` (`internal/utils/prompt.go:110-140`), given a + // zero-item list, quits immediately without ever letting the user select + // anything — verified against the real binary (CLI-1961). Previously this + // crashed with an unhandled `TypeError` instead of failing gracefully. + const { layer } = setup({ stdinIsTty: true }); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => writeSigningKeys("[]")); + + const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyGenBearerJwtKeyPickerAbortedError"); + expect(json).toContain("user aborted"); + } + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "Branch B: selects a key by exact kid match among several (bearerjwt_test.go parity)", + () => { + const ecJwk = generateEcJwk("ec-kid"); + const rsaJwk = generateRsaJwk("rsa-kid"); + const { layer, out } = setup({ pipedAnswer: "rsa-kid" }); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => writeSigningKeys(JSON.stringify([ecJwk, rsaJwk]))); + + yield* legacyGenBearerJwt({ ...baseFlags, role: Option.some("postgres") }); + const token = tokenFrom(out); + const [header] = token.split("."); + expect(decodeSegment(header ?? "")).toEqual({ alg: "RS256", kid: "rsa-kid", typ: "JWT" }); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "Branch B: an unmatched kid, with a blank fallback available, still errors (bearerjwt_test.go parity)", + () => { + const { layer } = setup({ pipedAnswer: "test-key" }); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => writeSigningKeys("[]")); + + const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyGenBearerJwtKeyNotFoundError"); + expect(json).toContain("signing key not found: test-key"); + } + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "Branch B: an exact kid match on a key with an empty kid wins ahead of the blank-input fallback-to-first", + () => { + const namedKey = generateEcJwk("named-kid"); + const { kid: _kid, ...unnamedKey } = generateEcJwk("unused"); + const { layer, out } = setup(); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + // `namedKey` is listed FIRST, but has a non-empty kid; `unnamedKey` (no kid + // field at all -> "") is listed SECOND. A blank answer must still resolve to + // `unnamedKey` via the exact-match loop, not to `namedKey` via "return first". + yield* Effect.tryPromise(() => writeSigningKeys(JSON.stringify([namedKey, unnamedKey]))); + + yield* legacyGenBearerJwt(baseFlags); + const token = tokenFrom(out); + const [header] = token.split("."); + expect(decodeSegment(header ?? "")).toEqual({ alg: "ES256", typ: "JWT" }); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "Branch C: TTY picks a key via the interactive selector and echoes Selected key ID", + () => { + const ecJwk = generateEcJwk("ec-kid"); + const rsaJwk = generateRsaJwk("rsa-kid"); + const { layer, out } = setup({ stdinIsTty: true, promptSelectResponses: ["1"] }); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => writeSigningKeys(JSON.stringify([ecJwk, rsaJwk]))); + + yield* legacyGenBearerJwt(baseFlags); + const token = tokenFrom(out); + const [header] = token.split("."); + expect(decodeSegment(header ?? "")).toEqual({ alg: "RS256", kid: "rsa-kid", typ: "JWT" }); + // Go: `fmt.Fprintln(os.Stderr, "Selected key ID:", choice.Summary)` (`bearerjwt.go:82`) + // — this command's own stdout is the signed-token payload even in text mode, so the + // line must land on stderr (`output.raw(..., "stderr")`), not via `output.info` + // (clack's `log.info`, which defaults to stdout — Codex review finding, CLI-1961). + expect(out.stderrText).toContain("Selected key ID: rsa-kid"); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "Branch C: TTY renders an empty label/kid/hint for a stored key missing kid and alg", + () => { + const { kid: _kid, alg: _alg, ...bareKey } = generateEcJwk("unused"); + const { layer, out } = setup({ stdinIsTty: true, promptSelectResponses: ["0"] }); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => writeSigningKeys(JSON.stringify([bareKey]))); + + const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); + // No `alg` at all fails downstream in the shared signer ("unsupported + // algorithm: "), but the picker itself must still render before that. + expect(Exit.isFailure(exit)).toBe(true); + expect(out.promptSelectCalls[0]?.options[0]?.label).toBe(""); + expect(out.promptSelectCalls[0]?.options[0]?.hint).toBe(" ()"); + expect(out.stderrText).toContain("Selected key ID: "); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "Branch C: TTY renders the key's use/ext/key_ops fields when a stored key carries them", + () => { + const jwk = { + ...generateEcJwk("full-kid"), + use: "sig", + ext: true, + key_ops: ["sign", "verify"], + }; + const { layer, out } = setup({ stdinIsTty: true, promptSelectResponses: ["0"] }); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => writeSigningKeys(JSON.stringify([jwk]))); + + yield* legacyGenBearerJwt(baseFlags); + expect(out.promptSelectCalls[0]?.options[0]?.hint).toBe("ES256 (sign,verify)"); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "auth.enabled = false with signing_keys_path configured still uses the built-in default key (Go quirk)", + () => { + const otherJwk = generateEcJwk("configured-kid"); + const { layer, out } = setup(); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nenabled = false\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => writeSigningKeys(JSON.stringify([otherJwk]))); + + yield* legacyGenBearerJwt(baseFlags); + const token = tokenFrom(out); + const [header] = token.split("."); + // The default key's kid, NOT the file's key — the file is never read when + // auth.enabled is false (verified against the real binary, CLI-1961). + expect(decodeSegment(header ?? "")).toEqual({ + alg: "ES256", + kid: "b81269f1-21d8-4f2e-b719-c2240a840d90", + typ: "JWT", + }); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "auth.enabled = false with signing_keys_path configured: a real kid from the file is reported not found", + () => { + const otherJwk = generateEcJwk("configured-kid"); + const { layer } = setup({ pipedAnswer: "configured-kid" }); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nenabled = false\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => writeSigningKeys(JSON.stringify([otherJwk]))); + + const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("signing key not found: configured-kid"); + } + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("fails when signing_keys_path is configured but the file is missing", () => { + const { layer } = setup(); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + + const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyGenBearerJwtReadError"); + expect(json).toContain("failed to read signing keys"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when the configured signing keys file is not valid JSON at all", () => { + const { layer } = setup(); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => writeSigningKeys("not valid json {")); + + const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyGenBearerJwtDecodeError"); + expect(json).toContain("failed to decode signing keys:"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when the configured signing keys file is not a JSON array at all", () => { + const { layer } = setup(); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => writeSigningKeys("{}")); + + const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyGenBearerJwtDecodeError"); + expect(json).toContain("expected a JSON array"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when the configured signing keys file is a JSON array of non-objects", () => { + const { layer } = setup(); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => writeSigningKeys("[1, 2]")); + + const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyGenBearerJwtDecodeError"); + expect(json).toContain("expected a JSON array of objects"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with a config parse error when config.toml is malformed", () => { + const { layer } = setup(); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => writeConfig("not valid toml ][")); + + const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyGenBearerJwtConfigParseError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("flushes telemetry state after a successful run", () => { + const { layer, telemetry } = setup({ trackTelemetry: true }); + return Effect.gen(function* () { + yield* legacyGenBearerJwt(baseFlags); + expect(telemetry?.flushed).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("flushes telemetry state even when the signing-key resolution fails", () => { + const { layer, telemetry } = setup({ trackTelemetry: true }); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + // No signing_keys.json written -> LegacyGenBearerJwtReadError. + yield* Effect.exit(legacyGenBearerJwt(baseFlags)); + expect(telemetry?.flushed).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("runs through the command wiring without missing runtime services", () => { + const out = mockOutput({ format: "text", interactive: false }); + const analytics = mockAnalytics(); + const layer = Layer.mergeAll( + BunServices.layer, + processControlLayer, + CliOutput.layer(textCliOutputFormatter()), + out.layer, + analytics.layer, + mockRuntimeInfo({ cwd: tempRoot.current, homeDir: tempRoot.current }), + mockTty({ stdinIsTty: false, stdoutIsTty: false }), + Layer.succeed(CliArgs, { args: [] }), + mockStdin(false), + Layer.succeed( + TelemetryRuntime, + TelemetryRuntime.of({ + configDir: join(tempRoot.current, ".supabase"), + tracesDir: join(tempRoot.current, ".supabase", "traces"), + consent: "granted", + showDebug: false, + deviceId: "test-device-id", + sessionId: "test-session-id", + identity: makeTelemetryIdentity(undefined), + isFirstRun: false, + isTty: false, + isCi: false, + os: "linux", + arch: "x64", + cliVersion: "0.1.0", + }), + ), + ); + + return Effect.gen(function* () { + yield* Command.runWith(legacyTestRoot, { version: "0.0.0-test" })([ + "gen", + "bearer-jwt", + "--role", + "service_role", + "--workdir", + tempRoot.current, + ]); + + const token = tokenFrom(out); + const [, payload] = token.split("."); + const claims = decodeSegment(payload ?? "") as Record; + expect(claims["role"]).toBe("service_role"); + }).pipe(Effect.provide(layer)) as Effect.Effect; + }); +}); diff --git a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.signing-key.ts b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.signing-key.ts new file mode 100644 index 0000000000..6ed0042600 --- /dev/null +++ b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.signing-key.ts @@ -0,0 +1,373 @@ +import { Effect, Option } from "effect"; +import { + assertNoMalformedDuplicateJwkField, + legacyReadSigningKeysFile, + legacyResolveSigningKeysConfigPaths, + readOptionalBoolean, + readOptionalString, + readOptionalStringArray, + resolveJwkFieldValue, +} from "../gen.signing-keys-config.ts"; +import { + legacyAssertDecodableJwkAlgorithm, + LEGACY_DEFAULT_SIGNING_KEY, + type LegacyJwk, +} from "../../../shared/legacy-go-jwt.ts"; +import { legacyGoJsonKindName } from "../../../shared/legacy-go-json.ts"; +import { textOutputLayer } from "../../../../shared/output/output.layer.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { Stdin } from "../../../../shared/runtime/stdin.service.ts"; +import { Tty } from "../../../../shared/runtime/tty.service.ts"; +import { + legacyBearerJwtErrorMessage, + LegacyGenBearerJwtConfigParseError, + LegacyGenBearerJwtDecodeError, + LegacyGenBearerJwtKeyNotFoundError, + LegacyGenBearerJwtKeyParseError, + LegacyGenBearerJwtKeyPickerAbortedError, + LegacyGenBearerJwtReadError, +} from "./bearer-jwt.errors.ts"; + +/** Go's `Console.ReadLine` timeouts (`apps/cli-go/internal/utils/console.go:35-36`). */ +const GO_CONSOLE_TTY_TIMEOUT_MILLIS = 10 * 60 * 1000; +const GO_CONSOLE_NON_TTY_TIMEOUT_MILLIS = 100; + +/** + * Port of Go's `Console.PromptText` (`apps/cli-go/internal/utils/console.go:96-107`): + * writes `label` to stderr with NO trailing newline, reads one line bounded by a + * TTY-aware timeout, and — only on a non-TTY — echoes the (trimmed) input back to + * stderr. On a real TTY the terminal's own line-editing already echoes what the user + * types, so no explicit echo is written there, matching Go exactly. Used for BOTH of + * `getSigningKey`'s text prompts (`bearerjwt.go:38`, `:54`) — the branch between them + * is purely which label/fallback the caller applies to the returned string, not how + * the read itself behaves. + */ +const legacyConsolePromptText = Effect.fnUntraced(function* (label: string) { + const output = yield* Output; + const tty = yield* Tty; + const stdin = yield* Stdin; + yield* output.raw(label, "stderr"); + const line = yield* stdin.readLine( + tty.stdinIsTty ? GO_CONSOLE_TTY_TIMEOUT_MILLIS : GO_CONSOLE_NON_TTY_TIMEOUT_MILLIS, + ); + const input = Option.getOrElse(line, () => ""); + if (!tty.stdinIsTty) { + yield* output.raw(`${input}\n`, "stderr"); + } + return input; +}); + +/** + * Narrows an untrusted JSON record (a `signing_keys_path` file entry, or a pasted + * stdin JWK) into `LegacyJwk`'s shape — every field Go's own `config.JWK` struct + * would leave at its zero value (`""`/absent) when missing from the JSON decodes the + * same way here. Every downstream consumer in this file works with the resulting + * typed `LegacyJwk`, not the raw untrusted record, so key/kid/alg lookups stay + * type-checked instead of re-guarding `Record` at every call site. + * + * Throws a bare `Error` (Go's own unwrapped `encoding/json` text) the moment any field + * above is present with the wrong JSON type — both call sites below (`normalizeStoredJwk` + * itself is synchronous) catch that throw and apply THEIR OWN Go-matching wrap: Branch + * A's pasted-JWK path wraps `"failed to parse JWK: %w"`, while the `signing_keys_path` + * file path wraps `"failed to decode signing keys: failed to parse response body: %w"` + * — the same two wraps this file's sibling checks (the JSON-decode / `alg` allowlist + * checks) already use for those exact same two call sites. Go decodes struct fields in + * the JSON document's own key order and stops at the FIRST mismatch; this function + * instead always checks in a fixed field order, so on a payload with MULTIPLE + * simultaneously-malformed fields the reported field may not always match Go's for + * that specific multi-error case — accepted gap, no fixture in `bearerjwt_test.go` + * covers ordering across more than one bad field at once, and every field is still + * correctly rejected either way. `readOptionalString`/`readOptionalStringArray`/ + * `readOptionalBoolean` are hoisted to `gen.signing-keys-config.ts` (the `gen` family + * root) rather than defined locally: `assertNoMalformedDuplicateJwkField` there needs + * the exact same per-field checks to catch a DUPLICATED field whose earlier occurrence + * `JSON.parse` alone would have silently discarded (CLI-1961 Codex review finding) — + * see both call sites below, and that function's own doc comment for the mechanics. + */ +function normalizeStoredJwk(record: Record): LegacyJwk { + const keyOps = readOptionalStringArray(record, "key_ops"); + return { + kty: readOptionalString(record, "kty") ?? "", + kid: readOptionalString(record, "kid"), + use: readOptionalString(record, "use"), + key_ops: keyOps !== undefined ? [...keyOps] : undefined, + alg: readOptionalString(record, "alg"), + ext: readOptionalBoolean(record, "ext"), + n: readOptionalString(record, "n"), + e: readOptionalString(record, "e"), + d: readOptionalString(record, "d"), + p: readOptionalString(record, "p"), + q: readOptionalString(record, "q"), + dp: readOptionalString(record, "dp"), + dq: readOptionalString(record, "dq"), + qi: readOptionalString(record, "qi"), + crv: readOptionalString(record, "crv"), + x: readOptionalString(record, "x"), + y: readOptionalString(record, "y"), + }; +} + +/** + * Go's `getSigningKey` Branch A (`bearerjwt.go:37-51`, reached when + * `[auth].signing_keys_path` is NOT configured): prompt for a raw JWK, falling back to + * the built-in default ES256 dev key on a blank answer. + */ +const resolveSigningKeyFromStdinJwk = Effect.fnUntraced(function* () { + const input = yield* legacyConsolePromptText( + "Enter your signing key in JWK format (or leave blank to use local default): ", + ); + if (input.length === 0) { + return LEGACY_DEFAULT_SIGNING_KEY; + } + let parsed: unknown; + try { + parsed = JSON.parse(input); + } catch (cause) { + return yield* Effect.fail( + new LegacyGenBearerJwtKeyParseError({ + message: `failed to parse JWK: ${legacyBearerJwtErrorMessage(cause)}`, + }), + ); + } + // A JSON `null` answer decodes into a ZERO-VALUE `config.JWK{}` in Go — verified + // against the real binary (CLI-1961): `json.Unmarshal([]byte("null"), &key)` where + // `key` is a non-pointer struct is a documented Go no-op (it leaves every field at + // its zero value: `kty: ""`, `alg: ""`, ...) rather than an error, and rather than + // the built-in default key. This must reach the SAME zero-value JWK the empty-object + // shape below does, so it fails downstream at SIGN time with "unsupported key type: + // " (empty kty) — Go genuinely rejects a `null` answer where a truly BLANK answer + // (handled above, before `JSON.parse` is ever called) falls back to the default key. + if (parsed === null) { + return normalizeStoredJwk({}); + } + if (typeof parsed !== "object" || Array.isArray(parsed)) { + return yield* Effect.fail( + new LegacyGenBearerJwtKeyParseError({ + message: `failed to parse JWK: json: cannot unmarshal ${legacyGoJsonKindName(parsed)} into Go value of type config.JWK`, + }), + ); + } + const record = parsed as Record; + // Case-insensitive lookup (`resolveJwkFieldValue`) — Go's `alg` allowlist check + // (`config.Algorithm.UnmarshalText`) runs at JSON-decode time regardless of the + // key's casing (CLI-1961 Codex review finding); see that function's doc comment + // in `gen.signing-keys-config.ts`. + const alg = resolveJwkFieldValue(record, "alg"); + try { + legacyAssertDecodableJwkAlgorithm(typeof alg === "string" ? alg : undefined); + } catch (cause) { + return yield* Effect.fail( + new LegacyGenBearerJwtKeyParseError({ + message: `failed to parse JWK: ${legacyBearerJwtErrorMessage(cause)}`, + }), + ); + } + // `normalizeStoredJwk` throws Go's bare `encoding/json` struct-field type-mismatch + // text (see its own doc comment) the moment any OTHER field is malformed — e.g. + // `{"kty":"oct","alg":"ES256","key_ops":["sign",1]}` — wrapped here with the same + // `"failed to parse JWK: %w"` this Branch A path already uses above (Codex review + // finding, CLI-1961). + // + // `assertNoMalformedDuplicateJwkField` runs FIRST, against `input` — the untouched + // pasted text — rather than `record`: by the time `parsed`/`record` exist, `JSON.parse` + // has ALREADY collapsed any duplicate top-level key down to its last occurrence (plain + // JS object-literal semantics), silently discarding evidence of an earlier occurrence + // Go's own decode would still reject — e.g. a pasted `{"kid":1,"kid":"k"}` parses to + // `record.kid === "k"` with no trace `1` was ever there, yet `json.Unmarshal` into + // `config.JWK` still errors on the `1` and never reaches `normalizeStoredJwk`'s + // equivalent of the merged, valid `"k"` (verified against the real binary, CLI-1961 + // Codex review finding — see that function's own doc comment for the full mechanics, + // including the `alg` field's distinct allowlist-driven behavior). + return yield* Effect.try({ + try: () => { + assertNoMalformedDuplicateJwkField(input); + return normalizeStoredJwk(record); + }, + catch: (cause) => + new LegacyGenBearerJwtKeyParseError({ + message: `failed to parse JWK: ${legacyBearerJwtErrorMessage(cause)}`, + }), + }); +}); + +/** + * Go's `getSigningKey` Branches B/C (`bearerjwt.go:52-83`, reached when + * `[auth].signing_keys_path` IS configured): non-TTY prompts for a kid by exact + * string match (falling back to the first key on a blank answer); a real TTY + * presents an interactive picker instead (`output.promptSelect`, the same + * `@clack/prompts`-backed pattern `legacy-project-ref.layer.ts` already uses for + * Go's bubbletea `PromptChoice` — the rendered ANSI never byte-matches Go's TUI + * either way, so this codebase's established precedent is to match only the + * observable stderr line Go itself prints after a choice, "Selected key ID: "). + * + * Both the picker AND that line are routed to stderr explicitly (`{ stream: "stderr" + * }` / `output.raw(..., "stderr")` below) rather than the shared `promptSelect`/`info` + * defaults: Go's own `PromptChoice` comments "Interactive prompts should always be + * written to stderr" and passes `tea.WithOutput(os.Stderr)` + * (`internal/utils/prompt.go:127-128`) — but clack's `select()`/`log.info()` default to + * `process.stdout` (verified directly against the installed `@clack/prompts` source), + * and this command's own stdout IS the signed-token payload even in text mode (see + * `bearer-jwt.handler.ts`'s doc comment) — so the unmodified defaults would corrupt a + * piped/captured token with picker UI and the "Selected key ID: ..." line for any + * interactive user with a configured `signing_keys_path` (Codex review finding, + * CLI-1961). + * + * The picker tries the AMBIENT `Output` first — this is what every test in this file + * mocks, and it's what a real `text` run already uses — and only on the AMBIENT + * `output.promptSelect`/`raw` failing with `NonInteractiveError` (the json/stream-json + * `Output` layers' unconditional behavior, `output.layer.ts`) does it retry through a + * FRESH, locally-provided {@link textOutputLayer} instance. This is the same rationale + * as `legacy/commands/migration/migration.prompt.ts`'s `legacyMigrationConfirm` + * (CLI-1974): Go's own `PromptChoice` has no concept of an output format at all and + * always prompts on a real TTY, and this command's stdout is the raw token + * unconditionally in EVERY format (see `bearer-jwt.handler.ts`'s doc comment) — there + * is no structured json/stream-json result here for an interactive widget to corrupt, + * unlike `legacy-project-ref.layer.ts`'s own `promptSelect` (which DOES stay gated + * behind `output.format`, because ITS caller's json/stream-json mode has a real + * machine payload an interactive prompt would otherwise interleave with). Verified + * against the real binary (CLI-1961, Codex review finding): the ambient + * json/stream-json `Output` layers' `promptSelect` unconditionally raises + * `NonInteractiveError`, which would abort this command entirely on a real TTY with a + * configured `signing_keys_path` and more than one stored key — a regression Go never + * had, since it has no `--output-format` flag to trip over. The try-then-fall-back + * shape (rather than unconditionally swapping to `textOutputLayer`) is deliberate: it + * keeps every existing mock-driven test exercising the SAME ambient `Output` path they + * already do, and only reaches the real, un-mockable `@clack/prompts` renderer in the + * one combination (`NonInteractiveError` from a genuinely non-text production layer) + * that requires it. + */ +const resolveSigningKeyFromConfigured = Effect.fnUntraced(function* ( + availableKeys: ReadonlyArray, +) { + const tty = yield* Tty; + + if (!tty.stdinIsTty) { + const kid = yield* legacyConsolePromptText( + "Enter the kid of your signing key (or leave blank to use the first one): ", + ); + // Go's loop checks every key for an EXACT `KeyID` match BEFORE the blank-input + // fallback (`bearerjwt.go:59-66`) — so a key whose own `kid` is literally `""` + // still matches a blank answer here, ahead of "return the first key". + const found = availableKeys.find((key) => (key.kid ?? "") === kid); + if (found !== undefined) { + return found; + } + if (kid.length === 0 && availableKeys.length > 0) { + return availableKeys[0]!; + } + return yield* Effect.fail( + new LegacyGenBearerJwtKeyNotFoundError({ message: `signing key not found: ${kid}` }), + ); + } + + if (availableKeys.length === 0) { + // Go's bubbletea `PromptChoice` (`internal/utils/prompt.go:110-140`), given a + // ZERO-item list, quits immediately without ever letting the user select anything: + // `errors.New("user aborted")`, unwrapped. Guarded here rather than ever reaching + // `output.promptSelect` — `@clack/prompts`' own `select()` has no equivalent + // "immediately quit on an empty option list" behavior to lean on, and calling it + // with zero options would otherwise resolve to an out-of-range index and crash + // with a raw `TypeError` when `.kid` is accessed below. + return yield* Effect.fail( + new LegacyGenBearerJwtKeyPickerAbortedError({ message: "user aborted" }), + ); + } + + const output = yield* Output; + const options = availableKeys.map((key, index) => ({ + value: String(index), + label: key.kid ?? "", + hint: `${key.alg ?? ""} (${(key.key_ops ?? []).join(",")})`, + })); + // Try the AMBIENT `Output` first (this is what every test in this file mocks, and + // what a real `text` run already uses) — only on `NonInteractiveError` (the + // json/stream-json `Output` layers' `promptSelect`/`raw`, `output.layer.ts`) fall + // back to a REAL, locally-provided `textOutputLayer` instance so the picker still + // renders on a genuine TTY. `textOutputLayer` only needs `Tty` (already in scope), + // so this fallback is a purely local override; the token itself is still written + // through the AMBIENT `Output` later, in `bearer-jwt.handler.ts`, completely + // unaffected by it. See this function's doc comment above for why Go's own picker + // needs this at all. + const pickSigningKey = (pickerOutput: typeof Output.Service) => + Effect.gen(function* () { + const chosen = yield* pickerOutput.promptSelect("Select a signing key:", options, { + stream: "stderr", + }); + const chosenKey = availableKeys[Number(chosen)]!; + // Go: `fmt.Fprintln(os.Stderr, "Selected key ID:", choice.Summary)` (`bearerjwt.go:82`). + // `output.raw(..., "stderr")`, NOT `output.info` — `info` is clack's `log.info`, + // which defaults to stdout (see this function's doc comment above). + yield* pickerOutput.raw(`Selected key ID: ${chosenKey.kid ?? ""}\n`, "stderr"); + return chosenKey; + }); + return yield* pickSigningKey(output).pipe( + Effect.catchTag("NonInteractiveError", () => + Effect.provide( + Effect.gen(function* () { + const realOutput = yield* Output; + return yield* pickSigningKey(realOutput); + }), + textOutputLayer, + ), + ), + ); +}); + +/** + * Go's `getSigningKey` (`apps/cli-go/internal/gen/bearerjwt/bearerjwt.go:35-84`), fully + * assembled: resolves `[auth].signing_keys_path`'s config, then dispatches to Branch + * A (unconfigured) or Branches B/C (configured, non-TTY/TTY). + * + * Reproduces the verified `[auth].enabled = false` quirk: Go's `Config.Validate` only + * reads the `signing_keys_path` FILE inside `if c.Auth.Enabled` (`config.go:1087`), but + * `getSigningKey` only checks whether the PATH STRING is configured + * (`len(SigningKeysPath) == 0`) — independent of `auth.enabled`. So when auth is + * disabled and a path IS configured, the kid-prompt branch still runs, but the actual + * available keys stay the built-in default (the file is never read) — a real user + * hitting this combination sees a misleading kid prompt they can never satisfy with + * their own file's kids. `gen signing-key`'s OWN sibling resolver + * ({@link legacyGenSigningKey}'s `loadSigningKeysConfig`) needs and replicates this exact + * same gate — it goes through the identical `flags.LoadConfig` -> `Config.Validate` Go + * pipeline as this command, so it is subject to the same auth-disabled quirk (CLI-1961 + * Codex review finding; see `gen.signing-keys-config.ts`'s `authEnabled` doc comment). + */ +export const legacyResolveBearerJwtSigningKey = Effect.fnUntraced(function* (workdir: string) { + const paths = yield* legacyResolveSigningKeysConfigPaths( + workdir, + (message) => new LegacyGenBearerJwtConfigParseError({ message }), + ); + + if (Option.isNone(paths.signingKeysPath)) { + return yield* resolveSigningKeyFromStdinJwk(); + } + + let availableKeys: ReadonlyArray; + if (paths.authEnabled) { + const storedKeys = yield* legacyReadSigningKeysFile( + paths.signingKeysPath.value.actualPath, + (message) => new LegacyGenBearerJwtReadError({ message }), + (message) => new LegacyGenBearerJwtDecodeError({ message }), + ); + // `normalizeStoredJwk` throws Go's bare `encoding/json` struct-field type-mismatch + // text (see its own doc comment) the moment any stored key entry has a malformed + // field — e.g. `[{"kty":"oct","alg":"ES256","ext":"true"}]` — wrapped here with the + // SAME `"failed to decode signing keys: failed to parse response body: %w"` this + // file's sibling `alg`-allowlist check (inside `legacyReadSigningKeysFile`) already + // uses for this exact call site (Codex review finding, CLI-1961). A DUPLICATE + // malformed field (e.g. `[{"kid":1,"kid":"k1",...}]`) is already rejected earlier, + // by `legacyReadSigningKeysFile` itself calling `assertNoMalformedDuplicateJwkField` + // against each element's own raw source text — `storedKeys` here is guaranteed + // free of that gap by the time this runs (CLI-1961 Codex review finding). + availableKeys = yield* Effect.try({ + try: () => storedKeys.map(normalizeStoredJwk), + catch: (cause) => + new LegacyGenBearerJwtDecodeError({ + message: `failed to decode signing keys: failed to parse response body: ${legacyBearerJwtErrorMessage(cause)}`, + }), + }); + } else { + availableKeys = [LEGACY_DEFAULT_SIGNING_KEY]; + } + + return yield* resolveSigningKeyFromConfigured(availableKeys); +}); diff --git a/apps/cli/src/legacy/commands/gen/gen.signing-keys-config.ts b/apps/cli/src/legacy/commands/gen/gen.signing-keys-config.ts new file mode 100644 index 0000000000..36f8ac5ed3 --- /dev/null +++ b/apps/cli/src/legacy/commands/gen/gen.signing-keys-config.ts @@ -0,0 +1,665 @@ +import { loadProjectConfig, loadProjectEnvironment } from "@supabase/config"; +import { Effect, FileSystem, Option, Path } from "effect"; +import { legacyAssertDecodableJwkAlgorithm } from "../../shared/legacy-go-jwt.ts"; +import { legacyGoJsonKindName } from "../../shared/legacy-go-json.ts"; +import { legacyResolveProjectEnvironmentValues } from "../../shared/legacy-project-environment.ts"; + +/** + * Shared `[auth].signing_keys_path` config-loading logic for the `gen` command + * family — used by both `gen signing-key` (`signing-key.handler.ts`, generating + * or appending a key) and `gen bearer-jwt` (`bearer-jwt.handler.ts`, resolving + * a key to sign with). Per `apps/cli/CLAUDE.md`'s "hoist before you duplicate" + * rule: this logic is used by ≥2 commands in the same command family, so it + * lives at the family root (`legacy/commands/gen/`) rather than being inlined + * in either sibling. + * + * Error TYPES are intentionally NOT shared — each caller passes its own + * tagged-error constructors (mirroring `sso.saml.ts`'s `readMetadataFile` + * pattern), so `gen signing-key` and `gen bearer-jwt` keep independent error + * hierarchies while sharing the actual file-resolution/read/decode logic. + */ + +export type LegacyStoredSigningKeyJwk = Readonly>; + +interface LegacyGenSigningKeysConfigPaths { + /** CWD-relative `supabase/config.toml` (or the resolved config file's own display path). */ + readonly configDisplayPath: string; + /** + * `[auth].enabled` from the resolved config (default `true`). Go's `Config.Validate` + * only reads `[auth].signing_keys_path`'s file INSIDE `if c.Auth.Enabled` + * (`config.go:1087-1116`) — EVERY caller that reaches this file's read through Go's + * `flags.LoadConfig` (both `gen bearer-jwt`'s `bearerjwt.Run` and `gen signing-key`'s + * `signingkeys.Run` call it, `bearerjwt.go:20` / `signingkeys.go:111`) is subject to the + * SAME gate — there is no separate, ungated Go code path for `gen signing-key`. Verified + * against the real binary (CLI-1961 Codex review finding): with `auth.enabled = false` + * and a configured `signing_keys_path` file, `gen signing-key --append` never reads that + * file's real content at all — it appends to (and a subsequent write clobbers) Go's own + * `NewConfig()` default single-key array instead, discarding whatever was actually on + * disk. Both `gen bearer-jwt`'s `getSigningKey` ({@link legacyResolveBearerJwtSigningKey}) + * and `gen signing-key` ({@link legacyGenSigningKey}) branch on this field for exactly that + * reason. + */ + readonly authEnabled: boolean; + /** `Option.some` when `[auth].signing_keys_path` is configured (non-empty). */ + readonly signingKeysPath: Option.Option<{ + readonly actualPath: string; + readonly displayPath: string; + }>; +} + +/** + * `typeof value === "object"` is also `true` for a JSON array — without excluding + * `Array.isArray(value)`, a `signing_keys_path` entry shaped like `[]` (or any nested + * array) would pass this check and be accepted as a JWK-shaped record. Go's own decode + * (`fetcher.ParseJSON[[]JWK]`, straight into `[]config.JWK`) genuinely rejects an + * array-shaped element with `"json: cannot unmarshal array into Go value of type + * config.JWK"` — verified directly against `encoding/json` (CLI-1961 Codex review + * finding): `[[], {"kty":"EC","kid":"k2"}]` fails Go's decode outright, it does not + * partially accept `k2` the way this check would without the array exclusion. + */ +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Go's own `legacyGoJsonKindName` (`legacy-go-json.ts`) is deliberately scoped to + * scalars only — every one of its existing call sites already excludes null/array/ + * object before reaching it. This file's per-field JWK checks below DO need to name a + * bare JSON object (`key_ops`'s elements, or a nested value under any field, can be an + * object — verified against the real binary: `--payload`-style `{}` inside `key_ops` + * reports `"...into Go struct field JWK.key_ops of type string"` with kind `object`), + * so this is a local superset rather than a change to that shared, narrower contract. + */ +function jwkFieldKindName(value: unknown): string { + if (value !== null && typeof value === "object" && !Array.isArray(value)) { + return "object"; + } + return legacyGoJsonKindName(value); +} + +/** + * Go's exact `encoding/json` struct-field type-mismatch text: `"json: cannot unmarshal + * into Go struct field JWK. of type "` — verified against the + * real binary (CLI-1961) for every field this file reads: `kty`/`kid`/`use`/`alg`/`n`/ + * `e`/`d`/`p`/`q`/`dp`/`dq`/`qi`/`crv`/`x`/`y` (`goType: "string"`), `key_ops` as a + * whole (`goType: "[]string"`) vs. one of its elements (`goType: "string"`, the same as + * any other string field), and `ext` (`goType: "bool"`). + */ +function jwkStructFieldTypeMismatch(field: string, value: unknown, goType: string): string { + return `json: cannot unmarshal ${jwkFieldKindName(value)} into Go struct field JWK.${field} of type ${goType}`; +} + +/** + * A JSON `null` for any `config.JWK` field is a documented Go `encoding/json` no-op — + * same as a `null` for the whole JWK (see `bearer-jwt.signing-key.ts`'s + * `resolveSigningKeyFromStdinJwk` doc comment) — so `null` must NOT be treated as a type + * mismatch here, only as "absent". + */ +function isAbsentJwkField(value: unknown): boolean { + return value === undefined || value === null; +} + +/** + * Go's `encoding/json` struct-field matching is case-insensitive — the decoder + * "match[es] incoming object keys to the keys used by Marshal (either the struct field + * name or its tag), preferring an exact match but also accepting a case-insensitive + * match" — and `config.JWK` gets NO field-specific exemption from this: verified + * directly against the real `config.JWK` struct for every field this file reads + * (`kty`, `kid`, `use`, `key_ops`, `alg`, `ext`, `n`, `e`, `d`, `p`, `q`, `dp`, `dq`, + * `qi`, `crv`, `x`, `y`), including `alg` despite its extra `encoding.TextUnmarshaler` + * hook — `{"KTY":"EC","ALG":"ES256"}` decodes identically to the all-lowercase + * spelling. A plain `record[field]` index (JS property access is always exact-case) + * would otherwise treat `"KTY"`/`"ALG"`/etc. as absent instead of decoding them, + * incorrectly rejecting keys Go's real decoder accepts (CLI-1961 Codex review finding). + * + * When MULTIPLE case-variant spellings of the same field are present, Go's decoder + * processes JSON keys strictly in SOURCE order and overwrites the struct field on + * each match, so whichever case-variant key comes LAST in the object wins — verified + * directly: `{"KTY":"EC","kty":"RSA"}` decodes to `kty:"RSA"`, and `{"kty":"EC", + * "KTY":"RSA"}` also decodes to `kty:"RSA"` (the later key, regardless of casing, + * always wins). This only GENERALIZES `JSON.parse`'s own already-relied-on + * last-value-wins behavior for a same-case duplicate key to cross-case duplicates + * too — it never changes the answer for an object with no case-variant duplicates. + */ +export function resolveJwkFieldValue(record: Record, field: string): unknown { + let value: unknown; + let found = false; + for (const key of Object.keys(record)) { + if (key.toLowerCase() === field) { + value = record[key]; + found = true; + } + } + return found ? value : undefined; +} + +/** + * Reads an optional STRING field, throwing Go's exact struct-field type-mismatch text + * (see {@link jwkStructFieldTypeMismatch}) when the field is PRESENT with a non-string + * value — e.g. `{"kid":123}` or `{"ext":"true"}` — rather than silently treating a + * malformed field as absent. Go's `json.Unmarshal` into `config.JWK` fails outright on + * any such field, so a mistyped optional field must never let a caller mint a token as + * if the field had simply been omitted. Hoisted here (rather than living only in + * `bearer-jwt.signing-key.ts`) because {@link assertNoMalformedDuplicateJwkField} — used + * by BOTH `gen signing-key` and `gen bearer-jwt` via {@link legacyReadSigningKeysFile} — + * needs the exact same per-field check (CLI-1961 Codex review finding). Looks the field + * up case-insensitively via {@link resolveJwkFieldValue} to match Go's own + * case-insensitive struct-field matching (CLI-1961 Codex review finding). + */ +export function readOptionalString( + record: Record, + field: string, +): string | undefined { + const value = resolveJwkFieldValue(record, field); + if (isAbsentJwkField(value)) { + return undefined; + } + if (typeof value !== "string") { + throw new Error(jwkStructFieldTypeMismatch(field, value, "string")); + } + return value; +} + +/** + * Reads the optional `key_ops` STRING ARRAY field, throwing Go's exact struct-field + * type-mismatch text (see {@link jwkStructFieldTypeMismatch}) when the field is + * PRESENT but is not an array (`goType: "[]string"`) or contains a non-string, + * non-null element (`goType: "string"`, Go decodes each element individually into + * the slice's element type) — same "never silently treat malformed as absent" rule as + * {@link readOptionalString}. See that function's doc comment for why this is exported + * from the family root rather than kept local to `bearer-jwt.signing-key.ts`. + * + * A `null` ELEMENT (e.g. `"key_ops":["sign",null]`) is its own separate zero-value + * case, one level deeper than {@link isAbsentJwkField}'s "the whole field is absent": + * Go's `encoding/json` decodes a `null` slice element into `[]string` as that + * element's zero value (`""`), not a type mismatch. Verified against the real binary + * (CLI-1961 Codex review finding): `json.Unmarshal(["sign", null], &[]string{})` + * yields `["sign", ""]` with no error — and `key_ops` is never even READ by + * `GenerateAsymmetricJWT` (it only inspects `kty`/`Algorithm`/the key-material + * fields), so a JWK with a `null` `key_ops` element still signs successfully in Go, + * where this function previously rejected it outright. + */ +export function readOptionalStringArray( + record: Record, + field: string, +): ReadonlyArray | undefined { + const value = resolveJwkFieldValue(record, field); + if (isAbsentJwkField(value)) { + return undefined; + } + if (!Array.isArray(value)) { + throw new Error(jwkStructFieldTypeMismatch(field, value, "[]string")); + } + return value.map((entry) => { + if (entry === null) { + return ""; + } + if (typeof entry !== "string") { + throw new Error(jwkStructFieldTypeMismatch(field, entry, "string")); + } + return entry; + }); +} + +/** + * Reads the optional `ext` BOOLEAN field (Go's `*bool`), throwing Go's exact + * struct-field type-mismatch text (see {@link jwkStructFieldTypeMismatch}) when the + * field is PRESENT with a non-boolean value — e.g. `{"ext":"true"}` or `{"ext":1}` — + * same "never silently treat malformed as absent" rule as {@link readOptionalString}. + * See that function's doc comment for why this is exported from the family root. + */ +export function readOptionalBoolean( + record: Record, + field: string, +): boolean | undefined { + const value = resolveJwkFieldValue(record, field); + if (isAbsentJwkField(value)) { + return undefined; + } + if (typeof value !== "boolean") { + throw new Error(jwkStructFieldTypeMismatch(field, value, "bool")); + } + return value; +} + +/** + * Every plain-`string` `config.JWK` field EXCEPT `alg` — `alg`'s own `config.Algorithm` + * type additionally implements `encoding.TextUnmarshaler` (the RS256/ES256 allowlist, + * `pkg/config/auth.go:80-86`), which changes ITS duplicate-key mechanics enough that + * {@link assertNoMalformedDuplicateJwkField} checks it separately — see that function's + * doc comment. + */ +const JWK_PLAIN_STRING_FIELDS = [ + "kty", + "kid", + "use", + "n", + "e", + "d", + "p", + "q", + "dp", + "dq", + "qi", + "crv", + "x", + "y", +] as const; + +/** + * Advances past exactly one JSON value starting at `text[start]` (after any leading + * whitespace), returning the index of the first character after that value — a minimal + * span-only JSON tokenizer (it never builds a JS value) shared by + * {@link splitJsonArrayElementTexts} and `findTopLevelObjectFieldOccurrences`. Tracks + * string-literal state (including `\"` escapes) so structural characters (`{}[]:,`) + * inside a string never affect nesting depth — a naive brace/bracket counter would + * otherwise mis-parse a value like `"a,b}c"`. Only ever called on text that has ALREADY + * parsed successfully as a whole via `JSON.parse` (a duplicate key is a semantic oddity + * `JSON.parse` tolerates, not a syntax error), so this can assume well-formed JSON + * grammar throughout — the `i === start` guards below are defense-in-depth against a + * hang, not a correctness requirement for well-formed input. + */ +function skipJsonValue(text: string, start: number): number { + let i = start; + while (i < text.length && /\s/.test(text[i] ?? "")) i++; + const skipString = () => { + i++; // opening quote + while (i < text.length) { + const c = text[i]; + if (c === "\\") { + i += 2; + continue; + } + i++; + if (c === '"') break; + } + }; + const ch = text[i]; + if (ch === '"') { + skipString(); + return i; + } + if (ch === "{" || ch === "[") { + const close = ch === "{" ? "}" : "]"; + let depth = 1; + i++; + while (i < text.length && depth > 0) { + const c = text[i]; + if (c === '"') { + skipString(); + continue; + } + if (c === ch) depth++; + else if (c === close) depth--; + i++; + } + return i; + } + // number / true / false / null. + while (i < text.length && !",}] \n\r\t".includes(text[i] ?? "")) i++; + return i === start ? start + 1 : i; // never stall on an unexpected character. +} + +/** + * Splits a JSON *array* literal's own top-level elements into their exact source + * substrings — respecting nested strings/objects/arrays so a comma or bracket inside a + * nested value never splits an element early — WITHOUT ever re-serializing them through + * `JSON.stringify` (which could reorder/reformat, and can't reproduce a source-only + * artifact like a duplicate key at all). {@link legacyReadSigningKeysFile} uses this to + * recover each `signing_keys_path` entry's OWN untouched text for + * {@link assertNoMalformedDuplicateJwkField} — `JSON.parse`, which the array as a WHOLE + * already went through for the ordinary shape checks in that function, has by that + * point already discarded the very duplicate-key evidence that check exists to find + * (CLI-1961 Codex review finding). + */ +function splitJsonArrayElementTexts(arrayText: string): ReadonlyArray { + const result: Array = []; + let i = 0; + while (i < arrayText.length && /\s/.test(arrayText[i] ?? "")) i++; + if (arrayText[i] !== "[") return result; + i++; + while (i < arrayText.length && /\s/.test(arrayText[i] ?? "")) i++; + if (arrayText[i] === "]") return result; + while (i < arrayText.length) { + while (i < arrayText.length && /\s/.test(arrayText[i] ?? "")) i++; + const start = i; + i = skipJsonValue(arrayText, i); + result.push(arrayText.slice(start, i)); + while (i < arrayText.length && /\s/.test(arrayText[i] ?? "")) i++; + if (arrayText[i] === ",") { + i++; + continue; + } + break; + } + return result; +} + +/** + * Returns every top-level field of a JSON *object* literal, keyed by the field name + * LOWERCASED, with ALL occurrences' raw source substrings preserved in true source + * order — including duplicates `JSON.parse` would silently collapse down to just the + * last one, AND case-variant "duplicates" of the same `config.JWK` field (e.g. `{"KID": + * 1,"kid":"k"}`) that a same-case-only grouping would otherwise miss entirely: Go's + * `encoding/json` matches struct fields case-insensitively (see + * {@link resolveJwkFieldValue}'s doc comment), so `KID` and `kid` here both feed the + * SAME struct field and must be checked together, in true relative source order, for + * {@link assertNoMalformedDuplicateJwkField} to catch a malformed earlier occurrence + * regardless of which case variant it used (verified against the real binary, + * CLI-1961 Codex review finding: `{"KID":123,"kid":"validkid"}` still errors + * `"...JWK.kid..."` in Go, even though `kid`'s own final, valid occurrence comes + * later). Grouping by lowercase here — rather than post-hoc merging per-key arrays + * after the fact — keeps every occurrence in exactly the order it appeared in the + * source, regardless of which case variant it used. + */ +function findTopLevelObjectFieldOccurrences( + objectText: string, +): ReadonlyMap> { + const result = new Map>(); + let i = 0; + while (i < objectText.length && /\s/.test(objectText[i] ?? "")) i++; + if (objectText[i] !== "{") return result; + i++; + while (i < objectText.length && /\s/.test(objectText[i] ?? "")) i++; + if (objectText[i] === "}") return result; + while (i < objectText.length) { + while (i < objectText.length && /\s/.test(objectText[i] ?? "")) i++; + const keyStart = i; + i = skipJsonValue(objectText, i); + const key = (JSON.parse(objectText.slice(keyStart, i)) as string).toLowerCase(); + while (i < objectText.length && /\s/.test(objectText[i] ?? "")) i++; + if (objectText[i] === ":") i++; + while (i < objectText.length && /\s/.test(objectText[i] ?? "")) i++; + const valueStart = i; + i = skipJsonValue(objectText, i); + const valueText = objectText.slice(valueStart, i); + const existing = result.get(key); + if (existing === undefined) result.set(key, [valueText]); + else existing.push(valueText); + while (i < objectText.length && /\s/.test(objectText[i] ?? "")) i++; + if (objectText[i] === ",") { + i++; + continue; + } + break; + } + return result; +} + +/** + * Detects a JWK-shaped object literal's raw source text having a KNOWN `config.JWK` + * field repeated with an EARLIER occurrence Go's real decode would reject, even when the + * LAST occurrence — the only one `JSON.parse` actually keeps, per plain JS + * object-literal semantics — is perfectly valid on its own. `JSON.parse` collapsing + * `{"kid":1,"kid":"k"}` down to `{kid: "k"}` erases the very evidence + * `readOptionalString`/etc. would need to catch the earlier `1`. + * + * Verified against the real binary (CLI-1961 Codex review finding) with two genuinely + * different mechanics depending on the field: + * + * - **Plain `string`/`[]string`/`*bool` fields** (every field here except `alg`): Go's + * `encoding/json` decodes duplicate occurrences in source order and ALWAYS continues + * past a type-mismatched occurrence to try the next one (a later valid occurrence DOES + * get written into the struct) — but `Unmarshal`'s own returned error is always the + * FIRST mismatch found, regardless of what a later occurrence does. Net effect: if ANY + * occurrence of a plain field mismatches, `Unmarshal` errors, full stop — which is + * exactly what iterating every occurrence through the same {@link readOptionalString}/ + * {@link readOptionalStringArray}/{@link readOptionalBoolean} the merged value already + * goes through, in source order, reproduces (first thrown wins, same as Go's first + * saved error). + * - **`alg`**: `config.Algorithm` additionally implements `encoding.TextUnmarshaler` + * (the RS256/ES256 allowlist). Once an EARLIER occurrence's `UnmarshalText` itself + * returns a non-nil error (i.e. a validly-typed but disallowed string, like `"HS256"`), + * Go's decoder never even ATTEMPTS a later occurrence of `alg` — confirmed directly: + * `json.Unmarshal` of `{"alg":"HS256","alg":"ES256"}` into a `config.JWK`-shaped struct + * still errors `"must be one of [RS256 ES256]"`, and the field never advances past the + * first, disallowed value, even though `"ES256"` alone would have been fine and is the + * value `JSON.parse` alone would have kept. A bare JSON-type mismatch on `alg` (e.g. a + * number) behaves like the plain-field case above instead — Go's outer struct-field + * type check runs BEFORE `UnmarshalText` is ever reached, and does not block later + * occurrences the way `UnmarshalText`'s OWN error does. So `alg` needs both checks, in + * order, per occurrence: {@link readOptionalString} (type) then + * {@link legacyAssertDecodableJwkAlgorithm} (allowlist) — first thrown wins, matching + * Go's first-saved-error exactly for this field too. + * + * Checks known fields in a fixed order (not the object's own source order) — an accepted + * gap already documented on `bearer-jwt.signing-key.ts`'s `normalizeStoredJwk` for the + * analogous "multiple simultaneously-malformed DISTINCT fields" case, which this + * inherits: every genuinely malformed duplicate is still rejected, just not always + * attributed to Go's exact first field when more than one is wrong at once. + * + * A no-op (never throws, never even builds `findTopLevelObjectFieldOccurrences`'s full + * map unnecessarily) for an object with no duplicated known field — the overwhelmingly + * common case, where every value `readOptionalString`/etc. would need to inspect is + * exactly the one they already inspect via the merged value downstream. + */ +export function assertNoMalformedDuplicateJwkField(objectText: string): void { + const occurrences = findTopLevelObjectFieldOccurrences(objectText); + + const alg = occurrences.get("alg"); + if (alg !== undefined && alg.length >= 2) { + for (const rawValue of alg) { + const checked = readOptionalString({ alg: JSON.parse(rawValue) }, "alg"); + legacyAssertDecodableJwkAlgorithm(checked); + } + } + + for (const field of JWK_PLAIN_STRING_FIELDS) { + const values = occurrences.get(field); + if (values === undefined || values.length < 2) continue; + for (const rawValue of values) { + readOptionalString({ [field]: JSON.parse(rawValue) }, field); + } + } + + const keyOps = occurrences.get("key_ops"); + if (keyOps !== undefined && keyOps.length >= 2) { + for (const rawValue of keyOps) { + readOptionalStringArray({ key_ops: JSON.parse(rawValue) }, "key_ops"); + } + } + + const ext = occurrences.get("ext"); + if (ext !== undefined && ext.length >= 2) { + for (const rawValue of ext) { + readOptionalBoolean({ ext: JSON.parse(rawValue) }, "ext"); + } + } +} + +/** + * Resolves `supabase/config.toml`'s display path and `[auth].signing_keys_path`'s + * actual/display path — no file I/O on the keys path itself (see + * {@link legacyReadSigningKeysFile} for that). Mirrors Go's `flags.LoadConfig` + + * `Config.Validate`'s path resolution (`apps/cli-go/pkg/config/config.go:928-930`). + */ +export const legacyResolveSigningKeysConfigPaths = Effect.fnUntraced(function* ( + cwd: string, + onConfigParseError: (message: string) => E, +) { + const path = yield* Path.Path; + // Go's `Config.Load` runs its `loadNestedEnv` dotenv cascade (`config.go:786-793`) + // BEFORE `loadFromFile` ever decodes `env(...)` TOML references (`LoadEnvHook`, + // `config.go:735-738`) — and that cascade reaches `.env.[.local]` files + // AND the project-root directory (`/.env`), not just `supabase/.env`/ + // `.env.local`. `loadProjectConfig`'s OWN internal env resolution (used whenever + // `options.projectEnv` is omitted, `@supabase/config`'s `loadProjectEnvironment`) only + // covers that narrower `supabase/`-dir, env-agnostic half — so `[auth].signing_keys_path + // = "env(KEYS_PATH)"` with `KEYS_PATH` set only in `.env.development`/`/.env` + // would otherwise stay literally unexpanded here even though Go's CLI resolves and + // signs with it fine (verified against the real Go source: CLI-1961 Codex review + // finding). Fills the exact same gap `legacy-local-project-context.ts`'s + // `legacyLoadLocalProjectContext` already fills for `stop`/`status`, via the same + // two-step resolution. + const projectEnv = yield* loadProjectEnvironment({ + cwd, + baseEnv: process.env, + search: false, + skipEnvLocal: (process.env["SUPABASE_ENV"] || "development") === "test", + }).pipe( + Effect.mapError((cause) => onConfigParseError(`failed to read config: ${String(cause)}`)), + ); + const projectEnvValues = yield* Effect.try({ + try: () => legacyResolveProjectEnvironmentValues(projectEnv, cwd), + catch: (cause) => onConfigParseError(`failed to read config: ${String(cause)}`), + }); + const loaded = yield* loadProjectConfig(cwd, { + projectEnv: projectEnv !== null ? { ...projectEnv, values: projectEnvValues } : undefined, + goViperCompat: true, + // `cwd` here is the ALREADY-resolved `LegacyCliConfig.workdir` (Go's own ancestor + // climb, `ChangeWorkDir`/`getProjectRoot`, already ran once to produce it — see + // `legacy-cli-config.layer.ts`'s `resolveWorkdir`). Without `search: false`, this + // call would climb AGAIN from `cwd`, which diverges from Go's real + // `Config.Load("")` (`pkg/config/utils.go:43-48`) whenever an explicit `--workdir` + // points at a subdirectory below another project's root: Go changes directly into + // that exact subdirectory (no climb once `--workdir`/`SUPABASE_WORKDIR` is set — + // `internal/utils/misc.go:246-249`) and finds no `supabase/config.toml` there, + // while this call would otherwise still find the ANCESTOR project's config — + // verified against the real binary (Codex review finding, CLI-1961): the ancestor's + // `signing_keys_path` leaked into the picker prompt in the TS port but not in Go. + // `tomlOnly: true` matches the same `Config.Load` — Go has no concept of a JSON + // project config file, so a stray `supabase/config.json` must never win over + // `config.toml` here either (`legacy-local-project-context.ts` establishes this + // exact pair of options for the same underlying reason). + search: false, + tomlOnly: true, + }).pipe( + Effect.catchTag("ProjectConfigParseError", (cause) => + Effect.fail(onConfigParseError(`failed to parse ${cause.path}: ${String(cause.cause)}`)), + ), + ); + if (loaded === null) { + return { + configDisplayPath: path.join("supabase", "config.toml"), + authEnabled: true, + signingKeysPath: Option.none(), + } satisfies LegacyGenSigningKeysConfigPaths; + } + + // Go displays the CWD-relative `supabase/config.toml` (utils.ConfigPath), never an absolute + // path. `@supabase/config` always resolves `loaded.path` to an absolute path, so relativize it + // back against the project root to match Go's output. + const projectRoot = path.dirname(path.dirname(loaded.path)); + const configDisplayPath = path.relative(projectRoot, loaded.path); + const authEnabled = loaded.config.auth.enabled; + + const configuredPath = loaded.config.auth.signing_keys_path; + if (configuredPath === undefined || configuredPath.length === 0) { + return { + configDisplayPath, + authEnabled, + signingKeysPath: Option.none(), + } satisfies LegacyGenSigningKeysConfigPaths; + } + + const resolvedPath = path.isAbsolute(configuredPath) + ? configuredPath + : path.join(path.dirname(loaded.path), configuredPath); + const displayPath = path.isAbsolute(configuredPath) + ? configuredPath + : path.relative(projectRoot, resolvedPath); + return { + configDisplayPath, + authEnabled, + signingKeysPath: Option.some({ actualPath: resolvedPath, displayPath }), + } satisfies LegacyGenSigningKeysConfigPaths; +}); + +/** + * Reads and JSON-decodes a `[auth].signing_keys_path` file at `actualPath` into an array of + * JWK-shaped records. Mirrors Go's `Config.Validate` read (`config.go:1110-1116`, wrapped + * `"failed to read signing keys: %w"` / `"failed to decode signing keys: %w"`) — the + * "expected a JSON array [of objects]" shape check matches this package's own pre-existing + * `gen signing-key` behavior (not a literal Go error string; Go's decode failures come from + * `encoding/json`'s own type-mismatch errors, which `readJwkArray`'s two checks approximate). + * + * The `alg` allowlist check and the duplicate-field check below ARE literal Go error strings + * (or reproductions of Go's exact struct-field type-mismatch text), unlike the shape checks + * above: Go's `fetcher.ParseJSON[[]JWK]` (`pkg/fetcher/http.go:144-151`) decodes straight + * into `[]config.JWK`, running the full `encoding/json` struct decode — including + * `config.Algorithm.UnmarshalText` (`pkg/config/auth.go:80-86`) — for every element, wrapped + * here as `"failed to decode signing keys: failed to parse response body: %w"`, matching + * `ParseJSON`'s own wrap on top of `Config.Validate`'s. {@link assertNoMalformedDuplicateJwkField} + * closes the gap where an element has a duplicate top-level field whose earlier occurrence + * `JSON.parse` alone would have discarded before either check ever saw it (CLI-1961 Codex + * review finding). + */ +export const legacyReadSigningKeysFile = Effect.fnUntraced(function* ( + actualPath: string, + onReadError: (message: string) => E1, + onDecodeError: (message: string) => E2, +) { + const fs = yield* FileSystem.FileSystem; + const raw = yield* fs + .readFileString(actualPath) + .pipe(Effect.mapError((cause) => onReadError(`failed to read signing keys: ${String(cause)}`))); + const decoded = yield* Effect.try({ + // Go's `fetcher.ParseJSON[[]JWK]` (`pkg/fetcher/http.go:144-151`) is a single + // `json.Decoder.Decode` call, which reads exactly ONE JSON value and never checks + // for trailing bytes — content after that first value (even further + // syntactically-valid JSON, e.g. a `signing_keys_path` file containing + // `"[validKey] []"`) is silently ignored, not an error. Plain `JSON.parse` + // requires the ENTIRE string to be exactly one value and throws on anything left + // over, so parse only the first value's own source span — reusing the same + // {@link skipJsonValue} span-scanner {@link splitJsonArrayElementTexts} already + // uses below — to match Go's decode-once-ignore-the-rest behavior. Verified + // against the real binary (CLI-1961 Codex review finding): Go still signs with + // `validKey` from a `signing_keys_path` file containing `[validKey] []`. + try: () => JSON.parse(raw.slice(0, skipJsonValue(raw, 0))), + catch: (cause) => onDecodeError(`failed to decode signing keys: ${String(cause)}`), + }); + if (!Array.isArray(decoded)) { + return yield* Effect.fail( + onDecodeError("failed to decode signing keys: expected a JSON array"), + ); + } + // A bare `null` ARRAY ELEMENT (as opposed to `isAbsentJwkField`'s "a FIELD is + // absent") is Go's own `encoding/json` zero-value case, not a type mismatch: a + // `null` decoded into `config.JWK` (a struct, not a pointer) leaves every field at + // its zero value, same as `bearer-jwt.signing-key.ts`'s `resolveSigningKeyFromStdinJwk` + // already documents for a pasted `null` JWK. So `null` must normalize to `{}` + // (an empty record — {@link readOptionalString}/etc. treat every field as absent) + // rather than fail this shape check outright, regardless of WHERE in the array it + // appears. Verified against the real binary (CLI-1961 Codex review finding): with + // `signing_keys_path` decoding to `[validKey, null]`, `Config.Validate` succeeds + // (`generateAPIKeys` signs with `SigningKeys[0]`, which is `validKey`), and a + // non-TTY `gen bearer-jwt` can still select `validKey` by kid or blank-input + // fallback — a `[null, validKey]` ordering is different (already adjudicated on + // this PR): `SigningKeys[0]` there is the null-decoded zero-value JWK, so + // `generateAPIKeys` itself fails signing before selection is ever reached — but + // that later, ALREADY-REJECTED failure is Go's own downstream signing behavior, not + // a reason for this decode step to reject either ordering up front. + for (const item of decoded) { + if (item !== null && !isRecord(item)) { + return yield* Effect.fail( + onDecodeError("failed to decode signing keys: expected a JSON array of objects"), + ); + } + } + const elementTexts = splitJsonArrayElementTexts(raw); + const normalized: Array> = []; + for (const [index, item] of ( + decoded as ReadonlyArray | null> + ).entries()) { + const record = item === null ? {} : item; + const elementText = elementTexts[index]; + try { + // Case-insensitive lookup (`resolveJwkFieldValue`) — Go's `alg` allowlist check + // (`config.Algorithm.UnmarshalText`) runs at JSON-decode time regardless of the + // key's casing (CLI-1961 Codex review finding); see that function's doc comment. + const alg = resolveJwkFieldValue(record, "alg"); + legacyAssertDecodableJwkAlgorithm(typeof alg === "string" ? alg : undefined); + if (elementText !== undefined) { + assertNoMalformedDuplicateJwkField(elementText); + } + } catch (cause) { + return yield* Effect.fail( + onDecodeError( + `failed to decode signing keys: failed to parse response body: ${cause instanceof Error ? cause.message : String(cause)}`, + ), + ); + } + normalized.push(record); + } + return normalized as ReadonlyArray; +}); diff --git a/apps/cli/src/legacy/commands/gen/signing-key/signing-key.handler.ts b/apps/cli/src/legacy/commands/gen/signing-key/signing-key.handler.ts index 695bd660dd..b164ce134a 100644 --- a/apps/cli/src/legacy/commands/gen/signing-key/signing-key.handler.ts +++ b/apps/cli/src/legacy/commands/gen/signing-key/signing-key.handler.ts @@ -1,6 +1,5 @@ import { generateKeyPairSync, randomUUID } from "node:crypto"; import { styleText } from "node:util"; -import { loadProjectConfig } from "@supabase/config"; import { Effect, FileSystem, Option, Path } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; @@ -8,12 +7,18 @@ import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; import { findGitRootPath } from "../../../../shared/git/git-root.ts"; import { legacyLoadProjectEnv } from "../../../shared/legacy-db-config.toml-read.ts"; import { LegacyDebugLogger } from "../../../shared/legacy-debug-logger.service.ts"; +import { LEGACY_DEFAULT_SIGNING_KEY } from "../../../shared/legacy-go-jwt.ts"; import { legacyPromptYesNo } from "../../../../shared/legacy/legacy-prompt-yes-no.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; import { legacyResolveYesWithProjectEnv } from "../../../../shared/legacy/global-flags.ts"; import { CONTEXT_CANCELED_MESSAGE } from "../../../../shared/output/errors.ts"; import { Output } from "../../../../shared/output/output.service.ts"; import { Tty } from "../../../../shared/runtime/tty.service.ts"; +import { + legacyReadSigningKeysFile, + legacyResolveSigningKeysConfigPaths, + type LegacyStoredSigningKeyJwk, +} from "../gen.signing-keys-config.ts"; import type { LegacyGenSigningKeyFlags } from "./signing-key.command.ts"; import { LegacyGenSigningKeyCancelledError, @@ -46,14 +51,12 @@ interface SigningKeyJwk { readonly qi?: string; } -type StoredSigningKeyJwk = Readonly>; - interface ResolvedSigningKeysConfig { readonly configDisplayPath: string; readonly configured: Option.Option<{ actualPath: string; displayPath: string; - existingKeys: ReadonlyArray; + existingKeys: ReadonlyArray; }>; } @@ -75,28 +78,6 @@ function readStringField( ); } -function readJwkArray( - value: unknown, -): Effect.Effect, LegacyGenSigningKeyDecodeError> { - if (!Array.isArray(value)) { - return Effect.fail( - new LegacyGenSigningKeyDecodeError({ - message: "failed to decode signing keys: expected a JSON array", - }), - ); - } - for (const item of value) { - if (!isRecord(item)) { - return Effect.fail( - new LegacyGenSigningKeyDecodeError({ - message: "failed to decode signing keys: expected a JSON array of objects", - }), - ); - } - } - return Effect.succeed(value); -} - function styleIfTty( enabled: boolean, format: Parameters[0], @@ -162,64 +143,42 @@ const generatePrivateKey = Effect.fnUntraced(function* (algorithm: SigningAlgori } satisfies SigningKeyJwk; }); +// `gen signing-key` goes through the exact same `flags.LoadConfig` -> `Config.Validate` +// pipeline as `gen bearer-jwt` (`signingkeys.go:111` vs `bearerjwt.go:20`) — there is no +// separate, ungated Go code path for this command. Go's `Config.Validate` only reads +// `[auth].signing_keys_path`'s file INSIDE `if c.Auth.Enabled` (`config.go:1087-1116`), so +// with auth disabled `utils.Config.Auth.SigningKeys` never advances past `NewConfig()`'s own +// default single-key array — meaning `--append` appends to (and a subsequent overwrite +// clobbers) that phantom default set, NOT the file's real content. Verified against the real +// binary (CLI-1961 Codex review finding): with `auth.enabled = false` and a configured +// `signing_keys_path` pointing at a file containing a real custom key, `gen signing-key +// --append` overwrote the file with the default ES256 key plus the newly generated one, +// discarding the original entry entirely — surprising, but this is what Go actually does, so +// this must gate the read on `paths.authEnabled` exactly like `gen bearer-jwt`'s own +// `legacyResolveBearerJwtSigningKey` already does. const loadSigningKeysConfig = Effect.fnUntraced(function* (cwd: string) { - const path = yield* Path.Path; - const loaded = yield* loadProjectConfig(cwd, { goViperCompat: true }).pipe( - Effect.catchTag("ProjectConfigParseError", (cause) => - Effect.fail( - new LegacyGenSigningKeyConfigParseError({ - message: `failed to parse ${cause.path}: ${String(cause.cause)}`, - }), - ), - ), + const paths = yield* legacyResolveSigningKeysConfigPaths( + cwd, + (message) => new LegacyGenSigningKeyConfigParseError({ message }), ); - if (loaded === null) { + if (Option.isNone(paths.signingKeysPath)) { return { - configDisplayPath: path.join("supabase", "config.toml"), + configDisplayPath: paths.configDisplayPath, configured: Option.none(), } satisfies ResolvedSigningKeysConfig; } - // Go displays the CWD-relative `supabase/config.toml` (utils.ConfigPath), never an absolute - // path. `@supabase/config` always resolves `loaded.path` to an absolute path, so relativize it - // back against the project root to match Go's output. - const projectRoot = path.dirname(path.dirname(loaded.path)); - const configDisplayPath = path.relative(projectRoot, loaded.path); - - const configuredPath = loaded.config.auth.signing_keys_path; - if (configuredPath === undefined || configuredPath.length === 0) { - return { - configDisplayPath, - configured: Option.none(), - } satisfies ResolvedSigningKeysConfig; - } - - const resolvedPath = path.isAbsolute(configuredPath) - ? configuredPath - : path.join(path.dirname(loaded.path), configuredPath); - const displayPath = path.isAbsolute(configuredPath) - ? configuredPath - : path.relative(projectRoot, resolvedPath); - const fs = yield* FileSystem.FileSystem; - const raw = yield* fs.readFileString(resolvedPath).pipe( - Effect.mapError( - (cause) => - new LegacyGenSigningKeyReadError({ - message: `failed to read signing keys: ${String(cause)}`, - }), - ), - ); - const decoded = yield* Effect.try({ - try: () => JSON.parse(raw), - catch: (cause) => - new LegacyGenSigningKeyDecodeError({ - message: `failed to decode signing keys: ${String(cause)}`, - }), - }); - const existingKeys = yield* readJwkArray(decoded); + const { actualPath, displayPath } = paths.signingKeysPath.value; + const existingKeys = paths.authEnabled + ? yield* legacyReadSigningKeysFile( + actualPath, + (message) => new LegacyGenSigningKeyReadError({ message }), + (message) => new LegacyGenSigningKeyDecodeError({ message }), + ) + : [{ ...LEGACY_DEFAULT_SIGNING_KEY }]; return { - configDisplayPath, - configured: Option.some({ actualPath: resolvedPath, displayPath, existingKeys }), + configDisplayPath: paths.configDisplayPath, + configured: Option.some({ actualPath, displayPath, existingKeys }), } satisfies ResolvedSigningKeysConfig; }); diff --git a/apps/cli/src/legacy/commands/gen/signing-key/signing-key.integration.test.ts b/apps/cli/src/legacy/commands/gen/signing-key/signing-key.integration.test.ts index 59e346fb75..11c13765b7 100644 --- a/apps/cli/src/legacy/commands/gen/signing-key/signing-key.integration.test.ts +++ b/apps/cli/src/legacy/commands/gen/signing-key/signing-key.integration.test.ts @@ -30,6 +30,7 @@ import { TelemetryRuntime } from "../../../../shared/telemetry/runtime.service.t import { makeTelemetryIdentity } from "../../../../shared/telemetry/identity.ts"; import { legacyGenCommand } from "../gen.command.ts"; import { legacyGenSigningKey } from "./signing-key.handler.ts"; +import { LEGACY_DEFAULT_SIGNING_KEY } from "../../../shared/legacy-go-jwt.ts"; const tempRoot = useLegacyTempWorkdir("supabase-gen-signing-key-int-"); @@ -201,18 +202,27 @@ describe("legacy gen signing-key integration", () => { }).pipe(Effect.provide(layer)) as Effect.Effect; }); - it.live("uses the project-relative config file path in the local setup hint", () => { - const { layer, out } = setup(); - return Effect.gen(function* () { - yield* Effect.tryPromise(() => writeJsonConfig("{}\n")); - yield* legacyGenSigningKey({ algorithm: "ES256", append: false }); + it.live( + "ignores a stray config.json and uses the default config.toml path in the local setup hint (CLI-1961)", + () => { + const { layer, out } = setup(); + return Effect.gen(function* () { + // Go's `Config.Load` (`pkg/config/utils.go:43-48`) has no concept of a JSON project + // config file — a stray `supabase/config.json` with no `config.toml` present must be + // treated exactly like no config at all, never as a substitute config source + // (`gen.signing-keys-config.ts`'s `loadProjectConfig(..., { tomlOnly: true })`; Codex + // review finding, CLI-1961). Go prints the CWD-relative `supabase/config.toml` in this + // "absent config" case; the hint must stay relative and must never leak the absolute + // temp-dir path either. + yield* Effect.tryPromise(() => writeJsonConfig("{}\n")); + yield* legacyGenSigningKey({ algorithm: "ES256", append: false }); - // Go prints the CWD-relative `supabase/config.toml`; the hint must stay relative and must - // never leak the absolute temp-dir path. - expect(out.stderrText).toContain(join("supabase", "config.json")); - expect(out.stderrText).not.toContain(join(tempRoot.current, "supabase", "config.json")); - }).pipe(Effect.provide(layer)); - }); + expect(out.stderrText).toContain(join("supabase", "config.toml")); + expect(out.stderrText).not.toContain("config.json"); + expect(out.stderrText).not.toContain(tempRoot.current); + }).pipe(Effect.provide(layer)); + }, + ); it.live( "overwrites the configured signing keys file and defaults to yes on non-tty when stdin has no piped answer", @@ -339,6 +349,61 @@ describe("legacy gen signing-key integration", () => { }).pipe(Effect.provide(layer)); }); + // CLI-1961 Codex review finding: Go's `Config.Validate` only reads/decodes the configured + // `signing_keys_path` file INSIDE `if c.Auth.Enabled` — `gen signing-key` goes through the + // exact same `flags.LoadConfig` pipeline as `gen bearer-jwt` (both call it), so it is subject + // to the same gate. Verified against the real binary: with `auth.enabled = false`, a + // malformed signing-keys file is never read at all, so the command succeeds instead of + // failing on a decode error. + it.live("does not fail on a malformed signing keys file when [auth] enabled is false", () => { + const { layer } = setup(); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nenabled = false\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => + writeFile(join(tempRoot.current, "supabase", "signing_keys.json"), "not valid json {\n"), + ); + + const exit = yield* Effect.exit(legacyGenSigningKey({ algorithm: "ES256", append: true })); + expect(Exit.isFailure(exit)).toBe(false); + }).pipe(Effect.provide(layer)); + }); + + // Same finding, the more surprising half: Go's `Config.Auth.SigningKeys` never advances past + // `NewConfig()`'s own default single-key array when auth is disabled, so `--append` appends + // to (and the resulting write clobbers) that phantom default set, NOT the file's real + // content — verified against the real binary: appending under `auth.enabled = false` against + // a file containing a genuine custom key overwrote it with the default ES256 key plus the + // newly generated one, discarding the original entry entirely. + it.live( + "appends to (and overwrites with) the built-in default key, ignoring the real file content, when [auth] enabled is false", + () => { + const { layer } = setup(); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nenabled = false\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => + writeFile( + join(tempRoot.current, "supabase", "signing_keys.json"), + `${JSON.stringify([{ kty: "EC", kid: "existing-key", x: "existing-x" }])}\n`, + ), + ); + + yield* legacyGenSigningKey({ algorithm: "ES256", append: true }); + + const saved = yield* Effect.tryPromise(() => + readFile(join(tempRoot.current, "supabase", "signing_keys.json"), "utf8"), + ); + const parsed = JSON.parse(saved) as ReadonlyArray>; + expect(parsed).toHaveLength(2); + expect(parsed[0]?.kid).toBe(LEGACY_DEFAULT_SIGNING_KEY.kid); + expect(parsed.some((key) => key["kid"] === "existing-key")).toBe(false); + }).pipe(Effect.provide(layer)); + }, + ); + it.live("fails when the configured signing keys file is not a JSON array of objects", () => { const { layer } = setup(); return Effect.gen(function* () { diff --git a/apps/cli/src/legacy/commands/gen/types/types.integration.test.ts b/apps/cli/src/legacy/commands/gen/types/types.integration.test.ts index 9ee4f0d09a..0679186101 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.integration.test.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.integration.test.ts @@ -814,7 +814,13 @@ describe("legacy gen types", () => { // Effect parser produces for this argv (both `local` and `linked` parse as // independently true, since its tokenizer is unaware of pflag's value // consumption — CLI-1982); only the pflag-faithful scan can tell them apart. - const { layer } = setup({ args: ["gen", "types", "-s", "--linked", "--local"] }); + // `childExitCode: 1` fails the local target's `container inspect`, keeping the + // downstream failure deterministic before the real SSL probe can reach whatever + // is listening on the local db port. + const { layer } = setup({ + args: ["gen", "types", "-s", "--linked", "--local"], + childExitCode: 1, + }); return Effect.gen(function* () { const exit = yield* legacyGenTypes(defaultFlags({ local: true, linked: true })).pipe( @@ -824,6 +830,7 @@ describe("legacy gen types", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { + expect(String(exit.cause)).toContain("failed to inspect service"); expect(String(exit.cause)).not.toContain("if any flags in the group"); } }); diff --git a/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md index a19804de4a..6f6236b192 100644 --- a/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md @@ -186,17 +186,17 @@ not implemented. ## Environment Variables -| Variable | Purpose | Required? | -| -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | -| `SUPABASE_*` (any dotted config field) | Generic Viper-style `AutomaticEnv` override of any `config.toml` field (e.g. `SUPABASE_AUTH_ENABLED`, `SUPABASE_API_PORT`) | no | -| `SUPABASE_EXPERIMENTAL` (or `--experimental`) | Fresh volume + no pg-delta: applies `db.migrations.schema_paths` files instead of `migrations/*.sql` (see "Fresh-volume DB setup" above) | no | -| `SUPABASE_EXPERIMENTAL_PG_DELTA` | Enables the post-`MigrateAndSeed` migrations-catalog cache warmup when `[experimental.pgdelta].enabled` is unset | no | -| `SUPABASE_INTERNAL_IMAGE_REGISTRY` | Overrides the image registry used to resolve every service's image | no | -| `SUPABASE_PROJECT_ID` | Overrides the resolved local project id (env → config.toml → workdir basename) | no | -| `SUPABASE_WORKDIR` | Resolves `LegacyCliConfig.workdir` | no | -| `BITBUCKET_CLONE_DIR` | When non-empty, drops named volumes and `--security-opt` from every container create | no | -| `DOCKER_HOST` / `DOCKER_CONTEXT` / `DOCKER_TLS_VERIFY` / `DOCKER_CERT_PATH` / `DOCKER_API_VERSION` | Read (ambient shell OR a project `.env`/`.env.`/`.env.local` file — matching Go's `godotenv.Load`) to discover the Docker daemon this whole command talks to; `DOCKER_HOST` is also re-derived and set on Vector's container env so it can reach the host's Docker socket for log collection | no | -| `KONG_NGINX_WORKER_PROCESSES` | Read (ambient shell or project dotenv) into Kong's own container env (defaults to `"1"` when unset) | no | +| Variable | Purpose | Required? | +| -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | +| `SUPABASE_*` (any dotted config field) | Generic Viper-style `AutomaticEnv` override of any `config.toml` field (e.g. `SUPABASE_AUTH_ENABLED`, `SUPABASE_API_PORT`) | no | +| `SUPABASE_EXPERIMENTAL` (or `--experimental`) | Fresh volume + no pg-delta: applies `db.migrations.schema_paths` files instead of `migrations/*.sql` (see "Fresh-volume DB setup" above) | no | +| `SUPABASE_EXPERIMENTAL_PG_DELTA` | Enables the post-`MigrateAndSeed` migrations-catalog cache warmup when `[experimental.pgdelta].enabled` is unset | no | +| `SUPABASE_INTERNAL_IMAGE_REGISTRY` | Overrides the image registry used to resolve every service's image | no | +| `SUPABASE_PROJECT_ID` | Overrides the resolved local project id (env → config.toml → workdir basename) | no | +| `SUPABASE_WORKDIR` | Resolves `LegacyCliConfig.workdir` | no | +| `BITBUCKET_CLONE_DIR` | When non-empty, drops named volumes and `--security-opt` from every container create | no | +| `DOCKER_HOST` / `DOCKER_CONTEXT` / `DOCKER_TLS_VERIFY` / `DOCKER_CERT_PATH` / `DOCKER_API_VERSION` / `DOCKER_CONFIG` | Read (ambient shell OR a project `.env`/`.env.`/`.env.local` file — matching Go's `godotenv.Load`) to discover the Docker daemon this whole command talks to; `DOCKER_HOST` is also re-derived and set on Vector's container env so it can reach the host's Docker socket for log collection | no | +| `KONG_NGINX_WORKER_PROCESSES` | Read (ambient shell or project dotenv) into Kong's own container env (defaults to `"1"` when unset) | no | `docker`/`podman` must be resolvable on `PATH` — same fallback behavior as `stop`/`status`. diff --git a/apps/cli/src/legacy/commands/start/services/edge-runtime.service.ts b/apps/cli/src/legacy/commands/start/services/edge-runtime.service.ts index a08b8376c6..31a22496fd 100644 --- a/apps/cli/src/legacy/commands/start/services/edge-runtime.service.ts +++ b/apps/cli/src/legacy/commands/start/services/edge-runtime.service.ts @@ -13,7 +13,7 @@ * ``` * * Unlike its 12 siblings in this directory, this module does NOT build a - * `LegacyStartContainerSpec` for `legacyStartContainer` + * `LegacyStartContainerSpec` for `legacyCreateContainer` * (`../../../shared/db-bootstrap/container-lifecycle.ts`) to create+start uniformly. That * unification (`docker create`/`docker start`, `-e KEY`-only env with values * supplied via the spawned process's own environment) was evaluated against @@ -40,7 +40,7 @@ * exactly as `functions serve` already spawns it (see that module's own doc * comment), and exposes {@link legacyStartEdgeRuntimeContainer} as a direct * bring-up `Effect` for `start.handler.ts` to call from its own bring-up loop - * — NOT a spec for `legacyStartContainer` to create. `start.handler.ts`'s + * — NOT a spec for `legacyCreateContainer` to create. `start.handler.ts`'s * wiring must special-case Edge Runtime's bring-up call, the same way it * already special-cases Postgres's (also called directly, not through the * generic `buildSpecForService` switch, since it needs its own health-wait @@ -138,7 +138,7 @@ export interface LegacyEdgeRuntimeBringUpInput { * `startEdgeRuntimeContainer` (already ported for `functions serve`) with * `start`'s own already-resolved config/secrets in place of that command's * independent config-loading pipeline. `start.handler.ts`'s bring-up loop - * should call this directly (NOT `legacyStartContainer`) for the Edge Runtime + * should call this directly (NOT `legacyCreateContainer`) for the Edge Runtime * entry in its service list, gated the same way as every other service on * `config.edge_runtime.enabled && !isContainerExcluded(...)`. * @@ -152,7 +152,7 @@ export interface LegacyEdgeRuntimeBringUpInput { * `cleanup` (removing the temp env-file/multiline-env-script/serve-main- * template files this call writes to the host) is intentionally left to the * caller, and the caller must NOT invoke it on a successful bring-up. Unlike - * every other `start` service (`legacyStartContainer`'s `restartPolicy: + * every other `start` service (`legacyCreateContainer`'s `restartPolicy: * "unless-stopped"`), Go's own Edge Runtime bring-up (`serve.ServeFunctions`, * `internal/functions/serve/serve.go:218-241`) sets NO Docker restart policy * at all — its lifecycle is deliberately reconciled at the CLI level @@ -162,7 +162,7 @@ export interface LegacyEdgeRuntimeBringUpInput { * still exist for as long as the container itself can be reattached to * (e.g. a plain `docker start` by the user, or discovery by a later CLI * invocation) — unlike Kong/Postgres/Supavisor's `secretFiles`, which - * `container-lifecycle.ts`'s `legacyStartContainer` now `docker cp`s straight + * `container-lifecycle.ts`'s `legacyCreateContainer` now `docker cp`s straight * into the container instead of staging on host disk (see * `legacyCopyStartSecretFileIntoContainer`'s doc comment), Edge Runtime's own * bind-mounted env-file/multiline-env-script/serve-main-template artifacts diff --git a/apps/cli/src/legacy/commands/start/start.handler.ts b/apps/cli/src/legacy/commands/start/start.handler.ts index ee7337c092..b7399f37a0 100644 --- a/apps/cli/src/legacy/commands/start/start.handler.ts +++ b/apps/cli/src/legacy/commands/start/start.handler.ts @@ -142,8 +142,8 @@ import { legacyResolveDbBootstrapConfig } from "../../shared/db-bootstrap/bootst import { legacyStartDatabase } from "../../shared/db-bootstrap/start-database.ts"; import { LEGACY_START_SERVICES } from "./start.services.ts"; import { - legacyStartContainer, - type LegacyStartContainerOpts, + legacyCreateContainer, + type LegacyContainerOpts, } from "../../shared/db-bootstrap/container-lifecycle.ts"; import { legacyEnsureImagesCached } from "../../shared/db-bootstrap/image-prepull.ts"; import { @@ -939,7 +939,7 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta // (`legacy-edge-runtime-script.layer.ts`). const extraHosts = runtimeInfo.platform === "linux" ? ["host.docker.internal:host-gateway"] : []; - const startOpts: LegacyStartContainerOpts = { + const startOpts: LegacyContainerOpts = { projectId, isBitbucketPipeline, workdir: cliConfig.workdir, @@ -1804,7 +1804,7 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta const runtime: StartedRuntime = yield* legacyStartEdgeRuntimeContainer(edgeRuntimeInput); // Deliberately NOT calling `runtime.cleanup` here — see // `edge-runtime.service.ts`'s header for why. Unlike every other - // service built here (`legacyStartContainer`'s `restartPolicy: + // service built here (`legacyCreateContainer`'s `restartPolicy: // "unless-stopped"`), Go's own Edge Runtime bring-up sets no Docker // restart policy at all, so this container's `docker run` matches // that — but its bind-mounted host temp files must still exist for @@ -1848,7 +1848,7 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta ), ), ); - yield* legacyStartContainer(spawner, spec, startOpts); + yield* legacyCreateContainer(spawner, spec, startOpts); if (excludeFromHealthWatch !== true) { started.set(spec.containerName, spec.image); } diff --git a/apps/cli/src/legacy/commands/start/start.integration.test.ts b/apps/cli/src/legacy/commands/start/start.integration.test.ts index 04a71ff5d3..aae4dd9bc9 100644 --- a/apps/cli/src/legacy/commands/start/start.integration.test.ts +++ b/apps/cli/src/legacy/commands/start/start.integration.test.ts @@ -209,7 +209,7 @@ function createdContainerNames(spawned: ReadonlyArray): ReadonlyArr /** * Wraps `base` to also intercept every `docker cp :` call - * `legacyStartContainer`'s `secretFiles` delivery issues (`legacyCopyStartSecretFileIntoContainer`, + * `legacyCreateContainer`'s `secretFiles` delivery issues (`legacyCopyStartSecretFileIntoContainer`, * `container-lifecycle.ts` — supabase/cli#6022): synchronously reads the host-side temp file's * content while it's still on disk (its own cleanup only runs once THIS spawn's effect resolves) * and records it against the destination `containerPath`, so a test can assert on delivered secret @@ -284,7 +284,7 @@ function freshVolumeRoute( base: (args: ReadonlyArray) => RouteResult, ): (args: ReadonlyArray) => RouteResult { return (args) => { - // `legacyStartVolumeExists` now distinguishes a confirmed "not found" from + // `legacyVolumeExists` now distinguishes a confirmed "not found" from // any other inspect error (matching Go's `errdefs.IsNotFound` gate) — the // stderr text is what makes this simulate a genuinely fresh/non-existent // volume rather than an ambiguous inspect failure. @@ -3135,7 +3135,7 @@ content_path = "./templates/custom_notice.html" expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const serialized = JSON.stringify(exit.cause); - expect(serialized).toContain("LegacyStartNetworkCreateError"); + expect(serialized).toContain("LegacyNetworkCreateError"); expect(serialized).toContain("failed to create docker network"); } expect(child.spawned.some((s) => s.args[0] === "create")).toBe(false); @@ -3160,7 +3160,7 @@ content_path = "./templates/custom_notice.html" expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const serialized = JSON.stringify(exit.cause); - expect(serialized).toContain("LegacyStartContainerCreateError"); + expect(serialized).toContain("LegacyContainerCreateError"); expect(serialized).toContain("failed to create docker container"); } expect(rollbackWasAttempted(child.spawned)).toBe(true); @@ -3186,7 +3186,7 @@ content_path = "./templates/custom_notice.html" expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const serialized = JSON.stringify(exit.cause); - expect(serialized).toContain("LegacyStartContainerStartError"); + expect(serialized).toContain("LegacyContainerStartError"); expect(serialized).toContain("port is already allocated"); expect(serialized).toContain( "Try stopping the project or container already using 0.0.0.0:54322", diff --git a/apps/cli/src/legacy/commands/storage/cp/cp.command.ts b/apps/cli/src/legacy/commands/storage/cp/cp.command.ts index e1befdbf56..82bf9a3af2 100644 --- a/apps/cli/src/legacy/commands/storage/cp/cp.command.ts +++ b/apps/cli/src/legacy/commands/storage/cp/cp.command.ts @@ -8,7 +8,7 @@ import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-comm import { legacyRequireExperimental } from "../../../shared/legacy-experimental-gate.ts"; import { legacyStorageGatewayRuntimeLayer } from "../../../shared/legacy-storage-runtime.layer.ts"; import { legacyStorageInvalidJobsMessage } from "../storage.errors.ts"; -import { legacyParseUintBase0 } from "./cp.parse-uint.ts"; +import { legacyParseUintBase0 } from "../../../shared/legacy-parse-uint.ts"; import { LegacyStorageLinkedFlagDef, LegacyStorageLocalFlagDef, diff --git a/apps/cli/src/legacy/commands/storage/cp/cp.parse-uint.ts b/apps/cli/src/legacy/commands/storage/cp/cp.parse-uint.ts deleted file mode 100644 index b56e96622f..0000000000 --- a/apps/cli/src/legacy/commands/storage/cp/cp.parse-uint.ts +++ /dev/null @@ -1,119 +0,0 @@ -/** - * Faithful port of Go's `strconv.ParseUint(s, 0, 64)` — the exact parser pflag - * runs for a `UintVarP` flag like `storage cp --jobs` (`uintValue.Set`, - * `pflag/uint.go`). Operating on the RAW flag token (instead of a - * pre-normalized number) is load-bearing for parity: - * - * - every sign prefix is rejected, including `-0` and `+1` (a numeric - * normalization turns `-0` into negative zero, for which `value < 0` is - * false, silently accepting what Go rejects); - * - error messages carry the ORIGINAL spelling (`-01`, not `-1`); - * - base 0 enables Go's prefix/underscore forms: `0x10` → 16, `0o10`/`010` → - * 8 (octal!), `0b10` → 2, and `1_0` → 10 — all of which Go accepts. - * - * All verdicts below are verified against go1.26 (`strconv.ParseUint(s, 0, 64)`): - * `-0`/`-01`/`+1`/`3.5`/`abc`/`09`/`0x`/`_1`/`1_`/`1__0`/` 1` → invalid - * syntax; `0x_10` → 16; `18446744073709551616` → value out of range. - * - * Go iterates bytes where this iterates UTF-16 code units, but every non-ASCII - * unit (and every byte of a multibyte rune) falls outside the digit/letter - * ranges in both, so the verdict is identical. - * - * Known residual: values above 2^53 lose precision in the `Number` conversion - * (Go carries the exact uint64). They still PARSE identically; only the - * resulting parallel-job count differs, in territory where Go's own behavior - * (an `int` conversion of a near-2^64 uint) is already degenerate. - */ - -const MAX_UINT64 = (1n << 64n) - 1n; - -export type LegacyParseUintResult = - | { readonly value: number } - | { readonly cause: "invalid syntax" | "value out of range" }; - -export function legacyParseUintBase0(token: string): LegacyParseUintResult { - if (token.length === 0) return { cause: "invalid syntax" }; - - // Base detection for base 0 (`strconv/atoi.go`): `0x`/`0b`/`0o` prefixes - // (only when at least one more character follows), else a leading `0` means - // octal, else decimal. There is NO sign handling: `-`/`+` fall through to - // the digit loop below and fail as non-digits, exactly like Go. - let s = token; - let base = 10n; - if (s[0] === "0") { - const marker = s.length >= 3 ? s[1]?.toLowerCase() : undefined; - if (marker === "b") { - base = 2n; - s = s.slice(2); - } else if (marker === "o") { - base = 8n; - s = s.slice(2); - } else if (marker === "x") { - base = 16n; - s = s.slice(2); - } else { - base = 8n; - s = s.slice(1); - } - } - - let sawUnderscore = false; - let n = 0n; - for (let i = 0; i < s.length; i++) { - const code = s.charCodeAt(i); - let digit: bigint; - if (code === 0x5f /* _ */) { - // Only base 0 admits underscores; position rules are checked at the end. - sawUnderscore = true; - continue; - } else if (code >= 0x30 && code <= 0x39) { - digit = BigInt(code - 0x30); - } else { - const lower = code | 0x20; - if (lower >= 0x61 && lower <= 0x7a) digit = BigInt(lower - 0x61 + 10); - else return { cause: "invalid syntax" }; - } - if (digit >= base) return { cause: "invalid syntax" }; - n = n * base + digit; - if (n > MAX_UINT64) return { cause: "value out of range" }; - } - if (sawUnderscore && !underscoreOk(token)) return { cause: "invalid syntax" }; - return { value: Number(n) }; -} - -/** - * Go's `underscoreOK` (`strconv/atoi.go`): underscores must sit between - * digits, or between the base prefix and the first digit (`0x_10` is valid). - * The sign skip is unreachable through `legacyParseUintBase0` (a sign already - * fails the digit loop) but is kept for fidelity to the Go source. - */ -function underscoreOk(token: string): boolean { - // `saw` tracks the class of the previous character: `^` start-of-number, - // `0` digit-or-prefix, `_` underscore, `!` anything else. - let saw = "^"; - let s = token; - if (s.length >= 1 && (s[0] === "-" || s[0] === "+")) s = s.slice(1); - let i = 0; - let hex = false; - const marker = s[1]?.toLowerCase(); - if (s.length >= 2 && s[0] === "0" && (marker === "b" || marker === "o" || marker === "x")) { - i = 2; - saw = "0"; // the base prefix counts as a digit for separator purposes - hex = marker === "x"; - } - for (; i < s.length; i++) { - const c = s[i] as string; - if ((c >= "0" && c <= "9") || (hex && c.toLowerCase() >= "a" && c.toLowerCase() <= "f")) { - saw = "0"; - continue; - } - if (c === "_") { - if (saw !== "0") return false; - saw = "_"; - continue; - } - if (saw === "_") return false; - saw = "!"; - } - return saw !== "_"; -} diff --git a/apps/cli/src/legacy/shared/db-bootstrap/await-storage-ready.ts b/apps/cli/src/legacy/shared/db-bootstrap/await-storage-ready.ts new file mode 100644 index 0000000000..87d34a913a --- /dev/null +++ b/apps/cli/src/legacy/shared/db-bootstrap/await-storage-ready.ts @@ -0,0 +1,65 @@ +/** + * Port of Go's `AwaitStorageReady` (`apps/cli-go/internal/db/reset/reset.go:115-126`) — + * the storage-health gate local `db reset` runs before seeding buckets. Two things the + * seam this replaces got subtly wrong, corrected here: + * + * 1. `resp, err := utils.Docker.ContainerInspect(ctx, utils.StorageId); if err != nil { + * return false, nil }` — ANY inspect error (not just "not found") maps to "absent" + * (`false`), matching Go exactly (`errdefs.IsNotFound` is never checked on this + * particular path). + * 2. `if resp.State.Health == nil || resp.State.Health.Status != types.Healthy { if err + * := start.WaitForHealthyService(ctx, 30*time.Second, utils.StorageId); err != nil { + * return false, err } }` — a container that EXISTS but is unhealthy (or has no + * healthcheck at all) triggers a real 30-SECOND wait, hardcoded independent of + * `db.health_timeout`; if that wait times out, the failure propagates and FAILS THE + * WHOLE RESET (not just "skip buckets") — dumping the storage container's logs to + * stderr on the way out, via `legacyWaitForHealthyServices`'s own existing behavior. + * + * Hoisted to `legacy/shared/db-bootstrap/` (CLI-2062): originally lived in + * `commands/db/reset/` since `db reset`'s own handler was its only caller — the + * bucket-seeding health gate has no equivalent in `db start`/`supabase start` at + * all (CLI-1955 review follow-up). `legacyResetLocalDatabase` + * (`reset-local-database.ts`) is now a second caller (`db schema declarative`'s + * smart-target/sync recovery reset), so this moved alongside it. + */ + +import { Effect, Result } from "effect"; +import type * as HttpClient from "effect/unstable/http/HttpClient"; +import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; + +import { legacyInspectContainerState } from "../legacy-docker-lifecycle.ts"; +import { legacyServiceContainerName } from "../legacy-docker-ids.ts"; +import { + legacyWaitForHealthyServices, + type LegacyHealthCheckTimeoutError, +} from "./health-check.ts"; + +type Spawner = ChildProcessSpawner["Service"]; + +/** Go's hardcoded `30*time.Second` (`reset.go:121`) — independent of `db.health_timeout`. */ +const LEGACY_AWAIT_STORAGE_READY_TIMEOUT_SECONDS = 30; + +/** + * Resolves `true` when the storage container exists (so the caller should run the + * ported bucket-seeding core) and `false` when it does not (any inspect error) — + * matching Go, which silently skips buckets when storage is absent. Fails with + * {@link LegacyHealthCheckTimeoutError} when storage exists but never becomes healthy + * within 30 seconds — this is NOT swallowed into `false`, matching Go's own + * `return false, err` propagating the wait's error to the caller, which fails the + * entire reset. + */ +export function legacyAwaitStorageReady( + spawner: Spawner, + projectId: string, +): Effect.Effect { + const storageId = legacyServiceContainerName("storage", projectId); + return Effect.gen(function* () { + const inspected = yield* legacyInspectContainerState(spawner, storageId).pipe(Effect.result); + if (Result.isFailure(inspected)) return false; + if (inspected.success.health === "healthy") return true; + yield* legacyWaitForHealthyServices(spawner, [storageId], { + timeoutSeconds: LEGACY_AWAIT_STORAGE_READY_TIMEOUT_SECONDS, + }); + return true; + }); +} diff --git a/apps/cli/src/legacy/shared/db-bootstrap/await-storage-ready.unit.test.ts b/apps/cli/src/legacy/shared/db-bootstrap/await-storage-ready.unit.test.ts new file mode 100644 index 0000000000..1ae10f8553 --- /dev/null +++ b/apps/cli/src/legacy/shared/db-bootstrap/await-storage-ready.unit.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Cause, Deferred, Effect, Exit, Fiber, Layer, Sink, Stream } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as TestClock from "effect/testing/TestClock"; + +import { LegacyHealthCheckTimeoutError } from "./health-check.ts"; +import { legacyAwaitStorageReady } from "./await-storage-ready.ts"; + +const unusedHttpClientLayer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("HttpClient should not be called for a plain container check")), +); + +function mockSpawner( + handler: (args: ReadonlyArray) => { exitCode: number; stdout?: string; stderr?: string }, +) { + const spawned: Array> = []; + + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + const args = command._tag === "StandardCommand" ? command.args : []; + spawned.push(args); + const result = handler(args); + + const exitDeferred = yield* Deferred.make(); + yield* Deferred.succeed(exitDeferred, ChildProcessSpawner.ExitCode(result.exitCode)); + + const encoder = new TextEncoder(); + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + stdout: Stream.fromIterable( + result.stdout !== undefined ? [encoder.encode(result.stdout)] : [], + ), + stderr: Stream.fromIterable( + result.stderr !== undefined ? [encoder.encode(result.stderr)] : [], + ), + all: Stream.empty, + exitCode: Deferred.await(exitDeferred), + isRunning: Effect.succeed(false), + stdin: Sink.drain, + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + }), + ); + + return { + spawner, + get spawned() { + return spawned; + }, + }; +} + +const HEALTHY_STATE = '{"Running":true,"Status":"running","Health":{"Status":"healthy"}}'; +const STARTING_STATE = '{"Running":true,"Status":"running","Health":{"Status":"starting"}}'; + +describe("legacyAwaitStorageReady", () => { + it.live("resolves true immediately when storage already reports healthy", () => { + const mock = mockSpawner(() => ({ exitCode: 0, stdout: HEALTHY_STATE })); + return legacyAwaitStorageReady(mock.spawner, "proj").pipe( + Effect.provide(unusedHttpClientLayer), + Effect.map((ready) => { + expect(ready).toBe(true); + // No `docker logs`/extra polling round needed — just the one inspect. + expect(mock.spawned).toHaveLength(1); + }), + ); + }); + + it.live('resolves false on ANY inspect error — not just a confirmed "not found"', () => { + const mock = mockSpawner(() => ({ + exitCode: 1, + stderr: "Cannot connect to the Docker daemon\n", + })); + return legacyAwaitStorageReady(mock.spawner, "proj").pipe( + Effect.provide(unusedHttpClientLayer), + Effect.map((ready) => { + expect(ready).toBe(false); + }), + ); + }); + + it.live('resolves false when storage genuinely does not exist ("No such container")', () => { + const mock = mockSpawner(() => ({ + exitCode: 1, + stderr: "Error: No such container: supabase_storage_proj\n", + })); + return legacyAwaitStorageReady(mock.spawner, "proj").pipe( + Effect.provide(unusedHttpClientLayer), + Effect.map((ready) => { + expect(ready).toBe(false); + }), + ); + }); + + it.effect( + "waits up to the hardcoded 30s for an unhealthy-but-present container, then succeeds", + () => + Effect.gen(function* () { + let calls = 0; + const mock = mockSpawner((args) => { + if (args[0] === "container" && args[1] === "inspect") { + calls++; + return { exitCode: 0, stdout: calls === 1 ? STARTING_STATE : HEALTHY_STATE }; + } + return { exitCode: 0 }; + }); + + const fiber = yield* legacyAwaitStorageReady(mock.spawner, "proj").pipe( + Effect.provide(unusedHttpClientLayer), + Effect.forkChild({ startImmediately: true }), + ); + yield* TestClock.adjust("1 seconds"); + const exit = yield* Fiber.await(fiber); + + expect(Exit.isSuccess(exit)).toBe(true); + if (Exit.isSuccess(exit)) expect(exit.value).toBe(true); + }), + ); + + it.effect( + "FAILS THE WHOLE RESET (not just 'skip buckets') when storage never becomes healthy within 30s", + () => + Effect.gen(function* () { + const mock = mockSpawner((args) => { + if (args[0] === "container" && args[1] === "inspect") { + return { exitCode: 0, stdout: STARTING_STATE }; + } + return { exitCode: 0 }; + }); + + const fiber = yield* legacyAwaitStorageReady(mock.spawner, "proj").pipe( + Effect.provide(unusedHttpClientLayer), + Effect.forkChild({ startImmediately: true }), + ); + // Go's hardcoded 30-second wait (`start.WaitForHealthyService(ctx, 30*time.Second, + // utils.StorageId)`, reset.go:121) — 30 retries after the initial attempt. + for (let i = 0; i < 30; i++) { + yield* TestClock.adjust("1 seconds"); + } + const exit = yield* Fiber.await(fiber); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.squash(exit.cause)).toBeInstanceOf(LegacyHealthCheckTimeoutError); + } + }), + ); + + it.effect( + "is still retrying after 29 seconds, but fails once the 30th second is exhausted — pins Go's hardcoded 30s constant", + () => + Effect.gen(function* () { + const mock = mockSpawner((args) => { + if (args[0] === "container" && args[1] === "inspect") { + return { exitCode: 0, stdout: STARTING_STATE }; + } + return { exitCode: 0 }; + }); + + const fiber = yield* legacyAwaitStorageReady(mock.spawner, "proj").pipe( + Effect.provide(unusedHttpClientLayer), + Effect.forkChild({ startImmediately: true }), + ); + + for (let i = 0; i < 29; i++) { + yield* TestClock.adjust("1 seconds"); + } + // Not yet exhausted — 29 retries is one short of the hardcoded 30-second cap. If this + // constant were ever accidentally shortened (e.g. to 3s), the fiber would already be + // done here, failing this assertion instead of silently passing. + expect(fiber.pollUnsafe()).toBeUndefined(); + + // The 30th second crosses the boundary. + yield* TestClock.adjust("1 seconds"); + const exit = yield* Fiber.await(fiber); + expect(Exit.isFailure(exit)).toBe(true); + }), + ); +}); diff --git a/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.ts b/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.ts index d058f0fbae..2a9d8657b6 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.ts @@ -9,7 +9,7 @@ * spawns `docker start`. * * Network creation (`DockerNetworkCreateIfNotExists`) is deliberately NOT part - * of this per-container function — see {@link legacyEnsureStartNetwork}'s doc + * of this per-container function — see {@link legacyEnsureNetwork}'s doc * comment for why it is hoisted to run once instead of once per container. */ @@ -17,10 +17,15 @@ import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { Data, Effect, Stream } from "effect"; +import { Data, Effect } from "effect"; import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; -import { legacyDescribeContainerCliFailure, spawnContainerCli } from "../legacy-container-cli.ts"; +import { + collectText, + legacyDescribeContainerCliFailure, + runContainerCliExpectSuccess, + spawnContainerCli, +} from "../legacy-container-cli.ts"; import { legacyBindMountSpecSource, legacyIsBindMountSource, @@ -57,37 +62,31 @@ type Spawner = ChildProcessSpawner["Service"]; export const LEGACY_COMPOSE_PROJECT_LABEL = "com.docker.compose.project"; /** `docker network create --label ...`/`docker volume create --label ...` failed. */ -export class LegacyStartNetworkCreateError extends Data.TaggedError( - "LegacyStartNetworkCreateError", -)<{ +export class LegacyNetworkCreateError extends Data.TaggedError("LegacyNetworkCreateError")<{ readonly message: string; }> {} -export class LegacyStartVolumeCreateError extends Data.TaggedError("LegacyStartVolumeCreateError")<{ +export class LegacyVolumeCreateError extends Data.TaggedError("LegacyVolumeCreateError")<{ readonly message: string; }> {} /** `docker create` failed. */ -export class LegacyStartContainerCreateError extends Data.TaggedError( - "LegacyStartContainerCreateError", -)<{ +export class LegacyContainerCreateError extends Data.TaggedError("LegacyContainerCreateError")<{ readonly message: string; }> {} /** `docker start` failed — see {@link legacyPortConflictSuggestion} for the port-already-allocated case. */ -export class LegacyStartContainerStartError extends Data.TaggedError( - "LegacyStartContainerStartError", -)<{ +export class LegacyContainerStartError extends Data.TaggedError("LegacyContainerStartError")<{ readonly message: string; }> {} -/** Every failure {@link legacyStartContainer} itself can produce (network creation is separate, see {@link legacyEnsureStartNetwork}). */ -export type LegacyStartContainerError = - | LegacyStartVolumeCreateError - | LegacyStartContainerCreateError - | LegacyStartContainerStartError; +/** Every failure {@link legacyCreateContainer} itself can produce (network creation is separate, see {@link legacyEnsureNetwork}). */ +export type LegacyContainerError = + | LegacyVolumeCreateError + | LegacyContainerCreateError + | LegacyContainerStartError; -export interface LegacyStartContainerOpts { +export interface LegacyContainerOpts { /** * Go's `Config.ProjectId`, already sanitized (`legacySanitizeProjectId`) by * the caller's config-load pipeline — `DockerStart` itself performs no @@ -136,15 +135,6 @@ export interface LegacyStartContainerOpts { readonly extraHosts: ReadonlyArray; } -function collectText(stream: Stream.Stream) { - const decoder = new TextDecoder(); - return Stream.runFold( - stream, - () => "", - (text, chunk) => text + decoder.decode(chunk, { stream: true }), - ).pipe(Effect.map((text) => text + decoder.decode())); -} - /** * Extracts every named-volume source from `binds` (Go's `loader.ParseVolume` * classification loop, `docker.go:388-399`): a bind is `source:target[:mode]` @@ -221,7 +211,7 @@ function legacyPortConflictSuggestion(hostPort: string, serviceLabel: string): s * already exists), so this is a pure optimization, not a behavior change: a * `start` run's containers are exclusively created by this same code path in * one process, never interleaved with an external network deletion, so the - * network is guaranteed to still exist for every later `legacyStartContainer` + * network is guaranteed to still exist for every later `legacyCreateContainer` * call in the same run. * * Mirrors Go's own `isUserDefined(mode)` guard (`docker.go:65`, @@ -234,11 +224,11 @@ function legacyPortConflictSuggestion(hostPort: string, serviceLabel: string): s * `isUserDefinedDockerNetwork` check `shared/functions/deploy.ts` already * applies for the unrelated `functions deploy` extension-gateway network. */ -export function legacyEnsureStartNetwork( +export function legacyEnsureNetwork( spawner: Spawner, networkId: string, labels: Readonly>, -): Effect.Effect { +): Effect.Effect { if (!isUserDefinedDockerNetwork(networkId)) { return Effect.void; } @@ -257,7 +247,7 @@ export function legacyEnsureStartNetwork( }).pipe( Effect.mapError( (cause) => - new LegacyStartNetworkCreateError({ + new LegacyNetworkCreateError({ message: `failed to create docker network: ${legacyDescribeContainerCliFailure(cause)}`, }), ), @@ -267,13 +257,13 @@ export function legacyEnsureStartNetwork( { concurrency: "unbounded" }, ).pipe( Effect.mapError( - () => new LegacyStartNetworkCreateError({ message: "failed to create docker network" }), + () => new LegacyNetworkCreateError({ message: "failed to create docker network" }), ), ); if (exitCode !== 0 && !legacyIsNetworkAlreadyExistsError(stderr)) { const message = stderr.trim(); return yield* Effect.fail( - new LegacyStartNetworkCreateError({ + new LegacyNetworkCreateError({ message: message.length > 0 ? `failed to create docker network: ${message}` @@ -298,7 +288,7 @@ function legacyIsVolumeAlreadyExistsError(stderr: string): boolean { /** * Go's per-source-name `Docker.VolumeCreate` call (`docker.go:407-415`) via * `docker volume create --label ...`, treating "already exists" as success the - * same way {@link legacyEnsureStartNetwork} does; any other non-zero exit is a + * same way {@link legacyEnsureNetwork} does; any other non-zero exit is a * real failure. * * Go's Engine API is idempotent for a repeated name, including against Podman's @@ -306,11 +296,11 @@ function legacyIsVolumeAlreadyExistsError(stderr: string): boolean { * and rejects it, so every `stop`/`start` cycle aborted the bring-up on the * volumes `stop` preserves (supabase/cli#6020). */ -export function legacyEnsureStartVolume( +export function legacyEnsureVolume( spawner: Spawner, name: string, labels: Readonly>, -): Effect.Effect { +): Effect.Effect { return Effect.scoped( Effect.gen(function* () { const args = [ @@ -326,7 +316,7 @@ export function legacyEnsureStartVolume( }).pipe( Effect.mapError( (cause) => - new LegacyStartVolumeCreateError({ + new LegacyVolumeCreateError({ message: `failed to create volume: ${legacyDescribeContainerCliFailure(cause)}`, }), ), @@ -335,14 +325,12 @@ export function legacyEnsureStartVolume( [child.exitCode.pipe(Effect.map(Number)), collectText(child.stderr)], { concurrency: "unbounded" }, ).pipe( - Effect.mapError( - () => new LegacyStartVolumeCreateError({ message: "failed to create volume" }), - ), + Effect.mapError(() => new LegacyVolumeCreateError({ message: "failed to create volume" })), ); if (exitCode !== 0 && !legacyIsVolumeAlreadyExistsError(stderr)) { const message = stderr.trim(); return yield* Effect.fail( - new LegacyStartVolumeCreateError({ + new LegacyVolumeCreateError({ message: message.length > 0 ? `failed to create volume: ${message}` @@ -355,9 +343,7 @@ export function legacyEnsureStartVolume( } /** `docker volume inspect` failed to spawn at all (no docker/podman binary). */ -export class LegacyStartVolumeInspectError extends Data.TaggedError( - "LegacyStartVolumeInspectError", -)<{ +export class LegacyVolumeInspectError extends Data.TaggedError("LegacyVolumeInspectError")<{ readonly message: string; }> {} @@ -384,17 +370,17 @@ function isVolumeNotFoundMessage(message: string): boolean { * regression Go's own gate doesn't have. Only a spawn failure (neither * `docker` nor `podman` on `PATH`) is a real error here. * - * A separate, additional export — NOT called from {@link legacyEnsureStartVolume} + * A separate, additional export — NOT called from {@link legacyEnsureVolume} * itself, whose existing idempotent-create behavior must not change. The caller * orchestrating a `start` run checks this BEFORE creating the volume, to gate the * `SetupLocalDatabase`-equivalent pipeline and bucket seeding on "was this a * fresh volume", matching Go's exact check-before-create ordering * (`internal/db/start/start.go:165-184`). */ -export function legacyStartVolumeExists( +export function legacyVolumeExists( spawner: Spawner, name: string, -): Effect.Effect { +): Effect.Effect { return Effect.scoped( Effect.gen(function* () { const child = yield* spawnContainerCli(spawner, ["volume", "inspect", name], { @@ -404,7 +390,7 @@ export function legacyStartVolumeExists( }).pipe( Effect.mapError( (cause) => - new LegacyStartVolumeInspectError({ + new LegacyVolumeInspectError({ message: `failed to inspect volume: ${legacyDescribeContainerCliFailure(cause)}`, }), ), @@ -414,7 +400,7 @@ export function legacyStartVolumeExists( { concurrency: "unbounded" }, ).pipe( Effect.mapError( - () => new LegacyStartVolumeInspectError({ message: "failed to inspect volume" }), + () => new LegacyVolumeInspectError({ message: "failed to inspect volume" }), ), ); if (exitCode === 0) return true; @@ -423,11 +409,63 @@ export function legacyStartVolumeExists( ); } +/** `docker container rm -f ` (or `docker rm -f`) failed. */ +export class LegacyContainerRemoveError extends Data.TaggedError("LegacyContainerRemoveError")<{ + readonly message: string; +}> {} + +/** + * Port of Go's `db reset`-only `Docker.ContainerRemove(ctx, DbId, + * container.RemoveOptions{Force: true})` (`apps/cli-go/internal/db/reset/reset.go:147-149`) + * via `docker container rm -f `. Unlike most other container lookups in this codebase, + * Go does NOT tolerate a "not found" response here — a genuine remove failure is a hard + * `failed to remove container: %w` — so this propagates ANY non-zero exit without the + * usual "no such container" swallow. `-f` alone (no `-v`) matches Go's `RemoveOptions`, + * which sets `Force` but not `RemoveVolumes` — the paired named volume is removed + * separately by {@link legacyRemoveVolume}. + */ +export function legacyRemoveContainer( + spawner: Spawner, + containerId: string, +): Effect.Effect { + return runContainerCliExpectSuccess( + spawner, + ["container", "rm", "-f", containerId], + "remove container", + (message) => new LegacyContainerRemoveError({ message }), + ); +} + +/** `docker volume rm -f ` failed. */ +export class LegacyVolumeRemoveError extends Data.TaggedError("LegacyVolumeRemoveError")<{ + readonly message: string; +}> {} + +/** + * Port of Go's `db reset`-only `Docker.VolumeRemove(ctx, DbId, true)` + * (`apps/cli-go/internal/db/reset/reset.go:150-152`) via `docker volume rm -f `. + * The `force` argument makes a MISSING volume a no-op (Docker's `DELETE /volumes/{name}` + * returns 204 even when the volume doesn't exist, once `force` is set — verified against + * a real Docker daemon), so — unlike {@link legacyRemoveContainer} — no special-casing is + * needed here: any non-zero exit is a genuine failure. + */ +export function legacyRemoveVolume( + spawner: Spawner, + volumeName: string, +): Effect.Effect { + return runContainerCliExpectSuccess( + spawner, + ["volume", "rm", "-f", volumeName], + "remove volume", + (message) => new LegacyVolumeRemoveError({ message }), + ); +} + function legacyDockerCreateContainer( spawner: Spawner, args: ReadonlyArray, env: Readonly>, -): Effect.Effect { +): Effect.Effect { return Effect.scoped( Effect.gen(function* () { // `docker-create-args.ts` emits the key-only `-e KEY` form (never `-e KEY=value`) so @@ -456,7 +494,7 @@ function legacyDockerCreateContainer( }).pipe( Effect.mapError( (cause) => - new LegacyStartContainerCreateError({ + new LegacyContainerCreateError({ message: `failed to create docker container: ${legacyDescribeContainerCliFailure(cause)}`, }), ), @@ -470,14 +508,13 @@ function legacyDockerCreateContainer( { concurrency: "unbounded" }, ).pipe( Effect.mapError( - () => - new LegacyStartContainerCreateError({ message: "failed to create docker container" }), + () => new LegacyContainerCreateError({ message: "failed to create docker container" }), ), ); if (exitCode !== 0) { const message = stderr.trim(); return yield* Effect.fail( - new LegacyStartContainerCreateError({ + new LegacyContainerCreateError({ message: message.length > 0 ? `failed to create docker container: ${message}` @@ -494,7 +531,7 @@ function legacyDockerStartContainer( spawner: Spawner, containerId: string, spec: LegacyStartContainerSpec, -): Effect.Effect { +): Effect.Effect { return Effect.scoped( Effect.gen(function* () { const child = yield* spawnContainerCli(spawner, ["start", containerId], { @@ -504,7 +541,7 @@ function legacyDockerStartContainer( }).pipe( Effect.mapError( (cause) => - new LegacyStartContainerStartError({ + new LegacyContainerStartError({ message: `failed to start docker container "${spec.containerName}": ${legacyDescribeContainerCliFailure(cause)}`, }), ), @@ -515,7 +552,7 @@ function legacyDockerStartContainer( ).pipe( Effect.mapError( () => - new LegacyStartContainerStartError({ + new LegacyContainerStartError({ message: `failed to start docker container "${spec.containerName}"`, }), ), @@ -527,11 +564,11 @@ function legacyDockerStartContainer( }`; const hostPort = legacyParsePortBindError(trimmed); if (hostPort === undefined) { - return yield* Effect.fail(new LegacyStartContainerStartError({ message: base })); + return yield* Effect.fail(new LegacyContainerStartError({ message: base })); } const serviceLabel = spec.networkAliases?.[0] ?? spec.containerName; return yield* Effect.fail( - new LegacyStartContainerStartError({ + new LegacyContainerStartError({ message: `${base}${legacyPortConflictSuggestion(hostPort, serviceLabel)}`, }), ); @@ -555,7 +592,7 @@ function legacyDockerCopyIntoContainer( spawner: Spawner, hostPath: string, containerDest: string, -): Effect.Effect { +): Effect.Effect { return Effect.scoped( Effect.gen(function* () { const child = yield* spawnContainerCli(spawner, ["cp", hostPath, containerDest], { @@ -565,7 +602,7 @@ function legacyDockerCopyIntoContainer( }).pipe( Effect.mapError( (cause) => - new LegacyStartContainerCreateError({ + new LegacyContainerCreateError({ message: `failed to create docker container: failed to copy secret file into container: ${legacyDescribeContainerCliFailure(cause)}`, }), ), @@ -576,7 +613,7 @@ function legacyDockerCopyIntoContainer( ).pipe( Effect.mapError( () => - new LegacyStartContainerCreateError({ + new LegacyContainerCreateError({ message: "failed to create docker container: failed to copy secret file into container", }), @@ -585,7 +622,7 @@ function legacyDockerCopyIntoContainer( if (exitCode !== 0) { const message = stderr.trim(); return yield* Effect.fail( - new LegacyStartContainerCreateError({ + new LegacyContainerCreateError({ message: message.length > 0 ? `failed to create docker container: failed to copy secret file into container: ${message}` @@ -629,11 +666,11 @@ function legacyCopyStartSecretFileIntoContainer( spawner: Spawner, containerId: string, secretFile: LegacyStartSecretFileSpec, -): Effect.Effect { +): Effect.Effect { return Effect.tryPromise({ try: () => mkdtemp(join(tmpdir(), "supabase-start-secret-")), catch: (cause) => - new LegacyStartContainerCreateError({ + new LegacyContainerCreateError({ message: `failed to create docker container: failed to stage container secret file: ${ cause instanceof Error ? cause.message : String(cause) }`, @@ -649,7 +686,7 @@ function legacyCopyStartSecretFileIntoContainer( await chmod(hostPath, 0o644); }, catch: (cause) => - new LegacyStartContainerCreateError({ + new LegacyContainerCreateError({ message: `failed to create docker container: failed to stage container secret file: ${ cause instanceof Error ? cause.message : String(cause) }`, @@ -670,7 +707,7 @@ function legacyCopyStartSecretFileIntoContainer( /** * Delivers every {@link LegacyStartContainerSpec.secretFiles} entry into `containerId` — the - * container `legacyStartContainer` just created via `docker create`, but has NOT yet started — + * container `legacyCreateContainer` just created via `docker create`, but has NOT yet started — * via `docker cp` ({@link legacyCopyStartSecretFileIntoContainer}, one call per entry, run * concurrently). No Go struct equivalent — see `docker-create-args.ts`'s `secretFiles` doc * comment for why this port needs it at all, and this function's own doc comment for why `docker @@ -680,7 +717,7 @@ function legacyCopyStartSecretFilesIntoContainer( spawner: Spawner, containerId: string, secretFiles: ReadonlyArray, -): Effect.Effect { +): Effect.Effect { return Effect.forEach( secretFiles, (secretFile) => legacyCopyStartSecretFileIntoContainer(spawner, containerId, secretFile), @@ -691,7 +728,7 @@ function legacyCopyStartSecretFilesIntoContainer( /** * Port of Go's `DockerStart` (`apps/cli-go/internal/utils/docker.go:363-440`), * minus image resolution (already done by `image-prepull.ts`) and network - * creation (hoisted, see {@link legacyEnsureStartNetwork}): + * creation (hoisted, see {@link legacyEnsureNetwork}): * * 1. Merge the two project-identity labels onto `spec.labels`. * 2. Provision this container's own named volumes (skipped entirely under @@ -710,11 +747,11 @@ function legacyCopyStartSecretFilesIntoContainer( * * Resolves to the created container's id/name on success. */ -export function legacyStartContainer( +export function legacyCreateContainer( spawner: Spawner, spec: LegacyStartContainerSpec, - opts: LegacyStartContainerOpts, -): Effect.Effect { + opts: LegacyContainerOpts, +): Effect.Effect { return Effect.gen(function* () { const labels: Record = { ...spec.labels, @@ -722,7 +759,7 @@ export function legacyStartContainer( [LEGACY_COMPOSE_PROJECT_LABEL]: opts.projectId, }; // The workdir label is stamped on the CONTAINER only, not on its named volumes below - // (`legacyEnsureStartVolume` is passed `labels`, not `containerLabels`) — a volume's own + // (`legacyEnsureVolume` is passed `labels`, not `containerLabels`) — a volume's own // name already carries the project id, and nothing ever reads a workdir label back off a // volume the way `legacyListContainerIdsAndNames` does for containers. const containerLabels: Record = { @@ -737,7 +774,7 @@ export function legacyStartContainer( if (!opts.isBitbucketPipeline) { for (const name of legacyNamedVolumeSources(labeledSpec.binds)) { - yield* legacyEnsureStartVolume(spawner, name, labels); + yield* legacyEnsureVolume(spawner, name, labels); } } diff --git a/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.unit.test.ts b/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.unit.test.ts index a17cb30cbb..9a510dc374 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.unit.test.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.unit.test.ts @@ -8,15 +8,19 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { afterEach, beforeEach } from "vitest"; import { - LegacyStartContainerCreateError, - LegacyStartContainerStartError, - LegacyStartNetworkCreateError, - LegacyStartVolumeCreateError, - LegacyStartVolumeInspectError, - legacyEnsureStartNetwork, - legacyEnsureStartVolume, - legacyStartContainer, - legacyStartVolumeExists, + LegacyContainerCreateError, + LegacyContainerRemoveError, + LegacyContainerStartError, + LegacyNetworkCreateError, + LegacyVolumeCreateError, + LegacyVolumeInspectError, + LegacyVolumeRemoveError, + legacyEnsureNetwork, + legacyEnsureVolume, + legacyRemoveContainer, + legacyRemoveVolume, + legacyCreateContainer, + legacyVolumeExists, } from "./container-lifecycle.ts"; import type { LegacyStartContainerSpec } from "./docker-create-args.ts"; @@ -107,12 +111,12 @@ function alwaysSucceed(stdout = "container-id-123\n") { }); } -describe("legacyStartContainer", () => { +describe("legacyCreateContainer", () => { it.live( "merges project + compose labels, provisions named volumes, then creates and starts", () => { const mock = alwaysSucceed(); - return legacyStartContainer(mock.spawner, baseSpec, { + return legacyCreateContainer(mock.spawner, baseSpec, { projectId: "proj", isBitbucketPipeline: false, workdir, @@ -165,7 +169,7 @@ describe("legacyStartContainer", () => { // `toEqual`, so a regression that leaked the workdir label onto volumes too would fail that // test's exact-match assertion. const mock = alwaysSucceed(); - return legacyStartContainer(mock.spawner, baseSpec, { + return legacyCreateContainer(mock.spawner, baseSpec, { projectId: "proj", isBitbucketPipeline: false, workdir, @@ -192,7 +196,7 @@ describe("legacyStartContainer", () => { ...baseSpec, env: { POSTGRES_PASSWORD: "s3cret", JWT_SECRET: "super-secret-value" }, }; - return legacyStartContainer(mock.spawner, spec, { + return legacyCreateContainer(mock.spawner, spec, { projectId: "proj", isBitbucketPipeline: false, workdir, @@ -224,7 +228,7 @@ describe("legacyStartContainer", () => { ...baseSpec, env: { DOCKER_HOST: "http://host.docker.internal:2375", API_KEY: "s3cret" }, }; - return legacyStartContainer(mock.spawner, spec, { + return legacyCreateContainer(mock.spawner, spec, { projectId: "proj", isBitbucketPipeline: false, workdir, @@ -243,7 +247,7 @@ describe("legacyStartContainer", () => { "skips volume creation and drops the named-volume bind + security-opt under Bitbucket Pipelines", () => { const mock = alwaysSucceed(); - return legacyStartContainer(mock.spawner, baseSpec, { + return legacyCreateContainer(mock.spawner, baseSpec, { projectId: "proj", isBitbucketPipeline: true, workdir, @@ -261,12 +265,12 @@ describe("legacyStartContainer", () => { }, ); - it.live("fails with LegacyStartVolumeCreateError before ever creating the container", () => { + it.live("fails with LegacyVolumeCreateError before ever creating the container", () => { const mock = mockSpawner((args) => { if (args[0] === "volume") return { exitCode: 1, stderr: "no space left on device\n" }; return { exitCode: 0, stdout: "should-not-be-created\n" }; }); - return legacyStartContainer(mock.spawner, baseSpec, { + return legacyCreateContainer(mock.spawner, baseSpec, { projectId: "proj", isBitbucketPipeline: false, workdir, @@ -274,20 +278,20 @@ describe("legacyStartContainer", () => { }).pipe( Effect.flip, Effect.map((error) => { - expect(error).toBeInstanceOf(LegacyStartVolumeCreateError); + expect(error).toBeInstanceOf(LegacyVolumeCreateError); expect(error.message).toBe("failed to create volume: no space left on device"); expect(mock.spawned.some((args) => args[0] === "create")).toBe(false); }), ); }); - it.live("fails with LegacyStartContainerCreateError on a `docker create` non-zero exit", () => { + it.live("fails with LegacyContainerCreateError on a `docker create` non-zero exit", () => { const mock = mockSpawner((args) => { if (args[0] === "create") return { exitCode: 1, stderr: "no such image\n" }; return { exitCode: 0 }; }); const spec: LegacyStartContainerSpec = { ...baseSpec, binds: [] }; - return legacyStartContainer(mock.spawner, spec, { + return legacyCreateContainer(mock.spawner, spec, { projectId: "proj", isBitbucketPipeline: false, workdir, @@ -295,21 +299,21 @@ describe("legacyStartContainer", () => { }).pipe( Effect.flip, Effect.map((error) => { - expect(error).toBeInstanceOf(LegacyStartContainerCreateError); + expect(error).toBeInstanceOf(LegacyContainerCreateError); expect(error.message).toBe("failed to create docker container: no such image"); expect(mock.spawned.some((args) => args[0] === "start")).toBe(false); }), ); }); - it.live("fails with LegacyStartContainerStartError, unmodified, on a plain start failure", () => { + it.live("fails with LegacyContainerStartError, unmodified, on a plain start failure", () => { const mock = mockSpawner((args) => { if (args[0] === "create") return { exitCode: 0, stdout: "abc\n" }; if (args[0] === "start") return { exitCode: 1, stderr: "container is already stopped\n" }; return { exitCode: 0 }; }); const spec: LegacyStartContainerSpec = { ...baseSpec, binds: [] }; - return legacyStartContainer(mock.spawner, spec, { + return legacyCreateContainer(mock.spawner, spec, { projectId: "proj", isBitbucketPipeline: false, workdir, @@ -317,7 +321,7 @@ describe("legacyStartContainer", () => { }).pipe( Effect.flip, Effect.map((error) => { - expect(error).toBeInstanceOf(LegacyStartContainerStartError); + expect(error).toBeInstanceOf(LegacyContainerStartError); expect(error.message).toBe( 'failed to start docker container "supabase_db_proj": container is already stopped', ); @@ -340,7 +344,7 @@ describe("legacyStartContainer", () => { return { exitCode: 0 }; }); const spec: LegacyStartContainerSpec = { ...baseSpec, binds: [] }; - return legacyStartContainer(mock.spawner, spec, { + return legacyCreateContainer(mock.spawner, spec, { projectId: "proj", isBitbucketPipeline: false, workdir, @@ -348,7 +352,7 @@ describe("legacyStartContainer", () => { }).pipe( Effect.flip, Effect.map((error) => { - expect(error).toBeInstanceOf(LegacyStartContainerStartError); + expect(error).toBeInstanceOf(LegacyContainerStartError); expect(error.message).toContain('failed to start docker container "supabase_db_proj"'); expect(error.message).toContain("0.0.0.0:5432"); expect(error.message).toContain("db port in supabase/config.toml"); @@ -358,7 +362,7 @@ describe("legacyStartContainer", () => { ); }); -describe("legacyStartContainer secretFiles", () => { +describe("legacyCreateContainer secretFiles", () => { it.live( "docker cp's a secretFile into the created (not yet started) container, strictly between `docker create` and `docker start`, keeping its content out of every spawned process's own argv, then removes the local temp file", () => { @@ -379,7 +383,7 @@ describe("legacyStartContainer secretFiles", () => { secretFiles: [{ containerPath: "/etc/kong/kong.yml", content: "super-secret-content" }], }; - return legacyStartContainer(mock.spawner, spec, { + return legacyCreateContainer(mock.spawner, spec, { projectId: "proj", isBitbucketPipeline: false, workdir, @@ -433,7 +437,7 @@ describe("legacyStartContainer secretFiles", () => { // after this effect actually completes, on success, failure, or defect alike. return Effect.sync(() => process.umask(0o077)).pipe( Effect.flatMap((originalUmask) => - legacyStartContainer(mock.spawner, spec, { + legacyCreateContainer(mock.spawner, spec, { projectId: "proj", isBitbucketPipeline: false, workdir, @@ -472,7 +476,7 @@ describe("legacyStartContainer secretFiles", () => { secretFiles: [{ containerPath: "/etc/kong/kong.yml", content: "super-secret-content" }], }; - return legacyStartContainer(mock.spawner, spec, { + return legacyCreateContainer(mock.spawner, spec, { projectId: "proj", isBitbucketPipeline: false, workdir, @@ -480,7 +484,7 @@ describe("legacyStartContainer secretFiles", () => { }).pipe( Effect.flip, Effect.map((error) => { - expect(error).toBeInstanceOf(LegacyStartContainerStartError); + expect(error).toBeInstanceOf(LegacyContainerStartError); expect(hostPath).toBeDefined(); // Already removed right after its own successful `docker cp` — long before `docker // start` even ran, let alone failed. @@ -491,7 +495,7 @@ describe("legacyStartContainer secretFiles", () => { ); it.live( - "fails with LegacyStartContainerCreateError when `docker cp` exits non-zero, removes the local temp file, and never invokes `docker start`", + "fails with LegacyContainerCreateError when `docker cp` exits non-zero, removes the local temp file, and never invokes `docker start`", () => { let hostPath: string | undefined; const mock = mockSpawner((args) => { @@ -509,7 +513,7 @@ describe("legacyStartContainer secretFiles", () => { secretFiles: [{ containerPath: "/etc/kong/kong.yml", content: "super-secret-content" }], }; - return legacyStartContainer(mock.spawner, spec, { + return legacyCreateContainer(mock.spawner, spec, { projectId: "proj", isBitbucketPipeline: false, workdir, @@ -517,7 +521,7 @@ describe("legacyStartContainer secretFiles", () => { }).pipe( Effect.flip, Effect.map((error) => { - expect(error).toBeInstanceOf(LegacyStartContainerCreateError); + expect(error).toBeInstanceOf(LegacyContainerCreateError); expect(error.message).toBe( "failed to create docker container: failed to copy secret file into container: Error: No such container: container-id-def", ); @@ -543,7 +547,7 @@ describe("legacyStartContainer secretFiles", () => { secretFiles: [{ containerPath: "/etc/kong/kong.yml", content: "super-secret-content" }], }; - return legacyStartContainer(mock.spawner, spec, { + return legacyCreateContainer(mock.spawner, spec, { projectId: "proj", isBitbucketPipeline: false, workdir, @@ -551,7 +555,7 @@ describe("legacyStartContainer secretFiles", () => { }).pipe( Effect.flip, Effect.map((error) => { - expect(error).toBeInstanceOf(LegacyStartContainerCreateError); + expect(error).toBeInstanceOf(LegacyContainerCreateError); expect(mock.spawned.some((args) => args[0] === "cp")).toBe(false); expect(mock.spawned.some((args) => args[0] === "start")).toBe(false); }), @@ -629,7 +633,7 @@ describe("legacyStartContainer secretFiles", () => { }; return Effect.gen(function* () { - const fiber = yield* legacyStartContainer(spawner, spec, { + const fiber = yield* legacyCreateContainer(spawner, spec, { projectId: "proj", isBitbucketPipeline: false, workdir, @@ -645,7 +649,7 @@ describe("legacyStartContainer secretFiles", () => { ); it.live( - "maps a local temp-file creation failure to LegacyStartContainerCreateError, without ever invoking `docker cp` or `docker start`", + "maps a local temp-file creation failure to LegacyContainerCreateError, without ever invoking `docker cp` or `docker start`", () => { const previousTmpdir = process.env["TMPDIR"]; // Points `os.tmpdir()` at a path whose PARENT doesn't exist, forcing `fs.mkdtemp` to fail @@ -663,7 +667,7 @@ describe("legacyStartContainer secretFiles", () => { secretFiles: [{ containerPath: "/etc/kong/kong.yml", content: "super-secret-content" }], }; - return legacyStartContainer(mock.spawner, spec, { + return legacyCreateContainer(mock.spawner, spec, { projectId: "proj", isBitbucketPipeline: false, workdir, @@ -671,7 +675,7 @@ describe("legacyStartContainer secretFiles", () => { }).pipe( Effect.flip, Effect.map((error) => { - expect(error).toBeInstanceOf(LegacyStartContainerCreateError); + expect(error).toBeInstanceOf(LegacyContainerCreateError); expect(error.message).toMatch( /^failed to create docker container: failed to stage container secret file: /, ); @@ -689,10 +693,10 @@ describe("legacyStartContainer secretFiles", () => { ); }); -describe("legacyEnsureStartNetwork", () => { +describe("legacyEnsureNetwork", () => { it.live("creates the network with labels", () => { const mock = mockSpawner(() => ({ exitCode: 0 })); - return legacyEnsureStartNetwork(mock.spawner, "supabase_network_proj", { + return legacyEnsureNetwork(mock.spawner, "supabase_network_proj", { "com.supabase.cli.project": "proj", "com.docker.compose.project": "proj", }).pipe( @@ -718,19 +722,19 @@ describe("legacyEnsureStartNetwork", () => { stderr: "Error response from daemon: network with name supabase_network_proj already exists\n", })); - return legacyEnsureStartNetwork(mock.spawner, "supabase_network_proj", {}).pipe( + return legacyEnsureNetwork(mock.spawner, "supabase_network_proj", {}).pipe( Effect.map(() => { // Just needs to not fail — no return value to assert on. }), ); }); - it.live("fails with LegacyStartNetworkCreateError on any other failure", () => { + it.live("fails with LegacyNetworkCreateError on any other failure", () => { const mock = mockSpawner(() => ({ exitCode: 1, stderr: "permission denied\n" })); - return legacyEnsureStartNetwork(mock.spawner, "supabase_network_proj", {}).pipe( + return legacyEnsureNetwork(mock.spawner, "supabase_network_proj", {}).pipe( Effect.flip, Effect.map((error) => { - expect(error).toBeInstanceOf(LegacyStartNetworkCreateError); + expect(error).toBeInstanceOf(LegacyNetworkCreateError); expect(error.message).toBe("failed to create docker network: permission denied"); }), ); @@ -743,7 +747,7 @@ describe("legacyEnsureStartNetwork", () => { exitCode: 1, stderr: "operation is not permitted on predefined host network", })); - return legacyEnsureStartNetwork(mock.spawner, networkId, {}).pipe( + return legacyEnsureNetwork(mock.spawner, networkId, {}).pipe( Effect.map(() => { expect(mock.spawned).toEqual([]); }), @@ -752,10 +756,10 @@ describe("legacyEnsureStartNetwork", () => { ); }); -describe("legacyEnsureStartVolume", () => { +describe("legacyEnsureVolume", () => { it.live("creates the named volume with labels", () => { const mock = mockSpawner(() => ({ exitCode: 0 })); - return legacyEnsureStartVolume(mock.spawner, "supabase_db_proj", { + return legacyEnsureVolume(mock.spawner, "supabase_db_proj", { "com.supabase.cli.project": "proj", }).pipe( Effect.map(() => { @@ -771,7 +775,7 @@ describe("legacyEnsureStartVolume", () => { exitCode: 125, stderr: "Error: volume with name supabase_db_proj already exists: volume already exists\n", })); - return legacyEnsureStartVolume(mock.spawner, "supabase_db_proj", {}).pipe( + return legacyEnsureVolume(mock.spawner, "supabase_db_proj", {}).pipe( Effect.map(() => { // Just needs to not fail — no return value to assert on. }), @@ -783,19 +787,19 @@ describe("legacyEnsureStartVolume", () => { exitCode: 125, stderr: "volume with name supabase_db_proj already exists\n", })); - return legacyEnsureStartVolume(mock.spawner, "supabase_db_proj", {}).pipe( + return legacyEnsureVolume(mock.spawner, "supabase_db_proj", {}).pipe( Effect.map(() => { // Just needs to not fail — no return value to assert on. }), ); }); - it.live("fails with LegacyStartVolumeCreateError on any other failure", () => { + it.live("fails with LegacyVolumeCreateError on any other failure", () => { const mock = mockSpawner(() => ({ exitCode: 1, stderr: "permission denied\n" })); - return legacyEnsureStartVolume(mock.spawner, "supabase_db_proj", {}).pipe( + return legacyEnsureVolume(mock.spawner, "supabase_db_proj", {}).pipe( Effect.flip, Effect.map((error) => { - expect(error).toBeInstanceOf(LegacyStartVolumeCreateError); + expect(error).toBeInstanceOf(LegacyVolumeCreateError); expect(error.message).toBe("failed to create volume: permission denied"); }), ); @@ -807,10 +811,10 @@ describe("legacyEnsureStartVolume", () => { stderr: "a volume named supabase_db_proj already exists but was not created for the current specification\n", })); - return legacyEnsureStartVolume(mock.spawner, "supabase_db_proj", {}).pipe( + return legacyEnsureVolume(mock.spawner, "supabase_db_proj", {}).pipe( Effect.flip, Effect.map((error) => { - expect(error).toBeInstanceOf(LegacyStartVolumeCreateError); + expect(error).toBeInstanceOf(LegacyVolumeCreateError); expect(error.message).toBe( "failed to create volume: a volume named supabase_db_proj already exists but was not created for the current specification", ); @@ -819,10 +823,10 @@ describe("legacyEnsureStartVolume", () => { }); }); -describe("legacyStartVolumeExists", () => { +describe("legacyVolumeExists", () => { it.live("resolves true when `docker volume inspect` exits 0", () => { const mock = mockSpawner(() => ({ exitCode: 0, stdout: "[]\n" })); - return legacyStartVolumeExists(mock.spawner, "supabase_db_proj").pipe( + return legacyVolumeExists(mock.spawner, "supabase_db_proj").pipe( Effect.map((exists) => { expect(exists).toBe(true); expect(mock.spawned).toEqual([["volume", "inspect", "supabase_db_proj"]]); @@ -835,7 +839,7 @@ describe("legacyStartVolumeExists", () => { exitCode: 1, stderr: "Error: No such volume: supabase_db_proj\n", })); - return legacyStartVolumeExists(mock.spawner, "supabase_db_proj").pipe( + return legacyVolumeExists(mock.spawner, "supabase_db_proj").pipe( Effect.map((exists) => { expect(exists).toBe(false); }), @@ -846,7 +850,7 @@ describe("legacyStartVolumeExists", () => { "resolves true (protected, not fresh) on an ambiguous inspect failure, matching Go's IsNotFound gate", () => { const mock = mockSpawner(() => ({ exitCode: 1, stderr: "permission denied\n" })); - return legacyStartVolumeExists(mock.spawner, "supabase_db_proj").pipe( + return legacyVolumeExists(mock.spawner, "supabase_db_proj").pipe( Effect.map((exists) => { expect(exists).toBe(true); }), @@ -854,7 +858,7 @@ describe("legacyStartVolumeExists", () => { }, ); - it.live("fails with LegacyStartVolumeInspectError when no runtime can be spawned", () => { + it.live("fails with LegacyVolumeInspectError when no runtime can be spawned", () => { const spawner = ChildProcessSpawner.make(() => Effect.fail( PlatformError.systemError({ @@ -865,10 +869,80 @@ describe("legacyStartVolumeExists", () => { }), ), ); - return legacyStartVolumeExists(spawner, "supabase_db_proj").pipe( + return legacyVolumeExists(spawner, "supabase_db_proj").pipe( Effect.flip, Effect.map((error) => { - expect(error).toBeInstanceOf(LegacyStartVolumeInspectError); + expect(error).toBeInstanceOf(LegacyVolumeInspectError); + }), + ); + }); +}); + +describe("legacyRemoveContainer", () => { + it.live("spawns `docker container rm -f ` and succeeds on exit 0", () => { + const mock = mockSpawner(() => ({ exitCode: 0 })); + return legacyRemoveContainer(mock.spawner, "supabase_db_proj").pipe( + Effect.map(() => { + expect(mock.spawned).toEqual([["container", "rm", "-f", "supabase_db_proj"]]); + }), + ); + }); + + it.live( + 'fails with LegacyContainerRemoveError on ANY non-zero exit — not tolerant of "not found"', + () => { + const mock = mockSpawner(() => ({ + exitCode: 1, + stderr: "Error: No such container: supabase_db_proj\n", + })); + return legacyRemoveContainer(mock.spawner, "supabase_db_proj").pipe( + Effect.flip, + Effect.map((error) => { + expect(error).toBeInstanceOf(LegacyContainerRemoveError); + expect(error.message).toContain("failed to remove container"); + expect(error.message).toContain("No such container"); + }), + ); + }, + ); + + it.live("fails with LegacyContainerRemoveError when no runtime can be spawned", () => { + const spawner = ChildProcessSpawner.make(() => + Effect.fail( + PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: "spawn ENOENT", + }), + ), + ); + return legacyRemoveContainer(spawner, "supabase_db_proj").pipe( + Effect.flip, + Effect.map((error) => { + expect(error).toBeInstanceOf(LegacyContainerRemoveError); + }), + ); + }); +}); + +describe("legacyRemoveVolume", () => { + it.live("spawns `docker volume rm -f ` and succeeds on exit 0", () => { + const mock = mockSpawner(() => ({ exitCode: 0 })); + return legacyRemoveVolume(mock.spawner, "supabase_db_proj").pipe( + Effect.map(() => { + expect(mock.spawned).toEqual([["volume", "rm", "-f", "supabase_db_proj"]]); + }), + ); + }); + + it.live("fails with LegacyVolumeRemoveError on a genuine non-zero exit", () => { + const mock = mockSpawner(() => ({ exitCode: 1, stderr: "permission denied\n" })); + return legacyRemoveVolume(mock.spawner, "supabase_db_proj").pipe( + Effect.flip, + Effect.map((error) => { + expect(error).toBeInstanceOf(LegacyVolumeRemoveError); + expect(error.message).toContain("failed to remove volume"); }), ); }); diff --git a/apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts b/apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts index d1c9bbb033..33d2ccd90a 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts @@ -3,7 +3,7 @@ * `SetupLocalDatabase` (`apps/cli-go/internal/db/start/start.go:359-381`), run once * the `db` container's healthcheck passes on a FRESH volume (Go's `NoBackupVolume` * gate, `start.go:184` — the caller decides whether to invoke this at all; see - * `legacyStartVolumeExists` in `./container-lifecycle.ts`). The single exported + * `legacyVolumeExists` in `./container-lifecycle.ts`). The single exported * entry point, {@link legacyStartSetupLocalDatabase}, runs the exact Go call chain * in order: * @@ -47,36 +47,46 @@ * rather than a caught not-found error — see the call site's own comment for why); * any other read/exec error propagates. * 5. **`apply.MigrateAndSeed`** (`start.go:368`, via the already-ported - * `legacyMigrateAndSeed`) with `version: ""` — every pending migration, matching - * `SetupLocalDatabase`'s own call in the `start` context. + * `legacyMigrateAndSeed`) with the caller-supplied {@link + * LegacyStartSetupLocalDatabaseInput.version} — `""` (every pending migration) for + * `db start`'s own call, matching `SetupLocalDatabase`'s call in the `start` + * context; `db reset`'s PG15 recreate (the function's OTHER real Go caller, + * `resetDatabase15`, `reset.go:169`) passes its own resolved reset version instead. + * {@link LegacyStartSetupLocalDatabaseInput.seedFlags} applies `db reset`'s + * `--no-seed`/`--sql-paths` overrides on top of the loaded `[db.seed]` config first + * (a no-op for `db start`, which has neither flag) — see + * {@link legacyResolveResetSeedConfig}. * 6. **`pgcache.TryCacheMigrationsCatalog`** (`start.go:371-379`) — a best-effort * warmup of the `catalog-local-migrations-*` snapshot subsequent pg-delta * workflows (`db diff`/`db push`) consume, via the already-ported * `legacyTryCacheMigrationsCatalog` ({@link legacy-pgdelta.cache.ts}, the exact * same function `db push` already calls after its own migration apply). Gated * identically to Go's `ShouldCacheMigrationsCatalog()` (`pgcache/cache.go:93-95`): - * `toml.pgDelta.enabled` OR the `SUPABASE_EXPERIMENTAL_PG_DELTA` env override — - * Go's other half of the gate, `len(version) == 0`, is unconditionally true here - * since step 5 above always runs with `version: ""`. A failure prints Go's exact - * warning (`Warning: failed to cache migrations catalog: `, `start.go:378`) - * to stderr and is otherwise swallowed, reusing the identical best-effort - * catch/warn shape `legacy-db-push-core.ts` already established for its own call - * — this step never fails {@link legacyStartSetupLocalDatabase} or the caller's - * `start`/`db start` run. Requires `LegacyEdgeRuntimeScript`/`LegacyPgDeltaSslProbe` - * in this function's own effect environment (widened accordingly below), so both - * `start.command.ts` and `db/start/start.layers.ts` now compose - * `legacyEdgeRuntimeScriptLayer`/`legacyPgDeltaSslProbeLayer`, matching `db push`'s - * own layer composition (`push.layers.ts`). The underlying `legacyExportCatalogPgDelta` - * reads `PGDELTA_NPM_REGISTRY` straight off bare `process.env` ({@link - * legacy-pgdelta.ts}'s `legacyPgDeltaNpmRegistryOption`) — Go's `Config.Load` already - * `os.Setenv`'d the project `.env` into the process before `start`/`db start` ever - * reaches this call (`loadNestedEnv`, `config.go:788`), so a registry override set - * only in `supabase/.env` (not the shell) must be visible here too. This module never - * mutates `process.env` globally the way `start`/`db start`'s own config resolution - * does — every other Go env override is threaded explicitly via `projectEnvValues` — - * so this ONE call is scoped with `legacyApplyProjectEnv` (the same opt-in helper - * `db push`/`db pull`/`db dump`/`bootstrap` already use around their own pg-delta/image - * work) for just its own duration, then reverted. + * `input.version.length === 0` AND (`toml.pgDelta.enabled` OR + * `SUPABASE_EXPERIMENTAL_PG_DELTA`) — reached by BOTH real Go callers of this + * shared function, `db start` (always `version: ""`) and `db reset`'s PG15 + * recreate (its own resolved reset version, usually also `""`). A failure prints + * Go's exact warning (`Warning: failed to cache migrations catalog: `, + * `start.go:378`) to stderr and is otherwise swallowed, reusing the identical + * best-effort catch/warn shape `legacy-db-push-core.ts` already established for + * its own call — this step never fails {@link legacyStartSetupLocalDatabase} or + * the caller's `start`/`db start`/`db reset` run. Requires + * `LegacyEdgeRuntimeScript`/`LegacyPgDeltaSslProbe` in this function's own effect + * environment (widened accordingly below), so `start.command.ts`, + * `db/start/start.layers.ts`, AND `db/reset/reset.layers.ts` all compose + * `legacyEdgeRuntimeScriptLayer`/`legacyPgDeltaSslProbeLayer`, matching `db + * push`'s own layer composition (`push.layers.ts`). The underlying + * `legacyExportCatalogPgDelta` reads `PGDELTA_NPM_REGISTRY` straight off bare + * `process.env` ({@link legacy-pgdelta.ts}'s `legacyPgDeltaNpmRegistryOption`) — + * Go's `Config.Load` already `os.Setenv`'d the project `.env` into the process + * before `start`/`db start`/`db reset` ever reaches this call (`loadNestedEnv`, + * `config.go:788`), so a registry override set only in `supabase/.env` (not the + * shell) must be visible here too. This module never mutates `process.env` + * globally the way `start`/`db start`'s own config resolution does — every other + * Go env override is threaded explicitly via `projectEnvValues` — so this ONE + * call is scoped with `legacyApplyProjectEnv` (the same opt-in helper `db + * push`/`db pull`/`db dump`/`bootstrap` already use around their own pg-delta/ + * image work) for just its own duration, then reverted. * * Go's `initCurrentBranch` (`start.go:233-241`, writes `supabase/.branches/ * _current_branch` = `"main"` if absent) is NOT part of this pipeline, even though @@ -84,39 +94,49 @@ * called by `StartDatabase` (the caller of `SetupLocalDatabase`) UNCONDITIONALLY, * regardless of `NoBackupVolume` (`start.go:184-189`) — unlike everything above, * which only runs on a fresh volume. `start.handler.ts` calls it directly, outside - * the `isFreshVolume` gate that wraps {@link legacyStartSetupLocalDatabase}. + * the `isFreshVolume` gate that wraps {@link legacyStartSetupLocalDatabase}; `db + * reset` never calls it at all (Go's own `resetDatabase`/`resetDatabase15` never + * call `initCurrentBranch` either). * * This module also duplicates ONE config-load pass: `legacyCheckDbToml` is called * internally (not threaded in from the caller) to resolve `[db.vault]`, `[db.seed]`, * `db.migrations.enabled`, and the effective `api.auto_expose_new_tables` tri-state — * the same accepted duplication `db start`'s own handler (`commands/db/start/ * start.handler.ts`) already takes independently of the top-level `supabase start` - * command's own config resolution. + * command's own config resolution; `db reset`'s own caller duplicates it again for the + * same reason. */ import type { ProjectConfig } from "@supabase/config"; import { Clock, Data, Effect, type FileSystem, Option, type Path } from "effect"; import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; +import type { LocalServiceVersionOverrides } from "../../../shared/services/services.shared.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts"; -import { legacyTryCacheMigrationsCatalog } from "../legacy-pgdelta.cache.ts"; -import type { LegacyPgDeltaContext } from "../legacy-pgdelta.ts"; -import { legacyParseBoolEnv } from "../legacy-diff-engine.ts"; -import type { LegacyDbSession } from "../legacy-db-connection.service.ts"; +import { LegacyDbConnection, type LegacyDbSession } from "../legacy-db-connection.service.ts"; +import type { LegacyDbConnectError } from "../legacy-db-connection.errors.ts"; import { LegacyDbConfigLoadError } from "../legacy-db-config.errors.ts"; import { redactLegacyConnectionString } from "../legacy-db-config.parse.ts"; -import { legacyApplyProjectEnv, legacyCheckDbToml } from "../legacy-db-config.toml-read.ts"; +import { + legacyApplyProjectEnv, + legacyCheckDbToml, + legacyResolveSeedSqlPath, +} from "../legacy-db-config.toml-read.ts"; +import { legacyParseBoolEnv } from "../legacy-diff-engine.ts"; import { LEGACY_CLI_PROJECT_LABEL, legacyServiceContainerName } from "../legacy-docker-ids.ts"; import { LegacyDockerRun, type LegacyDockerRunOpts } from "../legacy-docker-run.service.ts"; import { LegacyEdgeRuntimeScript } from "../legacy-edge-runtime-script.service.ts"; -import { legacyEnsureImagesCached, type LegacyImagePrepullError } from "./image-prepull.ts"; import { legacyMigrateAndSeed } from "../legacy-migrate-and-seed.ts"; import { LegacyMigrationApplyError, legacyExecSqlFile } from "../legacy-migration-apply.ts"; -import type { LegacyMigrationSeedError } from "../legacy-seed.ts"; +import { legacyTryCacheMigrationsCatalog } from "../legacy-pgdelta.cache.ts"; +import type { LegacyPgDeltaContext } from "../legacy-pgdelta.ts"; import { LegacyPgDeltaSslProbe } from "../legacy-pgdelta-ssl-probe.service.ts"; +import type { LegacyMigrationSeedError, LegacySeedConfig } from "../legacy-seed.ts"; import { ramInBytes } from "../legacy-size-units.ts"; import { LegacyMigrationVaultError, legacyUpsertVaultSecrets } from "../legacy-vault.ts"; +import { legacyEnsureImagesCached, type LegacyImagePrepullError } from "./image-prepull.ts"; +import { legacyResolvePinnedImage } from "./pinned-image.ts"; import { LEGACY_COMPOSE_PROJECT_LABEL } from "./container-lifecycle.ts"; import { LEGACY_REALTIME_TENANT_ID, legacyBuildRealtimeEnv } from "./realtime-env.ts"; import { LEGACY_START_DB_GLOBALS_SQL } from "./templates/db-globals.sql.ts"; @@ -151,21 +171,21 @@ alter default privileges for role postgres in schema public * utils/docker.go:469-487,559-591` — Go discards the container's own stdout/stderr * outside `--debug`, so only the exit code is meaningful here too). */ -export class LegacyStartDbSetupError extends Data.TaggedError("LegacyStartDbSetupError")<{ +export class LegacyDbSetupError extends Data.TaggedError("LegacyDbSetupError")<{ readonly message: string; }> {} /** Every failure {@link legacyStartSetupLocalDatabase} can produce. */ export type LegacyStartSetupLocalDatabaseError = | LegacyDbConfigLoadError - | LegacyStartDbSetupError + | LegacyDbSetupError | LegacyMigrationVaultError | LegacyMigrationApplyError | LegacyMigrationSeedError | LegacyImagePrepullError; /** Already-resolved Docker images for the three PG15+ one-shot migrate jobs (`initSchema15`'s `initJobs`). */ -export interface LegacyStartDbSetupImages { +interface LegacyStartDbSetupImages { /** `utils.Config.Realtime.Image`, resolved by the caller (not part of the decoded `ProjectConfig` schema — `toml:"-"`). */ readonly realtime: string; /** `utils.Config.Storage.Image`, ditto. */ @@ -174,6 +194,33 @@ export interface LegacyStartDbSetupImages { readonly auth: string; } +/** + * Computes the three PG15+ one-shot setup jobs' PINNED image names (`initSchema15`'s + * `initRealtimeJob`/`initStorageJob`/`initAuthJob`) for {@link legacyRunFreshDbSetup} — the + * ONE place both real Go callers (`db start`'s fresh-volume branch and `db reset`'s PG15 + * recreate) reach this from. Mirrors Go's `initSchema15`, which uses the SAME + * already-pin-rewritten `utils.Config.{Realtime,Storage,Auth}.Image` fields the + * long-running containers would use, regardless of `--exclude` — resolved via + * `legacyResolvePinnedImage`, not the raw Dockerfile default, so a linked project's + * version pins apply here too. Deliberately does NOT resolve these against the registry + * (`legacyEnsureImagesCached`) as a batch: Go resolves (and pulls) each one-shot job's + * own image individually, sequentially, right before THAT job runs (`DockerRunJob` -> + * `DockerStart` -> `DockerResolveImageIfNotCached`, `start.go:334-355`, + * `docker.go:363-365`) — {@link legacyRunStartMigrateJob} does that lazily itself, right + * before running each job (see its own doc comment): a batch resolve here would let one + * unreachable image fail the WHOLE setup before an earlier job Go would already have run + * to completion ever gets to run. + */ +function legacyResolveDbSetupImages( + serviceVersionOverrides: LocalServiceVersionOverrides, +): LegacyStartDbSetupImages { + return { + realtime: legacyResolvePinnedImage("realtime", "realtime", serviceVersionOverrides), + storage: legacyResolvePinnedImage("storage", "storage", serviceVersionOverrides), + auth: legacyResolvePinnedImage("gotrue", "auth", serviceVersionOverrides), + }; +} + /** Input to {@link legacyStartSetupLocalDatabase}. */ export interface LegacyStartSetupLocalDatabaseInput { /** @@ -261,6 +308,46 @@ export interface LegacyStartSetupLocalDatabaseInput { * `utils.GetDebugLogger()` as the job's stderr writer (`start.go:349-353`). */ readonly debug: boolean; + /** + * The migration version to reapply (Go's `apply.MigrateAndSeed(ctx, version, ...)`). + * `db start`'s own caller always passes `""` (Go's `SetupLocalDatabase(ctx, "", ...)`, + * `start.go:185` — every pending migration). `db reset`'s PG15 recreate + * (`resetDatabase15`, `reset.go:169`) passes its own RESOLVED reset version instead — + * the one genuine difference between the two Go callers of this shared function. + */ + readonly version: string; + /** + * `db reset`'s `--no-seed`/`--sql-paths` overrides (Go's `applyDbResetSeedFlags`, + * `cmd/db.go:567-583`, mutating the global `utils.Config.Db.Seed` BEFORE `reset.Run` + * — read by this same `MigrateAndSeed` call on the PG15 recreate path). `db start` + * has neither flag, so its caller passes `{ noSeed: false, sqlPaths: [] }`, which + * {@link legacyResolveResetSeedConfig} reduces to the loaded `[db.seed]` config + * unchanged. + */ + readonly seedFlags: { readonly noSeed: boolean; readonly sqlPaths: ReadonlyArray }; +} + +/** + * Applies `db reset`'s `--no-seed`/`--sql-paths` overrides to an already-resolved + * `[db.seed]` config, mirroring Go's `applyDbResetSeedFlags` (`cmd/db.go:567-583`): + * `--no-seed` disables seeding outright; otherwise a non-empty `--sql-paths` + * force-enables seeding and overrides `sqlPaths` (each pattern resolved against + * `supabase/` the same way Go's own `resolveSeedSqlPaths` does); an empty + * `--sql-paths` is a no-op. The two flags are mutually exclusive (validated by the + * caller — `db/reset/reset.handler.ts`'s `validateDbResetSeedFlags` port — before + * this ever runs), matching Go's own `if noSeed { ...; return } ...` early return. + */ +export function legacyResolveResetSeedConfig( + seed: LegacySeedConfig, + override: { readonly noSeed: boolean; readonly sqlPaths: ReadonlyArray }, + path: Path.Path, +): LegacySeedConfig { + if (override.noSeed) return { ...seed, enabled: false }; + if (override.sqlPaths.length === 0) return seed; + return { + enabled: true, + sqlPaths: override.sqlPaths.map((pattern) => legacyResolveSeedSqlPath(path, pattern)), + }; } const errMessage = (e: unknown): string => @@ -287,7 +374,7 @@ const legacyExecSqlConstant = Effect.fnUntraced(function* ( yield* fs.writeFileString(filePath, sql).pipe( Effect.mapError( (error) => - new LegacyStartDbSetupError({ + new LegacyDbSetupError({ message: `failed to write ${filename}: ${errMessage(error)}`, }), ), @@ -297,15 +384,42 @@ const legacyExecSqlConstant = Effect.fnUntraced(function* ( fs, path, filePath, - (message) => new LegacyStartDbSetupError({ message }), + (message) => new LegacyDbSetupError({ message }), ); }); /** - * Port of Go's `InitSchema14` (`start.go:256-266`): execs - * {@link LEGACY_START_DB_GLOBALS_SQL} then the major-version-appropriate initial - * schema. Only reached for `majorVersion <= 14` (the caller, `legacyStartInitSchema`, - * gates on that). + * Port of Go's EXPORTED `InitSchema14` (`start.go:256-266`) — execs ONLY the + * major-version-appropriate initial-schema SQL, deliberately WITHOUT + * {@link LEGACY_START_DB_GLOBALS_SQL}. Go's own `initSchema` wrapper (the PG<=14 + * branch below, `legacyStartInitSchemaPre15`) execs globals.sql itself, immediately + * before calling `InitSchema14` — but `db reset`'s PG14 path (`reset.go:176-186` + * `initDatabase`) calls `start.InitSchema14` DIRECTLY, skipping globals.sql + * entirely. Exported so `legacy/shared/db-bootstrap/recreate-local-database.ts` + * can reproduce that exact (if surprising) Go asymmetry instead of reusing + * {@link legacyStartInitSchemaPre15}, which would run globals.sql an extra time Go + * never does on the reset path. + */ +export const legacyInitSchema14 = Effect.fnUntraced(function* ( + session: LegacyDbSession, + fs: FileSystem.FileSystem, + path: Path.Path, + tmpDir: string, + majorVersion: number, +) { + const schemaSql = + majorVersion === 13 + ? LEGACY_START_DB_INITIAL_SCHEMA_13_SQL + : LEGACY_START_DB_INITIAL_SCHEMA_14_SQL; + yield* legacyExecSqlConstant(session, fs, path, tmpDir, "initial-schema.sql", schemaSql); +}); + +/** + * Port of Go's `initSchema`'s PG<=14 branch (`start.go:245-251`): execs + * {@link LEGACY_START_DB_GLOBALS_SQL} then {@link legacyInitSchema14}. Only + * reached for `majorVersion <= 14` (the caller, `legacyStartInitSchema`, gates on + * that) — used by `db start`'s fresh-volume setup ONLY; `db reset`'s PG14 path + * calls {@link legacyInitSchema14} directly instead (see its own doc comment). */ const legacyStartInitSchemaPre15 = Effect.fnUntraced(function* ( session: LegacyDbSession, @@ -322,11 +436,7 @@ const legacyStartInitSchemaPre15 = Effect.fnUntraced(function* ( "globals.sql", LEGACY_START_DB_GLOBALS_SQL, ); - const schemaSql = - majorVersion === 13 - ? LEGACY_START_DB_INITIAL_SCHEMA_13_SQL - : LEGACY_START_DB_INITIAL_SCHEMA_14_SQL; - yield* legacyExecSqlConstant(session, fs, path, tmpDir, "initial-schema.sql", schemaSql); + yield* legacyInitSchema14(session, fs, path, tmpDir, majorVersion); }); /** @@ -409,10 +519,10 @@ const legacyRunStartMigrateJob = Effect.fnUntraced(function* ( // (review: Codex, PR #6022). const result = yield* docker .runStream(runOpts, { onStdout: () => Effect.void, teeStderr: opts.debug }) - .pipe(Effect.mapError((cause) => new LegacyStartDbSetupError({ message: cause.message }))); + .pipe(Effect.mapError((cause) => new LegacyDbSetupError({ message: cause.message }))); if (result.exitCode !== 0) { return yield* Effect.fail( - new LegacyStartDbSetupError({ message: `error running container: exit ${result.exitCode}` }), + new LegacyDbSetupError({ message: `error running container: exit ${result.exitCode}` }), ); } }); @@ -522,7 +632,7 @@ const legacyStartInitSchema15 = Effect.fnUntraced(function* ( // Go fails this same malformed value at TOML-decode time, before any // Docker work (`sizeInBytes.UnmarshalText`, `pkg/config/config.go:41-47`) // — this can't be replicated literally here since Postgres is already up - // by this step, but surfacing it as a typed `LegacyStartDbSetupError` so + // by this step, but surfacing it as a typed `LegacyDbSetupError` so // rollback actually runs is the achievable equivalent, matching the same // fix already applied to `resolveDbHealthTimeoutSeconds` and the // long-running Storage container's own file-size-limit parsing @@ -539,7 +649,7 @@ const legacyStartInitSchema15 = Effect.fnUntraced(function* ( fileSizeLimit: input.config.storage.file_size_limit, }), catch: (cause) => - new LegacyStartDbSetupError({ + new LegacyDbSetupError({ message: `invalid config for storage: ${errMessage(cause)}`, }), }); @@ -604,18 +714,26 @@ const legacyStartInitSchema = Effect.fnUntraced(function* ( * `api.auto_expose_new_tables` — `true` keeps the bundled initial-schema grants * (no-op); unset/`false` execs {@link LEGACY_START_REVOKE_API_PRIVILEGES_SQL}. Runs * regardless of PG major version (unlike `initSchema`, this always execs SQL over - * `session` directly — it is never part of the PG15+ one-shot Docker jobs). + * `session` directly — it is never part of the PG15+ one-shot Docker jobs). Exported + * (and taking `session`/`fs`/`path` directly, not the whole + * {@link LegacyStartSetupLocalDatabaseInput}) because Go's `ApplyApiPrivileges` is + * the SAME exported function `db reset`'s PG14 `initDatabase` calls + * (`reset.go:176-186`), after its own `InitSchema14` call and with none of + * `SetupDatabase`'s other steps (vault/roles.sql/MigrateAndSeed) — see + * `legacy/shared/db-bootstrap/recreate-local-database.ts`. */ -const legacyStartApplyApiPrivileges = Effect.fnUntraced(function* ( - input: LegacyStartSetupLocalDatabaseInput, +export const legacyApplyApiPrivileges = Effect.fnUntraced(function* ( + session: LegacyDbSession, + fs: FileSystem.FileSystem, + path: Path.Path, tmpDir: string, autoExposeNewTables: Option.Option, ) { if (Option.isSome(autoExposeNewTables) && autoExposeNewTables.value) return; yield* legacyExecSqlConstant( - input.session, - input.fs, - input.path, + session, + fs, + path, tmpDir, "revoke-api-privileges.sql", LEGACY_START_REVOKE_API_PRIVILEGES_SQL, @@ -642,7 +760,7 @@ export const legacyStartInitCurrentBranch = Effect.fnUntraced(function* ( const exists = yield* fs.exists(currentBranchPath).pipe( Effect.mapError( (error) => - new LegacyStartDbSetupError({ + new LegacyDbSetupError({ message: `failed init current branch: ${errMessage(error)}`, }), ), @@ -651,7 +769,7 @@ export const legacyStartInitCurrentBranch = Effect.fnUntraced(function* ( yield* fs.makeDirectory(path.dirname(currentBranchPath), { recursive: true }).pipe( Effect.mapError( (error) => - new LegacyStartDbSetupError({ + new LegacyDbSetupError({ message: `failed init current branch: ${errMessage(error)}`, }), ), @@ -665,7 +783,7 @@ export const legacyStartInitCurrentBranch = Effect.fnUntraced(function* ( yield* fs.writeFileString(currentBranchPath, "main", { mode: 0o644 }).pipe( Effect.mapError( (error) => - new LegacyStartDbSetupError({ + new LegacyDbSetupError({ message: `failed init current branch: ${errMessage(error)}`, }), ), @@ -723,13 +841,19 @@ export const legacyStartSetupLocalDatabase = ( .pipe( Effect.mapError( (error) => - new LegacyStartDbSetupError({ + new LegacyDbSetupError({ message: `failed to create temp directory: ${errMessage(error)}`, }), ), ); yield* legacyStartInitSchema(spawner, input, tmpDir); - yield* legacyStartApplyApiPrivileges(input, tmpDir, toml.baseline.apiAutoExposeNewTables); + yield* legacyApplyApiPrivileges( + session, + fs, + path, + tmpDir, + toml.baseline.apiAutoExposeNewTables, + ); }), ); @@ -753,7 +877,7 @@ export const legacyStartSetupLocalDatabase = ( const rolesExist = yield* fs.exists(customRolesPath).pipe( Effect.mapError( (error) => - new LegacyStartDbSetupError({ + new LegacyDbSetupError({ message: `failed to check roles.sql: ${errMessage(error)}`, }), ), @@ -764,21 +888,26 @@ export const legacyStartSetupLocalDatabase = ( fs, path, customRolesPath, - (message) => new LegacyStartDbSetupError({ message }), + (message) => new LegacyDbSetupError({ message }), ); } - // apply.MigrateAndSeed(ctx, "", conn, fsys) — empty version = every pending - // migration, matching `SetupLocalDatabase`'s own call in the `start` context - // (start.go:368). `experimental`/`pgDeltaEnabled`/`schemaPaths` gate - // `legacyMigrateAndSeed`'s own declarative-schema-files branch (apply.go:19) — see its - // doc comment; `toml.pgDelta.enabled` and `toml.schemaPaths` are this module's own - // already-loaded config (the latter already resolved + `SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS` - // env-overridden by `legacyCheckDbToml`, `legacy-db-config.toml-read.ts`), not re-read from - // the caller's raw, unresolved `ProjectConfig`. - yield* legacyMigrateAndSeed(session, fs, path, workdir, "", { + // apply.MigrateAndSeed(ctx, version, conn, fsys) — `db start`'s own caller always + // passes `version: ""` (every pending migration, matching `SetupLocalDatabase`'s + // own call in the `start` context, `start.go:185,368`); `db reset`'s PG15 recreate + // passes its own resolved reset version instead (`resetDatabase15`, `reset.go:169`) + // — see `input.version`'s own doc comment. `experimental`/`pgDeltaEnabled`/ + // `schemaPaths` gate `legacyMigrateAndSeed`'s own declarative-schema-files branch + // (apply.go:19) — see its doc comment; `toml.pgDelta.enabled` and `toml.schemaPaths` + // are this module's own already-loaded config (the latter already resolved + + // `SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS` env-overridden by `legacyCheckDbToml`, + // `legacy-db-config.toml-read.ts`), not re-read from the caller's raw, unresolved + // `ProjectConfig`. `input.seedFlags` applies `db reset`'s own `--no-seed`/ + // `--sql-paths` overrides on top of the loaded `[db.seed]` config — a no-op for + // `db start`, which has neither flag. + yield* legacyMigrateAndSeed(session, fs, path, workdir, input.version, { migrationsEnabled: toml.migrationsEnabled, - seed: toml.seed, + seed: legacyResolveResetSeedConfig(toml.seed, input.seedFlags, path), experimental: input.experimental, pgDeltaEnabled: toml.pgDelta.enabled, schemaPaths: toml.schemaPaths, @@ -787,20 +916,23 @@ export const legacyStartSetupLocalDatabase = ( // pgcache.TryCacheMigrationsCatalog(ctx, pgconn.Config{Host: Config.Hostname, // Port: Config.Db.Port, User: "postgres", Password: Config.Db.Password, Database: // "postgres"}, "local", version, fsys, ...) (start.go:371-379): best-effort, run - // immediately after MigrateAndSeed above, on every call — this function's - // `version` is always `""` (the line above), matching the `len(version) == 0` - // half of Go's `ShouldCacheMigrationsCatalog()` gate unconditionally. `cacheEnabled` - // reproduces the OTHER half of that gate (`pgcache/cache.go:93-95`) exactly — the - // same `toml.pgDelta.enabled || SUPABASE_EXPERIMENTAL_PG_DELTA` formula - // `legacy-db-push-core.ts` already uses for its own call. `input.dbUrl` is already - // the HOST-facing `postgresql://postgres:@:/postgres` - // address (see its own doc comment) — the exact same shape Go's `utils. - // ToPostgresURL(config)` builds from that literal `pgconn.Config` here, so it's - // reused directly as `targetUrl` rather than re-derived. `conn`'s fields are only - // ever read by `legacyCatalogPrefixFromConfig` on a non-local prefix fallback, - // unreachable here since `isLocal` is always `true`. + // immediately after MigrateAndSeed above, for BOTH real Go callers of this shared + // function — `db start` (always `version: ""`) and `db reset`'s PG15 recreate + // (its own resolved reset `input.version`, usually also `""`). `cacheEnabled` + // reproduces Go's `ShouldCacheMigrationsCatalog()` gate exactly + // (`pgcache/cache.go:93-95`): `len(version) == 0` AND (`toml.pgDelta.enabled` OR + // `SUPABASE_EXPERIMENTAL_PG_DELTA`) — the same formula `legacy-db-push-core.ts` + // already uses for its own call. `input.dbUrl` is already the HOST-facing + // `postgresql://postgres:@:/postgres` address (see its + // own doc comment) — the exact same shape Go's `utils.ToPostgresURL(config)` builds + // from that literal `pgconn.Config` here, so it's reused directly as `targetUrl` + // rather than re-derived. `conn`'s fields are only ever read by + // `legacyCatalogPrefixFromConfig` on a non-local prefix fallback, unreachable here + // since `isLocal` is always `true`. const cacheEnabled = - toml.pgDelta.enabled || legacyParseBoolEnv(toml.envLookup("SUPABASE_EXPERIMENTAL_PG_DELTA")); + input.version.length === 0 && + (toml.pgDelta.enabled || + legacyParseBoolEnv(toml.envLookup("SUPABASE_EXPERIMENTAL_PG_DELTA"))); const pgDeltaCtx: LegacyPgDeltaContext = { projectId: input.projectId, cwd: workdir, @@ -834,7 +966,7 @@ export const legacyStartSetupLocalDatabase = ( }).pipe( // Best-effort: Go's own `TryCacheMigrationsCatalog` failure only ever warns // (`fmt.Fprintln(os.Stderr, "Warning: failed to cache migrations catalog:", err)`, - // start.go:378) and never fails `SetupLocalDatabase` — same shape + // start.go:378) and never fails `legacyStartSetupLocalDatabase` — same shape // `legacy-db-push-core.ts` already established for this exact call. Effect.catch((error) => output.raw( @@ -849,3 +981,138 @@ export const legacyStartSetupLocalDatabase = ( // `initCurrentBranch` (start.go:233-241) is NOT called here — see this // module's header for why it moved to the caller instead. }); + +/** + * The `setup` shape shared by BOTH real Go callers of {@link + * legacyStartSetupLocalDatabase} — `db start`'s own fresh-volume branch + * (`start-database.ts`'s `legacyStartDatabase`) and `db reset`'s PG15 recreate + * composition (`recreate-local-database.ts`'s `legacyRecreateLocalDatabase15`) — + * everything {@link legacyStartSetupLocalDatabase} needs, minus what {@link + * legacyRunFreshDbSetup} itself already resolves/threads through (`session`, + * `images`). The two callers used to each declare an identical copy of this + * interface; hoisted here alongside {@link legacyRunFreshDbSetup} itself + * (CLI-1955 review follow-up). + */ +export interface LegacyFreshDbSetupInput { + readonly majorVersion: number; + /** Already spliced with the caller's own realtime/storage/auth enabled-for-setup + ip_version/max_header_length/file_size_limit overrides — see `bootstrap-config.ts`'s `LegacyDbBootstrapConfig`. */ + readonly config: LegacyStartSetupLocalDatabaseInput["config"]; + /** Threaded straight through to {@link LegacyStartSetupLocalDatabaseInput.experimental} — see its own doc comment. */ + readonly experimental: boolean; + readonly dbUrl: string; + readonly jwtSecret: string; + /** Lazy — evaluated only when reached AND `realtimeEnabledForSetup`. See `start-database.ts`'s header for why this is caller-supplied rather than resolved here unconditionally. */ + readonly jwks: Effect.Effect; + readonly apiUrl: string; + readonly authExternalUrl: string | undefined; + readonly siteUrl: string; + readonly anonKey: string; + readonly serviceRoleKey: string; + readonly storageTargetMigration: string; + readonly realtimeEnabledForSetup: boolean; + readonly storageEnabledForSetup: boolean; + readonly authEnabledForSetup: boolean; + readonly serviceVersionOverrides: LocalServiceVersionOverrides; + readonly projectEnvValues: Readonly> | undefined; + /** + * `--debug` — threaded straight through to {@link + * LegacyStartSetupLocalDatabaseInput.debug}; see its own doc comment. + */ + readonly debug: boolean; +} + +/** + * Runs {@link legacyStartSetupLocalDatabase} against a freshly-provisioned local + * Postgres — the exact sequence BOTH real Go callers run once Postgres's own + * healthcheck passes on a fresh database (`db start`'s fresh-volume branch and + * `db reset`'s PG15 recreate, see {@link LegacyFreshDbSetupInput}'s own doc + * comment): dial the host-facing session (Go's `ConnectLocalPostgres`), resolve + * JWKS lazily (only when `majorVersion >= 15` AND `realtimeEnabledForSetup` — Go's + * `initSchema`, `start.go:243-254`, only ever reaches `initSchema15`'s + * `ResolveJWKS` call on PG15+; the PG13/14 branch, `InitSchema14`, never touches + * JWKS at all), compute the three PG15+ one-shot job images' PINNED names via + * {@link legacyResolveDbSetupImages}, then run {@link legacyStartSetupLocalDatabase} + * itself. `version`/`seedFlags` are the one genuine difference between the two + * callers (`db start` always passes `""`/`{noSeed:false, sqlPaths:[]}`; `db + * reset` passes its own resolved reset version/flags) — threaded straight + * through by the caller, matching each one's own `LegacyStartSetupLocalDatabaseInput` + * field of the same name. + */ +export const legacyRunFreshDbSetup = ( + spawner: Spawner, + input: { + readonly fs: FileSystem.FileSystem; + readonly path: Path.Path; + readonly workdir: string; + readonly projectId: string; + readonly networkId: string; + readonly hostname: string; + readonly dbPort: number; + readonly version: string; + readonly seedFlags: { readonly noSeed: boolean; readonly sqlPaths: ReadonlyArray }; + readonly setup: LegacyFreshDbSetupInput; + }, +): Effect.Effect< + void, + LegacyStartSetupLocalDatabaseError | LegacyDbConnectError | LegacyImagePrepullError | E, + | Output + | LegacyDbConnection + | LegacyDockerRun + | RuntimeInfo + | LegacyEdgeRuntimeScript + | LegacyPgDeltaSslProbe + | FileSystem.FileSystem + | Path.Path +> => + Effect.scoped( + Effect.gen(function* () { + const dbConnection = yield* LegacyDbConnection; + const { setup } = input; + const dbPassword = legacyStartInternalDbPassword(setup.dbUrl); + const session = yield* dbConnection.connect( + { + host: input.hostname, + port: input.dbPort, + user: "postgres", + password: dbPassword, + database: "postgres", + }, + { isLocal: true, dnsResolver: "native" }, + ); + + // Go's `initSchema` (`start.go:243-254`) branches to `initSchema15` — the ONLY place + // `ResolveJWKS` is ever called — solely on `majorVersion >= 15`; the PG13/14 branch + // (`InitSchema14`) never touches JWKS, so a PG13/14 database with realtime enabled + // must not pay for (or fail on) an external JWKS fetch it will never use. + const jwks = + setup.majorVersion >= 15 && setup.realtimeEnabledForSetup ? yield* setup.jwks : ""; + + const dbSetupImages = legacyResolveDbSetupImages(setup.serviceVersionOverrides); + + yield* legacyStartSetupLocalDatabase(spawner, { + session, + fs: input.fs, + path: input.path, + workdir: input.workdir, + config: setup.config, + experimental: setup.experimental, + majorVersion: setup.majorVersion, + projectId: input.projectId, + networkId: input.networkId, + dbUrl: setup.dbUrl, + jwtSecret: setup.jwtSecret, + jwks, + apiUrl: setup.apiUrl, + authExternalUrl: setup.authExternalUrl, + siteUrl: setup.siteUrl, + anonKey: setup.anonKey, + serviceRoleKey: setup.serviceRoleKey, + storageTargetMigration: setup.storageTargetMigration, + images: dbSetupImages, + projectEnvValues: setup.projectEnvValues, + debug: setup.debug, + version: input.version, + seedFlags: input.seedFlags, + }); + }), + ); diff --git a/apps/cli/src/legacy/shared/db-bootstrap/db-setup.unit.test.ts b/apps/cli/src/legacy/shared/db-bootstrap/db-setup.unit.test.ts index 65cec45291..f7d6fdc8c0 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/db-setup.unit.test.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/db-setup.unit.test.ts @@ -19,7 +19,7 @@ import { } from "../legacy-edge-runtime-script.service.ts"; import { LegacyPgDeltaSslProbe } from "../legacy-pgdelta-ssl-probe.service.ts"; import { - LegacyStartDbSetupError, + LegacyDbSetupError, legacyStartInitCurrentBranch, legacyStartSetupLocalDatabase, type LegacyStartSetupLocalDatabaseInput, @@ -200,6 +200,8 @@ function baseInput( }, projectEnvValues: undefined, debug: false, + version: "", + seedFlags: { noSeed: false, sqlPaths: [] }, ...overrides, }; } @@ -523,10 +525,8 @@ describe("legacyStartSetupLocalDatabase", () => { return run(baseInput(workdir, session, { majorVersion: 15, config }), out, docker).pipe( Effect.flip, Effect.map((error) => { - expect(error).toBeInstanceOf(LegacyStartDbSetupError); - expect((error as LegacyStartDbSetupError).message).toBe( - "error running container: exit 1", - ); + expect(error).toBeInstanceOf(LegacyDbSetupError); + expect((error as LegacyDbSetupError).message).toBe("error running container: exit 1"); rmSync(workdir, { recursive: true, force: true }); }), ); diff --git a/apps/cli/src/legacy/shared/db-bootstrap/docker-create-args.ts b/apps/cli/src/legacy/shared/db-bootstrap/docker-create-args.ts index f51d03431a..a0219f9995 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/docker-create-args.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/docker-create-args.ts @@ -47,7 +47,7 @@ * It exists purely because this module's own "shell out to `docker create`" * architecture (unlike Go's direct Engine API calls) has an argv-exposure * problem `container.Config`/`container.HostConfig` never had — see that - * field's doc comment, and `container-lifecycle.ts`'s `legacyStartContainer`, + * field's doc comment, and `container-lifecycle.ts`'s `legacyCreateContainer`, * for the mitigation. */ @@ -153,7 +153,7 @@ export interface LegacyStartContainerSpec { * * NOT consumed here: {@link legacyBuildStartContainerCreateArgs} stays * pure/no-I/O and never reads this field. `container-lifecycle.ts`'s - * `legacyStartContainer` is the sole consumer — once `docker create` returns + * `legacyCreateContainer` is the sole consumer — once `docker create` returns * a container id, it writes each entry's `content` to a SHORT-LIVED * HOST-side temp file (mode `0644` — world-readable, so the non-root * in-container user reading it (e.g. Kong, Postgres) doesn't hit `EACCES`; diff --git a/apps/cli/src/legacy/shared/db-bootstrap/local-container-inputs.ts b/apps/cli/src/legacy/shared/db-bootstrap/local-container-inputs.ts new file mode 100644 index 0000000000..4195cd8774 --- /dev/null +++ b/apps/cli/src/legacy/shared/db-bootstrap/local-container-inputs.ts @@ -0,0 +1,253 @@ +/** + * The local-container-bring-up prelude BOTH `db start` (`commands/db/start/start.handler.ts`) + * and `db reset` (`commands/db/reset/reset.handler.ts`) build before calling their own + * composition (`legacyStartDatabase`/`legacyRecreateLocalDatabase`): load the local project + * context, resolve config values + the `LegacyDbBootstrapConfig` derivation, the container's + * network id/opts/id, the Postgres container-spec fields common to both callers, the lazy + * image-resolve `Effect`, and the `LegacyFreshDbSetupInput` `setup` object `legacyRunFreshDbSetup` + * needs. Hoisted here (CLI-1955 review follow-up) — the two callers used to each run an + * independently-typed ~130-line copy of this exact sequence, with no test comparing them. + * + * Deliberately does NOT include the two callers' genuinely divergent parts, which stay at each + * call site instead of being forced into this shared shape: + * - `db start`'s `fromBackup` (spliced into its OWN `postgresSpec` on top of + * {@link LegacyLocalDbContainerInputs.postgresSpecBase}) and its `isFreshVolume`/`filterValue` + * rollback tracking — `db reset` has neither concept at all (a reset never rolls back, and its + * volume is always fresh, having just been removed). + * - `db reset`'s resolved `version`/`seedFlags` (passed straight to `legacyRecreateLocalDatabase`, + * not part of this prelude) and its OWN, separately-resolved `--experimental` gate: `db reset` + * must resolve `--experimental` BEFORE this prelude ever runs (it gates the remote-target + * Go-delegation decision too, reached before `cfg.isLocal` is even known), via the Go-parity + * nested-env walk (`legacyLoadProjectEnv`/`legacyResolveExperimentalWithProjectEnv`) — a + * deliberately different mechanism than the `@supabase/config`-backed + * `context.projectEnvValues` this function resolves `experimental` from (see + * {@link LegacyLocalDbContainerInputs.experimental}'s own doc comment). `db reset`'s caller + * overrides `setup.experimental` with its own earlier-resolved value rather than using this + * function's, to preserve that pre-existing behavior exactly. + */ + +import { Effect, FileSystem, Option, Path } from "effect"; +import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; +import type { GlobalFlag } from "effect/unstable/cli"; + +import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; +import { legacyResolveExperimentalWithProjectEnv } from "../../../shared/legacy/global-flags.ts"; +import { LegacyDbConfigLoadError } from "../legacy-db-config.errors.ts"; +import { localDbContainerId, legacyResolveNetworkId } from "../legacy-docker-ids.ts"; +import { legacyIsBitbucketPipeline } from "../legacy-bitbucket-pipeline.ts"; +import { + legacyResolveAuthExternalUrl, + legacyResolveDbSettingsEnvOverrides, + legacyResolveLocalConfigValues, + legacyResolveLocalJwks, + type LegacyLocalConfigValues, +} from "../legacy-local-config-values.ts"; +import { + legacyLoadLocalProjectContext, + type LegacyLocalProjectContext, +} from "../legacy-local-project-context.ts"; +import { + legacyResolveDbBootstrapConfig, + type LegacyDbBootstrapConfig, +} from "./bootstrap-config.ts"; +import type { LegacyFreshDbSetupInput } from "./db-setup.ts"; +import type { LegacyContainerOpts } from "./container-lifecycle.ts"; +import { legacyEnsureImagesCached, type LegacyImagePrepullError } from "./image-prepull.ts"; +import type { LegacyPostgresStartServiceInput } from "./postgres.service.ts"; + +type Spawner = ChildProcessSpawner["Service"]; + +/** Everything {@link legacyBuildLocalDbContainerInputs} resolves for its two real callers. */ +export interface LegacyLocalDbContainerInputs { + readonly context: LegacyLocalProjectContext; + readonly values: LegacyLocalConfigValues; + readonly bootstrapConfig: LegacyDbBootstrapConfig; + /** Go's `DockerStart`-forced `--network-id`, or the generated `supabase_network_` fallback. */ + readonly networkId: string; + readonly containerOpts: LegacyContainerOpts; + /** `localDbContainerId(projectId)` — also this project's volume name and the internal Docker network name. */ + readonly dbContainerId: string; + /** + * The Postgres container-spec fields common to BOTH callers — `db start` splices its own + * `fromBackup` on top; `db reset` (which has no `fromBackup` concept) passes this straight + * through as its whole `postgresSpec`. + */ + readonly postgresSpecBase: Omit; + /** Lazy — evaluated right where Go's `DockerStart` would resolve the `db` container's own image. */ + readonly resolvePostgresImage: Effect.Effect; + readonly dbHealthTimeoutSeconds: number; + /** + * `--experimental`/`SUPABASE_EXPERIMENTAL`, resolved from THIS prelude's own + * {@link LegacyLocalProjectContext.projectEnvValues} (the `@supabase/config`-backed reader) — + * matches `db start`'s own need exactly (it has no earlier use for this gate). `db reset` + * already resolves its own `experimental` earlier, from the Go-parity nested-env walk + * (`legacyLoadProjectEnv`), because it needs the gate before this prelude ever runs (to decide + * Go-delegation for the remote target too) — its caller overrides {@link + * LegacyFreshDbSetupInput.experimental} with that earlier value instead of using this field, to + * preserve that pre-existing divergence exactly. See this module's own header. + */ + readonly experimental: boolean; + readonly setup: LegacyFreshDbSetupInput; +} + +/** + * Builds {@link LegacyLocalDbContainerInputs} — see this module's header for the full call + * order and for which parts are deliberately excluded (kept at each call site instead). + */ +export const legacyBuildLocalDbContainerInputs = ( + spawner: Spawner, + workdir: string, + networkIdFlag: Option.Option, + platform: string, + debug: boolean, +): Effect.Effect< + LegacyLocalDbContainerInputs, + LegacyDbConfigLoadError, + FileSystem.FileSystem | Path.Path | GlobalFlag.Setting.Identifier<"experimental"> | CliArgs +> => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const mapError = (message: string) => new LegacyDbConfigLoadError({ message }); + + const context = yield* legacyLoadLocalProjectContext(workdir, mapError); + const { config, projectEnvValues, loaded, hostname, projectId } = context; + // Go's `viper.GetBool("EXPERIMENTAL")` (`internal/migration/apply/apply.go:19`), read deep + // inside `legacyRunFreshDbSetup`'s fresh-volume setup pipeline — see this field's own doc + // comment for why `db reset`'s caller overrides it instead of using it directly. + const experimental = yield* legacyResolveExperimentalWithProjectEnv(projectEnvValues); + + const values = yield* Effect.try({ + try: () => + legacyResolveLocalConfigValues( + config, + hostname, + workdir, + projectEnvValues, + loaded?.document, + ), + catch: (cause) => mapError(cause instanceof Error ? cause.message : String(cause)), + }); + + const bootstrapConfig = yield* legacyResolveDbBootstrapConfig( + fs, + path, + { config, projectEnvValues, workdir }, + mapError, + ); + + // Go's `DockerStart` forces every container's network mode (and the network it creates) to + // `--network-id` when set, ahead of the generated `supabase_network_` fallback + // (`docker.go:379-383`) — and `--network-id` falls back to the `SUPABASE_NETWORK_ID` + // shell/project-dotenv env var when the flag itself is omitted, via the same + // `viper`/`AutomaticEnv` mechanism as `SUPABASE_YES`/`SUPABASE_EXPERIMENTAL` (review: + // PRRT_kwDOErm0O86VlqIL; see {@link legacyResolveNetworkId}'s doc comment for why this is NOT + // the same freeze-at-package-init shape as `utils.Config.Hostname`). + const networkId = legacyResolveNetworkId( + Option.getOrUndefined(networkIdFlag), + projectId, + projectEnvValues, + ); + // Go's `DockerStart` unconditionally appends the Linux-only `host.docker.internal:host-gateway` + // extra host for every container it starts (`docker_linux.go`; empty on darwin/windows, where + // Docker Desktop already resolves that hostname). + const extraHosts = platform === "linux" ? ["host.docker.internal:host-gateway"] : []; + const containerOpts: LegacyContainerOpts = { + projectId, + isBitbucketPipeline: legacyIsBitbucketPipeline(), + workdir, + extraHosts, + }; + const dbContainerId = localDbContainerId(projectId); + + const postgresSpecBase: Omit = { + db: { + ...config.db, + port: values.dbPort, + major_version: bootstrapConfig.majorVersion, + settings: legacyResolveDbSettingsEnvOverrides(config.db.settings, projectEnvValues), + }, + experimental: { + ...config.experimental, + orioledb_version: bootstrapConfig.orioledbVersion, + s3_host: bootstrapConfig.s3Host, + s3_region: bootstrapConfig.s3Region, + s3_access_key: bootstrapConfig.s3AccessKey, + s3_secret_key: bootstrapConfig.s3SecretKey, + }, + jwtSecret: values.jwtSecret, + jwtExpiry: values.authJwtExpiry, + projectId, + networkId, + configImage: bootstrapConfig.postgresImage, + rootKey: values.rootKey, + }; + + const resolvePostgresImage = legacyEnsureImagesCached( + spawner, + [bootstrapConfig.postgresImage], + projectEnvValues, + ).pipe( + Effect.map( + (resolved) => resolved.get(bootstrapConfig.postgresImage) ?? bootstrapConfig.postgresImage, + ), + ); + + const setup: LegacyFreshDbSetupInput = { + majorVersion: bootstrapConfig.majorVersion, + experimental, + config: { + ...config, + realtime: { + ...config.realtime, + enabled: bootstrapConfig.realtimeEnabledForSetup, + ip_version: bootstrapConfig.realtimeIpVersion, + max_header_length: bootstrapConfig.realtimeMaxHeaderLength, + }, + storage: { + ...config.storage, + enabled: bootstrapConfig.storageEnabledForSetup, + file_size_limit: bootstrapConfig.storageFileSizeLimit, + }, + auth: { + ...config.auth, + enabled: bootstrapConfig.authEnabledForSetup, + }, + }, + dbUrl: values.dbUrl, + jwtSecret: values.jwtSecret, + // Go's `initSchema15`'s realtime job resolves JWKS itself, LOCALLY, gated on + // `Realtime.Enabled` (`internal/db/start/start.go:337-341`) — `legacyRunFreshDbSetup` only + // evaluates this Effect when reached AND `realtimeEnabledForSetup`. + jwks: Effect.tryPromise({ + try: () => legacyResolveLocalJwks(config, workdir, values.jwtSecret, projectEnvValues), + catch: (cause) => mapError(cause instanceof Error ? cause.message : String(cause)), + }), + apiUrl: values.apiUrl, + authExternalUrl: legacyResolveAuthExternalUrl(loaded?.document, projectEnvValues), + siteUrl: values.authSiteUrl, + anonKey: values.anonKey, + serviceRoleKey: values.serviceRoleKey, + storageTargetMigration: bootstrapConfig.storageTargetMigration, + realtimeEnabledForSetup: bootstrapConfig.realtimeEnabledForSetup, + storageEnabledForSetup: bootstrapConfig.storageEnabledForSetup, + authEnabledForSetup: bootstrapConfig.authEnabledForSetup, + serviceVersionOverrides: bootstrapConfig.serviceVersionOverrides, + projectEnvValues, + debug, + }; + + return { + context, + values, + bootstrapConfig, + networkId, + containerOpts, + dbContainerId, + postgresSpecBase, + resolvePostgresImage, + dbHealthTimeoutSeconds: bootstrapConfig.dbHealthTimeoutSeconds, + experimental, + setup, + }; + }); diff --git a/apps/cli/src/legacy/shared/db-bootstrap/local-db-running.ts b/apps/cli/src/legacy/shared/db-bootstrap/local-db-running.ts index 251ff45ff3..5ed7247888 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/local-db-running.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/local-db-running.ts @@ -42,11 +42,12 @@ const decodeChunks = (chunks: ReadonlyArray): string => { * error rather than silently treating the database as stopped. * * Shared by `db start` (`commands/db/start/start.handler.ts`) and `db reset` - * (`commands/db/reset/reset.handler.ts`) — hoisted out of the now-removed - * `db __db-bootstrap` Go seam by CLI-1954, since this check was already a - * native TS `docker container inspect`, not a Go subprocess call. `db reset` - * still delegates its container-recreate + storage-health-gate primitives to - * that seam (`LegacyDbBootstrapSeam`); only this probe moved. + * (`commands/db/reset/reset.handler.ts`) — hoisted out of the `db __db-bootstrap` + * Go seam by CLI-1954, since this check was already a native TS `docker container + * inspect`, not a Go subprocess call. CLI-1955 later removed the rest of that seam + * too (`db reset`'s container-recreate + storage-health-gate primitives are now + * native — `recreate-local-database.ts`/`await-storage-ready.ts`), so the seam + * itself no longer exists at all. * * `resolveDbToml` mirrors the seam's own best-effort read: the caller has * already run Go's `LoadConfig` validation before reaching this check, so here diff --git a/apps/cli/src/legacy/shared/db-bootstrap/postgres.service.ts b/apps/cli/src/legacy/shared/db-bootstrap/postgres.service.ts index 13cb5c3c39..d6ea252181 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/postgres.service.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/postgres.service.ts @@ -15,7 +15,7 @@ * - `SetupLocalDatabase` (initial schema bootstrap, `start.go:184-187`) — an * explicit follow-up, not container construction. * - Actually creating/starting the container and waiting for it to become - * healthy — that's {@link legacyStartContainer} (`./container-lifecycle.ts`) + * healthy — that's {@link legacyCreateContainer} (`./container-lifecycle.ts`) * and {@link legacyWaitForHealthyServices} (`./health-check.ts`), wired * up by each caller's own handler. */ diff --git a/apps/cli/src/legacy/shared/db-bootstrap/recreate-local-database.ts b/apps/cli/src/legacy/shared/db-bootstrap/recreate-local-database.ts new file mode 100644 index 0000000000..8db1504e85 --- /dev/null +++ b/apps/cli/src/legacy/shared/db-bootstrap/recreate-local-database.ts @@ -0,0 +1,495 @@ +/** + * `db reset --local`'s container-recreate half — a strict 1:1 port of Go's + * `resetDatabase`/`resetDatabase14`/`resetDatabase15` + * (`apps/cli-go/internal/db/reset/reset.go:81-208`). This is DELIBERATELY NOT a + * thin wrapper over {@link legacyStartDatabase} (`./start-database.ts`, the + * `StartDatabase`-equivalent `db start`/`supabase start` share) — Go's own + * `resetDatabase15` never calls `StartDatabase` either. It is a distinctly + * different composition over the SAME underlying primitives + * (`legacyEnsureNetwork`, `legacyBuildPostgresStartContainerSpec`, + * `legacyCreateContainer`, `legacyWaitForHealthyServices`, + * `legacyStartSetupLocalDatabase`), matching Go's own structure: + * + * **PG >= 15** (`resetDatabase15`, `reset.go:146-174`): + * 1. `docker container rm -f ` — NOT tolerant of "not found" (a genuine + * remove failure is a hard `failed to remove container`), unlike most other + * container lookups in this codebase. + * 2. `docker volume rm -f ` — tolerant of "not found" via the `-f` flag + * ITSELF (verified against a real Docker daemon: `force` makes a missing + * volume's removal a no-op), so no special-casing is needed here either. + * 3. `legacyEnsureNetwork` (Go's `DockerStart` always ensures the network + * exists, on every call — this is hoisted out of `legacyCreateContainer` for + * the SAME reason `start-database.ts` hoists it). + * 4. `Recreating database...\n` to stderr (NOT `Starting database...`). + * 5. Build + create + start the Postgres container (byte-identical inputs to + * `legacyStartDatabase`'s own — there is no `fromBackup` variant on this + * path, reset has no restore concept) via the same + * `legacyBuildPostgresStartContainerSpec`/`legacyCreateContainer`. + * 6. Health wait — NEVER swallowed (no `--from-backup`-equivalent gate here at + * all). + * 7. `legacyStartSetupLocalDatabase` — UNCONDITIONALLY (no fresh-volume gate: a + * reset just removed the volume, so it's always fresh) and with the + * RESOLVED reset `version`/`seedFlags` (not `""` like `db start`'s own call) + * — see `db-setup.ts`'s own header for this one genuine parameter + * difference between the shared function's two real Go callers. + * 8. `Restarting containers...\n` to stderr, then + * {@link legacyRestartServicesAndReloadKong} (`./restart-services.ts`). + * + * **PG <= 14** (`resetDatabase14`, `reset.go:128-144`): + * 1. `recreateDatabase` (`reset.go:188-208`) — connect as `supabase_admin` to + * `template1`, `DisconnectClients`, then four UNWRAPPED (no `BEGIN`/`COMMIT`) + * statements: `DROP`/`CREATE DATABASE postgres`, `DROP`/`CREATE DATABASE + * _supabase`. Go batches these via a pgconn protocol trick that has no TS + * equivalent — not needed here: none of the four can ever run inside a + * transaction anyway, so plain sequential `session.exec` calls, relying on + * Effect's own short-circuit-on-failure, reproduce Go's "stop at first + * error" behavior exactly (verified empirically against real Postgres 14/15 + * with the pinned pgconn/pgx versions — the batching trick is real, but its + * OBSERVABLE effect is identical to sequential execution for this + * particular statement set). + * 2. `initDatabase` (`reset.go:176-186`) — connect as `supabase_admin` to the + * default `postgres` database, then Go's EXPORTED `InitSchema14` (schema SQL + * ONLY, deliberately WITHOUT globals.sql — see `legacyInitSchema14`'s + * own doc comment for why this is NOT the same as `db start`'s PG<=14 path) + * + `ApplyApiPrivileges` (the exact same exported function `SetupDatabase` + * also calls). + * 3. `RestartDatabase` (`reset.go:246-257`) — `Restarting containers...\n` + * FIRST, then a REAL `docker restart` of the `db` container itself (NOT + * tolerant of "not found" — pg_cron must restart after + * `pg_terminate_backend`, Go's own comment), health wait (not swallowed), + * then {@link legacyRestartServicesAndReloadKong} — so Kong reload happens on + * the PG14 path too, after the db container restart. + * 4. Final connect as `postgres`/`postgres` → `apply.MigrateAndSeed` with the + * resolved reset `version`/`seedFlags` — the same seed-override logic PG15's + * `legacyStartSetupLocalDatabase` call applies. + * + * Deliberately absent, matching Go exactly: no volume-existence probe (a reset + * just removed the volume, so there is nothing to probe), no `NoBackupVolume`/ + * fresh-volume concept, no `fromBackup` handling at all, `initCurrentBranch` is + * NEVER called (Go's `resetDatabase`/`resetDatabase14`/`resetDatabase15` never + * call it), and no rollback on failure (Go's `cmd/db.go` only wraps `--mode + * start` in a `DockerRemoveAll` cleanup — the recreate dispatch has none). + * + * `pgcache.TryCacheMigrationsCatalog`'s best-effort catalog warmup (part of Go's + * `SetupLocalDatabase`, reachable from the PG15 path above via + * `legacyStartSetupLocalDatabase`) IS reached here too — see `db-setup.ts`'s own + * header for the exact gate/citations. `reset.layers.ts` composes + * `legacyEdgeRuntimeScriptLayer`/`legacyPgDeltaSslProbeLayer` for it, matching + * `db start`'s own layer composition (`db/start/start.layers.ts`). + */ + +import { Data, Effect, Result, Schedule, type FileSystem, type Path } from "effect"; +import type * as HttpClient from "effect/unstable/http/HttpClient"; +import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; + +import { Output } from "../../../shared/output/output.service.ts"; +import type { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts"; +import { legacyIsSqlState } from "../legacy-connect-errors.ts"; +import { legacyCheckDbToml } from "../legacy-db-config.toml-read.ts"; +import { LegacyDbConnection, type LegacyDbSession } from "../legacy-db-connection.service.ts"; +import type { LegacyDbConnectError, LegacyDbExecError } from "../legacy-db-connection.errors.ts"; +import { LEGACY_CLI_PROJECT_LABEL } from "../legacy-docker-ids.ts"; +import type { LegacyDockerRun } from "../legacy-docker-run.service.ts"; +import type { LegacyEdgeRuntimeScript } from "../legacy-edge-runtime-script.service.ts"; +import { legacyMigrateAndSeed } from "../legacy-migrate-and-seed.ts"; +import type { LegacyMigrationApplyError } from "../legacy-migration-apply.ts"; +import type { LegacyPgDeltaSslProbe } from "../legacy-pgdelta-ssl-probe.service.ts"; +import type { LegacyMigrationSeedError } from "../legacy-seed.ts"; +import { + legacyEnsureNetwork, + legacyRemoveContainer, + legacyRemoveVolume, + legacyCreateContainer, + LEGACY_COMPOSE_PROJECT_LABEL, + type LegacyContainerRemoveError, + type LegacyContainerError, + type LegacyContainerOpts, + type LegacyNetworkCreateError, + type LegacyVolumeRemoveError, +} from "./container-lifecycle.ts"; +import { + legacyRunFreshDbSetup, + legacyResolveResetSeedConfig, + legacyApplyApiPrivileges, + legacyInitSchema14, + LegacyDbSetupError, + type LegacyFreshDbSetupInput, + type LegacyStartSetupLocalDatabaseError, +} from "./db-setup.ts"; +import { + legacyWaitForHealthyServices, + type LegacyHealthCheckTimeoutError, +} from "./health-check.ts"; +import type { LegacyImagePrepullError } from "./image-prepull.ts"; +import { legacyStartInternalDbPassword } from "./internal-db-connection.ts"; +import { + legacyBuildPostgresStartContainerSpec, + type LegacyPostgresStartServiceInput, +} from "./postgres.service.ts"; +import { + legacyRestartContainer, + legacyRestartServicesAndReloadKong, + type LegacyContainerRestartError, + type LegacyKongReloadError, + type LegacyRestartServicesError, +} from "./restart-services.ts"; + +type Spawner = ChildProcessSpawner["Service"]; + +const errMessage = (e: unknown): string => + typeof e === "object" && e !== null && "message" in e && typeof e.message === "string" + ? e.message + : String(e); + +/** + * One or more replication slots are still active (retryable — the WAL sender + * that owns the slot may still be tearing down), OR counting them failed + * outright (permanent — Go's `backoff.PermanentError`, `reset.go:236-238`: + * a query-execution failure is never retried, only "count > 0" is). Not + * exported outside this module — callers discriminate this via the + * {@link LegacyRecreateLocalDatabaseError} union's `_tag`, never by importing + * the class itself (same pattern as `legacy-docker-remove-all.ts`). + */ +class LegacyResetReplicationSlotsError extends Data.TaggedError( + "LegacyResetReplicationSlotsError", +)<{ + readonly message: string; + readonly retryable: boolean; +}> {} + +/** Every failure the PG14/PG15 `db reset` recreate composition can produce. */ +export type LegacyRecreateLocalDatabaseError = + // PG15 (`resetDatabase15`) + | LegacyNetworkCreateError + | LegacyContainerRemoveError + | LegacyVolumeRemoveError + | LegacyContainerError + | LegacyImagePrepullError + | LegacyHealthCheckTimeoutError + | LegacyStartSetupLocalDatabaseError + // PG14 (`resetDatabase14`) + | LegacyDbConnectError + | LegacyDbExecError + | LegacyResetReplicationSlotsError + | LegacyDbSetupError + | LegacyContainerRestartError + | LegacyMigrationApplyError + | LegacyMigrationSeedError + // Shared post-recreate step (both branches) + | LegacyRestartServicesError + | LegacyKongReloadError; + +export interface LegacyRecreateLocalDatabaseInput { + readonly fs: FileSystem.FileSystem; + readonly path: Path.Path; + readonly workdir: string; + readonly projectId: string; + readonly networkId: string; + readonly hostname: string; + /** `localDbContainerId(projectId)` — also this composition's own volume name (Go: `utils.DbId` names both). */ + readonly dbContainerId: string; + readonly dbPort: number; + readonly containerOpts: LegacyContainerOpts; + /** Fed straight to `legacyBuildPostgresStartContainerSpec` — reset has no `fromBackup` concept at all. */ + readonly postgresSpec: Omit; + /** Lazy — evaluated right where Go's `DockerStart` would resolve it (PG15 path only). */ + readonly resolvePostgresImage: Effect.Effect; + readonly dbHealthTimeoutSeconds: number; + /** The resolved reset migration version (`""` for every pending migration). */ + readonly version: string; + /** `db reset`'s `--no-seed`/`--sql-paths` — see {@link legacyResolveResetSeedConfig}. */ + readonly seedFlags: { readonly noSeed: boolean; readonly sqlPaths: ReadonlyArray }; + /** The exact same shape `start-database.ts`'s `LegacyStartDatabaseInput.setup` uses, since Go's `resetDatabase15` calls the SAME `SetupLocalDatabase` `db start` does — hoisted to {@link LegacyFreshDbSetupInput}. */ + readonly setup: LegacyFreshDbSetupInput; +} + +/** Go's `pgerrcode.InvalidCatalogName` (`3D000`) — "database doesn't exist yet" on a first-ever reset. */ +const PG_INVALID_CATALOG_NAME = "3D000"; + +/** + * Port of Go's `DisconnectClients` (`reset.go:215-244`): disable new connections + * to `postgres`/`_supabase`, terminate existing backends, then wait for WAL + * senders to drop their replication slots (constant 1-second backoff, 10 + * retries max — Go's `NewBackoffPolicy(ctx, 10*time.Second)`). + * + * Exported ONLY so `recreate-local-database.unit.test.ts` can pin the retry + * schedule's exact 10-retry boundary against a plain mocked {@link + * LegacyDbSession} (no real filesystem/Docker I/O), using the same `TestClock` + * + `Effect.forkChild` pattern as `db-bootstrap/await-storage-ready.unit.test.ts` — + * driving the full `legacyDbReset` composite effect through a fake clock isn't + * reliable (its many REAL filesystem awaits race unpredictably against a + * virtual-time nudge issued from the outside), so this narrower, no-real-I/O + * entry point is the one actually worth pinning this way; the full retry-count + * behavior stays covered end-to-end by `reset.integration.test.ts`'s own + * `it.live` tests. Not otherwise used outside this module. + */ +export const legacyResetDisconnectClients = Effect.fnUntraced(function* (session: LegacyDbSession) { + // Must be executed separately because looping in a transaction is unsupported + // (Go's own comment, `reset.go:216-217`) — sequential, unwrapped execs, relying on + // Effect's short-circuit-on-failure to stop at the first bad statement, exactly + // like pgconn's own batch-pipeline semantics would. + const disconnectResult = yield* Effect.forEach( + [ + "ALTER DATABASE postgres ALLOW_CONNECTIONS false", + "ALTER DATABASE _supabase ALLOW_CONNECTIONS false", + "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname IN ('postgres', '_supabase')", + ], + (sql) => session.exec(sql), + { discard: true }, + ).pipe(Effect.result); + if (Result.isFailure(disconnectResult)) { + const failure = disconnectResult.failure; + // Go: `if errors.As(err, &pgErr) && pgErr.Code != pgerrcode.InvalidCatalogName { return wrapped }` + // — surfaced ONLY for a genuine PgError whose code isn't 3D000. `failure.code` is NOT reliably + // only-ever-set-for-a-real-ErrorResponse: the driver layer's exec-error mapping + // (`legacyToExecError`) falls back to `legacyExtractSqlState`, which can surface a bare node + // system errno (`ECONNRESET`, `ETIMEDOUT`, …) as `code` too — those are NOT SQLSTATEs, and + // `errors.As(err, &pgErr)` never matches a socket error in Go, so this must check + // `legacyIsSqlState` before treating `code` as a genuine PgError code. A non-PgError failure + // (network blip, no `code`, or a `code` that isn't a real SQLSTATE) AND a 3D000 PgError are + // BOTH silently swallowed. + if ( + failure.code !== undefined && + legacyIsSqlState(failure.code) && + failure.code !== PG_INVALID_CATALOG_NAME + ) { + return yield* Effect.fail( + new LegacyDbSetupError({ + message: `failed to disconnect clients: ${failure.message}`, + }), + ); + } + } + + const countReplicationSlots = session + .query("SELECT COUNT(*) FROM pg_replication_slots WHERE database IN ('postgres', '_supabase')") + .pipe( + Effect.mapError( + (cause) => + new LegacyResetReplicationSlotsError({ + message: `failed to count replication slots: ${cause.message}`, + retryable: false, + }), + ), + Effect.flatMap((rows) => { + const count = Number(rows[0]?.["count"] ?? 0); + return count > 0 + ? Effect.fail( + new LegacyResetReplicationSlotsError({ + message: `replication slots still active: ${count}`, + retryable: true, + }), + ) + : Effect.void; + }), + ); + yield* countReplicationSlots.pipe( + Effect.retry({ + schedule: Schedule.max([Schedule.spaced("1 seconds"), Schedule.recurs(10)]), + while: (error) => error.retryable, + }), + ); +}); + +/** + * Port of Go's `recreateDatabase` (`reset.go:188-208`): connect as + * `supabase_admin` to `template1`, disconnect clients, then four UNWRAPPED + * statements. "We are not dropping roles here because they are cluster level + * entities. Use stop && start instead." (Go's own comment.) + */ +const legacyResetRecreateDatabases = Effect.fnUntraced(function* (session: LegacyDbSession) { + yield* legacyResetDisconnectClients(session); + yield* session.exec("DROP DATABASE IF EXISTS postgres WITH (FORCE)"); + yield* session.exec("CREATE DATABASE postgres WITH OWNER postgres"); + yield* session.exec("DROP DATABASE IF EXISTS _supabase WITH (FORCE)"); + yield* session.exec("CREATE DATABASE _supabase WITH OWNER postgres"); +}); + +/** + * Port of Go's `resetDatabase15` (`reset.go:146-174`) — see this module's own + * header for the full sequence and citations. + */ +const legacyRecreateLocalDatabase15 = ( + spawner: Spawner, + input: LegacyRecreateLocalDatabaseInput, +): Effect.Effect< + void, + LegacyRecreateLocalDatabaseError | E, + | Output + | LegacyDbConnection + | LegacyDockerRun + | RuntimeInfo + | HttpClient.HttpClient + | LegacyEdgeRuntimeScript + | LegacyPgDeltaSslProbe + | FileSystem.FileSystem + | Path.Path +> => + Effect.gen(function* () { + const output = yield* Output; + + yield* legacyRemoveContainer(spawner, input.dbContainerId); + yield* legacyRemoveVolume(spawner, input.dbContainerId); + + yield* legacyEnsureNetwork(spawner, input.networkId, { + [LEGACY_CLI_PROJECT_LABEL]: input.projectId, + [LEGACY_COMPOSE_PROJECT_LABEL]: input.projectId, + }); + + yield* output.raw("Recreating database...\n", "stderr"); + + const resolvedPostgresImage = yield* input.resolvePostgresImage; + const postgresSpec = legacyBuildPostgresStartContainerSpec({ + ...input.postgresSpec, + image: resolvedPostgresImage, + }); + yield* legacyCreateContainer(spawner, postgresSpec, input.containerOpts); + + // Never swallowed — reset has no `--from-backup`-equivalent gate at all. + yield* legacyWaitForHealthyServices(spawner, [postgresSpec.containerName], { + timeoutSeconds: input.dbHealthTimeoutSeconds, + images: new Map([[postgresSpec.containerName, resolvedPostgresImage]]), + }); + + // UNCONDITIONAL — no fresh-volume gate: a reset just removed the volume above, so + // it's always fresh. Passes the RESOLVED reset `version`/`seedFlags`, unlike `db + // start`'s own call — see `db-setup.ts`'s header for this one real difference. + yield* legacyRunFreshDbSetup(spawner, { + fs: input.fs, + path: input.path, + workdir: input.workdir, + projectId: input.projectId, + networkId: input.networkId, + hostname: input.hostname, + dbPort: input.dbPort, + version: input.version, + seedFlags: input.seedFlags, + setup: input.setup, + }); + + yield* output.raw("Restarting containers...\n", "stderr"); + yield* legacyRestartServicesAndReloadKong(spawner, input.projectId); + }); + +/** + * Port of Go's `resetDatabase14` (`reset.go:128-144`) — see this module's own + * header for the full sequence and citations. Loads `config.toml` once, ahead of + * `initDatabase` (needs `api.auto_expose_new_tables`) and the final + * `MigrateAndSeed` (needs `db.migrations.enabled`/`[db.seed]`/pg-delta gate) — + * the same "each caller re-loads its own config" duplication `db start`'s own + * handler and `legacyStartSetupLocalDatabase` both already take independently. + */ +const legacyRecreateLocalDatabase14 = ( + spawner: Spawner, + input: LegacyRecreateLocalDatabaseInput, +): Effect.Effect< + void, + LegacyRecreateLocalDatabaseError | E, + | Output + | LegacyDbConnection + | LegacyDockerRun + | RuntimeInfo + | HttpClient.HttpClient + | LegacyEdgeRuntimeScript + | LegacyPgDeltaSslProbe + | FileSystem.FileSystem + | Path.Path +> => + Effect.gen(function* () { + const { setup, fs, path, workdir } = input; + const dbPassword = legacyStartInternalDbPassword(setup.dbUrl); + const toml = yield* legacyCheckDbToml(fs, path, workdir); + const dbConnection = yield* LegacyDbConnection; + const output = yield* Output; + + const connectAs = (user: string, database: string) => + dbConnection.connect( + { host: input.hostname, port: input.dbPort, user, password: dbPassword, database }, + { isLocal: true, dnsResolver: "native" }, + ); + + // recreateDatabase: connect as `supabase_admin` to `template1`. + yield* Effect.scoped( + Effect.gen(function* () { + const session = yield* connectAs("supabase_admin", "template1"); + yield* legacyResetRecreateDatabases(session); + }), + ); + + // initDatabase: connect as `supabase_admin` to the default `postgres` database. + yield* Effect.scoped( + Effect.gen(function* () { + const session = yield* connectAs("supabase_admin", "postgres"); + const tmpDir = yield* fs + .makeTempDirectoryScoped({ prefix: "supabase-reset-db-setup-" }) + .pipe( + Effect.mapError( + (error) => + new LegacyDbSetupError({ + message: `failed to create temp directory: ${errMessage(error)}`, + }), + ), + ); + yield* legacyInitSchema14(session, fs, path, tmpDir, setup.majorVersion); + yield* legacyApplyApiPrivileges( + session, + fs, + path, + tmpDir, + toml.baseline.apiAutoExposeNewTables, + ); + }), + ); + + // RestartDatabase: "Restarting containers..." FIRST, then a REAL restart of the `db` + // container itself (pg_cron must restart after `pg_terminate_backend`) — NOT tolerant + // of "not found", unlike the satellite restarts inside `legacyRestartServicesAndReloadKong`. + yield* output.raw("Restarting containers...\n", "stderr"); + yield* legacyRestartContainer(spawner, input.dbContainerId); + yield* legacyWaitForHealthyServices(spawner, [input.dbContainerId], { + timeoutSeconds: input.dbHealthTimeoutSeconds, + }); + yield* legacyRestartServicesAndReloadKong(spawner, input.projectId); + + // Final connect as `postgres`/`postgres` -> apply.MigrateAndSeed(ctx, version, ...). + yield* Effect.scoped( + Effect.gen(function* () { + const session = yield* connectAs("postgres", "postgres"); + yield* legacyMigrateAndSeed(session, fs, path, workdir, input.version, { + migrationsEnabled: toml.migrationsEnabled, + seed: legacyResolveResetSeedConfig(toml.seed, input.seedFlags, path), + experimental: setup.experimental, + pgDeltaEnabled: toml.pgDelta.enabled, + schemaPaths: toml.schemaPaths, + }); + }), + ); + }); + +/** + * Runs the exact Go `resetDatabase`/`resetDatabase14`/`resetDatabase15` sequence — + * see this module's header for the full call order and citations. The caller has + * already printed `Resetting local database…`, matching Go's own `resetDatabase` + * wrapper (`reset.go:81-87`) minus that one line (which the seam this replaces + * used to print itself, and which `db/reset/reset.handler.ts` now prints + * directly, exactly like before). + */ +export const legacyRecreateLocalDatabase = ( + spawner: Spawner, + input: LegacyRecreateLocalDatabaseInput, +): Effect.Effect< + void, + LegacyRecreateLocalDatabaseError | E, + | Output + | LegacyDbConnection + | LegacyDockerRun + | RuntimeInfo + | HttpClient.HttpClient + | LegacyEdgeRuntimeScript + | LegacyPgDeltaSslProbe + | FileSystem.FileSystem + | Path.Path +> => + input.setup.majorVersion <= 14 + ? legacyRecreateLocalDatabase14(spawner, input) + : legacyRecreateLocalDatabase15(spawner, input); diff --git a/apps/cli/src/legacy/shared/db-bootstrap/recreate-local-database.unit.test.ts b/apps/cli/src/legacy/shared/db-bootstrap/recreate-local-database.unit.test.ts new file mode 100644 index 0000000000..c57b08ad80 --- /dev/null +++ b/apps/cli/src/legacy/shared/db-bootstrap/recreate-local-database.unit.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, Fiber } from "effect"; +import * as TestClock from "effect/testing/TestClock"; + +import type { LegacyDbSession } from "../legacy-db-connection.service.ts"; +import { LegacyDbExecError } from "../legacy-db-connection.errors.ts"; +import { legacyResetDisconnectClients } from "./recreate-local-database.ts"; + +const COUNT_REPLICATION_SLOTS = + "SELECT COUNT(*) FROM pg_replication_slots WHERE database IN ('postgres', '_supabase')"; + +/** + * A minimal {@link LegacyDbSession} mock built entirely from `Effect.succeed`/ + * `Effect.suspend` — no real filesystem/Docker I/O anywhere in the chain, unlike + * driving the full `legacyDbReset` composite effect through `TestClock`. That's + * what makes the boundary tests below reliable: `legacyResetDisconnectClients` + * reaches its retry schedule's sleep on the very first synchronous pass, so a + * single `TestClock.adjust` per round always lands exactly where expected — + * see `legacyResetDisconnectClients`'s own doc comment. + */ +function mockSession(opts: { + readonly counts?: ReadonlyArray; + readonly queryFails?: boolean; +}) { + const queries: Array = []; + let callIndex = 0; + const session: LegacyDbSession = { + exec: () => Effect.void, + query: (sql): Effect.Effect>, LegacyDbExecError> => + Effect.suspend(() => { + queries.push(sql); + if (sql !== COUNT_REPLICATION_SLOTS) return Effect.succeed([]); + if (opts.queryFails === true) { + return Effect.fail(new LegacyDbExecError({ message: "connection reset" })); + } + const counts = opts.counts ?? [0]; + const count = counts[Math.min(callIndex, counts.length - 1)] ?? 0; + callIndex++; + return Effect.succeed([{ count: String(count) }]); + }), + extensionExists: () => Effect.succeed(false), + copyToCsv: () => Effect.succeed(new Uint8Array()), + queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), + }; + return { + session, + get queries() { + return queries; + }, + }; +} + +describe("legacyResetDisconnectClients", () => { + it.effect("resolves once replication slots drain within the retry budget", () => + Effect.gen(function* () { + const mock = mockSession({ counts: [2, 1, 0] }); + const fiber = yield* legacyResetDisconnectClients(mock.session).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* TestClock.adjust("1 seconds"); + yield* TestClock.adjust("1 seconds"); + const exit = yield* Fiber.await(fiber); + expect(Exit.isSuccess(exit)).toBe(true); + expect(mock.queries.filter((sql) => sql === COUNT_REPLICATION_SLOTS)).toHaveLength(3); + }), + ); + + it.effect( + "is still retrying after 9 one-second backoffs, but fails once the 10th is exhausted — pins Go's `NewBackoffPolicy(ctx, 10*time.Second)` constant", + () => + Effect.gen(function* () { + // Never drains — pegs the retry schedule to its hard 10-retry ceiling. + const mock = mockSession({ counts: [1] }); + const fiber = yield* legacyResetDisconnectClients(mock.session).pipe( + Effect.forkChild({ startImmediately: true }), + ); + + for (let i = 0; i < 9; i++) { + yield* TestClock.adjust("1 seconds"); + } + // Not yet exhausted — 9 retries is one short of Go's hardcoded 10-retry cap. + expect(fiber.pollUnsafe()).toBeUndefined(); + + // The 10th one-second backoff crosses the boundary. + yield* TestClock.adjust("1 seconds"); + const exit = yield* Fiber.await(fiber); + expect(Exit.isFailure(exit)).toBe(true); + expect(mock.queries.filter((sql) => sql === COUNT_REPLICATION_SLOTS)).toHaveLength(11); + }), + ); + + it.effect( + "fails permanently, without retrying, when counting replication slots itself fails", + () => + Effect.gen(function* () { + const mock = mockSession({ queryFails: true }); + const exit = yield* legacyResetDisconnectClients(mock.session).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + // A single attempt — the permanent (non-retryable) failure never retries. + expect(mock.queries.filter((sql) => sql === COUNT_REPLICATION_SLOTS)).toHaveLength(1); + }), + ); +}); diff --git a/apps/cli/src/legacy/shared/db-bootstrap/reset-local-database.ts b/apps/cli/src/legacy/shared/db-bootstrap/reset-local-database.ts new file mode 100644 index 0000000000..3bc87a9448 --- /dev/null +++ b/apps/cli/src/legacy/shared/db-bootstrap/reset-local-database.ts @@ -0,0 +1,244 @@ +/** + * A plain, full local-database reset — Go's `reset.Run(ctx, "", 0, flags.DbConfig, fsys)` + * called against the local target (`internal/db/reset/reset.go:57-77`), with an EMPTY + * version and NO `--last` filtering. Hoisted out of `commands/db/reset/reset.handler.ts`'s + * own `cfg.isLocal` branch (CLI-1955) so it is callable in-process by any Effect context + * that provides the services below (CLI-2062) — the two `db schema declarative` + * call sites (`declarative.smart-target.ts`'s local-reset prompt, + * `sync.handler.ts`'s failed-apply recovery reset) used to shell out to a SEPARATE + * `supabase-go` child process for this (`LegacyDeclarativeSeam.execInherit`), which is + * itself a divergence from real Go: Go's `db schema declarative`/`sync` call + * `reset.Run` as a plain in-process function, sharing the outer command's own + * `PersistentPostRun` (telemetry flush / linked-project-cache write) rather than firing + * a second, independent one from a child process's own `Execute()`. Calling this + * function in-process collapses back to that single-firing behavior. + * + * `db reset`'s own handler is the only caller that ever passes a non-empty + * `version`/`seedFlags` override (`--version`/`--last`/`--no-seed`/`--sql-paths`) — the + * declarative callers always want the plain full reset and call with no arguments. + * + * Resolves every service it needs (`LegacyDebugFlag`, `LegacyNetworkIdFlag`, + * `RuntimeInfo`, `ChildProcessSpawner`, `FileSystem`, `Path`, `LegacyCliConfig`, the + * project `.env` + `legacyResolveExperimentalWithProjectEnv` gate) itself via `yield*`, + * exactly like `legacyDbReset` did inline before this extraction — so it is + * self-contained and does not need `LegacyDbResetFlags`/`CliArgs`/ + * `resolveLegacyDbTargetFlags` (the top-level `db reset` command's own flag-parsing + * concerns, which stay in `reset.handler.ts`). + * + * Emits the exact same two stderr lines the removed `execInherit` subprocess used to + * produce via the Go child's inherited stdio (`Resetting local database...` / + * `Finished supabase db reset on branch .`) — always via `output.raw`, + * regardless of `output.format`, matching a child process's inherited stdio, which + * never receives `-o`/`--output-format` and always prints Go-native text. Deliberately + * does NOT emit the JSON `output.success(...)` envelope: that belongs to a real + * top-level `db reset` invocation only (`reset.handler.ts` emits it itself, after + * calling this function) — neither Go's in-process `reset.Run` nor the removed + * `execInherit` subprocess ever produced a machine-JSON envelope for this nested call. + */ + +import { Data, Effect, FileSystem, Option, Path } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { detectGitBranch } from "../../../shared/git/git-branch.ts"; +import { + LegacyDebugFlag, + LegacyNetworkIdFlag, + legacyResolveExperimentalWithProjectEnv, + legacyResolveYesWithProjectEnv, +} from "../../../shared/legacy/global-flags.ts"; +import { Output } from "../../../shared/output/output.service.ts"; +import { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts"; +import { legacyAqua, legacyYellow } from "../legacy-colors.ts"; +import { LegacyCliConfig } from "../../config/legacy-cli-config.service.ts"; +import { legacyCheckDbToml, legacyLoadProjectEnv } from "../legacy-db-config.toml-read.ts"; +import { legacySeedBucketsRun } from "../legacy-seed-buckets.ts"; +import { legacyAwaitStorageReady } from "./await-storage-ready.ts"; +import { legacyBuildLocalDbContainerInputs } from "./local-container-inputs.ts"; +import { legacyIsLocalDbRunning } from "./local-db-running.ts"; +import { legacyRecreateLocalDatabase } from "./recreate-local-database.ts"; + +/** + * The local database container is not running. Byte-matches Go's + * `utils.ErrNotRunning` (`internal/utils/misc.go:116`), `"supabase start + * is not running."`, returned by `AssertSupabaseDbIsRunning` before the local + * reset (`internal/db/reset/reset.go:57`). Not exported outside this module — + * callers discriminate this via the failure's own message, never by importing the + * class itself (same pattern as `recreate-local-database.ts`'s own + * `LegacyResetReplicationSlotsError`). + */ +class LegacyResetLocalDbNotRunningError extends Data.TaggedError( + "LegacyResetLocalDbNotRunningError", +)<{ + readonly message: string; +}> {} + +/** Go's `toLogMessage` (`internal/db/reset/reset.go:88-91`). */ +const toLogMessage = (version: string): string => + version.length > 0 ? ` to version: ${version}` : "..."; + +export interface LegacyResetLocalDatabaseInput { + /** The resolved reset migration version (`""` for every pending migration, `db reset`'s default). */ + readonly version: string; + /** `db reset`'s `--no-seed`/`--sql-paths` — see `legacyResolveResetSeedConfig`. */ + readonly seedFlags: { readonly noSeed: boolean; readonly sqlPaths: ReadonlyArray }; +} + +const PLAIN_FULL_RESET: LegacyResetLocalDatabaseInput = { + version: "", + seedFlags: { noSeed: false, sqlPaths: [] }, +}; + +/** + * Resets the local database in-process. See this module's own header for the full + * design rationale. Mirrors `internal/db/reset/reset.go:57-77`. + */ +export const legacyResetLocalDatabase = Effect.fnUntraced(function* ( + input: LegacyResetLocalDatabaseInput = PLAIN_FULL_RESET, +) { + const output = yield* Output; + const cliConfig = yield* LegacyCliConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const runtimeInfo = yield* RuntimeInfo; + const networkIdFlag = yield* LegacyNetworkIdFlag; + // Threaded into `legacyBuildLocalDbContainerInputs`'s own `setup.debug`, so a failed + // fresh-volume Realtime/Storage/Auth migrate job on the PG15 recreate path tees its own + // stderr, matching Go's `initSchema15` passing `utils.GetDebugLogger()` as that job's + // stderr writer (`start.go:349-353`) — reached by BOTH real Go callers of + // `SetupLocalDatabase` (`db start` and `db reset`'s PG15 recreate). + const debug = yield* LegacyDebugFlag; + + const workdir = cliConfig.workdir; + // Go's `ParseDatabaseConfig` runs `loadNestedEnv` (which `os.Setenv`s each project-.env key) + // before `reset.Run` reads `viper.GetBool("EXPERIMENTAL")`, so a `SUPABASE_EXPERIMENTAL` set + // only in `supabase/.env` is honored. Load the project env first and resolve against it, as + // `legacyDbReset` does for its own experimental gate. + const projectEnv = yield* legacyLoadProjectEnv(fs, path, workdir); + const yes = yield* legacyResolveYesWithProjectEnv(projectEnv); + const experimental = yield* legacyResolveExperimentalWithProjectEnv(projectEnv); + + // Go's `flags.LoadConfig` (root `PersistentPreRunE` → the local target's per-connType + // `LoadConfig`, `internal/utils/flags/db_url.go:77-80`) runs full config validation before + // `reset.Run` ever reaches `AssertSupabaseDbIsRunning` / the destructive `resetDatabase` + // (`internal/db/reset/reset.go:57-61`). Re-validate here as an explicit, independent gate + // (the same pattern `db start`/`db push` use), so "a malformed config aborts before the + // local database is recreated" is enforced by this function directly. + yield* legacyCheckDbToml(fs, path, workdir); + + // AssertSupabaseDbIsRunning — error if the local db container is down. + const running = yield* legacyIsLocalDbRunning( + spawner, + fs, + path, + workdir, + Option.getOrUndefined(cliConfig.projectId), + ); + if (!running) { + return yield* Effect.fail( + new LegacyResetLocalDbNotRunningError({ + message: `${legacyAqua("supabase start")} is not running.`, + }), + ); + } + // resetDatabase: "Resetting local database…" then recreate + migrate + seed. + yield* output.raw(`Resetting local database${toLogMessage(input.version)}\n`, "stderr"); + + // Build the SAME prelude `db start`'s own handler builds (config values + + // `legacyResolveDbBootstrapConfig`) — Go's `resetDatabase15`/`resetDatabase14` + // recreate the `db` container with byte-identical inputs to `StartDatabase`'s own. + const inputs = yield* legacyBuildLocalDbContainerInputs( + spawner, + workdir, + networkIdFlag, + runtimeInfo.platform, + debug, + ); + const { + context: { projectId, hostname }, + values, + bootstrapConfig, + networkId, + containerOpts, + dbContainerId, + postgresSpecBase, + resolvePostgresImage, + setup, + } = inputs; + + yield* legacyRecreateLocalDatabase(spawner, { + fs, + path, + workdir, + projectId, + networkId, + hostname, + dbContainerId, + dbPort: values.dbPort, + containerOpts, + // `db reset` has no `fromBackup` concept at all, so `postgresSpecBase` — the + // exact same fields `db start` splices its own `fromBackup` on top of — is + // already this composition's WHOLE `postgresSpec`. + postgresSpec: postgresSpecBase, + resolvePostgresImage, + dbHealthTimeoutSeconds: bootstrapConfig.dbHealthTimeoutSeconds, + version: input.version, + seedFlags: input.seedFlags, + // `db reset` resolves `--experimental` EARLIER than this prelude (it gates the + // remote-target Go-delegation decision too, reached before `cfg.isLocal` is even + // known) via the Go-parity nested-env walk (`legacyResolveExperimentalWithProjectEnv` + // over `projectEnv`, above) — override the prelude's OWN `setup.experimental` (resolved + // from its `@supabase/config`-backed context instead) with that earlier value, to + // preserve this pre-existing divergence exactly. See `legacyBuildLocalDbContainerInputs`'s + // own header. + setup: { ...setup, experimental }, + }); + + // Seed objects from supabase/buckets when storage is up (Go gates buckets on + // an existing, healthy storage container). Reuses the ported seed-buckets + // local path; its summary is suppressed (reset emits its own result). + const storageReady = yield* legacyAwaitStorageReady(spawner, projectId); + if (storageReady) { + // Go's `buckets.Run(ctx, "", false, fsys)` — non-interactive: overwrite/prune + // confirmations take their defaults instead of blocking on input. + // + // `legacyCheckDbToml` above resolves `env(VAR)` via `legacyLoadProjectEnv`, which + // mirrors Go's full nested-env walk (`.env..local`, `.env.local`, + // `.env.`, `.env`, across both `supabase/` and the project root — + // `pkg/config/config.go:1220-1257`). This reload instead goes through + // `@supabase/config`'s `loadProjectConfig` → `loadProjectEnvironment`, which only + // ever reads `supabase/.env`/`.env.local` plus ambient env + // (`packages/config/src/project.ts:209-245`) — regardless of `goViperCompat`, which + // only widens `env(VAR)` matching, not the file set consulted. So a config whose + // `env(VAR)` reference is backed by e.g. `supabase/.env.development` is genuinely + // Go-valid (Go's `godotenv.Load` calls `os.Setenv`, so the value is real ambient env + // by the time Go resolves it — `config.go:1260-1261`) and already passed + // `legacyCheckDbToml` and the real recreate above, but this narrower reload can + // still reject it. A `LegacySeedConfigLoadError` here is that env-file-set gap, not + // a genuinely invalid config — and recreate already dropped/rebuilt the DB, so + // aborting now would leave the reset half-done; warn and skip buckets so the reset + // finishes like Go instead. + yield* legacySeedBucketsRun({ + projectRef: "", + emitSummary: false, + interactive: false, + // Go loads nested env before `buckets.Run`, so `SUPABASE_YES` in `supabase/.env` + // auto-confirms bucket/vector/analytics prune prompts. + yes, + }).pipe( + Effect.catchTag("LegacySeedConfigLoadError", (error) => + output.raw( + `${legacyYellow("WARNING:")} skipped seeding storage buckets: ${error.message}\n`, + "stderr", + ), + ), + ); + } + + // "Finished supabase db reset on branch ." (both Aqua). + const branch = Option.getOrElse(yield* detectGitBranch(workdir), () => "main"); + yield* output.raw( + `Finished ${legacyAqua("supabase db reset")} on branch ${legacyAqua(branch)}.\n`, + "stderr", + ); +}); diff --git a/apps/cli/src/legacy/shared/db-bootstrap/restart-services.ts b/apps/cli/src/legacy/shared/db-bootstrap/restart-services.ts new file mode 100644 index 0000000000..fdc0df74b0 --- /dev/null +++ b/apps/cli/src/legacy/shared/db-bootstrap/restart-services.ts @@ -0,0 +1,248 @@ +/** + * Post-recreate satellite-container restart + Kong reload, shared by both PG14's + * `RestartDatabase` and PG15's `resetDatabase15` (`apps/cli-go/internal/db/reset/ + * reset.go:246-317`) — the ONLY two Go call sites of `restartServices`. Neither `db + * start` nor `supabase start` calls any of this: it exists purely to bring the + * satellite containers (storage/auth/realtime/pooler) back in sync with a `db` + * container that was just recreated or force-restarted out from under them, and to + * reload Kong's nginx so its cached upstream addresses (which may have changed if a + * satellite container came back on a different one) stop 502ing. + */ + +import { Data, Effect, Option, Result } from "effect"; +import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; + +import { legacyAqua } from "../legacy-colors.ts"; +import { + collectText, + legacyDescribeContainerCliFailure, + legacyIsContainerNotFoundMessage, + runContainerCliExpectSuccess, + spawnContainerCli, +} from "../legacy-container-cli.ts"; +import { legacyInspectContainerState } from "../legacy-docker-lifecycle.ts"; +import { legacyServiceContainerName } from "../legacy-docker-ids.ts"; + +type Spawner = ChildProcessSpawner["Service"]; + +/** `docker restart ` (the db container itself) failed — used only by PG14's `RestartDatabase`. */ +export class LegacyContainerRestartError extends Data.TaggedError("LegacyContainerRestartError")<{ + readonly message: string; +}> {} + +/** + * Port of Go's `Docker.ContainerRestart(ctx, utils.DbId, container.StopOptions{})` + * (`apps/cli-go/internal/db/reset/reset.go:250-252`), used ONLY by PG14's + * `RestartDatabase` to restart the `db` container itself after `pg_terminate_backend` + * (pg_cron must restart, per Go's own comment). Unlike the satellite restarts below, + * this one does NOT tolerate "not found" — Go's own `RestartDatabase` has no + * `errdefs.IsNotFound` guard on this call at all, so ANY failure is a hard + * `failed to restart container: %w`. + */ +export function legacyRestartContainer( + spawner: Spawner, + containerId: string, +): Effect.Effect { + return runContainerCliExpectSuccess( + spawner, + ["restart", containerId], + "restart container", + (message) => new LegacyContainerRestartError({ message }), + ); +} + +/** + * One satellite service's restart, tolerant of "not found" (Go's `!errdefs.IsNotFound(err)` + * guard, `reset.go:263`) — a service excluded from the stack (e.g. `[realtime] enabled = + * false`) has no container to restart, and that's not an error. Never fails the surrounding + * `Effect.all` itself: resolves `Option.some(message)` on a genuine failure so the caller + * can join every service's outcome the way Go's `errors.Join(result...)` does, and + * `Option.none()` on success OR a tolerated not-found. + */ +const legacyRestartSatelliteService = ( + spawner: Spawner, + containerId: string, +): Effect.Effect> => + Effect.scoped( + Effect.gen(function* () { + const child = yield* spawnContainerCli(spawner, ["restart", containerId], { + stdin: "ignore", + stdout: "ignore", + stderr: "pipe", + }); + const [exitCode, stderr] = yield* Effect.all( + [child.exitCode.pipe(Effect.map(Number)), collectText(child.stderr)], + { concurrency: "unbounded" }, + ); + if (exitCode === 0) return Option.none(); + const trimmed = stderr.trim(); + if (legacyIsContainerNotFoundMessage(trimmed)) return Option.none(); + return Option.some( + `failed to restart ${containerId}: ${trimmed.length > 0 ? trimmed : `exit ${exitCode}`}`, + ); + }), + ).pipe( + Effect.catch((cause) => + Effect.succeed( + Option.some( + `failed to restart ${containerId}: ${legacyDescribeContainerCliFailure(cause)}`, + ), + ), + ), + ); + +/** One or more satellite-service restarts failed. Messages are newline-joined, matching Go's `errors.Join`. */ +export class LegacyRestartServicesError extends Data.TaggedError("LegacyRestartServicesError")<{ + readonly message: string; +}> {} + +/** + * Port of Go's `restartServices` restart half (`reset.go:259-271`): restarts + * storage/auth/realtime/pooler CONCURRENTLY (Go's `utils.WaitAll`, a goroutine per + * service) — NOT PostgREST, which "automatically reconnects and listens for schema + * changes" (Go's own comment) — and does NOT wait for them to become healthy + * afterward ("those services may be excluded from starting"). Every per-service + * failure (excluding a tolerated not-found) is joined into one newline-separated + * message, matching `errors.Join`. Not exported outside this module — only + * {@link legacyRestartServicesAndReloadKong} calls this directly. + */ +function legacyRestartSatelliteServices( + spawner: Spawner, + projectId: string, +): Effect.Effect { + const containerIds = [ + legacyServiceContainerName("storage", projectId), + legacyServiceContainerName("auth", projectId), + legacyServiceContainerName("realtime", projectId), + legacyServiceContainerName("pooler", projectId), + ]; + return Effect.gen(function* () { + const results = yield* Effect.all( + containerIds.map((containerId) => legacyRestartSatelliteService(spawner, containerId)), + { concurrency: "unbounded" }, + ); + const failures = results.filter(Option.isSome).map((result) => result.value); + if (failures.length > 0) { + return yield* Effect.fail(new LegacyRestartServicesError({ message: failures.join("\n") })); + } + }); +} + +/** + * Gateway-recovery hint, byte-matching Go's `suggestKongRecovery` + * (`reset.go:307-317`): rendered as a `Suggestion:` line by `Output.fail`, mirroring + * `utils.CmdSuggestion`. + */ +function legacyKongRecoverySuggestion(kongId: string): string { + return ( + "Local services restarted, but API routes may return 502 until the gateway reloads.\n" + + `Try restarting it with ${legacyAqua(`docker restart ${kongId}`)}, and check ${legacyAqua( + `docker logs ${kongId}`, + )} if the failure persists.` + ); +} + +/** Kong could not be reloaded — fails the WHOLE command (unlike `functions serve`'s best-effort reload). */ +export class LegacyKongReloadError extends Data.TaggedError("LegacyKongReloadError")<{ + readonly message: string; + readonly suggestion: string; +}> {} + +/** `docker exec `, combined stdout+stderr into one buffer — mirrors Go's shared `io.Writer` in `DockerExecOnceWithStream(ctx, KongId, "", nil, cmd, &out, &out)`. Never fails the Effect itself: a spawn failure (no docker/podman) folds into `exitCode: 1`. */ +function legacyExecCaptureCombined( + spawner: Spawner, + containerId: string, + cmd: ReadonlyArray, +): Effect.Effect<{ readonly exitCode: number; readonly output: string }> { + return Effect.scoped( + Effect.gen(function* () { + const child = yield* spawnContainerCli(spawner, ["exec", containerId, ...cmd], { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + const [exitCode, stdout, stderr] = yield* Effect.all( + [ + child.exitCode.pipe(Effect.map(Number)), + collectText(child.stdout), + collectText(child.stderr), + ], + { concurrency: "unbounded" }, + ); + return { exitCode, output: stdout + stderr }; + }), + ).pipe( + Effect.catch((cause) => + Effect.succeed({ exitCode: 1, output: legacyDescribeContainerCliFailure(cause) }), + ), + ); +} + +/** + * Port of Go's `reloadKong` (`reset.go:285-305`): inspect Kong's container — not + * found means Kong is excluded from the stack (`return nil`, not an error); any OTHER + * inspect failure is wrapped with the recovery suggestion; not running means there's + * no stale cache to flush (`return nil`); otherwise `docker exec kong reload + * --nginx-conf /home/kong/custom_nginx.template` (the flag is required — a bare + * `kong reload` regenerates nginx.conf from Kong's default template and drops the + * custom `email_templates` server, reintroducing #6059), failing hard (with the same + * suggestion) on a non-zero exit, the combined output appended when non-empty. Not + * exported outside this module — only {@link legacyRestartServicesAndReloadKong} + * calls this directly. + */ +function legacyReloadKong( + spawner: Spawner, + projectId: string, +): Effect.Effect { + const kongId = legacyServiceContainerName("kong", projectId); + return Effect.gen(function* () { + const inspected = yield* legacyInspectContainerState(spawner, kongId).pipe(Effect.result); + if (Result.isFailure(inspected)) { + if (legacyIsContainerNotFoundMessage(inspected.failure.message)) return; + return yield* Effect.fail( + new LegacyKongReloadError({ + message: `failed to inspect kong: ${inspected.failure.message}`, + suggestion: legacyKongRecoverySuggestion(kongId), + }), + ); + } + if (!inspected.success.running) return; + const result = yield* legacyExecCaptureCombined(spawner, kongId, [ + "kong", + "reload", + "--nginx-conf", + "/home/kong/custom_nginx.template", + ]); + if (result.exitCode !== 0) { + const trimmed = result.output.trim(); + // Go's `DockerExecOnceWithStream` (`utils/docker.go:646-648`) sets a FIXED constant + // error, `errors.New("error executing command")`, for `iresp.ExitCode > 0` — not the + // exit code itself. `reloadKong` then wraps it as `failed to reload kong: %w[:\n%s]` + // (`reset.go:298-303`), so the `%w` slot is always this exact string, never `exit N`. + return yield* Effect.fail( + new LegacyKongReloadError({ + message: + trimmed.length > 0 + ? `failed to reload kong: error executing command:\n${trimmed}` + : "failed to reload kong: error executing command", + suggestion: legacyKongRecoverySuggestion(kongId), + }), + ); + } + }); +} + +/** + * Port of Go's `restartServices` (`reset.go:259-273`): the satellite restarts above, + * then {@link legacyReloadKong} — ONLY when every restart succeeded (Go returns the + * joined restart error immediately, without ever attempting the Kong reload). + */ +export function legacyRestartServicesAndReloadKong( + spawner: Spawner, + projectId: string, +): Effect.Effect { + return Effect.gen(function* () { + yield* legacyRestartSatelliteServices(spawner, projectId); + yield* legacyReloadKong(spawner, projectId); + }); +} diff --git a/apps/cli/src/legacy/shared/db-bootstrap/restart-services.unit.test.ts b/apps/cli/src/legacy/shared/db-bootstrap/restart-services.unit.test.ts new file mode 100644 index 0000000000..f2a05154d1 --- /dev/null +++ b/apps/cli/src/legacy/shared/db-bootstrap/restart-services.unit.test.ts @@ -0,0 +1,306 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Deferred, Effect, Sink, Stream } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { + LegacyContainerRestartError, + LegacyKongReloadError, + legacyRestartContainer, + legacyRestartServicesAndReloadKong, +} from "./restart-services.ts"; + +/** Matches the standing `mockSpawner` shape used across `legacy-docker-*.unit.test.ts` files. */ +function mockSpawner( + handler: (args: ReadonlyArray) => { exitCode: number; stdout?: string; stderr?: string }, +) { + const spawned: Array> = []; + + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + const args = command._tag === "StandardCommand" ? command.args : []; + spawned.push(args); + const result = handler(args); + + const exitDeferred = yield* Deferred.make(); + yield* Deferred.succeed(exitDeferred, ChildProcessSpawner.ExitCode(result.exitCode)); + + const encoder = new TextEncoder(); + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + stdout: Stream.fromIterable( + result.stdout !== undefined ? [encoder.encode(result.stdout)] : [], + ), + stderr: Stream.fromIterable( + result.stderr !== undefined ? [encoder.encode(result.stderr)] : [], + ), + all: Stream.empty, + exitCode: Deferred.await(exitDeferred), + isRunning: Effect.succeed(false), + stdin: Sink.drain, + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + }), + ); + + return { + spawner, + get spawned() { + return spawned; + }, + }; +} + +const HEALTHY_STATE = '{"Running":true,"Status":"running","Health":{"Status":"healthy"}}'; +const STOPPED_STATE = '{"Running":false,"Status":"exited"}'; + +describe("legacyRestartContainer", () => { + it.live("spawns `docker restart ` and succeeds on exit 0", () => { + const mock = mockSpawner(() => ({ exitCode: 0 })); + return legacyRestartContainer(mock.spawner, "supabase_db_proj").pipe( + Effect.map(() => { + expect(mock.spawned).toEqual([["restart", "supabase_db_proj"]]); + }), + ); + }); + + it.live('fails on a "not found" restart — NOT tolerant, unlike the satellite restarts', () => { + const mock = mockSpawner(() => ({ + exitCode: 1, + stderr: "Error: No such container: supabase_db_proj\n", + })); + return legacyRestartContainer(mock.spawner, "supabase_db_proj").pipe( + Effect.flip, + Effect.map((error) => { + expect(error).toBeInstanceOf(LegacyContainerRestartError); + expect(error.message).toContain("failed to restart container"); + }), + ); + }); +}); + +describe("legacyRestartServicesAndReloadKong", () => { + const PROJECT_ID = "proj"; + const KONG_ID = "supabase_kong_proj"; + + it.live("restarts the four satellite services then reloads Kong", () => { + const mock = mockSpawner((args) => { + if (args[0] === "container" && args[1] === "inspect") + return { exitCode: 0, stdout: HEALTHY_STATE }; + return { exitCode: 0 }; + }); + return legacyRestartServicesAndReloadKong(mock.spawner, PROJECT_ID).pipe( + Effect.map(() => { + const restarted = mock.spawned.filter((args) => args[0] === "restart").map((a) => a[1]); + expect(restarted).toEqual( + expect.arrayContaining([ + "supabase_storage_proj", + "supabase_auth_proj", + "supabase_realtime_proj", + "supabase_pooler_proj", + ]), + ); + expect(mock.spawned).toContainEqual([ + "exec", + KONG_ID, + "kong", + "reload", + "--nginx-conf", + "/home/kong/custom_nginx.template", + ]); + }), + ); + }); + + it.live("restarts the four satellite services CONCURRENTLY, not sequentially", () => + Effect.gen(function* () { + const barrier = yield* Deferred.make(); + let inFlight = 0; + const restarted: Array = []; + + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + const args = command._tag === "StandardCommand" ? command.args : []; + if (args[0] === "restart") { + restarted.push(args[1] ?? ""); + inFlight++; + if (inFlight === 4) yield* Deferred.succeed(barrier, undefined); + // Every one of the four restarts blocks here until ALL FOUR are in flight + // simultaneously (Go's `utils.WaitAll`, a goroutine per service — reset.go:259-271). + // If `legacyRestartSatelliteServices` ever regressed to a sequential restart (e.g. + // `concurrency: 1`), the second restart would never even be DISPATCHED until the + // first resolves, so `inFlight` would never reach 4 and this `await` would hang + // forever, timing out the test instead of silently passing. + yield* Deferred.await(barrier); + } else if (args[0] === "container" && args[1] === "inspect" && args[2] === KONG_ID) { + // Kong excluded from the stack — skips the reload, keeping this test focused on + // the satellite-restart concurrency guarantee alone. + const exitDeferred = yield* Deferred.make(); + yield* Deferred.succeed(exitDeferred, ChildProcessSpawner.ExitCode(1)); + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + stdout: Stream.empty, + stderr: Stream.fromIterable([ + new TextEncoder().encode(`Error: No such container: ${KONG_ID}\n`), + ]), + all: Stream.empty, + exitCode: Deferred.await(exitDeferred), + isRunning: Effect.succeed(false), + stdin: Sink.drain, + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + } + + const exitDeferred = yield* Deferred.make(); + yield* Deferred.succeed(exitDeferred, ChildProcessSpawner.ExitCode(0)); + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + stdout: Stream.empty, + stderr: Stream.empty, + all: Stream.empty, + exitCode: Deferred.await(exitDeferred), + isRunning: Effect.succeed(false), + stdin: Sink.drain, + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + }), + ); + + yield* legacyRestartServicesAndReloadKong(spawner, PROJECT_ID); + + expect(restarted).toEqual( + expect.arrayContaining([ + "supabase_storage_proj", + "supabase_auth_proj", + "supabase_realtime_proj", + "supabase_pooler_proj", + ]), + ); + }), + ); + + it.live('tolerates a "not found" satellite restart without failing', () => { + const mock = mockSpawner((args) => { + if (args[0] === "restart" && args[1] === "supabase_realtime_proj") { + return { exitCode: 1, stderr: "Error: No such container: supabase_realtime_proj\n" }; + } + if (args[0] === "container" && args[1] === "inspect") + return { exitCode: 0, stdout: HEALTHY_STATE }; + return { exitCode: 0 }; + }); + return legacyRestartServicesAndReloadKong(mock.spawner, PROJECT_ID).pipe(Effect.asVoid); + }); + + it.live("joins multiple satellite-restart failures and never attempts the Kong reload", () => { + const mock = mockSpawner((args) => { + if (args[0] === "restart" && args[1] === "supabase_storage_proj") { + return { exitCode: 1, stderr: "boom-storage" }; + } + if (args[0] === "restart" && args[1] === "supabase_auth_proj") { + return { exitCode: 1, stderr: "boom-auth" }; + } + return { exitCode: 0 }; + }); + return legacyRestartServicesAndReloadKong(mock.spawner, PROJECT_ID).pipe( + Effect.flip, + Effect.map((error) => { + expect(error.message).toContain("failed to restart supabase_storage_proj"); + expect(error.message).toContain("failed to restart supabase_auth_proj"); + expect(mock.spawned.some((args) => args[0] === "exec")).toBe(false); + }), + ); + }); + + it.live("skips the reload without failing when Kong is excluded from the stack", () => { + const mock = mockSpawner((args) => { + if (args[0] === "container" && args[1] === "inspect" && args[2] === KONG_ID) { + return { exitCode: 1, stderr: `Error: No such container: ${KONG_ID}\n` }; + } + return { exitCode: 0 }; + }); + return legacyRestartServicesAndReloadKong(mock.spawner, PROJECT_ID).pipe( + Effect.map(() => { + expect(mock.spawned.some((args) => args[0] === "exec")).toBe(false); + }), + ); + }); + + it.live("skips the reload without failing when Kong is present but stopped", () => { + const mock = mockSpawner((args) => { + if (args[0] === "container" && args[1] === "inspect" && args[2] === KONG_ID) { + return { exitCode: 0, stdout: STOPPED_STATE }; + } + return { exitCode: 0 }; + }); + return legacyRestartServicesAndReloadKong(mock.spawner, PROJECT_ID).pipe( + Effect.map(() => { + expect(mock.spawned.some((args) => args[0] === "exec")).toBe(false); + }), + ); + }); + + it.live("fails with the exact suggestion when the Kong inspect fails for another reason", () => { + const mock = mockSpawner((args) => { + if (args[0] === "container" && args[1] === "inspect" && args[2] === KONG_ID) { + return { exitCode: 1, stderr: "Cannot connect to the Docker daemon\n" }; + } + return { exitCode: 0 }; + }); + return legacyRestartServicesAndReloadKong(mock.spawner, PROJECT_ID).pipe( + Effect.flip, + Effect.map((error) => { + expect(error).toBeInstanceOf(LegacyKongReloadError); + if (!(error instanceof LegacyKongReloadError)) return; + expect(error.message).toContain("failed to inspect kong"); + expect(error.suggestion).toContain( + "Local services restarted, but API routes may return 502", + ); + expect(error.suggestion).toContain(`docker restart ${KONG_ID}`); + expect(error.suggestion).toContain(`docker logs ${KONG_ID}`); + }), + ); + }); + + it.live("fails with the combined output and suggestion when `kong reload` itself fails", () => { + const mock = mockSpawner((args) => { + if (args[0] === "container" && args[1] === "inspect" && args[2] === KONG_ID) { + return { exitCode: 0, stdout: HEALTHY_STATE }; + } + if (args[0] === "exec" && args[1] === KONG_ID) { + return { exitCode: 1, stderr: "nginx: [error] invalid config\n" }; + } + return { exitCode: 0 }; + }); + return legacyRestartServicesAndReloadKong(mock.spawner, PROJECT_ID).pipe( + Effect.flip, + Effect.map((error) => { + expect(error).toBeInstanceOf(LegacyKongReloadError); + if (!(error instanceof LegacyKongReloadError)) return; + // Byte-matches Go: `DockerExecOnceWithStream` sets a fixed `error executing command` + // for a non-zero exec exit code (`utils/docker.go:646-648`) — not the exit code itself. + expect(error.message).toContain("failed to reload kong: error executing command"); + expect(error.message).toContain("nginx: [error] invalid config"); + expect(error.suggestion).toContain(`docker restart ${KONG_ID}`); + // Pins the `--nginx-conf` flag (reset.go:269, reset_test.go:512) — a bare + // `kong reload` regenerates nginx.conf from Kong's default template and + // drops the custom `email_templates` server, reintroducing #6059. + expect(mock.spawned).toContainEqual([ + "exec", + KONG_ID, + "kong", + "reload", + "--nginx-conf", + "/home/kong/custom_nginx.template", + ]); + }), + ); + }); +}); diff --git a/apps/cli/src/legacy/shared/db-bootstrap/start-database.ts b/apps/cli/src/legacy/shared/db-bootstrap/start-database.ts index 464bd00b61..ea89e4175b 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/start-database.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/start-database.ts @@ -53,44 +53,40 @@ import type * as HttpClient from "effect/unstable/http/HttpClient"; import { Output } from "../../../shared/output/output.service.ts"; import type { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts"; -import type { LocalServiceVersionOverrides } from "../../../shared/services/services.shared.ts"; import { legacyAqua } from "../legacy-colors.ts"; import { LegacyDbConnection } from "../legacy-db-connection.service.ts"; import type { LegacyDbConnectError } from "../legacy-db-connection.errors.ts"; import { LEGACY_CLI_PROJECT_LABEL } from "../legacy-docker-ids.ts"; import type { LegacyDockerRun } from "../legacy-docker-run.service.ts"; -import type { LegacyEdgeRuntimeScript } from "../legacy-edge-runtime-script.service.ts"; -import type { LegacyPgDeltaSslProbe } from "../legacy-pgdelta-ssl-probe.service.ts"; import { - legacyEnsureStartNetwork, - legacyStartContainer, - legacyStartVolumeExists, + legacyEnsureNetwork, + legacyCreateContainer, + legacyVolumeExists, LEGACY_COMPOSE_PROJECT_LABEL, - type LegacyStartContainerCreateError, - type LegacyStartContainerOpts, - type LegacyStartContainerStartError, - type LegacyStartNetworkCreateError, - type LegacyStartVolumeCreateError, - type LegacyStartVolumeInspectError, + type LegacyContainerCreateError, + type LegacyContainerOpts, + type LegacyContainerStartError, + type LegacyNetworkCreateError, + type LegacyVolumeCreateError, + type LegacyVolumeInspectError, } from "./container-lifecycle.ts"; import { + legacyRunFreshDbSetup, legacyStartInitCurrentBranch, - legacyStartSetupLocalDatabase, - type LegacyStartDbSetupImages, + type LegacyFreshDbSetupInput, type LegacyStartSetupLocalDatabaseError, - type LegacyStartSetupLocalDatabaseInput, } from "./db-setup.ts"; -import { type LegacyImagePrepullError } from "./image-prepull.ts"; +import type { LegacyImagePrepullError } from "./image-prepull.ts"; import { legacyWaitForHealthyServices, type LegacyHealthCheckTimeoutError, } from "./health-check.ts"; -import { legacyStartInternalDbPassword } from "./internal-db-connection.ts"; +import type { LegacyEdgeRuntimeScript } from "../legacy-edge-runtime-script.service.ts"; +import type { LegacyPgDeltaSslProbe } from "../legacy-pgdelta-ssl-probe.service.ts"; import { LEGACY_START_STARTING_DATABASE_FROM_BACKUP_MESSAGE, LEGACY_START_STARTING_DATABASE_MESSAGE, } from "./messages.ts"; -import { legacyResolvePinnedImage } from "./pinned-image.ts"; import { legacyBuildPostgresStartContainerSpec, type LegacyPostgresStartServiceInput, @@ -117,48 +113,17 @@ class LegacyStartBackupVolumeExistsError extends Data.TaggedError( /** Every failure {@link legacyStartDatabase} itself can produce, independent of the caller's own `E`. */ export type LegacyStartDatabaseError = - | LegacyStartNetworkCreateError - | LegacyStartVolumeInspectError + | LegacyNetworkCreateError + | LegacyVolumeInspectError | LegacyStartBackupVolumeExistsError - | LegacyStartVolumeCreateError - | LegacyStartContainerCreateError - | LegacyStartContainerStartError + | LegacyVolumeCreateError + | LegacyContainerCreateError + | LegacyContainerStartError | LegacyImagePrepullError | LegacyHealthCheckTimeoutError | LegacyDbConnectError | LegacyStartSetupLocalDatabaseError; -/** - * Everything {@link legacyStartSetupLocalDatabase} needs, minus what `legacyStartDatabase` - * itself already resolves/threads through (`session`, `majorVersion`, `projectId`, - * `networkId`, `images`). Not exported outside this module — callers build this shape as - * the `setup` field of {@link LegacyStartDatabaseInput} without needing to name the type. - */ -interface LegacyStartDatabaseSetupInput { - readonly majorVersion: number; - /** Already spliced with the caller's own realtime/storage/auth enabled-for-setup + ip_version/max_header_length/file_size_limit overrides — see `bootstrap-config.ts`'s `LegacyDbBootstrapConfig`. */ - readonly config: LegacyStartSetupLocalDatabaseInput["config"]; - /** Threaded straight through to {@link LegacyStartSetupLocalDatabaseInput.experimental} — see its own doc comment. */ - readonly experimental: boolean; - readonly dbUrl: string; - readonly jwtSecret: string; - /** Lazy — evaluated only when reached (fresh volume, `fromBackup` unset) AND `realtimeEnabledForSetup`. See this module's header for why this is caller-supplied rather than resolved here unconditionally. */ - readonly jwks: Effect.Effect; - readonly apiUrl: string; - readonly authExternalUrl: string | undefined; - readonly siteUrl: string; - readonly anonKey: string; - readonly serviceRoleKey: string; - readonly storageTargetMigration: string; - readonly realtimeEnabledForSetup: boolean; - readonly storageEnabledForSetup: boolean; - readonly authEnabledForSetup: boolean; - readonly serviceVersionOverrides: LocalServiceVersionOverrides; - readonly projectEnvValues: Readonly> | undefined; - /** `--debug` — threaded straight through to {@link LegacyStartSetupLocalDatabaseInput.debug}; see its own doc comment. */ - readonly debug: boolean; -} - export interface LegacyStartDatabaseInput { readonly fs: FileSystem.FileSystem; readonly path: Path.Path; @@ -169,7 +134,7 @@ export interface LegacyStartDatabaseInput { /** `localDbContainerId(projectId)` — also the connect-target host inside the local Postgres session below. */ readonly dbContainerId: string; readonly dbPort: number; - readonly containerOpts: LegacyStartContainerOpts; + readonly containerOpts: LegacyContainerOpts; /** Fed straight to `legacyBuildPostgresStartContainerSpec` — `fromBackup` (if set) drives BOTH the restore-entrypoint variant and the backup-volume-exists guard below. */ readonly postgresSpec: Omit; /** @@ -181,7 +146,7 @@ export interface LegacyStartDatabaseInput { */ readonly resolvePostgresImage: Effect.Effect; readonly dbHealthTimeoutSeconds: number; - readonly setup: LegacyStartDatabaseSetupInput; + readonly setup: LegacyFreshDbSetupInput; /** * Fired synchronously, exactly once, right after the pre-create volume probe resolves — * the caller's own equivalent of Go's package-level `utils.NoBackupVolume` global, needed by @@ -209,14 +174,11 @@ export const legacyStartDatabase = ( | HttpClient.HttpClient | LegacyEdgeRuntimeScript | LegacyPgDeltaSslProbe - // Widened for `legacyStartSetupLocalDatabase`'s own pgcache-warmup call — see - // its own R type's doc comment (`db-setup.ts`). | FileSystem.FileSystem | Path.Path > => Effect.gen(function* () { const output = yield* Output; - const dbConnection = yield* LegacyDbConnection; // Go's pre-create volume-existence check (`internal/db/start/start.go:165-167`) — MUST run // before Postgres's own volume gets created below, AND before the network is created too: @@ -225,7 +187,7 @@ export const legacyStartDatabase = ( // network behind even for a request the guard below is about to reject outright — Go's own // `VolumeInspect` and the guard both run strictly BEFORE `DockerStart`, which is the ONLY // place Go ever creates the network (`docker.go:363-386`). - const isFreshVolume = !(yield* legacyStartVolumeExists(spawner, input.dbContainerId)); + const isFreshVolume = !(yield* legacyVolumeExists(spawner, input.dbContainerId)); input.onFreshVolumeResolved(isFreshVolume); const fromBackup = input.postgresSpec.fromBackup; @@ -258,9 +220,9 @@ export const legacyStartDatabase = ( // Go's `DockerStart` (`docker.go:363-386`): image resolve, THEN network create, both // strictly ahead of container create — hoisted here to run ONCE per `start` run instead of // once per container (Go's own repeated per-container call is a no-op after the first, see - // `legacyEnsureStartNetwork`'s own doc comment), but kept in Go's own relative position: + // `legacyEnsureNetwork`'s own doc comment), but kept in Go's own relative position: // after the volume probe/guard above, never before it. - yield* legacyEnsureStartNetwork(spawner, input.networkId, { + yield* legacyEnsureNetwork(spawner, input.networkId, { [LEGACY_CLI_PROJECT_LABEL]: input.projectId, [LEGACY_COMPOSE_PROJECT_LABEL]: input.projectId, }); @@ -269,7 +231,7 @@ export const legacyStartDatabase = ( ...input.postgresSpec, image: resolvedPostgresImage, }); - yield* legacyStartContainer(spawner, postgresSpec, input.containerOpts); + yield* legacyCreateContainer(spawner, postgresSpec, input.containerOpts); const postgresHealthResult = yield* legacyWaitForHealthyServices( spawner, @@ -294,83 +256,21 @@ export const legacyStartDatabase = ( // (`start.go:184-188`) — SKIPPED IN FULL when `fromBackup` is set, not merely reduced: no // initSchema/ApplyApiPrivileges/vault/roles.sql/MigrateAndSeed on that path at all. if (isFreshVolume && fromBackup === undefined) { - yield* Effect.scoped( - Effect.gen(function* () { - const { setup } = input; - const dbPassword = legacyStartInternalDbPassword(setup.dbUrl); - const session = yield* dbConnection.connect( - { - host: input.hostname, - port: input.dbPort, - user: "postgres", - password: dbPassword, - database: "postgres", - }, - { isLocal: true, dnsResolver: "native" }, - ); - - // Go's `initSchema15`'s realtime job resolves JWKS itself — see this module's header - // for why this is a caller-supplied lazy `Effect`, gated the same way Go gates the - // call: only when reached AND `majorVersion >= 15` AND `Realtime.Enabled`. Go's - // `initSchema` (`start.go:243-254`) branches to `initSchema15` — the ONLY place - // `ResolveJWKS` is ever called — solely on `majorVersion >= 15`; the PG13/14 branch - // (`InitSchema14`) never touches JWKS, so a PG13/14 database with realtime enabled must - // not pay for (or fail on) an external JWKS fetch it will never use. - const jwks = - setup.majorVersion >= 15 && setup.realtimeEnabledForSetup ? yield* setup.jwks : ""; - - // Go's one-shot fresh-DB setup jobs (`initSchema15`) use the SAME already-pin-rewritten - // `utils.Config.{Realtime,Storage,Auth}.Image` fields the long-running containers would - // use (`internal/db/start/start.go:270,299,321`), regardless of `--exclude` — resolved - // through `legacyResolvePinnedImage`, not the raw Dockerfile default, so a linked - // project's version pins apply here too. Deliberately NOT resolved/pulled here as a - // batch: Go resolves (and pulls) each one-shot job's own image individually, - // sequentially, right before THAT job runs (`DockerRunJob` -> `DockerStart` -> - // `DockerResolveImageIfNotCached`, `start.go:334-355`, `docker.go:363-365`) — neither - // caller pre-pulls these three images as a batch ahead of time (see - // `commands/start/start.handler.ts`'s own `resolvedImages` comment and - // `commands/db/start/start.handler.ts`'s `resolvePostgresImage` comment, both of which - // explicitly exclude these from their own upfront pre-pulls). Batching the resolve here - // instead would mean one unreachable image (e.g. Storage's) fails the WHOLE setup - // before an earlier job (e.g. Realtime's) ever gets to run, even though Go would already - // have run it to completion by the time it reaches Storage's own resolve. - // `legacyRunStartMigrateJob` (`db-setup.ts`) resolves each of these lazily itself, right - // before running that job — see its own doc comment. - const dbSetupImages: LegacyStartDbSetupImages = { - realtime: legacyResolvePinnedImage( - "realtime", - "realtime", - setup.serviceVersionOverrides, - ), - storage: legacyResolvePinnedImage("storage", "storage", setup.serviceVersionOverrides), - auth: legacyResolvePinnedImage("gotrue", "auth", setup.serviceVersionOverrides), - }; - - yield* legacyStartSetupLocalDatabase(spawner, { - session, - fs: input.fs, - path: input.path, - workdir: input.workdir, - config: setup.config, - experimental: setup.experimental, - majorVersion: setup.majorVersion, - projectId: input.projectId, - networkId: input.networkId, - dbUrl: setup.dbUrl, - jwtSecret: setup.jwtSecret, - jwks, - apiUrl: setup.apiUrl, - authExternalUrl: setup.authExternalUrl, - siteUrl: setup.siteUrl, - anonKey: setup.anonKey, - serviceRoleKey: setup.serviceRoleKey, - storageTargetMigration: setup.storageTargetMigration, - images: dbSetupImages, - projectEnvValues: setup.projectEnvValues, - debug: setup.debug, - }); - }), - ); + yield* legacyRunFreshDbSetup(spawner, { + fs: input.fs, + path: input.path, + workdir: input.workdir, + projectId: input.projectId, + networkId: input.networkId, + hostname: input.hostname, + dbPort: input.dbPort, + // Go's own `StartDatabase` -> `SetupLocalDatabase(ctx, "", ...)` call + // (`start.go:185`) — every pending migration, no `db reset`-only seed + // override (`db start` has neither `--no-seed` nor `--sql-paths`). + version: "", + seedFlags: { noSeed: false, sqlPaths: [] }, + setup: input.setup, + }); } // Go's `initCurrentBranch` (`db/start/start.go:189`) — the LAST line of `StartDatabase`, diff --git a/apps/cli/src/legacy/shared/legacy-container-cli.ts b/apps/cli/src/legacy/shared/legacy-container-cli.ts index 7ccfa5790a..8327137bfd 100644 --- a/apps/cli/src/legacy/shared/legacy-container-cli.ts +++ b/apps/cli/src/legacy/shared/legacy-container-cli.ts @@ -50,26 +50,6 @@ export function legacyDescribeContainerCliFailure(cause: unknown): string { return String(cause); } -/** - * Docker's/Podman's "container doesn't exist" stderr shapes for `container inspect` — Docker's - * "No such container"/"No such object" (either casing depending on daemon version/CLI path) or - * Podman's own differently worded "no container with name or ID ... found: no such container" — - * the subprocess equivalent of Go's `errdefs.IsNotFound` for a CLI-shelled-out (rather than - * Engine-API) caller. Case-insensitive and covers all three shapes so a lowercase Podman message - * is tolerated exactly like an uppercase Docker one — the same distinction - * `legacy-docker-image-resolve.ts`'s `isImageNotFoundMessage` draws for `image inspect`. - * Hoisted here (rather than left as separate per-caller copies) so every container-not-found - * check across the container-lifecycle/start/health-check domain shares one predicate instead of - * re-deriving the same match with different Podman coverage. - */ -export function legacyIsContainerNotFoundMessage(message: string): boolean { - return ( - /no such container/iu.test(message) || - /no such object/iu.test(message) || - /no container with name or id/iu.test(message) - ); -} - /** Which of the two supported container CLIs actually answered a spawn. */ export type LegacyContainerRuntime = "docker" | "podman"; @@ -142,7 +122,13 @@ export const containerCliExitCode = ( ), ); -function collectDockerCliText(stream: Stream.Stream) { +/** + * Folds a byte stream into a decoded string. Hoisted here (the shared home for + * container-CLI plumbing) so `container-lifecycle.ts`/`restart-services.ts`/ + * `legacy-docker-lifecycle.ts` — every module that spawns `docker`/`podman` and + * needs its stdout/stderr as text — stop each defining their own copy. + */ +export function collectText(stream: Stream.Stream) { const decoder = new TextDecoder(); return Stream.runFold( stream, @@ -151,6 +137,71 @@ function collectDockerCliText(stream: Stream.Stream) { ).pipe(Effect.map((text) => text + decoder.decode())); } +/** + * Docker's/Podman's "container doesn't exist" stderr shapes for `container inspect` — Docker's + * "No such container"/"No such object" (either casing depending on daemon version/CLI path) or + * Podman's own differently worded "no container with name or ID ... found: no such container" — + * the subprocess equivalent of Go's `errdefs.IsNotFound` for a CLI-shelled-out (rather than + * Engine-API) caller. Case-insensitive and covers all three shapes so a lowercase Podman message + * is tolerated exactly like an uppercase Docker one — the same distinction + * `legacy-docker-image-resolve.ts`'s `isImageNotFoundMessage` draws for `image inspect`, and the + * same distinction the pre-existing Podman-aware parser in `commands/start/start.handler.ts`'s + * own (now-removed) local `isContainerNotFoundMessage` used to draw. Hoisted here (rather than + * left as separate per-caller copies) so every container-not-found check across the + * container-lifecycle/start/restart/health-check domain (`legacyIsLocalDbRunning`, + * `legacyRestartSatelliteService`, `legacyReloadKong`, …) shares one predicate instead of + * re-deriving the same match with different Podman coverage — a reset excluding a satellite + * service (storage/auth/realtime/pooler) or Kong would otherwise report a hard restart/reload + * failure instead of tolerating the absent container. + */ +export function legacyIsContainerNotFoundMessage(message: string): boolean { + return ( + /no such container/iu.test(message) || + /no such object/iu.test(message) || + /no container with name or id/iu.test(message) + ); +} + +/** + * Runs a container-CLI command that must succeed outright — no tolerance for any + * failure mode (spawn failure, non-zero exit) — the shared shape behind every + * "docker verb target" primitive that fails hard on any problem + * (`legacyRemoveContainer`/`legacyRemoveVolume`/`legacyRestartContainer`; see + * `containers/container-lifecycle.ts` and `db-bootstrap/restart-services.ts`). + * `verb` is the human-readable action embedded in the error message (e.g. + * `"remove container"` → `"failed to remove container: "`). + */ +export function runContainerCliExpectSuccess( + spawner: Spawner, + args: ReadonlyArray, + verb: string, + makeError: (message: string) => E, +): Effect.Effect { + return Effect.scoped( + Effect.gen(function* () { + const child = yield* spawnContainerCli(spawner, args, { + stdin: "ignore", + stdout: "ignore", + stderr: "pipe", + }).pipe( + Effect.mapError((cause) => + makeError(`failed to ${verb}: ${legacyDescribeContainerCliFailure(cause)}`), + ), + ); + const [exitCode, stderr] = yield* Effect.all( + [child.exitCode.pipe(Effect.map(Number)), collectText(child.stderr)], + { concurrency: "unbounded" }, + ).pipe(Effect.mapError(() => makeError(`failed to ${verb}`))); + if (exitCode !== 0) { + const message = stderr.trim(); + return yield* Effect.fail( + makeError(message.length > 0 ? `failed to ${verb}: ${message}` : `failed to ${verb}`), + ); + } + }), + ); +} + /** * Like {@link containerCliExitCode}, but also collecting the child's stdout — * for callers that need the CLI's own report of what it did (e.g. the `docker @@ -190,7 +241,7 @@ export const legacyContainerCliExitCodeAndStdout = ( // so a late subscriber would see an already-ended, empty stream (same // pattern as `legacy-docker-lifecycle.ts`'s `spawnDockerPsLines`). const [exitCode, stdout] = yield* Effect.all( - [handle.exitCode.pipe(Effect.map(Number)), collectDockerCliText(handle.stdout)], + [handle.exitCode.pipe(Effect.map(Number)), collectText(handle.stdout)], { concurrency: "unbounded" }, ); return { exitCode, stdout }; @@ -246,7 +297,7 @@ export const legacyDockerSupportsVolumePruneAllFlag = (spawner: Spawner) => }), ); const [exitCode, stdout] = yield* Effect.all( - [child.exitCode.pipe(Effect.map(Number)), collectDockerCliText(child.stdout)], + [child.exitCode.pipe(Effect.map(Number)), collectText(child.stdout)], { concurrency: "unbounded" }, ); if (exitCode !== 0) return false; diff --git a/apps/cli/src/legacy/shared/legacy-docker-ids.ts b/apps/cli/src/legacy/shared/legacy-docker-ids.ts index 83ddbf72fc..1a145feceb 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-ids.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-ids.ts @@ -115,7 +115,7 @@ export const LEGACY_CLI_PROJECT_LABEL = "com.supabase.cli.project"; * TS-port-only Docker label (no Go equivalent — Go never stages secrets on host disk in * the first place, see `legacy-start-secrets-cleanup.ts`'s doc comment) recording the * absolute `LegacyCliConfig.workdir` a container was created under, set on every - * container `start` creates (`container-lifecycle.ts`'s `legacyStartContainer`). + * container `start` creates (`container-lifecycle.ts`'s `legacyCreateContainer`). * * Read back by `legacyListContainerIdsAndNames` (`legacy-docker-lifecycle.ts`) so a later * `stop`/`legacyRollbackStart` can reclaim `legacyCleanupStartSecrets`'s staged-secret diff --git a/apps/cli/src/legacy/shared/legacy-go-duration.ts b/apps/cli/src/legacy/shared/legacy-go-duration.ts index af9485452b..4b5574e108 100644 --- a/apps/cli/src/legacy/shared/legacy-go-duration.ts +++ b/apps/cli/src/legacy/shared/legacy-go-duration.ts @@ -36,14 +36,26 @@ const NS_PER_MS_BIG = 1_000_000n; const NS_PER_US_BIG = 1_000n; // Go's `time.Duration` ceiling (`math.MaxInt64` nanoseconds, ~292.47 years) — `time.ParseDuration` -// rejects any value whose accumulated nanosecond count would exceed this. Go's real max parseable -// duration is `2562047h47m16.854775807s`. +// rejects any POSITIVE value whose accumulated nanosecond count would exceed this. Go's real max +// parseable duration is `2562047h47m16.854775807s`. const MAX_INT64_NS = 9223372036854775807n; +// Go's `time.ParseDuration` (`src/time/format.go`) accumulates into a `uint64`, checking +// `d > 1<<63` (NOT `1<<63-1`, i.e. `MAX_INT64_NS`) both per-term and on the running total, and +// only applies the STRICTER `d > 1<<63-1` check afterwards, and only when the parsed value is NOT +// negated. A magnitude of exactly `1<<63` therefore survives parsing when the input is negative — +// `-Duration(d)` on `d == 1<<63` wraps via `int64` two's-complement into exactly `math.MinInt64`, +// Go's own minimum representable duration (`-2562047h47m16.854775808s`) — but is rejected when the +// input has no sign, since a positive `time.Duration` can never reach `1<<63` itself. Verified +// against the real `time` package (CLI-1961 Codex review finding): `time.ParseDuration( +// "-9223372036854775808ns")` succeeds and returns `math.MinInt64`, while the unsigned form +// `"9223372036854775808ns"` (identical magnitude, no leading `-`) is rejected as an overflow. +const UINT64_ACCUMULATOR_BOUND_NS = 1n << 63n; + /** * Port of Go `time.ParseDuration`. Returns nanoseconds as a number. Accepts * the same grammar Go does: a possibly-signed sequence of decimal numbers, - * each with a unit suffix (`"ns"`, `"us"`/`"µs"`, `"ms"`, `"s"`, `"m"`, `"h"`), + * each with a unit suffix (`"ns"`, `"us"`/`"µs"`/`"μs"`, `"ms"`, `"s"`, `"m"`, `"h"`), * e.g. `"5s"`, `"1h30m"`, `"300ms"`. Throws on invalid input, matching Go's * own `errors.New("time: invalid duration ...")` failure mode — including * overflowing `math.MaxInt64` nanoseconds and a fractional remainder that @@ -116,7 +128,12 @@ export function legacyParseGoDuration(value: string): number { if (s.startsWith("ns")) { unitNs = 1n; s = s.slice(2); - } else if (s.startsWith("us") || s.startsWith("µs")) { + } else if (s.startsWith("us") || s.startsWith("µs") || s.startsWith("μs")) { + // Go's `unitMap` (`time/format.go:1615-1622`) has THREE microsecond spellings: + // "us", "µs" (U+00B5 MICRO SIGN), and "μs" (U+03BC GREEK SMALL LETTER MU) — verified + // directly against the Go standard library: `time.ParseDuration("1μs")` succeeds + // identically to `"1µs"`. The Greek-mu spelling was previously missing here + // (CLI-1961 Codex review finding). unitNs = NS_PER_US_BIG; s = s.slice(2); } else if (s.startsWith("ms")) { @@ -135,15 +152,36 @@ export function legacyParseGoDuration(value: string): number { throw new Error(`time: unknown unit in duration "${orig}"`); } - // Go converts the fractional remainder via `uint64(float64(f) * (float64(unit)/scale))` — - // a float64->uint64 conversion, which truncates toward zero, not rounds: `"0.5ns"` becomes - // `0`, not `1`. `BigInt` division truncates toward zero unconditionally, so - // `(frac * unitNs) / post` matches that exactly, without any intermediate float64 rounding. - total += n * unitNs + (frac * unitNs) / post; - if (total > MAX_INT64_NS) { + // Go converts the fractional remainder via `uint64(float64(f) * (float64(unit)/scale))` + // (`time/format.go`'s `ParseDuration`) — an intermediate float64 MULTIPLICATION, THEN a + // float64->uint64 conversion that truncates toward zero. That intermediate float64 step + // means this is NOT equivalent to an exact BigInt division: once `frac` exceeds float64's + // 53-bit integer precision, rounding `frac` itself up to the nearest representable double + // can push the product past the next integer, so Go's result rounds UP to a full unit where + // an exact-BigInt computation would truncate DOWN — verified against the real `time` + // package (CLI-1961 Codex review finding): `time.ParseDuration("0.999999999999999999s")` + // (18 nines) returns exactly `1_000_000_000` ns, a full second, not the `999_999_999` an + // exact-BigInt truncation produces. Converting `frac`/`unitNs`/`post` to `Number` before + // multiplying reproduces Go's float64 step — and its rounding — bit-for-bit: both Go's + // `float64(uint64)` and JS's `BigInt`->`Number` conversion round to the nearest + // representable double (IEEE 754 round-to-nearest-even), so the same magnitude rounds the + // same way in both languages. `Math.trunc` mirrors the truncating `uint64(...)` conversion + // (both operands here are always non-negative, so truncation and floor coincide). + let term = n * unitNs; + if (frac > 0n) { + term += BigInt(Math.trunc(Number(frac) * (Number(unitNs) / Number(post)))); + } + total += term; + if (total > UINT64_ACCUMULATOR_BOUND_NS) { throw new Error(`time: invalid duration "${orig}"`); } } + // Only a positive result gets the stricter post-loop bound — see + // `UINT64_ACCUMULATOR_BOUND_NS`'s doc comment for why a negative result is allowed to reach + // one nanosecond further (down to exactly `math.MinInt64`). + if (!neg && total > MAX_INT64_NS) { + throw new Error(`time: invalid duration "${orig}"`); + } return Number(neg ? -total : total); } diff --git a/apps/cli/src/legacy/shared/legacy-go-duration.unit.test.ts b/apps/cli/src/legacy/shared/legacy-go-duration.unit.test.ts index 551f5518ad..a9069048f9 100644 --- a/apps/cli/src/legacy/shared/legacy-go-duration.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-go-duration.unit.test.ts @@ -34,13 +34,21 @@ describe("legacyParseGoDuration", () => { // digits, skip the "missing unit" guard (since `s` was still non-empty), // and silently match the unit anyway — returning 0 instead of erroring like // Go's real `time.ParseDuration`. - it.each(["s", "m", "h", "ms", "us", "µs", "ns"])( + it.each(["s", "m", "h", "ms", "us", "µs", "μs", "ns"])( 'rejects a bare unit with no preceding digit ("%s")', (input) => { expect(() => legacyParseGoDuration(input)).toThrow(`time: invalid duration "${input}"`); }, ); + // Go's `unitMap` (`time/format.go:1615-1622`) has THREE microsecond spellings: + // "us", "µs" (U+00B5 MICRO SIGN), and "μs" (U+03BC GREEK SMALL LETTER MU) — verified + // directly against the Go standard library. The Greek-mu spelling was previously + // missing here (CLI-1961 Codex review finding). + it.each(["us", "µs", "μs"])('accepts every microsecond unit spelling ("1%s")', (unit) => { + expect(legacyParseGoDuration(`1${unit}`)).toBe(1_000); + }); + it('rejects a bare unit following a valid unit ("1hs")', () => { expect(() => legacyParseGoDuration("1hs")).toThrow('time: invalid duration "1hs"'); }); @@ -79,6 +87,17 @@ describe("legacyParseGoDuration", () => { expect(legacyParseGoDuration("1.9ns")).toBe(1); }); + // Go converts the fractional remainder through an intermediate float64 multiplication + // (`uint64(float64(f) * (float64(unit)/scale))`) BEFORE truncating to `uint64` — not an + // exact-precision division. Once the fraction has enough digits to exceed float64's 53-bit + // integer precision, rounding the fraction itself up can push the float64 product past the + // next integer, so Go's real result rounds UP to a full second here — verified against the + // real `time` package (CLI-1961 Codex review finding): an exact BigInt division would instead + // truncate DOWN to `999_999_999`. + it('rounds a long fractional remainder up like Go\'s float64 conversion ("0.999999999999999999s")', () => { + expect(legacyParseGoDuration("0.999999999999999999s")).toBe(1_000_000_000); + }); + // Go's `time.Duration` is bounded by `math.MaxInt64` nanoseconds // (~292.47 years); `time.ParseDuration` rejects any value whose // accumulated nanosecond count would exceed it. @@ -103,6 +122,27 @@ describe("legacyParseGoDuration", () => { it("accepts a duration exactly at Go's true math.MaxInt64 ceiling in nanoseconds", () => { expect(() => legacyParseGoDuration("9223372036854775807ns")).not.toThrow(); }); + + // Go's `time.ParseDuration` accumulates into a `uint64` and only rejects `d > 1<<63` + // (NOT `1<<63-1`) during parsing — a magnitude of exactly `1<<63` (one MORE than + // `math.MaxInt64`) survives the loop and, once negated, lands exactly on + // `math.MinInt64`. Verified against the real `time` package (CLI-1961 Codex review + // finding): `time.ParseDuration("-9223372036854775808ns")` succeeds. + it("accepts Go's exact minimum representable negative duration (math.MinInt64 ns)", () => { + expect(legacyParseGoDuration("-9223372036854775808ns")).toBe(-9223372036854775808); + }); + + it("accepts the equivalent hours/minutes/seconds form of math.MinInt64 ns", () => { + expect(legacyParseGoDuration("-2562047h47m16.854775808s")).toBe(-9223372036854775808); + }); + + // One nanosecond further negative than `math.MinInt64` has no valid `int64` + // representation at all — Go rejects this too, not just the positive overflow. + it("rejects a duration 1ns past math.MinInt64", () => { + expect(() => legacyParseGoDuration("-9223372036854775809ns")).toThrow( + 'time: invalid duration "-9223372036854775809ns"', + ); + }); }); describe("legacyFormatGoDuration", () => { diff --git a/apps/cli/src/legacy/shared/legacy-go-json.ts b/apps/cli/src/legacy/shared/legacy-go-json.ts index 4887fa9d3a..e9e169960a 100644 --- a/apps/cli/src/legacy/shared/legacy-go-json.ts +++ b/apps/cli/src/legacy/shared/legacy-go-json.ts @@ -6,8 +6,17 @@ * Unlike `legacy-go-output.encoders.ts`'s `encodeGoJson`, this encoder does NOT * sort object keys — Go serializes structs in field-declaration order, so the * caller builds plain objects whose key insertion order is the Go struct order - * (JS preserves string-key insertion order). `omitempty` is likewise the - * caller's responsibility: simply omit the key. + * (JS preserves string-key insertion order, EXCEPT for integer-like keys — see + * the `Map` handling below). `omitempty` is likewise the caller's responsibility: + * simply omit the key. + * + * A caller that needs Go's true lexicographic map-key order (e.g. + * `legacy-go-output.encoders.ts`'s `sortKeysDeep`, for a genuine Go map like + * `jwt.MapClaims`) must pass a `Map` rather than a plain object at + * that level: a plain object silently reorders integer-like string keys ("2", "10") + * into ascending NUMERIC order on enumeration, regardless of insertion order, which + * would undo a lexicographic sort for any numeric-looking key. `Map` iteration order + * is true insertion order for every key shape, so this walker special-cases it. * * The two behaviours `JSON.stringify(x, null, 2)` gets wrong for Go parity are: * 1. HTML escaping — Go's default encoder escapes `<`, `>`, `&` as @@ -78,8 +87,18 @@ function walk(value: unknown, depth: number, pretty: boolean): string { case "number": // Finite numbers from JSON parsing render identically to Go for the // integer and ordinary-float cases relevant here; defer to JSON.stringify - // for the canonical shortest representation. - return Number.isFinite(value) ? JSON.stringify(value) : "null"; + // for the canonical shortest representation — EXCEPT negative zero, which + // `JSON.stringify(-0)` collapses to `"0"` (ECMA-262's `Number::toString` + // prints no sign for negative zero) while Go's `encoding/json` marshals a + // `float64` negative zero as `-0`. Reachable via `gen bearer-jwt`'s + // `--payload '{"extra":-0}'` (or an underflowing literal like `-1e-10000`): + // `json.Unmarshal` into `jwt.MapClaims` (a real `map[string]any`) decodes + // the number as `float64(-0)`, and the signed payload segment carries that + // sign through to `-0` — verified against the real binary (CLI-1961 Codex + // review finding): the compiled Go CLI's signed token payload literally + // contains `"extra":-0`. Special-case it so the signed bytes match. + if (!Number.isFinite(value)) return "null"; + return Object.is(value, -0) ? "-0" : JSON.stringify(value); case "boolean": return value ? "true" : "false"; } @@ -93,7 +112,18 @@ function walk(value: unknown, depth: number, pretty: boolean): string { const items = value.map((item) => indent + walk(item, depth + 1, pretty)); return `[${open}${items.join(separator)}${close}${closeIndent}]`; } - const entries = Object.entries(value as Record); + // A plain object silently reorders integer-like string keys ("2", "10") into ascending + // NUMERIC order on any enumeration (`Object.keys`/`Object.entries`), regardless of insertion + // order (ECMA-262 `OrdinaryOwnPropertyKeys`) — Go's `encoding/json` has no such special case, + // so a real Go map's string keys sort purely lexicographically (`"10"` before `"2"`). Callers + // that need that exact order (e.g. `legacy-go-output.encoders.ts`'s `sortKeysDeep`) pass a + // `Map` instead of a plain object specifically to carry the sort through intact — `Map` + // iteration order is true insertion order for every key shape, unlike a plain object + // (CLI-1961 Codex review finding: `{"10":"a","2":"b"}` must stay "10" before "2"). + const entries = + value instanceof Map + ? [...(value as Map).entries()] + : Object.entries(value as Record); if (entries.length === 0) return "{}"; const colon = pretty ? ": " : ":"; const lines = entries.map( @@ -119,3 +149,24 @@ export function encodeGoJsonIndented(value: unknown): string { export function encodeGoJsonCompact(value: unknown): string { return walk(value, 0, false); } + +/** + * Go's `encoding/json` type names for the JSON-representable kinds `json.Unmarshal` + * rejects. Shared by every legacy command that reproduces Go's exact `"json: cannot + * unmarshal into Go value of type "` wording against its own target + * type — `gen bearer-jwt`'s `jwt.MapClaims` (`bearer-jwt.claims.ts`) and `config.JWK` + * (`bearer-jwt.signing-key.ts`) are today's two callers. + */ +export function legacyGoJsonKindName(value: unknown): string { + if (Array.isArray(value)) return "array"; + switch (typeof value) { + case "number": + return "number"; + case "string": + return "string"; + case "boolean": + return "bool"; + default: + return "value"; + } +} diff --git a/apps/cli/src/legacy/shared/legacy-go-json.unit.test.ts b/apps/cli/src/legacy/shared/legacy-go-json.unit.test.ts index c5d39eae8d..b16b802795 100644 --- a/apps/cli/src/legacy/shared/legacy-go-json.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-go-json.unit.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "vitest"; -import { encodeGoJsonCompact, encodeGoJsonIndented, escapeGoJsonString } from "./legacy-go-json.ts"; +import { + encodeGoJsonCompact, + encodeGoJsonIndented, + escapeGoJsonString, + legacyGoJsonKindName, +} from "./legacy-go-json.ts"; describe("escapeGoJsonString", () => { it("escapes quotes and backslashes like Go", () => { @@ -70,4 +75,40 @@ describe("encodeGoJsonCompact", () => { expect(encodeGoJsonCompact([])).toBe("[]"); expect(encodeGoJsonCompact(null)).toBe("null"); }); + + // `JSON.stringify(-0)` collapses to `"0"` (ECMA-262 prints no sign for negative + // zero), but Go's `encoding/json` marshals a `float64` negative zero as `-0` — + // reachable through `gen bearer-jwt --payload`'s `json.Unmarshal` into a real Go + // map. Verified against the real binary (CLI-1961 Codex review finding): the + // compiled Go CLI's signed token payload for `--payload '{"extra":-0}'` literally + // contains `"extra":-0`. + it("preserves negative zero's sign, unlike plain JSON.stringify", () => { + expect(encodeGoJsonCompact({ extra: -0 })).toBe('{"extra":-0}'); + expect(encodeGoJsonCompact({ extra: Number("-1e-10000") })).toBe('{"extra":-0}'); + expect(encodeGoJsonCompact({ extra: 0 })).toBe('{"extra":0}'); + }); + + it("iterates a Map in true insertion order, unlike a plain object with integer-like keys", () => { + // A plain object always reorders integer-like string keys ("2", "10") into ascending + // NUMERIC order on enumeration, regardless of insertion order — a `Map` does not, which + // is exactly why `legacy-go-output.encoders.ts`'s `sortKeysDeep` builds one to carry a + // lexicographic sort through to this walker intact (CLI-1961 Codex review finding). + const map = new Map([ + ["10", "a"], + ["2", "b"], + ]); + expect(encodeGoJsonCompact(map)).toBe('{"10":"a","2":"b"}'); + }); +}); + +describe("legacyGoJsonKindName", () => { + it("names every JSON-representable kind, including the generic fallback", () => { + expect(legacyGoJsonKindName([])).toBe("array"); + expect(legacyGoJsonKindName(1)).toBe("number"); + expect(legacyGoJsonKindName("s")).toBe("string"); + expect(legacyGoJsonKindName(true)).toBe("bool"); + // Never reachable from real JSON.parse output (every call site already excludes + // null/array/object before calling this) — exercised directly for completeness. + expect(legacyGoJsonKindName(undefined)).toBe("value"); + }); }); diff --git a/apps/cli/src/legacy/shared/legacy-go-jwt.ts b/apps/cli/src/legacy/shared/legacy-go-jwt.ts index 96d68e640a..6a2b90a5a9 100644 --- a/apps/cli/src/legacy/shared/legacy-go-jwt.ts +++ b/apps/cli/src/legacy/shared/legacy-go-jwt.ts @@ -1,4 +1,5 @@ import { createHmac, createPrivateKey, createSign } from "node:crypto"; +import { encodeGoJsonCompact } from "./legacy-go-json.ts"; /** * RFC 7517 JWK fields Go's `JWK` struct round-trips (`pkg/config/auth.go:88-108`, @@ -159,61 +160,176 @@ function ensureRsaCrtParams(jwk: LegacyJwk): LegacyJwk { }; } +type LegacySupportedJwtAlgorithm = "RS256" | "ES256"; + +/** + * Go's `config.Algorithm.UnmarshalText` (`apps/cli-go/pkg/config/auth.go:80-86`) — + * `encoding/json` calls this automatically whenever a JWK's `alg` field decodes from + * a JSON STRING (the only shape a pasted stdin JWK or a `signing_keys_path` file ever + * provides), rejecting anything other than `RS256`/`ES256` at JSON-DECODE time — well + * BEFORE the JWK ever reaches signing. An absent `alg` never reaches this check at + * all (`encoding/json` only calls `UnmarshalText` for a key present in the source + * JSON; a missing key just leaves the struct field at its zero value), so that case + * is caught later, at SIGN time, by {@link legacySignJwtWithJwk}'s own `unsupported + * algorithm: ` check instead. Throws Go's own bare `UnmarshalText` error text + * unwrapped; callers apply their own decode-context wrapping (`"failed to parse + * JWK: %w"` for a pasted stdin JWK, `"failed to decode signing keys: failed to parse + * response body: %w"` for a `signing_keys_path` file — see `fetcher.ParseJSON`, + * `apps/cli-go/pkg/fetcher/http.go:144-151`). + */ +export function legacyAssertDecodableJwkAlgorithm(alg: string | undefined): void { + if (alg !== undefined && alg !== "RS256" && alg !== "ES256") { + throw new Error("must be one of [RS256 ES256]"); + } +} + +/** + * Go's `jwkToPrivateKey` (`apps/cli-go/pkg/config/apikeys.go:120-129`): validates + * `jwk.kty`/`jwk.crv` ONLY — it has no awareness of `jwk.alg` at all. Throws Go's + * own unwrapped message text; the caller ({@link legacySignJwtWithJwk}) applies + * `GenerateAsymmetricJWT`'s `"failed to convert JWK to private key: %w"` wrapper + * (`apikeys.go:91-94`) on top. + */ +function assertSupportedKty(jwk: LegacyJwk): void { + if (jwk.kty === "EC") { + if (jwk.crv !== "P-256") { + throw new Error(`unsupported curve: ${jwk.crv ?? ""}`); + } + return; + } + if (jwk.kty !== "RSA") { + throw new Error(`unsupported key type: ${jwk.kty ?? ""}`); + } +} + /** - * Go's `GenerateAsymmetricJWT` (`pkg/config/apikeys.go:88-113`), reached from - * `generateJWT` only when `auth.signing_keys_path` resolves to a non-empty JWK - * array (`pkg/config/apikeys.go:76-80`) — the first key in the file signs both - * the anon and service_role tokens. Same claim shape as {@link legacyGenerateGoJwt} - * (`iss`/`role`/`exp`), except the expiry is 10 years from now rather than Go's - * fixed HMAC-path timestamp, since `generateJWT` sets `claims.ExpiresAt` - * explicitly before calling this function instead of falling through to - * `CustomClaims.NewToken()`'s fixed default. + * Go's `jwkToECDSAPrivateKey`/`jwkToRSAPrivateKey` (`apps/cli-go/pkg/config/apikeys.go:132-185`) + * decode every numeric field with `base64.RawURLEncoding.DecodeString` immediately after the + * kty/curve check above — and that decoder genuinely REJECTS `=`-padded input (`RawURLEncoding` + * has no pad character at all), unlike Node's own JWK importer + * (`createPrivateKey({format:"jwk"})`), which happily accepts a padded coordinate and signs a + * token Go would have refused to produce (verified empirically: Go's decoder raises + * `illegal base64 data at input byte 43` for a padded 32-byte P-256 coordinate; Node's importer + * raises nothing at all and returns a usable key) — CLI-1961 Codex review finding. * - * Only `RS256`/`ES256` are supported, matching Go's `jwkToPrivateKey` - * (RSA/EC key types) + this function's own switch on `jwk.alg`. `kty`/`alg` - * are cross-validated (RS256 requires `kty: "RSA"`, ES256 requires - * `kty: "EC"` and `crv: "P-256"`) — matching Go's `jwkToRSAPrivateKey` / - * `jwkToECDSAPrivateKey`, which reject any other combination rather than - * signing with a mismatched key or curve (Node's `createPrivateKey`/`createSign` - * do not themselves catch this: an EC key signed as RS256, or a non-P-256 - * curve signed as ES256, both "succeed" and produce a spec-invalid token that - * silently fails verification instead of raising an error). The header key + * Runs in Go's exact per-field order (EC: x, y, d; RSA: n, e, d, p, q) so the FIRST invalid field + * matches Go's own first-failure-wins decode order. Reproduces + * `encoding/base64`'s `CorruptInputError` text exactly: the reported byte offset is the index of + * the first character outside the `RawURLEncoding` alphabet (`A-Za-z0-9-_` — a padding `=` is + * such a character, since this encoding has no pad character to special-case), or + * `value.length - 1` for an otherwise-valid string whose length is impossible for base64 + * (`length % 4 === 1`) — both verified directly against the Go standard library's + * `decodeQuantum`. An absent field is Go's own zero value (`""`), which decodes cleanly to zero + * bytes, so `undefined` is skipped here rather than treated as invalid. + */ +function assertDecodableJwkNumericFields(jwk: LegacyJwk): void { + const assertField = (label: string, value: string | undefined): void => { + if (value === undefined) return; + for (let i = 0; i < value.length; i++) { + if (!/^[A-Za-z0-9_-]$/.test(value[i]!)) { + throw new Error(`failed to decode ${label}: illegal base64 data at input byte ${i}`); + } + } + if (value.length % 4 === 1) { + throw new Error( + `failed to decode ${label}: illegal base64 data at input byte ${value.length - 1}`, + ); + } + }; + if (jwk.kty === "EC") { + assertField("x coordinate", jwk.x); + assertField("y coordinate", jwk.y); + assertField("private key", jwk.d); + return; + } + assertField("modulus", jwk.n); + assertField("exponent", jwk.e); + assertField("private exponent", jwk.d); + assertField("first prime factor", jwk.p); + assertField("second prime factor", jwk.q); +} + +/** + * Go has NO explicit cross-check between `jwk.Algorithm` and `jwk.KeyType` before + * signing: `jwkToPrivateKey` only validates kty/curve (see {@link assertSupportedKty}), + * and `GenerateAsymmetricJWT`'s algorithm switch only validates `jwk.Algorithm` + * itself. A mismatched pair (e.g. `kty: "RSA"` signed as `ES256`) reaches + * `token.SignedString(privateKey)` and fails INSIDE golang-jwt's own signing + * method, which type-asserts the key (jwt/v5@v5.3.1 `rsa.go:76` / `ecdsa.go:99`): + * `"key is of invalid type: "`, wrapped by `apikeys.go:113` into + * `"failed to sign JWT: %w"`. Node's own `createSign(...).sign(privateKey)` would + * also fail on this mismatch, but with an OpenSSL-level message that does not + * match Go's text — so this reproduces Go's OBSERVABLE error deliberately, ahead + * of ever touching Node's signer. + */ +function assertKeyMatchesAlgorithm(jwk: LegacyJwk, algorithm: LegacySupportedJwtAlgorithm): void { + if (algorithm === "RS256" && jwk.kty !== "RSA") { + throw new Error("key is of invalid type: RSA sign expects *rsa.PrivateKey"); + } + if (algorithm === "ES256" && jwk.kty !== "EC") { + throw new Error("key is of invalid type: ECDSA sign expects *ecdsa.PrivateKey"); + } +} + +/** + * Go's `GenerateAsymmetricJWT` (`pkg/config/apikeys.go:88-113`): signs an + * already-encoded JSON claims payload with a JWK private key. Callers own their + * own claims shape/serialization (struct-field order for + * {@link legacyGenerateAsymmetricGoJwt}'s fixed anon/service_role claims, Go + * map-key alphabetical order for `gen bearer-jwt`'s `jwt.MapClaims`-shaped + * claims) — this function only handles the parts Go's `GenerateAsymmetricJWT` + * itself handles: key validation, header construction, and signing. + * + * Validation order matches Go exactly: kty/curve first (wrapped + * `"failed to convert JWK to private key: %w"`, {@link assertSupportedKty}), + * then the algorithm switch (unwrapped `"unsupported algorithm: %s"`), then the + * kty-vs-alg mismatch Go's OWN signing method raises (wrapped + * `"failed to sign JWT: %w"`, {@link assertKeyMatchesAlgorithm}). The header key * order (`alg`, `kid`, `typ`) matches Go's `encoding/json` alphabetically - * sorting `map[string]interface{}` keys — `kid` is only present when set on - * the JWK, matching Go's `if len(jwk.KeyID) > 0` guard. + * sorting `map[string]interface{}` keys — `kid` is only present when set on the + * JWK, matching Go's `if len(jwk.KeyID) > 0` guard. * * `dsaEncoding: "ieee-p1363"` is required for ES256: Node's default ECDSA * signature output is DER-encoded, which is not the raw (r‖s) format JWS * requires — verified by round-tripping through `jose`'s `jwtVerify`. + * + * The header is serialized with {@link encodeGoJsonCompact}, NOT `JSON.stringify` — Go's + * `token.SignedString` marshals the header via `encoding/json`'s default `json.Marshal` + * (`golang-jwt/jwt/v5`'s `Token.SigningString`), which HTML-escapes `<`/`>`/`&` (verified + * directly against the Go standard library: `json.Marshal` of a `kid` containing those + * characters produces `<`/`>`/`&`, where `JSON.stringify` leaves them literal) — + * a `kid` with any of those characters would otherwise sign different header bytes (and thus a + * different signature) than Go for identical input (CLI-1961 Codex review finding). */ -export function legacyGenerateAsymmetricGoJwt( - jwk: LegacyJwk, - role: "anon" | "service_role", -): string { +export function legacySignJwtWithJwk(jwk: LegacyJwk, payloadJson: string): string { + try { + assertSupportedKty(jwk); + assertDecodableJwkNumericFields(jwk); + } catch (cause) { + throw new Error( + `failed to convert JWK to private key: ${cause instanceof Error ? cause.message : String(cause)}`, + ); + } + const algorithm = jwk.alg; if (algorithm !== "RS256" && algorithm !== "ES256") { throw new Error(`unsupported algorithm: ${algorithm ?? ""}`); } - if (algorithm === "RS256" && jwk.kty !== "RSA") { - throw new Error(`unsupported key type: ${jwk.kty}`); - } - if (algorithm === "ES256") { - if (jwk.kty !== "EC") { - throw new Error(`unsupported key type: ${jwk.kty}`); - } - if (jwk.crv !== "P-256") { - throw new Error(`unsupported curve: ${jwk.crv ?? ""}`); - } + + try { + assertKeyMatchesAlgorithm(jwk, algorithm); + } catch (cause) { + throw new Error( + `failed to sign JWT: ${cause instanceof Error ? cause.message : String(cause)}`, + ); } + const header = jwk.kid !== undefined && jwk.kid.length > 0 ? { alg: algorithm, kid: jwk.kid, typ: "JWT" } : { alg: algorithm, typ: "JWT" }; - const expiresAt = Math.floor(Date.now() / 1000) + GO_JWT_ASYMMETRIC_EXPIRY_SECONDS; - const headerEncoded = base64UrlEncode(JSON.stringify(header)); - const payloadEncoded = base64UrlEncode( - JSON.stringify({ iss: GO_JWT_ISSUER, role, exp: expiresAt }), - ); + const headerEncoded = base64UrlEncode(encodeGoJsonCompact(header)); + const payloadEncoded = base64UrlEncode(payloadJson); const data = `${headerEncoded}.${payloadEncoded}`; const privateKey = createPrivateKey({ @@ -230,3 +346,24 @@ export function legacyGenerateAsymmetricGoJwt( return `${data}.${signature.toString("base64url")}`; } + +/** + * Go's `(a auth) generateJWT` asymmetric branch (`pkg/config/apikeys.go:76-80`), + * reached only when `auth.signing_keys_path` resolves to a non-empty JWK array — + * the first key in the file signs both the anon and service_role tokens. Same + * claim shape as {@link legacyGenerateGoJwt} (`iss`/`role`/`exp`), except the + * expiry is 10 years from now rather than Go's fixed HMAC-path timestamp, since + * `generateJWT` sets `claims.ExpiresAt` explicitly before calling + * `GenerateAsymmetricJWT` with a `CustomClaims` STRUCT value (not a map) — + * `encoding/json` serializes a struct in field-DECLARATION order, so this + * builds the payload with a plain (insertion-order) `JSON.stringify`, unlike + * `gen bearer-jwt`'s claims (always a real `jwt.MapClaims`, alphabetically + * key-sorted — see `bearer-jwt.claims.ts`). + */ +export function legacyGenerateAsymmetricGoJwt( + jwk: LegacyJwk, + role: "anon" | "service_role", +): string { + const expiresAt = Math.floor(Date.now() / 1000) + GO_JWT_ASYMMETRIC_EXPIRY_SECONDS; + return legacySignJwtWithJwk(jwk, JSON.stringify({ iss: GO_JWT_ISSUER, role, exp: expiresAt })); +} diff --git a/apps/cli/src/legacy/shared/legacy-go-jwt.unit.test.ts b/apps/cli/src/legacy/shared/legacy-go-jwt.unit.test.ts index 5bfeabfe70..c1f7048e3f 100644 --- a/apps/cli/src/legacy/shared/legacy-go-jwt.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-go-jwt.unit.test.ts @@ -3,8 +3,10 @@ import { importJWK, jwtVerify } from "jose"; import { describe, expect, it } from "vitest"; import { + legacyAssertDecodableJwkAlgorithm, legacyGenerateAsymmetricGoJwt, legacyGenerateGoJwt, + legacySignJwtWithJwk, type LegacyJwk, } from "./legacy-go-jwt.ts"; @@ -153,27 +155,125 @@ describe("legacyGenerateAsymmetricGoJwt", () => { ); }); + // Go has NO explicit kty<->alg cross-check (verified against the real binary, + // CLI-1961): `jwkToPrivateKey` only validates kty/curve, and the algorithm + // switch only validates `jwk.Algorithm` — a mismatched pair reaches + // `token.SignedString(privateKey)` and fails INSIDE golang-jwt's own signing + // method with "key is of invalid type: ...", wrapped as "failed to sign + // JWT: %w" (`apikeys.go:113`). These two tests previously asserted an + // "unsupported key type" message that Go never actually produces for this + // input — corrected here. it("rejects an EC key forged with alg: RS256 instead of signing garbage", () => { const jwk = { ...generateEcJwk(), alg: "RS256" }; - expect(() => legacyGenerateAsymmetricGoJwt(jwk, "anon")).toThrow("unsupported key type: EC"); + expect(() => legacyGenerateAsymmetricGoJwt(jwk, "anon")).toThrow( + "failed to sign JWT: key is of invalid type: RSA sign expects *rsa.PrivateKey", + ); }); it("rejects an RSA key forged with alg: ES256 instead of signing garbage", () => { const jwk = { ...generateRsaJwk(), alg: "ES256" }; - expect(() => legacyGenerateAsymmetricGoJwt(jwk, "anon")).toThrow("unsupported key type: RSA"); + expect(() => legacyGenerateAsymmetricGoJwt(jwk, "anon")).toThrow( + "failed to sign JWT: key is of invalid type: ECDSA sign expects *ecdsa.PrivateKey", + ); }); - it("rejects an ES256 EC key whose curve is not P-256", () => { + it("rejects an ES256 EC key whose curve is not P-256, wrapped like Go's GenerateAsymmetricJWT", () => { const { privateKey } = generateKeyPairSync("ec", { namedCurve: "P-384" }); const jwk = { ...privateKey.export({ format: "jwk" }), kty: "EC", alg: "ES256" }; - expect(() => legacyGenerateAsymmetricGoJwt(jwk, "anon")).toThrow("unsupported curve: P-384"); + expect(() => legacyGenerateAsymmetricGoJwt(jwk, "anon")).toThrow( + "failed to convert JWK to private key: unsupported curve: P-384", + ); + }); + + it("rejects a JWK with no kty at all, wrapped like Go's GenerateAsymmetricJWT", () => { + // `bearerjwt_test.go`'s "throws error on unsupported kty" fixture uses exactly + // this shape (`{"kty": "oct"}`, no `alg`) — kty is checked before alg regardless. + const jwk = { kty: "oct" } as LegacyJwk; + expect(() => legacyGenerateAsymmetricGoJwt(jwk, "anon")).toThrow( + "failed to convert JWK to private key: unsupported key type: oct", + ); }); it("rejects an ES256 EC key with no curve at all", () => { const jwk = generateEcJwk(); const { crv: _crv, ...jwkWithoutCurve } = jwk; expect(() => legacyGenerateAsymmetricGoJwt(jwkWithoutCurve, "anon")).toThrow( - "unsupported curve: ", + "failed to convert JWK to private key: unsupported curve: ", + ); + }); + + it("rejects a padded EC coordinate instead of signing a token Go would refuse to produce (CLI-1961 Codex review finding)", () => { + // Go's `jwkToECDSAPrivateKey` decodes x/y/d with `base64.RawURLEncoding.DecodeString`, + // which genuinely rejects `=` padding — verified directly against the real binary: + // the exact same padded x coordinate produces + // "failed to convert JWK to private key: failed to decode x coordinate: illegal base64 + // data at input byte 43". Node's own `createPrivateKey({format:"jwk"})` accepts the + // padding and would otherwise sign successfully, minting a token Go could never produce. + const jwk = generateEcJwk("ec-kid"); + const padded = { ...jwk, x: `${jwk.x}=` }; + expect(() => legacyGenerateAsymmetricGoJwt(padded, "anon")).toThrow( + /^failed to convert JWK to private key: failed to decode x coordinate: illegal base64 data at input byte \d+$/, + ); + }); + + it("rejects a padded RSA modulus the same way", () => { + const jwk = generateRsaJwk("rsa-kid"); + const padded = { ...jwk, n: `${jwk.n}=` }; + expect(() => legacyGenerateAsymmetricGoJwt(padded, "anon")).toThrow( + /^failed to convert JWK to private key: failed to decode modulus: illegal base64 data at input byte \d+$/, + ); + }); + + it("still signs successfully for unpadded (correctly-encoded) coordinates", () => { + const jwk = generateEcJwk("ec-kid"); + expect(() => legacyGenerateAsymmetricGoJwt(jwk, "anon")).not.toThrow(); + }); +}); + +describe("legacySignJwtWithJwk", () => { + it("signs the caller's exact pre-encoded payload string verbatim (no re-serialization)", async () => { + const jwk = generateEcJwk("ec-kid"); + // Deliberately NOT alphabetically sorted and containing characters Go's + // `encoding/json` would HTML-escape (`&`) — this function must sign exactly + // the bytes it's given, leaving ordering/escaping decisions to the caller. + const payloadJson = '{"role":"postgres","sb-role":"mgmt-api & co"}'; + const token = legacySignJwtWithJwk(jwk, payloadJson); + const [, payload] = token.split("."); + expect(decodeSegment(payload ?? "")).toBe(payloadJson); + + const publicKey = await importJWK(publicJwkOf(jwk), "ES256"); + const { payload: verified } = await jwtVerify(token, publicKey); + expect(verified).toEqual({ role: "postgres", "sb-role": "mgmt-api & co" }); + }); + + it("HTML-escapes the kid in the header like Go's json.Marshal, unlike JSON.stringify (CLI-1961 Codex review finding)", () => { + // Go's `token.SignedString` marshals the protected header via `encoding/json`'s + // default `json.Marshal`, which HTML-escapes `<`/`>`/`&` — verified directly against + // the Go standard library. A plain `JSON.stringify` leaves those characters literal, + // which would sign different header bytes (and thus a different signature) than Go + // for an otherwise-identical kid. + const jwk = generateEcJwk("ac&d"); + const token = legacySignJwtWithJwk(jwk, '{"role":"anon"}'); + const [header] = token.split("."); + expect(decodeSegment(header ?? "")).toBe( + '{"alg":"ES256","kid":"a\\u003cb\\u003ec\\u0026d","typ":"JWT"}', + ); + }); +}); + +describe("legacyAssertDecodableJwkAlgorithm", () => { + it("accepts RS256 and ES256", () => { + expect(() => legacyAssertDecodableJwkAlgorithm("RS256")).not.toThrow(); + expect(() => legacyAssertDecodableJwkAlgorithm("ES256")).not.toThrow(); + }); + + it("accepts an absent alg (validated later, at sign time, not at decode time)", () => { + expect(() => legacyAssertDecodableJwkAlgorithm(undefined)).not.toThrow(); + }); + + it("rejects an unsupported algorithm with Go's exact UnmarshalText message", () => { + expect(() => legacyAssertDecodableJwkAlgorithm("HS256")).toThrow( + "must be one of [RS256 ES256]", ); }); }); diff --git a/apps/cli/src/legacy/shared/legacy-go-output.encoders.ts b/apps/cli/src/legacy/shared/legacy-go-output.encoders.ts index 423cee70fa..d5ce060236 100644 --- a/apps/cli/src/legacy/shared/legacy-go-output.encoders.ts +++ b/apps/cli/src/legacy/shared/legacy-go-output.encoders.ts @@ -2,6 +2,7 @@ import { stringify as stringifyToml } from "smol-toml"; import { stringify as stringifyYaml } from "yaml"; import { encodeGoJsonCompact, encodeGoJsonIndented } from "./legacy-go-json.ts"; +import { goStringCompare } from "./legacy-go-struct-output.encoders.ts"; /** * Reproduces Go's `json.Encoder` output (`utils.EncodeOutput` with `-o json`): @@ -46,13 +47,32 @@ export function encodeGoJson( function sortKeysDeep(value: unknown): unknown { if (Array.isArray(value)) return value.map(sortKeysDeep); if (value === null || typeof value !== "object") return value; - const sorted: Record = {}; - for (const key of Object.keys(value as Record).sort()) { + // A plain object silently reorders integer-like string keys ("2", "10") into ascending + // NUMERIC order on any subsequent enumeration (`Object.keys`/`Object.entries` in + // `legacy-go-json.ts`'s `walk`), regardless of what order they're inserted in here — Go's + // `encoding/json` has no such special case: a real Go map's string keys sort purely + // lexicographically ("10" before "2"). Building a `Map` instead of a plain object carries + // this sort through to `walk` intact, since `Map` iteration order is true insertion order + // for every key shape (CLI-1961 Codex review finding: `{"10":"a","2":"b"}` must stay "10" + // before "2" all the way through to the final encoded output). + const sorted = new Map(); + // `.sort()` with no comparator uses JS default string comparison, which orders by + // UTF-16 code unit — NOT the same as Go's byte/code-point order once an astral + // character (U+10000+, a surrogate PAIR in UTF-16) meets a high-BMP one (U+E000- + // U+FFFF, a single code unit numerically ABOVE the astral character's leading + // surrogate). `goStringCompare` (hoisted from `legacy-go-struct-output.encoders.ts`, + // which already needed it for TOML/struct-map key sorting) reproduces Go's real + // `encoding/json` map-key order instead (CLI-1961 Codex review finding: verified + // against the real binary that `json.Marshal` of a map keyed by U+E000 and U+10000 + // emits the U+E000 key first — the reverse of plain JS `.sort()` on those two keys — + // which matters here because `gen bearer-jwt`'s `--payload` custom claims flow + // through this same `sortKeysDeep` via `encodeGoStructJsonBody` before signing). + for (const key of Object.keys(value as Record).sort(goStringCompare)) { const child = (value as Record)[key]; // JSON.stringify used to drop undefined properties; the Go-faithful walker // renders them as null, so drop them here to keep the old key surface. if (child === undefined) continue; - sorted[key] = sortKeysDeep(child); + sorted.set(key, sortKeysDeep(child)); } return sorted; } diff --git a/apps/cli/src/legacy/shared/legacy-go-output.encoders.unit.test.ts b/apps/cli/src/legacy/shared/legacy-go-output.encoders.unit.test.ts index 9b245558ea..112661110d 100644 --- a/apps/cli/src/legacy/shared/legacy-go-output.encoders.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-go-output.encoders.unit.test.ts @@ -101,6 +101,36 @@ describe("encodeGoJson", () => { `, ); }); + + it("keeps Go's true lexicographic order for numeric-looking keys (CLI-1961 Codex review finding)", () => { + // A plain JS object always reorders integer-like string keys ("2", "10") into + // ascending NUMERIC order on enumeration, regardless of insertion order — Go's + // `encoding/json` has no such special case for a real Go map, so "10" sorts before + // "2" lexicographically. `sortKeysDeep` must build a `Map` (not a plain object) to + // carry that sort through to the final encoded output intact. + const out = encodeGoJson({ 10: "a", 2: "b", role: "anon" }); + expect(out).toBe( + `{ + "10": "a", + "2": "b", + "role": "anon" +} +`, + ); + }); + + it("sorts keys by Go's byte/code-point order, not JS's UTF-16 code-unit order (CLI-1961 Codex review finding)", () => { + // U+E000 is a single UTF-16 code unit (0xE000); U+10000 is a surrogate PAIR whose + // leading unit (0xD800) is numerically SMALLER than 0xE000 — so plain JS `.sort()` + // (which compares UTF-16 code units) puts the astral key FIRST, while Go's real + // `encoding/json` (byte/code-point order) puts the high-BMP key first instead. + // Verified against the real binary: `json.Marshal` of a Go map keyed by these two + // strings emits the U+E000 key first. + const highBmp = String.fromCodePoint(0xe000); + const astral = String.fromCodePoint(0x10000); + const out = encodeGoJson({ [astral]: 2, [highBmp]: 1 }); + expect(out).toBe(`{\n "${highBmp}": 1,\n "${astral}": 2\n}\n`); + }); }); describe("encodeYaml", () => { diff --git a/apps/cli/src/legacy/shared/legacy-go-struct-output.encoders.ts b/apps/cli/src/legacy/shared/legacy-go-struct-output.encoders.ts index 04c179fcd3..95402530bf 100644 --- a/apps/cli/src/legacy/shared/legacy-go-struct-output.encoders.ts +++ b/apps/cli/src/legacy/shared/legacy-go-struct-output.encoders.ts @@ -504,8 +504,18 @@ function yamlMapEntries( * `keyList.Less` compares runes — both equal Unicode code-point order, which * differs from JS `<` (UTF-16 code-unit order) when an astral character meets * a high-BMP one (e.g. Go sorts U+E000 before U+1F600, UTF-16 the reverse). + * + * Exported for `legacy-go-output.encoders.ts`'s `sortKeysDeep` - + * `encoding/json`'s map-key sort is the exact same Go byte/code-point + * order, so `gen bearer-jwt`'s `--payload` custom-claims object (and every + * other `encodeGoJson`/`encodeGoStructJsonBody` caller) needs this same + * comparator instead of a second, divergent copy (CLI-1961 Codex review + * finding: verified against the real binary that `json.Marshal` of a map + * with a U+E000 key and a U+10000 key emits the U+E000 key FIRST, while + * plain JS `Object.keys(...).sort()` on the same two keys yields the + * reverse order). */ -function goStringCompare(a: string, b: string): number { +export function goStringCompare(a: string, b: string): number { let i = 0; while (i < a.length && i < b.length) { const ac = a.codePointAt(i) as number; diff --git a/apps/cli/src/legacy/shared/legacy-param-introspection.ts b/apps/cli/src/legacy/shared/legacy-param-introspection.ts new file mode 100644 index 0000000000..713d3b1592 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-param-introspection.ts @@ -0,0 +1,98 @@ +import { Option } from "effect"; +import { Param } from "effect/unstable/cli"; + +/** + * `effect/unstable/cli`'s own `Param.extractSingleParams`/`Param.getParamMetadata` + * (`.repos/effect/packages/effect/src/unstable/cli/Param.ts`) already implement + * exactly this unwrap — the same functions `--help` rendering uses internally — + * but both carry an `@internal` JSDoc tag and are confirmed ABSENT from this + * package's published `.d.ts` (present only in the compiled `.js`; verified + * against the pinned `effect@4.0.0-beta.97` under `node_modules`), so calling + * them would only type-check via an `as` cast, which this repo forbids. + * + * This module reimplements the same unwrap using only type-visible public + * fields. A `Map`/`Transform`/`Optional`/`Variadic` param wraps an inner + * `.param` of the same shape (e.g. `.pipe(Flag.optional)`, + * `.pipe(Flag.withDefault(...))`, which composes as `Map(Optional(Single))`), + * and every non-`Single` variant publicly declares `.param` per its own + * interface. The variant union is closed as of this effect version, so an + * unrecognized future variant fails *closed* (the walk stops and returns + * `undefined`) rather than open. Delete this in favor of + * `Param.extractSingleParams`/`Param.getParamMetadata` if effect ever + * publishes them. + */ +interface LegacyWrappedParam { + readonly param: Param.Any; +} + +function legacyIsWrappedParam(param: Param.Any): param is Param.Any & LegacyWrappedParam { + return "param" in param; +} + +interface LegacyVariadicParam { + readonly min: Option.Option; +} + +function legacyIsVariadicParam( + param: Param.Any & LegacyWrappedParam, +): param is Param.Any & LegacyWrappedParam & LegacyVariadicParam { + return "min" in param; +} + +export interface LegacyUnwrappedParam { + readonly single: Param.Single; + readonly isOptional: boolean; + readonly isVariadic: boolean; + /** + * The `Param.variadic`/`Flag.atLeast`/`Flag.between` minimum occurrence + * count, or `0` when the param isn't variadic at all. A variadic param with + * `min === 0` (e.g. `Flag.atLeast(0)`, what `legacyStringSliceFlag` uses) + * can legitimately be omitted entirely — `Param.ts`'s `parseOptionVariadic` + * only fails with `MissingOption` when `count < min` and `min > 0` — so + * "variadic" alone does NOT imply "optional" the way wrapping in + * `Param.Optional` does. Callers computing required-ness must check this, + * not just `isVariadic`. + */ + readonly variadicMin: number; +} + +/** + * Unwraps a possibly-wrapped `Param` down to its underlying `Single` leaf, + * alongside whether the param passed through `Param.optional`/`Flag.optional` + * (or `Flag.withDefault`, which composes as `Map(Optional(Single))`) and/or + * `Param.variadic`/`Flag.between`/`Flag.atLeast`/`Flag.atMost`. Returns + * `undefined` only if the variant union gains an unrecognized future case. + */ +export function legacyUnwrapParam(param: Param.Any): LegacyUnwrappedParam | undefined { + let current: Param.Any = param; + let isOptional = false; + let isVariadic = false; + let variadicMin = 0; + + while (!Param.isSingle(current)) { + if (!legacyIsWrappedParam(current)) return undefined; + if (current._tag === "Optional") isOptional = true; + if (current._tag === "Variadic") { + isVariadic = true; + if (legacyIsVariadicParam(current)) { + variadicMin = Option.getOrElse(current.min, () => 0); + } + } + current = current.param; + } + + return { single: current, isOptional, isVariadic, variadicMin }; +} + +/** + * Unwraps down to the underlying `Single` param only, discarding the + * optional/variadic metadata `legacyUnwrapParam` also computes. Shared by + * `legacy/telemetry/legacy-command-instrumentation.ts` (telemetry flag + * redaction) and `legacy/cli/legacy-complete.ts` (shell completion), both of + * which only need the leaf `Single`'s `name`/`aliases`/`primitiveType` fields. + */ +export function legacyUnwrapToSingleParam( + param: Param.Any, +): Param.Single | undefined { + return legacyUnwrapParam(param)?.single; +} diff --git a/apps/cli/src/legacy/shared/legacy-param-introspection.unit.test.ts b/apps/cli/src/legacy/shared/legacy-param-introspection.unit.test.ts new file mode 100644 index 0000000000..7d0666f5c1 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-param-introspection.unit.test.ts @@ -0,0 +1,94 @@ +import { Flag } from "effect/unstable/cli"; +import { describe, expect, it } from "vitest"; + +import { legacyUnwrapParam, legacyUnwrapToSingleParam } from "./legacy-param-introspection.ts"; + +describe("legacyUnwrapParam", () => { + it("unwraps a plain, required flag with isOptional/isVariadic both false and variadicMin 0", () => { + const result = legacyUnwrapParam(Flag.string("custom-hostname")); + expect(result?.single.name).toBe("custom-hostname"); + expect(result?.isOptional).toBe(false); + expect(result?.isVariadic).toBe(false); + expect(result?.variadicMin).toBe(0); + }); + + it("marks a Flag.optional-wrapped flag as isOptional", () => { + const result = legacyUnwrapParam(Flag.string("desired-subdomain").pipe(Flag.optional)); + expect(result?.single.name).toBe("desired-subdomain"); + expect(result?.isOptional).toBe(true); + }); + + it("marks a Flag.withDefault-wrapped flag as isOptional (composes as Map(Optional(Single)))", () => { + const result = legacyUnwrapParam(Flag.string("profile").pipe(Flag.withDefault("supabase"))); + expect(result?.single.name).toBe("profile"); + expect(result?.isOptional).toBe(true); + }); + + it("does not mark a plain boolean flag as isOptional (booleans default to false unwrapped)", () => { + const result = legacyUnwrapParam(Flag.boolean("debug")); + expect(result?.single.name).toBe("debug"); + expect(result?.isOptional).toBe(false); + expect(result?.single.primitiveType._tag).toBe("Boolean"); + }); + + it("marks a zero-minimum variadic flag (Flag.atLeast(0)) as variadic but NOT optional, with variadicMin 0", () => { + // This is the exact shape `legacyStringSliceFlag` builds on + // (`legacy-string-slice-flag.ts`) — a real bug (CLI-1965 review) treated + // this as "required" for shell-completion purposes because it isn't + // `Optional`-wrapped, even though `Param.ts`'s `parseOptionVariadic` only + // fails with `MissingOption` when `count < min` and `min > 0`, so a + // zero-minimum variadic flag can legitimately be omitted entirely. + const result = legacyUnwrapParam(Flag.string("domains").pipe(Flag.atLeast(0))); + expect(result?.single.name).toBe("domains"); + expect(result?.isOptional).toBe(false); + expect(result?.isVariadic).toBe(true); + expect(result?.variadicMin).toBe(0); + }); + + it("captures a positive variadic minimum (Flag.atLeast(2))", () => { + const result = legacyUnwrapParam(Flag.string("source").pipe(Flag.atLeast(2))); + expect(result?.isVariadic).toBe(true); + expect(result?.variadicMin).toBe(2); + }); + + it("captures the minimum from Flag.between", () => { + const result = legacyUnwrapParam(Flag.string("host").pipe(Flag.between(1, 3))); + expect(result?.isVariadic).toBe(true); + expect(result?.variadicMin).toBe(1); + }); + + it("reports variadicMin 0 for an unbounded Flag.atMost (no minimum set)", () => { + const result = legacyUnwrapParam(Flag.string("warning").pipe(Flag.atMost(3))); + expect(result?.isVariadic).toBe(true); + expect(result?.variadicMin).toBe(0); + }); + + it("walks through a chained Map after Optional (Flag.withDefault on a choice flag)", () => { + const result = legacyUnwrapParam( + Flag.choice("dns-resolver", ["native", "https"] as const).pipe(Flag.withDefault("native")), + ); + expect(result?.single.name).toBe("dns-resolver"); + expect(result?.isOptional).toBe(true); + expect(result?.single.primitiveType._tag).toBe("Choice"); + }); + + it("preserves aliases and hidden metadata on the underlying Single", () => { + const result = legacyUnwrapParam( + Flag.string("type").pipe(Flag.withAlias("t"), Flag.withHidden), + ); + expect(result?.single.aliases).toEqual(["t"]); + expect(result?.single.hidden).toBe(true); + }); +}); + +describe("legacyUnwrapToSingleParam", () => { + it("returns just the underlying Single, discarding optional/variadic metadata", () => { + const single = legacyUnwrapToSingleParam(Flag.string("role").pipe(Flag.optional)); + expect(single?.name).toBe("role"); + }); + + it("agrees with legacyUnwrapParam's own .single for the same input", () => { + const param = Flag.string("status").pipe(Flag.atLeast(0)); + expect(legacyUnwrapToSingleParam(param)).toBe(legacyUnwrapParam(param)?.single); + }); +}); diff --git a/apps/cli/src/legacy/shared/legacy-parse-uint.ts b/apps/cli/src/legacy/shared/legacy-parse-uint.ts new file mode 100644 index 0000000000..fc7eae66a1 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-parse-uint.ts @@ -0,0 +1,193 @@ +/** + * Faithful port of Go's `strconv.ParseUint(s, 0, 64)` (`legacyParseUintBase0`) + * and `strconv.ParseInt(s, 0, 64)` (`legacyIsValidBase0Int64`) — the exact + * parsers pflag runs for `UintVarP`/`UintVar` and `Int64VarP`/`Int64Var` + * flags respectively (`uintValue.Set`/`int64Value.Set`, `pflag/{uint,int64}.go`). + * Hoisted here (from its original home under `commands/storage/cp/`, CLI-1965 + * review) once a second family needed it: `legacy-complete.ts` validates + * `functions deploy --jobs`/`migration down --last`/`db reset --last` (uint) and + * `backups restore --timestamp` (int64) — all declared `Flag.integer` in TS but + * a Go pflag numeric type with a narrower, sign-and-range-sensitive parser — + * the same way `storage cp --jobs` already does at parse time. Operating on + * the RAW flag token (instead of a pre-normalized number) is load-bearing for + * parity: + * + * - every sign prefix is rejected, including `-0` and `+1` (a numeric + * normalization turns `-0` into negative zero, for which `value < 0` is + * false, silently accepting what Go rejects); + * - error messages carry the ORIGINAL spelling (`-01`, not `-1`); + * - base 0 enables Go's prefix/underscore forms: `0x10` → 16, `0o10`/`010` → + * 8 (octal!), `0b10` → 2, and `1_0` → 10 — all of which Go accepts. + * + * All verdicts below are verified against go1.26 (`strconv.ParseUint(s, 0, 64)`): + * `-0`/`-01`/`+1`/`3.5`/`abc`/`09`/`0x`/`_1`/`1_`/`1__0`/` 1` → invalid + * syntax; `0x_10` → 16; `18446744073709551616` → value out of range. + * + * Go iterates bytes where this iterates UTF-16 code units, but every non-ASCII + * unit (and every byte of a multibyte rune) falls outside the digit/letter + * ranges in both, so the verdict is identical. + * + * Known residual: values above 2^53 lose precision in the `Number` conversion + * (Go carries the exact uint64). They still PARSE identically; only the + * resulting parallel-job count differs, in territory where Go's own behavior + * (an `int` conversion of a near-2^64 uint) is already degenerate. + */ + +const MAX_UINT64 = (1n << 64n) - 1n; +const MAX_INT64 = (1n << 63n) - 1n; +// `strconv.ParseInt`'s negative bound has one MORE representable magnitude +// than the positive bound (two's complement) — `-9223372036854775808` is a +// valid `int64`, but `9223372036854775808` (its positive magnitude) is not. +const MAX_INT64_NEGATIVE_MAGNITUDE = 1n << 63n; + +export type LegacyParseUintResult = + | { readonly value: number } + | { readonly cause: "invalid syntax" | "value out of range" }; + +/** + * The base-0 digit grammar shared by `legacyParseUintBase0` (`ParseUint`, + * unsigned) and `legacyIsValidBase0Int64` (`ParseInt`, signed) — base + * detection (`0x`/`0o`/`0b` prefixes, else a leading `0` for octal, else + * decimal), digit accumulation, and underscore placement, all per + * `strconv/atoi.go`. Bounds the accumulated magnitude at `MAX_UINT64` — the + * widest of the two callers' limits, and therefore a safe SUPERSET bound for + * both (`MAX_INT64`/`MAX_INT64_NEGATIVE_MAGNITUDE` are both smaller): a + * magnitude that already exceeds `MAX_UINT64` is "value out of range" for + * either caller, so the exit can live here once. A value between the int64 + * bound and `MAX_UINT64` (this finding's own repro, + * `9223372036854775808` — one past int64 max, comfortably under uint64 max) + * parses successfully here and is bounded by `legacyIsValidBase0Int64`'s + * OWN, narrower check afterward instead. + * + * `token` is the value with any sign prefix already stripped by the caller + * (a sign character reaching this loop directly would fail as a non-digit, + * exactly like Go's own digit loop) — `originalToken` (WITH the sign, when + * the caller has one to give) is threaded through only for `underscoreOk`'s + * separator check, which inspects the full original spelling. + */ +function legacyParseBase0Digits( + token: string, + originalToken: string, +): { readonly n: bigint } | { readonly cause: "invalid syntax" | "value out of range" } { + if (token.length === 0) return { cause: "invalid syntax" }; + + // Base detection for base 0 (`strconv/atoi.go`): `0x`/`0b`/`0o` prefixes + // (only when at least one more character follows), else a leading `0` means + // octal, else decimal. + let s = token; + let base = 10n; + if (s[0] === "0") { + const marker = s.length >= 3 ? s[1]?.toLowerCase() : undefined; + if (marker === "b") { + base = 2n; + s = s.slice(2); + } else if (marker === "o") { + base = 8n; + s = s.slice(2); + } else if (marker === "x") { + base = 16n; + s = s.slice(2); + } else { + base = 8n; + s = s.slice(1); + } + } + + let sawUnderscore = false; + let n = 0n; + for (let i = 0; i < s.length; i++) { + const code = s.charCodeAt(i); + let digit: bigint; + if (code === 0x5f /* _ */) { + // Only base 0 admits underscores; position rules are checked at the end. + sawUnderscore = true; + continue; + } else if (code >= 0x30 && code <= 0x39) { + digit = BigInt(code - 0x30); + } else { + const lower = code | 0x20; + if (lower >= 0x61 && lower <= 0x7a) digit = BigInt(lower - 0x61 + 10); + else return { cause: "invalid syntax" }; + } + if (digit >= base) return { cause: "invalid syntax" }; + n = n * base + digit; + if (n > MAX_UINT64) return { cause: "value out of range" }; + } + if (sawUnderscore && !underscoreOk(originalToken)) return { cause: "invalid syntax" }; + return { n }; +} + +export function legacyParseUintBase0(token: string): LegacyParseUintResult { + const parsed = legacyParseBase0Digits(token, token); + return "cause" in parsed ? parsed : { value: Number(parsed.n) }; +} + +/** + * Faithful port of Go's `strconv.ParseInt(s, 0, 64)` — the exact parser + * pflag runs for an `Int64VarP`/`Int64Var` flag (`int64Value.Set`, + * `pflag/int64.go`), e.g. `backups restore --timestamp` + * (`apps/cli-go/cmd/backups.go:43`). Only a syntax-and-range VERDICT is + * needed for completion (not the parsed value), so this returns a boolean + * rather than mirroring `LegacyParseUintResult`'s shape. + * + * Reuses {@link legacyParseBase0Digits} for the base/digit grammar (the + * same one `legacyParseUintBase0` runs) on the sign-stripped remainder, then + * applies `ParseInt`'s own two-step design: strip an optional leading + * `+`/`-`, parse the magnitude, and bound it against `int64`'s asymmetric + * two's-complement range — `-9223372036854775808` is valid, but that same + * magnitude, `9223372036854775808`, is NOT (it is one past `int64`'s + * positive bound, `9223372036854775807`) — verified empirically against a + * real `apps/cli-go` build: `backups restore --timestamp + * 9223372036854775808 --p` returns zero candidates with the Default + * directive, while `--timestamp 9223372036854775807` (`int64` max) and + * `--timestamp -9223372036854775808` (`int64` min) both still offer + * `--profile`/`--project-ref` — CLI-1965 review finding. + */ +export function legacyIsValidBase0Int64(token: string): boolean { + const isNegative = token[0] === "-"; + const unsigned = isNegative || token[0] === "+" ? token.slice(1) : token; + const parsed = legacyParseBase0Digits(unsigned, token); + if ("cause" in parsed) return false; + return parsed.n <= (isNegative ? MAX_INT64_NEGATIVE_MAGNITUDE : MAX_INT64); +} + +/** + * Go's `underscoreOK` (`strconv/atoi.go`): underscores must sit between + * digits, or between the base prefix and the first digit (`0x_10` is valid). + * The sign skip is unreachable through `legacyParseUintBase0` (a sign already + * fails the digit loop before `underscoreOk` is ever reached) but IS reachable + * through `legacyIsValidBase0Int64`, which passes the original, still-signed + * token through for this check specifically (see that function's doc + * comment) — kept unconditionally rather than split per-caller so both stay + * governed by one port of Go's source. + */ +function underscoreOk(token: string): boolean { + // `saw` tracks the class of the previous character: `^` start-of-number, + // `0` digit-or-prefix, `_` underscore, `!` anything else. + let saw = "^"; + let s = token; + if (s.length >= 1 && (s[0] === "-" || s[0] === "+")) s = s.slice(1); + let i = 0; + let hex = false; + const marker = s[1]?.toLowerCase(); + if (s.length >= 2 && s[0] === "0" && (marker === "b" || marker === "o" || marker === "x")) { + i = 2; + saw = "0"; // the base prefix counts as a digit for separator purposes + hex = marker === "x"; + } + for (; i < s.length; i++) { + const c = s[i] as string; + if ((c >= "0" && c <= "9") || (hex && c.toLowerCase() >= "a" && c.toLowerCase() <= "f")) { + saw = "0"; + continue; + } + if (c === "_") { + if (saw !== "0") return false; + saw = "_"; + continue; + } + if (saw === "_") return false; + saw = "!"; + } + return saw !== "_"; +} diff --git a/apps/cli/src/legacy/commands/storage/cp/cp.parse-uint.unit.test.ts b/apps/cli/src/legacy/shared/legacy-parse-uint.unit.test.ts similarity index 61% rename from apps/cli/src/legacy/commands/storage/cp/cp.parse-uint.unit.test.ts rename to apps/cli/src/legacy/shared/legacy-parse-uint.unit.test.ts index 1133857f3a..5185e5a4bd 100644 --- a/apps/cli/src/legacy/commands/storage/cp/cp.parse-uint.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-parse-uint.unit.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { legacyParseUintBase0 } from "./cp.parse-uint.ts"; +import { legacyIsValidBase0Int64, legacyParseUintBase0 } from "./legacy-parse-uint.ts"; // Every expectation in this file is ground truth captured from go1.26: // `strconv.ParseUint(s, 0, 64)` — the exact call pflag makes for a `UintVarP` @@ -56,9 +56,47 @@ describe("legacyParseUintBase0 (Go strconv.ParseUint(s, 0, 64) parity)", () => { expect(legacyParseUintBase0("18446744073709551616")).toEqual({ cause: "value out of range" }); expect(legacyParseUintBase0("0x10000000000000000")).toEqual({ cause: "value out of range" }); // Max uint64 parses (the Number conversion is lossy up there — documented - // residual in cp.parse-uint.ts — but the accept/reject verdict matches Go). + // residual in legacy-parse-uint.ts — but the accept/reject verdict matches Go). expect(legacyParseUintBase0("18446744073709551615")).toEqual({ value: Number(18446744073709551615n), }); }); }); + +// Every expectation in this file is ground truth captured from go1.26: +// `strconv.ParseInt(s, 0, 64)` — the exact call pflag makes for an +// `Int64VarP` flag (`int64Value.Set`, `pflag/int64.go`), e.g. `backups +// restore --timestamp` (`apps/cli-go/cmd/backups.go:43`). +describe("legacyIsValidBase0Int64 (Go strconv.ParseInt(s, 0, 64) parity)", () => { + it("accepts int64's exact bounds, both signs", () => { + expect(legacyIsValidBase0Int64("9223372036854775807")).toBe(true); // int64 max + expect(legacyIsValidBase0Int64("-9223372036854775808")).toBe(true); // int64 min + }); + + it("rejects a magnitude one past int64's bound on each side — the asymmetric two's-complement range", () => { + // 9223372036854775808 is a syntactically valid uint64 (well under + // MAX_UINT64) but exceeds int64's positive bound by exactly one. + expect(legacyIsValidBase0Int64("9223372036854775808")).toBe(false); + // -9223372036854775809's magnitude, 9223372036854775809, exceeds int64's + // negative-side bound (9223372036854775808) by one too. + expect(legacyIsValidBase0Int64("-9223372036854775809")).toBe(false); + }); + + it("still enforces the uint64 ceiling for a wildly out-of-range magnitude", () => { + expect(legacyIsValidBase0Int64("18446744073709551616")).toBe(false); // one past uint64 max + }); + + it("accepts plain decimals and Go's base-0 prefix forms, signed", () => { + expect(legacyIsValidBase0Int64("0")).toBe(true); + expect(legacyIsValidBase0Int64("42")).toBe(true); + expect(legacyIsValidBase0Int64("-42")).toBe(true); + expect(legacyIsValidBase0Int64("0x10")).toBe(true); + expect(legacyIsValidBase0Int64("-0x10")).toBe(true); + }); + + it("rejects non-numeric junk the same way the uint64 parser does", () => { + expect(legacyIsValidBase0Int64("bogus")).toBe(false); + expect(legacyIsValidBase0Int64("3.5")).toBe(false); + expect(legacyIsValidBase0Int64("")).toBe(false); + }); +}); diff --git a/apps/cli/src/legacy/shared/legacy-start-secrets-cleanup.ts b/apps/cli/src/legacy/shared/legacy-start-secrets-cleanup.ts index 261a4bf071..90dbec8e0c 100644 --- a/apps/cli/src/legacy/shared/legacy-start-secrets-cleanup.ts +++ b/apps/cli/src/legacy/shared/legacy-start-secrets-cleanup.ts @@ -17,7 +17,7 @@ import type { LegacyContainerIdName } from "./legacy-docker-lifecycle.ts"; * * As of supabase/cli#6022, Kong/Postgres/Supavisor's own `secretFiles` no * longer stage anything under this tree — `container-lifecycle.ts`'s - * `legacyStartContainer` now `docker cp`s them straight into the created + * `legacyCreateContainer` now `docker cp`s them straight into the created * container instead (see `legacyCopyStartSecretFileIntoContainer`'s doc * comment), so a bind mount's host-side path never has to be resolved by a * remote Docker daemon. This module remains load-bearing for Edge Runtime's diff --git a/apps/cli/src/legacy/telemetry/legacy-command-instrumentation.ts b/apps/cli/src/legacy/telemetry/legacy-command-instrumentation.ts index ceae9c91be..bd349ff18b 100644 --- a/apps/cli/src/legacy/telemetry/legacy-command-instrumentation.ts +++ b/apps/cli/src/legacy/telemetry/legacy-command-instrumentation.ts @@ -31,6 +31,7 @@ import { VALUE_CONSUMING_LONG_FLAGS, VALUE_CONSUMING_SHORT_FLAGS, } from "../shared/legacy-db-target-flags.ts"; +import { legacyUnwrapToSingleParam } from "../shared/legacy-param-introspection.ts"; interface LegacyCommandInstrumentationOptions = never> { readonly analytics?: boolean; @@ -216,40 +217,6 @@ function normalizeFlagValue(value: unknown): unknown | undefined { return normalizeFlagValue(value.value); } -// A `Map`/`Transform`/`Optional`/`Variadic` param wraps an inner `param` of the -// same shape (e.g. `.pipe(Flag.optional)`, `.pipe(Flag.withDefault(...))`, which -// composes as `Map(Optional(Single))`). `effect/unstable/cli` already ships the -// exact unwrap this needs — `Param.extractSingleParams`, the same function -// `--help` rendering uses — but it (and `Primitive.getChoiceKeys`) are -// `@internal`-tagged and confirmed absent from this package's published `.d.ts` -// (present in the compiled `.js`, so calling them would only type-check via an -// `as` cast, which this repo forbids). This predicate reimplements the -// `isSingle`-or-has-a-`.param`-field check using only type-visible public -// fields; every non-`Single` variant publicly declares `.param` per its own -// interface, and the variant union is closed as of this effect version, so an -// unrecognized future variant fails *closed* (silently not detected as a -// choice flag, i.e. stays redacted) rather than open. Delete this in favor of -// `Param.extractSingleParams` if effect ever publishes it. -interface WrappedParam { - readonly param: Param.Any; -} -function isWrappedParam(param: Param.Any): param is Param.Any & WrappedParam { - return "param" in param; -} - -// Unwraps down to the underlying `Single` param the same way `--help` -// rendering does. Shared by `getChoiceFlagNames` and `GLOBAL_SHORT_ALIASES` -// below — both need the leaf `Single` to read its type-visible `name`/ -// `aliases`/`primitiveType` fields. Returns `undefined` only if the variant -// union gains an unrecognized future case (fails closed, see the -// `isWrappedParam` doc above for why this hand-rolled unwrap exists instead of -// the `@internal` `Param.extractSingleParams`). -function unwrapToSingleParam(param: Param.Any): Param.Single | undefined { - if (Param.isSingle(param)) return param; - if (isWrappedParam(param)) return unwrapToSingleParam(param.param); - return undefined; -} - // Mirrors Go's `isEnumFlag` (`cmd/root_analytics.go:110-116`), which checks // `flag.Value.(*utils.EnumFlag)` unconditionally — every enum flag is // telemetry-safe, no per-flag annotation needed. Checks the unwrapped @@ -261,7 +228,7 @@ function getChoiceFlagNames(config: Record | undefined): Read if (config === undefined) return names; for (const param of Object.values(config)) { - const single = unwrapToSingleParam(param); + const single = legacyUnwrapToSingleParam(param); if ( single !== undefined && single.kind === Param.flagKind && @@ -289,7 +256,7 @@ function getChoiceFlagNames(config: Record | undefined): Read const GLOBAL_SHORT_ALIASES: Readonly> = (() => { const aliases: Record = {}; for (const globalFlag of LEGACY_GLOBAL_FLAGS) { - const single = unwrapToSingleParam(globalFlag.flag); + const single = legacyUnwrapToSingleParam(globalFlag.flag); if (single === undefined) continue; for (const alias of single.aliases) { aliases[alias] = single.name; diff --git a/apps/cli/src/shared/cli/run.ts b/apps/cli/src/shared/cli/run.ts index 69be7da263..5ddb683068 100644 --- a/apps/cli/src/shared/cli/run.ts +++ b/apps/cli/src/shared/cli/run.ts @@ -75,13 +75,19 @@ const globalFlagsWithValues = new Set([ // legacyRollbackStart(...))` wrapper `supabase start` uses, which only ever fires when this // process's own fiber is interrupted (by `Fiber.interrupt` below, or by an ordinary typed // failure) — a raw, unhandled OS signal skips it entirely, exactly like the `start` case above. -const selfManagedSignalCommands: ReadonlyArray> = [ - // `db reset` (local path) drives the bootstrap seam, which holds SIGINT/SIGTERM/SIGHUP with - // no-op listeners while the Go child recreates the container; the global handler would - // otherwise race that and cut off the child's Docker cleanup / status propagation. - ["db", "reset"], - ["functions", "serve"], -]; +// +// `["db", "reset"]` was ALSO listed here once, for the same reason `db start` used to be: +// its local path drove the hidden `db __db-bootstrap --mode recreate`/`--mode await-storage` +// seam via a bespoke DIRECT `ChildProcess.make` spawn (not through `LegacyGoProxy`), which +// held SIGINT/SIGTERM/SIGHUP itself while the Go child recreated the container — the global +// handler's own `Fiber.interrupt` would otherwise race that child's Docker cleanup and lose +// its real exit status. CLI-1955 removed that seam entirely: `db reset --local` is now fully +// native TS (`legacy/shared/db-bootstrap/recreate-local-database.ts`), installing no signal +// handling of its own. Its only remaining Go child is the niche `--experimental` remote +// delegate, via the SAME `LegacyGoProxy.exec`/`execCapture` every other unlisted legacy +// command already uses safely alongside this global handler — so `db reset` was removed from +// this list too, matching `db start`'s own precedent exactly. +const selfManagedSignalCommands: ReadonlyArray> = [["functions", "serve"]]; /** Positional command-path tokens from argv, skipping global flags and their values. */ export function extractCommandPath(args: ReadonlyArray): ReadonlyArray { diff --git a/apps/cli/src/shared/cli/run.unit.test.ts b/apps/cli/src/shared/cli/run.unit.test.ts index 0189a5f5c4..28fac7c014 100644 --- a/apps/cli/src/shared/cli/run.unit.test.ts +++ b/apps/cli/src/shared/cli/run.unit.test.ts @@ -47,20 +47,21 @@ describe("extractCommandPath", () => { describe("shouldUseGlobalSignalInterrupt", () => { it("opts out for self-managed signal commands, even behind global flags", () => { expect(shouldUseGlobalSignalInterrupt(["functions", "serve"])).toBe(false); - // `db reset` drives the bootstrap seam (holds signals for the Go child), so it must not - // be wrapped in the global handler either. - expect(shouldUseGlobalSignalInterrupt(["db", "reset"])).toBe(false); expect( shouldUseGlobalSignalInterrupt(["--workdir", "/tmp/app", "functions", "serve", "--debug"]), ).toBe(false); }); - it("opts in for ordinary commands, including native start/db start (each installs no signal handling of its own, so the global wrapper's rollback-on-interrupt is the only thing that runs legacyRollbackStart on Ctrl-C)", () => { + it("opts in for ordinary commands, including native start/db start/db reset (each installs no signal handling of its own, so the global wrapper's rollback-on-interrupt/finalizers are the only thing that runs on Ctrl-C)", () => { expect(shouldUseGlobalSignalInterrupt(["functions", "list"])).toBe(true); expect(shouldUseGlobalSignalInterrupt(["db", "push"])).toBe(true); expect(shouldUseGlobalSignalInterrupt(["projects", "list"])).toBe(true); expect(shouldUseGlobalSignalInterrupt(["start"])).toBe(true); expect(shouldUseGlobalSignalInterrupt(["db", "start"])).toBe(true); + // `db reset` (CLI-1955): the hidden `db __db-bootstrap` seam this used to drive is + // gone — the local path is fully native TS, installing no signal handling of its + // own, so it participates in the global handler like `db start` (CLI-1954) before it. + expect(shouldUseGlobalSignalInterrupt(["db", "reset"])).toBe(true); expect(shouldUseGlobalSignalInterrupt([])).toBe(true); }); diff --git a/apps/cli/src/shared/functions/serve.ts b/apps/cli/src/shared/functions/serve.ts index 9149289ccf..b19dd06d2e 100644 --- a/apps/cli/src/shared/functions/serve.ts +++ b/apps/cli/src/shared/functions/serve.ts @@ -812,17 +812,19 @@ const parseCustomEnvFile = Effect.fnUntraced(function* ( if (Option.isNone(envFileFlag)) { const fallbackPath = join(projectRoot, fallbackEnvFilePath); - const exists = yield* Effect.tryPromise(() => - readFile(fallbackPath, "utf8").then( - (contents) => ({ contents, path: fallbackPath }), - (error) => { - if (error instanceof Error && "code" in error && error.code === "ENOENT") { - return undefined; - } - throw error; - }, - ), - ); + const exists = yield* Effect.tryPromise({ + try: () => + readFile(fallbackPath, "utf8").then( + (contents) => ({ contents, path: fallbackPath }), + (error) => { + if (error instanceof Error && "code" in error && error.code === "ENOENT") { + return undefined; + } + throw error; + }, + ), + catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + }); if (exists === undefined) { return yield* toEnvEntries({}); } @@ -1031,19 +1033,19 @@ const loadServeProjectEnvironment = Effect.fnUntraced(function* (projectRoot: st for (const dir of [paths.supabaseDir, paths.projectRoot]) { for (const filename of loadDefaultEnvFilenames(env)) { const envPath = join(dir, filename); - const contents = yield* Effect.tryPromise(() => - readFile(envPath, "utf8").then( - (value) => value, - (error) => { - if (error instanceof Error && "code" in error && error.code === "ENOENT") { - return undefined; - } - throw error; - }, - ), - ).pipe( - Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause)))), - ); + const contents = yield* Effect.tryPromise({ + try: () => + readFile(envPath, "utf8").then( + (value) => value, + (error) => { + if (error instanceof Error && "code" in error && error.code === "ENOENT") { + return undefined; + } + throw error; + }, + ), + catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + }); if (contents === undefined) { continue; } @@ -1611,19 +1613,20 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo try: () => validateDockerMultilineEnvNames(multilineDockerEnv), catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), }); - const dockerEnvFile = yield* Effect.tryPromise(() => - writeDockerEnvFile(singleLineDockerEnv, join(stagingDir, "env")), - ); + const dockerEnvFile = yield* Effect.tryPromise({ + try: () => writeDockerEnvFile(singleLineDockerEnv, join(stagingDir, "env")), + catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + }); const multilineEnvDir = "/root/.supabase/multiline-env"; - const dockerMultilineEnvScript = yield* Effect.tryPromise(() => - writeDockerMultilineEnvScript( - multilineDockerEnv, - multilineEnvDir, - join(stagingDir, "multiline-env"), - ), - ).pipe( - Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause)))), - ); + const dockerMultilineEnvScript = yield* Effect.tryPromise({ + try: () => + writeDockerMultilineEnvScript( + multilineDockerEnv, + multilineEnvDir, + join(stagingDir, "multiline-env"), + ), + catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + }); const labels = dockerProjectLabels(projectId); const runtimeCommand = [ @@ -1636,11 +1639,10 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo ...(input.debug ? ["--verbose"] : []), ]; const serveMainTemplate = yield* Effect.promise(() => getLegacyFunctionsServeMainTemplate()); - const serveMainTemplateFile = yield* Effect.tryPromise(() => - writeServeMainTemplateFile(serveMainTemplate, join(stagingDir, "main")), - ).pipe( - Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause)))), - ); + const serveMainTemplateFile = yield* Effect.tryPromise({ + try: () => writeServeMainTemplateFile(serveMainTemplate, join(stagingDir, "main")), + catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + }); const containerProjectRoot = toDockerPath(input.projectRoot); const command = [ "run", diff --git a/apps/cli/src/shared/legacy/global-flags.ts b/apps/cli/src/shared/legacy/global-flags.ts index 6e2f1f6070..03f74e840e 100644 --- a/apps/cli/src/shared/legacy/global-flags.ts +++ b/apps/cli/src/shared/legacy/global-flags.ts @@ -17,6 +17,15 @@ import { legacyViperEnvBool, legacyViperEnvBoolWithProjectFallback } from "./leg // only the values its Go counterpart does (e.g. `db query` reads `table`/`csv`, // resource commands ignore them and fall through to text). `table`/`csv` are // only meaningful to `db query`. +// +// Every description string below is copied VERBATIM (including Go's own +// lowercase, no-trailing-period house style for root persistent flags) from +// `apps/cli-go/cmd/root.go:324-333` — this text is directly user-visible now +// that native shell completion (CLI-1965) surfaces it in `__complete` +// candidate descriptions, where a prior Go-binary passthrough used to emit +// Go's own text byte-for-byte; before that, this only reached the TS-native +// `--help` renderer, whose overall layout already diverges from cobra's, so +// the mismatch was harder to notice (CLI-1965 review finding). export const LegacyOutputFlag = GlobalFlag.setting("output")({ flag: Flag.choice("output", [ "env", @@ -28,60 +37,60 @@ export const LegacyOutputFlag = GlobalFlag.setting("output")({ "csv", ] as const).pipe( Flag.withAlias("o"), - Flag.withDescription("Output format of status variables."), + Flag.withDescription("output format of status variables"), Flag.optional, ), }); export const LegacyProfileFlag = GlobalFlag.setting("profile")({ flag: Flag.string("profile").pipe( - Flag.withDescription("Use a specific profile for connecting to Supabase API."), + Flag.withDescription("use a specific profile for connecting to Supabase API"), Flag.withDefault("supabase"), ), }); export const LegacyDebugFlag = GlobalFlag.setting("debug")({ - flag: Flag.boolean("debug").pipe(Flag.withDescription("Output debug logs to stderr.")), + flag: Flag.boolean("debug").pipe(Flag.withDescription("output debug logs to stderr")), }); export const LegacyWorkdirFlag = GlobalFlag.setting("workdir")({ flag: Flag.string("workdir").pipe( - Flag.withDescription("Path to a Supabase project directory."), + Flag.withDescription("path to a Supabase project directory"), Flag.optional, ), }); export const LegacyExperimentalFlag = GlobalFlag.setting("experimental")({ - flag: Flag.boolean("experimental").pipe(Flag.withDescription("Enable experimental features.")), + flag: Flag.boolean("experimental").pipe(Flag.withDescription("enable experimental features")), }); export const LegacyNetworkIdFlag = GlobalFlag.setting("network-id")({ flag: Flag.string("network-id").pipe( - Flag.withDescription("Use the specified Docker network instead of a generated one."), + Flag.withDescription("use the specified docker network instead of a generated one"), Flag.optional, ), }); export const LegacyYesFlag = GlobalFlag.setting("yes")({ - flag: Flag.boolean("yes").pipe(Flag.withDescription("Answer yes to all prompts.")), + flag: Flag.boolean("yes").pipe(Flag.withDescription("answer yes to all prompts")), }); export const LegacyDnsResolverFlag = GlobalFlag.setting("dns-resolver")({ flag: Flag.choice("dns-resolver", ["native", "https"] as const).pipe( - Flag.withDescription("Look up domain names using the specified resolver."), + Flag.withDescription("lookup domain names using the specified resolver"), Flag.withDefault("native" as const), ), }); export const LegacyCreateTicketFlag = GlobalFlag.setting("create-ticket")({ flag: Flag.boolean("create-ticket").pipe( - Flag.withDescription("Create a support ticket for any CLI error."), + Flag.withDescription("create a support ticket for any CLI error"), ), }); export const LegacyAgentFlag = GlobalFlag.setting("agent")({ flag: Flag.choice("agent", ["auto", "yes", "no"] as const).pipe( - Flag.withDescription("Override agent detection: yes, no, or auto (default auto)."), + Flag.withDescription("Override agent detection: yes, no, or auto (default auto)"), Flag.withDefault("auto" as const), ), }); diff --git a/apps/cli/src/shared/legacy/legacy-go-child-exit.error.ts b/apps/cli/src/shared/legacy/legacy-go-child-exit.error.ts index d2b7f22ef3..c0cd93673e 100644 --- a/apps/cli/src/shared/legacy/legacy-go-child-exit.error.ts +++ b/apps/cli/src/shared/legacy/legacy-go-child-exit.error.ts @@ -1,8 +1,8 @@ import { Data, Runtime } from "effect"; /** - * A spawned `supabase-go` child process — via `LegacyGoProxy.exec`/`execCapture`, - * or the hidden `db __db-bootstrap` seam (`legacy-db-bootstrap.seam.layer.ts`) — + * A spawned `supabase-go` child process — via `LegacyGoProxy.exec`/`execCapture`, or + * (historically, before CLI-1955 removed it) the hidden `db __db-bootstrap` seam — * exited non-zero, or could not be spawned at all (binary not found). * * Carries the child's exact exit code through Effect's `Runtime.errorExitCode` diff --git a/apps/cli/src/shared/output/output.layer.ts b/apps/cli/src/shared/output/output.layer.ts index fe8ff28710..d1bf520d82 100644 --- a/apps/cli/src/shared/output/output.layer.ts +++ b/apps/cli/src/shared/output/output.layer.ts @@ -112,6 +112,7 @@ export const textOutputLayer = Layer.effect( readonly autocompleteThreshold?: number; readonly placeholder?: string; readonly maxItems?: number; + readonly stream?: "stdout" | "stderr"; } = {}, ) => Effect.gen(function* () { @@ -122,6 +123,11 @@ export const textOutputLayer = Layer.effect( ? "autocomplete" : "select" : mode; + // clack itself defaults every one of these to `process.stdout` (verified against + // the installed `@clack/prompts` source) — only override when a caller explicitly + // asks for stderr (e.g. a command whose own stdout is a machine-readable payload + // even in text mode). + const clackOutput = behavior.stream === "stderr" ? process.stderr : undefined; const value = yield* Effect.promise(() => effectiveMode === "autocomplete" ? autocomplete({ @@ -131,15 +137,20 @@ export const textOutputLayer = Layer.effect( ? { placeholder: behavior.placeholder } : {}), ...(behavior.maxItems !== undefined ? { maxItems: behavior.maxItems } : {}), + ...(clackOutput !== undefined ? { output: clackOutput } : {}), }) : select({ message, options: buildSelectOptions(options), ...(behavior.maxItems !== undefined ? { maxItems: behavior.maxItems } : {}), + ...(clackOutput !== undefined ? { output: clackOutput } : {}), }), ); if (isCancel(value)) { - cancel("Operation cancelled."); + cancel( + "Operation cancelled.", + clackOutput !== undefined ? { output: clackOutput } : undefined, + ); return yield* Effect.interrupt; } return value; diff --git a/apps/cli/src/shared/output/output.layer.unit.test.ts b/apps/cli/src/shared/output/output.layer.unit.test.ts index e3f8346f97..6317b5c79b 100644 --- a/apps/cli/src/shared/output/output.layer.unit.test.ts +++ b/apps/cli/src/shared/output/output.layer.unit.test.ts @@ -55,7 +55,7 @@ vi.mock("@clack/prompts", () => ({ select: (a: unknown) => mockClack.select(a), autocomplete: (a: unknown) => mockClack.autocomplete(a), multiselect: (a: unknown) => mockClack.multiselect(a), - cancel: (a: unknown) => mockClack.cancel(a), + cancel: (a: unknown, b?: unknown) => mockClack.cancel(a, b), isCancel: (a: unknown) => mockClack.isCancel(a), })); @@ -404,6 +404,57 @@ describe("Output", () => { }).pipe(Effect.provide(layer)); }); + it.effect("promptSelect defaults to clack's own stdout when stream is unset", () => { + mockClack.select.mockResolvedValue("pro"); + return Effect.gen(function* () { + const out = yield* Output; + yield* out.promptSelect("Select a plan", [{ value: "pro", label: "Pro" }]); + expect(mockClack.select).toHaveBeenCalledWith( + expect.not.objectContaining({ output: expect.anything() }), + ); + }).pipe(Effect.provide(layer)); + }); + + // Go's own interactive picker always writes to stderr (`internal/utils/prompt.go`'s + // `PromptChoice`: `tea.WithOutput(os.Stderr)`, "Interactive prompts should always be + // written to stderr") — but clack's `select()`/`autocomplete()` default to stdout, which + // would corrupt a command whose own stdout is a machine-readable payload even in text + // mode (e.g. `gen bearer-jwt`'s signed token — Codex review finding, CLI-1961). `{ stream: + // "stderr" }` is the opt-in escape hatch such a command passes. + it.effect('promptSelect routes the picker to stderr when stream: "stderr" is requested', () => { + mockClack.select.mockResolvedValue("pro"); + return Effect.gen(function* () { + const out = yield* Output; + yield* out.promptSelect("Select a plan", [{ value: "pro", label: "Pro" }], { + stream: "stderr", + }); + expect(mockClack.select).toHaveBeenCalledWith( + expect.objectContaining({ output: process.stderr }), + ); + }).pipe(Effect.provide(layer)); + }); + + it.effect( + "promptSelect routes a cancelled stderr-routed picker's cancel message to stderr too", + () => { + mockClack.select.mockResolvedValue(Symbol("clack-cancel")); + mockClack.isCancel.mockReturnValueOnce(true); + return Effect.gen(function* () { + const out = yield* Output; + const exit = yield* Effect.exit( + out.promptSelect("Select a plan", [{ value: "pro", label: "Pro" }], { + stream: "stderr", + }), + ); + expect(Exit.isFailure(exit)).toBe(true); + expect(mockClack.cancel).toHaveBeenCalledWith( + "Operation cancelled.", + expect.objectContaining({ output: process.stderr }), + ); + }).pipe(Effect.provide(layer)); + }, + ); + it.effect("promptSelect uses autocomplete for long lists in auto mode", () => { mockClack.autocomplete.mockResolvedValue("project-11"); return Effect.gen(function* () { diff --git a/apps/cli/src/shared/output/output.service.ts b/apps/cli/src/shared/output/output.service.ts index 54baf347f0..8026f793b3 100644 --- a/apps/cli/src/shared/output/output.service.ts +++ b/apps/cli/src/shared/output/output.service.ts @@ -24,6 +24,16 @@ interface OutputSelectBehavior { readonly autocompleteThreshold?: number; readonly placeholder?: string; readonly maxItems?: number; + /** + * Which stream the interactive picker itself renders to. Defaults to `"stdout"` + * (clack's own default, matching every existing caller). Pass `"stderr"` for a + * command whose own stdout is a machine-readable payload even in text mode (e.g. + * `gen bearer-jwt`'s signed token) — matching Go's own convention of always + * rendering interactive prompts to stderr (`internal/utils/prompt.go`'s + * `PromptChoice`: `tea.WithOutput(os.Stderr)`, "Interactive prompts should always + * be written to stderr"). + */ + readonly stream?: "stdout" | "stderr"; } /** diff --git a/apps/cli/src/shared/telemetry/standalone-analytics-config.layer.ts b/apps/cli/src/shared/telemetry/standalone-analytics-config.layer.ts new file mode 100644 index 0000000000..3c25998087 --- /dev/null +++ b/apps/cli/src/shared/telemetry/standalone-analytics-config.layer.ts @@ -0,0 +1,26 @@ +import { Layer } from "effect"; +import { cliConfigLayer } from "../../next/config/cli-config.layer.ts"; +import { projectContextLayer } from "../../next/config/project-context.layer.ts"; +import { runtimeInfoLayer } from "../runtime/runtime-info.layer.ts"; +import { ttyLayer } from "../runtime/tty.layer.ts"; + +/** + * Resolves `CliConfig | RuntimeInfo | Tty` — the services `telemetryRuntimeLayer` + * (and, transitively, `analyticsLayer`/`legacyAnalyticsLayer`) need beyond the + * `FileSystem`/`Path` platform layer — for callers that build and run an + * `Analytics`-capturing effect OUTSIDE `runCli`'s own composed layer tree + * (`shared/cli/run.ts` already wires the equivalent of this inline for every + * command run via its own `cliConfigLayerFor`/`projectContextLayerFor` + * helpers). The one caller today is `legacy/cli/legacy-complete.ts`'s + * `__complete`/`__completeNoDesc` telemetry capture, which fires before + * `runCli` ever bootstraps. + * + * Still requires the platform layer (`FileSystem`/`Path`, e.g. + * `@effect/platform-bun`'s `BunServices.layer`) to be provided separately by + * the caller, matching `run.ts`'s own top-level `Effect.provide(BunServices.layer)`. + */ +export const standaloneAnalyticsConfigLayer = Layer.mergeAll( + cliConfigLayer.pipe(Layer.provide(projectContextLayer), Layer.provide(runtimeInfoLayer)), + runtimeInfoLayer, + ttyLayer, +); diff --git a/apps/cli/tests/helpers/legacy-local-reset.ts b/apps/cli/tests/helpers/legacy-local-reset.ts new file mode 100644 index 0000000000..7749190d23 --- /dev/null +++ b/apps/cli/tests/helpers/legacy-local-reset.ts @@ -0,0 +1,177 @@ +import { Effect, Layer, PlatformError, Sink, Stream } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; + +/** + * A minimal `docker`/`podman` CLI spawner mock + default happy-path route for + * `legacyResetLocalDatabase`'s real, native container-recreate flow — used + * wherever a test now drives a REAL in-process local reset instead of a + * subprocess/seam stub (CLI-2062: `db schema declarative`'s smart-target/sync + * recovery reset). Mirrors `commands/db/reset/reset.integration.test.ts`'s own + * `mockContainerCliSpawner`/`defaultLocalResetRoute` (that file predates this + * hoist and keeps its own copy, adapted for its container-REMOVE-then-recreate + * assertions) — same shape here, hoisted for the two `db schema declarative` + * callers so they don't each duplicate it again. + */ + +export interface LegacySpawnRecord { + readonly args: ReadonlyArray; +} + +export type LegacyRouteResult = { + readonly exitCode?: number; + readonly stdout?: ReadonlyArray; + readonly stderr?: ReadonlyArray; +}; + +export function mockContainerCliSpawner(route: (args: ReadonlyArray) => LegacyRouteResult) { + const spawned: Array = []; + const encoder = new TextEncoder(); + + const layer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => + Effect.gen(function* () { + const args = command._tag === "StandardCommand" ? command.args : []; + spawned.push({ args }); + + if (command._tag !== "StandardCommand") { + return yield* Effect.fail( + PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: "spawn failed", + }), + ); + } + + const result = route(args); + const stdoutBytes = (result.stdout ?? []).map((line) => encoder.encode(`${line}\n`)); + const stderrBytes = (result.stderr ?? []).map((line) => encoder.encode(`${line}\n`)); + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(6000 + spawned.length), + stdout: Stream.fromIterable(stdoutBytes), + stderr: Stream.fromIterable(stderrBytes), + all: Stream.empty, + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(result.exitCode ?? 0)), + isRunning: Effect.succeed(false), + stdin: Sink.drain, + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + }), + ), + ); + + return { + layer, + get spawned() { + return spawned; + }, + }; +} + +export interface LegacyDefaultLocalResetRouteOpts { + readonly running?: boolean; + readonly kongMissing?: boolean; + readonly kongNotRunning?: boolean; + readonly storageMissing?: boolean; +} + +const HEALTHY_STATE = '{"Running":true,"Status":"running","Health":{"Status":"healthy"}}'; +const STOPPED_STATE = '{"Running":false,"Status":"exited"}'; + +function containerNameFromCreateArgs(args: ReadonlyArray): string { + const nameIndex = args.indexOf("--name"); + return nameIndex !== -1 ? (args[nameIndex + 1] ?? "unknown") : "unknown"; +} + +function fakeContainerId(name: string): string { + return [...name] + .map((char) => (char.codePointAt(0) ?? 0).toString(16).padStart(2, "0")) + .join("") + .padEnd(64, "0") + .slice(0, 64); +} + +/** + * A happy-path Docker CLI route for `legacyResetLocalDatabase`'s PG15+ + * container-recreate — everything succeeds (running, healthy, no restart + * failures) unless overridden. `projectId` must match the `LegacyCliConfig` + * mock's own `projectId` (both default to `"test"`), since container names are + * derived from it (`supabase_db_`, `supabase_kong_`, + * `supabase_storage_`). + */ +export function defaultLocalResetRoute( + projectId = "test", + opts: LegacyDefaultLocalResetRouteOpts = {}, +) { + const dbId = `supabase_db_${projectId}`; + const kongId = `supabase_kong_${projectId}`; + const storageId = `supabase_storage_${projectId}`; + return (args: ReadonlyArray): LegacyRouteResult => { + if (args[0] === "image" && args[1] === "inspect") return { exitCode: 0 }; + if (args[0] === "context" && args[1] === "inspect") return { exitCode: 1 }; + if (args[0] === "container" && args[1] === "rm") return { exitCode: 0 }; + if (args[0] === "volume" && args[1] === "rm") return { exitCode: 0 }; + if (args[0] === "network" && args[1] === "create") return { exitCode: 0 }; + if (args[0] === "volume" && args[1] === "create") return { exitCode: 0 }; + if (args[0] === "create") { + const name = containerNameFromCreateArgs(args); + return { stdout: [fakeContainerId(name)] }; + } + if (args[0] === "start") return { exitCode: 0 }; + if (args[0] === "restart") return { exitCode: 0 }; + if (args[0] === "exec" && args[1] === kongId) return { exitCode: 0 }; + if (args[0] === "container" && args[1] === "inspect") { + const id = args[2] ?? ""; + if (id === kongId) { + if (opts.kongMissing === true) + return { exitCode: 1, stderr: [`Error: No such container: ${id}`] }; + return { stdout: [opts.kongNotRunning === true ? STOPPED_STATE : HEALTHY_STATE] }; + } + if (id === storageId) { + if (opts.storageMissing === true) + return { exitCode: 1, stderr: [`Error: No such container: ${id}`] }; + return { stdout: [HEALTHY_STATE] }; + } + if (id === dbId && opts.running === false) { + return { exitCode: 1, stderr: [`Error: No such container: ${id}`] }; + } + return { stdout: [HEALTHY_STATE] }; + } + if (args[0] === "logs") return { exitCode: 0 }; + if (args[0] === "ps") return { stdout: [] }; + return { exitCode: 0 }; + }; +} + +/** Selects the `docker create` argv for the recreated `db` container, if any. */ +export const legacyLocalResetCreateArgs = ( + spawned: ReadonlyArray, +): ReadonlyArray | undefined => spawned.find((s) => s.args[0] === "create")?.args; + +/** `docker container rm -f ` targets — the id is argv[3], after the `-f` flag at argv[2]. */ +export const legacyLocalResetRemovedContainers = ( + spawned: ReadonlyArray, +): ReadonlyArray => + spawned + .filter((s) => s.args[0] === "container" && s.args[1] === "rm") + .map((s) => s.args[3] ?? ""); + +/** + * An HTTP client that answers every request with an empty `200 OK` — satisfies + * `legacyAwaitStorageReady`'s static `HttpClient.HttpClient` requirement without + * this route ever really being reached (storage health is checked purely via + * the container-CLI spawner above). + */ +export const alwaysReadyHttpClientLayer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status: 200 }))), + ), +); diff --git a/apps/cli/tests/helpers/legacy-mocks.ts b/apps/cli/tests/helpers/legacy-mocks.ts index 1242bd1d44..311ec8f621 100644 --- a/apps/cli/tests/helpers/legacy-mocks.ts +++ b/apps/cli/tests/helpers/legacy-mocks.ts @@ -267,11 +267,20 @@ export function mockLegacyLoginApi( export function mockLegacyTelemetryStateTracked(): { readonly layer: Layer.Layer; readonly flushed: boolean; + /** + * Number of `flush` calls — beyond the plain `flushed` boolean, this lets a + * test prove a command's own `Effect.ensuring` finalizer fired EXACTLY once + * even when its body calls an in-process helper (e.g. `legacyResetLocalDatabase`, + * CLI-2062) that could, if it wrongly owned a second finalizer, double the + * count instead of leaving it at 1. + */ + readonly flushCount: number; readonly stitchedDistinctId: string | undefined; readonly clearedDistinctId: boolean; readonly identityReset: boolean; } { let flushed = false; + let flushCount = 0; let stitchedDistinctId: string | undefined; let clearedDistinctId = false; let identityReset = false; @@ -279,6 +288,7 @@ export function mockLegacyTelemetryStateTracked(): { get flush() { return Effect.sync(() => { flushed = true; + flushCount += 1; }); }, stitchLogin: (distinctId: string) => @@ -301,6 +311,9 @@ export function mockLegacyTelemetryStateTracked(): { get flushed() { return flushed; }, + get flushCount() { + return flushCount; + }, get stitchedDistinctId() { return stitchedDistinctId; }, @@ -316,11 +329,14 @@ export function mockLegacyTelemetryStateTracked(): { export function mockLegacyLinkedProjectCacheTracked(): { readonly layer: Layer.Layer; readonly cached: boolean; + /** Number of `cache` calls — see {@link mockLegacyTelemetryStateTracked}'s own `flushCount`. */ + readonly cacheCount: number; readonly cachedRef: string | undefined; readonly cachedApiUrl: string | undefined; readonly cachedAccessToken: Option.Option> | undefined; } { let cached = false; + let cacheCount = 0; let cachedRef: string | undefined; let cachedApiUrl: string | undefined; let cachedAccessToken: Option.Option> | undefined; @@ -333,6 +349,7 @@ export function mockLegacyLinkedProjectCacheTracked(): { ) => Effect.sync(() => { cached = true; + cacheCount += 1; cachedRef = ref; cachedApiUrl = apiUrl; cachedAccessToken = accessToken; @@ -343,6 +360,9 @@ export function mockLegacyLinkedProjectCacheTracked(): { get cached() { return cached; }, + get cacheCount() { + return cacheCount; + }, get cachedRef() { return cachedRef; }, diff --git a/packages/api/scripts/generate.ts b/packages/api/scripts/generate.ts index f6ff09526b..8d1e52f11d 100644 --- a/packages/api/scripts/generate.ts +++ b/packages/api/scripts/generate.ts @@ -290,6 +290,15 @@ export function sanitizeOpenApiSchema( sanitized.pattern = UUID_PATTERN; } + // The spec's `date-time` patterns reject timestamps the Management API + // itself emits: most are Z-anchored, and even the most permissive variant + // rejects offset-less values and the lowercase `t`/`z` RFC 3339 §5.6 allows + // (supabase/cli#6115). Keeping any of them means owning a guess about every + // shape the API may serialize, so keep `format` and drop the pattern. + if (sanitized.type === "string" && sanitized.format === "date-time") { + delete sanitized.pattern; + } + return sanitized; } diff --git a/packages/api/scripts/generate.unit.test.ts b/packages/api/scripts/generate.unit.test.ts index 5b4dbe720e..c0e2dab526 100644 --- a/packages/api/scripts/generate.unit.test.ts +++ b/packages/api/scripts/generate.unit.test.ts @@ -33,6 +33,27 @@ describe("generate", () => { ); }); + test("drops the spec's Z-only pattern from date-time strings (#6115)", () => { + expect( + renderOpenApiSchema({ + type: "string", + format: "date-time", + pattern: "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z$", + nullable: true, + }), + ).toBe('Schema.Union([Schema.String.annotate({ "format": "date-time" }), Schema.Null])'); + }); + + test("drops even an offset-tolerant date-time pattern (#6115)", () => { + // The spec's most permissive variant still rejects offset-less values and + // the lowercase `t`/`z` RFC 3339 §5.6 allows, so no date-time pattern survives. + const offsetTolerant = + "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?(Z|[+-]\\d{2}:\\d{2})$"; + expect( + renderOpenApiSchema({ type: "string", format: "date-time", pattern: offsetTolerant }), + ).not.toContain("isPattern"); + }); + test("accepts booleans for string-encoded boolean query parameters", () => { expect( normalizeQueryParameterSchema( diff --git a/packages/api/src/generated/contracts.ts b/packages/api/src/generated/contracts.ts index 6e5f36f43b..1cbcb9dcc7 100644 --- a/packages/api/src/generated/contracts.ts +++ b/packages/api/src/generated/contracts.ts @@ -77,34 +77,10 @@ export const ApiKeyResponse = Schema.Struct({ ]), ), inserted_at: Schema.optionalKey( - Schema.Union([ - Schema.String.annotate({ format: "date-time" }).check( - Schema.isPattern( - new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - ), - ).annotate({ - expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - }), - ), - Schema.Null, - ]), + Schema.Union([Schema.String.annotate({ format: "date-time" }), Schema.Null]), ), updated_at: Schema.optionalKey( - Schema.Union([ - Schema.String.annotate({ format: "date-time" }).check( - Schema.isPattern( - new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - ), - ).annotate({ - expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - }), - ), - Schema.Null, - ]), + Schema.Union([Schema.String.annotate({ format: "date-time" }), Schema.Null]), ), }).annotate({ identifier: "ApiKeyResponse" }); export const V1ServiceHealthResponse = Schema.Struct({ @@ -194,52 +170,12 @@ export const BranchResponse = Schema.Struct({ ]).annotate({ description: "This field is deprecated. List action runs to get branch status instead.", }), - created_at: Schema.String.annotate({ format: "date-time" }).check( - Schema.isPattern( - new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - ), - ).annotate({ - expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - }), - ), - updated_at: Schema.String.annotate({ format: "date-time" }).check( - Schema.isPattern( - new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - ), - ).annotate({ - expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - }), - ), - review_requested_at: Schema.optionalKey( - Schema.String.annotate({ format: "date-time" }).check( - Schema.isPattern( - new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - ), - ).annotate({ - expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - }), - ), - ), + created_at: Schema.String.annotate({ format: "date-time" }), + updated_at: Schema.String.annotate({ format: "date-time" }), + review_requested_at: Schema.optionalKey(Schema.String.annotate({ format: "date-time" })), with_data: Schema.Boolean, notify_url: Schema.optionalKey(Schema.String.annotate({ format: "uri" })), - deletion_scheduled_at: Schema.optionalKey( - Schema.String.annotate({ format: "date-time" }).check( - Schema.isPattern( - new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - ), - ).annotate({ - expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - }), - ), - ), + deletion_scheduled_at: Schema.optionalKey(Schema.String.annotate({ format: "date-time" })), preview_project_status: Schema.optionalKey( Schema.Literals([ "INACTIVE", @@ -1109,52 +1045,12 @@ export const V1CreateABranchOutput = Schema.Struct({ ]).annotate({ description: "This field is deprecated. List action runs to get branch status instead.", }), - created_at: Schema.String.annotate({ format: "date-time" }).check( - Schema.isPattern( - new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - ), - ).annotate({ - expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - }), - ), - updated_at: Schema.String.annotate({ format: "date-time" }).check( - Schema.isPattern( - new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - ), - ).annotate({ - expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - }), - ), - review_requested_at: Schema.optionalKey( - Schema.String.annotate({ format: "date-time" }).check( - Schema.isPattern( - new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - ), - ).annotate({ - expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - }), - ), - ), + created_at: Schema.String.annotate({ format: "date-time" }), + updated_at: Schema.String.annotate({ format: "date-time" }), + review_requested_at: Schema.optionalKey(Schema.String.annotate({ format: "date-time" })), with_data: Schema.Boolean, notify_url: Schema.optionalKey(Schema.String.annotate({ format: "uri" })), - deletion_scheduled_at: Schema.optionalKey( - Schema.String.annotate({ format: "date-time" }).check( - Schema.isPattern( - new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - ), - ).annotate({ - expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - }), - ), - ), + deletion_scheduled_at: Schema.optionalKey(Schema.String.annotate({ format: "date-time" })), preview_project_status: Schema.optionalKey( Schema.Literals([ "INACTIVE", @@ -1550,26 +1446,8 @@ export const V1CreateLegacySigningKeyOutput = Schema.Struct({ algorithm: Schema.Literals(["EdDSA", "ES256", "RS256", "HS256"]), status: Schema.Literals(["in_use", "previously_used", "revoked", "standby"]), public_jwk: Schema.Union([Schema.Json.annotate({ expected: "JSON value" }), Schema.Null]), - created_at: Schema.String.annotate({ format: "date-time" }).check( - Schema.isPattern( - new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - ), - ).annotate({ - expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - }), - ), - updated_at: Schema.String.annotate({ format: "date-time" }).check( - Schema.isPattern( - new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - ), - ).annotate({ - expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - }), - ), + created_at: Schema.String.annotate({ format: "date-time" }), + updated_at: Schema.String.annotate({ format: "date-time" }), }); export const V1CreateLoginRoleInput = Schema.Struct({ ref: Schema.String.check( @@ -1660,34 +1538,10 @@ export const V1CreateProjectApiKeyOutput = Schema.Struct({ ]), ), inserted_at: Schema.optionalKey( - Schema.Union([ - Schema.String.annotate({ format: "date-time" }).check( - Schema.isPattern( - new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - ), - ).annotate({ - expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - }), - ), - Schema.Null, - ]), + Schema.Union([Schema.String.annotate({ format: "date-time" }), Schema.Null]), ), updated_at: Schema.optionalKey( - Schema.Union([ - Schema.String.annotate({ format: "date-time" }).check( - Schema.isPattern( - new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - ), - ).annotate({ - expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - }), - ), - Schema.Null, - ]), + Schema.Union([Schema.String.annotate({ format: "date-time" }), Schema.Null]), ), }); export const V1CreateProjectClaimTokenInput = Schema.Struct({ @@ -1877,26 +1731,8 @@ export const V1CreateProjectSigningKeyOutput = Schema.Struct({ algorithm: Schema.Literals(["EdDSA", "ES256", "RS256", "HS256"]), status: Schema.Literals(["in_use", "previously_used", "revoked", "standby"]), public_jwk: Schema.Union([Schema.Json.annotate({ expected: "JSON value" }), Schema.Null]), - created_at: Schema.String.annotate({ format: "date-time" }).check( - Schema.isPattern( - new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - ), - ).annotate({ - expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - }), - ), - updated_at: Schema.String.annotate({ format: "date-time" }).check( - Schema.isPattern( - new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - ), - ).annotate({ - expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - }), - ), + created_at: Schema.String.annotate({ format: "date-time" }), + updated_at: Schema.String.annotate({ format: "date-time" }), }); export const V1CreateProjectTpaIntegrationInput = Schema.Struct({ ref: Schema.String.check( @@ -1953,19 +1789,7 @@ export const V1CreateRestorePointInput = Schema.Struct({ export const V1CreateRestorePointOutput = Schema.Struct({ name: Schema.String, status: Schema.Literals(["AVAILABLE", "PENDING", "REMOVED", "FAILED"]), - completed_on: Schema.Union([ - Schema.String.annotate({ format: "date-time" }).check( - Schema.isPattern( - new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - ), - ).annotate({ - expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - }), - ), - Schema.Null, - ]), + completed_on: Schema.Union([Schema.String.annotate({ format: "date-time" }), Schema.Null]), }); export const V1DeactivateVanitySubdomainConfigInput = Schema.Struct({ ref: Schema.String.check( @@ -2254,34 +2078,10 @@ export const V1DeleteProjectApiKeyOutput = Schema.Struct({ ]), ), inserted_at: Schema.optionalKey( - Schema.Union([ - Schema.String.annotate({ format: "date-time" }).check( - Schema.isPattern( - new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - ), - ).annotate({ - expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - }), - ), - Schema.Null, - ]), + Schema.Union([Schema.String.annotate({ format: "date-time" }), Schema.Null]), ), updated_at: Schema.optionalKey( - Schema.Union([ - Schema.String.annotate({ format: "date-time" }).check( - Schema.isPattern( - new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - ), - ).annotate({ - expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - }), - ), - Schema.Null, - ]), + Schema.Union([Schema.String.annotate({ format: "date-time" }), Schema.Null]), ), }); export const V1DeleteProjectClaimTokenInput = Schema.Struct({ @@ -2610,52 +2410,12 @@ export const V1GetABranchOutput = Schema.Struct({ ]).annotate({ description: "This field is deprecated. List action runs to get branch status instead.", }), - created_at: Schema.String.annotate({ format: "date-time" }).check( - Schema.isPattern( - new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - ), - ).annotate({ - expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - }), - ), - updated_at: Schema.String.annotate({ format: "date-time" }).check( - Schema.isPattern( - new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - ), - ).annotate({ - expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - }), - ), - review_requested_at: Schema.optionalKey( - Schema.String.annotate({ format: "date-time" }).check( - Schema.isPattern( - new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - ), - ).annotate({ - expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - }), - ), - ), + created_at: Schema.String.annotate({ format: "date-time" }), + updated_at: Schema.String.annotate({ format: "date-time" }), + review_requested_at: Schema.optionalKey(Schema.String.annotate({ format: "date-time" })), with_data: Schema.Boolean, notify_url: Schema.optionalKey(Schema.String.annotate({ format: "uri" })), - deletion_scheduled_at: Schema.optionalKey( - Schema.String.annotate({ format: "date-time" }).check( - Schema.isPattern( - new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - ), - ).annotate({ - expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - }), - ), - ), + deletion_scheduled_at: Schema.optionalKey(Schema.String.annotate({ format: "date-time" })), preview_project_status: Schema.optionalKey( Schema.Literals([ "INACTIVE", @@ -3677,16 +3437,7 @@ export const V1GetAuthServiceConfigOutput = Schema.Struct({ sms_template: Schema.Union([Schema.String, Schema.Null]), sms_test_otp: Schema.Union([Schema.String, Schema.Null]), sms_test_otp_valid_until: Schema.Union([ - Schema.String.annotate({ format: "date-time" }).check( - Schema.isPattern( - new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", - ), - ).annotate({ - expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", - }), - ), + Schema.String.annotate({ format: "date-time" }), Schema.Null, ]), sms_textlocal_api_key: Schema.Union([Schema.String, Schema.Null]), @@ -3878,16 +3629,7 @@ export const V1GetBackupScheduleOutput = Schema.Struct({ updated_at: Schema.String.annotate({ description: "Timestamp of when the backup schedule was last updated.", format: "date-time", - }).check( - Schema.isPattern( - new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", - ), - ).annotate({ - expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", - }), - ), + }), }); export const V1GetDatabaseDiskInput = Schema.Struct({ ref: Schema.String.check( @@ -4180,26 +3922,8 @@ export const V1GetLegacySigningKeyOutput = Schema.Struct({ algorithm: Schema.Literals(["EdDSA", "ES256", "RS256", "HS256"]), status: Schema.Literals(["in_use", "previously_used", "revoked", "standby"]), public_jwk: Schema.Union([Schema.Json.annotate({ expected: "JSON value" }), Schema.Null]), - created_at: Schema.String.annotate({ format: "date-time" }).check( - Schema.isPattern( - new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - ), - ).annotate({ - expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - }), - ), - updated_at: Schema.String.annotate({ format: "date-time" }).check( - Schema.isPattern( - new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - ), - ).annotate({ - expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - }), - ), + created_at: Schema.String.annotate({ format: "date-time" }), + updated_at: Schema.String.annotate({ format: "date-time" }), }); export const V1GetNetworkRestrictionsInput = Schema.Struct({ ref: Schema.String.check( @@ -4231,30 +3955,8 @@ export const V1GetNetworkRestrictionsOutput = Schema.Struct({ }), ), status: Schema.Literals(["stored", "applied"]), - updated_at: Schema.optionalKey( - Schema.String.annotate({ format: "date-time" }).check( - Schema.isPattern( - new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - ), - ).annotate({ - expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - }), - ), - ), - applied_at: Schema.optionalKey( - Schema.String.annotate({ format: "date-time" }).check( - Schema.isPattern( - new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - ), - ).annotate({ - expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - }), - ), - ), + updated_at: Schema.optionalKey(Schema.String.annotate({ format: "date-time" })), + applied_at: Schema.optionalKey(Schema.String.annotate({ format: "date-time" })), }); export const V1GetOrganizationEntitlementsInput = Schema.Struct({ slug: Schema.String.check( @@ -5016,34 +4718,10 @@ export const V1GetProjectApiKeyOutput = Schema.Struct({ ]), ), inserted_at: Schema.optionalKey( - Schema.Union([ - Schema.String.annotate({ format: "date-time" }).check( - Schema.isPattern( - new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - ), - ).annotate({ - expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - }), - ), - Schema.Null, - ]), + Schema.Union([Schema.String.annotate({ format: "date-time" }), Schema.Null]), ), updated_at: Schema.optionalKey( - Schema.Union([ - Schema.String.annotate({ format: "date-time" }).check( - Schema.isPattern( - new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - ), - ).annotate({ - expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - }), - ), - Schema.Null, - ]), + Schema.Union([Schema.String.annotate({ format: "date-time" }), Schema.Null]), ), }); export const V1GetProjectApiKeysInput = Schema.Struct({ @@ -5189,30 +4867,8 @@ export const V1GetProjectLogsInput = Schema.Struct({ }), ), sql: Schema.optionalKey(Schema.String), - iso_timestamp_start: Schema.optionalKey( - Schema.String.annotate({ format: "date-time" }).check( - Schema.isPattern( - new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - ), - ).annotate({ - expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - }), - ), - ), - iso_timestamp_end: Schema.optionalKey( - Schema.String.annotate({ format: "date-time" }).check( - Schema.isPattern( - new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - ), - ).annotate({ - expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - }), - ), - ), + iso_timestamp_start: Schema.optionalKey(Schema.String.annotate({ format: "date-time" })), + iso_timestamp_end: Schema.optionalKey(Schema.String.annotate({ format: "date-time" })), }); export const V1GetProjectLogsOutput = Schema.Struct({ result: Schema.optionalKey(Schema.Array(Schema.Json.annotate({ expected: "JSON value" }))), @@ -5247,30 +4903,8 @@ export const V1GetProjectLogsAllInput = Schema.Struct({ }), ), sql: Schema.optionalKey(Schema.String), - iso_timestamp_start: Schema.optionalKey( - Schema.String.annotate({ format: "date-time" }).check( - Schema.isPattern( - new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - ), - ).annotate({ - expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - }), - ), - ), - iso_timestamp_end: Schema.optionalKey( - Schema.String.annotate({ format: "date-time" }).check( - Schema.isPattern( - new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - ), - ).annotate({ - expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - }), - ), - ), + iso_timestamp_start: Schema.optionalKey(Schema.String.annotate({ format: "date-time" })), + iso_timestamp_end: Schema.optionalKey(Schema.String.annotate({ format: "date-time" })), }); export const V1GetProjectLogsAllOutput = Schema.Struct({ result: Schema.optionalKey(Schema.Array(Schema.Json.annotate({ expected: "JSON value" }))), @@ -5423,26 +5057,8 @@ export const V1GetProjectSigningKeyOutput = Schema.Struct({ algorithm: Schema.Literals(["EdDSA", "ES256", "RS256", "HS256"]), status: Schema.Literals(["in_use", "previously_used", "revoked", "standby"]), public_jwk: Schema.Union([Schema.Json.annotate({ expected: "JSON value" }), Schema.Null]), - created_at: Schema.String.annotate({ format: "date-time" }).check( - Schema.isPattern( - new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - ), - ).annotate({ - expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - }), - ), - updated_at: Schema.String.annotate({ format: "date-time" }).check( - Schema.isPattern( - new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - ), - ).annotate({ - expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - }), - ), + created_at: Schema.String.annotate({ format: "date-time" }), + updated_at: Schema.String.annotate({ format: "date-time" }), }); export const V1GetProjectSigningKeysInput = Schema.Struct({ ref: Schema.String.check( @@ -5471,26 +5087,8 @@ export const V1GetProjectSigningKeysOutput = Schema.Struct({ algorithm: Schema.Literals(["EdDSA", "ES256", "RS256", "HS256"]), status: Schema.Literals(["in_use", "previously_used", "revoked", "standby"]), public_jwk: Schema.Union([Schema.Json.annotate({ expected: "JSON value" }), Schema.Null]), - created_at: Schema.String.annotate({ format: "date-time" }).check( - Schema.isPattern( - new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - ), - ).annotate({ - expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - }), - ), - updated_at: Schema.String.annotate({ format: "date-time" }).check( - Schema.isPattern( - new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - ), - ).annotate({ - expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - }), - ), + created_at: Schema.String.annotate({ format: "date-time" }), + updated_at: Schema.String.annotate({ format: "date-time" }), }), ), }); @@ -5557,16 +5155,7 @@ export const V1GetProjectUsageApiCountOutput = Schema.Struct({ result: Schema.optionalKey( Schema.Array( Schema.Struct({ - timestamp: Schema.String.annotate({ format: "date-time" }).check( - Schema.isPattern( - new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|))$", - ), - ).annotate({ - expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|))$", - }), - ), + timestamp: Schema.String.annotate({ format: "date-time" }), total_auth_requests: Schema.Number.check( Schema.isFinite().annotate({ expected: "a finite number" }), ), @@ -5825,19 +5414,7 @@ export const V1GetRestorePointInput = Schema.Struct({ export const V1GetRestorePointOutput = Schema.Struct({ name: Schema.String, status: Schema.Literals(["AVAILABLE", "PENDING", "REMOVED", "FAILED"]), - completed_on: Schema.Union([ - Schema.String.annotate({ format: "date-time" }).check( - Schema.isPattern( - new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - ), - ).annotate({ - expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - }), - ), - Schema.Null, - ]), + completed_on: Schema.Union([Schema.String.annotate({ format: "date-time" }), Schema.Null]), }); export const V1GetSecurityAdvisorsInput = Schema.Struct({ ref: Schema.String.check( @@ -7053,30 +6630,8 @@ export const V1PatchNetworkRestrictionsOutput = Schema.Struct({ "Populated when a new config has been received, but not registered as successfully applied to a project.", }), ), - updated_at: Schema.optionalKey( - Schema.String.annotate({ format: "date-time" }).check( - Schema.isPattern( - new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - ), - ).annotate({ - expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - }), - ), - ), - applied_at: Schema.optionalKey( - Schema.String.annotate({ format: "date-time" }).check( - Schema.isPattern( - new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - ), - ).annotate({ - expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - }), - ), - ), + updated_at: Schema.optionalKey(Schema.String.annotate({ format: "date-time" })), + applied_at: Schema.optionalKey(Schema.String.annotate({ format: "date-time" })), status: Schema.Literals(["stored", "applied"]), }); export const V1PauseAProjectInput = Schema.Struct({ @@ -7215,26 +6770,8 @@ export const V1RemoveProjectSigningKeyOutput = Schema.Struct({ algorithm: Schema.Literals(["EdDSA", "ES256", "RS256", "HS256"]), status: Schema.Literals(["in_use", "previously_used", "revoked", "standby"]), public_jwk: Schema.Union([Schema.Json.annotate({ expected: "JSON value" }), Schema.Null]), - created_at: Schema.String.annotate({ format: "date-time" }).check( - Schema.isPattern( - new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - ), - ).annotate({ - expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - }), - ), - updated_at: Schema.String.annotate({ format: "date-time" }).check( - Schema.isPattern( - new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - ), - ).annotate({ - expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - }), - ), + created_at: Schema.String.annotate({ format: "date-time" }), + updated_at: Schema.String.annotate({ format: "date-time" }), }); export const V1ResetABranchInput = Schema.Struct({ branch_id_or_ref: Schema.Union([ @@ -7561,52 +7098,12 @@ export const V1UpdateABranchConfigOutput = Schema.Struct({ ]).annotate({ description: "This field is deprecated. List action runs to get branch status instead.", }), - created_at: Schema.String.annotate({ format: "date-time" }).check( - Schema.isPattern( - new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - ), - ).annotate({ - expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - }), - ), - updated_at: Schema.String.annotate({ format: "date-time" }).check( - Schema.isPattern( - new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - ), - ).annotate({ - expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - }), - ), - review_requested_at: Schema.optionalKey( - Schema.String.annotate({ format: "date-time" }).check( - Schema.isPattern( - new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - ), - ).annotate({ - expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - }), - ), - ), + created_at: Schema.String.annotate({ format: "date-time" }), + updated_at: Schema.String.annotate({ format: "date-time" }), + review_requested_at: Schema.optionalKey(Schema.String.annotate({ format: "date-time" })), with_data: Schema.Boolean, notify_url: Schema.optionalKey(Schema.String.annotate({ format: "uri" })), - deletion_scheduled_at: Schema.optionalKey( - Schema.String.annotate({ format: "date-time" }).check( - Schema.isPattern( - new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - ), - ).annotate({ - expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - }), - ), - ), + deletion_scheduled_at: Schema.optionalKey(Schema.String.annotate({ format: "date-time" })), preview_project_status: Schema.optionalKey( Schema.Literals([ "INACTIVE", @@ -8367,19 +7864,7 @@ export const V1UpdateAuthServiceConfigInput = Schema.Struct({ ]), ), sms_test_otp_valid_until: Schema.optionalKey( - Schema.Union([ - Schema.String.annotate({ format: "date-time" }).check( - Schema.isPattern( - new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", - ), - ).annotate({ - expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", - }), - ), - Schema.Null, - ]), + Schema.Union([Schema.String.annotate({ format: "date-time" }), Schema.Null]), ), sms_textlocal_api_key: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), sms_textlocal_sender: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), @@ -9121,16 +8606,7 @@ export const V1UpdateAuthServiceConfigOutput = Schema.Struct({ sms_template: Schema.Union([Schema.String, Schema.Null]), sms_test_otp: Schema.Union([Schema.String, Schema.Null]), sms_test_otp_valid_until: Schema.Union([ - Schema.String.annotate({ format: "date-time" }).check( - Schema.isPattern( - new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", - ), - ).annotate({ - expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", - }), - ), + Schema.String.annotate({ format: "date-time" }), Schema.Null, ]), sms_textlocal_api_key: Schema.Union([Schema.String, Schema.Null]), @@ -9231,16 +8707,7 @@ export const V1UpdateBackupScheduleOutput = Schema.Struct({ updated_at: Schema.String.annotate({ description: "Timestamp of when the backup schedule was last updated.", format: "date-time", - }).check( - Schema.isPattern( - new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", - ), - ).annotate({ - expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", - }), - ), + }), }); export const V1UpdateDatabasePasswordInput = Schema.Struct({ ref: Schema.String.check( @@ -9499,30 +8966,8 @@ export const V1UpdateNetworkRestrictionsOutput = Schema.Struct({ }), ), status: Schema.Literals(["stored", "applied"]), - updated_at: Schema.optionalKey( - Schema.String.annotate({ format: "date-time" }).check( - Schema.isPattern( - new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - ), - ).annotate({ - expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - }), - ), - ), - applied_at: Schema.optionalKey( - Schema.String.annotate({ format: "date-time" }).check( - Schema.isPattern( - new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - ), - ).annotate({ - expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - }), - ), - ), + updated_at: Schema.optionalKey(Schema.String.annotate({ format: "date-time" })), + applied_at: Schema.optionalKey(Schema.String.annotate({ format: "date-time" })), }); export const V1UpdatePgsodiumConfigInput = Schema.Struct({ ref: Schema.String.check( @@ -10151,34 +9596,10 @@ export const V1UpdateProjectApiKeyOutput = Schema.Struct({ ]), ), inserted_at: Schema.optionalKey( - Schema.Union([ - Schema.String.annotate({ format: "date-time" }).check( - Schema.isPattern( - new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - ), - ).annotate({ - expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - }), - ), - Schema.Null, - ]), + Schema.Union([Schema.String.annotate({ format: "date-time" }), Schema.Null]), ), updated_at: Schema.optionalKey( - Schema.Union([ - Schema.String.annotate({ format: "date-time" }).check( - Schema.isPattern( - new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - ), - ).annotate({ - expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - }), - ), - Schema.Null, - ]), + Schema.Union([Schema.String.annotate({ format: "date-time" }), Schema.Null]), ), }); export const V1UpdateProjectLegacyApiKeysInput = Schema.Struct({ @@ -10230,26 +9651,8 @@ export const V1UpdateProjectSigningKeyOutput = Schema.Struct({ algorithm: Schema.Literals(["EdDSA", "ES256", "RS256", "HS256"]), status: Schema.Literals(["in_use", "previously_used", "revoked", "standby"]), public_jwk: Schema.Union([Schema.Json.annotate({ expected: "JSON value" }), Schema.Null]), - created_at: Schema.String.annotate({ format: "date-time" }).check( - Schema.isPattern( - new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - ), - ).annotate({ - expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - }), - ), - updated_at: Schema.String.annotate({ format: "date-time" }).check( - Schema.isPattern( - new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - ), - ).annotate({ - expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - }), - ), + created_at: Schema.String.annotate({ format: "date-time" }), + updated_at: Schema.String.annotate({ format: "date-time" }), }); export const V1UpdateRealtimeConfigInput = Schema.Struct({ ref: Schema.String.check( diff --git a/packages/api/src/internal/client.unit.test.ts b/packages/api/src/internal/client.unit.test.ts index 76515ed47b..9887879346 100644 --- a/packages/api/src/internal/client.unit.test.ts +++ b/packages/api/src/internal/client.unit.test.ts @@ -386,6 +386,71 @@ describe("makeSupabaseApiClient", () => { ]); }); + // Both payloads are the shapes reported against 2.112.0, where the spec's + // Z-anchored pattern rejected them and broke `link` and `branches list` + // outright (supabase/cli#6115). + test("decodes timestamps with a numeric UTC offset", async () => { + const apiKeys = await Effect.runPromise( + makeSupabaseApiClient(config).pipe( + Effect.flatMap((client) => + client.execute<"v1GetProjectApiKeys">(operationDefinitions.v1GetProjectApiKeys, { + ref: "abcdefghijklmnopqrst", + }), + ), + Effect.provide( + httpClientLayer((request) => + Effect.succeed( + jsonResponse(request, 200, [ + { + name: "anon", + type: "legacy", + api_key: "anon-key", + inserted_at: "2026-05-01T08:00:00+00:00", + updated_at: "2026-05-01T08:00:00.123456+02:00", + }, + ]), + ), + ), + ), + ), + ); + + expect(apiKeys[0]?.inserted_at).toBe("2026-05-01T08:00:00+00:00"); + expect(apiKeys[0]?.updated_at).toBe("2026-05-01T08:00:00.123456+02:00"); + + const branches = await Effect.runPromise( + makeSupabaseApiClient(config).pipe( + Effect.flatMap((client) => + client.execute<"v1ListAllBranches">(operationDefinitions.v1ListAllBranches, { + ref: "abcdefghijklmnopqrst", + }), + ), + Effect.provide( + httpClientLayer((request) => + Effect.succeed( + jsonResponse(request, 200, [ + { + id: "6f8f9d2c-1f43-4b8a-9d0e-3a2b1c4d5e6f", + name: "preview", + project_ref: "abcdefghijklmnopqrst", + parent_project_ref: "tsrqponmlkjihgfedcba", + is_default: false, + persistent: false, + status: "MIGRATIONS_PASSED", + with_data: false, + created_at: "2026-08-06T19:27:30.261795+00:00", + updated_at: "2026-08-06T19:27:30.261795+00:00", + }, + ]), + ), + ), + ), + ), + ); + + expect(branches[0]?.created_at).toBe("2026-08-06T19:27:30.261795+00:00"); + }); + test("accepts missing custom-hostname SSL validation records", async () => { const result = await Effect.runPromise( makeSupabaseApiClient(config).pipe(