From 412d570c86ea7a074fb32257f0ea9fbe8cd233c1 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 7 Aug 2026 12:29:01 +0100 Subject: [PATCH 1/7] feat(cli): port shell completion to native TypeScript (CLI-1965) (#6083) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What changed Replaces the Go-binary passthrough for shell tab-completion with two native TypeScript implementations, closing [CLI-1965](https://linear.app/supabase/issue/CLI-1965/port-shell-completion-to-typescript-and-remove-the-complete): 1. **Static scripts** (`legacy/commands/completion/legacy-completion-scripts.ts`) — `supabase completion {bash,zsh,fish,powershell}` now generates the script natively instead of proxying to the Go binary. Cobra v1.10.2's completion scripts turned out to be 100% generic templates that don't bake in the command tree at all (every tab press just shells back out to `supabase __complete`/`__completeNoDesc`), so this is a byte-for-byte transcription of cobra's own `genBashComp`/`genZshComp`/`genFishComp`/`genPowerShellComp` functions, parameterized only by the program name (`"supabase"`) and which hidden command the script calls back into. Pinned against real cobra output via 8 checked-in golden fixtures (`legacy/commands/completion/__fixtures__/`) generated from a real `apps/cli-go` build, so a future accidental edit to the hand-transcribed templates fails CI instead of shipping silently. 2. **Dynamic responder** (`legacy/cli/legacy-complete.ts`, replacing the deleted `complete-passthrough.ts`) — reimplements cobra's `__complete`/`__completeNoDesc` protocol (candidates + a trailing `:` line) by reflecting over the live `legacyRoot` command tree, rather than hand-authoring a separate Go-shaped shadow model or continuing to shell out to the Go binary. Reflecting over the real tree means completion output self-corrects as the tree's own, separately-tracked content bugs (extra/missing commands, description mismatches) get fixed elsewhere. This removes the completion command family's last dependency on the bundled Go binary — the structural blocker the milestone description calls out, since every `cmd/*.go` command *registration* was load-bearing for tab completion even where the handler itself was already dead. Unblocks the final Go binary trim. ## How the cobra-output-matching question was resolved Delegated protocol research to `go-parity-auditor`, which read cobra v1.10.2 source directly (available in the Go module cache) and cross-checked against `apps/cli-go`. Two categories of cobra behavior turned out to need different treatment: - **Static scripts**: provably independent of the command tree — a pure string-template port, verified byte-identical against a real cobra-built binary (both at generation time and end-to-end through the actual TS CLI subprocess). - **Dynamic protocol**: cobra annotations (`MarkFlagRequired`, `MarkFlagFilename`) that have no equivalent concept anywhere in this TS tree are mirrored as small, explicit, hand-verified lookup tables (matching Go's own hardcoded `cmd/*.go` call sites 1:1) rather than derived generically from TS flag declarations — an earlier attempt to infer "required" from whether a flag was `Flag.optional`-wrapped was a real, confirmed-wrong heuristic (it silently disagreed with cobra on 3 of 6 real required flags, including flags this TS port deliberately made optional at parse time for unrelated validation-ordering reasons) and was replaced with an explicit table during review. ## Review findings and how they were resolved Three independent reviewers (`go-parity-auditor`, `engineer-reviewer`, `architect-reviewer`) ran differential testing against a real `apps/cli-go` build and converged on the same set of real regressions in the first draft of the dynamic responder, all now fixed and covered by new regression tests: - Any global flag before the cursor (e.g. `supabase --debug `) was incorrectly suppressing all subcommand-name completion. - A non-root command's own declared global flags (e.g. `seed`'s `--linked`/`--local`) were invisible from anywhere in that command's subtree. - A command's own local flag with the same name as a global flag (e.g. `db diff`'s local `--output`) was offered twice, with contradictory descriptions, instead of the local one shadowing the global one. - `--version` was offered on every command instead of the root only (cobra registers it non-persistently, root-only). - The `--help`/`--version` short-circuit could misfire on a subcommand's own unrelated local flag of the same name (e.g. `migration squash --version `). - The completion-candidate directive was `4` (no-file-completion) too eagerly in cases cobra leaves at `0`. **Deliberately left open / documented, not fixed**: mutually-exclusive flag-group hiding (cobra's `MarkFlagsMutuallyExclusive`, ~45 call sites in `apps/cli-go/cmd/`) is not reproduced — there's no equivalent annotation anywhere in this TS tree to derive it from, and hand-building a ~45-entry shadow table was judged materially higher transcription-error risk than the small, stable tables this PR does maintain (4 file-extension entries, 6 required-flag entries). Deprecated-command/flag filtering is similarly not reproduced, since this TS tree has no "deprecated" concept distinct from "hidden" today. Both are called out in `legacy-complete.ts`'s module doc comment and the completion family's `SIDE_EFFECTS.md`. ## Testing Unit tests for the pure command-path-resolution/flag-collection/classification/formatting logic in `legacy-complete.ts` (against the real `legacyRoot` tree, not a synthetic one) and for `legacy-completion-scripts.ts` (including the golden-fixture byte-exact checks); a small e2e file for each covering the real-subprocess golden paths (`__complete`, `__completeNoDesc`, `completion bash/zsh`); a new shared `legacy-param-introspection.ts` unit test covering the `Param` unwrap logic (hoisted out of `legacy-command-instrumentation.ts`, which had a private, near-identical helper). --- .github/workflows/verify-install-channels.yml | 89 +- apps/cli-go/cmd/root.go | 11 + apps/cli-go/cmd/sso.go | 4 - apps/cli-go/docs/main.go | 1 - apps/cli/docs/go-cli-porting-status.md | 14 +- .../src/legacy/cli/complete-passthrough.ts | 64 - .../cli/complete-passthrough.unit.test.ts | 114 - .../legacy/cli/legacy-complete.e2e.test.ts | 42 + .../cli/legacy-complete.integration.test.ts | 117 ++ apps/cli/src/legacy/cli/legacy-complete.ts | 1828 +++++++++++++++++ .../legacy/cli/legacy-complete.unit.test.ts | 1594 ++++++++++++++ apps/cli/src/legacy/cli/main.ts | 4 +- .../commands/completion/SIDE_EFFECTS.md | 121 +- .../completion/__fixtures__/bash.desc.txt | 426 ++++ .../completion/__fixtures__/bash.nodesc.txt | 426 ++++ .../completion/__fixtures__/fish.desc.txt | 235 +++ .../completion/__fixtures__/fish.nodesc.txt | 235 +++ .../__fixtures__/powershell.desc.txt | 270 +++ .../__fixtures__/powershell.nodesc.txt | 270 +++ .../completion/__fixtures__/zsh.desc.txt | 212 ++ .../completion/__fixtures__/zsh.nodesc.txt | 212 ++ .../commands/completion/bash/bash.command.ts | 25 +- .../commands/completion/bash/bash.handler.ts | 11 +- .../completion/bash/bash.integration.test.ts | 97 +- .../commands/completion/completion.command.ts | 2 +- .../completion/completion.e2e.test.ts | 31 +- .../commands/completion/fish/fish.command.ts | 20 +- .../commands/completion/fish/fish.handler.ts | 11 +- .../completion/fish/fish.integration.test.ts | 97 +- .../completion/legacy-completion-scripts.ts | 1258 ++++++++++++ .../legacy-completion-scripts.unit.test.ts | 145 ++ .../powershell/powershell.command.ts | 19 +- .../powershell/powershell.handler.ts | 11 +- .../powershell/powershell.integration.test.ts | 97 +- .../commands/completion/zsh/zsh.command.ts | 26 +- .../commands/completion/zsh/zsh.handler.ts | 11 +- .../completion/zsh/zsh.integration.test.ts | 97 +- .../legacy/commands/storage/cp/cp.command.ts | 2 +- .../commands/storage/cp/cp.parse-uint.ts | 119 -- .../shared/legacy-param-introspection.ts | 98 + .../legacy-param-introspection.unit.test.ts | 94 + .../src/legacy/shared/legacy-parse-uint.ts | 193 ++ .../legacy-parse-uint.unit.test.ts} | 42 +- .../legacy-command-instrumentation.ts | 39 +- apps/cli/src/shared/legacy/global-flags.ts | 29 +- .../standalone-analytics-config.layer.ts | 26 + 46 files changed, 8304 insertions(+), 585 deletions(-) delete mode 100644 apps/cli/src/legacy/cli/complete-passthrough.ts delete mode 100644 apps/cli/src/legacy/cli/complete-passthrough.unit.test.ts create mode 100644 apps/cli/src/legacy/cli/legacy-complete.e2e.test.ts create mode 100644 apps/cli/src/legacy/cli/legacy-complete.integration.test.ts create mode 100644 apps/cli/src/legacy/cli/legacy-complete.ts create mode 100644 apps/cli/src/legacy/cli/legacy-complete.unit.test.ts create mode 100644 apps/cli/src/legacy/commands/completion/__fixtures__/bash.desc.txt create mode 100644 apps/cli/src/legacy/commands/completion/__fixtures__/bash.nodesc.txt create mode 100644 apps/cli/src/legacy/commands/completion/__fixtures__/fish.desc.txt create mode 100644 apps/cli/src/legacy/commands/completion/__fixtures__/fish.nodesc.txt create mode 100644 apps/cli/src/legacy/commands/completion/__fixtures__/powershell.desc.txt create mode 100644 apps/cli/src/legacy/commands/completion/__fixtures__/powershell.nodesc.txt create mode 100644 apps/cli/src/legacy/commands/completion/__fixtures__/zsh.desc.txt create mode 100644 apps/cli/src/legacy/commands/completion/__fixtures__/zsh.nodesc.txt create mode 100644 apps/cli/src/legacy/commands/completion/legacy-completion-scripts.ts create mode 100644 apps/cli/src/legacy/commands/completion/legacy-completion-scripts.unit.test.ts delete mode 100644 apps/cli/src/legacy/commands/storage/cp/cp.parse-uint.ts create mode 100644 apps/cli/src/legacy/shared/legacy-param-introspection.ts create mode 100644 apps/cli/src/legacy/shared/legacy-param-introspection.unit.test.ts create mode 100644 apps/cli/src/legacy/shared/legacy-parse-uint.ts rename apps/cli/src/legacy/{commands/storage/cp/cp.parse-uint.unit.test.ts => shared/legacy-parse-uint.unit.test.ts} (61%) create mode 100644 apps/cli/src/shared/telemetry/standalone-analytics-config.layer.ts 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-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/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/docs/go-cli-porting-status.md b/apps/cli/docs/go-cli-porting-status.md index defaffc3e2..72960566f7 100644 --- a/apps/cli/docs/go-cli-porting-status.md +++ b/apps/cli/docs/go-cli-porting-status.md @@ -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 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/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/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/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/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/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, +); From 326939b55bf0c145dea757e1cbec36e5a41aa24b Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 7 Aug 2026 15:51:30 +0100 Subject: [PATCH 2/7] fix(cli): port gen bearer-jwt to native TypeScript (CLI-1961) (#6064) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What changed Ports `gen bearer-jwt` (a Phase-0 Go-proxy) to native TypeScript. Go's implementation (`apps/cli-go/internal/gen/bearerjwt/bearerjwt.go`, `cmd/gen.go`) is fully local — no Docker, no network: load config, resolve a signing key from `[auth].signing_keys` (with interactive JWK/kid selection prompts), build claims, sign. Key parity detail: Go's real claims object is a `jwt.MapClaims` (a Go map), so JSON serializes keys **alphabetically**, not insertion order — unlike the pre-existing `legacyGenerateAsymmetricGoJwt` helper (struct-shaped, insertion-order). This required a dedicated map-shaped claims encoder rather than reusing the existing struct-shaped signer as-is; both are now documented and kept deliberately distinct to avoid a future caller mixing them up. Also fixes a validation-order bug in the pre-existing shared `legacy-go-jwt.ts` (extracting a new `legacySignJwtWithJwk`): Go checks key-type/curve first (wrapped in `failed to convert JWK to private key: ...`), then algorithm (unwrapped), and has **no explicit cross-check** between kty and algorithm — a mismatch is only caught when the underlying JWT library itself fails to sign (`failed to sign JWT: key is of invalid type: ...`). Two pre-existing unit tests that asserted the wrong (Go-divergent) behavior are corrected as part of this fix. This is a genuine prerequisite for the port (both commands share this signing path), not new-code-only — flagging explicitly since the commit doesn't otherwise signal that shipped error text for the pre-existing `gen signing-key`-adjacent path changed. Hoisted `apps/cli/src/legacy/commands/gen/gen.signing-keys-config.ts`, shared between `gen bearer-jwt` and the pre-existing `gen signing-key` command (both in the same `gen` family, per this repo's hoist-to-family-root rule). ## Why Part of the M9 "Go removal" milestone. ## Review notes `gen bearer-jwt` mints signed JWTs, so this got an unusually thorough pass: the go-parity-auditor and engineer-reviewer both **built and executed the real Go binary** with probe inputs to verify claims empirically rather than reading source alone. That surfaced (and this PR's follow-up commit fixes) several real correctness/security gaps found only by execution: - A stdin JWK of `null` was silently falling back to a default, non-secret signing key where Go actually refuses. - JWK `alg` wasn't validated against Go's `RS256`/`ES256` allowlist at decode time, letting an `HS256` key reach the signing step instead of being rejected earlier, matching Go. - `--sub ""` (explicitly empty, as opposed to omitted) was incorrectly suppressing `is_anonymous` — Go's own check treats an empty string the same as absent. - `--exp` accepted invalid calendar dates (e.g. Feb 30) that `Date.parse` silently rolled over instead of rejecting, unlike Go's `time.Parse`. - An empty signing-keys array on a real TTY crashed with an unhandled `TypeError` instead of Go's `user aborted`. Also fixed: a missing `Legacy`-prefix convention violation, a misplaced generic JSON-parity helper, sub-second `--valid-for` truncation ordering (verified backwards vs. Go), `--exp` whitespace trimming (Go's pflag trims, this didn't), and a stale test assertion. One architectural suggestion — reusing `legacy-config-validate.ts`'s existing signing-keys helpers instead of the new hoisted module — is deliberately deferred: the go-parity-auditor's own code-executing pass did not find a live behavioral bug from the current shape, and this PR is already large; noting it here so it isn't lost. Fixes CLI-1961 --- apps/cli/docs/go-cli-porting-status.md | 2 +- .../commands/gen/bearer-jwt/SIDE_EFFECTS.md | 91 +- .../gen/bearer-jwt/bearer-jwt.claims.ts | 387 +++++ .../bearer-jwt/bearer-jwt.claims.unit.test.ts | 412 +++++ .../gen/bearer-jwt/bearer-jwt.command.ts | 59 +- .../gen/bearer-jwt/bearer-jwt.errors.ts | 103 ++ .../bearer-jwt/bearer-jwt.errors.unit.test.ts | 13 + .../gen/bearer-jwt/bearer-jwt.flags.ts | 259 +++ .../bearer-jwt/bearer-jwt.flags.unit.test.ts | 259 +++ .../gen/bearer-jwt/bearer-jwt.handler.ts | 110 +- .../bearer-jwt/bearer-jwt.integration.test.ts | 1457 +++++++++++++++++ .../gen/bearer-jwt/bearer-jwt.signing-key.ts | 373 +++++ .../commands/gen/gen.signing-keys-config.ts | 665 ++++++++ .../gen/signing-key/signing-key.handler.ts | 111 +- .../signing-key.integration.test.ts | 87 +- .../src/legacy/shared/legacy-go-duration.ts | 58 +- .../shared/legacy-go-duration.unit.test.ts | 42 +- apps/cli/src/legacy/shared/legacy-go-json.ts | 61 +- .../legacy/shared/legacy-go-json.unit.test.ts | 43 +- apps/cli/src/legacy/shared/legacy-go-jwt.ts | 213 ++- .../legacy/shared/legacy-go-jwt.unit.test.ts | 110 +- .../shared/legacy-go-output.encoders.ts | 26 +- .../legacy-go-output.encoders.unit.test.ts | 30 + .../legacy-go-struct-output.encoders.ts | 12 +- apps/cli/src/shared/output/output.layer.ts | 13 +- .../shared/output/output.layer.unit.test.ts | 53 +- apps/cli/src/shared/output/output.service.ts | 10 + 27 files changed, 4873 insertions(+), 186 deletions(-) create mode 100644 apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.claims.ts create mode 100644 apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.claims.unit.test.ts create mode 100644 apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.errors.ts create mode 100644 apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.errors.unit.test.ts create mode 100644 apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.flags.ts create mode 100644 apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.flags.unit.test.ts create mode 100644 apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.integration.test.ts create mode 100644 apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.signing-key.ts create mode 100644 apps/cli/src/legacy/commands/gen/gen.signing-keys-config.ts diff --git a/apps/cli/docs/go-cli-porting-status.md b/apps/cli/docs/go-cli-porting-status.md index 72960566f7..c40c7a251b 100644 --- a/apps/cli/docs/go-cli-porting-status.md +++ b/apps/cli/docs/go-cli-porting-status.md @@ -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/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/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/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"; } /** From 1136b0eca797ad649103508674550eb193006b27 Mon Sep 17 00:00:00 2001 From: Vaibhav <117663341+7ttp@users.noreply.github.com> Date: Fri, 7 Aug 2026 21:41:42 +0530 Subject: [PATCH 3/7] fix(cli): surface serve staging errors (#6113) ## TL;DR fixes `functions serve` and `supabase start` failing with only `An error occurred in Effect.tryPromise` when an env file read or a runtime staging write fails, which was caused by bare `Effect.tryPromise` call sites whose `UnknownError` wrapper is itself an `Error` so the piped `mapError` guards returned the generic wrapper unchanged.. and is now fixed by converting the five serve call sites to the `try` and `catch` form so the raw filesystem error reaches the user. Also pins the gen types pflag consumption test on a deterministic container inspect failure instead of depending on nothing listening on the local db port... ## ref: - extends: https://github.com/supabase/cli/pull/5904 --- .../functions/serve/serve.integration.test.ts | 73 +++++++++++++++- .../gen/types/types.integration.test.ts | 9 +- apps/cli/src/shared/functions/serve.ts | 84 ++++++++++--------- 3 files changed, 123 insertions(+), 43 deletions(-) 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/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/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", From a0fcaa80f920d49fd8d137d5a557f4715abc94c7 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 7 Aug 2026 17:17:52 +0100 Subject: [PATCH 4/7] fix(cli): port db reset local recreate to native TS (CLI-1955) (#6026) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Stacks on supabase/cli#6022 This PR is based on `columferry/cli-1954-port-db-start-container-bootstrap-natively-and-remove-the` (supabase/cli#6022), not `develop` — it needs to edit that PR's new `legacy/shared/db-bootstrap/` code before supabase/cli#6022 has merged. GitHub will show supabase/cli#6022's diff here too until that PR merges; once it does, this PR's diff will narrow to just what's described below. ## What changed `supabase db reset`'s local path delegated its container-recreate work to the bundled Go binary via a hidden `db __db-bootstrap --mode recreate` / `--mode await-storage` seam. Ports this to native TS and **deletes the seam entirely** (both files). The issue's premise — that reset "reuses the same create/health/SetupLocalDatabase chain the native start port already implements" — was wrong. Go's `resetDatabase15` (`internal/db/reset/reset.go`) never calls `StartDatabase`; it's a distinctly different composition (no volume-existence probe, no `--from-backup` concept, unconditional setup with the *resolved* migration version instead of `""`, no rollback-on-failure, no `_current_branch` write). This port builds a reset-specific `legacyRecreateLocalDatabase` directly over the same underlying primitives `db start` uses, rather than wrapping `legacyStartDatabase`. Also native now: * The **PG14 recreate branch**: template1 `DROP`/`CREATE DATABASE`, disconnect-clients with Go's exact swallow/surface semantics (a genuine server error surfaces; a node-level socket error or the "database doesn't exist yet" case is swallowed), replication-slot drain with backoff, `InitSchema14`/`ApplyApiPrivileges` (deliberately narrower than the PG15+ `SetupLocalDatabase` — no globals.sql/vault/roles.sql). * **Concurrent satellite-container restart + Kong** `nginx reload` (the Kong-reload behavior was added same-day upstream to fix issue supabase/cli#6016 — this reload **fails the whole command** on error, unlike the existing best-effort Kong reload in `functions serve`, matching Go's own two different policies for the two call sites). * The **storage-container health gate** (`AwaitStorageReady`) — any inspect error maps to "absent" (not just not-found), and an unhealthy-but-present container triggers a hardcoded 30s wait that fails the whole reset on timeout, not just "skip bucket seeding." An empirical probe (real Postgres 14 and 15, using the exact pinned pgconn/pgx versions from `apps/cli-go/go.mod`) settled an open question before implementation: whether Go's single-batch `DROP`/`CREATE DATABASE` sequence is safe against Postgres's "cannot run inside a transaction block" restriction. It is — pgconn's batching semantics never trigger that guard — and the TS port doesn't need to replicate any of that protocol-level behavior: four sequential, unwrapped statement execs reproduce the identical real-world result more simply. Since this is the **third** consumer of `legacy/shared/db-bootstrap/`, also did the directory split that milestone review had been deferring: split it into `legacy/shared/containers/` (generic, cross-service Docker primitives used well beyond Postgres bootstrap) and a narrower `db-bootstrap/` (genuinely Postgres-specific), hoisted the container-CLI boilerplate that had been duplicated across the new remove/restart primitives into the existing `legacy-container-cli.ts`, and extracted the local container-input prelude `db start` and `db reset` were duplicating verbatim (\~130 lines) into a shared `legacyBuildLocalDbContainerInputs`. ## Follow-up: closing the local-reset scope boundary (CLI-2062) The PR originally left one boundary open: `db schema declarative`'s smart-target local-reset prompt and `db schema sync`'s failed-apply recovery reset still shelled out to a **second** `supabase-go` child (`LegacyDeclarativeSeam.execInherit`) to run `db reset --local`, rather than calling the now-native `legacyDbReset` in-process. That subprocess design was itself a parity divergence: because it's a genuinely separate OS process, its own `Execute()`/`PersistentPostRun` fired an *independent* second `cli_command_executed` telemetry event and linked-project-cache write on top of the outer `db schema declarative`/`sync` command's own — something real single-process Go never does (Go's `db_schema_declarative.go` calls `reset.Run` as a plain in-process function call, sharing the one outer `PersistentPostRun`). This is now fixed: * Hoisted the `cfg.isLocal` branch of `legacyDbReset` into a new shared `legacyResetLocalDatabase` (`legacy/shared/db-bootstrap/reset-local-database.ts`) — self-contained, resolving its own services (`LegacyDebugFlag`, `LegacyNetworkIdFlag`, `RuntimeInfo`, `ChildProcessSpawner`, `FileSystem`, `Path`, `LegacyCliConfig`, project-env) rather than taking `LegacyDbResetFlags`/`CliArgs`, so it's callable from any Effect context. `reset.handler.ts`'s own `cfg.isLocal` branch is now a thin wrapper around it, keeping only the version/seed-flags plumbing and the JSON envelope (both specific to the top-level `db reset` command). * Rewired both `db schema declarative`'s smart-target and `db schema sync`'s recovery-reset call sites to call `legacyResetLocalDatabase` directly, dropping the `--network-id` argv-forwarding (the function now resolves `LegacyNetworkIdFlag` itself from the shared context — a closer match to Go's single-process model). The synthesized `` `database reset failed (exit ${code})` `` error message is replaced with a message built from the real typed failure (`` `database reset failed: ${error.message}` ``), since there's no longer a literal subprocess exit code. * Removed `execInherit` entirely — from the `LegacyDeclarativeSeam` interface, its real implementation, and every test mock that stubbed it. * Moved `await-storage-ready.ts` into `legacy/shared/db-bootstrap/` alongside `legacyResetLocalDatabase`, since it now has a second caller. * `generate.layers.ts`/`sync.layers.ts` now expose `legacyDockerRunLayer` directly (previously only nested inside their own `edgeRuntime` composition) — needed for `legacyResetLocalDatabase`'s PG15+ one-shot migrate jobs, the same way `db start`/`db reset`'s own layers do. * Rewrote `generate`/`sync`'s local-reset integration tests to exercise the real native reset (mocked `ChildProcessSpawner` + Docker CLI route, hoisted into a new shared `tests/helpers/legacy-local-reset.ts`) instead of asserting tracked `execInherit` call args, and added explicit assertions that the outer command's telemetry-flush/linked-project-cache-write finalizer fires exactly once even though its body now calls an in-process helper that could, if wrongly implemented, double it. * Verified (grep across `apps/cli-go`) that no Go code becomes dead from removing this TS call site: `internal/db/reset/reset.go` remains fully reachable both via the Go binary's own top-level `db reset` command and via the remaining `--experimental` remote-delegation path in `reset.handler.ts`. ## Why Part of the M9 "Final Cleanup — Go Removal" milestone. Fixes [CLI-1955](https://linear.app/supabase/issue/CLI-1955/port-db-reset-local-recreate-natively-and-remove-the-db-bootstrap) Fixes [CLI-2062](https://linear.app/supabase/issue/CLI-2062) --- .../live/db-reset-start.live.e2e.test.ts | 14 +- apps/cli-go/cmd/db.go | 67 - apps/cli-go/cmd/start.go | 6 +- apps/cli-go/internal/db/reset/reset.go | 32 - apps/cli-go/pkg/migration/file.go | 9 +- apps/cli/docs/binary-distribution.md | 2 +- apps/cli/docs/go-cli-porting-status.md | 90 +- .../legacy/commands/db/diff/SIDE_EFFECTS.md | 5 +- .../commands/db/diff/diff.integration.test.ts | 1 - .../commands/db/pull/pull.integration.test.ts | 1 - .../legacy/commands/db/reset/SIDE_EFFECTS.md | 206 +- .../legacy/commands/db/reset/reset.errors.ts | 10 - .../legacy/commands/db/reset/reset.handler.ts | 154 +- .../db/reset/reset.integration.test.ts | 2521 ++++++++++------- .../legacy/commands/db/reset/reset.layers.ts | 26 +- ...eclarative.orchestrate.integration.test.ts | 1 - .../declarative/declarative.smart-target.ts | 33 +- .../declarative/generate/SIDE_EFFECTS.md | 2 +- .../generate/generate.integration.test.ts | 124 +- .../declarative/generate/generate.layers.ts | 7 +- .../schema/declarative/sync/SIDE_EFFECTS.md | 7 +- .../schema/declarative/sync/sync.handler.ts | 31 +- .../declarative/sync/sync.integration.test.ts | 113 +- .../db/schema/declarative/sync/sync.layers.ts | 8 +- .../db/shared/legacy-db-bootstrap.errors.ts | 22 - .../shared/legacy-db-bootstrap.seam.layer.ts | 166 -- .../legacy-db-bootstrap.seam.service.ts | 65 - .../db/shared/legacy-pgdelta.seam.layer.ts | 29 +- .../db/shared/legacy-pgdelta.seam.service.ts | 14 - .../legacy/commands/db/start/SIDE_EFFECTS.md | 58 +- .../legacy/commands/db/start/start.handler.ts | 4 +- .../legacy/commands/db/start/start.layers.ts | 12 +- .../src/legacy/commands/start/SIDE_EFFECTS.md | 22 +- .../start/services/edge-runtime.service.ts | 10 +- .../legacy/commands/start/start.handler.ts | 10 +- .../commands/start/start.integration.test.ts | 10 +- .../db-bootstrap/await-storage-ready.ts | 65 + .../await-storage-ready.unit.test.ts | 184 ++ .../db-bootstrap/container-lifecycle.ts | 187 +- .../container-lifecycle.unit.test.ts | 200 +- .../legacy/shared/db-bootstrap/db-setup.ts | 445 ++- .../shared/db-bootstrap/db-setup.unit.test.ts | 10 +- .../shared/db-bootstrap/docker-create-args.ts | 4 +- .../db-bootstrap/local-container-inputs.ts | 253 ++ .../shared/db-bootstrap/local-db-running.ts | 11 +- .../shared/db-bootstrap/postgres.service.ts | 2 +- .../db-bootstrap/recreate-local-database.ts | 495 ++++ .../recreate-local-database.unit.test.ts | 103 + .../db-bootstrap/reset-local-database.ts | 244 ++ .../shared/db-bootstrap/restart-services.ts | 248 ++ .../restart-services.unit.test.ts | 306 ++ .../shared/db-bootstrap/start-database.ts | 180 +- .../src/legacy/shared/legacy-container-cli.ts | 97 +- .../src/legacy/shared/legacy-docker-ids.ts | 2 +- .../shared/legacy-start-secrets-cleanup.ts | 2 +- apps/cli/src/shared/cli/run.ts | 20 +- apps/cli/src/shared/cli/run.unit.test.ts | 9 +- .../legacy/legacy-go-child-exit.error.ts | 4 +- apps/cli/tests/helpers/legacy-local-reset.ts | 177 ++ apps/cli/tests/helpers/legacy-mocks.ts | 20 + 60 files changed, 4900 insertions(+), 2260 deletions(-) delete mode 100644 apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.errors.ts delete mode 100644 apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.seam.layer.ts delete mode 100644 apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.seam.service.ts create mode 100644 apps/cli/src/legacy/shared/db-bootstrap/await-storage-ready.ts create mode 100644 apps/cli/src/legacy/shared/db-bootstrap/await-storage-ready.unit.test.ts create mode 100644 apps/cli/src/legacy/shared/db-bootstrap/local-container-inputs.ts create mode 100644 apps/cli/src/legacy/shared/db-bootstrap/recreate-local-database.ts create mode 100644 apps/cli/src/legacy/shared/db-bootstrap/recreate-local-database.unit.test.ts create mode 100644 apps/cli/src/legacy/shared/db-bootstrap/reset-local-database.ts create mode 100644 apps/cli/src/legacy/shared/db-bootstrap/restart-services.ts create mode 100644 apps/cli/src/legacy/shared/db-bootstrap/restart-services.unit.test.ts create mode 100644 apps/cli/tests/helpers/legacy-local-reset.ts 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/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/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/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 c40c7a251b..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 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/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/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-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/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/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/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; }, From a2f90a014d5342967db8272f2efb2a0bb2d3d8ee Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 8 Aug 2026 00:09:25 +0000 Subject: [PATCH 5/7] fix(docker): bump the docker-minor group in /apps/cli-go/pkg/config/templates with 3 updates (#6119) Bumps the docker-minor group in /apps/cli-go/pkg/config/templates with 3 updates: supabase/realtime, supabase/storage-api and supabase/logflare. Updates `supabase/realtime` from v2.123.5 to v2.124.2 Updates `supabase/storage-api` from v1.68.8 to v1.68.10 Updates `supabase/logflare` from 1.50.0 to 1.50.1 Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- apps/cli-go/pkg/config/templates/Dockerfile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 From f16a6c2f4fef3f3a291cd2431f1fa8cb95b91da3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 8 Aug 2026 00:15:44 +0000 Subject: [PATCH 6/7] chore(ci): bump jdx/mise-action from 4.2.3 to 4.2.4 in the actions-major group (#6120) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the actions-major group with 1 update: [jdx/mise-action](https://github.com/jdx/mise-action). Updates `jdx/mise-action` from 4.2.3 to 4.2.4
Changelog

Sourced from jdx/mise-action's changelog.

Changelog


4.2.4 - 2026-07-28

🐛 Bug Fixes

  • locking support detection with force-colored output (#580) by @​scop in #580

4.2.3 - 2026-07-24

🐛 Bug Fixes


4.2.2 - 2026-07-24

🐛 Bug Fixes

📚 Documentation

New Contributors


4.2.1 - 2026-07-16

🐛 Bug Fixes

🔍 Other Changes

⚙️ Miscellaneous Tasks

... (truncated)

Commits
  • 7e36c90 chore: release v4.2.4 (#581)
  • 493a5fd chore(deps): update jdx/renovate-config digest to d4f71e1 (#585)
  • 4f79861 chore(deps): update github/codeql-action action to v4.37.2 (#586)
  • 3fb09b2 chore(deps): update jdx/pr-closer action to v1.2.0 (#584)
  • 4606f11 chore(deps): update jdx/renovate-config digest to aa7a43b (#582)
  • 2319179 chore(deps): update actions/checkout action to v7.0.1 (#583)
  • b4fa3f8 fix: locking support detection with force-colored output (#580)
  • c3c9861 chore(deps): lock file maintenance (#579)
  • See full diff in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=jdx/mise-action&package-manager=github_actions&previous-version=4.2.3&new-version=4.2.4)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/cli-go-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 03880bb15379c308a73b078d98780eef1eb1bd63 Mon Sep 17 00:00:00 2001 From: Vaibhav <117663341+7ttp@users.noreply.github.com> Date: Sat, 8 Aug 2026 12:54:22 +0530 Subject: [PATCH 7/7] fix(api): accept offset timestamps (#6117) ## TL;DR fixes `supabase link` and `supabase branches list` failing against every project, which was caused by the generated response schemas inheriting the OpenAPI spec's Z-anchored `date-time` patterns while the Management API serializes timestamps with a numeric UTC offset, and is now pragmatically fixed by dropping that pattern during code generation so `date-time` strings keep only their `format` annotation. Timestamps are no longer pattern checked at decode time, matching the contract that shipped before the spec added patterns... ## ref: - closes: https://github.com/supabase/cli/issues/6115 - broken by: https://github.com/supabase/cli/pull/6073 - closes CLI-2136 --- packages/api/scripts/generate.ts | 9 + packages/api/scripts/generate.unit.test.ts | 21 + packages/api/src/generated/contracts.ts | 713 ++---------------- packages/api/src/internal/client.unit.test.ts | 65 ++ 4 files changed, 153 insertions(+), 655 deletions(-) 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(
Release notes

Sourced from jdx/mise-action's releases.

v4.2.4: Reliable locking detection under forced color

A small patch release that fixes locking-support detection when workflows force colored output.

Fixed

Detect mise install --locked reliably under forced color (#580 by @​scop)

When colored output was forced globally (for example via CLICOLOR_FORCE=1), ANSI escape codes in mise install --help prevented the action from matching --locked in the help text, so locking support was reported as unavailable even on versions of mise that supported it.

The help probe now runs with NO_COLOR=1 in its environment, which overrides CLICOLOR_FORCE and guarantees plain-text output for the feature detection — regardless of the surrounding workflow's color settings.

Full Changelog: https://github.com/jdx/mise-action/compare/v4.2.3...v4.2.4