diff --git a/.bun-version b/.bun-version new file mode 100644 index 0000000000..7962dcfdb6 --- /dev/null +++ b/.bun-version @@ -0,0 +1 @@ +1.3.13 diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index cdd585bae4..d95b35caf4 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -9,8 +9,8 @@ inputs: default: "" dependency-cache: description: >- - Whether to enable the pnpm dependency cache. Disable this when the job - deletes the pnpm store before exiting, otherwise the post-job cache save + Whether to enable dependency caches. Disable this when the job deletes a + cached dependency path before exiting, otherwise the post-job cache save fails with a path validation error. required: false default: "true" @@ -18,47 +18,44 @@ inputs: runs: using: "composite" steps: - - name: Resolve Bun version - shell: bash - run: echo "BUN_VERSION=1.3.13" >> "$GITHUB_ENV" - - - name: Install Bun - id: install-bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - continue-on-error: true - with: - bun-version: ${{ env.BUN_VERSION }} - - - name: Install Bun (fallback with retries) - if: steps.install-bun.outcome == 'failure' - uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 # v3.0.2 + - name: Install toolchains + uses: jdx/mise-action@e6a8b3978addb5a52f2b4cd9d91eafa7f0ab959d # v4 with: - timeout_minutes: 3 - max_attempts: 5 - retry_wait_seconds: 30 - command: | - curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" - echo "$HOME/.bun/bin" >> "$GITHUB_PATH" + version: 2026.7.0 - - name: Verify Bun + - name: Resolve pnpm store path + if: inputs.dependency-cache == 'true' + id: pnpm-store shell: bash - run: bun --version + run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" - - name: Install Node.js - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + - name: Configure pnpm dependency cache + if: inputs.dependency-cache == 'true' + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - node-version-file: .nvmrc - package-manager-cache: false + path: ${{ steps.pnpm-store.outputs.path }} + key: pnpm-store-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + pnpm-store-${{ runner.os }}-${{ runner.arch }}- - - name: Enable Corepack + - name: Resolve Go cache paths + if: inputs.dependency-cache == 'true' + id: go-cache shell: bash - run: npm install --global --force corepack && corepack enable + run: | + echo "mod=$(go env GOMODCACHE)" >> "$GITHUB_OUTPUT" + echo "build=$(go env GOCACHE)" >> "$GITHUB_OUTPUT" - - name: Configure dependency cache + - name: Configure Go dependency cache if: inputs.dependency-cache == 'true' - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - cache: pnpm + path: | + ${{ steps.go-cache.outputs.mod }} + ${{ steps.go-cache.outputs.build }} + key: go-cache-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('apps/cli-go/go.mod', 'apps/cli-go/go.sum') }} + restore-keys: | + go-cache-${{ runner.os }}-${{ runner.arch }}- - name: Install dependencies shell: bash diff --git a/.github/workflows/apply-release-notes.yml b/.github/workflows/apply-release-notes.yml index 55ef8f94a5..2b46a7d7d1 100644 --- a/.github/workflows/apply-release-notes.yml +++ b/.github/workflows/apply-release-notes.yml @@ -82,7 +82,7 @@ jobs: # Checkout the PR head so any reviewer edits made in the GitHub UI before # approval are captured. apply-release-notes.ts reads from the working # tree. - - uses: useblacksmith/checkout@41cdeedae8edb2e684ba22896a5fd2a3cb85db6b # v1 + - uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 with: ref: ${{ github.event.pull_request.head.sha }} fetch-depth: 1 diff --git a/.github/workflows/backfill-release-notes.yml b/.github/workflows/backfill-release-notes.yml index 87e0bdc358..36dc30584b 100644 --- a/.github/workflows/backfill-release-notes.yml +++ b/.github/workflows/backfill-release-notes.yml @@ -46,7 +46,7 @@ jobs: TAG: ${{ inputs.tag }} GH_TOKEN: ${{ github.token }} steps: - - uses: useblacksmith/checkout@41cdeedae8edb2e684ba22896a5fd2a3cb85db6b # v1 + - uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 with: fetch-depth: 0 persist-credentials: false diff --git a/.github/workflows/build-cli-artifacts.yml b/.github/workflows/build-cli-artifacts.yml index 7300d26b7a..8faf9d0377 100644 --- a/.github/workflows/build-cli-artifacts.yml +++ b/.github/workflows/build-cli-artifacts.yml @@ -21,8 +21,8 @@ on: required: false type: string default: blacksmith-32vcpu-ubuntu-2404 - artifact_name_suffix: - description: Suffix to distinguish build artifact producers (e.g. -github) + cache_key_suffix: + description: Suffix to distinguish build artifact cache producers required: false type: string default: "" @@ -76,13 +76,6 @@ jobs: with: dependency-firewall-token: ${{ secrets.DF_FIREWALL_TOKEN }} - - name: Setup Go - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 - with: - go-version-file: apps/cli-go/go.mod - cache: true - cache-dependency-path: apps/cli-go/go.sum - - name: Pre-download Go modules working-directory: apps/cli-go run: go mod download -x @@ -123,26 +116,23 @@ jobs: echo "Checking dist/..." ls -la dist/ + - name: Check existing build artifacts cache + id: build-artifacts-cache + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + packages/cli-*/bin/ + dist/ + key: cli-build-${{ github.run_id }}-${{ inputs.shell }}-${{ inputs.version }}${{ inputs.cache_key_suffix }}-v1 + enableCrossOsArchive: true + lookup-only: true - # Hand the build off to the smoke/publish/brew/scoop jobs via a run-scoped - # artifact rather than a cache. Caches share a 10 GB per-repo budget and - # are evicted LRU, so a large build cache could vanish mid-run between the - # producer and a later consumer (e.g. publish), failing the restore. - # Artifacts have their own deterministic retention and survive job re-runs - # within the run, which is exactly what this handoff needs. - - name: Upload build artifacts - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + - name: Save build artifacts cache + if: steps.build-artifacts-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - name: cli-build-${{ inputs.shell }}-${{ inputs.version }}${{ inputs.artifact_name_suffix }} path: | packages/cli-*/bin/ dist/ - # Intra-run handoff, not a kept deliverable — expire it the next day. - retention-days: 1 - # A full re-run of this job replaces its own artifact instead of - # failing on the duplicate name from the previous attempt. - overwrite: true - # dist/* is already compressed (tar.gz/zip/deb/rpm/apk); a light level - # trims the raw bin/ binaries without burning CPU re-packing the rest. - compression-level: 1 - if-no-files-found: error + key: cli-build-${{ github.run_id }}-${{ inputs.shell }}-${{ inputs.version }}${{ inputs.cache_key_suffix }}-v1 + enableCrossOsArchive: true diff --git a/.github/workflows/cli-go-api-sync.yml b/.github/workflows/cli-go-api-sync.yml index f65a73c6e2..4c7d3a7fbe 100644 --- a/.github/workflows/cli-go-api-sync.yml +++ b/.github/workflows/cli-go-api-sync.yml @@ -61,6 +61,13 @@ jobs: branch: sync/api-types base: develop + - name: Approve Pull Request + if: steps.check.outputs.has_changes == 'true' + run: gh pr review --approve --repo "${{ github.repository }}" "${STEPS_CPR_OUTPUTS_PULL_REQUEST_NUMBER}" + env: + GH_TOKEN: ${{ secrets.AUTO_APPROVE_PR_PAT }} + STEPS_CPR_OUTPUTS_PULL_REQUEST_NUMBER: ${{ steps.cpr.outputs.pull-request-number }} + - name: Enable Pull Request Automerge if: steps.check.outputs.has_changes == 'true' run: gh pr merge --auto --squash --repo "${{ github.repository }}" "${STEPS_CPR_OUTPUTS_PULL_REQUEST_NUMBER}" diff --git a/.github/workflows/cli-go-ci.yml b/.github/workflows/cli-go-ci.yml index 0fc58f8c37..0d497ffc99 100644 --- a/.github/workflows/cli-go-ci.yml +++ b/.github/workflows/cli-go-ci.yml @@ -78,16 +78,18 @@ jobs: with: persist-credentials: false - - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 + - uses: jdx/mise-action@e6a8b3978addb5a52f2b4cd9d91eafa7f0ab959d # v4 with: - go-version-file: apps/cli-go/go.mod - # Linter requires no cache - cache: false + version: 2026.7.0 + install: true + install_args: >- + go + golangci-lint - - uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee # v9.2.1 + - uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0 with: args: --timeout 5m --verbose - version: latest + install-mode: none only-new-issues: true working-directory: apps/cli-go diff --git a/.github/workflows/cli-go-mirror-image.yml b/.github/workflows/cli-go-mirror-image.yml index d5c864e337..3c9484e266 100644 --- a/.github/workflows/cli-go-mirror-image.yml +++ b/.github/workflows/cli-go-mirror-image.yml @@ -34,7 +34,7 @@ jobs: run: | echo "image=${TAG##*/}" >> $GITHUB_OUTPUT - name: configure aws credentials - uses: aws-actions/configure-aws-credentials@e7f100cf4c008499ea8adda475de1042d6975c7b # v6.2.0 + uses: aws-actions/configure-aws-credentials@254c19bd240aabef8777f48595e9d2d7b972184b # v6.2.1 with: role-to-assume: ${{ secrets.PROD_AWS_ROLE }} aws-region: us-east-1 diff --git a/.github/workflows/deploy-check.yml b/.github/workflows/deploy-check.yml index 8db23a6e55..534aebd7d9 100644 --- a/.github/workflows/deploy-check.yml +++ b/.github/workflows/deploy-check.yml @@ -15,9 +15,9 @@ permissions: jobs: check: - if: github.head_ref != 'develop' + if: github.head_ref != 'develop' && !startsWith(github.head_ref, 'hotfix/') runs-on: ubuntu-latest steps: - run: | - echo "Pull requests to main branch are only allowed from develop branch." + echo "Pull requests to main branch are only allowed from develop or hotfix/* branches." exit 1 diff --git a/.github/workflows/live-e2e.yml b/.github/workflows/live-e2e.yml index 8b2d94e10f..efdb4a8374 100644 --- a/.github/workflows/live-e2e.yml +++ b/.github/workflows/live-e2e.yml @@ -11,7 +11,7 @@ name: Live E2E # ref (github.ref), always a same-repo branch. We deliberately take NO # free-form `ref` input: that would let a manual run check out arbitrary # (e.g. external PR) code while the staging token is in the job env. -# - schedule (hourly) — exercises the `@beta` channel. `develop` is the default +# - schedule (daily) — exercises the `@beta` channel. `develop` is the default # branch AND the beta release source, so a scheduled run checks it out and # builds from source. The `gate` job skips the run unless the published # `supabase@beta` version changed since the last green run (an actions/cache @@ -23,8 +23,8 @@ name: Live E2E on: workflow_dispatch: schedule: - # Hourly, offset from other scheduled workflows. Cron timing is best-effort. - - cron: "23 * * * *" + # Daily, offset from other scheduled workflows. Cron timing is best-effort. + - cron: "23 6 * * *" permissions: contents: read @@ -113,12 +113,6 @@ jobs: - name: Setup uses: ./.github/actions/setup - - name: Setup Go - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 - with: - go-version-file: apps/cli-go/go.mod - cache-dependency-path: apps/cli-go/go.sum - # Build the Go binary for every target: `go` runs it directly and # `ts-legacy` shells out to it for most commands. - name: Build Go CLI @@ -168,7 +162,7 @@ jobs: # Record that this beta tested green so the next scheduled run skips it. Needs # the whole matrix: the marker is saved only if BOTH go and ts-legacy passed - # (a red leg leaves no marker, so the next hour re-runs the same beta). + # (a red leg leaves no marker, so the next day re-runs the same beta). finalize: needs: [gate, live-e2e] if: github.event_name == 'schedule' && needs.live-e2e.result == 'success' diff --git a/.github/workflows/propose-release-notes.yml b/.github/workflows/propose-release-notes.yml index 164dfee30c..49de50a825 100644 --- a/.github/workflows/propose-release-notes.yml +++ b/.github/workflows/propose-release-notes.yml @@ -62,7 +62,7 @@ jobs: client-id: ${{ vars.GH_APP_CLIENT_ID }} private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} - - uses: useblacksmith/checkout@41cdeedae8edb2e684ba22896a5fd2a3cb85db6b # v1 + - uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 with: # Full history + tags so backfill-release-notes.ts can reach the # commit graph it needs (semantic-release walks notes back to the diff --git a/.github/workflows/publish-preview-cli-packages.yml b/.github/workflows/publish-preview-cli-packages.yml index 206a1931a5..e7486fc5f7 100644 --- a/.github/workflows/publish-preview-cli-packages.yml +++ b/.github/workflows/publish-preview-cli-packages.yml @@ -57,10 +57,15 @@ jobs: with: dependency-firewall-token: ${{ secrets.DF_FIREWALL_TOKEN }} - - name: Download preview build artifacts - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + - name: Restore preview build artifacts cache + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - name: cli-build-legacy-${{ env.PREVIEW_VERSION }} + path: | + packages/cli-*/bin/ + dist/ + key: cli-build-${{ github.run_id }}-legacy-${{ env.PREVIEW_VERSION }}-v1 + enableCrossOsArchive: true + fail-on-cache-miss: true - name: Prepare package files run: | diff --git a/.github/workflows/release-shared.yml b/.github/workflows/release-shared.yml index 7d4301c037..79a3175805 100644 --- a/.github/workflows/release-shared.yml +++ b/.github/workflows/release-shared.yml @@ -55,6 +55,10 @@ on: required: false DF_FIREWALL_TOKEN: required: false + LINEAR_CLI_STABLE_RELEASE_ACCESS_KEY: + required: false + LINEAR_CLI_BETA_RELEASE_ACCESS_KEY: + required: false jobs: build-blacksmith: name: Build CLI artifacts (Blacksmith) @@ -75,7 +79,7 @@ jobs: version: ${{ inputs.version }} shell: ${{ inputs.shell }} runner: large-linux-x86 - artifact_name_suffix: -github + cache_key_suffix: -github timeout_minutes: 45 build_timeout_minutes: 20 secrets: @@ -109,10 +113,15 @@ jobs: with: dependency-firewall-token: ${{ secrets.DF_FIREWALL_TOKEN }} - - name: Download build artifacts - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + - name: Restore build artifacts cache + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - name: cli-build-${{ inputs.shell }}-${{ inputs.version }} + path: | + packages/cli-*/bin/ + dist/ + key: cli-build-${{ github.run_id }}-${{ inputs.shell }}-${{ inputs.version }}-v1 + enableCrossOsArchive: true + fail-on-cache-miss: true # Docker's classic image store keeps a single platform manifest per # tag, so pulling `alpine:3.21` for amd64 and again for arm64 leaves @@ -240,10 +249,15 @@ jobs: with: dependency-firewall-token: ${{ secrets.DF_FIREWALL_TOKEN }} - - name: Download build artifacts - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + - name: Restore build artifacts cache + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - name: cli-build-${{ inputs.shell }}-${{ inputs.version }}-github + path: | + packages/cli-*/bin/ + dist/ + key: cli-build-${{ github.run_id }}-${{ inputs.shell }}-${{ inputs.version }}-github-v1 + enableCrossOsArchive: true + fail-on-cache-miss: true - name: Fix binary permissions run: chmod +x packages/cli-*/bin/supabase || true @@ -270,6 +284,8 @@ jobs: CHANNEL: ${{ inputs.channel }} NPM_TAG: ${{ inputs.npm_tag }} VERSION: ${{ inputs.version }} + LINEAR_CLI_STABLE_RELEASE_ACCESS_KEY: ${{ secrets.LINEAR_CLI_STABLE_RELEASE_ACCESS_KEY }} + LINEAR_CLI_BETA_RELEASE_ACCESS_KEY: ${{ secrets.LINEAR_CLI_BETA_RELEASE_ACCESS_KEY }} permissions: contents: write id-token: write @@ -287,6 +303,7 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: true + fetch-depth: 0 token: ${{ steps.app-token.outputs.token }} - name: Setup @@ -294,17 +311,15 @@ jobs: with: dependency-firewall-token: ${{ secrets.DF_FIREWALL_TOKEN }} - - name: Download build artifacts - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + - name: Restore build artifacts cache + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - name: cli-build-${{ inputs.shell }}-${{ inputs.version }}-github - - # Artifacts are zipped and do not carry Unix permissions, so the compiled - # binaries arrive without the executable bit. publish.ts ships - # packages/cli-*/bin/supabase to npm verbatim, so restore +x before - # publishing or the installed CLI would not be runnable. - - name: Fix binary permissions - run: chmod +x packages/cli-*/bin/supabase || true + path: | + packages/cli-*/bin/ + dist/ + key: cli-build-${{ github.run_id }}-${{ inputs.shell }}-${{ inputs.version }}-github-v1 + enableCrossOsArchive: true + fail-on-cache-miss: true - name: Sync versions run: pnpm exec bun apps/cli/scripts/sync-versions.ts --version "${VERSION}" @@ -409,6 +424,28 @@ jobs: GH_TOKEN: ${{ github.token }} run: gh release edit "v${VERSION}" --draft=false + - name: Sync stable release to Linear + if: ${{ inputs.channel == 'stable' && env.LINEAR_CLI_STABLE_RELEASE_ACCESS_KEY != '' }} + uses: linear/linear-release-action@c0cb8354a362c24c6d3e0948f37fd66d07588e3f # v0 + with: + access_key: ${{ env.LINEAR_CLI_STABLE_RELEASE_ACCESS_KEY }} + version: v${{ inputs.version }} + name: Supabase CLI v${{ inputs.version }} + links: | + GitHub Release=https://github.com/${{ github.repository }}/releases/tag/v${{ inputs.version }} + Release workflow=https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} + + - name: Sync beta release to Linear + if: ${{ inputs.channel == 'beta' && env.LINEAR_CLI_BETA_RELEASE_ACCESS_KEY != '' }} + uses: linear/linear-release-action@c0cb8354a362c24c6d3e0948f37fd66d07588e3f # v0 + with: + access_key: ${{ env.LINEAR_CLI_BETA_RELEASE_ACCESS_KEY }} + version: v${{ inputs.version }} + name: Supabase CLI v${{ inputs.version }} + links: | + GitHub Release=https://github.com/${{ github.repository }}/releases/tag/v${{ inputs.version }} + Release workflow=https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} + # Changelog backfill only needs tag + published GH release (from publish). # Runs in parallel with brew/scoop/smoke; must not gate distribution. backfill-release-notes: @@ -442,6 +479,8 @@ jobs: publish-homebrew: needs: publish if: ${{ !inputs.dry_run && inputs.publish_brew_scoop }} + # github-hosted to share a cache store with build-github/publish, whose + # -github-v1 artifacts this job's checksums must match. runs-on: ubuntu-latest timeout-minutes: 30 env: @@ -458,16 +497,21 @@ jobs: with: dependency-firewall-token: ${{ secrets.DF_FIREWALL_TOKEN }} - # Must download the github-hosted build (-github), the same artifacts the - # publish job uploads to the GitHub Release. The Bun-compiled binaries are - # not byte-for-byte reproducible across the blacksmith and github builds, - # so the blacksmith dist/checksums.txt does not match the released + # Must restore the github-hosted build (-github-v1), the same artifacts + # the publish job uploads to the GitHub Release. The Bun-compiled binaries + # are not byte-for-byte reproducible across the blacksmith and github + # builds, so the blacksmith dist/checksums.txt does not match the released # tarballs. Reading it here produced a formula whose sha256 rejected the # downloaded archive ("Formula reports different checksum"). - - name: Download build artifacts - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + - name: Restore build artifacts cache + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - name: cli-build-${{ inputs.shell }}-${{ inputs.version }}-github + path: | + packages/cli-*/bin/ + dist/ + key: cli-build-${{ github.run_id }}-${{ inputs.shell }}-${{ inputs.version }}-github-v1 + enableCrossOsArchive: true + fail-on-cache-miss: true - name: Generate Homebrew tap token id: app-token @@ -498,6 +542,8 @@ jobs: publish-scoop: needs: publish if: ${{ !inputs.dry_run && inputs.publish_brew_scoop }} + # github-hosted to share a cache store with build-github/publish, whose + # -github-v1 artifacts this job's checksums must match. runs-on: ubuntu-latest timeout-minutes: 30 env: @@ -514,16 +560,21 @@ jobs: with: dependency-firewall-token: ${{ secrets.DF_FIREWALL_TOKEN }} - # Must download the github-hosted build (-github), the same artifacts the - # publish job uploads to the GitHub Release. The Bun-compiled binaries are - # not byte-for-byte reproducible across the blacksmith and github builds, - # so the blacksmith dist/checksums.txt does not match the released + # Must restore the github-hosted build (-github-v1), the same artifacts + # the publish job uploads to the GitHub Release. The Bun-compiled binaries + # are not byte-for-byte reproducible across the blacksmith and github + # builds, so the blacksmith dist/checksums.txt does not match the released # tarballs. Reading it here would produce a manifest whose hash rejects the # downloaded archive. - - name: Download build artifacts - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + - name: Restore build artifacts cache + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - name: cli-build-${{ inputs.shell }}-${{ inputs.version }}-github + path: | + packages/cli-*/bin/ + dist/ + key: cli-build-${{ github.run_id }}-${{ inputs.shell }}-${{ inputs.version }}-github-v1 + enableCrossOsArchive: true + fail-on-cache-miss: true - name: Generate Scoop bucket token id: app-token diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 276c8c5add..50de42423f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -64,7 +64,7 @@ jobs: client-id: ${{ vars.GH_APP_CLIENT_ID }} private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} permission-contents: write - - uses: useblacksmith/checkout@41cdeedae8edb2e684ba22896a5fd2a3cb85db6b # v1 + - uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 with: persist-credentials: true fetch-depth: 0 @@ -109,7 +109,7 @@ jobs: # Authorization header overrides the App token semantic-release puts in # the push URL — making the dry-push identify as `github-actions[bot]` # and get rejected by branch protection. - - uses: useblacksmith/checkout@41cdeedae8edb2e684ba22896a5fd2a3cb85db6b # v1 + - uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 with: fetch-depth: 0 persist-credentials: false @@ -225,6 +225,8 @@ jobs: GH_APP_PRIVATE_KEY: ${{ secrets.GH_APP_PRIVATE_KEY }} ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} DF_FIREWALL_TOKEN: ${{ secrets.DF_FIREWALL_TOKEN }} + LINEAR_CLI_STABLE_RELEASE_ACCESS_KEY: ${{ secrets.LINEAR_CLI_STABLE_RELEASE_ACCESS_KEY }} + LINEAR_CLI_BETA_RELEASE_ACCESS_KEY: ${{ secrets.LINEAR_CLI_BETA_RELEASE_ACCESS_KEY }} # Posts to the release Slack channel once the pipeline succeeds. Listing # `release` in `needs` without a status function in `if:` keeps the implicit diff --git a/.github/workflows/sync-main-to-develop.yml b/.github/workflows/sync-main-to-develop.yml new file mode 100644 index 0000000000..b46397c33b --- /dev/null +++ b/.github/workflows/sync-main-to-develop.yml @@ -0,0 +1,56 @@ +name: Sync main to develop + +# Keeps stable hotfix commits reachable from the integration branch after the +# production source of truth publishes successfully. +on: + workflow_run: + workflows: + - Release + types: + - completed + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: sync-main-to-develop + cancel-in-progress: false + +jobs: + sync: + name: Merge main into develop + if: github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + steps: + - id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.GH_APP_CLIENT_ID }} + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + permission-contents: write + + - uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 + with: + ref: develop + fetch-depth: 0 + persist-credentials: true + token: ${{ steps.app-token.outputs.token }} + + - name: Merge main into develop + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git fetch origin main develop + if git merge-base --is-ancestor origin/main HEAD; then + echo "develop already contains main." + else + git merge origin/main --no-edit + git push origin develop + fi diff --git a/.github/workflows/sync-stack-service-versions.yml b/.github/workflows/sync-stack-service-versions.yml new file mode 100644 index 0000000000..0650e3e233 --- /dev/null +++ b/.github/workflows/sync-stack-service-versions.yml @@ -0,0 +1,61 @@ +name: Sync Stack Service Versions + +on: + pull_request: + types: + - opened + - synchronize + - reopened + paths: + - apps/cli-go/pkg/config/templates/Dockerfile + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + sync: + name: Sync stack service versions + runs-on: blacksmith-2vcpu-ubuntu-2404 + if: github.event.pull_request.user.login == 'dependabot[bot]' && github.repository == github.event.pull_request.head.repo.full_name + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ github.event.pull_request.head.ref }} + persist-credentials: false + + - name: Setup + uses: ./.github/actions/setup + with: + dependency-firewall-token: ${{ secrets.DF_FIREWALL_TOKEN }} + + - name: Sync stack service versions + run: pnpm sync:versions + working-directory: packages/stack + + - name: Generate token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.GH_APP_CLIENT_ID }} + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + permission-contents: write + + - name: Commit synced stack service versions + env: + GH_APP_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + if git diff --quiet -- packages/stack/src/versions.ts; then + echo "Stack service versions are already synced." + exit 0 + fi + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add packages/stack/src/versions.ts + git commit -m "chore(stack): sync service version manifest" + git push "https://x-access-token:${GH_APP_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "HEAD:${GITHUB_HEAD_REF}" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e5da5adca0..571fb5fc62 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -43,14 +43,6 @@ jobs: uses: ./.github/actions/setup with: dependency-firewall-token: ${{ secrets.DF_FIREWALL_TOKEN }} - - name: Setup Go - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 - with: - go-version-file: apps/cli-go/go.mod - cache-dependency-path: apps/cli-go/go.sum - - name: Install golangci-lint - run: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest && - echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" - name: Unlock keyring (for cli-go keyring tests) uses: t1m0thyj/unlock-keyring@cbcf205c879ebd86add70bab3a6abfcce59a5cae # v1.2.0 @@ -74,14 +66,6 @@ jobs: uses: ./.github/actions/setup with: dependency-firewall-token: ${{ secrets.DF_FIREWALL_TOKEN }} - - name: Setup Go - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 - with: - go-version-file: apps/cli-go/go.mod - cache-dependency-path: apps/cli-go/go.sum - - name: Install golangci-lint - run: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest && - echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" - name: Unlock keyring (for cli-go keyring tests) uses: t1m0thyj/unlock-keyring@cbcf205c879ebd86add70bab3a6abfcce59a5cae # v1.2.0 @@ -101,7 +85,7 @@ jobs: shard: [ 1, 2, 3 ] steps: - name: Checkout - uses: useblacksmith/checkout@41cdeedae8edb2e684ba22896a5fd2a3cb85db6b # v1.0.0-beta + uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1.0.0-beta with: fetch-depth: 0 @@ -144,14 +128,6 @@ jobs: key: go-cli-${{ runner.os }}-${{ hashFiles('apps/cli-go/**/*.go', 'apps/cli-go/go.mod', 'apps/cli-go/go.sum') }} - - name: Setup Go - if: steps.detect.outputs.cli_e2e == 'true' && - steps.cache-go-binary.outputs.cache-hit != 'true' - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 - with: - go-version-file: apps/cli-go/go.mod - cache-dependency-path: apps/cli-go/go.sum - - name: Build Go CLI if: steps.detect.outputs.cli_e2e == 'true' && steps.cache-go-binary.outputs.cache-hit != 'true' diff --git a/.nvmrc b/.nvmrc deleted file mode 100644 index cabf43b5dd..0000000000 --- a/.nvmrc +++ /dev/null @@ -1 +0,0 @@ -24 \ No newline at end of file diff --git a/.repos/effect b/.repos/effect index 49b5a569ea..455ecc9ac7 160000 --- a/.repos/effect +++ b/.repos/effect @@ -1 +1 @@ -Subproject commit 49b5a569ea6c9d459f3db9cb2f150ca9d04b3cd0 +Subproject commit 455ecc9ac70f8495bd829e42a92d416d0b3fc730 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cfa452dd09..4312076c56 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,6 +4,60 @@ Bun monorepo for exploring the next generation of the Supabase CLI and local dev ## Setup +### Tool versions + +This repo pins the versions of Node, Bun, Go, pnpm, and golangci-lint that contributors are expected to build against, and uses [`mise`](https://mise.jdx.dev/) — a polyglot version manager — to install and activate them automatically. If you don't already have these tools installed, `mise` is a great way to get up and running quickly. + +#### Installing mise + +```sh +# macOS / Linux +curl https://mise.run | sh + +# macOS via Homebrew +brew install mise +``` + +See the [`mise` installation docs](https://mise.jdx.dev/getting-started.html) for other package managers (apt, dnf, cargo, npm, Windows, …). + +`mise` needs to hook into your shell so it can inject the right tool versions into your `PATH` as you move between directories. Follow the `mise activate` instructions [in this section](https://mise.jdx.dev/getting-started.html#activate-mise) to add the activation line for your shell to its startup file. + +This repo relies on `mise` support for reading Node and pnpm versions from `package.json`, so use mise `2026.7.0` or newer. + +#### Installing the pinned tool versions + +Trust this repo's `mise.toml` once from the repo root so `mise` can read the project setting that enables idiomatic version files: + +```sh +mise trust +``` + +Then install the pinned tool versions: + +```sh +mise install +``` + +`mise install` resolves the versions this repo expects from a handful of files, rather than hardcoding them all in one place: + +| Tool | Version source | +| --- | --- | +| Bun | `.bun-version` | +| Node.js | `devEngines.runtime` field in `package.json` | +| pnpm | `packageManager` field in `package.json` | +| Go | `mise.toml` | +| golangci-lint | `mise.toml` | + +The Go and golangci-lint entries in `mise.toml` are intentionally temporary while the Go CLI remains in the repo. The canonical Go module metadata still lives in `apps/cli-go/go.mod`; keep the `mise.toml` entries aligned only until the Go code is removed. + +Once installed, `mise` activates these versions automatically whenever your shell is inside this repo — no manual `nvm use`, `gvm use`, or similar switching required. + +#### Without mise + +`mise` is not required. If you already have Bun, Node, pnpm, and Go installed and managed some other way, just make sure your versions match the ones pinned in `.bun-version`, `mise.toml`, `package.json`, and `apps/cli-go/go.mod`. + +### Install dependencies + Install workspace dependencies: ```sh diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___postgrest/2.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___postgrest/2.response.json index be253aaf01..ec12771c6e 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___postgrest/2.response.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___postgrest/2.response.json @@ -12,6 +12,7 @@ "max_rows": 1000, "db_extra_search_path": "public, extensions", "db_pool": null, + "db_pool_acquisition_timeout": null, "jwt_secret": "EH9SYrAOLy4wUwRSts5kU5oZfiqFLy3+v+a1EAwFpKRbqxzPldJ5wMoiySHJiDmyWrMZXL5yK/9V7XkCtRdU0Q==" } } diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___postgrest/3.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___postgrest/3.response.json index 7d6f03351f..71b0aeac92 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___postgrest/3.response.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___postgrest/3.response.json @@ -12,6 +12,7 @@ "max_rows": 1000, "db_extra_search_path": "public, extensions", "db_pool": null, + "db_pool_acquisition_timeout": null, "jwt_secret": "r0/2ujDmPfvUIbzLHvYV1Uz58kitPHk55HZt/wJ8ULYVsNKQ7huFluWj7LL+yi8nTv+yUVPEHFtsqCiHlR1lRw==" } } diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___postgrest/default.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___postgrest/default.response.json index 563ffe0590..abacd48f2b 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___postgrest/default.response.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___postgrest/default.response.json @@ -12,6 +12,7 @@ "max_rows": 1000, "db_extra_search_path": "public, extensions", "db_pool": null, + "db_pool_acquisition_timeout": null, "jwt_secret": "wYu1Y5zcq5XMI/BHlkIl6p9LkFCuWMLyiAVVoKKcIKJkp2un+UF9KkNth/4YXd2nLagAXtJ0wq/DaixwsuUfww==" } } diff --git a/apps/cli-e2e/fixtures/scenarios/config-push-emits-http-trace-with-debug/interactions.json b/apps/cli-e2e/fixtures/scenarios/config-push-emits-http-trace-with-debug/interactions.json index 4341b8a12a..72a45c2466 100644 --- a/apps/cli-e2e/fixtures/scenarios/config-push-emits-http-trace-with-debug/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/config-push-emits-http-trace-with-debug/interactions.json @@ -691,6 +691,7 @@ "max_rows": 1000, "db_extra_search_path": "public, extensions", "db_pool": null, + "db_pool_acquisition_timeout": null, "jwt_secret": "EH9SYrAOLy4wUwRSts5kU5oZfiqFLy3+v+a1EAwFpKRbqxzPldJ5wMoiySHJiDmyWrMZXL5yK/9V7XkCtRdU0Q==" } } diff --git a/apps/cli-e2e/fixtures/scenarios/config-push-reconciles-every-section-against-the-remote/interactions.json b/apps/cli-e2e/fixtures/scenarios/config-push-reconciles-every-section-against-the-remote/interactions.json index b19751a226..71257560ea 100644 --- a/apps/cli-e2e/fixtures/scenarios/config-push-reconciles-every-section-against-the-remote/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/config-push-reconciles-every-section-against-the-remote/interactions.json @@ -691,6 +691,7 @@ "max_rows": 1000, "db_extra_search_path": "public, extensions", "db_pool": null, + "db_pool_acquisition_timeout": null, "jwt_secret": "EH9SYrAOLy4wUwRSts5kU5oZfiqFLy3+v+a1EAwFpKRbqxzPldJ5wMoiySHJiDmyWrMZXL5yK/9V7XkCtRdU0Q==" } } diff --git a/apps/cli-e2e/fixtures/scenarios/gen-types-exits-non-zero-with-lang-go-when-using-project-id/interactions.json b/apps/cli-e2e/fixtures/scenarios/gen-types-exits-non-zero-with-lang-go-when-using-project-id/interactions.json deleted file mode 100644 index b090bbeaed..0000000000 --- a/apps/cli-e2e/fixtures/scenarios/gen-types-exits-non-zero-with-lang-go-when-using-project-id/interactions.json +++ /dev/null @@ -1,29 +0,0 @@ -[ - { - "request": { - "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", - "query": {}, - "headers": { - "accept-encoding": "gzip", - "authorization": "Bearer __ACCESS_TOKEN__", - "host": "localhost:__PORT__", - "user-agent": "SupabaseCLI/" - }, - "body": null - }, - "response": { - "status": 400, - "headers": { - "content-type": "application/json; charset=utf-8", - "x-gotrue-id": "__UUID__", - "x-ratelimit-limit": "120", - "x-ratelimit-remaining": "119", - "x-ratelimit-reset": "60" - }, - "body": { - "message": "Resource has been removed" - } - } - } -] diff --git a/apps/cli-e2e/fixtures/scenarios/link-links-when-only-supabase-project-id-is-set-in-non-tty/interactions.json b/apps/cli-e2e/fixtures/scenarios/link-links-when-only-supabase-project-id-is-set-in-non-tty/interactions.json index 263250637c..a0d179bafa 100644 --- a/apps/cli-e2e/fixtures/scenarios/link-links-when-only-supabase-project-id-is-set-in-non-tty/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/link-links-when-only-supabase-project-id-is-set-in-non-tty/interactions.json @@ -236,6 +236,7 @@ "max_rows": 1000, "db_extra_search_path": "public, extensions", "db_pool": null, + "db_pool_acquisition_timeout": null, "jwt_secret": "wYu1Y5zcq5XMI/BHlkIl6p9LkFCuWMLyiAVVoKKcIKJkp2un+UF9KkNth/4YXd2nLagAXtJ0wq/DaixwsuUfww==" } } diff --git a/apps/cli-e2e/src/tests/gen.e2e.test.ts b/apps/cli-e2e/src/tests/gen.e2e.test.ts index cdb173382c..aa92480a80 100644 --- a/apps/cli-e2e/src/tests/gen.e2e.test.ts +++ b/apps/cli-e2e/src/tests/gen.e2e.test.ts @@ -67,12 +67,6 @@ describe("gen", () => { expect(result.stderr).toContain("Project not found"); }); - testBehaviour("exits non-zero with --lang go when using --project-id", async ({ run }) => { - const result = await run(["gen", "types", "--project-id", PROJECT_REF, "--lang", "go"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("db-url"); - }); - testBehaviour("exits non-zero with no data source specified", async ({ runNoProjectId }) => { const result = await runNoProjectId(["gen", "types"]); expect(result.exitCode).not.toBe(0); diff --git a/apps/cli-go/go.mod b/apps/cli-go/go.mod index 4bc46b5d07..82b8ea4526 100644 --- a/apps/cli-go/go.mod +++ b/apps/cli-go/go.mod @@ -1,6 +1,6 @@ module github.com/supabase/cli -go 1.25.5 +go 1.25.11 require ( github.com/BurntSushi/toml v1.6.0 @@ -42,7 +42,7 @@ require ( github.com/multigres/multigres v0.0.0-20260126223308-f5a52171bbc4 github.com/oapi-codegen/nullable v1.2.0 github.com/olekukonko/tablewriter v1.1.4 - github.com/posthog/posthog-go v1.16.1 + github.com/posthog/posthog-go v1.17.2 github.com/spf13/afero v1.15.0 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 @@ -65,31 +65,40 @@ require ( require ( 4d63.com/gocheckcompilerdirectives v1.3.0 // indirect 4d63.com/gochecknoglobals v0.2.2 // indirect + charm.land/lipgloss/v2 v2.0.3 // indirect + codeberg.org/chavacava/garif v0.2.0 // indirect + codeberg.org/polyfloyd/go-errorlint v1.9.0 // indirect dario.cat/mergo v1.0.2 // indirect - github.com/4meepo/tagalign v1.4.2 // indirect - github.com/Abirdcfly/dupword v0.1.3 // indirect - github.com/Antonboom/errname v1.1.0 // indirect - github.com/Antonboom/nilnil v1.1.0 // indirect - github.com/Antonboom/testifylint v1.6.1 // indirect + dev.gaijin.team/go/exhaustruct/v4 v4.0.0 // indirect + dev.gaijin.team/go/golib v0.6.0 // indirect + github.com/4meepo/tagalign v1.4.3 // indirect + github.com/Abirdcfly/dupword v0.1.7 // indirect + github.com/AdminBenni/iota-mixing v1.0.0 // indirect + github.com/AlwxSin/noinlineerr v1.0.5 // indirect + github.com/Antonboom/errname v1.1.1 // indirect + github.com/Antonboom/nilnil v1.1.1 // indirect + github.com/Antonboom/testifylint v1.6.4 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect + github.com/ClickHouse/clickhouse-go-linter v1.2.0 // indirect github.com/DefangLabs/secret-detector v0.0.0-20250403165618-22662109213e // indirect - github.com/Djarvur/go-err113 v0.0.0-20210108212216-aea10b59be24 // indirect - github.com/GaijinEntertainment/go-exhaustruct/v3 v3.3.1 // indirect - github.com/Masterminds/semver/v3 v3.4.0 // indirect + github.com/Djarvur/go-err113 v0.1.1 // indirect + github.com/Masterminds/semver/v3 v3.5.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/MirrexOne/unqueryvet v1.5.4 // indirect github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect github.com/ProtonMail/go-crypto v1.3.0 // indirect github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d // indirect - github.com/alecthomas/chroma/v2 v2.20.0 // indirect + github.com/alecthomas/chroma/v2 v2.24.1 // indirect github.com/alecthomas/go-check-sumtype v0.3.1 // indirect github.com/alexkohler/nakedret/v2 v2.0.6 // indirect - github.com/alexkohler/prealloc v1.0.0 // indirect + github.com/alexkohler/prealloc v1.1.0 // indirect + github.com/alfatraining/structtag v1.0.0 // indirect github.com/alingse/asasalint v0.0.11 // indirect github.com/alingse/nilnesserr v0.2.0 // indirect github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect - github.com/ashanbrown/forbidigo v1.6.0 // indirect - github.com/ashanbrown/makezero v1.2.0 // indirect + github.com/ashanbrown/forbidigo/v2 v2.3.1 // indirect + github.com/ashanbrown/makezero/v2 v2.2.1 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aws/aws-sdk-go-v2 v1.41.1 // indirect github.com/aws/aws-sdk-go-v2/config v1.32.7 // indirect @@ -112,26 +121,29 @@ require ( github.com/bkielbasa/cyclop v1.2.3 // indirect github.com/blizzy78/varnamelen v0.8.0 // indirect github.com/bombsimon/wsl/v4 v4.7.0 // indirect + github.com/bombsimon/wsl/v5 v5.8.0 // indirect github.com/breml/bidichk v0.3.3 // indirect github.com/breml/errchkjson v0.4.1 // indirect github.com/buger/goterm v1.0.4 // indirect - github.com/butuzov/ireturn v0.4.0 // indirect + github.com/butuzov/ireturn v0.4.1 // indirect github.com/butuzov/mirror v1.3.0 // indirect - github.com/catenacyber/perfsprint v0.9.1 // indirect - github.com/ccojocar/zxcvbn-go v1.0.2 // indirect + github.com/catenacyber/perfsprint v0.10.1 // indirect + github.com/ccojocar/zxcvbn-go v1.0.4 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/charithe/durationcheck v0.0.10 // indirect - github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charithe/durationcheck v0.0.11 // indirect + github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/harmonica v0.2.0 // indirect - github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 // indirect + github.com/charmbracelet/x/ansi v0.11.7 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect github.com/charmbracelet/x/term v0.2.2 // indirect - github.com/chavacava/garif v0.1.0 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect github.com/ckaznocha/intrange v0.3.1 // indirect - github.com/clipperhouse/displaywidth v0.10.0 // indirect - github.com/clipperhouse/uax29/v2 v2.6.0 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/cloudflare/circl v1.6.3 // indirect github.com/containerd/console v1.0.5 // indirect github.com/containerd/containerd/api v1.10.0 // indirect @@ -145,14 +157,14 @@ require ( github.com/containers/storage v1.59.1 // indirect github.com/curioswitch/go-reassign v0.3.0 // indirect github.com/cyphar/filepath-securejoin v0.6.1 // indirect - github.com/daixiang0/gci v0.13.6 // indirect + github.com/daixiang0/gci v0.13.7 // indirect github.com/danieljoos/wincred v1.2.3 // indirect github.com/dave/dst v0.27.3 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect github.com/denis-tingaikin/go-header v0.5.0 // indirect github.com/distribution/reference v0.6.0 // indirect - github.com/dlclark/regexp2 v1.11.5 // indirect + github.com/dlclark/regexp2 v1.12.0 // indirect github.com/dnephin/pflag v1.0.7 // indirect github.com/docker/buildx v0.29.1 // indirect github.com/docker/cli-docs-tool v0.10.0 // indirect @@ -168,7 +180,7 @@ require ( github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/ethereum/go-ethereum v1.17.0 // indirect github.com/ettle/strcase v0.2.0 // indirect - github.com/fatih/color v1.18.0 // indirect + github.com/fatih/color v1.19.0 // indirect github.com/fatih/structtag v1.2.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/firefart/nonamedreturns v1.0.6 // indirect @@ -178,8 +190,8 @@ require ( github.com/fzipp/gocyclo v0.6.0 // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/getkin/kin-openapi v0.131.0 // indirect - github.com/ghostiam/protogetter v0.3.15 // indirect - github.com/go-critic/go-critic v0.13.0 // indirect + github.com/ghostiam/protogetter v0.3.20 // indirect + github.com/go-critic/go-critic v0.14.3 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.9.0 // indirect github.com/go-logr/logr v1.4.3 // indirect @@ -212,38 +224,42 @@ require ( github.com/gobwas/glob v0.2.3 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect + github.com/godoc-lint/godoc-lint v0.11.2 // indirect github.com/gofrs/flock v0.13.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/golang/protobuf v1.5.4 // indirect - github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect - github.com/golangci/go-printf-func-name v0.1.0 // indirect + github.com/golangci/asciicheck v0.5.0 // indirect + github.com/golangci/dupl v0.0.0-20260401084720-c99c5cf5c202 // indirect + github.com/golangci/go-printf-func-name v0.1.1 // indirect github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d // indirect - github.com/golangci/golangci-lint/v2 v2.1.6 // indirect - github.com/golangci/golines v0.0.0-20250217134842-442fd0091d95 // indirect - github.com/golangci/misspell v0.6.0 // indirect - github.com/golangci/plugin-module-register v0.1.1 // indirect + github.com/golangci/golangci-lint/v2 v2.12.2 // indirect + github.com/golangci/golines v0.15.0 // indirect + github.com/golangci/misspell v0.8.0 // indirect + github.com/golangci/plugin-module-register v0.1.2 // indirect github.com/golangci/revgrep v0.8.0 // indirect + github.com/golangci/rowserrcheck v0.0.0-20260419091836-c5f79b8a11ba // indirect + github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e // indirect github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e // indirect github.com/google/certificate-transparency-go v1.3.2 // indirect github.com/google/gnostic-models v0.7.0 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect - github.com/gordonklaus/ineffassign v0.1.0 // indirect + github.com/gordonklaus/ineffassign v0.2.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/gorilla/mux v1.8.1 // indirect github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect github.com/gostaticanalysis/analysisutil v0.7.1 // indirect github.com/gostaticanalysis/comment v1.5.0 // indirect github.com/gostaticanalysis/forcetypeassert v0.2.0 // indirect - github.com/gostaticanalysis/nilerr v0.1.1 // indirect + github.com/gostaticanalysis/nilerr v0.1.2 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-immutable-radix/v2 v2.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect - github.com/hashicorp/go-version v1.7.0 // indirect + github.com/hashicorp/go-version v1.9.0 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/hexops/gotextdiff v1.0.3 // indirect github.com/in-toto/attestation v1.1.2 // indirect @@ -257,45 +273,46 @@ require ( github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgtype v1.14.4 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect - github.com/jgautheron/goconst v1.8.1 // indirect - github.com/jingyugao/rowserrcheck v1.1.1 // indirect - github.com/jjti/go-spancheck v0.6.4 // indirect + github.com/jgautheron/goconst v1.10.0 // indirect + github.com/jjti/go-spancheck v0.6.5 // indirect github.com/jonboulle/clockwork v0.5.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/julz/importas v0.2.0 // indirect - github.com/karamaru-alpha/copyloopvar v1.2.1 // indirect + github.com/karamaru-alpha/copyloopvar v1.2.2 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect - github.com/kisielk/errcheck v1.9.0 // indirect + github.com/kisielk/errcheck v1.10.0 // indirect github.com/kkHAIKE/contextcheck v1.1.6 // indirect github.com/klauspost/compress v1.18.5 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/kr/text v0.2.0 // indirect - github.com/kulti/thelper v0.6.3 // indirect - github.com/kunwardeep/paralleltest v1.0.14 // indirect + github.com/kulti/thelper v0.7.1 // indirect + github.com/kunwardeep/paralleltest v1.0.15 // indirect github.com/lasiar/canonicalheader v1.1.2 // indirect - github.com/ldez/exptostd v0.4.3 // indirect - github.com/ldez/gomoddirectives v0.6.1 // indirect - github.com/ldez/grignotin v0.9.0 // indirect - github.com/ldez/tagliatelle v0.7.1 // indirect - github.com/ldez/usetesting v0.4.3 // indirect + github.com/ldez/exptostd v0.4.5 // indirect + github.com/ldez/gomoddirectives v0.8.0 // indirect + github.com/ldez/grignotin v0.10.1 // indirect + github.com/ldez/structtags v0.6.1 // indirect + github.com/ldez/tagliatelle v0.7.2 // indirect + github.com/ldez/usetesting v0.5.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/leonklingele/grouper v1.1.2 // indirect - github.com/lib/pq v1.10.9 // indirect - github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/lib/pq v1.12.3 // indirect + github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/macabu/inamedparam v0.2.0 // indirect github.com/mailru/easyjson v0.9.0 // indirect - github.com/manuelarte/funcorder v0.2.1 // indirect - github.com/maratori/testableexamples v1.0.0 // indirect - github.com/maratori/testpackage v1.1.1 // indirect + github.com/manuelarte/embeddedstructfieldcheck v0.4.0 // indirect + github.com/manuelarte/funcorder v0.6.0 // indirect + github.com/maratori/testableexamples v1.0.1 // indirect + github.com/maratori/testpackage v1.1.2 // indirect github.com/matoous/godox v1.1.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/mattn/go-runewidth v0.0.23 // indirect github.com/mattn/go-shellwords v1.0.12 // indirect - github.com/mgechev/revive v1.9.0 // indirect + github.com/mgechev/revive v1.15.0 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/miekg/pkcs11 v1.1.1 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect @@ -332,7 +349,7 @@ require ( github.com/nakabonne/nestif v0.3.1 // indirect github.com/nishanths/exhaustive v0.12.0 // indirect github.com/nishanths/predeclared v0.2.2 // indirect - github.com/nunnatsa/ginkgolinter v0.19.1 // indirect + github.com/nunnatsa/ginkgolinter v0.23.0 // indirect github.com/oapi-codegen/oapi-codegen/v2 v2.4.1 // indirect github.com/oapi-codegen/runtime v1.4.2 // indirect github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect @@ -343,13 +360,12 @@ require ( github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pelletier/go-toml v1.9.5 // indirect - github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pelletier/go-toml/v2 v2.3.1 // indirect github.com/perimeterx/marshmallow v1.1.5 // indirect github.com/pjbgf/sha1cd v0.6.0 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/polyfloyd/go-errorlint v1.8.0 // indirect github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.67.4 // indirect @@ -363,15 +379,16 @@ require ( github.com/rivo/uniseg v0.4.7 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/ryancurrah/gomodguard v1.4.1 // indirect - github.com/ryanrolds/sqlclosecheck v0.5.1 // indirect + github.com/ryancurrah/gomodguard/v2 v2.1.3 // indirect + github.com/ryanrolds/sqlclosecheck v0.6.0 // indirect github.com/sagikazarmark/locafero v0.12.0 // indirect github.com/sahilm/fuzzy v0.1.1 // indirect github.com/sanposhiho/wastedassign/v2 v2.1.0 // indirect - github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect github.com/sashamelentyev/interfacebloat v1.1.0 // indirect - github.com/sashamelentyev/usestdlibvars v1.28.0 // indirect + github.com/sashamelentyev/usestdlibvars v1.29.0 // indirect github.com/secure-systems-lab/go-securesystemslib v0.10.0 // indirect - github.com/securego/gosec/v2 v2.22.3 // indirect + github.com/securego/gosec/v2 v2.26.1 // indirect github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect github.com/serialx/hashring v0.0.0-20200727003509-22c0c7ab6b1b // indirect github.com/shibumi/go-pathspec v1.3.0 // indirect @@ -379,21 +396,20 @@ require ( github.com/sivchari/containedctx v1.0.3 // indirect github.com/skeema/knownhosts v1.3.1 // indirect github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 // indirect - github.com/sonatard/noctx v0.1.0 // indirect - github.com/sourcegraph/go-diff v0.7.0 // indirect + github.com/sonatard/noctx v0.5.1 // indirect + github.com/sourcegraph/go-diff v0.8.0 // indirect github.com/speakeasy-api/openapi-overlay v0.9.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/ssgreg/nlreturn/v2 v2.2.1 // indirect - github.com/stbenjam/no-sprintf-host-port v0.2.0 // indirect + github.com/stbenjam/no-sprintf-host-port v0.3.1 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/subosito/gotenv v1.6.0 // indirect - github.com/tdakkota/asciicheck v0.4.1 // indirect - github.com/tetafro/godot v1.5.1 // indirect + github.com/tetafro/godot v1.5.6 // indirect github.com/theupdateframework/notary v0.7.0 // indirect github.com/tilt-dev/fsnotify v1.4.8-0.20220602155310-fff9c274a375 // indirect - github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67 // indirect + github.com/timakin/bodyclose v0.0.0-20260129054331-73d1f95b84b4 // indirect github.com/timonwong/loggercheck v0.11.0 // indirect - github.com/tomarrell/wrapcheck/v2 v2.11.0 // indirect + github.com/tomarrell/wrapcheck/v2 v2.12.0 // indirect github.com/tommy-muehle/go-mnd/v2 v2.5.1 // indirect github.com/tonistiigi/dchapes-mode v0.0.0-20250318174251-73d941a28323 // indirect github.com/tonistiigi/fsutil v0.0.0-20251211185533-a2aa163d723f // indirect @@ -402,8 +418,8 @@ require ( github.com/tonistiigi/vt100 v0.0.0-20240514184818-90bafcd6abab // indirect github.com/ultraware/funlen v0.2.0 // indirect github.com/ultraware/whitespace v0.2.0 // indirect - github.com/uudashr/gocognit v1.2.0 // indirect - github.com/uudashr/iface v1.3.1 // indirect + github.com/uudashr/gocognit v1.2.1 // indirect + github.com/uudashr/iface v1.4.2 // indirect github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect @@ -420,9 +436,10 @@ require ( github.com/yuin/goldmark-emoji v1.0.6 // indirect github.com/zclconf/go-cty v1.17.0 // indirect gitlab.com/bosi/decorder v0.4.2 // indirect - go-simpler.org/musttag v0.13.1 // indirect - go-simpler.org/sloglint v0.11.0 // indirect - go.augendre.info/fatcontext v0.8.0 // indirect + go-simpler.org/musttag v0.14.0 // indirect + go-simpler.org/sloglint v0.12.0 // indirect + go.augendre.info/arangolint v0.4.0 // indirect + go.augendre.info/fatcontext v0.9.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.64.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.63.0 // indirect @@ -437,20 +454,17 @@ require ( go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect go.opentelemetry.io/otel/trace v1.44.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect - go.uber.org/automaxprocs v1.6.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.53.0 // indirect - golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac // indirect + golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358 // indirect golang.org/x/sync v0.21.0 // indirect golang.org/x/sys v0.46.0 // indirect golang.org/x/text v0.38.0 // indirect golang.org/x/time v0.14.0 // indirect golang.org/x/tools v0.45.0 // indirect - golang.org/x/tools/go/expect v0.1.1-deprecated // indirect - golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect google.golang.org/protobuf v1.36.11 // indirect @@ -460,15 +474,15 @@ require ( gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gotest.tools/gotestsum v1.12.2 // indirect - honnef.co/go/tools v0.6.1 // indirect + honnef.co/go/tools v0.7.0 // indirect k8s.io/api v0.34.1 // indirect k8s.io/apimachinery v0.34.1 // indirect k8s.io/client-go v0.34.1 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect - mvdan.cc/gofumpt v0.9.1 // indirect - mvdan.cc/unparam v0.0.0-20250301125049-0df0534333a4 // indirect + mvdan.cc/gofumpt v0.9.2 // indirect + mvdan.cc/unparam v0.0.0-20251027182757-5beb8c8f8f15 // indirect sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect diff --git a/apps/cli-go/go.sum b/apps/cli-go/go.sum index e8ed2f0f72..f929d36494 100644 --- a/apps/cli-go/go.sum +++ b/apps/cli-go/go.sum @@ -2,43 +2,59 @@ 4d63.com/gocheckcompilerdirectives v1.3.0/go.mod h1:ofsJ4zx2QAuIP/NO/NAh1ig6R1Fb18/GI7RVMwz7kAY= 4d63.com/gochecknoglobals v0.2.2 h1:H1vdnwnMaZdQW/N+NrkT1SZMTBmcwHe9Vq8lJcYYTtU= 4d63.com/gochecknoglobals v0.2.2/go.mod h1:lLxwTQjL5eIesRbvnzIP3jZtG140FnTdz+AlMa+ogt0= +charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU= +charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA= +codeberg.org/chavacava/garif v0.2.0 h1:F0tVjhYbuOCnvNcU3YSpO6b3Waw6Bimy4K0mM8y6MfY= +codeberg.org/chavacava/garif v0.2.0/go.mod h1:P2BPbVbT4QcvLZrORc2T29szK3xEOlnl0GiPTJmEqBQ= +codeberg.org/polyfloyd/go-errorlint v1.9.0 h1:VkdEEmA1VBpH6ecQoMR4LdphVI3fA4RrCh2an7YmodI= +codeberg.org/polyfloyd/go-errorlint v1.9.0/go.mod h1:GPRRu2LzVijNn4YkrZYJfatQIdS+TrcK8rL5Xs24qw8= cyphar.com/go-pathrs v0.2.1 h1:9nx1vOgwVvX1mNBWDu93+vaceedpbsDqo+XuBGL40b8= cyphar.com/go-pathrs v0.2.1/go.mod h1:y8f1EMG7r+hCuFf/rXsKqMJrJAUoADZGNh5/vZPKcGc= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +dev.gaijin.team/go/exhaustruct/v4 v4.0.0 h1:873r7aNneqoBB3IaFIzhvt2RFYTuHgmMjoKfwODoI1Y= +dev.gaijin.team/go/exhaustruct/v4 v4.0.0/go.mod h1:aZ/k2o4Y05aMJtiux15x8iXaumE88YdiB0Ai4fXOzPI= +dev.gaijin.team/go/golib v0.6.0 h1:v6nnznFTs4bppib/NyU1PQxobwDHwCXXl15P7DV5Zgo= +dev.gaijin.team/go/golib v0.6.0/go.mod h1:uY1mShx8Z/aNHWDyAkZTkX+uCi5PdX7KsG1eDQa2AVE= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= -github.com/4meepo/tagalign v1.4.2 h1:0hcLHPGMjDyM1gHG58cS73aQF8J4TdVR96TZViorO9E= -github.com/4meepo/tagalign v1.4.2/go.mod h1:+p4aMyFM+ra7nb41CnFG6aSDXqRxU/w1VQqScKqDARI= -github.com/Abirdcfly/dupword v0.1.3 h1:9Pa1NuAsZvpFPi9Pqkd93I7LIYRURj+A//dFd5tgBeE= -github.com/Abirdcfly/dupword v0.1.3/go.mod h1:8VbB2t7e10KRNdwTVoxdBaxla6avbhGzb8sCTygUMhw= +github.com/4meepo/tagalign v1.4.3 h1:Bnu7jGWwbfpAie2vyl63Zup5KuRv21olsPIha53BJr8= +github.com/4meepo/tagalign v1.4.3/go.mod h1:00WwRjiuSbrRJnSVeGWPLp2epS5Q/l4UEy0apLLS37c= +github.com/Abirdcfly/dupword v0.1.7 h1:2j8sInznrje4I0CMisSL6ipEBkeJUJAmK1/lfoNGWrQ= +github.com/Abirdcfly/dupword v0.1.7/go.mod h1:K0DkBeOebJ4VyOICFdppB23Q0YMOgVafM0zYW0n9lF4= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= -github.com/Antonboom/errname v1.1.0 h1:A+ucvdpMwlo/myWrkHEUEBWc/xuXdud23S8tmTb/oAE= -github.com/Antonboom/errname v1.1.0/go.mod h1:O1NMrzgUcVBGIfi3xlVuvX8Q/VP/73sseCaAppfjqZw= -github.com/Antonboom/nilnil v1.1.0 h1:jGxJxjgYS3VUUtOTNk8Z1icwT5ESpLH/426fjmQG+ng= -github.com/Antonboom/nilnil v1.1.0/go.mod h1:b7sAlogQjFa1wV8jUW3o4PMzDVFLbTux+xnQdvzdcIE= -github.com/Antonboom/testifylint v1.6.1 h1:6ZSytkFWatT8mwZlmRCHkWz1gPi+q6UBSbieji2Gj/o= -github.com/Antonboom/testifylint v1.6.1/go.mod h1:k+nEkathI2NFjKO6HvwmSrbzUcQ6FAnbZV+ZRrnXPLI= +github.com/AdminBenni/iota-mixing v1.0.0 h1:Os6lpjG2dp/AE5fYBPAA1zfa2qMdCAWwPMCgpwKq7wo= +github.com/AdminBenni/iota-mixing v1.0.0/go.mod h1:i4+tpAaB+qMVIV9OK3m4/DAynOd5bQFaOu+2AhtBCNY= +github.com/AlwxSin/noinlineerr v1.0.5 h1:RUjt63wk1AYWTXtVXbSqemlbVTb23JOSRiNsshj7TbY= +github.com/AlwxSin/noinlineerr v1.0.5/go.mod h1:+QgkkoYrMH7RHvcdxdlI7vYYEdgeoFOVjU9sUhw/rQc= +github.com/Antonboom/errname v1.1.1 h1:bllB7mlIbTVzO9jmSWVWLjxTEbGBVQ1Ff/ClQgtPw9Q= +github.com/Antonboom/errname v1.1.1/go.mod h1:gjhe24xoxXp0ScLtHzjiXp0Exi1RFLKJb0bVBtWKCWQ= +github.com/Antonboom/nilnil v1.1.1 h1:9Mdr6BYd8WHCDngQnNVV0b554xyisFioEKi30sksufQ= +github.com/Antonboom/nilnil v1.1.1/go.mod h1:yCyAmSw3doopbOWhJlVci+HuyNRuHJKIv6V2oYQa8II= +github.com/Antonboom/testifylint v1.6.4 h1:gs9fUEy+egzxkEbq9P4cpcMB6/G0DYdMeiFS87UiqmQ= +github.com/Antonboom/testifylint v1.6.4/go.mod h1:YO33FROXX2OoUfwjz8g+gUxQXio5i9qpVy7nXGbxDD4= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/ClickHouse/clickhouse-go-linter v1.2.0 h1:zbm174up3hTKjp0wKZVnTzRiG7tSF5XZF0FJG/MuCBI= +github.com/ClickHouse/clickhouse-go-linter v1.2.0/go.mod h1:pLorS7ffPTfuUV9M0SJgfHA/h/WQPQUk2FWG9x74cQ4= github.com/DefangLabs/secret-detector v0.0.0-20250403165618-22662109213e h1:rd4bOvKmDIx0WeTv9Qz+hghsgyjikFiPrseXHlKepO0= github.com/DefangLabs/secret-detector v0.0.0-20250403165618-22662109213e/go.mod h1:blbwPQh4DTlCZEfk1BLU4oMIhLda2U+A840Uag9DsZw= -github.com/Djarvur/go-err113 v0.0.0-20210108212216-aea10b59be24 h1:sHglBQTwgx+rWPdisA5ynNEsoARbiCBOyGcJM4/OzsM= -github.com/Djarvur/go-err113 v0.0.0-20210108212216-aea10b59be24/go.mod h1:4UJr5HIiMZrwgkSPdsjy2uOQExX/WEILpIrO9UPGuXs= -github.com/GaijinEntertainment/go-exhaustruct/v3 v3.3.1 h1:Sz1JIXEcSfhz7fUi7xHnhpIE0thVASYjvosApmHuD2k= -github.com/GaijinEntertainment/go-exhaustruct/v3 v3.3.1/go.mod h1:n/LSCXNuIYqVfBlVXyHfMQkZDdp1/mmxfSjADd3z1Zg= +github.com/Djarvur/go-err113 v0.1.1 h1:eHfopDqXRwAi+YmCUas75ZE0+hoBHJ2GQNLYRSxao4g= +github.com/Djarvur/go-err113 v0.1.1/go.mod h1:IaWJdYFLg76t2ihfflPZnM1LIQszWOsFDh2hhhAVF6k= github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= -github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= -github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= +github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/Microsoft/hcsshim v0.14.1 h1:CMuB3fqQVfPdhyXhUqYdUmPUIOhJkmghCx3dJet8Cqs= github.com/Microsoft/hcsshim v0.14.1/go.mod h1:VnzvPLyWUhxiPVsJ31P6XadxCcTogTguBFDy/1GR/OM= +github.com/MirrexOne/unqueryvet v1.5.4 h1:38QOxShO7JmMWT+eCdDMbcUgGCOeJphVkzzRgyLJgsQ= +github.com/MirrexOne/unqueryvet v1.5.4/go.mod h1:fs9Zq6eh1LRIhsDIsxf9PONVUjYdFHdtkHIgZdJnyPU= github.com/Netflix/go-env v0.1.2 h1:0DRoLR9lECQ9Zqvkswuebm3jJ/2enaDX6Ei8/Z+EnK0= github.com/Netflix/go-env v0.1.2/go.mod h1:WlIhYi++8FlKNJtrop1mjXYAJMzv1f43K4MqCoh0yGE= github.com/OpenPeeDeeP/depguard/v2 v2.2.1 h1:vckeWVESWp6Qog7UZSARNqfu/cZqvki8zsuj3piCMx4= @@ -52,18 +68,20 @@ github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpH github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d/go.mod h1:asat636LX7Bqt5lYEZ27JNDcqxfjdBQuJ/MM4CN/Lzo= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= -github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw= -github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA= +github.com/alecthomas/chroma/v2 v2.24.1 h1:m5ffpfZbIb++k8AqFEKy9uVgY12xIQtBsQlc6DfZJQM= +github.com/alecthomas/chroma/v2 v2.24.1/go.mod h1:l+ohZ9xRXIbGe7cIW+YZgOGbvuVLjMps/FYN/CwuabI= github.com/alecthomas/go-check-sumtype v0.3.1 h1:u9aUvbGINJxLVXiFvHUlPEaD7VDULsrxJb4Aq31NLkU= github.com/alecthomas/go-check-sumtype v0.3.1/go.mod h1:A8TSiN3UPRw3laIgWEUOHHLPa6/r9MtoigdlP5h3K/E= -github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg= -github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= +github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alexkohler/nakedret/v2 v2.0.6 h1:ME3Qef1/KIKr3kWX3nti3hhgNxw6aqN5pZmQiFSsuzQ= github.com/alexkohler/nakedret/v2 v2.0.6/go.mod h1:l3RKju/IzOMQHmsEvXwkqMDzHHvurNQfAgE1eVmT40Q= -github.com/alexkohler/prealloc v1.0.0 h1:Hbq0/3fJPQhNkN0dR95AVrr6R7tou91y0uHG5pOcUuw= -github.com/alexkohler/prealloc v1.0.0/go.mod h1:VetnK3dIgFBBKmg0YnD9F9x6Icjd+9cvfHR56wJVlKE= +github.com/alexkohler/prealloc v1.1.0 h1:cKGRBqlXw5iyQGLYhrXrDlcHxugXpTq4tQ5c91wkf8M= +github.com/alexkohler/prealloc v1.1.0/go.mod h1:fT39Jge3bQrfA7nPMDngUfvUbQGQeJyGQnR+913SCig= +github.com/alfatraining/structtag v1.0.0 h1:2qmcUqNcCoyVJ0up879K614L9PazjBSFruTB0GOFjCc= +github.com/alfatraining/structtag v1.0.0/go.mod h1:p3Xi5SwzTi+Ryj64DqjLWz7XurHxbGsq6y3ubePJPus= github.com/alingse/asasalint v0.0.11 h1:SFwnQXJ49Kx/1GghOFz1XGqHYKp21Kq1nHad/0WQRnw= github.com/alingse/asasalint v0.0.11/go.mod h1:nCaoMhw7a9kSJObvQyVzNTPBDbNpdocqrSP7t/cW5+I= github.com/alingse/nilnesserr v0.2.0 h1:raLem5KG7EFVb4UIDAXgrv3N2JIaffeKNtcEXkEWd/w= @@ -80,10 +98,10 @@ github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= -github.com/ashanbrown/forbidigo v1.6.0 h1:D3aewfM37Yb3pxHujIPSpTf6oQk9sc9WZi8gerOIVIY= -github.com/ashanbrown/forbidigo v1.6.0/go.mod h1:Y8j9jy9ZYAEHXdu723cUlraTqbzjKF1MUyfOKL+AjcU= -github.com/ashanbrown/makezero v1.2.0 h1:/2Lp1bypdmK9wDIq7uWBlDF1iMUpIIS4A+pF6C9IEUU= -github.com/ashanbrown/makezero v1.2.0/go.mod h1:dxlPhHbDMC6N6xICzFBSK+4njQDdK8euNO0qjQMtGY4= +github.com/ashanbrown/forbidigo/v2 v2.3.1 h1:KAZijvQ7zeIBKbhikT4jCm0TLYXC4u78bTiLh/8JROI= +github.com/ashanbrown/forbidigo/v2 v2.3.1/go.mod h1:2QDkLTzU6TV937eFROamXrW92M3paehdae4HCDCOZCM= +github.com/ashanbrown/makezero/v2 v2.2.1 h1:A7uU8dgB1PA9aelTxHMfHIQ8Qev8AB3JLxJUBUsejqM= +github.com/ashanbrown/makezero/v2 v2.2.1/go.mod h1:aEGT/9q3S8DHeE57C88z2a6xydvgx8J5hgXIGWgo0MY= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU= @@ -116,8 +134,8 @@ github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= -github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= +github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/beorn7/perks v0.0.0-20150223135152-b965b613227f/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= @@ -137,6 +155,8 @@ github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvF github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= github.com/bombsimon/wsl/v4 v4.7.0 h1:1Ilm9JBPRczjyUs6hvOPKvd7VL1Q++PL8M0SXBDf+jQ= github.com/bombsimon/wsl/v4 v4.7.0/go.mod h1:uV/+6BkffuzSAVYD+yGyld1AChO7/EuLrCF/8xTiapg= +github.com/bombsimon/wsl/v5 v5.8.0 h1:JTkyfs4yl8SPejrCF2GdABXE+mO1WvM7iUYzRWlsxDs= +github.com/bombsimon/wsl/v5 v5.8.0/go.mod h1:AbOLsulgkqP4ZnitHf9gwPtCOGlrzkk0jb0uNxRSY0o= github.com/breml/bidichk v0.3.3 h1:WSM67ztRusf1sMoqH6/c4OBCUlRVTKq+CbSeo0R17sE= github.com/breml/bidichk v0.3.3/go.mod h1:ISbsut8OnjB367j5NseXEGGgO/th206dVa427kR8YTE= github.com/breml/errchkjson v0.4.1 h1:keFSS8D7A2T0haP9kzZTi7o26r7kE3vymjZNeNDRDwg= @@ -149,55 +169,59 @@ github.com/bugsnag/osext v0.0.0-20130617224835-0dd3f918b21b h1:otBG+dV+YK+Soembj github.com/bugsnag/osext v0.0.0-20130617224835-0dd3f918b21b/go.mod h1:obH5gd0BsqsP2LwDJ9aOkm/6J86V6lyAXCoQWGw3K50= github.com/bugsnag/panicwrap v0.0.0-20151223152923-e2c28503fcd0 h1:nvj0OLI3YqYXer/kZD8Ri1aaunCxIEsOst1BVJswV0o= github.com/bugsnag/panicwrap v0.0.0-20151223152923-e2c28503fcd0/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE= -github.com/butuzov/ireturn v0.4.0 h1:+s76bF/PfeKEdbG8b54aCocxXmi0wvYdOVsWxVO7n8E= -github.com/butuzov/ireturn v0.4.0/go.mod h1:ghI0FrCmap8pDWZwfPisFD1vEc56VKH4NpQUxDHta70= +github.com/butuzov/ireturn v0.4.1 h1:vWb3NO4t77iku/sjCQ/2pHTQeOmxEhjIriJqRLg1Y+I= +github.com/butuzov/ireturn v0.4.1/go.mod h1:q+DXKzTDV5guNuXLnIab9fKXizTn2miZHLhxH7V/GB4= github.com/butuzov/mirror v1.3.0 h1:HdWCXzmwlQHdVhwvsfBb2Au0r3HyINry3bDWLYXiKoc= github.com/butuzov/mirror v1.3.0/go.mod h1:AEij0Z8YMALaq4yQj9CPPVYOyJQyiexpQEQgihajRfI= -github.com/catenacyber/perfsprint v0.9.1 h1:5LlTp4RwTooQjJCvGEFV6XksZvWE7wCOUvjD2z0vls0= -github.com/catenacyber/perfsprint v0.9.1/go.mod h1:q//VWC2fWbcdSLEY1R3l8n0zQCDPdE4IjZwyY1HMunM= -github.com/ccojocar/zxcvbn-go v1.0.2 h1:na/czXU8RrhXO4EZme6eQJLR4PzcGsahsBOAwU6I3Vg= -github.com/ccojocar/zxcvbn-go v1.0.2/go.mod h1:g1qkXtUSvHP8lhHp5GrSmTz6uWALGRMQdw6Qnz/hi60= +github.com/catenacyber/perfsprint v0.10.1 h1:u7Riei30bk46XsG8nknMhKLXG9BcXz3+3tl/WpKm0PQ= +github.com/catenacyber/perfsprint v0.10.1/go.mod h1:DJTGsi/Zufpuus6XPGJyKOTMELe347o6akPvWG9Zcsc= +github.com/ccojocar/zxcvbn-go v1.0.4 h1:FWnCIRMXPj43ukfX000kvBZvV6raSxakYr1nzyNrUcc= +github.com/ccojocar/zxcvbn-go v1.0.4/go.mod h1:3GxGX+rHmueTUMvm5ium7irpyjmm7ikxYFOSJB21Das= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/charithe/durationcheck v0.0.10 h1:wgw73BiocdBDQPik+zcEoBG/ob8uyBHf2iyoHGPf5w4= -github.com/charithe/durationcheck v0.0.10/go.mod h1:bCWXb7gYRysD1CU3C+u4ceO49LoGOY1C1L6uouGNreQ= +github.com/charithe/durationcheck v0.0.11 h1:g1/EX1eIiKS57NTWsYtHDZ/APfeXKhye1DidBcABctk= +github.com/charithe/durationcheck v0.0.11/go.mod h1:x5iZaixRNl8ctbM+3B2RrPG5t856TxRyVQEnbIEM2X4= github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= -github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= -github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08= github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo= github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= -github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= -github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 h1:OqDqxQZliC7C8adA7KjelW3OjtAxREfeHkNcd66wpeI= +github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318/go.mod h1:Y6kE2GzHfkyQQVCSL9r2hwokSrIlHGzZG+71+wDYSZI= +github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= +github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= -github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= -github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= -github.com/chavacava/garif v0.1.0 h1:2JHa3hbYf5D9dsgseMKAmc/MZ109otzgNFk5s87H9Pc= -github.com/chavacava/garif v0.1.0/go.mod h1:XMyYCkEL58DF0oyW4qDjjnPWONs2HBqYKI+UIPD+Gww= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/ckaznocha/intrange v0.3.1 h1:j1onQyXvHUsPWujDH6WIjhyH26gkRt/txNlV7LspvJs= github.com/ckaznocha/intrange v0.3.1/go.mod h1:QVepyz1AkUoFQkpEqksSYpNpUo3c5W7nWh/s6SHIJJk= -github.com/clipperhouse/displaywidth v0.10.0 h1:GhBG8WuerxjFQQYeuZAeVTuyxuX+UraiZGD4HJQ3Y8g= -github.com/clipperhouse/displaywidth v0.10.0/go.mod h1:XqJajYsaiEwkxOj4bowCTMcT1SgvHo9flfF3jQasdbs= -github.com/clipperhouse/uax29/v2 v2.6.0 h1:z0cDbUV+aPASdFb2/ndFnS9ts/WNXgTNNGFoKXuhpos= -github.com/clipperhouse/uax29/v2 v2.6.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cloudflare/cfssl v0.0.0-20180223231731-4e2dcbde5004 h1:lkAMpLVBDaj17e85keuznYcH5rqI438v41pKcBl4ZxQ= github.com/cloudflare/cfssl v0.0.0-20180223231731-4e2dcbde5004/go.mod h1:yMWuSON2oQp+43nFtAV/uvKQIFpSPerB57DCt9t8sSA= github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= @@ -254,8 +278,8 @@ github.com/curioswitch/go-reassign v0.3.0 h1:dh3kpQHuADL3cobV/sSGETA8DOv457dwl+f github.com/curioswitch/go-reassign v0.3.0/go.mod h1:nApPCCTtqLJN/s8HfItCcKV0jIPwluBOvZP+dsJGA88= github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= -github.com/daixiang0/gci v0.13.6 h1:RKuEOSkGpSadkGbvZ6hJ4ddItT3cVZ9Vn9Rybk6xjl8= -github.com/daixiang0/gci v0.13.6/go.mod h1:12etP2OniiIdP4q+kjUGrC/rUagga7ODbqsom5Eo5Yk= +github.com/daixiang0/gci v0.13.7 h1:+0bG5eK9vlI08J+J/NWGbWPTNiXPG4WhNLJOkSxWITQ= +github.com/daixiang0/gci v0.13.7/go.mod h1:812WVN6JLFY9S6Tv76twqmNqevN0pa3SX3nih0brVzQ= github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= github.com/dave/dst v0.27.3 h1:P1HPoMza3cMEquVf9kKy8yXsFirry4zEnWOdYPOoIzY= @@ -273,8 +297,8 @@ github.com/denis-tingaikin/go-header v0.5.0/go.mod h1:mMenU5bWrok6Wl2UsZjy+1okeg github.com/denisenkom/go-mssqldb v0.0.0-20191128021309-1d7a30a10f73/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= -github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= -github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8= +github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dnephin/pflag v1.0.7 h1:oxONGlWxhmUct0YzKTgrpQv9AUA1wtPBn7zuSjJqptk= github.com/dnephin/pflag v1.0.7/go.mod h1:uxE91IoWURlOiTUIA8Mq5ZZkAv3dPUfZNaT80Zm7OQE= github.com/docker/buildx v0.29.1 h1:58hxM5Z4mnNje3G5NKfULT9xCr8ooM8XFtlfUK9bKaA= @@ -325,8 +349,8 @@ github.com/ethereum/go-ethereum v1.17.0 h1:2D+1Fe23CwZ5tQoAS5DfwKFNI1HGcTwi65/kR github.com/ethereum/go-ethereum v1.17.0/go.mod h1:2W3msvdosS/MCWytpqTcqgFiRYbTH59FxDJzqah120o= github.com/ettle/strcase v0.2.0 h1:fGNiVF21fHXpX1niBgk0aROov1LagYsOwV/xqKDKR/Q= github.com/ettle/strcase v0.2.0/go.mod h1:DajmHElDSaX76ITe3/VHVyMin4LWSJN5Z909Wp+ED1A= -github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= -github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= +github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4= github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= @@ -351,12 +375,12 @@ github.com/getkin/kin-openapi v0.131.0 h1:NO2UeHnFKRYhZ8wg6Nyh5Cq7dHk4suQQr72a4p github.com/getkin/kin-openapi v0.131.0/go.mod h1:3OlG51PCYNsPByuiMB0t4fjnNlIDnaEDsjiKUV8nL58= github.com/getsentry/sentry-go v0.47.0 h1:AnSMSyrYA5qZCIN/2xpgAAwv63sVULV+vBq37ajouc8= github.com/getsentry/sentry-go v0.47.0/go.mod h1:h+b4VHpKnK7aUXB5wc+KDnPgp9ZtfliRD4eV85FbiSA= -github.com/ghostiam/protogetter v0.3.15 h1:1KF5sXel0HE48zh1/vn0Loiw25A9ApyseLzQuif1mLY= -github.com/ghostiam/protogetter v0.3.15/go.mod h1:WZ0nw9pfzsgxuRsPOFQomgDVSWtDLJRfQJEhsGbmQMA= +github.com/ghostiam/protogetter v0.3.20 h1:oW7OPFit2FxZOpmMRPP9FffU4uUpfeE/rEdE1f+MzD0= +github.com/ghostiam/protogetter v0.3.20/go.mod h1:FjIu5Yfs6FT391m+Fjp3fbAYJ6rkL/J6ySpZBfnODuI= github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= -github.com/go-critic/go-critic v0.13.0 h1:kJzM7wzltQasSUXtYyTl6UaPVySO6GkaR1thFnJ6afY= -github.com/go-critic/go-critic v0.13.0/go.mod h1:M/YeuJ3vOCQDnP2SU+ZhjgRzwzcBW87JqLpMJLrZDLI= +github.com/go-critic/go-critic v0.14.3 h1:5R1qH2iFeo4I/RJU8vTezdqs08Egi4u5p6vOESA0pog= +github.com/go-critic/go-critic v0.14.3/go.mod h1:xwntfW6SYAd7h1OqDzmN6hBX/JxsEKl5up/Y2bsxgVQ= github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk= github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= @@ -460,6 +484,8 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= +github.com/godoc-lint/godoc-lint v0.11.2 h1:Bp0FkJWoSdNsBikdNgIcgtaoo+xz6I/Y9s5WSBQUeeM= +github.com/godoc-lint/godoc-lint v0.11.2/go.mod h1:iVpGdL1JCikNH2gGeAn3Hh+AgN5Gx/I/cxV+91L41jo= github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0= github.com/gofrs/uuid v4.0.0+incompatible h1:1SD/1F5pU8p29ybwgQSwpQk+mwdRrXCYuPhW6m+TnJw= @@ -487,22 +513,28 @@ github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaS github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 h1:WUvBfQL6EW/40l6OmeSBYQJNSif4O11+bmWEz+C7FYw= -github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32/go.mod h1:NUw9Zr2Sy7+HxzdjIULge71wI6yEg1lWQr7Evcu8K0E= -github.com/golangci/go-printf-func-name v0.1.0 h1:dVokQP+NMTO7jwO4bwsRwLWeudOVUPPyAKJuzv8pEJU= -github.com/golangci/go-printf-func-name v0.1.0/go.mod h1:wqhWFH5mUdJQhweRnldEywnR5021wTdZSNgwYceV14s= +github.com/golangci/asciicheck v0.5.0 h1:jczN/BorERZwK8oiFBOGvlGPknhvq0bjnysTj4nUfo0= +github.com/golangci/asciicheck v0.5.0/go.mod h1:5RMNAInbNFw2krqN6ibBxN/zfRFa9S6tA1nPdM0l8qQ= +github.com/golangci/dupl v0.0.0-20260401084720-c99c5cf5c202 h1:CbTB8KpqnViI6lIXxp03Oclc4VFHi3K4BWC1TacsZ+A= +github.com/golangci/dupl v0.0.0-20260401084720-c99c5cf5c202/go.mod h1:NUw9Zr2Sy7+HxzdjIULge71wI6yEg1lWQr7Evcu8K0E= +github.com/golangci/go-printf-func-name v0.1.1 h1:hIYTFJqAGp1iwoIfsNTpoq1xZAarogrvjO9AfiW3B4U= +github.com/golangci/go-printf-func-name v0.1.1/go.mod h1:Es64MpWEZbh0UBtTAICOZiB+miW53w/K9Or/4QogJss= github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d h1:viFft9sS/dxoYY0aiOTsLKO2aZQAPT4nlQCsimGcSGE= github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d/go.mod h1:ivJ9QDg0XucIkmwhzCDsqcnxxlDStoTl89jDMIoNxKY= -github.com/golangci/golangci-lint/v2 v2.1.6 h1:LXqShFfAGM5BDzEOWD2SL1IzJAgUOqES/HRBsfKjI+w= -github.com/golangci/golangci-lint/v2 v2.1.6/go.mod h1:EPj+fgv4TeeBq3TcqaKZb3vkiV5dP4hHHKhXhEhzci8= -github.com/golangci/golines v0.0.0-20250217134842-442fd0091d95 h1:AkK+w9FZBXlU/xUmBtSJN1+tAI4FIvy5WtnUnY8e4p8= -github.com/golangci/golines v0.0.0-20250217134842-442fd0091d95/go.mod h1:k9mmcyWKSTMcPPvQUCfRWWQ9VHJ1U9Dc0R7kaXAgtnQ= -github.com/golangci/misspell v0.6.0 h1:JCle2HUTNWirNlDIAUO44hUsKhOFqGPoC4LZxlaSXDs= -github.com/golangci/misspell v0.6.0/go.mod h1:keMNyY6R9isGaSAu+4Q8NMBwMPkh15Gtc8UCVoDtAWo= -github.com/golangci/plugin-module-register v0.1.1 h1:TCmesur25LnyJkpsVrupv1Cdzo+2f7zX0H6Jkw1Ol6c= -github.com/golangci/plugin-module-register v0.1.1/go.mod h1:TTpqoB6KkwOJMV8u7+NyXMrkwwESJLOkfl9TxR1DGFc= +github.com/golangci/golangci-lint/v2 v2.12.2 h1:7+d1uY0bq1MU2UV3R5pW5Q7QWdcoq4naMRXM+gsJKrs= +github.com/golangci/golangci-lint/v2 v2.12.2/go.mod h1:opqHHuIcTG2R+4akzWMd4o1BnD9/1LcjICWOujr91U8= +github.com/golangci/golines v0.15.0 h1:Qnph25g8Y1c5fdo1X7GaRDGgnMHgnxh4Gk4VfPTtRx0= +github.com/golangci/golines v0.15.0/go.mod h1:AZjXd23tbHMpowhtnGlj9KCNsysj72aeZVVHnVcZx10= +github.com/golangci/misspell v0.8.0 h1:qvxQhiE2/5z+BVRo1kwYA8yGz+lOlu5Jfvtx2b04Jbg= +github.com/golangci/misspell v0.8.0/go.mod h1:WZyyI2P3hxPY2UVHs3cS8YcllAeyfquQcKfdeE9AFVg= +github.com/golangci/plugin-module-register v0.1.2 h1:e5WM6PO6NIAEcij3B053CohVp3HIYbzSuP53UAYgOpg= +github.com/golangci/plugin-module-register v0.1.2/go.mod h1:1+QGTsKBvAIvPvoY/os+G5eoqxWn70HYDm2uvUyGuVw= github.com/golangci/revgrep v0.8.0 h1:EZBctwbVd0aMeRnNUsFogoyayvKHyxlV3CdUA46FX2s= github.com/golangci/revgrep v0.8.0/go.mod h1:U4R/s9dlXZsg8uJmaR1GrloUr14D7qDl8gi2iPXJH8k= +github.com/golangci/rowserrcheck v0.0.0-20260419091836-c5f79b8a11ba h1:lqtcnSMDuuJdu/LrKWi5RJzpSNLOJXYe/nzQutTI5kg= +github.com/golangci/rowserrcheck v0.0.0-20260419091836-c5f79b8a11ba/go.mod h1:sCBNcpRmhJCtbFGz49+IM3ETTFf7QdJ30AeYCd43NKk= +github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e h1:ai0EfmVYE2bRA5htgAG9r7s3tHsfjIhN98WshBTJ9jM= +github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e/go.mod h1:Vrn4B5oR9qRwM+f54koyeH3yzphlecwERs0el27Fr/s= github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e h1:gD6P7NEo7Eqtt0ssnqSJNNndxe69DOQ24A5h7+i3KpM= github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e/go.mod h1:h+wZwLjUTJnm/P2rwlbJdRPZXOzaT36/FwnPnY2inzc= github.com/google/certificate-transparency-go v1.0.10-0.20180222191210-5ab67e519c93/go.mod h1:QeJfpSbVSfYc7RgB3gJFj9cbuQMMchQxrWXz8Ruopmg= @@ -513,11 +545,9 @@ github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7O github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -530,15 +560,15 @@ github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/ github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6 h1:EEHtgt9IwisQ2AZ4pIsMjahcegHh6rmhqxzIRQIyepY= -github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U= +github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc= +github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gordonklaus/ineffassign v0.1.0 h1:y2Gd/9I7MdY1oEIt+n+rowjBNDcLQq3RsH5hwJd0f9s= -github.com/gordonklaus/ineffassign v0.1.0/go.mod h1:Qcp2HIAYhR7mNUVSIxZww3Guk4it82ghYcEXIAk+QT0= +github.com/gordonklaus/ineffassign v0.2.0 h1:Uths4KnmwxNJNzq87fwQQDDnbNb7De00VOk9Nu0TySs= +github.com/gordonklaus/ineffassign v0.2.0/go.mod h1:TIpymnagPSexySzs7F9FnO1XFTy8IT3a59vmZp5Y9Lw= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/gorilla/mux v1.7.0/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= @@ -548,14 +578,13 @@ github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5T github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= github.com/gostaticanalysis/analysisutil v0.7.1 h1:ZMCjoue3DtDWQ5WyU16YbjbQEQ3VuzwxALrpYd+HeKk= github.com/gostaticanalysis/analysisutil v0.7.1/go.mod h1:v21E3hY37WKMGSnbsw2S/ojApNWb6C1//mXO48CXbVc= -github.com/gostaticanalysis/comment v1.4.1/go.mod h1:ih6ZxzTHLdadaiSnF5WY3dxUoXfXAlTaRzuaNDlSado= github.com/gostaticanalysis/comment v1.4.2/go.mod h1:KLUTGDv6HOCotCH8h2erHKmpci2ZoR8VPu34YA2uzdM= github.com/gostaticanalysis/comment v1.5.0 h1:X82FLl+TswsUMpMh17srGRuKaaXprTaytmEpgnKIDu8= github.com/gostaticanalysis/comment v1.5.0/go.mod h1:V6eb3gpCv9GNVqb6amXzEUX3jXLVK/AdA+IrAMSqvEc= github.com/gostaticanalysis/forcetypeassert v0.2.0 h1:uSnWrrUEYDr86OCxWa4/Tp2jeYDlogZiZHzGkWFefTk= github.com/gostaticanalysis/forcetypeassert v0.2.0/go.mod h1:M5iPavzE9pPqWyeiVXSFghQjljW1+l/Uke3PXHS6ILY= -github.com/gostaticanalysis/nilerr v0.1.1 h1:ThE+hJP0fEp4zWLkWHWcRyI2Od0p7DlgYG3Uqrmrcpk= -github.com/gostaticanalysis/nilerr v0.1.1/go.mod h1:wZYb6YI5YAxxq0i1+VJbY0s2YONW0HU0GPE3+5PWN4A= +github.com/gostaticanalysis/nilerr v0.1.2 h1:S6nk8a9N8g062nsx63kUkF6AzbHGw7zzyHMcpu52xQU= +github.com/gostaticanalysis/nilerr v0.1.2/go.mod h1:A19UHhoY3y8ahoL7YKz6sdjDtduwTSI4CsymaC2htPA= github.com/gostaticanalysis/testutil v0.3.1-0.20210208050101-bfb5c8eec0e4/go.mod h1:D+FIZ+7OahH3ePw/izIEeH5I06eKs1IKI4Xr64/Am3M= github.com/gostaticanalysis/testutil v0.5.0 h1:Dq4wT1DdTwTGCQQv3rl3IvD5Ld0E6HiY+3Zh0sUGqw8= github.com/gostaticanalysis/testutil v0.5.0/go.mod h1:OLQSbuM6zw2EvCcXTz1lVq5unyoNft372msDY0nY5Hs= @@ -579,8 +608,8 @@ github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9 github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-version v1.2.1/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= -github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= -github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA= +github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= @@ -651,17 +680,15 @@ github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dv github.com/jackc/puddle v1.3.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= -github.com/jgautheron/goconst v1.8.1 h1:PPqCYp3K/xlOj5JmIe6O1Mj6r1DbkdbLtR3AJuZo414= -github.com/jgautheron/goconst v1.8.1/go.mod h1:A0oxgBCHy55NQn6sYpO7UdnA9p+h7cPtoOZUmvNIako= -github.com/jingyugao/rowserrcheck v1.1.1 h1:zibz55j/MJtLsjP1OF4bSdgXxwL1b+Vn7Tjzq7gFzUs= -github.com/jingyugao/rowserrcheck v1.1.1/go.mod h1:4yvlZSDb3IyDTUZJUmpZfm2Hwok+Dtp+nu2qOq+er9c= +github.com/jgautheron/goconst v1.10.0 h1:Ptt+OoE4NaEWKhLrWrrN3IpZdGLiqaf7WLnEX/iv4Jw= +github.com/jgautheron/goconst v1.10.0/go.mod h1:0p+wv1lFOiUr0IlNNT1nrm6+8DB8u2sU6KHGzFRXHDc= github.com/jinzhu/gorm v0.0.0-20170222002820-5409931a1bb8 h1:CZkYfurY6KGhVtlalI4QwQ6T0Cu6iuY3e0x5RLu96WE= github.com/jinzhu/gorm v0.0.0-20170222002820-5409931a1bb8/go.mod h1:Vla75njaFJ8clLU1W44h34PjIkijhjHIYnZxMqCdxqo= github.com/jinzhu/inflection v0.0.0-20170102125226-1c35d901db3d h1:jRQLvyVGL+iVtDElaEIDdKwpPqUIZJfzkNLV34htpEc= github.com/jinzhu/inflection v0.0.0-20170102125226-1c35d901db3d/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= -github.com/jjti/go-spancheck v0.6.4 h1:Tl7gQpYf4/TMU7AT84MN83/6PutY21Nb9fuQjFTpRRc= -github.com/jjti/go-spancheck v0.6.4/go.mod h1:yAEYdKJ2lRkDA8g7X+oKUHXOWVAXSBJRv04OhF+QUjk= +github.com/jjti/go-spancheck v0.6.5 h1:lmi7pKxa37oKYIMScialXUK6hP3iY5F1gu+mLBPgYB8= +github.com/jjti/go-spancheck v0.6.5/go.mod h1:aEogkeatBrbYsyW6y5TgDfihCulDYciL1B7rG2vSsrU= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= @@ -677,13 +704,13 @@ github.com/juju/loggo v0.0.0-20190526231331-6e530bcce5d8/go.mod h1:vgyd7OREkbtVE github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/julz/importas v0.2.0 h1:y+MJN/UdL63QbFJHws9BVC5RpA2iq0kpjrFajTGivjQ= github.com/julz/importas v0.2.0/go.mod h1:pThlt589EnCYtMnmhmRYY/qn9lCf/frPOK+WMx3xiJY= -github.com/karamaru-alpha/copyloopvar v1.2.1 h1:wmZaZYIjnJ0b5UoKDjUHrikcV0zuPyyxI4SVplLd2CI= -github.com/karamaru-alpha/copyloopvar v1.2.1/go.mod h1:nFmMlFNlClC2BPvNaHMdkirmTJxVCY0lhxBtlfOypMM= +github.com/karamaru-alpha/copyloopvar v1.2.2 h1:yfNQvP9YaGQR7VaWLYcfZUlRP2eo2vhExWKxD/fP6q0= +github.com/karamaru-alpha/copyloopvar v1.2.2/go.mod h1:oY4rGZqZ879JkJMtX3RRkcXRkmUvH0x35ykgaKgsgJY= github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= -github.com/kisielk/errcheck v1.9.0 h1:9xt1zI9EBfcYBvdU1nVrzMzzUPUtPKs9bVSIM3TAb3M= -github.com/kisielk/errcheck v1.9.0/go.mod h1:kQxWMMVZgIkDq7U8xtG/n2juOjbLgZtedi0D+/VL/i8= +github.com/kisielk/errcheck v1.10.0 h1:Lvs/YAHP24YKg08LA8oDw2z9fJVme090RAXd90S+rrw= +github.com/kisielk/errcheck v1.10.0/go.mod h1:kQxWMMVZgIkDq7U8xtG/n2juOjbLgZtedi0D+/VL/i8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kkHAIKE/contextcheck v1.1.6 h1:7HIyRcnyzxL9Lz06NGhiKvenXq7Zw6Q0UQu/ttjfJCE= github.com/kkHAIKE/contextcheck v1.1.6/go.mod h1:3dDbMRNBFaq8HFXWC1JyvDSPm43CmE6IuHam8Wr0rkg= @@ -702,24 +729,26 @@ github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/kulti/thelper v0.6.3 h1:ElhKf+AlItIu+xGnI990no4cE2+XaSu1ULymV2Yulxs= -github.com/kulti/thelper v0.6.3/go.mod h1:DsqKShOvP40epevkFrvIwkCMNYxMeTNjdWL4dqWHZ6I= -github.com/kunwardeep/paralleltest v1.0.14 h1:wAkMoMeGX/kGfhQBPODT/BL8XhK23ol/nuQ3SwFaUw8= -github.com/kunwardeep/paralleltest v1.0.14/go.mod h1:di4moFqtfz3ToSKxhNjhOZL+696QtJGCFe132CbBLGk= +github.com/kulti/thelper v0.7.1 h1:fI8QITAoFVLx+y+vSyuLBP+rcVIB8jKooNSCT2EiI98= +github.com/kulti/thelper v0.7.1/go.mod h1:NsMjfQEy6sd+9Kfw8kCP61W1I0nerGSYSFnGaxQkcbs= +github.com/kunwardeep/paralleltest v1.0.15 h1:ZMk4Qt306tHIgKISHWFJAO1IDQJLc6uDyJMLyncOb6w= +github.com/kunwardeep/paralleltest v1.0.15/go.mod h1:di4moFqtfz3ToSKxhNjhOZL+696QtJGCFe132CbBLGk= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lasiar/canonicalheader v1.1.2 h1:vZ5uqwvDbyJCnMhmFYimgMZnJMjwljN5VGY0VKbMXb4= github.com/lasiar/canonicalheader v1.1.2/go.mod h1:qJCeLFS0G/QlLQ506T+Fk/fWMa2VmBUiEI2cuMK4djI= -github.com/ldez/exptostd v0.4.3 h1:Ag1aGiq2epGePuRJhez2mzOpZ8sI9Gimcb4Sb3+pk9Y= -github.com/ldez/exptostd v0.4.3/go.mod h1:iZBRYaUmcW5jwCR3KROEZ1KivQQp6PHXbDPk9hqJKCQ= -github.com/ldez/gomoddirectives v0.6.1 h1:Z+PxGAY+217f/bSGjNZr/b2KTXcyYLgiWI6geMBN2Qc= -github.com/ldez/gomoddirectives v0.6.1/go.mod h1:cVBiu3AHR9V31em9u2kwfMKD43ayN5/XDgr+cdaFaKs= -github.com/ldez/grignotin v0.9.0 h1:MgOEmjZIVNn6p5wPaGp/0OKWyvq42KnzAt/DAb8O4Ow= -github.com/ldez/grignotin v0.9.0/go.mod h1:uaVTr0SoZ1KBii33c47O1M8Jp3OP3YDwhZCmzT9GHEk= -github.com/ldez/tagliatelle v0.7.1 h1:bTgKjjc2sQcsgPiT902+aadvMjCeMHrY7ly2XKFORIk= -github.com/ldez/tagliatelle v0.7.1/go.mod h1:3zjxUpsNB2aEZScWiZTHrAXOl1x25t3cRmzfK1mlo2I= -github.com/ldez/usetesting v0.4.3 h1:pJpN0x3fMupdTf/IapYjnkhiY1nSTN+pox1/GyBRw3k= -github.com/ldez/usetesting v0.4.3/go.mod h1:eEs46T3PpQ+9RgN9VjpY6qWdiw2/QmfiDeWmdZdrjIQ= +github.com/ldez/exptostd v0.4.5 h1:kv2ZGUVI6VwRfp/+bcQ6Nbx0ghFWcGIKInkG/oFn1aQ= +github.com/ldez/exptostd v0.4.5/go.mod h1:QRjHRMXJrCTIm9WxVNH6VW7oN7KrGSht69bIRwvdFsM= +github.com/ldez/gomoddirectives v0.8.0 h1:JqIuTtgvFC2RdH1s357vrE23WJF2cpDCPFgA/TWDGpk= +github.com/ldez/gomoddirectives v0.8.0/go.mod h1:jutzamvZR4XYJLr0d5Honycp4Gy6GEg2mS9+2YX3F1Q= +github.com/ldez/grignotin v0.10.1 h1:keYi9rYsgbvqAZGI1liek5c+jv9UUjbvdj3Tbn5fn4o= +github.com/ldez/grignotin v0.10.1/go.mod h1:UlDbXFCARrXbWGNGP3S5vsysNXAPhnSuBufpTEbwOas= +github.com/ldez/structtags v0.6.1 h1:bUooFLbXx41tW8SvkfwfFkkjPYvFFs59AAMgVg6DUBk= +github.com/ldez/structtags v0.6.1/go.mod h1:YDxVSgDy/MON6ariaxLF2X09bh19qL7MtGBN5MrvbdY= +github.com/ldez/tagliatelle v0.7.2 h1:KuOlL70/fu9paxuxbeqlicJnCspCRjH0x8FW+NfgYUk= +github.com/ldez/tagliatelle v0.7.2/go.mod h1:PtGgm163ZplJfZMZ2sf5nhUT170rSuPgBimoyYtdaSI= +github.com/ldez/usetesting v0.5.0 h1:3/QtzZObBKLy1F4F8jLuKJiKBjjVFi1IavpoWbmqLwc= +github.com/ldez/usetesting v0.5.0/go.mod h1:Spnb4Qppf8JTuRgblLrEWb7IE6rDmUpGvxY3iRrzvDQ= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/leonklingele/grouper v1.1.2 h1:o1ARBDLOmmasUaNDesWqWCIFH3u7hoFlM84YrjT3mIY= @@ -729,21 +758,23 @@ github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= -github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= -github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= -github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= -github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= +github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= +github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= +github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/macabu/inamedparam v0.2.0 h1:VyPYpOc10nkhI2qeNUdh3Zket4fcZjEWe35poddBCpE= github.com/macabu/inamedparam v0.2.0/go.mod h1:+Pee9/YfGe5LJ62pYXqB89lJ+0k5bsR8Wgz/C0Zlq3U= github.com/magiconair/properties v1.5.3/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= -github.com/manuelarte/funcorder v0.2.1 h1:7QJsw3qhljoZ5rH0xapIvjw31EcQeFbF31/7kQ/xS34= -github.com/manuelarte/funcorder v0.2.1/go.mod h1:BQQ0yW57+PF9ZpjpeJDKOffEsQbxDFKW8F8zSMe/Zd0= -github.com/maratori/testableexamples v1.0.0 h1:dU5alXRrD8WKSjOUnmJZuzdxWOEQ57+7s93SLMxb2vI= -github.com/maratori/testableexamples v1.0.0/go.mod h1:4rhjL1n20TUTT4vdh3RDqSizKLyXp7K2u6HgraZCGzE= -github.com/maratori/testpackage v1.1.1 h1:S58XVV5AD7HADMmD0fNnziNHqKvSdDuEKdPD1rNTU04= -github.com/maratori/testpackage v1.1.1/go.mod h1:s4gRK/ym6AMrqpOa/kEbQTV4Q4jb7WeLZzVhVVVOQMc= +github.com/manuelarte/embeddedstructfieldcheck v0.4.0 h1:3mAIyaGRtjK6EO9E73JlXLtiy7ha80b2ZVGyacxgfww= +github.com/manuelarte/embeddedstructfieldcheck v0.4.0/go.mod h1:z8dFSyXqp+fC6NLDSljRJeNQJJDWnY7RoWFzV3PC6UM= +github.com/manuelarte/funcorder v0.6.0 h1:0hBngc4fa1IgNiI65A7sFGkMvoMCc878RjqB5V7rWP0= +github.com/manuelarte/funcorder v0.6.0/go.mod h1:id3NDhXdQBmeqXH7eVC6Z89xS6JxvZ8kF9xUxpArU/g= +github.com/maratori/testableexamples v1.0.1 h1:HfOQXs+XgfeRBJ+Wz0XfH+FHnoY9TVqL6Fcevpzy4q8= +github.com/maratori/testableexamples v1.0.1/go.mod h1:XE2F/nQs7B9N08JgyRmdGjYVGqxWwClLPCGSQhXQSrQ= +github.com/maratori/testpackage v1.1.2 h1:ffDSh+AgqluCLMXhM19f/cpvQAKygKAJXFl9aUjmbqs= +github.com/maratori/testpackage v1.1.2/go.mod h1:8F24GdVDFW5Ew43Et02jamrVMNXLUNaOynhDssITGfc= github.com/matoous/godox v1.1.0 h1:W5mqwbyWrwZv6OQ5Z1a/DHGMOvXYCBP3+Ht7KMoJhq4= github.com/matoous/godox v1.1.0/go.mod h1:jgE/3fUXiTurkdHOLT5WEkThTSuE7yxHv5iWPa80afs= github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE= @@ -760,14 +791,14 @@ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= -github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= -github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= +github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk= github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y= github.com/mattn/go-sqlite3 v1.6.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= -github.com/mgechev/revive v1.9.0 h1:8LaA62XIKrb8lM6VsBSQ92slt/o92z5+hTw3CmrvSrM= -github.com/mgechev/revive v1.9.0/go.mod h1:LAPq3+MgOf7GcL5PlWIkHb0PT7XH4NuC2LdWymhb9Mo= +github.com/mgechev/revive v1.15.0 h1:vJ0HzSBzfNyPbHKolgiFjHxLek9KUijhqh42yGoqZ8Q= +github.com/mgechev/revive v1.15.0/go.mod h1:LlAKO3QQe9OJ0pVZzI2GPa8CbXGZ/9lNpCGvK4T/a8A= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/miekg/pkcs11 v1.0.2/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs= @@ -856,8 +887,8 @@ github.com/nishanths/exhaustive v0.12.0 h1:vIY9sALmw6T/yxiASewa4TQcFsVYZQQRUQJhK github.com/nishanths/exhaustive v0.12.0/go.mod h1:mEZ95wPIZW+x8kC4TgC+9YCUgiST7ecevsVDTgc2obs= github.com/nishanths/predeclared v0.2.2 h1:V2EPdZPliZymNAn79T8RkNApBjMmVKh5XRpLm/w98Vk= github.com/nishanths/predeclared v0.2.2/go.mod h1:RROzoN6TnGQupbC+lqggsOlcgysk3LMK/HI84Mp280c= -github.com/nunnatsa/ginkgolinter v0.19.1 h1:mjwbOlDQxZi9Cal+KfbEJTCz327OLNfwNvoZ70NJ+c4= -github.com/nunnatsa/ginkgolinter v0.19.1/go.mod h1:jkQ3naZDmxaZMXPWaS9rblH+i+GWXQCaS/JFIWcOH2s= +github.com/nunnatsa/ginkgolinter v0.23.0 h1:x3o4DGYOWbBMP/VdNQKgSj+25aJKx2Pe6lHr8gBcgf8= +github.com/nunnatsa/ginkgolinter v0.23.0/go.mod h1:9qN1+0akwXEccwV1CAcCDfcoBlWXHB+ML9884pL4SZ4= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= @@ -886,16 +917,16 @@ github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108 github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc= github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c= -github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus= -github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8= +github.com/onsi/ginkgo/v2 v2.28.2 h1:DTrMfpqxiNUyQ3Y0zhn1n3cOO2euFgQPYIpkWwxVFps= +github.com/onsi/ginkgo/v2 v2.28.2/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE= github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= github.com/onsi/gomega v1.9.0/go.mod h1:Ho0h+IUsWyvy1OpqCwxlQ/21gkhVunqlU8fDGcoTdcA= github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= -github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y= -github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0= +github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= +github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg= github.com/opencontainers/go-digest v0.0.0-20170106003457-a6d0ee40d420/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= @@ -919,8 +950,8 @@ github.com/otiai10/mint v1.6.3 h1:87qsV/aw1F5as1eH1zS/yqHY85ANKVMgkDrf9rcxbQs= github.com/otiai10/mint v1.6.3/go.mod h1:MJm72SBthJjz8qhefc4z1PYEieWmy8Bku7CjcAqyUSM= github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= -github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= -github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc= +github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= github.com/pganalyze/pg_query_go/v6 v6.1.0 h1:jG5ZLhcVgL1FAw4C/0VNQaVmX1SUJx71wBGdtTtBvls= @@ -939,12 +970,8 @@ github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/polyfloyd/go-errorlint v1.8.0 h1:DL4RestQqRLr8U4LygLw8g2DX6RN1eBJOpa2mzsrl1Q= -github.com/polyfloyd/go-errorlint v1.8.0/go.mod h1:G2W0Q5roxbLCt0ZQbdoxQxXktTjwNyDbEaj3n7jvl4s= -github.com/posthog/posthog-go v1.16.1 h1:uEbaaYT361a3ImI0D1DYUyNLWN7Y9V9gLqCbQ/z5SxQ= -github.com/posthog/posthog-go v1.16.1/go.mod h1:xsVOW9YImilUcazwPNEq4PJDqEZf2KeCS758zXjwkPg= -github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= -github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= +github.com/posthog/posthog-go v1.17.2 h1:w0TaCAd+Z3WoEgVyR/nlcXlqNN2tpoBfIyxuGssDgCE= +github.com/posthog/posthog-go v1.17.2/go.mod h1://M430hNH3e8CDv4i8SJesb26816Mpa6GIZaiP4pNQU= github.com/prometheus/client_golang v0.9.0-pre1.0.20180209125602-c332b6f63c06/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= @@ -993,25 +1020,27 @@ github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThC github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryancurrah/gomodguard v1.4.1 h1:eWC8eUMNZ/wM/PWuZBv7JxxqT5fiIKSIyTvjb7Elr+g= github.com/ryancurrah/gomodguard v1.4.1/go.mod h1:qnMJwV1hX9m+YJseXEBhd2s90+1Xn6x9dLz11ualI1I= -github.com/ryanrolds/sqlclosecheck v0.5.1 h1:dibWW826u0P8jNLsLN+En7+RqWWTYrjCB9fJfSfdyCU= -github.com/ryanrolds/sqlclosecheck v0.5.1/go.mod h1:2g3dUjoS6AL4huFdv6wn55WpLIDjY7ZgUR4J8HOO/XQ= +github.com/ryancurrah/gomodguard/v2 v2.1.3 h1:E7sz3PJwE9Ba1reVxSpF6XLCPJZ74Kfw/LabTNM4GIA= +github.com/ryancurrah/gomodguard/v2 v2.1.3/go.mod h1:CQicdLGatWMxLX53JzoBjYlsNZhHbmLv2AVa0s2aivU= +github.com/ryanrolds/sqlclosecheck v0.6.0 h1:pEyL9okISdg1F1SEpJNlrEotkTGerv5BMk7U4AG0eVg= +github.com/ryanrolds/sqlclosecheck v0.6.0/go.mod h1:xyX16hsDaCMXHrMJ3JMzGf5OpDfHTOTTQrT7HOFUmeU= github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4= github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/sanposhiho/wastedassign/v2 v2.1.0 h1:crurBF7fJKIORrV85u9UUpePDYGWnwvv3+A96WvwXT0= github.com/sanposhiho/wastedassign/v2 v2.1.0/go.mod h1:+oSmSC+9bQ+VUAxA66nBb0Z7N8CK7mscKTDYC6aIek4= -github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 h1:PKK9DyHxif4LZo+uQSgXNqs0jj5+xZwwfKHgph2lxBw= -github.com/santhosh-tekuri/jsonschema/v6 v6.0.1/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/sashamelentyev/interfacebloat v1.1.0 h1:xdRdJp0irL086OyW1H/RTZTr1h/tMEOsumirXcOJqAw= github.com/sashamelentyev/interfacebloat v1.1.0/go.mod h1:+Y9yU5YdTkrNvoX0xHc84dxiN1iBi9+G8zZIhPVoNjQ= -github.com/sashamelentyev/usestdlibvars v1.28.0 h1:jZnudE2zKCtYlGzLVreNp5pmCdOxXUzwsMDBkR21cyQ= -github.com/sashamelentyev/usestdlibvars v1.28.0/go.mod h1:9nl0jgOfHKWNFS43Ojw0i7aRoS4j6EBye3YBhmAIRF8= +github.com/sashamelentyev/usestdlibvars v1.29.0 h1:8J0MoRrw4/NAXtjQqTHrbW9NN+3iMf7Knkq057v4XOQ= +github.com/sashamelentyev/usestdlibvars v1.29.0/go.mod h1:8PpnjHMk5VdeWlVb4wCdrB8PNbLqZ3wBZTZWkrpZZL8= github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= github.com/secure-systems-lab/go-securesystemslib v0.10.0 h1:l+H5ErcW0PAehBNrBxoGv1jjNpGYdZ9RcheFkB2WI14= github.com/secure-systems-lab/go-securesystemslib v0.10.0/go.mod h1:MRKONWmRoFzPNQ9USRF9i1mc7MvAVvF1LlW8X5VWDvk= -github.com/securego/gosec/v2 v2.22.3 h1:mRrCNmRF2NgZp4RJ8oJ6yPJ7G4x6OCiAXHd8x4trLRc= -github.com/securego/gosec/v2 v2.22.3/go.mod h1:42M9Xs0v1WseinaB/BmNGO8AVqG8vRfhC2686ACY48k= +github.com/securego/gosec/v2 v2.26.1 h1:gdkttGhQFVehqRJ8grKH4DrpqM/QlPKNHBnl8QgcEC4= +github.com/securego/gosec/v2 v2.26.1/go.mod h1:57UW4p0uoP3kxoTkhoo3axLdVAi+OWrLg/Ax/kdqtPE= github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= @@ -1022,8 +1051,6 @@ github.com/shibumi/go-pathspec v1.3.0/go.mod h1:Xutfslp817l2I1cZvgcfeMQJG5QnU2lh github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4= github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ= github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= -github.com/shurcooL/go v0.0.0-20180423040247-9e1955d9fb6e/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk= -github.com/shurcooL/go-goon v0.0.0-20170922171312-37c2f522c041/go.mod h1:N5mDOmsrJOB+vfqUK+7DmDyjhSLIIBnXo9lvZJj3MWQ= github.com/sirupsen/logrus v1.0.6/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= @@ -1037,10 +1064,10 @@ github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnB github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA= github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog= -github.com/sonatard/noctx v0.1.0 h1:JjqOc2WN16ISWAjAk8M5ej0RfExEXtkEyExl2hLW+OM= -github.com/sonatard/noctx v0.1.0/go.mod h1:0RvBxqY8D4j9cTTTWE8ylt2vqj2EPI8fHmrxHdsaZ2c= -github.com/sourcegraph/go-diff v0.7.0 h1:9uLlrd5T46OXs5qpp8L/MTltk0zikUGi0sNNyCpA8G0= -github.com/sourcegraph/go-diff v0.7.0/go.mod h1:iBszgVvyxdc8SFZ7gm69go2KDdt3ag071iBaWPF6cjs= +github.com/sonatard/noctx v0.5.1 h1:wklWg9c9ZYugOAk7qG4yP4PBrlQsmSLPTvW1K4PRQMs= +github.com/sonatard/noctx v0.5.1/go.mod h1:64XdbzFb18XL4LporKXp8poqZtPKbCrqQ402CV+kJas= +github.com/sourcegraph/go-diff v0.8.0 h1:ipIyu4cTsLbIrln4l0qtHA3r0a7gyK4ntKjtQytHhvY= +github.com/sourcegraph/go-diff v0.8.0/go.mod h1:hWlcO7Al+UZStZAP8rBumHpCK5ZHQ5BXsMls8p4+F5E= github.com/spdx/tools-golang v0.5.5 h1:61c0KLfAcNqAjlg6UNMdkwpMernhw3zVRwDZ2x9XOmk= github.com/spdx/tools-golang v0.5.5/go.mod h1:MVIsXx8ZZzaRWNQpUDhC4Dud34edUYJYecciXgrw5vE= github.com/speakeasy-api/openapi-overlay v0.9.0 h1:Wrz6NO02cNlLzx1fB093lBlYxSI54VRhy1aSutx0PQg= @@ -1065,8 +1092,8 @@ github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjb github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= github.com/ssgreg/nlreturn/v2 v2.2.1 h1:X4XDI7jstt3ySqGU86YGAURbxw3oTDPK9sPEi6YEwQ0= github.com/ssgreg/nlreturn/v2 v2.2.1/go.mod h1:E/iiPB78hV7Szg2YfRgyIrk1AD6JVMTRkkxBiELzh2I= -github.com/stbenjam/no-sprintf-host-port v0.2.0 h1:i8pxvGrt1+4G0czLr/WnmyH7zbZ8Bg8etvARQ1rpyl4= -github.com/stbenjam/no-sprintf-host-port v0.2.0/go.mod h1:eL0bQ9PasS0hsyTyfTjjG+E80QIyPnBVQbYZyv20Jfk= +github.com/stbenjam/no-sprintf-host-port v0.3.1 h1:AyX7+dxI4IdLBPtDbsGAyqiTSLpCP9hWRrXQDU4Cm/g= +github.com/stbenjam/no-sprintf-host-port v0.3.1/go.mod h1:ODbZesTCHMVKthBHskvUUexdcNHAQRXk9NpSsL8p/HQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= @@ -1082,33 +1109,30 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stripe/pg-schema-diff v1.0.5 h1:TNHkiRNMn7ttiBd+YBypAbx9v0SfVls+NQZFtamy1K4= github.com/stripe/pg-schema-diff v1.0.5/go.mod h1:3IctPaAqm+0LtWw/GiwyRoRlU1/N/+00+eXVk0KZIHs= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= -github.com/tdakkota/asciicheck v0.4.1 h1:bm0tbcmi0jezRA2b5kg4ozmMuGAFotKI3RZfrhfovg8= -github.com/tdakkota/asciicheck v0.4.1/go.mod h1:0k7M3rCfRXb0Z6bwgvkEIMleKH3kXNz9UqJ9Xuqopr8= github.com/tenntenn/modver v1.0.1 h1:2klLppGhDgzJrScMpkj9Ujy3rXPUspSjAcev9tSEBgA= github.com/tenntenn/modver v1.0.1/go.mod h1:bePIyQPb7UeioSRkw3Q0XeMhYZSMx9B8ePqg6SAMGH0= github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3 h1:f+jULpRQGxTSkNYKJ51yaw6ChIqO+Je8UqsTKN/cDag= github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3/go.mod h1:ON8b8w4BN/kE1EOhwT0o+d62W65a6aPw1nouo9LMgyY= -github.com/tetafro/godot v1.5.1 h1:PZnjCol4+FqaEzvZg5+O8IY2P3hfY9JzRBNPv1pEDS4= -github.com/tetafro/godot v1.5.1/go.mod h1:cCdPtEndkmqqrhiCfkmxDodMQJ/f3L1BCNskCUZdTwk= +github.com/tetafro/godot v1.5.6 h1:IEkrFCwXaYHlOn4mGzGS3F3dkP6m9t0jpwqBFPIkKiA= +github.com/tetafro/godot v1.5.6/go.mod h1:eOkMrVQurDui411nBY2FA05EYH01r14LuWY/NrVDVcU= github.com/theupdateframework/notary v0.7.0 h1:QyagRZ7wlSpjT5N2qQAh/pN+DVqgekv4DzbAiAiEL3c= github.com/theupdateframework/notary v0.7.0/go.mod h1:c9DRxcmhHmVLDay4/2fUYdISnHqbFDGRSlXPO0AhYWw= github.com/tidwall/jsonc v0.3.3 h1:RVQqL3xFfDkKKXIDsrBiVQiEpBtxoKbmMXONb2H/y2w= github.com/tidwall/jsonc v0.3.3/go.mod h1:dw+3CIxqHi+t8eFSpzzMlcVYxKp08UP5CD8/uSFCyJE= github.com/tilt-dev/fsnotify v1.4.8-0.20220602155310-fff9c274a375 h1:QB54BJwA6x8QU9nHY3xJSZR2kX9bgpZekRKGkLTmEXA= github.com/tilt-dev/fsnotify v1.4.8-0.20220602155310-fff9c274a375/go.mod h1:xRroudyp5iVtxKqZCrA6n2TLFRBf8bmnjr1UD4x+z7g= -github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67 h1:9LPGD+jzxMlnk5r6+hJnar67cgpDIz/iyD+rfl5r2Vk= -github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67/go.mod h1:mkjARE7Yr8qU23YcGMSALbIxTQ9r9QBVahQOBRfU460= +github.com/timakin/bodyclose v0.0.0-20260129054331-73d1f95b84b4 h1:SiHe5XLTn9sFWJ5pBwJ5FN/4j34q9ZlOAD//kMoMYp0= +github.com/timakin/bodyclose v0.0.0-20260129054331-73d1f95b84b4/go.mod h1:sDHLK7rb/59v/ZxZ7KtymgcoxuUMxjXq8gtu9VMOK8M= github.com/timonwong/loggercheck v0.11.0 h1:jdaMpYBl+Uq9mWPXv1r8jc5fC3gyXx4/WGwTnnNKn4M= github.com/timonwong/loggercheck v0.11.0/go.mod h1:HEAWU8djynujaAVX7QI65Myb8qgfcZ1uKbdpg3ZzKl8= -github.com/tomarrell/wrapcheck/v2 v2.11.0 h1:BJSt36snX9+4WTIXeJ7nvHBQBcm1h2SjQMSlmQ6aFSU= -github.com/tomarrell/wrapcheck/v2 v2.11.0/go.mod h1:wFL9pDWDAbXhhPZZt+nG8Fu+h29TtnZ2MW6Lx4BRXIU= +github.com/tomarrell/wrapcheck/v2 v2.12.0 h1:H/qQ1aNWz/eeIhxKAFvkfIA+N7YDvq6TWVFL27Of9is= +github.com/tomarrell/wrapcheck/v2 v2.12.0/go.mod h1:AQhQuZd0p7b6rfW+vUwHm5OMCGgp63moQ9Qr/0BpIWo= github.com/tommy-muehle/go-mnd/v2 v2.5.1 h1:NowYhSdyE/1zwK9QCLeRb6USWdoif80Ie+v+yU8u1Zw= github.com/tommy-muehle/go-mnd/v2 v2.5.1/go.mod h1:WsUAkMJMYww6l/ufffCD3m+P7LEvr8TnZn9lwVDlgzw= github.com/tonistiigi/dchapes-mode v0.0.0-20250318174251-73d941a28323 h1:r0p7fK56l8WPequOaR3i9LBqfPtEdXIQbUTzT55iqT4= @@ -1127,10 +1151,10 @@ github.com/ultraware/funlen v0.2.0 h1:gCHmCn+d2/1SemTdYMiKLAHFYxTYz7z9VIDRaTGyLk github.com/ultraware/funlen v0.2.0/go.mod h1:ZE0q4TsJ8T1SQcjmkhN/w+MceuatI6pBFSxxyteHIJA= github.com/ultraware/whitespace v0.2.0 h1:TYowo2m9Nfj1baEQBjuHzvMRbp19i+RCcRYrSWoFa+g= github.com/ultraware/whitespace v0.2.0/go.mod h1:XcP1RLD81eV4BW8UhQlpaR+SDc2givTvyI8a586WjW8= -github.com/uudashr/gocognit v1.2.0 h1:3BU9aMr1xbhPlvJLSydKwdLN3tEUUrzPSSM8S4hDYRA= -github.com/uudashr/gocognit v1.2.0/go.mod h1:k/DdKPI6XBZO1q7HgoV2juESI2/Ofj9AcHPZhBBdrTU= -github.com/uudashr/iface v1.3.1 h1:bA51vmVx1UIhiIsQFSNq6GZ6VPTk3WNMZgRiCe9R29U= -github.com/uudashr/iface v1.3.1/go.mod h1:4QvspiRd3JLPAEXBQ9AiZpLbJlrWWgRChOKDJEuQTdg= +github.com/uudashr/gocognit v1.2.1 h1:CSJynt5txTnORn/DkhiB4mZjwPuifyASC8/6Q0I/QS4= +github.com/uudashr/gocognit v1.2.1/go.mod h1:acaubQc6xYlXFEMb9nWX2dYBzJ/bIjEkc1zzvyIZg5Q= +github.com/uudashr/iface v1.4.2 h1:06Vq5RKVYThBsj0Bnw4oasMjD1r+7CE/bcKOA8dVSvg= +github.com/uudashr/iface v1.4.2/go.mod h1:pbeBPlbuU2qkNDn0mmfrxP2X+wjPMIQAy+r1MBXSXtg= github.com/vbatts/tar-split v0.12.2 h1:w/Y6tjxpeiFMR47yzZPlPj/FcPLpXbTUi/9H7d3CPa4= github.com/vbatts/tar-split v0.12.2/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA= github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk= @@ -1165,7 +1189,6 @@ github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= @@ -1181,12 +1204,14 @@ gitlab.com/bosi/decorder v0.4.2 h1:qbQaV3zgwnBZ4zPMhGLW4KZe7A7NwxEhJx39R3shffo= gitlab.com/bosi/decorder v0.4.2/go.mod h1:muuhHoaJkA9QLcYHq4Mj8FJUwDZ+EirSHRiaTcTf6T8= go-simpler.org/assert v0.9.0 h1:PfpmcSvL7yAnWyChSjOz6Sp6m9j5lyK8Ok9pEL31YkQ= go-simpler.org/assert v0.9.0/go.mod h1:74Eqh5eI6vCK6Y5l3PI8ZYFXG4Sa+tkr70OIPJAUr28= -go-simpler.org/musttag v0.13.1 h1:lw2sJyu7S1X8lc8zWUAdH42y+afdcCnHhWpnkWvd6vU= -go-simpler.org/musttag v0.13.1/go.mod h1:8r450ehpMLQgvpb6sg+hV5Ur47eH6olp/3yEanfG97k= -go-simpler.org/sloglint v0.11.0 h1:JlR1X4jkbeaffiyjLtymeqmGDKBDO1ikC6rjiuFAOco= -go-simpler.org/sloglint v0.11.0/go.mod h1:CFDO8R1i77dlciGfPEPvYke2ZMx4eyGiEIWkyeW2Pvw= -go.augendre.info/fatcontext v0.8.0 h1:2dfk6CQbDGeu1YocF59Za5Pia7ULeAM6friJ3LP7lmk= -go.augendre.info/fatcontext v0.8.0/go.mod h1:oVJfMgwngMsHO+KB2MdgzcO+RvtNdiCEOlWvSFtax/s= +go-simpler.org/musttag v0.14.0 h1:XGySZATqQYSEV3/YTy+iX+aofbZZllJaqwFWs+RTtSo= +go-simpler.org/musttag v0.14.0/go.mod h1:uP8EymctQjJ4Z1kUnjX0u2l60WfUdQxCwSNKzE1JEOE= +go-simpler.org/sloglint v0.12.0 h1:UzWDlLWNE5FLqsvyq3tWYHuQMbqrervOhT8qPl4Mmw4= +go-simpler.org/sloglint v0.12.0/go.mod h1:jBjjC2bm8rYrs88oTRlFX497kWjJsyZWYoNaXkGRI6I= +go.augendre.info/arangolint v0.4.0 h1:xSCZjRoS93nXazBSg5d0OGCi9APPLNMmmLrC995tR50= +go.augendre.info/arangolint v0.4.0/go.mod h1:l+f/b4plABuFISuKnTGD4RioXiCCgghv2xqst/xOvAA= +go.augendre.info/fatcontext v0.9.0 h1:Gt5jGD4Zcj8CDMVzjOJITlSb9cEch54hjRRlN3qDojE= +go.augendre.info/fatcontext v0.9.0/go.mod h1:L94brOAT1OOUNue6ph/2HnwxoNlds9aXDF2FcUntbNw= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= @@ -1225,8 +1250,6 @@ go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= -go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= -go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= @@ -1261,8 +1284,6 @@ golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= -golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.20.0/go.mod h1:Xwo95rrVNIoSMx9wa1JroENMToLWn3RNVrTBpLHgZPQ= golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= @@ -1271,22 +1292,17 @@ golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJk golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= golang.org/x/exp/typeparams v0.0.0-20230203172020-98cc5a0785f9/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= -golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac h1:TSSpLIG4v+p0rPv1pNOQtl1I8knsO4S9trOxNMOLVP4= -golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= +golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358 h1:qWFG1Dj7TBjOjOvhEOkmyGPVoquqUKnIU0lEVLp8xyk= +golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358/go.mod h1:4Mzdyp/6jzw9auFDJ3OMF5qksa7UvPnzKqTVGcb04ms= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -1301,18 +1317,13 @@ golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/ golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= -golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= @@ -1328,8 +1339,6 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= -golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -1354,10 +1363,8 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210331175145-43e1dd70ce54/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -1369,24 +1376,17 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= -golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= -golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= @@ -1396,11 +1396,8 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= @@ -1415,25 +1412,16 @@ golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200324003944-a576cf524670/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= golang.org/x/tools v0.0.0-20200329025819-fd4102a86c65/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200724022722-7017fd6b1305/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20200820010801-b793a1359eac/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20201023174141-c8cfbd0f21e6/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.1-0.20210205202024-ef80cdb6ec6d/go.mod h1:9bzcO0MWcOuT0tm1iBGzDVPshzfwoVvREIui8C+MHqU= golang.org/x/tools v0.1.1-0.20210302220138-2ac05c832e1a/go.mod h1:9bzcO0MWcOuT0tm1iBGzDVPshzfwoVvREIui8C+MHqU= -golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.3.0/go.mod h1:/rWhSS2+zyEVwoJf8YAX6L2f0ntZ7Kn/mGgAWcipA5k= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s= -golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= -golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg= golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= @@ -1507,8 +1495,8 @@ gotest.tools/gotestsum v1.12.2/go.mod h1:kjRtCglPZVsSU0hFHX3M5VWBM6Y63emHuB14ER1 gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= -honnef.co/go/tools v0.6.1 h1:R094WgE8K4JirYjBaOpz/AvTyUu/3wbmAoskKN/pxTI= -honnef.co/go/tools v0.6.1/go.mod h1:3puzxxljPCe8RGJX7BIy1plGbxEOZni5mR2aXe3/uk4= +honnef.co/go/tools v0.7.0 h1:w6WUp1VbkqPEgLz4rkBzH/CSU6HkoqNLp6GstyTx3lU= +honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc= k8s.io/api v0.34.1 h1:jC+153630BMdlFukegoEL8E/yT7aLyQkIVuwhmwDgJM= k8s.io/api v0.34.1/go.mod h1:SB80FxFtXn5/gwzCoN6QCtPD7Vbu5w2n1S0J5gFfTYk= k8s.io/apimachinery v0.34.1 h1:dTlxFls/eikpJxmAC7MVE8oOeP1zryV7iRyIjB0gky4= @@ -1521,10 +1509,10 @@ k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOP k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts= k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y= k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= -mvdan.cc/gofumpt v0.9.1 h1:p5YT2NfFWsYyTieYgwcQ8aKV3xRvFH4uuN/zB2gBbMQ= -mvdan.cc/gofumpt v0.9.1/go.mod h1:3xYtNemnKiXaTh6R4VtlqDATFwBbdXI8lJvH/4qk7mw= -mvdan.cc/unparam v0.0.0-20250301125049-0df0534333a4 h1:WjUu4yQoT5BHT1w8Zu56SP8367OuBV5jvo+4Ulppyf8= -mvdan.cc/unparam v0.0.0-20250301125049-0df0534333a4/go.mod h1:rthT7OuvRbaGcd5ginj6dA2oLE7YNlta9qhBNNdCaLE= +mvdan.cc/gofumpt v0.9.2 h1:zsEMWL8SVKGHNztrx6uZrXdp7AX8r421Vvp23sz7ik4= +mvdan.cc/gofumpt v0.9.2/go.mod h1:iB7Hn+ai8lPvofHd9ZFGVg2GOr8sBUw1QUWjNbmIL/s= +mvdan.cc/unparam v0.0.0-20251027182757-5beb8c8f8f15 h1:ssMzja7PDPJV8FStj7hq9IKiuiKhgz9ErWw+m68e7DI= +mvdan.cc/unparam v0.0.0-20251027182757-5beb8c8f8f15/go.mod h1:4M5MMXl2kW6fivUT6yRGpLLPNfuGtU2Z0cPvFquGDYU= sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= diff --git a/apps/cli-go/internal/db/diff/diff.go b/apps/cli-go/internal/db/diff/diff.go index 5d2790e5b9..85c9c09c9c 100644 --- a/apps/cli-go/internal/db/diff/diff.go +++ b/apps/cli-go/internal/db/diff/diff.go @@ -50,6 +50,13 @@ func Run(ctx context.Context, schema []string, file string, config pgconn.Config } func loadDeclaredSchemas(fsys afero.Fs) ([]string, error) { + if schemas := utils.Config.Db.Migrations.SchemaPaths; len(schemas) > 0 { + return schemas.SQLFiles( + afero.NewIOFS(fsys), + configpkg.WithSkipEmptyGlobs(), + configpkg.WithErrorOnAllSkippedGlobs(), + ) + } // When pg-delta is enabled, declarative path is the source of truth (config or default). if utils.IsPgDeltaEnabled() { declDir := utils.GetDeclarativeDir() @@ -70,13 +77,6 @@ func loadDeclaredSchemas(fsys afero.Fs) ([]string, error) { return declared, nil } } - if schemas := utils.Config.Db.Migrations.SchemaPaths; len(schemas) > 0 { - return schemas.Files( - afero.NewIOFS(fsys), - configpkg.WithSkipEmptyGlobs(), - configpkg.WithErrorOnAllSkippedGlobs(), - ) - } if exists, err := afero.DirExists(fsys, utils.SchemasDir); err != nil { return nil, errors.Errorf("failed to check schemas: %w", err) } else if !exists { @@ -100,6 +100,24 @@ func loadDeclaredSchemas(fsys afero.Fs) ([]string, error) { return declared, nil } +func shouldApplyDeclarativeWithPgDelta(usePgDelta bool) bool { + if !usePgDelta { + return false + } + schemas := utils.Config.Db.Migrations.SchemaPaths + if len(schemas) == 0 { + return true + } + if len(schemas) != 1 { + return false + } + return cleanSchemaPath(schemas[0]) == cleanSchemaPath(utils.GetDeclarativeDir()) +} + +func cleanSchemaPath(path string) string { + return filepath.ToSlash(filepath.Clean(path)) +} + // https://github.com/djrobstep/migra/blob/master/migra/statements.py#L6 var dropStatementPattern = regexp.MustCompile(`(?i)drop\s+`) diff --git a/apps/cli-go/internal/db/diff/diff_test.go b/apps/cli-go/internal/db/diff/diff_test.go index 2a6a2d4ca4..92fd01164b 100644 --- a/apps/cli-go/internal/db/diff/diff_test.go +++ b/apps/cli-go/internal/db/diff/diff_test.go @@ -24,6 +24,7 @@ import ( "github.com/supabase/cli/internal/testing/helper" "github.com/supabase/cli/internal/utils" "github.com/supabase/cli/internal/utils/flags" + pkgconfig "github.com/supabase/cli/pkg/config" "github.com/supabase/cli/pkg/migration" "github.com/supabase/cli/pkg/pgtest" ) @@ -36,6 +37,80 @@ var dbConfig = pgconn.Config{ Database: "postgres", } +func TestLoadDeclaredSchemas(t *testing.T) { + t.Run("respects schema_paths order when pg-delta declarative dir exists", func(t *testing.T) { + originalConfig := utils.Config + t.Cleanup(func() { utils.Config = originalConfig }) + utils.Config.Db.Migrations.SchemaPaths = pkgconfig.Glob{ + "supabase/schemas/z_function.sql", + "supabase/schemas/a_table.sql", + } + utils.Config.Experimental.PgDelta = &pkgconfig.PgDeltaConfig{ + Enabled: true, + DeclarativeSchemaPath: utils.SchemasDir, + } + fsys := afero.NewMemMapFs() + require.NoError(t, fsys.MkdirAll(utils.SchemasDir, 0755)) + require.NoError(t, afero.WriteFile(fsys, "supabase/schemas/a_table.sql", []byte("create table a();"), 0644)) + require.NoError(t, afero.WriteFile(fsys, "supabase/schemas/z_function.sql", []byte("create function z() returns void language sql as $$ select 1 $$;"), 0644)) + + declared, err := loadDeclaredSchemas(fsys) + + require.NoError(t, err) + assert.Equal(t, []string{ + "supabase/schemas/z_function.sql", + "supabase/schemas/a_table.sql", + }, declared) + }) + + t.Run("expands schema_paths directory entries deterministically", func(t *testing.T) { + originalConfig := utils.Config + t.Cleanup(func() { utils.Config = originalConfig }) + utils.Config.Db.Migrations.SchemaPaths = pkgconfig.Glob{utils.DeclarativeDir} + fsys := afero.NewMemMapFs() + require.NoError(t, fsys.MkdirAll(filepath.Join(utils.DeclarativeDir, "nested"), 0755)) + require.NoError(t, afero.WriteFile(fsys, filepath.Join(utils.DeclarativeDir, "nested", "b.sql"), []byte("select 2;"), 0644)) + require.NoError(t, afero.WriteFile(fsys, filepath.Join(utils.DeclarativeDir, "a.sql"), []byte("select 1;"), 0644)) + + declared, err := loadDeclaredSchemas(fsys) + + require.NoError(t, err) + assert.Equal(t, []string{ + filepath.Join(utils.DeclarativeDir, "a.sql"), + filepath.Join(utils.DeclarativeDir, "nested", "b.sql"), + }, declared) + }) +} + +func TestShouldApplyDeclarativeWithPgDelta(t *testing.T) { + t.Run("uses pg-delta declarative apply when no schema_paths override is configured", func(t *testing.T) { + originalConfig := utils.Config + t.Cleanup(func() { utils.Config = originalConfig }) + utils.Config.Db.Migrations.SchemaPaths = nil + + assert.True(t, shouldApplyDeclarativeWithPgDelta(true)) + }) + + t.Run("uses pg-delta declarative apply when schema_paths points at the declarative dir", func(t *testing.T) { + originalConfig := utils.Config + t.Cleanup(func() { utils.Config = originalConfig }) + utils.Config.Db.Migrations.SchemaPaths = pkgconfig.Glob{utils.DeclarativeDir + "/"} + + assert.True(t, shouldApplyDeclarativeWithPgDelta(true)) + }) + + t.Run("uses ordered migration apply for explicit schema_paths files", func(t *testing.T) { + originalConfig := utils.Config + t.Cleanup(func() { utils.Config = originalConfig }) + utils.Config.Db.Migrations.SchemaPaths = pkgconfig.Glob{ + "supabase/schemas/z_function.sql", + "supabase/schemas/a_table.sql", + } + + assert.False(t, shouldApplyDeclarativeWithPgDelta(true)) + }) +} + func TestRun(t *testing.T) { t.Run("runs migra diff", func(t *testing.T) { // Setup in-memory fs @@ -98,6 +173,91 @@ func TestRun(t *testing.T) { assert.Equal(t, []byte(diff), contents) }) + t.Run("applies schema_paths in order before saving generated diff", func(t *testing.T) { + originalConfig := utils.Config + t.Cleanup(func() { utils.Config = originalConfig }) + utils.Config.Db.MajorVersion = 14 + utils.Config.Db.ShadowPort = 54320 + utils.Config.Db.Migrations.SchemaPaths = pkgconfig.Glob{ + "supabase/schemas/z_function.sql", + "supabase/schemas/a_table.sql", + } + utils.Config.Experimental.PgDelta = &pkgconfig.PgDeltaConfig{ + Enabled: true, + DeclarativeSchemaPath: utils.SchemasDir, + } + utils.GlobalsSql = "create schema public" + utils.InitialSchemaPg14Sql = "create schema private" + functionSQL := "create function public.z_function() returns integer language sql as $$ select 1 $$" + tableSQL := "create table public.a_table (id integer default public.z_function())" + generated := functionSQL + ";\n" + tableSQL + ";\n" + fsys := afero.NewMemMapFs() + require.NoError(t, afero.WriteFile(fsys, "supabase/schemas/a_table.sql", []byte(tableSQL), 0644)) + require.NoError(t, afero.WriteFile(fsys, "supabase/schemas/z_function.sql", []byte(functionSQL), 0644)) + require.NoError(t, apitest.MockDocker(utils.Docker)) + defer gock.OffAll() + apitest.MockDockerStart(utils.Docker, utils.GetRegistryImageUrl(utils.Config.Db.Image), "test-shadow-db") + gock.New(utils.Docker.DaemonHost()). + Get("/v" + utils.Docker.ClientVersion() + "/containers/test-shadow-db/json"). + Reply(http.StatusOK). + JSON(container.InspectResponse{ContainerJSONBase: &container.ContainerJSONBase{ + State: &container.State{ + Running: true, + Health: &container.Health{Status: types.Healthy}, + }, + }}) + gock.New(utils.Docker.DaemonHost()). + Delete("/v" + utils.Docker.ClientVersion() + "/containers/test-shadow-db"). + Reply(http.StatusOK) + shadowConn := pgtest.NewConn() + defer shadowConn.Close(t) + shadowConn.Query(utils.GlobalsSql). + Reply("CREATE SCHEMA"). + Query(utils.InitialSchemaPg14Sql). + Reply("CREATE SCHEMA") + helper.MockApiPrivilegesRevoke(shadowConn). + Query(CREATE_TEMPLATE). + Reply("CREATE DATABASE") + declaredConn := pgtest.NewConn() + defer declaredConn.Close(t) + declaredConn.Query(functionSQL). + Reply("CREATE FUNCTION"). + Query(tableSQL). + Reply("CREATE TABLE") + diffCalled := false + differ := func(_ context.Context, _, target pgconn.Config, schema []string, _ ...func(*pgx.ConnConfig)) (string, error) { + diffCalled = true + assert.Equal(t, "contrib_regression", target.Database) + assert.Equal(t, []string{"public"}, schema) + return generated, nil + } + localConfig := pgconn.Config{ + Host: utils.Config.Hostname, + Port: utils.Config.Db.Port, + User: "postgres", + Password: utils.Config.Db.Password, + Database: "postgres", + } + + err := Run(context.Background(), []string{"public"}, "ordered_schema", localConfig, differ, true, fsys, func(cc *pgx.ConnConfig) { + if cc.Database == "contrib_regression" { + declaredConn.Intercept(cc) + } else { + shadowConn.Intercept(cc) + } + }) + + require.NoError(t, err) + assert.True(t, diffCalled) + assert.Empty(t, apitest.ListUnmatchedRequests()) + files, err := afero.ReadDir(fsys, utils.MigrationsDir) + require.NoError(t, err) + require.Len(t, files, 1) + contents, err := afero.ReadFile(fsys, filepath.Join(utils.MigrationsDir, files[0].Name())) + require.NoError(t, err) + assert.Equal(t, []byte(generated), contents) + }) + t.Run("throws error on failure to diff target", func(t *testing.T) { // Setup in-memory fs fsys := afero.NewMemMapFs() @@ -345,6 +505,16 @@ create schema public`) Delete("/v" + utils.Docker.ClientVersion() + "/containers/test-shadow-db"). Reply(http.StatusOK) apitest.MockDockerStart(utils.Docker, utils.GetRegistryImageUrl(utils.Config.EdgeRuntime.Image), "test-migra") + // The edge-runtime diff waits for the container to exit via inspect before + // reading its logs (it must not follow the log stream — that hangs under + // podman, supabase/pg-toolbelt#312), so the diff failure here surfaces from + // the log read rather than the followed stream. + gock.New(utils.Docker.DaemonHost()). + Get("/v" + utils.Docker.ClientVersion() + "/containers/test-migra/json"). + Reply(http.StatusOK). + JSON(container.InspectResponse{ContainerJSONBase: &container.ContainerJSONBase{ + State: &container.State{ExitCode: 0}, + }}) gock.New(utils.Docker.DaemonHost()). Get("/v" + utils.Docker.ClientVersion() + "/containers/test-migra/logs"). ReplyError(errors.New("network error")) diff --git a/apps/cli-go/internal/db/diff/pgdelta_template_test.go b/apps/cli-go/internal/db/diff/pgdelta_template_test.go new file mode 100644 index 0000000000..3fbc2bb792 --- /dev/null +++ b/apps/cli-go/internal/db/diff/pgdelta_template_test.go @@ -0,0 +1,48 @@ +package diff + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// lastCodeLine returns the final non-blank, non-comment line of a script. +func lastCodeLine(script string) string { + lines := strings.Split(script, "\n") + for i := len(lines) - 1; i >= 0; i-- { + line := strings.TrimSpace(lines[i]) + if line == "" || strings.HasPrefix(line, "//") { + continue + } + return line + } + return "" +} + +// Every pg-delta edge-runtime script must force the worker's event loop closed +// once its output has been written. The pg connection pool can leave keepalive +// handles registered even after close() resolves; if the worker never exits, +// the container never stops and the CLI — which streams the container logs with +// Follow:true — blocks forever following them, hanging declarative sync at 0% +// CPU (supabase/pg-toolbelt#312). The success path must terminate +// unconditionally rather than rely on the event loop draining on its own, so +// guard against the force-close being dropped from any template's success path. +func TestPgDeltaScriptsForceCloseOnSuccess(t *testing.T) { + scripts := map[string]string{ + "pgdelta.ts": pgDeltaScript, + "pgdelta_declarative_export.ts": pgDeltaDeclarativeExportScript, + "pgdelta_catalog_export.ts": pgDeltaCatalogExportScript, + } + for name, script := range scripts { + t.Run(name, func(t *testing.T) { + require.NotEmpty(t, script) + // The terminating statement runs on the success path (the catch + // branch no longer re-throws), so the worker is torn down whether + // or not the body succeeded. + assert.Equal(t, `throw new Error("");`, lastCodeLine(script), + "success path must force the Edge Runtime worker to exit so the container stops") + }) + } +} diff --git a/apps/cli-go/internal/db/diff/shadow.go b/apps/cli-go/internal/db/diff/shadow.go index 8012c565e8..2ebd13591f 100644 --- a/apps/cli-go/internal/db/diff/shadow.go +++ b/apps/cli-go/internal/db/diff/shadow.go @@ -67,7 +67,7 @@ func PrepareShadowSource(ctx context.Context, schema []string, targetLocal bool, if len(declared) > 0 { override := shadowConfig override.Database = "contrib_regression" - if usePgDelta { + if shouldApplyDeclarativeWithPgDelta(usePgDelta) { declDir := utils.GetDeclarativeDir() if exists, _ := afero.DirExists(fsys, declDir); exists { if err := pgdelta.ApplyDeclarative(ctx, override, fsys); err != nil { diff --git a/apps/cli-go/internal/db/diff/templates/pgdelta.ts b/apps/cli-go/internal/db/diff/templates/pgdelta.ts index 306fed6a73..0fd9d00e30 100644 --- a/apps/cli-go/internal/db/diff/templates/pgdelta.ts +++ b/apps/cli-go/internal/db/diff/templates/pgdelta.ts @@ -69,3 +69,9 @@ try { // Force close event loop throw new Error(""); } +// Force close the event loop on the success path too. When SOURCE/TARGET are +// live database URLs the plan opens connections whose keepalive handles can keep +// the Edge Runtime worker alive after the diff has been written, so the container +// never exits and the CLI — which follows this container's logs — hangs +// indefinitely at 0% CPU (supabase/pg-toolbelt#312). +throw new Error(""); diff --git a/apps/cli-go/internal/db/diff/templates/pgdelta_catalog_export.ts b/apps/cli-go/internal/db/diff/templates/pgdelta_catalog_export.ts index 992c5f21a8..6b7d426ce1 100644 --- a/apps/cli-go/internal/db/diff/templates/pgdelta_catalog_export.ts +++ b/apps/cli-go/internal/db/diff/templates/pgdelta_catalog_export.ts @@ -21,7 +21,16 @@ try { console.log(stringifyCatalogSnapshot(serializeCatalog(catalog))); } catch (e) { console.error(e); + // Force close event loop throw new Error(""); } finally { await close(); } +// Force close the event loop on the success path too. The connection pool can +// leave keepalive handles registered even after close() resolves, which keeps +// the Edge Runtime worker (and therefore the container) alive after the catalog +// has already been written to stdout. The CLI streams this container's logs with +// Follow:true, so a worker that never exits hangs the parent `__catalog` +// subprocess — and the declarative-sync command that spawned it — indefinitely +// at 0% CPU (supabase/pg-toolbelt#312). +throw new Error(""); diff --git a/apps/cli-go/internal/db/diff/templates/pgdelta_declarative_export.ts b/apps/cli-go/internal/db/diff/templates/pgdelta_declarative_export.ts index 117f16c58e..4656e4690e 100644 --- a/apps/cli-go/internal/db/diff/templates/pgdelta_declarative_export.ts +++ b/apps/cli-go/internal/db/diff/templates/pgdelta_declarative_export.ts @@ -71,3 +71,9 @@ try { // Force close event loop throw new Error(""); } +// Force close the event loop on the success path too. When SOURCE/TARGET are +// live database URLs the plan opens connections whose keepalive handles can keep +// the Edge Runtime worker alive after the export has been written, so the +// container never exits and the CLI — which follows this container's logs — +// hangs indefinitely at 0% CPU (supabase/pg-toolbelt#312). +throw new Error(""); diff --git a/apps/cli-go/internal/db/pgcache/cache.go b/apps/cli-go/internal/db/pgcache/cache.go index 5b518464ba..52960c9b06 100644 --- a/apps/cli-go/internal/db/pgcache/cache.go +++ b/apps/cli-go/internal/db/pgcache/cache.go @@ -49,10 +49,19 @@ try { console.log(stringifyCatalogSnapshot(serializeCatalog(catalog))); } catch (e) { console.error(e); + // Force close event loop throw new Error(""); } finally { await close(); } +// Force close the event loop on the success path too. The connection pool can +// leave keepalive handles registered even after close() resolves, which keeps +// the Edge Runtime worker (and therefore the container) alive after the catalog +// has already been written to stdout. The CLI streams this container's logs with +// Follow:true, so a worker that never exits hangs the migrations-catalog cache +// path (db start / db push with pg-delta caching) indefinitely at 0% CPU +// (supabase/pg-toolbelt#312). +throw new Error(""); ` ) diff --git a/apps/cli-go/internal/db/pgcache/cache_template_test.go b/apps/cli-go/internal/db/pgcache/cache_template_test.go new file mode 100644 index 0000000000..1118853597 --- /dev/null +++ b/apps/cli-go/internal/db/pgcache/cache_template_test.go @@ -0,0 +1,33 @@ +package pgcache + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The migrations-catalog cache script (db start / db push with pg-delta caching) +// opens a connection pool and must force the worker's event loop closed once it +// has written its snapshot. If a keepalive handle lingers after close() resolves +// the worker never exits, so the container never stops and the CLI — which +// follows the container logs with Follow:true — hangs indefinitely at 0% CPU +// (supabase/pg-toolbelt#312). Guard against the success-path force-close being +// dropped. +func TestPgDeltaCatalogExportScriptForceClosesOnSuccess(t *testing.T) { + require.NotEmpty(t, pgDeltaCatalogExportTS) + + lines := strings.Split(pgDeltaCatalogExportTS, "\n") + last := "" + for i := len(lines) - 1; i >= 0; i-- { + line := strings.TrimSpace(lines[i]) + if line == "" || strings.HasPrefix(line, "//") { + continue + } + last = line + break + } + assert.Equal(t, `throw new Error("");`, last, + "success path must force the Edge Runtime worker to exit so the container stops") +} diff --git a/apps/cli-go/internal/migration/apply/apply.go b/apps/cli-go/internal/migration/apply/apply.go index 16fdd63611..18bd330ea6 100644 --- a/apps/cli-go/internal/migration/apply/apply.go +++ b/apps/cli-go/internal/migration/apply/apply.go @@ -49,7 +49,7 @@ func applySeedFiles(ctx context.Context, conn *pgx.Conn, fsys afero.Fs) error { } func applySchemaFiles(ctx context.Context, conn *pgx.Conn, fsys fs.FS) error { - declared, err := utils.Config.Db.Migrations.SchemaPaths.Files(fsys) + declared, err := utils.Config.Db.Migrations.SchemaPaths.SQLFiles(fsys) if len(declared) == 0 { return err } diff --git a/apps/cli-go/internal/pgdelta/pgdelta_apply_template_test.go b/apps/cli-go/internal/pgdelta/pgdelta_apply_template_test.go new file mode 100644 index 0000000000..c0a7e601f3 --- /dev/null +++ b/apps/cli-go/internal/pgdelta/pgdelta_apply_template_test.go @@ -0,0 +1,33 @@ +package pgdelta + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The declarative-apply script connects to TARGET and must force the worker's +// event loop closed once it has written its result JSON. applyDeclarativeSchema +// can leave connection keepalive handles registered, and if the worker never +// exits the container never stops — the CLI, which follows the container logs +// with Follow:true, then hangs indefinitely at 0% CPU (supabase/pg-toolbelt#312). +// The success path must terminate unconditionally, so guard against the +// force-close being dropped. +func TestDeclarativeApplyScriptForceClosesOnSuccess(t *testing.T) { + require.NotEmpty(t, pgDeltaDeclarativeApplyScript) + + lines := strings.Split(pgDeltaDeclarativeApplyScript, "\n") + last := "" + for i := len(lines) - 1; i >= 0; i-- { + line := strings.TrimSpace(lines[i]) + if line == "" || strings.HasPrefix(line, "//") { + continue + } + last = line + break + } + assert.Equal(t, `throw new Error("");`, last, + "success path must force the Edge Runtime worker to exit so the container stops") +} diff --git a/apps/cli-go/internal/pgdelta/templates/pgdelta_declarative_apply.ts b/apps/cli-go/internal/pgdelta/templates/pgdelta_declarative_apply.ts index a6589bf2b0..9dfb07cf62 100644 --- a/apps/cli-go/internal/pgdelta/templates/pgdelta_declarative_apply.ts +++ b/apps/cli-go/internal/pgdelta/templates/pgdelta_declarative_apply.ts @@ -52,3 +52,10 @@ try { } catch (e) { throw e instanceof Error ? e : new Error(String(e)); } +// Force close the event loop on the success path. applyDeclarativeSchema opens a +// connection to TARGET whose keepalive handles can keep the Edge Runtime worker +// alive after the result JSON has been written, so the container never exits and +// the CLI — which follows this container's logs — hangs indefinitely at 0% CPU +// (supabase/pg-toolbelt#312). The catch above re-throws the real error, so this +// only runs once a successful apply has been reported on stdout. +throw new Error(""); diff --git a/apps/cli-go/internal/utils/docker.go b/apps/cli-go/internal/utils/docker.go index eff7854a4f..29d2edd397 100644 --- a/apps/cli-go/internal/utils/docker.go +++ b/apps/cli-go/internal/utils/docker.go @@ -486,6 +486,74 @@ func DockerRunOnceWithConfig(ctx context.Context, config container.Config, hostC return DockerStreamLogs(ctx, container, stdout, stderr) } +// DockerRunOnceWaitWithConfig is like DockerRunOnceWithConfig but waits for the +// container to exit and then reads its already-buffered logs WITHOUT following +// the stream. +// +// DockerRunOnceWithConfig detects completion by reading a follow=true log stream +// until EOF. That EOF never arrives under podman: its /containers//logs?follow +// endpoint does not close the response when the container stops, so +// stdcopy.StdCopy blocks on the chunked HTTP body forever and the caller hangs at +// 0% CPU (supabase/pg-toolbelt#312). Waiting on /wait for the exit code and then +// reading the log once is reliable on both Docker and podman. +// +// Use this only for short-lived containers with bounded output (e.g. the +// edge-runtime pg-delta scripts): the full log is read after exit rather than +// streamed, so it is not suitable for large streaming output such as db dump. +func DockerRunOnceWaitWithConfig(ctx context.Context, config container.Config, hostConfig container.HostConfig, networkingConfig network.NetworkingConfig, containerName string, stdout, stderr io.Writer) error { + containerId, err := DockerStart(ctx, config, hostConfig, networkingConfig, containerName) + if err != nil { + return err + } + defer DockerRemove(containerId) + exitCode, err := dockerWaitExit(ctx, containerId) + if err != nil { + return err + } + if err := DockerStreamLogsOnce(ctx, containerId, stdout, stderr); err != nil { + return err + } + switch exitCode { + case 0: + return nil + case 137: + err = ErrContainerKilled + default: + err = errors.Errorf("exit %d", exitCode) + } + return errors.Errorf("error running container: %w", err) +} + +// dockerWaitInterval is how often dockerWaitExit polls container state. Kept +// short so bounded one-shot scripts return promptly; it is a package var so +// tests can drop it to zero. +var dockerWaitInterval = 200 * time.Millisecond + +// dockerWaitExit polls container state until it stops and returns its exit code. +// +// It deliberately uses ContainerInspect rather than a followed log stream (or +// /wait) to detect completion: inspect is reliable under podman, whereas +// podman's /logs?follow endpoint does not close when the container stops, which +// is what hangs DockerStreamLogs (see DockerRunOnceWaitWithConfig). Reusing +// inspect also keeps the request surface identical to DockerStreamLogs, so the +// existing test mocks continue to apply. +func dockerWaitExit(ctx context.Context, containerId string) (int64, error) { + for { + resp, err := Docker.ContainerInspect(ctx, containerId) + if err != nil { + return 0, errors.Errorf("failed to inspect docker container: %w", err) + } + if resp.State != nil && !resp.State.Running { + return int64(resp.State.ExitCode), nil + } + select { + case <-ctx.Done(): + return 0, ctx.Err() + case <-time.After(dockerWaitInterval): + } + } +} + var ErrContainerKilled = errors.New("exit 137") func DockerStreamLogs(ctx context.Context, containerId string, stdout, stderr io.Writer, opts ...func(*container.LogsOptions)) error { diff --git a/apps/cli-go/internal/utils/docker_wait_test.go b/apps/cli-go/internal/utils/docker_wait_test.go new file mode 100644 index 0000000000..22ee32e0ee --- /dev/null +++ b/apps/cli-go/internal/utils/docker_wait_test.go @@ -0,0 +1,66 @@ +package utils + +import ( + "bytes" + "context" + "testing" + + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/network" + "github.com/h2non/gock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/supabase/cli/internal/testing/apitest" +) + +// DockerRunOnceWaitWithConfig must detect container completion via inspect and +// read logs without following the stream. Following the log stream to detect +// exit hangs forever under podman, whose /logs?follow endpoint never closes when +// the container stops (supabase/pg-toolbelt#312). These tests pin that the runner +// reads the captured output and maps the inspected exit code to an error. +func TestDockerRunOnceWait(t *testing.T) { + imageUrl := GetRegistryImageUrl(imageId) + + t.Run("waits for exit then reads buffered logs", func(t *testing.T) { + require.NoError(t, apitest.MockDocker(Docker)) + defer gock.OffAll() + apitest.MockDockerStart(Docker, imageUrl, containerId) + require.NoError(t, apitest.MockDockerLogs(Docker, containerId, "CATALOG\n")) + // Run test + var stdout, stderr bytes.Buffer + err := DockerRunOnceWaitWithConfig( + context.Background(), + container.Config{Image: imageUrl}, + container.HostConfig{}, + network.NetworkingConfig{}, + containerId, + &stdout, + &stderr, + ) + // Validate + assert.NoError(t, err) + assert.Equal(t, "CATALOG\n", stdout.String()) + assert.Empty(t, apitest.ListUnmatchedRequests()) + }) + + t.Run("maps non-zero exit code to error", func(t *testing.T) { + require.NoError(t, apitest.MockDocker(Docker)) + defer gock.OffAll() + apitest.MockDockerStart(Docker, imageUrl, containerId) + require.NoError(t, apitest.MockDockerLogsExitCode(Docker, containerId, 1)) + // Run test + var stdout, stderr bytes.Buffer + err := DockerRunOnceWaitWithConfig( + context.Background(), + container.Config{Image: imageUrl}, + container.HostConfig{}, + network.NetworkingConfig{}, + containerId, + &stdout, + &stderr, + ) + // Validate + assert.ErrorContains(t, err, "error running container: exit 1") + assert.Empty(t, apitest.ListUnmatchedRequests()) + }) +} diff --git a/apps/cli-go/internal/utils/edgeruntime.go b/apps/cli-go/internal/utils/edgeruntime.go index f5ee9e9bbb..c81169ea6f 100644 --- a/apps/cli-go/internal/utils/edgeruntime.go +++ b/apps/cli-go/internal/utils/edgeruntime.go @@ -94,7 +94,14 @@ func RunEdgeRuntimeScript(ctx context.Context, env []string, script string, bind if len(state.extraEnv) > 0 { combinedEnv = append(append([]string{}, env...), state.extraEnv...) } - if err := DockerRunOnceWithConfig( + // Wait for the container to exit and then read its logs, rather than + // following the log stream to detect completion. The edge-runtime worker is + // forced to exit once the script flushes its output, but podman's + // /logs?follow endpoint does not close when the container stops, so a + // followed read (DockerRunOnceWithConfig) hangs the CLI forever + // (supabase/pg-toolbelt#312). pg-delta script output is bounded, so reading + // the log once after exit is safe. + if err := DockerRunOnceWaitWithConfig( ctx, container.Config{ Image: Config.EdgeRuntime.Image, diff --git a/apps/cli-go/pkg/api/types.gen.go b/apps/cli-go/pkg/api/types.gen.go index 838e1e776a..743af52366 100644 --- a/apps/cli-go/pkg/api/types.gen.go +++ b/apps/cli-go/pkg/api/types.gen.go @@ -1427,6 +1427,7 @@ const ( // Defines values for V1ListEntitlementsResponseEntitlementsFeatureKey. const ( + V1ListEntitlementsResponseEntitlementsFeatureKeyApiMembersInvitations V1ListEntitlementsResponseEntitlementsFeatureKey = "api.members.invitations" V1ListEntitlementsResponseEntitlementsFeatureKeyApiMembersRoles V1ListEntitlementsResponseEntitlementsFeatureKey = "api.members.roles" V1ListEntitlementsResponseEntitlementsFeatureKeyAssistantAdvanceModel V1ListEntitlementsResponseEntitlementsFeatureKey = "assistant.advance_model" V1ListEntitlementsResponseEntitlementsFeatureKeyAuditLogDrains V1ListEntitlementsResponseEntitlementsFeatureKey = "audit_log_drains" @@ -3458,6 +3459,7 @@ type PostgresConfigResponse struct { MaintenanceWorkMem *string `json:"maintenance_work_mem,omitempty"` MaxConnections *int `json:"max_connections,omitempty"` MaxLocksPerTransaction *int `json:"max_locks_per_transaction,omitempty"` + MaxLogicalReplicationWorkers *int `json:"max_logical_replication_workers,omitempty"` MaxParallelMaintenanceWorkers *int `json:"max_parallel_maintenance_workers,omitempty"` MaxParallelWorkers *int `json:"max_parallel_workers,omitempty"` MaxParallelWorkersPerGather *int `json:"max_parallel_workers_per_gather,omitempty"` @@ -3465,6 +3467,7 @@ type PostgresConfigResponse struct { MaxSlotWalKeepSize *string `json:"max_slot_wal_keep_size,omitempty"` MaxStandbyArchiveDelay *string `json:"max_standby_archive_delay,omitempty"` MaxStandbyStreamingDelay *string `json:"max_standby_streaming_delay,omitempty"` + MaxSyncWorkersPerSubscription *int `json:"max_sync_workers_per_subscription,omitempty"` MaxWalSenders *int `json:"max_wal_senders,omitempty"` MaxWalSize *string `json:"max_wal_size,omitempty"` MaxWorkerProcesses *int `json:"max_worker_processes,omitempty"` @@ -4397,6 +4400,7 @@ type UpdatePostgresConfigBody struct { MaintenanceWorkMem *string `json:"maintenance_work_mem,omitempty"` MaxConnections *int `json:"max_connections,omitempty"` MaxLocksPerTransaction *int `json:"max_locks_per_transaction,omitempty"` + MaxLogicalReplicationWorkers *int `json:"max_logical_replication_workers,omitempty"` MaxParallelMaintenanceWorkers *int `json:"max_parallel_maintenance_workers,omitempty"` MaxParallelWorkers *int `json:"max_parallel_workers,omitempty"` MaxParallelWorkersPerGather *int `json:"max_parallel_workers_per_gather,omitempty"` @@ -4404,6 +4408,7 @@ type UpdatePostgresConfigBody struct { MaxSlotWalKeepSize *string `json:"max_slot_wal_keep_size,omitempty"` MaxStandbyArchiveDelay *string `json:"max_standby_archive_delay,omitempty"` MaxStandbyStreamingDelay *string `json:"max_standby_streaming_delay,omitempty"` + MaxSyncWorkersPerSubscription *int `json:"max_sync_workers_per_subscription,omitempty"` MaxWalSenders *int `json:"max_wal_senders,omitempty"` MaxWalSize *string `json:"max_wal_size,omitempty"` MaxWorkerProcesses *int `json:"max_worker_processes,omitempty"` diff --git a/apps/cli-go/pkg/config/config.go b/apps/cli-go/pkg/config/config.go index b9f253230a..b55a52b27e 100644 --- a/apps/cli-go/pkg/config/config.go +++ b/apps/cli-go/pkg/config/config.go @@ -116,9 +116,22 @@ func WithErrorOnAllSkippedGlobs() GlobOption { } } +func (g Glob) Files(fsys fs.FS, options ...GlobOption) ([]string, error) { + return g.files(fsys, nil, options...) +} + +// SQLFiles matches glob patterns and expands directory matches recursively to +// SQL files. Pattern order is preserved, and directory contents are sorted for +// deterministic application. +func (g Glob) SQLFiles(fsys fs.FS, options ...GlobOption) ([]string, error) { + return g.files(fsys, func(path string, entry fs.DirEntry) bool { + return entry.Type().IsRegular() && filepath.Ext(path) == ".sql" + }, options...) +} + // Match the glob patterns in the given FS to get a deduplicated // array of all migrations files to apply in the declared order. -func (g Glob) Files(fsys fs.FS, options ...GlobOption) ([]string, error) { +func (g Glob) files(fsys fs.FS, expandDir func(string, fs.DirEntry) bool, options ...GlobOption) ([]string, error) { opts := globOptions{} for _, apply := range options { apply(&opts) @@ -143,6 +156,27 @@ func (g Glob) Files(fsys fs.FS, options ...GlobOption) ([]string, error) { // Remove duplicates for _, item := range matches { fp := filepath.ToSlash(item) + if expandDir != nil { + info, err := fs.Stat(fsys, fp) + if err != nil { + allErrors = append(allErrors, errors.Errorf("failed to stat matched file: %w", err)) + continue + } + if info.IsDir() { + files, err := walkMatchedDir(fsys, fp, expandDir) + if err != nil { + allErrors = append(allErrors, err) + continue + } + for _, file := range files { + if _, exists := set[file]; !exists { + set[file] = struct{}{} + result = append(result, file) + } + } + continue + } + } if _, exists := set[fp]; !exists { set[fp] = struct{}{} result = append(result, fp) @@ -157,6 +191,23 @@ func (g Glob) Files(fsys fs.FS, options ...GlobOption) ([]string, error) { return result, errors.Join(allErrors...) } +func walkMatchedDir(fsys fs.FS, dir string, include func(string, fs.DirEntry) bool) ([]string, error) { + var files []string + if err := fs.WalkDir(fsys, dir, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if include(path, entry) { + files = append(files, filepath.ToSlash(path)) + } + return nil + }); err != nil { + return nil, errors.Errorf("failed to walk matched directory: %w", err) + } + sort.Strings(files) + return files, nil +} + func hasGlobMeta(pattern string) bool { return strings.ContainsAny(pattern, `*?[`) } diff --git a/apps/cli-go/pkg/config/config_test.go b/apps/cli-go/pkg/config/config_test.go index 6c2697ca0e..aa0570ec34 100644 --- a/apps/cli-go/pkg/config/config_test.go +++ b/apps/cli-go/pkg/config/config_test.go @@ -706,6 +706,50 @@ func TestGlobFiles(t *testing.T) { }) } +func TestGlobSQLFiles(t *testing.T) { + t.Run("expands directory entries in declared order", func(t *testing.T) { + fsys := fs.MapFS{ + "supabase/schemas/z_function.sql": &fs.MapFile{Data: []byte("select 1;")}, + "supabase/schemas/tables/a_table.sql": &fs.MapFile{Data: []byte("select 2;")}, + "supabase/schemas/tables/nested/b_table.sql": &fs.MapFile{Data: []byte("select 3;")}, + "supabase/schemas/tables/readme.md": &fs.MapFile{Data: []byte("ignored")}, + } + g := Glob{ + "supabase/schemas/z_function.sql", + "supabase/schemas/tables", + } + + files, err := g.SQLFiles(fsys) + + assert.NoError(t, err) + assert.Equal(t, []string{ + "supabase/schemas/z_function.sql", + "supabase/schemas/tables/a_table.sql", + "supabase/schemas/tables/nested/b_table.sql", + }, files) + }) + + t.Run("deduplicates explicit files and directory matches", func(t *testing.T) { + fsys := fs.MapFS{ + "supabase/database/a.sql": &fs.MapFile{Data: []byte("select 1;")}, + "supabase/database/b.sql": &fs.MapFile{Data: []byte("select 2;")}, + } + g := Glob{ + "supabase/database/a.sql", + "supabase/database", + "supabase/database/*.sql", + } + + files, err := g.SQLFiles(fsys) + + assert.NoError(t, err) + assert.Equal(t, []string{ + "supabase/database/a.sql", + "supabase/database/b.sql", + }, files) + }) +} + func TestLoadFunctionImportMap(t *testing.T) { t.Run("uses deno.json as import map when present", func(t *testing.T) { config := NewConfig() diff --git a/apps/cli-go/pkg/config/templates/Dockerfile b/apps/cli-go/pkg/config/templates/Dockerfile index 97dfc0dbea..ec7e643391 100644 --- a/apps/cli-go/pkg/config/templates/Dockerfile +++ b/apps/cli-go/pkg/config/templates/Dockerfile @@ -1,19 +1,19 @@ # Exposed for updates by .github/dependabot.yml -FROM supabase/postgres:17.6.1.140 AS pg +FROM supabase/postgres:17.6.1.143 AS pg # Append to ServiceImages when adding new dependencies below FROM library/kong:2.8.1 AS kong FROM axllent/mailpit:v1.30.2 AS mailpit FROM postgrest/postgrest:v14.14 AS postgrest FROM supabase/postgres-meta:v0.96.6 AS pgmeta -FROM supabase/studio:2026.06.29-sha-20290c7 AS studio +FROM supabase/studio:2026.07.06-sha-66cf431 AS studio FROM darthsim/imgproxy:v3.8.0 AS imgproxy FROM supabase/edge-runtime:v1.74.2 AS edgeruntime FROM timberio/vector:0.53.0-alpine AS vector FROM supabase/supavisor:2.9.7 AS supavisor FROM supabase/gotrue:v2.192.0 AS gotrue -FROM supabase/realtime:v2.112.1 AS realtime -FROM supabase/storage-api:v1.61.7 AS storage -FROM supabase/logflare:1.45.6 AS logflare +FROM supabase/realtime:v2.112.6 AS realtime +FROM supabase/storage-api:v1.62.5 AS storage +FROM supabase/logflare:1.46.0 AS logflare # Append to JobImages when adding new dependencies below FROM supabase/pgadmin-schema-diff:cli-0.0.5 AS differ FROM supabase/migra:3.0.1663481299 AS migra diff --git a/apps/cli-go/pkg/config/templates/config.toml b/apps/cli-go/pkg/config/templates/config.toml index 56cc27beac..98e034f8d8 100644 --- a/apps/cli-go/pkg/config/templates/config.toml +++ b/apps/cli-go/pkg/config/templates/config.toml @@ -59,8 +59,8 @@ max_client_conn = 100 [db.migrations] # If disabled, migrations will be skipped during a db push or reset. enabled = true -# Specifies an ordered list of schema files that describe your database. -# Supports glob patterns relative to supabase directory: "./schemas/*.sql" +# Specifies an ordered list of schema files, directories, or glob patterns that describe your database. +# Supports paths relative to supabase directory: "./schemas/*.sql", "./database". schema_paths = [] [db.seed] diff --git a/apps/cli-go/pkg/migration/seed.go b/apps/cli-go/pkg/migration/seed.go index 89a049878a..2e8fff71c8 100644 --- a/apps/cli-go/pkg/migration/seed.go +++ b/apps/cli-go/pkg/migration/seed.go @@ -32,7 +32,7 @@ func getRemoteSeeds(ctx context.Context, conn *pgx.Conn) (map[string]string, err } func GetPendingSeeds(ctx context.Context, locals config.Glob, conn *pgx.Conn, fsys fs.FS) ([]SeedFile, error) { - locals, err := locals.Files(fsys) + locals, err := locals.SQLFiles(fsys) if err != nil { fmt.Fprintln(os.Stderr, "WARN:", err) } diff --git a/apps/cli/docs/go-cli-porting-status.md b/apps/cli/docs/go-cli-porting-status.md index f5969bd199..42d783057c 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. | -| `db dump` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | -| `db lint` | `ported` | `legacy/commands/db/lint/` | `n/a` | `n/a` | Native TS port. Runs `plpgsql_check` in a rolled-back transaction via LegacyDbConnection; emits Go-parity pretty JSON. | -| `db pull` | `ported` | `legacy/commands/db/pull/` | `n/a` | `n/a` | Native TS port. Native pg-delta / migra migration + `--declarative` pg-delta export; reconciles `schema_migrations`. `--experimental` dump + initial-pull `pg_dump` (migra) delegate to the Go binary. | -| `db push` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | -| `db reset` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | -| `db start` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | -| `inspect report` | `ported` | `legacy/commands/inspect/report/` | `n/a` | `n/a` | Native TS port. Runs every inspect query via server-side `COPY ... CSV`, writes 14 CSVs under `//`, then renders a Go-parity Glamour rules summary (bounded csvq-subset evaluator; custom `[experimental.inspect.rules]` supported). | -| `inspect db db-stats` | `ported` | `legacy/commands/inspect/db/db-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db replication-slots` | `ported` | `legacy/commands/inspect/db/replication-slots/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db locks` | `ported` | `legacy/commands/inspect/db/locks/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db blocking` | `ported` | `legacy/commands/inspect/db/blocking/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db outliers` | `ported` | `legacy/commands/inspect/db/outliers/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db calls` | `ported` | `legacy/commands/inspect/db/calls/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db index-stats` | `ported` | `legacy/commands/inspect/db/index-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db long-running-queries` | `ported` | `legacy/commands/inspect/db/long-running-queries/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db bloat` | `ported` | `legacy/commands/inspect/db/bloat/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db role-stats` | `ported` | `legacy/commands/inspect/db/role-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db vacuum-stats` | `ported` | `legacy/commands/inspect/db/vacuum-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db table-stats` | `ported` | `legacy/commands/inspect/db/table-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db traffic-profile` | `ported` | `legacy/commands/inspect/db/traffic-profile/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db cache-hit` | `ported` | `legacy/commands/inspect/db/cache-hit/` | `n/a` | `n/a` | Native TS port. Deprecated (use db-stats); routes to the active query. | -| `inspect db index-usage` | `ported` | `legacy/commands/inspect/db/index-usage/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | -| `inspect db total-index-size` | `ported` | `legacy/commands/inspect/db/total-index-size/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | -| `inspect db index-sizes` | `ported` | `legacy/commands/inspect/db/index-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | -| `inspect db table-sizes` | `ported` | `legacy/commands/inspect/db/table-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | -| `inspect db table-index-sizes` | `ported` | `legacy/commands/inspect/db/table-index-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | -| `inspect db total-table-sizes` | `ported` | `legacy/commands/inspect/db/total-table-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | -| `inspect db unused-indexes` | `ported` | `legacy/commands/inspect/db/unused-indexes/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | -| `inspect db table-record-counts` | `ported` | `legacy/commands/inspect/db/table-record-counts/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | -| `inspect db seq-scans` | `ported` | `legacy/commands/inspect/db/seq-scans/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | -| `inspect db role-configs` | `ported` | `legacy/commands/inspect/db/role-configs/` | `n/a` | `n/a` | Native TS port. Deprecated (use role-stats); routes to the active query. | -| `inspect db role-connections` | `ported` | `legacy/commands/inspect/db/role-connections/` | `n/a` | `n/a` | Native TS port. Deprecated (use role-stats); routes to the active query. | -| `migration down` | `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` | Deliberate Go-proxy delegate (parity-preserving). A native port would emit pg-delta diff format instead of Go's `pg_dump` bytes (an accepted divergence, CLI-1597) and needs a bare-baseline shadow the seam does not yet expose; kept on the proxy for byte parity until CLI-1597's squash rewrite lands. | -| `migration up` | `ported` | `legacy/commands/migration/up/` | `n/a` | `n/a` | Native TS port. Computes pending migrations, upserts `[db.vault]`, applies each transactionally; `--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/commands/test/db/` | `n/a` | `n/a` | Native TS port. `--db-url`/`--local`/`--linked` + variadic paths; runs `supabase/pg_prove:3.36` via `docker run`; pgTAP enable/disable via `@effect/sql-pg`. `--network-id` override and `[images]` config override not modeled (documented divergences). | -| `test new` | `ported` | `legacy/commands/test/new/` | `n/a` | `n/a` | Native TS port. Writes `supabase/tests/_test.sql` from the embedded pgtap template; `--template` (pgtap). | +| Old command | TS status | TS command path or `missing` | Missing flags/params | Extra TS flags/params | Notes | +| --------------------------------- | --------- | -------------------------------------------------- | -------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `db diff` | `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. | +| `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, pending a TS PostgreSQL DDL parser for `format.WriteStructuredSchemas`. | +| `db push` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | +| `db reset` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | +| `db start` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | +| `inspect report` | `ported` | `legacy/commands/inspect/report/` | `n/a` | `n/a` | Native TS port. Runs every inspect query via server-side `COPY ... CSV`, writes 14 CSVs under `//`, then renders a Go-parity Glamour rules summary (bounded csvq-subset evaluator; custom `[experimental.inspect.rules]` supported). | +| `inspect db db-stats` | `ported` | `legacy/commands/inspect/db/db-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db replication-slots` | `ported` | `legacy/commands/inspect/db/replication-slots/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db locks` | `ported` | `legacy/commands/inspect/db/locks/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db blocking` | `ported` | `legacy/commands/inspect/db/blocking/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db outliers` | `ported` | `legacy/commands/inspect/db/outliers/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db calls` | `ported` | `legacy/commands/inspect/db/calls/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db index-stats` | `ported` | `legacy/commands/inspect/db/index-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db long-running-queries` | `ported` | `legacy/commands/inspect/db/long-running-queries/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db bloat` | `ported` | `legacy/commands/inspect/db/bloat/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db role-stats` | `ported` | `legacy/commands/inspect/db/role-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db vacuum-stats` | `ported` | `legacy/commands/inspect/db/vacuum-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db table-stats` | `ported` | `legacy/commands/inspect/db/table-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db traffic-profile` | `ported` | `legacy/commands/inspect/db/traffic-profile/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db cache-hit` | `ported` | `legacy/commands/inspect/db/cache-hit/` | `n/a` | `n/a` | Native TS port. Deprecated (use db-stats); routes to the active query. | +| `inspect db index-usage` | `ported` | `legacy/commands/inspect/db/index-usage/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | +| `inspect db total-index-size` | `ported` | `legacy/commands/inspect/db/total-index-size/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | +| `inspect db index-sizes` | `ported` | `legacy/commands/inspect/db/index-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | +| `inspect db table-sizes` | `ported` | `legacy/commands/inspect/db/table-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | +| `inspect db table-index-sizes` | `ported` | `legacy/commands/inspect/db/table-index-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | +| `inspect db total-table-sizes` | `ported` | `legacy/commands/inspect/db/total-table-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | +| `inspect db unused-indexes` | `ported` | `legacy/commands/inspect/db/unused-indexes/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | +| `inspect db table-record-counts` | `ported` | `legacy/commands/inspect/db/table-record-counts/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | +| `inspect db seq-scans` | `ported` | `legacy/commands/inspect/db/seq-scans/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | +| `inspect db role-configs` | `ported` | `legacy/commands/inspect/db/role-configs/` | `n/a` | `n/a` | Native TS port. Deprecated (use role-stats); routes to the active query. | +| `inspect db role-connections` | `ported` | `legacy/commands/inspect/db/role-connections/` | `n/a` | `n/a` | Native TS port. Deprecated (use role-stats); routes to the active query. | +| `migration down` | `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; `--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/commands/test/db/` | `n/a` | `n/a` | Native TS port. `--db-url`/`--local`/`--linked` + variadic paths; runs `supabase/pg_prove:3.36` via `docker run`; pgTAP enable/disable via `@effect/sql-pg`. `--network-id` override and `[images]` config override not modeled (documented divergences). | +| `test new` | `ported` | `legacy/commands/test/new/` | `n/a` | `n/a` | Native TS port. Writes `supabase/tests/_test.sql` from the embedded pgtap template; `--template` (pgtap). | ## Code Generation @@ -193,13 +193,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). | -| `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). | -| `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). | -| `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). | -| `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` | `-` | `-` | 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. | ## Legacy Shell Wrapping Status @@ -211,111 +211,111 @@ Legend: - `wrapped`: Phase 0 proxy wrapper exists in the legacy shell - `missing`: no legacy shell command yet -| Command | Legacy status | Legacy command path | -| -------------------------------------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `orgs list` | `ported` | [`../src/legacy/commands/orgs/list/list.command.ts`](../src/legacy/commands/orgs/list/list.command.ts) | -| `orgs create` | `ported` | [`../src/legacy/commands/orgs/create/create.command.ts`](../src/legacy/commands/orgs/create/create.command.ts) | -| `projects list` | `ported` | [`../src/legacy/commands/projects/list/list.command.ts`](../src/legacy/commands/projects/list/list.command.ts) | -| `projects create` | `ported` | [`../src/legacy/commands/projects/create/create.command.ts`](../src/legacy/commands/projects/create/create.command.ts) | -| `projects delete` | `ported` | [`../src/legacy/commands/projects/delete/delete.command.ts`](../src/legacy/commands/projects/delete/delete.command.ts) | -| `projects api-keys` | `ported` | [`../src/legacy/commands/projects/api-keys/api-keys.command.ts`](../src/legacy/commands/projects/api-keys/api-keys.command.ts) | -| `branches list` | `ported` | [`../src/legacy/commands/branches/list/list.command.ts`](../src/legacy/commands/branches/list/list.command.ts) | -| `branches create` | `ported` | [`../src/legacy/commands/branches/create/create.command.ts`](../src/legacy/commands/branches/create/create.command.ts) | -| `branches get` | `ported` | [`../src/legacy/commands/branches/get/get.command.ts`](../src/legacy/commands/branches/get/get.command.ts) | -| `branches update` | `ported` | [`../src/legacy/commands/branches/update/update.command.ts`](../src/legacy/commands/branches/update/update.command.ts) | -| `branches pause` | `ported` | [`../src/legacy/commands/branches/pause/pause.command.ts`](../src/legacy/commands/branches/pause/pause.command.ts) | -| `branches unpause` | `ported` | [`../src/legacy/commands/branches/unpause/unpause.command.ts`](../src/legacy/commands/branches/unpause/unpause.command.ts) | -| `branches delete` | `ported` | [`../src/legacy/commands/branches/delete/delete.command.ts`](../src/legacy/commands/branches/delete/delete.command.ts) | -| `branches disable` | `ported` | [`../src/legacy/commands/branches/disable/disable.command.ts`](../src/legacy/commands/branches/disable/disable.command.ts) | -| `secrets list` | `ported` | [`../src/legacy/commands/secrets/list/list.command.ts`](../src/legacy/commands/secrets/list/list.command.ts) | -| `secrets set` | `ported` | [`../src/legacy/commands/secrets/set/set.command.ts`](../src/legacy/commands/secrets/set/set.command.ts) | -| `secrets unset` | `ported` | [`../src/legacy/commands/secrets/unset/unset.command.ts`](../src/legacy/commands/secrets/unset/unset.command.ts) | -| `config push` | `ported` | [`../src/legacy/commands/config/push/push.command.ts`](../src/legacy/commands/config/push/push.command.ts) | -| `backups list` | `ported` | [`../src/legacy/commands/backups/list/list.command.ts`](../src/legacy/commands/backups/list/list.command.ts) | -| `backups restore` | `ported` | [`../src/legacy/commands/backups/restore/restore.command.ts`](../src/legacy/commands/backups/restore/restore.command.ts) | -| `snippets list` | `ported` | [`../src/legacy/commands/snippets/list/list.command.ts`](../src/legacy/commands/snippets/list/list.command.ts) | -| `snippets download` | `ported` | [`../src/legacy/commands/snippets/download/download.command.ts`](../src/legacy/commands/snippets/download/download.command.ts) | -| `sso list` | `ported` | [`../src/legacy/commands/sso/list/list.command.ts`](../src/legacy/commands/sso/list/list.command.ts) | -| `sso add` | `ported` | [`../src/legacy/commands/sso/add/add.command.ts`](../src/legacy/commands/sso/add/add.command.ts) | -| `sso remove` | `ported` | [`../src/legacy/commands/sso/remove/remove.command.ts`](../src/legacy/commands/sso/remove/remove.command.ts) | -| `sso update` | `ported` | [`../src/legacy/commands/sso/update/update.command.ts`](../src/legacy/commands/sso/update/update.command.ts) | -| `sso show` | `ported` | [`../src/legacy/commands/sso/show/show.command.ts`](../src/legacy/commands/sso/show/show.command.ts) | -| `sso info` | `ported` | [`../src/legacy/commands/sso/info/info.command.ts`](../src/legacy/commands/sso/info/info.command.ts) | -| `domains create` | `ported` | [`../src/legacy/commands/domains/create/create.command.ts`](../src/legacy/commands/domains/create/create.command.ts) | -| `domains get` | `ported` | [`../src/legacy/commands/domains/get/get.command.ts`](../src/legacy/commands/domains/get/get.command.ts) | -| `domains reverify` | `ported` | [`../src/legacy/commands/domains/reverify/reverify.command.ts`](../src/legacy/commands/domains/reverify/reverify.command.ts) | -| `domains activate` | `ported` | [`../src/legacy/commands/domains/activate/activate.command.ts`](../src/legacy/commands/domains/activate/activate.command.ts) | -| `domains delete` | `ported` | [`../src/legacy/commands/domains/delete/delete.command.ts`](../src/legacy/commands/domains/delete/delete.command.ts) | -| `vanity-subdomains get` | `ported` | [`../src/legacy/commands/vanity-subdomains/get/get.command.ts`](../src/legacy/commands/vanity-subdomains/get/get.command.ts) | -| `vanity-subdomains check-availability` | `ported` | [`../src/legacy/commands/vanity-subdomains/check-availability/check-availability.command.ts`](../src/legacy/commands/vanity-subdomains/check-availability/check-availability.command.ts) | -| `vanity-subdomains activate` | `ported` | [`../src/legacy/commands/vanity-subdomains/activate/activate.command.ts`](../src/legacy/commands/vanity-subdomains/activate/activate.command.ts) | -| `vanity-subdomains delete` | `ported` | [`../src/legacy/commands/vanity-subdomains/delete/delete.command.ts`](../src/legacy/commands/vanity-subdomains/delete/delete.command.ts) | -| `network-bans get` | `ported` | [`../src/legacy/commands/network-bans/get/get.command.ts`](../src/legacy/commands/network-bans/get/get.command.ts) | -| `network-bans remove` | `ported` | [`../src/legacy/commands/network-bans/remove/remove.command.ts`](../src/legacy/commands/network-bans/remove/remove.command.ts) | -| `network-restrictions get` | `ported` | [`../src/legacy/commands/network-restrictions/get/get.command.ts`](../src/legacy/commands/network-restrictions/get/get.command.ts) | -| `network-restrictions update` | `ported` | [`../src/legacy/commands/network-restrictions/update/update.command.ts`](../src/legacy/commands/network-restrictions/update/update.command.ts) | -| `encryption get-root-key` | `ported` | [`../src/legacy/commands/encryption/get-root-key/get-root-key.command.ts`](../src/legacy/commands/encryption/get-root-key/get-root-key.command.ts) | -| `encryption update-root-key` | `ported` | [`../src/legacy/commands/encryption/update-root-key/update-root-key.command.ts`](../src/legacy/commands/encryption/update-root-key/update-root-key.command.ts) | -| `ssl-enforcement get` | `ported` | [`../src/legacy/commands/ssl-enforcement/get/get.command.ts`](../src/legacy/commands/ssl-enforcement/get/get.command.ts) | -| `ssl-enforcement update` | `ported` | [`../src/legacy/commands/ssl-enforcement/update/update.command.ts`](../src/legacy/commands/ssl-enforcement/update/update.command.ts) | -| `postgres-config get` | `ported` | [`../src/legacy/commands/postgres-config/get/get.command.ts`](../src/legacy/commands/postgres-config/get/get.command.ts) | -| `postgres-config update` | `ported` | [`../src/legacy/commands/postgres-config/update/update.command.ts`](../src/legacy/commands/postgres-config/update/update.command.ts) | -| `postgres-config delete` | `ported` | [`../src/legacy/commands/postgres-config/delete/delete.command.ts`](../src/legacy/commands/postgres-config/delete/delete.command.ts) | -| `login` | `ported` | [`../src/legacy/commands/login/login.command.ts`](../src/legacy/commands/login/login.command.ts) | -| `logout` | `ported` | [`../src/legacy/commands/logout/logout.command.ts`](../src/legacy/commands/logout/logout.command.ts) | -| `link` | `ported` | [`../src/legacy/commands/link/link.command.ts`](../src/legacy/commands/link/link.command.ts) | -| `unlink` | `ported` | [`../src/legacy/commands/unlink/unlink.command.ts`](../src/legacy/commands/unlink/unlink.command.ts) | -| `bootstrap` | `ported` | [`../src/legacy/commands/bootstrap/bootstrap.command.ts`](../src/legacy/commands/bootstrap/bootstrap.command.ts) (native; `db push` step delegated to the Go binary — interim) | -| `init` | `ported` | [`../src/legacy/commands/init/init.command.ts`](../src/legacy/commands/init/init.command.ts) | -| `services` | `ported` | [`../src/legacy/commands/services/services.command.ts`](../src/legacy/commands/services/services.command.ts) | -| `start` | `wrapped` | [`../src/legacy/commands/start/start.command.ts`](../src/legacy/commands/start/start.command.ts) | -| `stop` | `wrapped` | [`../src/legacy/commands/stop/stop.command.ts`](../src/legacy/commands/stop/stop.command.ts) | -| `status` | `wrapped` | [`../src/legacy/commands/status/status.command.ts`](../src/legacy/commands/status/status.command.ts) | -| `telemetry enable` | `ported` | [`../src/legacy/commands/telemetry/enable/enable.command.ts`](../src/legacy/commands/telemetry/enable/enable.command.ts) | -| `telemetry disable` | `ported` | [`../src/legacy/commands/telemetry/disable/disable.command.ts`](../src/legacy/commands/telemetry/disable/disable.command.ts) | -| `telemetry status` | `ported` | [`../src/legacy/commands/telemetry/status/status.command.ts`](../src/legacy/commands/telemetry/status/status.command.ts) | -| `migration list` | `ported` | [`../src/legacy/commands/migration/list/list.command.ts`](../src/legacy/commands/migration/list/list.command.ts) — native; merged Local/Remote/Time-UTC Glamour table | -| `migration new` | `ported` | [`../src/legacy/commands/migration/new/new.command.ts`](../src/legacy/commands/migration/new/new.command.ts) — native; writes `supabase/migrations/_.sql` from piped stdin | -| `migration repair` | `ported` | [`../src/legacy/commands/migration/repair/repair.command.ts`](../src/legacy/commands/migration/repair/repair.command.ts) — native; transactional TRUNCATE/UPSERT/DELETE, repair-all prompt | -| `migration squash` | `wrapped` | [`../src/legacy/commands/migration/squash/squash.command.ts`](../src/legacy/commands/migration/squash/squash.command.ts) — deliberate Go delegate for byte parity (native pg-delta squash deferred to CLI-1597) | -| `migration up` | `ported` | [`../src/legacy/commands/migration/up/up.command.ts`](../src/legacy/commands/migration/up/up.command.ts) — native; pending compute + vault upsert + per-file apply | -| `migration down` | `ported` | [`../src/legacy/commands/migration/down/down.command.ts`](../src/legacy/commands/migration/down/down.command.ts) — native; drop + vault + migrate&seed to target version | -| `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) | -| `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 keys` | `wrapped` | [`../src/legacy/commands/gen/keys/keys.command.ts`](../src/legacy/commands/gen/keys/keys.command.ts) | -| `functions list` | `wrapped` | [`../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) | -| `functions download` | `ported` | [`../src/legacy/commands/functions/download/download.command.ts`](../src/legacy/commands/functions/download/download.command.ts) | -| `functions deploy` | `ported` | [`../src/legacy/commands/functions/deploy/deploy.command.ts`](../src/legacy/commands/functions/deploy/deploy.command.ts) | -| `functions new` | `ported` | [`../src/legacy/commands/functions/new/new.command.ts`](../src/legacy/commands/functions/new/new.command.ts) | -| `functions serve` | `ported` | [`../src/legacy/commands/functions/serve/serve.command.ts`](../src/legacy/commands/functions/serve/serve.command.ts) | -| `storage ls` | `ported` | [`../src/legacy/commands/storage/ls/ls.command.ts`](../src/legacy/commands/storage/ls/ls.command.ts) | -| `storage cp` | `ported` | [`../src/legacy/commands/storage/cp/cp.command.ts`](../src/legacy/commands/storage/cp/cp.command.ts) | -| `storage mv` | `ported` | [`../src/legacy/commands/storage/mv/mv.command.ts`](../src/legacy/commands/storage/mv/mv.command.ts) | -| `storage rm` | `ported` | [`../src/legacy/commands/storage/rm/rm.command.ts`](../src/legacy/commands/storage/rm/rm.command.ts) | -| `test db` | `ported` | [`../src/legacy/commands/test/db/db.command.ts`](../src/legacy/commands/test/db/db.command.ts) | -| `test new` | `ported` | [`../src/legacy/commands/test/new/new.command.ts`](../src/legacy/commands/test/new/new.command.ts) | -| `seed buckets` | `ported` | [`../src/legacy/commands/seed/buckets/buckets.command.ts`](../src/legacy/commands/seed/buckets/buckets.command.ts) | -| `db diff` | `ported` | [`../src/legacy/commands/db/diff/diff.command.ts`](../src/legacy/commands/db/diff/diff.command.ts) — native pg-delta / migra; `--use-pgadmin` / `--use-pg-schema` delegate to Go | -| `db dump` | `ported` | [`../src/legacy/commands/db/dump/dump.command.ts`](../src/legacy/commands/db/dump/dump.command.ts) | -| `db push` | `wrapped` | [`../src/legacy/commands/db/push/push.command.ts`](../src/legacy/commands/db/push/push.command.ts) | -| `db pull` | `ported` | [`../src/legacy/commands/db/pull/pull.command.ts`](../src/legacy/commands/db/pull/pull.command.ts) — native pg-delta / migra; `--declarative` (deprecated alias `--use-pg-delta`) + `--diff-engine` (migra\|pg-delta); `--experimental` / initial `pg_dump` delegate to Go | -| `db reset` | `wrapped` | [`../src/legacy/commands/db/reset/reset.command.ts`](../src/legacy/commands/db/reset/reset.command.ts) — includes Go-parity `--sql-paths` override for `[db.seed].sql_paths` | -| `db lint` | `ported` | [`../src/legacy/commands/db/lint/lint.command.ts`](../src/legacy/commands/db/lint/lint.command.ts) | -| `db start` | `wrapped` | [`../src/legacy/commands/db/start/start.command.ts`](../src/legacy/commands/db/start/start.command.ts) | -| `db query` | `ported` | [`../src/legacy/commands/db/query/query.command.ts`](../src/legacy/commands/db/query/query.command.ts) | -| `db advisors` | `ported` | [`../src/legacy/commands/db/advisors/advisors.command.ts`](../src/legacy/commands/db/advisors/advisors.command.ts) | -| `db test` | `wrapped` | [`../src/legacy/commands/db/test/test.command.ts`](../src/legacy/commands/db/test/test.command.ts) | -| `db branch create` | `wrapped` | [`../src/legacy/commands/db/branch/create/create.command.ts`](../src/legacy/commands/db/branch/create/create.command.ts) | -| `db branch delete` | `wrapped` | [`../src/legacy/commands/db/branch/delete/delete.command.ts`](../src/legacy/commands/db/branch/delete/delete.command.ts) | -| `db branch list` | `wrapped` | [`../src/legacy/commands/db/branch/list/list.command.ts`](../src/legacy/commands/db/branch/list/list.command.ts) | -| `db branch switch` | `wrapped` | [`../src/legacy/commands/db/branch/switch/switch.command.ts`](../src/legacy/commands/db/branch/switch/switch.command.ts) | -| `db remote changes` | `wrapped` | [`../src/legacy/commands/db/remote/changes/changes.command.ts`](../src/legacy/commands/db/remote/changes/changes.command.ts) | -| `db remote commit` | `wrapped` | [`../src/legacy/commands/db/remote/commit/commit.command.ts`](../src/legacy/commands/db/remote/commit/commit.command.ts) | -| `db schema declarative sync` | `ported` | [`../src/legacy/commands/db/schema/declarative/sync/sync.command.ts`](../src/legacy/commands/db/schema/declarative/sync/sync.command.ts) | -| `db schema declarative generate` | `ported` | [`../src/legacy/commands/db/schema/declarative/generate/generate.command.ts`](../src/legacy/commands/db/schema/declarative/generate/generate.command.ts) | +| Command | Legacy status | Legacy command path | +| -------------------------------------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `orgs list` | `ported` | [`../src/legacy/commands/orgs/list/list.command.ts`](../src/legacy/commands/orgs/list/list.command.ts) | +| `orgs create` | `ported` | [`../src/legacy/commands/orgs/create/create.command.ts`](../src/legacy/commands/orgs/create/create.command.ts) | +| `projects list` | `ported` | [`../src/legacy/commands/projects/list/list.command.ts`](../src/legacy/commands/projects/list/list.command.ts) | +| `projects create` | `ported` | [`../src/legacy/commands/projects/create/create.command.ts`](../src/legacy/commands/projects/create/create.command.ts) | +| `projects delete` | `ported` | [`../src/legacy/commands/projects/delete/delete.command.ts`](../src/legacy/commands/projects/delete/delete.command.ts) | +| `projects api-keys` | `ported` | [`../src/legacy/commands/projects/api-keys/api-keys.command.ts`](../src/legacy/commands/projects/api-keys/api-keys.command.ts) | +| `branches list` | `ported` | [`../src/legacy/commands/branches/list/list.command.ts`](../src/legacy/commands/branches/list/list.command.ts) | +| `branches create` | `ported` | [`../src/legacy/commands/branches/create/create.command.ts`](../src/legacy/commands/branches/create/create.command.ts) | +| `branches get` | `ported` | [`../src/legacy/commands/branches/get/get.command.ts`](../src/legacy/commands/branches/get/get.command.ts) | +| `branches update` | `ported` | [`../src/legacy/commands/branches/update/update.command.ts`](../src/legacy/commands/branches/update/update.command.ts) | +| `branches pause` | `ported` | [`../src/legacy/commands/branches/pause/pause.command.ts`](../src/legacy/commands/branches/pause/pause.command.ts) | +| `branches unpause` | `ported` | [`../src/legacy/commands/branches/unpause/unpause.command.ts`](../src/legacy/commands/branches/unpause/unpause.command.ts) | +| `branches delete` | `ported` | [`../src/legacy/commands/branches/delete/delete.command.ts`](../src/legacy/commands/branches/delete/delete.command.ts) | +| `branches disable` | `ported` | [`../src/legacy/commands/branches/disable/disable.command.ts`](../src/legacy/commands/branches/disable/disable.command.ts) | +| `secrets list` | `ported` | [`../src/legacy/commands/secrets/list/list.command.ts`](../src/legacy/commands/secrets/list/list.command.ts) | +| `secrets set` | `ported` | [`../src/legacy/commands/secrets/set/set.command.ts`](../src/legacy/commands/secrets/set/set.command.ts) | +| `secrets unset` | `ported` | [`../src/legacy/commands/secrets/unset/unset.command.ts`](../src/legacy/commands/secrets/unset/unset.command.ts) | +| `config push` | `ported` | [`../src/legacy/commands/config/push/push.command.ts`](../src/legacy/commands/config/push/push.command.ts) | +| `backups list` | `ported` | [`../src/legacy/commands/backups/list/list.command.ts`](../src/legacy/commands/backups/list/list.command.ts) | +| `backups restore` | `ported` | [`../src/legacy/commands/backups/restore/restore.command.ts`](../src/legacy/commands/backups/restore/restore.command.ts) | +| `snippets list` | `ported` | [`../src/legacy/commands/snippets/list/list.command.ts`](../src/legacy/commands/snippets/list/list.command.ts) | +| `snippets download` | `ported` | [`../src/legacy/commands/snippets/download/download.command.ts`](../src/legacy/commands/snippets/download/download.command.ts) | +| `sso list` | `ported` | [`../src/legacy/commands/sso/list/list.command.ts`](../src/legacy/commands/sso/list/list.command.ts) | +| `sso add` | `ported` | [`../src/legacy/commands/sso/add/add.command.ts`](../src/legacy/commands/sso/add/add.command.ts) | +| `sso remove` | `ported` | [`../src/legacy/commands/sso/remove/remove.command.ts`](../src/legacy/commands/sso/remove/remove.command.ts) | +| `sso update` | `ported` | [`../src/legacy/commands/sso/update/update.command.ts`](../src/legacy/commands/sso/update/update.command.ts) | +| `sso show` | `ported` | [`../src/legacy/commands/sso/show/show.command.ts`](../src/legacy/commands/sso/show/show.command.ts) | +| `sso info` | `ported` | [`../src/legacy/commands/sso/info/info.command.ts`](../src/legacy/commands/sso/info/info.command.ts) | +| `domains create` | `ported` | [`../src/legacy/commands/domains/create/create.command.ts`](../src/legacy/commands/domains/create/create.command.ts) | +| `domains get` | `ported` | [`../src/legacy/commands/domains/get/get.command.ts`](../src/legacy/commands/domains/get/get.command.ts) | +| `domains reverify` | `ported` | [`../src/legacy/commands/domains/reverify/reverify.command.ts`](../src/legacy/commands/domains/reverify/reverify.command.ts) | +| `domains activate` | `ported` | [`../src/legacy/commands/domains/activate/activate.command.ts`](../src/legacy/commands/domains/activate/activate.command.ts) | +| `domains delete` | `ported` | [`../src/legacy/commands/domains/delete/delete.command.ts`](../src/legacy/commands/domains/delete/delete.command.ts) | +| `vanity-subdomains get` | `ported` | [`../src/legacy/commands/vanity-subdomains/get/get.command.ts`](../src/legacy/commands/vanity-subdomains/get/get.command.ts) | +| `vanity-subdomains check-availability` | `ported` | [`../src/legacy/commands/vanity-subdomains/check-availability/check-availability.command.ts`](../src/legacy/commands/vanity-subdomains/check-availability/check-availability.command.ts) | +| `vanity-subdomains activate` | `ported` | [`../src/legacy/commands/vanity-subdomains/activate/activate.command.ts`](../src/legacy/commands/vanity-subdomains/activate/activate.command.ts) | +| `vanity-subdomains delete` | `ported` | [`../src/legacy/commands/vanity-subdomains/delete/delete.command.ts`](../src/legacy/commands/vanity-subdomains/delete/delete.command.ts) | +| `network-bans get` | `ported` | [`../src/legacy/commands/network-bans/get/get.command.ts`](../src/legacy/commands/network-bans/get/get.command.ts) | +| `network-bans remove` | `ported` | [`../src/legacy/commands/network-bans/remove/remove.command.ts`](../src/legacy/commands/network-bans/remove/remove.command.ts) | +| `network-restrictions get` | `ported` | [`../src/legacy/commands/network-restrictions/get/get.command.ts`](../src/legacy/commands/network-restrictions/get/get.command.ts) | +| `network-restrictions update` | `ported` | [`../src/legacy/commands/network-restrictions/update/update.command.ts`](../src/legacy/commands/network-restrictions/update/update.command.ts) | +| `encryption get-root-key` | `ported` | [`../src/legacy/commands/encryption/get-root-key/get-root-key.command.ts`](../src/legacy/commands/encryption/get-root-key/get-root-key.command.ts) | +| `encryption update-root-key` | `ported` | [`../src/legacy/commands/encryption/update-root-key/update-root-key.command.ts`](../src/legacy/commands/encryption/update-root-key/update-root-key.command.ts) | +| `ssl-enforcement get` | `ported` | [`../src/legacy/commands/ssl-enforcement/get/get.command.ts`](../src/legacy/commands/ssl-enforcement/get/get.command.ts) | +| `ssl-enforcement update` | `ported` | [`../src/legacy/commands/ssl-enforcement/update/update.command.ts`](../src/legacy/commands/ssl-enforcement/update/update.command.ts) | +| `postgres-config get` | `ported` | [`../src/legacy/commands/postgres-config/get/get.command.ts`](../src/legacy/commands/postgres-config/get/get.command.ts) | +| `postgres-config update` | `ported` | [`../src/legacy/commands/postgres-config/update/update.command.ts`](../src/legacy/commands/postgres-config/update/update.command.ts) | +| `postgres-config delete` | `ported` | [`../src/legacy/commands/postgres-config/delete/delete.command.ts`](../src/legacy/commands/postgres-config/delete/delete.command.ts) | +| `login` | `ported` | [`../src/legacy/commands/login/login.command.ts`](../src/legacy/commands/login/login.command.ts) | +| `logout` | `ported` | [`../src/legacy/commands/logout/logout.command.ts`](../src/legacy/commands/logout/logout.command.ts) | +| `link` | `ported` | [`../src/legacy/commands/link/link.command.ts`](../src/legacy/commands/link/link.command.ts) | +| `unlink` | `ported` | [`../src/legacy/commands/unlink/unlink.command.ts`](../src/legacy/commands/unlink/unlink.command.ts) | +| `bootstrap` | `ported` | [`../src/legacy/commands/bootstrap/bootstrap.command.ts`](../src/legacy/commands/bootstrap/bootstrap.command.ts) (native; `db push` step delegated to the Go binary — interim) | +| `init` | `ported` | [`../src/legacy/commands/init/init.command.ts`](../src/legacy/commands/init/init.command.ts) | +| `services` | `ported` | [`../src/legacy/commands/services/services.command.ts`](../src/legacy/commands/services/services.command.ts) | +| `start` | `wrapped` | [`../src/legacy/commands/start/start.command.ts`](../src/legacy/commands/start/start.command.ts) | +| `stop` | `wrapped` | [`../src/legacy/commands/stop/stop.command.ts`](../src/legacy/commands/stop/stop.command.ts) | +| `status` | `wrapped` | [`../src/legacy/commands/status/status.command.ts`](../src/legacy/commands/status/status.command.ts) | +| `telemetry enable` | `ported` | [`../src/legacy/commands/telemetry/enable/enable.command.ts`](../src/legacy/commands/telemetry/enable/enable.command.ts) | +| `telemetry disable` | `ported` | [`../src/legacy/commands/telemetry/disable/disable.command.ts`](../src/legacy/commands/telemetry/disable/disable.command.ts) | +| `telemetry status` | `ported` | [`../src/legacy/commands/telemetry/status/status.command.ts`](../src/legacy/commands/telemetry/status/status.command.ts) | +| `migration list` | `ported` | [`../src/legacy/commands/migration/list/list.command.ts`](../src/legacy/commands/migration/list/list.command.ts) — native; merged Local/Remote/Time-UTC Glamour table | +| `migration new` | `ported` | [`../src/legacy/commands/migration/new/new.command.ts`](../src/legacy/commands/migration/new/new.command.ts) — native; writes `supabase/migrations/_.sql` from piped stdin | +| `migration repair` | `ported` | [`../src/legacy/commands/migration/repair/repair.command.ts`](../src/legacy/commands/migration/repair/repair.command.ts) — native; transactional TRUNCATE/UPSERT/DELETE, repair-all prompt | +| `migration squash` | `wrapped` | [`../src/legacy/commands/migration/squash/squash.command.ts`](../src/legacy/commands/migration/squash/squash.command.ts) | +| `migration up` | `ported` | [`../src/legacy/commands/migration/up/up.command.ts`](../src/legacy/commands/migration/up/up.command.ts) — native; pending compute + vault upsert + per-file apply | +| `migration down` | `ported` | [`../src/legacy/commands/migration/down/down.command.ts`](../src/legacy/commands/migration/down/down.command.ts) — native; drop + vault + migrate&seed to target version | +| `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) - native; non-TypeScript project refs use pg-meta with IPv4 pooler retry instead of Go's "Try using --db-url" failure; `--swift-access-control` requires `--lang swift`; explicit source flags with `--query-timeout` on the remote TypeScript path error, while the implicit linked TypeScript path warns and continues | +| `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 keys` | `wrapped` | [`../src/legacy/commands/gen/keys/keys.command.ts`](../src/legacy/commands/gen/keys/keys.command.ts) | +| `functions list` | `wrapped` | [`../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) | +| `functions download` | `ported` | [`../src/legacy/commands/functions/download/download.command.ts`](../src/legacy/commands/functions/download/download.command.ts) | +| `functions deploy` | `ported` | [`../src/legacy/commands/functions/deploy/deploy.command.ts`](../src/legacy/commands/functions/deploy/deploy.command.ts) | +| `functions new` | `ported` | [`../src/legacy/commands/functions/new/new.command.ts`](../src/legacy/commands/functions/new/new.command.ts) | +| `functions serve` | `ported` | [`../src/legacy/commands/functions/serve/serve.command.ts`](../src/legacy/commands/functions/serve/serve.command.ts) | +| `storage ls` | `ported` | [`../src/legacy/commands/storage/ls/ls.command.ts`](../src/legacy/commands/storage/ls/ls.command.ts) | +| `storage cp` | `ported` | [`../src/legacy/commands/storage/cp/cp.command.ts`](../src/legacy/commands/storage/cp/cp.command.ts) | +| `storage mv` | `ported` | [`../src/legacy/commands/storage/mv/mv.command.ts`](../src/legacy/commands/storage/mv/mv.command.ts) | +| `storage rm` | `ported` | [`../src/legacy/commands/storage/rm/rm.command.ts`](../src/legacy/commands/storage/rm/rm.command.ts) | +| `test db` | `ported` | [`../src/legacy/commands/test/db/db.command.ts`](../src/legacy/commands/test/db/db.command.ts) | +| `test new` | `ported` | [`../src/legacy/commands/test/new/new.command.ts`](../src/legacy/commands/test/new/new.command.ts) | +| `seed buckets` | `ported` | [`../src/legacy/commands/seed/buckets/buckets.command.ts`](../src/legacy/commands/seed/buckets/buckets.command.ts) | +| `db diff` | `ported` | [`../src/legacy/commands/db/diff/diff.command.ts`](../src/legacy/commands/db/diff/diff.command.ts) — native pg-delta / migra; `--use-pgadmin` / `--use-pg-schema` delegate to Go | +| `db dump` | `ported` | [`../src/legacy/commands/db/dump/dump.command.ts`](../src/legacy/commands/db/dump/dump.command.ts) | +| `db push` | `wrapped` | [`../src/legacy/commands/db/push/push.command.ts`](../src/legacy/commands/db/push/push.command.ts) | +| `db pull` | `ported` | [`../src/legacy/commands/db/pull/pull.command.ts`](../src/legacy/commands/db/pull/pull.command.ts) — native pg-delta / migra; `--declarative` (deprecated alias `--use-pg-delta`) + `--diff-engine` (migra\|pg-delta); initial-migra pull dumps the schema natively (`pg_dump`) + appends the diff; only `--experimental` structured dump still delegates to Go (needs a TS DDL parser for `WriteStructuredSchemas`) | +| `db reset` | `wrapped` | [`../src/legacy/commands/db/reset/reset.command.ts`](../src/legacy/commands/db/reset/reset.command.ts) — includes Go-parity `--sql-paths` override for `[db.seed].sql_paths` | +| `db lint` | `ported` | [`../src/legacy/commands/db/lint/lint.command.ts`](../src/legacy/commands/db/lint/lint.command.ts) | +| `db start` | `wrapped` | [`../src/legacy/commands/db/start/start.command.ts`](../src/legacy/commands/db/start/start.command.ts) | +| `db query` | `ported` | [`../src/legacy/commands/db/query/query.command.ts`](../src/legacy/commands/db/query/query.command.ts) | +| `db advisors` | `ported` | [`../src/legacy/commands/db/advisors/advisors.command.ts`](../src/legacy/commands/db/advisors/advisors.command.ts) | +| `db test` | `wrapped` | [`../src/legacy/commands/db/test/test.command.ts`](../src/legacy/commands/db/test/test.command.ts) | +| `db branch create` | `wrapped` | [`../src/legacy/commands/db/branch/create/create.command.ts`](../src/legacy/commands/db/branch/create/create.command.ts) | +| `db branch delete` | `wrapped` | [`../src/legacy/commands/db/branch/delete/delete.command.ts`](../src/legacy/commands/db/branch/delete/delete.command.ts) | +| `db branch list` | `wrapped` | [`../src/legacy/commands/db/branch/list/list.command.ts`](../src/legacy/commands/db/branch/list/list.command.ts) | +| `db branch switch` | `wrapped` | [`../src/legacy/commands/db/branch/switch/switch.command.ts`](../src/legacy/commands/db/branch/switch/switch.command.ts) | +| `db remote changes` | `wrapped` | [`../src/legacy/commands/db/remote/changes/changes.command.ts`](../src/legacy/commands/db/remote/changes/changes.command.ts) | +| `db remote commit` | `wrapped` | [`../src/legacy/commands/db/remote/commit/commit.command.ts`](../src/legacy/commands/db/remote/commit/commit.command.ts) | +| `db schema declarative sync` | `ported` | [`../src/legacy/commands/db/schema/declarative/sync/sync.command.ts`](../src/legacy/commands/db/schema/declarative/sync/sync.command.ts) | +| `db schema declarative generate` | `ported` | [`../src/legacy/commands/db/schema/declarative/generate/generate.command.ts`](../src/legacy/commands/db/schema/declarative/generate/generate.command.ts) | Flag divergences from the Go reference: diff --git a/apps/cli/docs/release-process.md b/apps/cli/docs/release-process.md index c8196ef732..05ab1cb582 100644 --- a/apps/cli/docs/release-process.md +++ b/apps/cli/docs/release-process.md @@ -247,7 +247,7 @@ flowchart TD ### Trigger -Most releases are automatic — merge a PR into `develop` (beta) or approve the weekly Prod-Deploy PR into `main` (stable). For an `alpha` cut or a one-off override, dispatch manually: +Most releases are automatic — merge a PR into `develop` (beta) or approve the weekly Prod-Deploy PR into `main` (stable). Hotfixes use the same production gate: a reviewed `hotfix/*` PR targets `main`, and the resulting `main` push triggers the stable release path. For an `alpha` cut or a one-off override, dispatch manually: ```sh # Manual alpha cut (v3 / next shell): @@ -265,6 +265,34 @@ gh workflow run release.yml \ Auto-trigger paths leave `version` empty: semantic-release computes it from commits since the last tag. +### Hotfix release flow + +Use a hotfix when an urgent stable fix must ship before the next scheduled `develop` -> `main` promotion. The hotfix path deliberately reuses the production PR gate instead of adding a second approval mechanism: + +1. Branch from the current `main` tip: + + ```sh + git fetch origin main + git switch -c hotfix/ origin/main + ``` + +2. Make the smallest safe fix and open a PR from `hotfix/` into `main`. +3. Before merging, run a release dry run against the hotfix branch and the next unique stable version: + + ```sh + gh workflow run release.yml \ + --ref hotfix/ \ + --field channel=stable \ + --field version= \ + --field dry_run=true + ``` + +4. After review and green checks, merge the hotfix PR into `main`. The `push: main` trigger runs the normal stable release pipeline and publishes the next semantic-release version. +5. Watch the stable release workflow through publish, Homebrew/Scoop updates, and verification. +6. Confirm that `Sync main to develop` succeeds. That workflow merges `main` back into `develop` after a successful `Release` run on `main`, keeping the hotfix reachable from the next beta and the next scheduled production deploy. If the sync conflicts, resolve it with a follow-up PR into `develop` before the next production promotion. + +Do not use `workflow_dispatch dry_run=false` as the normal hotfix path. Manual stable dispatch is reserved for re-cutting a unique version after an interrupted or stale-bytes release. Hotfixes should land through a PR to `main` so the production source of truth and release tag history stay aligned. + ### What each job does `**build` (ubuntu-latest):\*\* diff --git a/apps/cli/package.json b/apps/cli/package.json index 03f695883a..c184c44d66 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -43,8 +43,8 @@ "jose": "^6.2.3" }, "devDependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.3.186", - "@anthropic-ai/sdk": "^0.105.0", + "@anthropic-ai/claude-agent-sdk": "^0.3.196", + "@anthropic-ai/sdk": "^0.107.0", "@clack/prompts": "^1.6.0", "@effect/atom-react": "catalog:", "@effect/platform-bun": "catalog:", @@ -76,7 +76,7 @@ "oxlint-tsgolint": "catalog:", "pg": "^8.22.0", "pg-copy-streams": "^7.0.0", - "posthog-node": "^5.38.2", + "posthog-node": "^5.38.8", "react": "^19.2.7", "react-devtools-core": "^7.0.1", "semantic-release": "^25.0.5", diff --git a/apps/cli/src/legacy/cli/complete-passthrough.ts b/apps/cli/src/legacy/cli/complete-passthrough.ts index 59d4d27947..313868bc2c 100644 --- a/apps/cli/src/legacy/cli/complete-passthrough.ts +++ b/apps/cli/src/legacy/cli/complete-passthrough.ts @@ -16,9 +16,13 @@ export interface CompletePassthroughDeps { /** * Cobra-generated completion scripts (`supabase completion {bash,zsh,fish,powershell}`) - * call back into `supabase __complete ` on every tab press. 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 + * 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. * @@ -26,7 +30,7 @@ export interface CompletePassthroughDeps { * otherwise. */ export function tryCompletePassthrough(deps: CompletePassthroughDeps): boolean { - if (deps.argv[0] !== "__complete") return false; + if (deps.argv[0] !== "__complete" && deps.argv[0] !== "__completeNoDesc") return false; const resolved = deps.resolveBinary(); if (!("found" in resolved)) { diff --git a/apps/cli/src/legacy/cli/complete-passthrough.unit.test.ts b/apps/cli/src/legacy/cli/complete-passthrough.unit.test.ts index 0e5f7c4801..09f578a801 100644 --- a/apps/cli/src/legacy/cli/complete-passthrough.unit.test.ts +++ b/apps/cli/src/legacy/cli/complete-passthrough.unit.test.ts @@ -68,6 +68,17 @@ describe("tryCompletePassthrough", () => { 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 }); diff --git a/apps/cli/src/legacy/commands/bootstrap/bootstrap.integration.test.ts b/apps/cli/src/legacy/commands/bootstrap/bootstrap.integration.test.ts index 41e386c16e..56c8fa1878 100644 --- a/apps/cli/src/legacy/commands/bootstrap/bootstrap.integration.test.ts +++ b/apps/cli/src/legacy/commands/bootstrap/bootstrap.integration.test.ts @@ -33,6 +33,7 @@ import { LegacyOutputFlag, } from "../../../shared/legacy/global-flags.ts"; import { LegacyGoProxy } from "../../../shared/legacy/go-proxy.service.ts"; +import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; import { LegacyTemplateService, type LegacyStarterTemplate } from "./bootstrap.templates.ts"; import { legacyBootstrap } from "./bootstrap.handler.ts"; import type { LegacyBootstrapFlags } from "./bootstrap.command.ts"; @@ -175,6 +176,7 @@ function setup(opts: SetupOpts = {}) { Layer.succeed(LegacyWorkdirFlag, opts.workdir ?? Option.some(tempRoot.current)), Layer.succeed(LegacyYesFlag, opts.yes ?? false), Layer.succeed(LegacyDebugFlag, opts.debug ?? false), + Layer.succeed(CliArgs, { args: [] }), ); return { diff --git a/apps/cli/src/legacy/commands/bootstrap/bootstrap.workdir-cache.integration.test.ts b/apps/cli/src/legacy/commands/bootstrap/bootstrap.workdir-cache.integration.test.ts index db5b08d01b..285a19900e 100644 --- a/apps/cli/src/legacy/commands/bootstrap/bootstrap.workdir-cache.integration.test.ts +++ b/apps/cli/src/legacy/commands/bootstrap/bootstrap.workdir-cache.integration.test.ts @@ -33,6 +33,7 @@ import { LegacyYesFlag, } from "../../../shared/legacy/global-flags.ts"; import { LegacyGoProxy } from "../../../shared/legacy/go-proxy.service.ts"; +import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; import { legacyDebugLoggerLayer } from "../../shared/legacy-debug-logger.layer.ts"; import { legacyIdentityStitchLayer } from "../../shared/legacy-identity-stitch.ts"; import { legacyCliConfigLayer } from "../../config/legacy-cli-config.layer.ts"; @@ -117,6 +118,7 @@ describe("legacy bootstrap linked-project cache location", () => { Layer.succeed(LegacyYesFlag, false), Layer.succeed(LegacyOutputFlag, Option.none()), Layer.succeed(LegacyDebugFlag, false), + Layer.succeed(CliArgs, { args: [] }), ); const runtime = mockRuntimeInfo({ cwd: parent }); const credentials = mockLegacyCredentialsTracked(); diff --git a/apps/cli/src/legacy/commands/branches/branches.format.ts b/apps/cli/src/legacy/commands/branches/branches.format.ts index c410c5dcd7..2cf1249d39 100644 --- a/apps/cli/src/legacy/commands/branches/branches.format.ts +++ b/apps/cli/src/legacy/commands/branches/branches.format.ts @@ -111,6 +111,9 @@ export type PoolerParseResult = * error message (e.g. `failed to parse pooler URL: parse "...": invalid port`), * never the URL itself. Returning the URL would leak the pooler username, * host, and port into stderr logs. + * + * This display-only parser intentionally does not enforce the profile-domain or + * tenant-ref guards used by `legacyPoolerConfigFromConnectionString`. */ export function parsePoolerConnectionString(connString: string): PoolerParseResult { const sanitized = connString.replaceAll(POOLER_PASSWORD_PLACEHOLDER, ""); diff --git a/apps/cli/src/legacy/commands/completion/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/completion/SIDE_EFFECTS.md index 9246628751..f25912c9c5 100644 --- a/apps/cli/src/legacy/commands/completion/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/completion/SIDE_EFFECTS.md @@ -44,8 +44,11 @@ brew-managed `_supabase` files in their `fpath`, or analogous bash/fish/powershe artifacts. Drift would break tab completion for those users. The generated scripts call back to `supabase __complete ` on every tab press to -fetch dynamic completion candidates — see `apps/cli/src/legacy/commands/__complete/`, -which provides the matching hidden command. +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. ## Notes @@ -58,3 +61,7 @@ which provides the matching hidden command. - The Go CLI exits non-zero when called without a shell subcommand (e.g. `supabase completion`). Effect CLI surfaces the same condition through its usual "missing subcommand" help-with-exit-1 behavior. +- 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. 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 5e8c1d9a10..a2810b19b7 100644 --- a/apps/cli/src/legacy/commands/completion/bash/bash.command.ts +++ b/apps/cli/src/legacy/commands/completion/bash/bash.command.ts @@ -1,8 +1,11 @@ import { Command } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; +import { LegacyCompletionNoDescriptionsFlagDef } from "../completion.flags.ts"; import { legacyCompletionBash } from "./bash.handler.ts"; -const config = {}; +const config = { + noDescriptions: LegacyCompletionNoDescriptionsFlagDef, +} as const; export type LegacyCompletionBashFlags = CliCommand.Command.Config.Infer; export const legacyCompletionBashCommand = Command.make("bash", config).pipe( 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 dce9331b4c..9ff371dd44 100644 --- a/apps/cli/src/legacy/commands/completion/bash/bash.handler.ts +++ b/apps/cli/src/legacy/commands/completion/bash/bash.handler.ts @@ -3,8 +3,10 @@ import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; import type { LegacyCompletionBashFlags } from "./bash.command.ts"; export const legacyCompletionBash = Effect.fn("legacy.completion.bash")(function* ( - _flags: LegacyCompletionBashFlags, + flags: LegacyCompletionBashFlags, ) { const proxy = yield* LegacyGoProxy; - yield* proxy.exec(["completion", "bash"]); + const args: string[] = ["completion", "bash"]; + if (flags.noDescriptions) args.push("--no-descriptions"); + yield* proxy.exec(args); }); 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 3506609072..3bb8f31137 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,6 +1,8 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, Layer } from "effect"; +import { Command } from "effect/unstable/cli"; import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { legacyCompletionBashCommand } from "./bash.command.ts"; import { legacyCompletionBash } from "./bash.handler.ts"; function setupLegacyCompletionBash() { @@ -15,12 +17,35 @@ function setupLegacyCompletionBash() { return { layer, calls }; } +function legacyTestRoot() { + return Command.make("supabase").pipe(Command.withSubcommands([legacyCompletionBashCommand])); +} + describe("legacy completion bash", () => { it.live("forwards `completion bash` to the Go binary", () => { const { layer, calls } = setupLegacyCompletionBash(); return Effect.gen(function* () { - yield* legacyCompletionBash({}); + yield* legacyCompletionBash({ noDescriptions: false }); expect(calls).toEqual([["completion", "bash"]]); }).pipe(Effect.provide(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("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; + }); }); diff --git a/apps/cli/src/legacy/commands/completion/completion.e2e.test.ts b/apps/cli/src/legacy/commands/completion/completion.e2e.test.ts new file mode 100644 index 0000000000..56c721e5f3 --- /dev/null +++ b/apps/cli/src/legacy/commands/completion/completion.e2e.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from "vitest"; +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. + test( + "bash --no-descriptions is accepted and forwarded to the Go binary", + { timeout: E2E_TIMEOUT_MS }, + async () => { + const { exitCode, stdout } = await runSupabase(["completion", "bash", "--no-descriptions"], { + entrypoint: "legacy", + }); + expect(exitCode).toBe(0); + expect(stdout).toContain("__completeNoDesc"); + }, + ); +}); diff --git a/apps/cli/src/legacy/commands/completion/completion.flags.ts b/apps/cli/src/legacy/commands/completion/completion.flags.ts new file mode 100644 index 0000000000..29e1193022 --- /dev/null +++ b/apps/cli/src/legacy/commands/completion/completion.flags.ts @@ -0,0 +1,17 @@ +import { Flag } from "effect/unstable/cli"; + +/** + * cobra auto-registers `--no-descriptions` on the bash/zsh/fish/powershell + * completion subcommands whenever descriptions are enabled (the default) — + * `compCmdNoDescFlagName`/`compCmdNoDescFlagDefault`/`compCmdNoDescFlagDesc` + * in `spf13/cobra@v1.10.2/completions.go:101-103`. Shared across all four + * leaves rather than redeclared per-file. + * + * `Flag.boolean` auto-derives a `--no-` negation, so this flag name + * produces `--no-no-descriptions` as a working (if odd) way to re-enable + * descriptions. Harmless — it resolves to the same `false` default — and + * not something cobra does, so there's no parity requirement to remove it. + */ +export const LegacyCompletionNoDescriptionsFlagDef = Flag.boolean("no-descriptions").pipe( + Flag.withDescription("disable completion descriptions"), +); 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 2d22d4cd6e..33ef664d5f 100644 --- a/apps/cli/src/legacy/commands/completion/fish/fish.command.ts +++ b/apps/cli/src/legacy/commands/completion/fish/fish.command.ts @@ -1,8 +1,11 @@ import { Command } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; +import { LegacyCompletionNoDescriptionsFlagDef } from "../completion.flags.ts"; import { legacyCompletionFish } from "./fish.handler.ts"; -const config = {}; +const config = { + noDescriptions: LegacyCompletionNoDescriptionsFlagDef, +} as const; export type LegacyCompletionFishFlags = CliCommand.Command.Config.Infer; export const legacyCompletionFishCommand = Command.make("fish", config).pipe( 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 c293d2e3b4..0157d9cc6a 100644 --- a/apps/cli/src/legacy/commands/completion/fish/fish.handler.ts +++ b/apps/cli/src/legacy/commands/completion/fish/fish.handler.ts @@ -3,8 +3,10 @@ import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; import type { LegacyCompletionFishFlags } from "./fish.command.ts"; export const legacyCompletionFish = Effect.fn("legacy.completion.fish")(function* ( - _flags: LegacyCompletionFishFlags, + flags: LegacyCompletionFishFlags, ) { const proxy = yield* LegacyGoProxy; - yield* proxy.exec(["completion", "fish"]); + const args: string[] = ["completion", "fish"]; + if (flags.noDescriptions) args.push("--no-descriptions"); + yield* proxy.exec(args); }); 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 2f441b4fbb..ba22430863 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,6 +1,8 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, Layer } from "effect"; +import { Command } from "effect/unstable/cli"; import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { legacyCompletionFishCommand } from "./fish.command.ts"; import { legacyCompletionFish } from "./fish.handler.ts"; function setupLegacyCompletionFish() { @@ -15,12 +17,35 @@ function setupLegacyCompletionFish() { return { layer, calls }; } +function legacyTestRoot() { + return Command.make("supabase").pipe(Command.withSubcommands([legacyCompletionFishCommand])); +} + describe("legacy completion fish", () => { it.live("forwards `completion fish` to the Go binary", () => { const { layer, calls } = setupLegacyCompletionFish(); return Effect.gen(function* () { - yield* legacyCompletionFish({}); + yield* legacyCompletionFish({ noDescriptions: false }); expect(calls).toEqual([["completion", "fish"]]); }).pipe(Effect.provide(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("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; + }); }); 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 2034dc3f80..72dd479f3f 100644 --- a/apps/cli/src/legacy/commands/completion/powershell/powershell.command.ts +++ b/apps/cli/src/legacy/commands/completion/powershell/powershell.command.ts @@ -1,8 +1,11 @@ import { Command } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; +import { LegacyCompletionNoDescriptionsFlagDef } from "../completion.flags.ts"; import { legacyCompletionPowershell } from "./powershell.handler.ts"; -const config = {}; +const config = { + noDescriptions: LegacyCompletionNoDescriptionsFlagDef, +} as const; export type LegacyCompletionPowershellFlags = CliCommand.Command.Config.Infer; export const legacyCompletionPowershellCommand = Command.make("powershell", config).pipe( 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 9410dccccb..8b1d056431 100644 --- a/apps/cli/src/legacy/commands/completion/powershell/powershell.handler.ts +++ b/apps/cli/src/legacy/commands/completion/powershell/powershell.handler.ts @@ -3,8 +3,10 @@ import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; import type { LegacyCompletionPowershellFlags } from "./powershell.command.ts"; export const legacyCompletionPowershell = Effect.fn("legacy.completion.powershell")(function* ( - _flags: LegacyCompletionPowershellFlags, + flags: LegacyCompletionPowershellFlags, ) { const proxy = yield* LegacyGoProxy; - yield* proxy.exec(["completion", "powershell"]); + const args: string[] = ["completion", "powershell"]; + if (flags.noDescriptions) args.push("--no-descriptions"); + yield* proxy.exec(args); }); 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 056c856dcb..da678deeb2 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,6 +1,8 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, Layer } from "effect"; +import { Command } from "effect/unstable/cli"; import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { legacyCompletionPowershellCommand } from "./powershell.command.ts"; import { legacyCompletionPowershell } from "./powershell.handler.ts"; function setupLegacyCompletionPowershell() { @@ -15,12 +17,37 @@ function setupLegacyCompletionPowershell() { return { layer, calls }; } +function legacyTestRoot() { + return Command.make("supabase").pipe( + Command.withSubcommands([legacyCompletionPowershellCommand]), + ); +} + describe("legacy completion powershell", () => { it.live("forwards `completion powershell` to the Go binary", () => { const { layer, calls } = setupLegacyCompletionPowershell(); return Effect.gen(function* () { - yield* legacyCompletionPowershell({}); + yield* legacyCompletionPowershell({ noDescriptions: false }); expect(calls).toEqual([["completion", "powershell"]]); }).pipe(Effect.provide(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("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; + }); }); 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 fdda3df9e1..8951d805f6 100644 --- a/apps/cli/src/legacy/commands/completion/zsh/zsh.command.ts +++ b/apps/cli/src/legacy/commands/completion/zsh/zsh.command.ts @@ -1,8 +1,11 @@ import { Command } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; +import { LegacyCompletionNoDescriptionsFlagDef } from "../completion.flags.ts"; import { legacyCompletionZsh } from "./zsh.handler.ts"; -const config = {}; +const config = { + noDescriptions: LegacyCompletionNoDescriptionsFlagDef, +} as const; export type LegacyCompletionZshFlags = CliCommand.Command.Config.Infer; export const legacyCompletionZshCommand = Command.make("zsh", config).pipe( 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 10ca64ae3d..472f1918bc 100644 --- a/apps/cli/src/legacy/commands/completion/zsh/zsh.handler.ts +++ b/apps/cli/src/legacy/commands/completion/zsh/zsh.handler.ts @@ -3,8 +3,10 @@ import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; import type { LegacyCompletionZshFlags } from "./zsh.command.ts"; export const legacyCompletionZsh = Effect.fn("legacy.completion.zsh")(function* ( - _flags: LegacyCompletionZshFlags, + flags: LegacyCompletionZshFlags, ) { const proxy = yield* LegacyGoProxy; - yield* proxy.exec(["completion", "zsh"]); + const args: string[] = ["completion", "zsh"]; + if (flags.noDescriptions) args.push("--no-descriptions"); + yield* proxy.exec(args); }); 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 f15c1d9a41..9745c1a1e4 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,6 +1,8 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, Layer } from "effect"; +import { Command } from "effect/unstable/cli"; import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { legacyCompletionZshCommand } from "./zsh.command.ts"; import { legacyCompletionZsh } from "./zsh.handler.ts"; function setupLegacyCompletionZsh() { @@ -15,12 +17,35 @@ function setupLegacyCompletionZsh() { return { layer, calls }; } +function legacyTestRoot() { + return Command.make("supabase").pipe(Command.withSubcommands([legacyCompletionZshCommand])); +} + describe("legacy completion zsh", () => { it.live("forwards `completion zsh` to the Go binary", () => { const { layer, calls } = setupLegacyCompletionZsh(); return Effect.gen(function* () { - yield* legacyCompletionZsh({}); + yield* legacyCompletionZsh({ noDescriptions: false }); expect(calls).toEqual([["completion", "zsh"]]); }).pipe(Effect.provide(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("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; + }); }); diff --git a/apps/cli/src/legacy/commands/config/push/push.command.ts b/apps/cli/src/legacy/commands/config/push/push.command.ts index 744b143201..05854892e9 100644 --- a/apps/cli/src/legacy/commands/config/push/push.command.ts +++ b/apps/cli/src/legacy/commands/config/push/push.command.ts @@ -1,7 +1,9 @@ +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 { stdinLayer } from "../../../../shared/runtime/stdin.layer.ts"; import { legacyManagementApiRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts"; import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; import { legacyConfigPush } from "./push.handler.ts"; @@ -34,5 +36,5 @@ export const legacyConfigPushCommand = Command.make("push", config).pipe( withJsonErrorHandling, ), ), - Command.provide(legacyManagementApiRuntimeLayer(["config", "push"])), + Command.provide(Layer.mergeAll(legacyManagementApiRuntimeLayer(["config", "push"]), stdinLayer)), ); diff --git a/apps/cli/src/legacy/commands/config/push/push.handler.ts b/apps/cli/src/legacy/commands/config/push/push.handler.ts index 7624c2c23e..ebd07baabc 100644 --- a/apps/cli/src/legacy/commands/config/push/push.handler.ts +++ b/apps/cli/src/legacy/commands/config/push/push.handler.ts @@ -1,14 +1,16 @@ -import { loadProjectConfig } from "@supabase/config"; -import { Effect } from "effect"; +import { findProjectRoot, loadProjectConfig } from "@supabase/config"; +import { Effect, FileSystem, Path } from "effect"; import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; -import { legacyResolveYes } from "../../../../shared/legacy/global-flags.ts"; +import { 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 { legacyLoadProjectEnv } from "../../../shared/legacy-db-config.toml-read.ts"; import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; +import { legacyPromptYesNo } from "../../../shared/legacy-prompt-yes-no.ts"; import { apiSubsetFromConfig, apiToUpdateBody, diffApiWithRemote } from "./config-sync/api.sync.ts"; import { applyRemoteAuthConfig, @@ -87,8 +89,18 @@ export const legacyConfigPush = Effect.fn("legacy.config.push")(function* ( const linkedProjectCache = yield* LegacyLinkedProjectCache; const telemetryState = yield* LegacyTelemetryState; const runtimeInfo = yield* RuntimeInfo; - // `--yes` OR `SUPABASE_YES` (Go's viper AutomaticEnv, root.go:318-320). - const yes = yield* legacyResolveYes; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + // `--yes` OR `SUPABASE_YES` (Go's viper AutomaticEnv, root.go:318-320). Go's + // `config push` runs `flags.LoadConfig`, which imports `supabase/.env` before + // `PromptYesNo` reads `viper.GetBool("YES")`, so a `SUPABASE_YES` set only in + // `supabase/.env` auto-confirms. Resolve against the project env, not just the + // flag + shell env. Load it from the resolved project root (walking up, same as + // `loadProjectConfig` below and Go's `ChangeWorkDir` before `LoadConfig`), so a + // push from a subdirectory still reads the project root's `supabase/.env`. + const projectRoot = (yield* findProjectRoot(runtimeInfo.cwd)) ?? runtimeInfo.cwd; + const projectEnv = yield* legacyLoadProjectEnv(fs, path, projectRoot); + const yes = yield* legacyResolveYesWithProjectEnv(projectEnv); const ref = yield* resolver.resolve(flags.projectRef); @@ -154,24 +166,17 @@ export const legacyConfigPush = Effect.fn("legacy.config.push")(function* ( yield* output.raw(`Pushing config to project: ${projectId}\n`, "stderr"); - // keep(name): Go push.go `keep` + console.PromptYesNo(title, true). - const keep = (name: string): Effect.Effect => + // keep(name): Go push.go `keep` + console.PromptYesNo(title, true). The shared + // helper mirrors Go's prompt across all modes, including scanning piped stdin on + // a non-TTY before falling back to the default (`console.go:64-82`). + const keep = (name: string) => Effect.gen(function* () { const item = cost.get(name); const title = item === undefined ? `Do you want to push ${name} config to remote?` : `Enabling ${item.name} will cost you ${item.price}. Keep it enabled?`; - if (output.format !== "text") { - return true; - } - if (yes) { - yield* output.raw(`${title} [Y/n] y\n`, "stderr"); - return true; - } - return yield* output - .promptConfirm(title, { defaultValue: true }) - .pipe(Effect.orElseSucceed(() => true)); + return yield* legacyPromptYesNo(output, yes, title, true); }); const services: Array = []; diff --git a/apps/cli/src/legacy/commands/config/push/push.integration.test.ts b/apps/cli/src/legacy/commands/config/push/push.integration.test.ts index 0cc0ffe678..02ccd9d77c 100644 --- a/apps/cli/src/legacy/commands/config/push/push.integration.test.ts +++ b/apps/cli/src/legacy/commands/config/push/push.integration.test.ts @@ -17,7 +17,7 @@ import { mockLegacyTelemetryStateTracked, useLegacyTempWorkdir, } from "../../../../../tests/helpers/legacy-mocks.ts"; -import { mockRuntimeInfo } from "../../../../../tests/helpers/mocks.ts"; +import { mockRuntimeInfo, mockStdin, mockTty } from "../../../../../tests/helpers/mocks.ts"; import { LegacyYesFlag } from "../../../../shared/legacy/global-flags.ts"; import { legacyConfigPush } from "./push.handler.ts"; @@ -37,6 +37,7 @@ const POSTGREST_DISABLED = { db_extra_search_path: "", max_rows: 0, db_pool: null, + db_pool_acquisition_timeout: null, }; /** Routes mock HTTP responses by URL path so a single handler serves every endpoint. */ @@ -57,6 +58,12 @@ function setup(opts: { readonly yes?: boolean; readonly confirm?: ReadonlyArray; readonly promptFail?: boolean; + /** stdin interactivity; defaults to a TTY so prompt-driven tests reach the confirm. */ + readonly stdinIsTty?: boolean; + /** Piped (non-TTY) stdin answers, one consumed per confirmation prompt. */ + readonly pipedAnswers?: ReadonlyArray; + /** Working directory the handler runs from; defaults to the temp project root. */ + readonly runtimeCwd?: string; }) { writeConfig(opts.toml); const routes = opts.routes ?? {}; @@ -111,10 +118,15 @@ function setup(opts: { out, api, cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), - runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), + runtimeInfo: mockRuntimeInfo({ cwd: opts.runtimeCwd ?? tempRoot.current }), telemetry: telemetry.layer, linkedProjectCache: linkedProjectCache.layer, + tty: mockTty({ stdinIsTty: opts.stdinIsTty ?? true, stdoutIsTty: false }), }), + mockStdin( + opts.stdinIsTty ?? true, + opts.pipedAnswers ? `${opts.pipedAnswers.join("\n")}\n` : undefined, + ), Layer.succeed(LegacyYesFlag, opts.yes ?? false), ); return { layer, out, api, telemetry, linkedProjectCache }; @@ -248,6 +260,7 @@ project_id = "abcdefghijklmnopqrst" db_extra_search_path: "public,extensions", max_rows: 1000, db_pool: null, + db_pool_acquisition_timeout: null, jwt_secret: "x", }, }, @@ -296,10 +309,13 @@ project_id = "abcdefghijklmnopqrst" }).pipe(Effect.provide(layer)); }); - it.live("defaults to yes in non-TTY text without --yes", () => { - const { layer, api } = setup({ + it.live("defaults to yes on empty non-TTY stdin, echoing the prompt", () => { + // Go's `PromptYesNo(..., true)` (`push.go:36`) prints the label and scans + // stdin even on a non-terminal (`console.go:96-102`); with no piped input the + // scan is empty and it falls back to the default (`true`), so the push proceeds. + const { layer, api, out } = setup({ toml: API_ONLY_TOML, - promptFail: true, + stdinIsTty: false, routes: { postgrestGet: { status: 200, body: POSTGREST_DISABLED }, postgresGet: { status: 200, body: {} }, @@ -310,9 +326,102 @@ project_id = "abcdefghijklmnopqrst" expect(api.requests.some((r) => r.method === "PATCH" && r.url.includes("/postgrest"))).toBe( true, ); + // Label printed + empty answer echoed (Go's non-TTY `PromptText`). + expect(out.stderrText).toContain("Do you want to push api config to remote? [Y/n] \n"); }).pipe(Effect.provide(layer)); }); + it.live("honors a piped 'n' decline on non-TTY stdin (no update)", () => { + // Regression: Go scans piped stdin before defaulting (`console.go:74-82`), so a + // piped `n` cancels the push even on a non-terminal — it must not silently apply. + const { layer, api, out } = setup({ + toml: API_ONLY_TOML, + stdinIsTty: false, + pipedAnswers: ["n"], + routes: { + postgrestGet: { status: 200, body: POSTGREST_DISABLED }, + postgresGet: { status: 200, body: {} }, + }, + }); + return Effect.gen(function* () { + yield* legacyConfigPush({ projectRef: Option.none() }); + expect(api.requests.some((r) => r.method === "PATCH" && r.url.includes("/postgrest"))).toBe( + false, + ); + // The consumed answer is echoed to stderr (Go's non-TTY `PromptText`). + expect(out.stderrText).toContain("Do you want to push api config to remote? [Y/n] n"); + }).pipe(Effect.provide(layer)); + }); + + it.live("honors SUPABASE_YES from supabase/.env even against a piped 'n'", () => { + // Go's config push runs `flags.LoadConfig`, importing `supabase/.env` before + // `PromptYesNo`, so a project-local `SUPABASE_YES=true` auto-confirms before + // stdin is read — the push proceeds despite the piped `n`. + const prev = process.env["SUPABASE_YES"]; + delete process.env["SUPABASE_YES"]; + const { layer, api } = setup({ + toml: API_ONLY_TOML, + stdinIsTty: false, + pipedAnswers: ["n"], + routes: { + postgrestGet: { status: 200, body: POSTGREST_DISABLED }, + postgresGet: { status: 200, body: {} }, + }, + }); + // Written after setup()'s writeConfig created supabase/. + writeFileSync(join(tempRoot.current, "supabase", ".env"), "SUPABASE_YES=true\n"); + return Effect.gen(function* () { + yield* legacyConfigPush({ projectRef: Option.none() }); + expect(api.requests.some((r) => r.method === "PATCH" && r.url.includes("/postgrest"))).toBe( + true, + ); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (prev === undefined) delete process.env["SUPABASE_YES"]; + else process.env["SUPABASE_YES"] = prev; + }), + ), + Effect.provide(layer), + ); + }); + + it.live("loads config-push env from the project root when run from a subdirectory", () => { + // Go's ChangeWorkDir moves to the project root before flags.LoadConfig, so a + // SUPABASE_YES in /supabase/.env auto-confirms even when invoked from a + // subdir. The env load must walk up like loadProjectConfig, not use the raw cwd. + const prev = process.env["SUPABASE_YES"]; + delete process.env["SUPABASE_YES"]; + const sub = join(tempRoot.current, "nested", "dir"); + mkdirSync(sub, { recursive: true }); + const { layer, api } = setup({ + toml: API_ONLY_TOML, + stdinIsTty: false, + pipedAnswers: ["n"], + runtimeCwd: sub, + routes: { + postgrestGet: { status: 200, body: POSTGREST_DISABLED }, + postgresGet: { status: 200, body: {} }, + }, + }); + // `.env` lives at the project ROOT (setup's writeConfig wrote config.toml there). + writeFileSync(join(tempRoot.current, "supabase", ".env"), "SUPABASE_YES=true\n"); + return Effect.gen(function* () { + yield* legacyConfigPush({ projectRef: Option.none() }); + expect(api.requests.some((r) => r.method === "PATCH" && r.url.includes("/postgrest"))).toBe( + true, + ); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (prev === undefined) delete process.env["SUPABASE_YES"]; + else process.env["SUPABASE_YES"] = prev; + }), + ), + Effect.provide(layer), + ); + }); + it.live("emits a structured summary in json mode without prompts", () => { const { layer, out } = setup({ toml: API_ONLY_TOML, @@ -394,6 +503,7 @@ file_size_limit = "50MiB" cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), }), + mockStdin(true), Layer.succeed(LegacyYesFlag, true), ); return Effect.gen(function* () { @@ -462,7 +572,10 @@ function setupService(opts: { runtimeInfo: mockRuntimeInfo({ cwd: opts.runtimeCwd ?? tempRoot.current }), telemetry: telemetry.layer, linkedProjectCache: linkedProjectCache.layer, + // Gated-service prompts model an interactive user answering via `confirm`. + tty: mockTty({ stdinIsTty: true, stdoutIsTty: false }), }), + mockStdin(true), Layer.succeed(LegacyYesFlag, opts.yes ?? false), ); return { layer, out, apiMock }; 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 bc2d759e52..0278e7e41e 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 @@ -157,7 +157,10 @@ function setup(workdir: string, opts: SetupOpts = {}) { LegacyNetworkIdFlag, opts.networkId === undefined ? Option.none() : Option.some(opts.networkId), ), - Layer.succeed(LegacyPgDeltaSslProbe, { requireSsl: () => Effect.succeed(false) }), + Layer.succeed(LegacyPgDeltaSslProbe, { + requireSsl: () => Effect.succeed(false), + requireSslForHost: () => Effect.succeed(false), + }), mockRuntimeInfo(), BunServices.layer, ); diff --git a/apps/cli/src/legacy/commands/db/dump/dump.handler.ts b/apps/cli/src/legacy/commands/db/dump/dump.handler.ts index 0a7ae2202b..0f72399b85 100644 --- a/apps/cli/src/legacy/commands/db/dump/dump.handler.ts +++ b/apps/cli/src/legacy/commands/db/dump/dump.handler.ts @@ -5,22 +5,20 @@ import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-proje import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; import type { LegacyDbConnType } from "../../../shared/legacy-db-target-flags.ts"; -import { legacyReadDbToml } from "../../../shared/legacy-db-config.toml-read.ts"; +import { + legacyApplyProjectEnv, + legacyLoadProjectEnv, + legacyReadDbToml, +} from "../../../shared/legacy-db-config.toml-read.ts"; import { legacyReadProjectRefFile } from "../../../shared/legacy-temp-paths.ts"; import { legacyResolveDbImage } from "../../../shared/legacy-db-image.ts"; -import { LegacyDockerRun } from "../../../shared/legacy-docker-run.service.ts"; -import { legacyGetRegistryImageUrl } from "../../../shared/legacy-docker-registry.ts"; import { legacyIpv6Suggestion, legacyIsIPv6ConnectivityError, } from "../../../shared/legacy-connect-errors.ts"; -import { legacyBold, legacyYellow } from "../../../shared/legacy-colors.ts"; -import { - LegacyDnsResolverFlag, - LegacyNetworkIdFlag, -} from "../../../../shared/legacy/global-flags.ts"; +import { legacyBold } from "../../../shared/legacy-colors.ts"; +import { LegacyDnsResolverFlag } from "../../../../shared/legacy/global-flags.ts"; import { Output } from "../../../../shared/output/output.service.ts"; -import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; import type { LegacyDbDumpFlags } from "./dump.command.ts"; import { LegacyDbDumpMutuallyExclusiveFlagsError, @@ -33,12 +31,14 @@ import { legacyBuildRoleDumpEnv, legacyBuildSchemaDumpEnv, legacyExpandScript, -} from "./dump.env.ts"; +} from "../shared/legacy-pg-dump.env.ts"; +import { legacyStreamPgDump } from "../shared/legacy-pg-dump.run.ts"; +import { legacyRunWithPoolerFallback } from "../shared/legacy-pooler-fallback.ts"; import { legacyDumpDataScript, legacyDumpRoleScript, legacyDumpSchemaScript, -} from "./dump.scripts.ts"; +} from "../shared/legacy-pg-dump.scripts.ts"; /** * Mutually-exclusive flag groups, in cobra's check order (it sorts the joined @@ -62,15 +62,12 @@ const toOpenFileError = (cause: { readonly message: string }) => export const legacyDbDump = Effect.fn("legacy.db.dump")(function* (flags: LegacyDbDumpFlags) { const output = yield* Output; const resolver = yield* LegacyDbConfigResolver; - const docker = yield* LegacyDockerRun; const cliConfig = yield* LegacyCliConfig; - const runtimeInfo = yield* RuntimeInfo; const telemetryState = yield* LegacyTelemetryState; const linkedProjectCache = yield* LegacyLinkedProjectCache; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const dnsResolver = yield* LegacyDnsResolverFlag; - const networkIdFlag = yield* LegacyNetworkIdFlag; // Resolved linked ref, captured so the post-run finalizer can cache the project // (GET /v1/projects/{ref}) AFTER the command's own API calls — matching Go's @@ -78,6 +75,13 @@ export const legacyDbDump = Effect.fn("legacy.db.dump")(function* (flags: Legacy let linkedRefForCache: string | undefined; yield* Effect.gen(function* () { + // Make an allowlisted `supabase/.env` registry override visible to the + // synchronous `process.env` reader in `legacyGetRegistryImageUrl` (the pg_dump + // image), reverted when this scope closes. Go's `loadNestedEnv` `os.Setenv`s the + // project `.env`; the pure `legacyLoadProjectEnv` no longer does that as a side + // effect of `resolveDbPassword`, so `db dump` opts in explicitly here. + yield* legacyApplyProjectEnv(yield* legacyLoadProjectEnv(fs, path, cliConfig.workdir)); + // The grouped boolean flags are modelled as `Option` (presence = pflag `Changed`) // for the mutex/target checks; resolve their effective values here for the places // that consume the value (Go's `BoolVar` default is false). @@ -138,7 +142,6 @@ export const legacyDbDump = Effect.fn("legacy.db.dump")(function* (flags: Legacy // linked, defaulting to linked when neither local nor db-url is set // (`internal/utils/flags/db_url.go:46-62`). const useLocal = Option.isNone(flags.dbUrl) && Option.isSome(flags.local); - const useLinked = Option.isNone(flags.dbUrl) && Option.isNone(flags.local); // `connType` selects the resolver branch (Go's Changed-first precedence): a // `--db-url` wins, then explicit `--local`; otherwise dump defaults to linked // (unlike the other db commands, whose unset default is local). @@ -190,7 +193,7 @@ export const legacyDbDump = Effect.fn("legacy.db.dump")(function* (flags: Legacy // remote `project_id`) fails rather than silently printing a script. const tomlValues = yield* legacyReadDbToml(fs, path, cliConfig.workdir, linkedRef); - // 4. Pick the mode-specific script + env (pure builders, `dump.env.ts`). + // 4. Pick the mode-specific script + env (pure builders, `legacy-pg-dump.env.ts`). // Go declares --schema/-s and --exclude/-x as cobra StringSlice // (`apps/cli-go/cmd/db.go:432,444`); both flags are CSV-parsed at the flag // level via `legacyParseSchemaFlags` (pflag `readAsCSV` semantics, quoted @@ -267,33 +270,14 @@ export const legacyDbDump = Effect.fn("legacy.db.dump")(function* (flags: Legacy // 6. Diagnostic to stderr (Go writes this for both real and dry-run paths). yield* output.raw(`Dumping ${mode.verb} from ${db} database...\n`, "stderr"); - // 7. Run the pg_dump container, capturing stdout. dump always uses host - // networking (`dockerExec` sets `NetworkMode: NetworkHost`), overridden only - // by `--network-id` (Go's `DockerStart`). No `SecurityOpt` is set. - const networkId = Option.getOrUndefined(networkIdFlag); - const network = - networkId !== undefined && networkId.length > 0 - ? { _tag: "named" as const, name: networkId } - : { _tag: "host" as const }; - const extraHosts = - runtimeInfo.platform === "linux" ? ["host.docker.internal:host-gateway"] : []; - - const dockerOpts = (env: Readonly>) => ({ - image: legacyGetRegistryImageUrl(image), - cmd: ["bash", "-c", mode.script, "--"], - env, - binds: [], - workingDir: Option.none(), - securityOpt: [], - extraHosts, - network, - }); - + // 7. Run the pg_dump container, streaming stdout. `legacyStreamPgDump` applies + // the registry mirror + host networking (overridden by `--network-id`) and + // tees stderr, mirroring Go's `dockerExec` (`internal/db/dump/dump.go`). + // // Go streams pg_dump stdout straight to the destination sink (the `--file` handle // or `os.Stdout`) via `stdcopy.StdCopy` with `Follow:true`, at constant memory // (`apps/cli-go/internal/utils/docker.go:374,394`). Mirror that: write each chunk - // to the destination as it arrives instead of buffering the whole dump. stderr is - // teed live (Go's `io.MultiWriter(os.Stderr, errBuf)`). + // to the destination as it arrives instead of buffering the whole dump. const runContainer = (env: Readonly>) => Option.isSome(resolvedFile) ? // `--file`: (re)truncate then append-stream. Truncating per attempt @@ -309,10 +293,12 @@ export const legacyDbDump = Effect.fn("legacy.db.dump")(function* (flags: Legacy const file = yield* fs .open(resolvedFile.value, { flag: "a" }) .pipe(Effect.mapError(toOpenFileError)); - return yield* docker.runStream(dockerOpts(env), { + return yield* legacyStreamPgDump({ + image, + script: mode.script, + env, onStdout: (chunk) => file.writeAll(chunk).pipe(Effect.mapError(toOpenFileError)), - teeStderr: true, }); }), ), @@ -321,53 +307,40 @@ export const legacyDbDump = Effect.fn("legacy.db.dump")(function* (flags: Legacy : // stdout: write each chunk straight to stdout (binary-safe, no decode). // On a pooler retry Go leaves the partial first-attempt bytes on stdout // (its `resetOutput` can't rewind a pipe); streaming matches that. - docker.runStream(dockerOpts(env), { + legacyStreamPgDump({ + image, + script: mode.script, + env, onStdout: (chunk) => output.rawBytes(chunk), - teeStderr: true, }); - let result = yield* runContainer(modeEnv); - - // 7b. Container-level pooler fallback (Go's `RunWithPoolerFallback`, - // `internal/db/dump/pooler_fallback.go`). A linked dump can reach the direct - // host from the CLI process (so the resolver returned the direct conn) yet - // fail from inside the pg_dump container on an IPv6-only Docker network. When - // the captured container stderr classifies as an IPv6 connectivity error, - // retry once through the project's IPv4 transaction pooler. Gated to the - // `--linked` path with a direct `db..` connection (Go's - // `PoolerFallbackEligible` + `ProjectRefFromDirectDbHost`). - if ( - result.exitCode !== 0 && - useLinked && - !isLocal && - conn.host.startsWith("db.") && - conn.host.endsWith(`.${cliConfig.projectHost}`) && - legacyIsIPv6ConnectivityError(result.stderr) - ) { - // Go's `PoolerFallbackConfig` returns `ok=false` on ANY fallback-resolution - // error (e.g. temp-role creation/wait fails) and then reports the ORIGINAL - // pg_dump failure with the IPv6 guidance — the optional retry must not replace - // the actionable dump error. So a resolution failure is treated as "no - // fallback" (the original `result` is surfaced at step 9). - const pooler = yield* resolver - .resolvePoolerFallback({ - dbUrl: flags.dbUrl, - connType: "linked", - dnsResolver, - password: flags.password, - }) - .pipe(Effect.orElseSucceed(() => Option.none())); - if (Option.isSome(pooler)) { - yield* output.raw( - `${legacyYellow( - `Warning: Direct connection to ${conn.host} is unavailable because this environment does not support IPv6.\nRetrying via the IPv4 connection pooler.`, - )}\n`, - "stderr", - ); - yield* output.raw(`Dumping ${mode.verb} from ${db} database...\n`, "stderr"); - result = yield* runContainer(mode.buildEnv(pooler.value, opt)); - } - } + // 7b. Container-level IPv6 → IPv4-pooler retry (Go's `RunWithPoolerFallback`, + // `internal/db/dump/pooler_fallback.go`), shared with `db pull`. A linked dump + // can reach the direct host from the CLI process (so the resolver returned the + // direct conn) yet fail from inside the pg_dump container on an IPv6-only Docker + // network. `resolvePoolerFallback` is neutralised to `None` on any resolution + // error so the original, actionable pg_dump failure is surfaced at step 9 rather + // than a fallback-setup error (Go's `PoolerFallbackConfig` ok=false path). + // `db dump` re-prints the "Dumping …" line on the retry (Go prints it inside the + // run closure, `dump.go:39-45`). + const result = yield* legacyRunWithPoolerFallback({ + result: yield* runContainer(modeEnv), + connType, + host: conn.host, + isLocal, + projectHost: cliConfig.projectHost, + resolvePooler: () => + resolver + .resolvePoolerFallback({ + dbUrl: flags.dbUrl, + connType: "linked", + dnsResolver, + password: flags.password, + }) + .pipe(Effect.orElseSucceed(() => Option.none())), + runWithConn: (c) => runContainer(mode.buildEnv(c, opt)), + reprintOnRetry: output.raw(`Dumping ${mode.verb} from ${db} database...\n`, "stderr"), + }); // 8. The dump has already been streamed to the destination by `runContainer` // (to `--file` or stdout) as pg_dump produced it. @@ -407,5 +380,8 @@ export const legacyDbDump = Effect.fn("legacy.db.dump")(function* (flags: Legacy ), ), Effect.ensuring(telemetryState.flush), + // Scope the `SUPABASE_INTERNAL_IMAGE_REGISTRY`-from-`.env` apply above to this + // command run: `legacyApplyProjectEnv` registers a finalizer that reverts it. + Effect.scoped, ); }); diff --git a/apps/cli/src/legacy/commands/db/dump/dump.live.test.ts b/apps/cli/src/legacy/commands/db/dump/dump.live.test.ts new file mode 100644 index 0000000000..6577c268b5 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/dump/dump.live.test.ts @@ -0,0 +1,48 @@ +import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; + +import { + describeLiveDataPlane, + requireLiveProjectRef, + runSupabaseLive, +} from "../../../../../tests/helpers/live.ts"; + +const LIVE_TIMEOUT_MS = 300_000; + +// A fresh, isolated temp workdir so the CLI writes the dump there and never touches +// the repo tree. The provisioned project ref is supplied to `--linked` via the +// `SUPABASE_PROJECT_ID` env var — that is the `--linked` resolver chain in both Go +// and the legacy port (flag → `SUPABASE_PROJECT_ID` → `supabase/.temp/project-ref`); +// `config.toml`'s `project_id` is NOT consulted for `--linked`. +function tempWorkdir(): string { + return mkdtempSync(join(tmpdir(), "sb-db-dump-live-")); +} + +// Data-plane: needs a provisioned project whose database is routable (the +// cli-e2e-ci Linux runner). `describeLiveDataPlane` runs this only when the project +// instance is ACTIVE_HEALTHY, so a control-plane-only stack (ref set but the DB +// unreachable, e.g. local macOS or the current cli-e2e-ci control-plane case) is +// skipped rather than timing out on pg_dump. +describeLiveDataPlane("supabase db dump (live)", () => { + test("dumps the linked project's schema to a file", { timeout: LIVE_TIMEOUT_MS }, async () => { + const ref = requireLiveProjectRef(); + const dir = tempWorkdir(); + try { + const outFile = join(dir, "schema.sql"); + const { exitCode, stdout, stderr } = await runSupabaseLive( + ["db", "dump", "--linked", "-f", outFile], + { cwd: dir, env: { SUPABASE_PROJECT_ID: ref }, exitTimeoutMs: LIVE_TIMEOUT_MS - 20_000 }, + ); + expect(`${stdout}${stderr}`).not.toContain("Unauthorized"); + expect(exitCode).toBe(0); + // The native pg_dump container (shared `legacyStreamPgDump`) opened + wrote + // the dump file. A fresh project's public schema may be near-empty, so assert + // the file was created rather than its size. + expect(existsSync(outFile)).toBe(true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/cli/src/legacy/commands/db/pull/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/pull/SIDE_EFFECTS.md index c8ae8ac6e9..6ca74fd5f5 100644 --- a/apps/cli/src/legacy/commands/db/pull/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/pull/SIDE_EFFECTS.md @@ -2,9 +2,12 @@ Native Effect port. Pulls the remote schema into either a new timestamped migration (diffing a throwaway shadow against the remote, native pg-delta or -migra) or declarative files (`--declarative`, native pg-delta export). The rare -`--experimental` structured-dump and initial-pull `pg_dump` (migra) sub-branches -delegate to the bundled Go binary. +migra) or declarative files (`--declarative`, native pg-delta export). The +initial-migra pull (no local migrations) seeds the migration file with a native +`pg_dump` of the remote schema (a Docker `pg_dump` container, with IPv4 +transaction-pooler fallback) and then appends the migra diff. Only the rare +`--experimental` structured-dump sub-branch still delegates to the bundled Go +binary (it needs `format.WriteStructuredSchemas`, which has no TS port yet). ## Files Read @@ -17,18 +20,20 @@ delegate to the bundled Go binary. ## Files Written -| Path | Format | When | -| ----------------------------------------------------------- | ------ | ------------------------------------- | -| `/supabase/migrations/_.sql` | SQL | migration-style pull (non-empty diff) | -| `/supabase/database/**` | SQL | `--declarative` | -| `~/.supabase//linked-project.json` | JSON | linked (post-run cache) | -| `~/.supabase/telemetry.json` | JSON | every invocation (post-run) | +| Path | Format | When | +| ----------------------------------------------------------- | ------ | -------------------------------------------------------------------------- | +| `/supabase/migrations/_.sql` | SQL | migration-style pull (non-empty diff, or the initial-migra `pg_dump` seed) | +| `/supabase/database/**` | SQL | `--declarative` | +| `~/.supabase//linked-project.json` | JSON | linked (post-run cache) | +| `~/.supabase/telemetry.json` | JSON | every invocation (post-run) | ## Docker - Edge-runtime container (pg-delta export / pg-delta or migra diff). - Shadow Postgres container (provisioned + torn down via the Go `db __shadow` seam). - `supabase/migra` container — the migra OOM bash fallback only. +- `pg_dump` container — the initial-migra pull's native remote-schema dump + (`legacyStreamPgDump`, shared with `db dump`). ## API Routes / DB @@ -47,7 +52,7 @@ delegate to the bundled Go binary. | `SUPABASE_ACCESS_TOKEN` | auth for the linked target | no | | `SUPABASE_DB_PASSWORD` | remote DB password (overridden by `-p`) | no | | `SUPABASE_EXPERIMENTAL_PG_DELTA` | force pg-delta diff engine | no | -| `SUPABASE_EXPERIMENTAL` | structured-dump pull branch (delegates to Go) | no | +| `SUPABASE_EXPERIMENTAL` | structured-dump pull branch (still delegates) | no | | `PGDELTA_NPM_REGISTRY` | scoped npm registry for edge-runtime | no | ## Exit Codes @@ -82,7 +87,12 @@ Progress strings still go to stderr; stdout carries a single structured envelope - `--declarative` / deprecated `--use-pg-delta` are mutually exclusive with `--diff-engine`; `--db-url` / `--linked` (default) / `--local` are a target group. - `--use-pg-delta` is hidden and emits the cobra deprecation line to stderr. -- The `--experimental` structured-dump branch and the initial-pull `pg_dump` (migra, - no local migrations) rebuild the argv and exec the bundled Go binary (their side - effects are Go's); the Go child's telemetry is disabled so the single - `cli_command_executed` event comes from this TS command. +- The initial-migra pull (no local migrations) is native: it streams a `pg_dump` of + the remote schema into the migration file, then appends the migra diff. An empty + diff after a non-empty dump is swallowed (Go's `swallowInitialInSync`); an empty + dump + empty diff is "No schema changes found". +- The `--experimental` structured-dump branch still rebuilds the argv and execs the + bundled Go binary (its side effects are Go's), because Go's + `format.WriteStructuredSchemas` needs a PostgreSQL DDL AST parser that has no TS + port yet. The Go child's telemetry is disabled so the single `cli_command_executed` + event comes from this TS command. diff --git a/apps/cli/src/legacy/commands/db/pull/pull.errors.ts b/apps/cli/src/legacy/commands/db/pull/pull.errors.ts index eaf81d3da6..25c3c36300 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.errors.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.errors.ts @@ -49,3 +49,15 @@ export class LegacyDbPullInSyncError extends Data.TaggedError("LegacyDbPullInSyn export class LegacyDbPullWriteError extends Data.TaggedError("LegacyDbPullWriteError")<{ readonly message: string; }> {} + +/** + * The initial-pull pg_dump container exited non-zero. Go's `dumpRemoteSchema` + * (`internal/db/pull/pull.go:144-158`) propagates the `dump.RunWithPoolerFallback` + * error; byte-matches the dump's `"error running container: exit " + code`. Carries + * the same optional IPv6 transaction-pooler hint the dump path attaches, which + * `Output.fail` prints bare on stderr after the message. + */ +export class LegacyDbPullDumpError extends Data.TaggedError("LegacyDbPullDumpError")<{ + readonly message: string; + readonly suggestion?: string; +}> {} diff --git a/apps/cli/src/legacy/commands/db/pull/pull.handler.ts b/apps/cli/src/legacy/commands/db/pull/pull.handler.ts index aa7a44fc27..e95fc934f2 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.handler.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.handler.ts @@ -3,20 +3,27 @@ import { Clock, Effect, FileSystem, Option, Path } from "effect"; import { LegacyDnsResolverFlag, LegacyExperimentalFlag, - LegacyYesFlag, + legacyResolveYesWithProjectEnv, } from "../../../../shared/legacy/global-flags.ts"; import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; import { Output } from "../../../../shared/output/output.service.ts"; import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; -import { legacyAqua, legacyBold, legacyYellow } from "../../../shared/legacy-colors.ts"; -import { legacyIsIPv6ConnectivityError } from "../../../shared/legacy-connect-errors.ts"; +import { legacyAqua, legacyBold } from "../../../shared/legacy-colors.ts"; +import { legacyPromptYesNo } from "../../../shared/legacy-prompt-yes-no.ts"; +import { + legacyIpv6Suggestion, + legacyIsIPv6ConnectivityError, +} from "../../../shared/legacy-connect-errors.ts"; import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; +import { legacyResolveDbImage } from "../../../shared/legacy-db-image.ts"; import { LegacyDbConnection, type LegacyPgConnInput, } from "../../../shared/legacy-db-connection.service.ts"; import { + legacyApplyProjectEnv, + legacyLoadProjectEnv, legacyReadDbToml, legacyResolveDeclarativeDir, } from "../../../shared/legacy-db-config.toml-read.ts"; @@ -37,6 +44,14 @@ import { legacyShouldUsePgDelta, } from "../shared/legacy-diff-engine.ts"; import { legacyDiffMigra } from "../shared/legacy-migra.ts"; +import { type LegacyDumpOptions, legacyBuildSchemaDumpEnv } from "../shared/legacy-pg-dump.env.ts"; +import { legacyStreamPgDump } from "../shared/legacy-pg-dump.run.ts"; +import { + legacyEmitPoolerFallbackWarning, + legacyIsDirectLinkedHost, + legacyRunWithPoolerFallback, +} from "../shared/legacy-pooler-fallback.ts"; +import { legacyDumpSchemaScript } from "../shared/legacy-pg-dump.scripts.ts"; import { legacyFormatMigrationTimestamp, legacyGetMigrationPath, @@ -53,6 +68,7 @@ import { legacySaveEmptyPgDeltaPullDebug } from "./pull.debug.ts"; import { LegacyDeclarativeSeam } from "../shared/legacy-pgdelta.seam.service.ts"; import type { LegacyDbPullFlags } from "./pull.command.ts"; import { + LegacyDbPullDumpError, LegacyDbPullEngineConflictError, LegacyDbPullInSyncError, LegacyDbPullMigrationConflictError, @@ -71,6 +87,9 @@ import { legacyUpdateMigrationHistory } from "./pull.sync.ts"; const DEPRECATION_LINE = "Flag --use-pg-delta has been deprecated, use --declarative with [experimental.pgdelta] enabled = true in your config.toml instead."; +/** Migration-file mode for the initial pg_dump seed (Go's `OpenFile(..., 0644)`). */ +const MIGRATION_FILE_MODE = 0o644; + /** Builds a plain Postgres URL from a resolved connection (Go's `ToPostgresURL`). */ const connToUrl = (conn: LegacyPgConnInput): string => legacyToPostgresURL({ @@ -88,7 +107,7 @@ const connToUrl = (conn: LegacyPgConnInput): string => : {}), }); -/** Rebuilds the `db pull` argv for the Go-delegated branches (initial-migra / EXPERIMENTAL dump). */ +/** Rebuilds the `db pull` argv for the Go-delegated `--experimental` structured-dump branch. */ const rebuildDelegateArgs = (flags: LegacyDbPullFlags): Array => { const args = ["db", "pull"]; if (Option.isSome(flags.name)) args.push(flags.name.value); @@ -135,16 +154,27 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy const cliConfig = yield* LegacyCliConfig; const telemetryState = yield* LegacyTelemetryState; const linkedProjectCache = yield* LegacyLinkedProjectCache; - const yes = yield* LegacyYesFlag; const experimental = yield* LegacyExperimentalFlag; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const dnsResolver = yield* LegacyDnsResolverFlag; const cliArgs = yield* CliArgs; + // `--yes` OR `SUPABASE_YES` (Go's `viper.GetBool("YES")`, root.go:318-320). Go + // loads the project `.env` via `loadNestedEnv` inside `ParseDatabaseConfig` + // (config.go:701) before `PromptYesNo`, so a `SUPABASE_YES` set only in + // `supabase/.env` auto-confirms the native initial-migra history repair too. + const projectEnv = yield* legacyLoadProjectEnv(fs, path, cliConfig.workdir); + const yes = yield* legacyResolveYesWithProjectEnv(projectEnv); + let linkedRefForCache: string | undefined; yield* Effect.gen(function* () { + // Make an allowlisted `supabase/.env` registry override visible to the + // synchronous `process.env` reader in `legacyGetRegistryImageUrl` (the pg_dump + // seed + migra/pg-delta diff images), reverted when this scope closes. Go's + // `loadNestedEnv` `os.Setenv`s the project `.env` before the dump runs. + yield* legacyApplyProjectEnv(projectEnv); const name = Option.getOrElse(flags.name, () => "remote_schema"); // `--declarative` and the deprecated `--use-pg-delta` both bind to the same // `useDeclarative` variable in Go (`cmd/db.go:534-535`), so when BOTH are @@ -242,10 +272,12 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy Effect.catch((error) => Effect.gen(function* () { if ( - connType === "linked" && - !resolved.isLocal && - resolved.conn.host.startsWith("db.") && - resolved.conn.host.endsWith(`.${cliConfig.projectHost}`) && + legacyIsDirectLinkedHost({ + connType, + host: resolved.conn.host, + isLocal: resolved.isLocal, + projectHost: cliConfig.projectHost, + }) && legacyIsIPv6ConnectivityError(error.message) ) { // Go's `PoolerFallbackConfig` returns `ok=false` on ANY resolution @@ -260,12 +292,7 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy }) .pipe(Effect.orElseSucceed(() => Option.none())); if (Option.isSome(pooler)) { - yield* output.raw( - `${legacyYellow( - `Warning: Direct connection to ${resolved.conn.host} is unavailable because this environment does not support IPv6.\nRetrying via the IPv4 connection pooler.`, - )}\n`, - "stderr", - ); + yield* legacyEmitPoolerFallbackWarning(resolved.conn.host); return yield* attempt(connToUrl(pooler.value)); } } @@ -284,19 +311,15 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy }), }); - // Runs a Go-delegated pull (initial-migra / EXPERIMENTAL structured dump). In - // machine-output mode the child's stdout is captured and a structured envelope - // is emitted instead, so scripted callers get valid JSON rather than the Go - // child's human output on stdout (CLI-1546: stdout is payload-only in machine - // mode). The child is run with a non-TTY stdin (`"ignore"`) so the migration - // path's "Update remote migration history table?" prompt (Go's `PromptYesNo`, - // `internal/db/pull/pull.go:73`) takes its `true` default without blocking the - // JSON caller. `remoteHistoryUpdated` is passed per call site because the two - // delegated Go paths differ: the initial-migra path prompts + calls - // `repair.UpdateMigrationTable` (so `true`), while the EXPERIMENTAL structured - // dump returns before writing a migration or touching `schema_migrations` - // (`pull.go:49-61`, so `false`). `schemaWritten` stays `null` — the child owns - // the write and doesn't surface the path on stdout. + // Runs the Go-delegated `--experimental` structured dump (still delegated; see the + // EXPERIMENTAL branch below for why). In machine-output mode the child's stdout is + // captured and a structured envelope is emitted instead, so scripted callers get + // valid JSON rather than the Go child's human output on stdout (CLI-1546: stdout is + // payload-only in machine mode). The child is run with a non-TTY stdin (`"ignore"`) + // so any prompt takes its default without blocking the JSON caller. The EXPERIMENTAL + // structured dump returns before writing a migration or touching `schema_migrations` + // (`pull.go:49-61`), so `remoteHistoryUpdated` is `false`; `schemaWritten` stays + // `null` — the child owns the write and doesn't surface the path on stdout. const delegatePull = ( engine: "migra" | "pg-delta", opts: { readonly remoteHistoryUpdated: boolean }, @@ -383,11 +406,18 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy return; } - // Go's `EXPERIMENTAL` structured-dump branch depends on unported `pg_dump` - // — delegate the whole pull to Go. viper resolves `EXPERIMENTAL` from - // *either* the global `--experimental` pflag or `SUPABASE_EXPERIMENTAL` - // (`cmd/root.go:318-320,327,334`), so honor both forms here; the legacy - // root only forwards `--experimental` to Go proxy argv, never into env. + // Go's `EXPERIMENTAL` structured-dump branch (`pull.go:49-61`) stays + // delegated to Go. pg_dump itself is now native (used by the initial-migra + // path below), but this branch also calls `format.WriteStructuredSchemas` + // (`cli-go/internal/migration/format/format.go:99`), which parses every + // dumped statement with a PostgreSQL DDL AST parser (`multigres`, ~50 node + // types) to route objects into structured files. No Postgres DDL parser + // exists in TS yet, so porting it is tracked separately; until then the + // experimental path delegates the whole pull to Go. viper resolves + // `EXPERIMENTAL` from *either* the global `--experimental` pflag or + // `SUPABASE_EXPERIMENTAL` (`cmd/root.go:318-320,327,334`), so honor both + // forms here; the legacy root only forwards `--experimental` to Go proxy + // argv, never into env. if (experimental || legacyParseBoolEnv(toml.envLookup("SUPABASE_EXPERIMENTAL"))) { // Go's structured-dump path returns before writing a migration or // touching schema_migrations (`pull.go:49-61`), so no history repair. @@ -417,12 +447,113 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy }), ); } - if (sync.kind === "missing" && !usePgDeltaDiff) { - // Initial pull with the migra engine needs `pg_dump` — delegate to Go. - // Go's migration path prompts + updates schema_migrations on the non-TTY - // default (`pull.go:73-76`), so the history is repaired. - yield* delegatePull("migra", { remoteHistoryUpdated: true }); - return; + // Initial pull, migra engine (Go's `run` → `assertRemoteInSync` returns + // `errMissing`): seed the migration file with a pg_dump of the remote schema + // (`dumpRemoteSchema`, `pull.go:144-158`), then run the migra diff below as a + // second pass appended to the same file (`diffRemoteSchema(ctx, nil, …)`), + // which captures default privileges / managed schemas pg_dump can't emit. + // pg-delta initial pulls skip the dump (`pull.go:126` `if !usePgDeltaDiff`): + // they diff against an empty shadow, which already yields the full schema. + const seededFromDump = sync.kind === "missing" && !usePgDeltaDiff; + // Tracks whether the pg_dump seed wrote any bytes, for Go's + // `ensureMigrationWritten` (`pull.go:68,263-268`): an empty dump + empty diff + // is "in sync", a non-empty dump is a valid initial migration on its own. + let seedWroteBytes = false; + if (seededFromDump) { + yield* legacyMakeDir(fs, path.dirname(migrationPath)).pipe( + Effect.mapError((cause) => new LegacyDbPullWriteError({ message: cause.message })), + ); + const image = yield* legacyResolveDbImage( + fs, + path, + cliConfig.workdir, + toml.majorVersion, + Option.getOrUndefined(toml.orioledbVersion), + ); + // Go's `migration.DumpSchema` default options: no schema filter (so the + // internal-schema exclude list applies) and comments stripped (`EXTRA_SED`). + const dumpEnvOpt: LegacyDumpOptions = { + schema: [], + keepComments: false, + excludeTable: [], + columnInsert: false, + }; + const toDumpOpenError = (cause: { readonly message: string }) => + new LegacyDbPullDumpError({ message: `failed to open dump file: ${cause.message}` }); + // Stream pg_dump → migration file, (re)truncating per attempt so a pooler + // retry leaves only the successful attempt's bytes (Go's `resetOutput`). + const runSchemaDump = (target: LegacyPgConnInput) => { + // Reset per attempt alongside the truncate, mirroring Go's `resetOutput` + // (`pooler_fallback.go:98-113`) which zeroes the file before the pooler + // retry. Go decides in-sync from the file on disk (`hasMigrationContent`, + // `pull.go:251-268`), so only the final successful attempt's bytes count: a + // partial direct write that then IPv6-fails must not leave this flag stuck + // true, or an empty pooler retry would be mis-reported as a schema write. + seedWroteBytes = false; + return fs + .writeFile(migrationPath, new Uint8Array(0), { mode: MIGRATION_FILE_MODE }) + .pipe(Effect.mapError(toDumpOpenError)) + .pipe( + Effect.andThen( + Effect.scoped( + Effect.gen(function* () { + const file = yield* fs + .open(migrationPath, { flag: "a" }) + .pipe(Effect.mapError(toDumpOpenError)); + return yield* legacyStreamPgDump({ + image, + script: legacyDumpSchemaScript, + env: legacyBuildSchemaDumpEnv(target, dumpEnvOpt), + onStdout: (chunk) => { + if (chunk.length > 0) seedWroteBytes = true; + return file.writeAll(chunk).pipe( + Effect.mapError( + (cause) => + new LegacyDbPullWriteError({ + message: `failed to write migration file: ${cause.message}`, + }), + ), + ); + }, + }); + }), + ), + ), + ); + }; + // Go's `dumpRemoteSchema` prints this once, before `RunWithPoolerFallback`. + yield* output.raw("Dumping schema from remote database...\n", "stderr"); + // Container-level IPv6 → IPv4-pooler retry (Go's `dump.RunWithPoolerFallback`), + // shared with `db dump`. `db pull` prints "Dumping…" once above, so it passes + // `Effect.void` for the retry re-print (Go prints it outside the retried closure). + const dumpResult = yield* legacyRunWithPoolerFallback({ + result: yield* runSchemaDump(resolved.conn), + connType, + host: resolved.conn.host, + isLocal: resolved.isLocal, + projectHost: cliConfig.projectHost, + resolvePooler: () => + resolver + .resolvePoolerFallback({ + dbUrl: flags.dbUrl, + connType: "linked", + dnsResolver, + password: flags.password ?? Option.none(), + }) + .pipe(Effect.orElseSucceed(() => Option.none())), + runWithConn: runSchemaDump, + reprintOnRetry: Effect.void, + }); + if (dumpResult.exitCode !== 0) { + return yield* Effect.fail( + new LegacyDbPullDumpError({ + message: `error running container: exit ${dumpResult.exitCode}`, + ...(legacyIsIPv6ConnectivityError(dumpResult.stderr) + ? { suggestion: legacyIpv6Suggestion() } + : {}), + }), + ); + } } // Native diff: shadow (baseline + local migrations) vs remote → migration SQL. @@ -504,7 +635,12 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy }).pipe(Effect.ensuring(seam.removeShadowContainer(shadow.container))); const out = diffOutcome.sql; - if (out.trim().length === 0) { + const diffEmpty = out.trim().length === 0; + // A non-initial pull with an empty diff is "in sync" and fails (Go's + // `diffRemoteSchema`). The initial-migra path seeded the file with a pg_dump + // above, so its empty second pass is swallowed (`swallowInitialInSync`, + // `pull.go:256-261`) and falls through to the shared tail below. + if (diffEmpty && !seededFromDump) { // Go saves a pg-delta debug bundle and embeds its path in the in-sync // error when PGDELTA_DEBUG is set (`internal/db/pull/pull.go:176-185`); a // bundle-save failure falls through to the plain in-sync error. @@ -541,17 +677,54 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy new LegacyDbPullInSyncError({ message: "No schema changes found" }), ); } - yield* legacyMakeDir(fs, path.dirname(migrationPath)).pipe( - Effect.mapError((cause) => new LegacyDbPullWriteError({ message: cause.message })), - ); - yield* fs.writeFileString(migrationPath, out).pipe( - Effect.mapError( - (cause) => - new LegacyDbPullWriteError({ - message: `failed to write migration file: ${cause.message}`, + + if (!diffEmpty) { + if (seededFromDump) { + // Append the migra diff to the dump-seeded file (Go's `diffRemoteSchema` + // opens the migration file `O_APPEND`, `pull.go:191`). + yield* Effect.scoped( + Effect.gen(function* () { + const file = yield* fs.open(migrationPath, { flag: "a" }).pipe( + Effect.mapError( + (cause) => + new LegacyDbPullWriteError({ + message: `failed to open migration file: ${cause.message}`, + }), + ), + ); + yield* file.writeAll(new TextEncoder().encode(out)).pipe( + Effect.mapError( + (cause) => + new LegacyDbPullWriteError({ + message: `failed to write migration file: ${cause.message}`, + }), + ), + ); }), - ), - ); + ); + } else { + yield* legacyMakeDir(fs, path.dirname(migrationPath)).pipe( + Effect.mapError((cause) => new LegacyDbPullWriteError({ message: cause.message })), + ); + yield* fs.writeFileString(migrationPath, out).pipe( + Effect.mapError( + (cause) => + new LegacyDbPullWriteError({ + message: `failed to write migration file: ${cause.message}`, + }), + ), + ); + } + } + + // Go's `ensureMigrationWritten` (`pull.go:68,263-268`): a dump that produced + // nothing followed by an empty diff leaves the file empty → in sync. + if (seededFromDump && !seedWroteBytes && diffEmpty) { + return yield* Effect.fail( + new LegacyDbPullInSyncError({ message: "No schema changes found" }), + ); + } + yield* output.raw(`Schema written to ${legacyBold(migrationPath)}\n`, "stderr"); // Prompt to update the remote migration history table. Go calls @@ -561,20 +734,10 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy // (`internal/utils/console.go:74-82`) — it never fails the command. let remoteHistoryUpdated = false; const updateHistoryTitle = "Update remote migration history table?"; - const shouldUpdate = yield* Effect.gen(function* () { - // Machine output (json/stream-json) never prompts — the non-text layers - // report non-interactive and fail every prompt — so take Go's default. - if (output.format !== "text") return true; - if (yes) { - yield* output.raw(`${updateHistoryTitle} [Y/n] y\n`, "stderr"); - return true; - } - // A non-interactive stdin or any prompt error falls back to the default, - // matching Go's `PromptYesNo` returning `def` on error/timeout. - return yield* output - .promptConfirm(updateHistoryTitle, { defaultValue: true }) - .pipe(Effect.orElseSucceed(() => true)); - }); + // Go's `PromptYesNo(ctx, title, true)` (`internal/db/pull/pull.go:73`): honors + // `--yes`, scans piped stdin on a non-TTY before falling back to the default + // (`console.go:64-82`), and otherwise prompts on a real TTY. + const shouldUpdate = yield* legacyPromptYesNo(output, yes, updateHistoryTitle, true); if (shouldUpdate) { yield* legacyUpdateMigrationHistory(session, fs, path, migrationPath, timestamp); remoteHistoryUpdated = true; @@ -599,5 +762,8 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy ), ), Effect.ensuring(telemetryState.flush), + // Scope the `SUPABASE_INTERNAL_IMAGE_REGISTRY`-from-`.env` apply below to this + // command run: `legacyApplyProjectEnv` registers a finalizer that reverts it. + Effect.scoped, ); }); 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 794b61ab5e..1e15768888 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 @@ -10,7 +10,12 @@ import { mockLegacyTelemetryStateTracked, useLegacyTempWorkdir, } from "../../../../../tests/helpers/legacy-mocks.ts"; -import { mockOutput, mockRuntimeInfo, mockTty } from "../../../../../tests/helpers/mocks.ts"; +import { + mockOutput, + mockRuntimeInfo, + mockStdin, + mockTty, +} from "../../../../../tests/helpers/mocks.ts"; import { LegacyDnsResolverFlag, LegacyExperimentalFlag, @@ -44,6 +49,8 @@ interface SetupOpts { readonly remoteVersions?: ReadonlyArray; readonly edgeStdout?: string; // diff SQL or declarative export JSON readonly stdinIsTty?: boolean; + // Piped (non-TTY) stdin answers, one consumed per confirmation prompt. + readonly pipedAnswers?: ReadonlyArray; readonly yes?: boolean; readonly experimental?: boolean; readonly shadowTargetOverride?: string; @@ -56,6 +63,17 @@ interface SetupOpts { readonly poolerAvailable?: boolean; readonly delegateStdout?: string; // stdout returned by a captured Go-delegate run readonly catalogStdout?: string; // stdout returned by pg-delta catalog-export runs + // Initial-migra pull: the bytes the native pg_dump container streams to its sink, + // its exit code / stderr, and (when set) an IPv6 stderr that fails the FIRST dump + // attempt so the pooler retry runs (the second attempt then streams `dumpStdout`). + readonly dumpStdout?: string; + readonly dumpExitCode?: number; + readonly dumpStderr?: string; + readonly dumpFailFirstWith?: string; + // Bytes the FIRST dump attempt streams to its sink before it fails with + // `dumpFailFirstWith`, reproducing a direct attempt that emits preamble then + // exits non-zero on an IPv6 drop. + readonly dumpFailFirstPartialBytes?: string; // Raw argv seen by the handler (CliArgs). Only consulted when both // `--declarative` and `--use-pg-delta` are present, to replay pflag's // last-occurrence-wins ordering; defaults to empty. @@ -112,10 +130,30 @@ function setup(workdir: string, opts: SetupOpts = {}) { }, }); + // The initial-migra pull seeds the migration file with a native pg_dump via + // `runStream`; deliver the configured bytes to `onStdout` (as Go's StdCopy would), + // then report the exit code + stderr. `dumpFailFirstWith` fails the first attempt + // so the pooler retry runs. + const dumpCalls: Array<{ env: Readonly>; image: string }> = []; + let dumpRunCount = 0; const docker = Layer.succeed(LegacyDockerRun, { run: () => Effect.die("run unused"), runCapture: () => Effect.die("runCapture unused"), - runStream: () => Effect.die("runStream unused"), + runStream: (runOpts, streamOpts) => + Effect.gen(function* () { + dumpRunCount += 1; + dumpCalls.push({ env: runOpts.env, image: runOpts.image }); + if (opts.dumpFailFirstWith !== undefined && dumpRunCount === 1) { + if (opts.dumpFailFirstPartialBytes !== undefined) { + const partial = new TextEncoder().encode(opts.dumpFailFirstPartialBytes); + if (partial.length > 0) yield* streamOpts.onStdout(partial); + } + return { exitCode: 1, stderr: opts.dumpFailFirstWith }; + } + const bytes = new TextEncoder().encode(opts.dumpStdout ?? ""); + if (bytes.length > 0) yield* streamOpts.onStdout(bytes); + return { exitCode: opts.dumpExitCode ?? 0, stderr: opts.dumpStderr ?? "" }; + }), }); const execLog: string[] = []; @@ -196,11 +234,18 @@ function setup(workdir: string, opts: SetupOpts = {}) { proxy, mockLegacyCliConfig({ workdir, projectId: Option.some("test") }), mockTty({ stdinIsTty: opts.stdinIsTty ?? false, stdoutIsTty: false }), + mockStdin( + opts.stdinIsTty ?? false, + opts.pipedAnswers ? `${opts.pipedAnswers.join("\n")}\n` : undefined, + ), Layer.succeed(LegacyYesFlag, opts.yes ?? false), Layer.succeed(LegacyExperimentalFlag, opts.experimental ?? false), Layer.succeed(LegacyDnsResolverFlag, "native"), Layer.succeed(LegacyNetworkIdFlag, Option.none()), - Layer.succeed(LegacyPgDeltaSslProbe, { requireSsl: () => Effect.succeed(false) }), + Layer.succeed(LegacyPgDeltaSslProbe, { + requireSsl: () => Effect.succeed(false), + requireSslForHost: () => Effect.succeed(false), + }), Layer.succeed(CliArgs, { args: opts.args ?? [] }), mockRuntimeInfo(), BunServices.layer, @@ -216,6 +261,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { historyUpserts, execLog, poolerFallbackCalls, + dumpCalls, get edgeRunCount() { return edgeRunCount; }, @@ -422,42 +468,177 @@ describe("legacy db pull", () => { }).pipe(Effect.provide(s.layer)); }); - it.effect("an initial pull with no local migrations delegates the dump to Go (migra)", () => { - const s = setup(tmp.current, { remoteVersions: [] }); - return Effect.gen(function* () { - yield* legacyDbPull(flags()); - expect(s.proxyCalls).toHaveLength(1); - expect(s.proxyCalls[0]?.args[0]).toBe("db"); - expect(s.proxyCalls[0]?.args[1]).toBe("pull"); - expect(s.proxyCalls[0]?.env).toEqual({ SUPABASE_TELEMETRY_DISABLED: "1" }); - }).pipe(Effect.provide(s.layer)); - }); + it.effect( + "an initial pull (no local migrations, migra) dumps the schema natively then appends the diff", + () => { + // Go's `run` → `dumpRemoteSchema` (pg_dump, now native) + `diffRemoteSchema(nil)` + // appended (`pull.go:117-141`). No Go delegation. + const s = setup(tmp.current, { + remoteVersions: [], + dumpStdout: "create table dumped ();\n", + edgeStdout: "create table diffed ();\n", // the migra second pass + yes: true, + }); + return Effect.gen(function* () { + yield* legacyDbPull(flags()); + expect(s.proxyCalls).toHaveLength(0); + expect(s.proxyCaptureCalls).toHaveLength(0); + // pg_dump ran with the schema-dump env (internal-schema exclude + comment strip). + expect(s.dumpCalls).toHaveLength(1); + expect(s.dumpCalls[0]?.env["EXTRA_SED"]).toBe("/^--/d"); + expect(s.dumpCalls[0]?.env["EXCLUDED_SCHEMAS"]).toContain("auth"); + // The diff ran against the shadow with the migra engine (no schema filter). + expect(s.provisionCalls[0]?.usePgDelta).toBe(false); + // The migration file holds the dump output followed by the appended diff. + const dir = join(tmp.current, "supabase", "migrations"); + const file = readdirSync(dir).find((f) => f.endsWith("_remote_schema.sql")); + expect(file).toBeDefined(); + const content = readFileSync(join(dir, file ?? ""), "utf8"); + expect(content).toContain("create table dumped ();"); + expect(content).toContain("create table diffed ();"); + expect(content.indexOf("dumped")).toBeLessThan(content.indexOf("diffed")); + // stderr order: dump → shadow → diff → written. + const err = streamText(s.out, "stderr"); + expect(err).toContain("Dumping schema from remote database..."); + expect(err).toContain("Creating shadow database..."); + expect(err).toContain("Schema written to"); + expect(err.indexOf("Dumping schema")).toBeLessThan(err.indexOf("Creating shadow")); + expect(s.historyUpserts.length).toBe(1); + }).pipe(Effect.provide(s.layer)); + }, + ); - it.effect("an initial pull in json mode emits a structured envelope (delegated output)", () => { - // Regression: the initial-migra delegate inherited stdout and returned without - // output.success, so machine-mode callers got the Go child's human output - // instead of a JSON envelope (CLI-1546). Now the child's stdout is captured and - // a structured payload is emitted instead. + it.effect("an initial pull in json mode emits a native structured envelope", () => { const s = setup(tmp.current, { format: "json", remoteVersions: [], - delegateStdout: "Schema written to supabase/migrations/x.sql\n", + dumpStdout: "create table dumped ();\n", + edgeStdout: "create table diffed ();\n", }); return Effect.gen(function* () { yield* legacyDbPull(flags()); expect(s.proxyCalls).toHaveLength(0); - expect(s.proxyCaptureCalls).toHaveLength(1); - // The delegated child runs with a non-TTY stdin so its history-update prompt - // takes Go's default (true) without blocking the JSON caller; the child then - // updates the history, so the envelope reports remoteHistoryUpdated: true. - expect(s.proxyCaptureCalls[0]?.stdin).toBe("ignore"); + expect(s.proxyCaptureCalls).toHaveLength(0); const success = s.out.messages.find((m) => m.type === "success"); + // Machine mode never prompts, so history is updated on Go's default (true); + // `schemaWritten` is the real native migration path (not null as when delegated). expect(success?.data).toMatchObject({ declarative: false, - schemaWritten: null, remoteHistoryUpdated: true, engine: "migra", }); + const data = success?.data as { schemaWritten?: string } | undefined; + expect(data?.schemaWritten).toMatch(/_remote_schema\.sql$/u); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("an initial pull swallows an empty migra diff once the dump wrote content", () => { + // Go's `swallowInitialInSync` (`pull.go:256-261`): after the pg_dump seed, an + // empty second pass is success, not "in sync". + const s = setup(tmp.current, { + remoteVersions: [], + dumpStdout: "create table dumped ();\n", + edgeStdout: "", // empty migra diff + yes: true, + }); + return Effect.gen(function* () { + const exit = yield* legacyDbPull(flags()).pipe(Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + const dir = join(tmp.current, "supabase", "migrations"); + const file = readdirSync(dir).find((f) => f.endsWith("_remote_schema.sql")); + expect(file).toBeDefined(); + expect(readFileSync(join(dir, file ?? ""), "utf8")).toContain("create table dumped ();"); + expect(streamText(s.out, "stderr")).toContain("Schema written to"); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("an initial pull with an empty schema reports 'No schema changes found'", () => { + // Go's `ensureMigrationWritten` (`pull.go:68,263-268`): an empty dump + empty diff + // leaves the file empty → in sync. + const s = setup(tmp.current, { remoteVersions: [], dumpStdout: "", edgeStdout: "" }); + return Effect.gen(function* () { + const error = yield* legacyDbPull(flags()).pipe(Effect.flip); + expect(error.message).toBe("No schema changes found"); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect( + "an initial-pull direct write that IPv6-fails then an empty pooler retry reports 'No schema changes found'", + () => { + // Regression: the direct attempt streams preamble bytes then drops over IPv6; + // the pooler retry succeeds empty. Go truncates the file before the retry + // (`resetOutput`, pooler_fallback.go:98-113) and decides in-sync from the file + // on disk (`hasMigrationContent`, pull.go:251-268), so an empty pooler retry + + // empty diff is in sync — not a schema write + migration-history upsert. The + // sticky `seedWroteBytes` flag must therefore reset per attempt. + const s = setup(tmp.current, { + remoteVersions: [], + dumpFailFirstWith: "could not translate host name: network is unreachable", + dumpFailFirstPartialBytes: "-- partial preamble\n", + dumpStdout: "", // pooler retry streams nothing + edgeStdout: "", // empty migra diff + poolerAvailable: true, + yes: true, + }); + return Effect.gen(function* () { + const error = yield* legacyDbPull(flags()).pipe(Effect.flip); + expect(error.message).toBe("No schema changes found"); + expect(s.dumpCalls).toHaveLength(2); // direct attempt + pooler retry + expect(s.historyUpserts).toHaveLength(0); // no migration-history row written + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect("an initial pull fails when the pg_dump container exits non-zero", () => { + const s = setup(tmp.current, { + remoteVersions: [], + dumpExitCode: 1, + dumpStderr: "connection refused", + }); + return Effect.gen(function* () { + const error = yield* legacyDbPull(flags()).pipe(Effect.flip); + expect(error.message).toContain("error running container: exit 1"); + // The diff pass never ran — the dump failure aborts before provisioning a shadow. + expect(s.provisionCalls).toHaveLength(0); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("an initial-pull dump retries via the IPv4 pooler on an IPv6 failure", () => { + // Go's `dump.RunWithPoolerFallback`: a `--linked` direct-host dump that fails over + // IPv6 retries once through the transaction pooler (`pull.go:155`). + const s = setup(tmp.current, { + remoteVersions: [], + dumpFailFirstWith: "could not translate host name: network is unreachable", + dumpStdout: "create table dumped ();\n", + edgeStdout: "create table diffed ();\n", + poolerAvailable: true, + yes: true, + }); + return Effect.gen(function* () { + yield* legacyDbPull(flags()); + expect(s.dumpCalls).toHaveLength(2); // direct attempt + pooler retry + expect(s.poolerFallbackCalls).toHaveLength(1); + const err = streamText(s.out, "stderr"); + expect(err).toContain("does not support IPv6"); + expect(err).toContain("Retrying via the IPv4 connection pooler"); + // The "Dumping schema…" line is printed once (before the fallback), not re-printed + // on the pooler retry (Go's `PoolerFallbackConfig` only emits the warning). + expect(err.match(/Dumping schema from remote database/gu)).toHaveLength(1); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("an initial-pull IPv6 dump failure with no pooler surfaces the dump error", () => { + const s = setup(tmp.current, { + remoteVersions: [], + dumpExitCode: 1, + dumpStderr: "could not translate host name: network is unreachable", + poolerAvailable: false, + }); + return Effect.gen(function* () { + const error = yield* legacyDbPull(flags()).pipe(Effect.flip); + expect(error.message).toContain("error running container: exit 1"); + expect(s.poolerFallbackCalls).toHaveLength(1); // gate checked, no pooler resolved + expect(streamText(s.out, "stderr")).not.toContain("Retrying via the IPv4 connection pooler"); }).pipe(Effect.provide(s.layer)); }); @@ -556,6 +737,45 @@ describe("legacy db pull", () => { }).pipe(Effect.provide(s.layer)); }); + it.effect("updates history on an empty non-interactive stdin (Go default)", () => { + // Go's `PromptYesNo` scans stdin and only falls back to the default (`true`) when + // the scan is empty/exhausted (`console.go:64-82`). With no piped input a + // non-interactive `db pull` therefore proceeds to update the remote history. + // (The production clack prompt would hang on a non-TTY — that no-hang behavior is + // proven end-to-end in `pull.live.test.ts`; here the empty piped scan defaults.) + seedMigration(tmp.current, "20240101000000"); + const s = setup(tmp.current, { + remoteVersions: ["20240101000000"], + edgeStdout: "create table remote ();\n", + stdinIsTty: false, + }); + return Effect.gen(function* () { + yield* legacyDbPull(flags()); + expect(s.historyUpserts.length).toBe(1); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("declines the history update on a piped 'n' (non-tty)", () => { + // Regression: Go scans piped stdin before defaulting (`console.go:74-82`), so a + // piped `n` cancels the history update even on a non-terminal — `schema_migrations` + // must not be touched against the user's explicit decline. + seedMigration(tmp.current, "20240101000000"); + const s = setup(tmp.current, { + remoteVersions: ["20240101000000"], + edgeStdout: "create table remote ();\n", + stdinIsTty: false, + pipedAnswers: ["n"], + }); + return Effect.gen(function* () { + yield* legacyDbPull(flags()); + expect(s.historyUpserts.length).toBe(0); + // Go prints the label then echoes the consumed answer (`console.go:96-102`). + expect(streamText(s.out, "stderr")).toContain( + "Update remote migration history table? [Y/n] n", + ); + }).pipe(Effect.provide(s.layer)); + }); + it.effect("emits a json envelope and suppresses 'Finished' in machine mode", () => { seedMigration(tmp.current, "20240101000000"); const s = setup(tmp.current, { @@ -587,6 +807,134 @@ describe("legacy db pull", () => { }).pipe(Effect.provide(s.layer)); }); + it.effect("honors SUPABASE_YES for the initial-pull history update", () => { + // Go's `PromptYesNo` reads `viper.GetBool("YES")`, which includes the + // `SUPABASE_YES` env var (AutomaticEnv), so it auto-confirms even on a TTY with + // no piped answer. The native path resolves `yes` via `legacyResolveYesWithProjectEnv`, + // not the raw `--yes` flag, so the shell env var is honored here too. + const prev = process.env["SUPABASE_YES"]; + process.env["SUPABASE_YES"] = "1"; + seedMigration(tmp.current, "20240101000000"); + const s = setup(tmp.current, { + remoteVersions: ["20240101000000"], + edgeStdout: "create table remote ();\n", + // A TTY with no scripted prompt response: only SUPABASE_YES makes this pass. + stdinIsTty: true, + }); + return Effect.gen(function* () { + yield* legacyDbPull(flags()); + expect(s.historyUpserts.length).toBe(1); + expect(streamText(s.out, "stderr")).toContain( + "Update remote migration history table? [Y/n] y", + ); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (prev === undefined) delete process.env["SUPABASE_YES"]; + else process.env["SUPABASE_YES"] = prev; + }), + ), + Effect.provide(s.layer), + ); + }); + + it.effect("honors SUPABASE_YES from supabase/.env for the initial-pull history update", () => { + // Go loads the project `.env` (loadNestedEnv) inside ParseDatabaseConfig before + // PromptYesNo (config.go:701), so `SUPABASE_YES` set only in `supabase/.env` + // auto-confirms — with no shell env or `--yes`. The native path resolves via + // `legacyResolveYesWithProjectEnv`, reading the loaded project env map. + const prev = process.env["SUPABASE_YES"]; + delete process.env["SUPABASE_YES"]; // only the project .env value must apply + seedMigration(tmp.current, "20240101000000"); + writeFileSync(join(tmp.current, "supabase", ".env"), "SUPABASE_YES=true\n"); + const s = setup(tmp.current, { + remoteVersions: ["20240101000000"], + edgeStdout: "create table remote ();\n", + // Pipe `n` on a non-TTY: only honoring the .env SUPABASE_YES (which is read + // before stdin, so it wins over the piped decline) still updates history. + stdinIsTty: false, + pipedAnswers: ["n"], + }); + return Effect.gen(function* () { + yield* legacyDbPull(flags()); + expect(s.historyUpserts.length).toBe(1); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (prev === undefined) delete process.env["SUPABASE_YES"]; + else process.env["SUPABASE_YES"] = prev; + }), + ), + Effect.provide(s.layer), + ); + }); + + it.effect( + "resolves the pg_dump image via SUPABASE_INTERNAL_IMAGE_REGISTRY from supabase/.env", + () => { + // Go's LoadConfig applies the project `.env` (os.Setenv) before GetRegistryImageUrl, + // so a registry mirror set only in `supabase/.env` is used for the native pg_dump + // seed. The handler mirrors that with `legacyApplyProjectEnv` (scoped to the run, + // reverted on close); the loader itself stays pure. + const prev = process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]; + delete process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]; + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync( + join(tmp.current, "supabase", ".env"), + "SUPABASE_INTERNAL_IMAGE_REGISTRY=my-mirror.example.com\n", + ); + const s = setup(tmp.current, { + remoteVersions: [], // no remote history → initial-migra pg_dump path + dumpStdout: "create table dumped ();\n", + edgeStdout: "", + yes: true, + }); + return Effect.gen(function* () { + yield* legacyDbPull(flags()); + expect(s.dumpCalls.length).toBeGreaterThanOrEqual(1); + // The pg_dump container image is rewritten to the configured mirror. + expect(s.dumpCalls[0]?.image).toMatch(/^my-mirror\.example\.com\/supabase\//u); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (prev === undefined) delete process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]; + else process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"] = prev; + }), + ), + Effect.provide(s.layer), + ); + }, + ); + + it.effect("an explicit --yes=false overrides SUPABASE_YES and honors the piped answer", () => { + // Go binds `--yes` to viper, so an explicit `--yes=false` wins over the + // SUPABASE_YES env (AutomaticEnv). `printf 'n\n' | SUPABASE_YES=1 supabase + // --yes=false db pull` must let the piped `n` decline the history update rather + // than auto-confirming — schema_migrations stays untouched. + const prev = process.env["SUPABASE_YES"]; + process.env["SUPABASE_YES"] = "1"; + seedMigration(tmp.current, "20240101000000"); + const s = setup(tmp.current, { + remoteVersions: ["20240101000000"], + edgeStdout: "create table remote ();\n", + stdinIsTty: false, + pipedAnswers: ["n"], + args: ["db", "pull", "--yes=false"], + }); + return Effect.gen(function* () { + yield* legacyDbPull(flags()); + expect(s.historyUpserts.length).toBe(0); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (prev === undefined) delete process.env["SUPABASE_YES"]; + else process.env["SUPABASE_YES"] = prev; + }), + ), + Effect.provide(s.layer), + ); + }); + it.effect("SUPABASE_EXPERIMENTAL delegates the structured-dump pull to Go", () => { const s = setup(tmp.current); return Effect.gen(function* () { diff --git a/apps/cli/src/legacy/commands/db/pull/pull.layers.ts b/apps/cli/src/legacy/commands/db/pull/pull.layers.ts index 0e298b8bc0..821fd07acd 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.layers.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.layers.ts @@ -11,6 +11,7 @@ import { legacyIdentityStitchLayer } from "../../../shared/legacy-identity-stitc import { legacyLinkedDbResolverRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts"; import { legacyPgDeltaSslProbeLayer } from "../../../shared/legacy-pgdelta-ssl-probe.layer.ts"; import { legacyTelemetryStateLayer } from "../../../telemetry/legacy-telemetry-state.layer.ts"; +import { stdinLayer } from "../../../../shared/runtime/stdin.layer.ts"; import { legacyDeclarativeSeamLayer } from "../shared/legacy-pgdelta.seam.layer.ts"; /** @@ -47,4 +48,5 @@ export const legacyDbPullRuntimeLayer = Layer.mergeAll( legacyTelemetryStateLayer, legacyLinkedDbResolverRuntimeLayer(["db", "pull"]).pipe(Layer.provide(legacyIdentityStitchLayer)), commandRuntimeLayer(["db", "pull"]), + stdinLayer, ); diff --git a/apps/cli/src/legacy/commands/db/pull/pull.live.test.ts b/apps/cli/src/legacy/commands/db/pull/pull.live.test.ts new file mode 100644 index 0000000000..320d274d60 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/pull/pull.live.test.ts @@ -0,0 +1,69 @@ +import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; + +import { + describeLiveDataPlane, + requireLiveProjectRef, + runSupabaseLive, +} from "../../../../../tests/helpers/live.ts"; + +const LIVE_TIMEOUT_MS = 300_000; + +// A fresh, isolated temp workdir so the CLI writes migrations there and never +// touches the repo tree. The provisioned project ref is supplied to `--linked` via +// the `SUPABASE_PROJECT_ID` env var — that is the `--linked` resolver chain in both +// Go and the legacy port (flag → `SUPABASE_PROJECT_ID` → `supabase/.temp/project-ref`); +// `config.toml`'s `project_id` is NOT consulted for `--linked`. +function tempWorkdir(): string { + return mkdtempSync(join(tmpdir(), "sb-db-pull-live-")); +} + +// Data-plane: needs a provisioned project whose database is routable (the +// cli-e2e-ci Linux runner). `describeLiveDataPlane` runs this only when the project +// instance is ACTIVE_HEALTHY, so a control-plane-only stack (ref set but the DB +// unreachable, e.g. local macOS or the current cli-e2e-ci control-plane case) is +// skipped rather than timing out on the pg_dump seed. +describeLiveDataPlane("supabase db pull (live)", () => { + test( + "initial pull from the linked project (native pg_dump seed + migra diff)", + { timeout: LIVE_TIMEOUT_MS }, + async () => { + const ref = requireLiveProjectRef(); + const dir = tempWorkdir(); + try { + const { stdout, stderr, exitCode } = await runSupabaseLive(["db", "pull", "--linked"], { + cwd: dir, + env: { SUPABASE_PROJECT_ID: ref }, + exitTimeoutMs: LIVE_TIMEOUT_MS - 20_000, + // Decline the "Update remote migration history table?" prompt with a piped + // `n`: this project ref is shared across live runs, and writing a + // `schema_migrations` row here would make a later run see it as an extra + // remote migration and fail with a history conflict before pulling. The + // piped answer also exercises the native prompt's stdin scanning end to end. + stdin: "n\n", + }); + const combined = `${stdout}${stderr}`; + expect(combined).not.toContain("Unauthorized"); + // No local migrations → the native initial-migra path runs: pg_dump the remote + // schema, then append the migra diff. Assert on the durable side effect: a + // provisioned project with schema writes a `_remote_schema.sql` + // migration; a fresh empty schema reports "No schema changes found". Either + // proves the path ran end to end against the real database without hanging. + const migDir = join(dir, "supabase", "migrations"); + const wroteMigration = + existsSync(migDir) && readdirSync(migDir).some((f) => f.endsWith("_remote_schema.sql")); + expect(wroteMigration || combined.includes("No schema changes found")).toBe(true); + // The native path creates the migration file BEFORE pg_dump runs, so a failed + // dump/diff could leave a stray file behind — a written migration is only + // meaningful if the command actually succeeded. + if (wroteMigration) { + expect(exitCode).toBe(0); + } + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }, + ); +}); diff --git a/apps/cli/src/legacy/commands/db/query/query.handler.ts b/apps/cli/src/legacy/commands/db/query/query.handler.ts index 0224799cec..4a51f9e301 100644 --- a/apps/cli/src/legacy/commands/db/query/query.handler.ts +++ b/apps/cli/src/legacy/commands/db/query/query.handler.ts @@ -121,7 +121,15 @@ export const legacyDbQuery = Effect.fn("legacy.db.query")(function* (flags: Lega const jsonData = fieldTypeIds === undefined ? data : legacyCoerceLocalJsonRows(data, fieldTypeIds); const boundary = agentMode ? yield* random.randomHex(BOUNDARY_BYTES) : ""; - yield* output.raw(legacyRenderJson(cols, jsonData, agentMode, boundary, advisory)); + const rendered = legacyRenderJson(cols, jsonData, agentMode, boundary, advisory); + if (output.format === "stream-json" && Option.getOrUndefined(outputFlag) !== "json") { + const compactRendered = rendered.trimEnd().replaceAll("\n", ""); + yield* output.raw( + `{"type":"result","data":${compactRendered},"timestamp":${JSON.stringify(new Date().toISOString())}}\n`, + ); + return; + } + yield* output.raw(rendered); }); const runLocal = ( @@ -356,9 +364,10 @@ export const legacyDbQuery = Effect.fn("legacy.db.query")(function* (flags: Lega // 2. Agent mode + the resolved payload format, mirroring Go's resolution // (`cmd/db.go:316-325`): an explicit `-o json|table|csv` always wins; // otherwise default to JSON for agents and a table for humans. The global - // `-o` choice is a union (see `query.command.ts`), so values outside Go's - // `json|table|csv` enum (`pretty|yaml|toml|env`) fall through to the - // agent-mode default rather than erroring. + // `-o` choice is a union (see `query.command.ts`), while TS + // `--output-format json|stream-json` must also resolve to JSON here, so + // values outside Go's `json|table|csv` enum (`pretty|yaml|toml|env`) + // fall through to the agent/machine default rather than erroring. const agentMode = legacyResolveAgentMode(agentFlag, aiTool.name); const explicit = Option.getOrUndefined(outputFlag); const format: LegacyResolvedFormat = @@ -366,9 +375,9 @@ export const legacyDbQuery = Effect.fn("legacy.db.query")(function* (flags: Lega ? "json" : explicit === "csv" ? "csv" - : explicit === "table" + : explicit === "table" || explicit === "pretty" ? "table" - : agentMode + : explicit === undefined && (output.format !== "text" || agentMode) ? "json" : "table"; diff --git a/apps/cli/src/legacy/commands/db/query/query.integration.test.ts b/apps/cli/src/legacy/commands/db/query/query.integration.test.ts index 8779891687..591a3efb4b 100644 --- a/apps/cli/src/legacy/commands/db/query/query.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/query/query.integration.test.ts @@ -432,6 +432,66 @@ describe("legacy db query integration", () => { }).pipe(Effect.provide(layer)); }); + it.live("renders plain JSON for --output-format json with --agent no", () => { + const { layer, out } = setup({ result: SELECT_RESULT, agent: "no", format: "json" }); + return Effect.gen(function* () { + yield* legacyDbQuery(flags({ sql: Option.some("select 1"), local: Option.some(true) })); + expect(JSON.parse(out.stdoutText)).toEqual([ + { id: 1, name: "alice" }, + { id: 2, name: "bob" }, + ]); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a result event for --output-format stream-json with --agent no", () => { + const { layer, out } = setup({ result: SELECT_RESULT, agent: "no", format: "stream-json" }); + return Effect.gen(function* () { + yield* legacyDbQuery(flags({ sql: Option.some("select 1"), local: Option.some(true) })); + expect(out.stdoutText.trimEnd().split("\n")).toHaveLength(1); + expect(JSON.parse(out.stdoutText)).toEqual( + expect.objectContaining({ + type: "result", + data: [ + { id: 1, name: "alice" }, + { id: 2, name: "bob" }, + ], + }), + ); + }).pipe(Effect.provide(layer)); + }); + + it.live("preserves exact bigint tokens in --output-format stream-json", () => { + const { layer, out } = setup({ + result: { + fields: ["n"], + fieldTypeIds: [20], + rows: [["9223372036854775807"]], + commandTag: "SELECT 1", + }, + agent: "no", + format: "stream-json", + }); + return Effect.gen(function* () { + yield* legacyDbQuery(flags({ sql: Option.some("select 1"), local: Option.some(true) })); + expect(out.stdoutText.trimEnd().split("\n")).toHaveLength(1); + expect(out.stdoutText).toContain('"n": 9223372036854775807'); + }).pipe(Effect.provide(layer)); + }); + + it.live("lets --output pretty win over --output-format json", () => { + const { layer, out } = setup({ + result: SELECT_RESULT, + agent: "no", + format: "json", + goOutput: "pretty", + }); + return Effect.gen(function* () { + yield* legacyDbQuery(flags({ sql: Option.some("select 1"), local: Option.some(true) })); + expect(out.stdoutText).toContain("│ id │ name │"); + expect(out.messages.find((message) => message.type === "success")).toBeUndefined(); + }).pipe(Effect.provide(layer)); + }); + it.live("fails JSON output on a non-finite float (Go's json.Encoder error), no stdout", () => { // select 'NaN'::float8 -o json — Go fails to encode and exits non-zero with empty // stdout, rather than emitting `null` like JSON.stringify. 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 19da790379..ad474a9a77 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 @@ -51,6 +51,7 @@ function mockEdge(stdout: string) { // reports "not required", so no CA bundle/SSL env is injected. const probe = Layer.succeed(LegacyPgDeltaSslProbe, { requireSsl: () => Effect.succeed(false), + requireSslForHost: () => Effect.succeed(false), }); const ctx = (declarativeDir: string): LegacyDeclarativeRunContext => ({ 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 938ceab885..8241336bd5 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 @@ -151,7 +151,10 @@ function setup(workdir: string, opts: SetupOpts = {}) { Layer.succeed(LegacyNetworkIdFlag, opts.networkId ?? Option.none()), Layer.succeed(LegacyDnsResolverFlag, "native"), // The remote ref is a non-Supabase host that refuses TLS → no SSL env. - Layer.succeed(LegacyPgDeltaSslProbe, { requireSsl: () => Effect.succeed(false) }), + Layer.succeed(LegacyPgDeltaSslProbe, { + requireSsl: () => Effect.succeed(false), + requireSslForHost: () => Effect.succeed(false), + }), BunServices.layer, ); return { 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 aa2718ac62..d1e83e7290 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 @@ -158,7 +158,10 @@ function setup(workdir: string, opts: SetupOpts = {}) { ), Layer.succeed(LegacyDnsResolverFlag, "native"), // Sync diffs against the local DB, which refuses TLS → no SSL env injected. - Layer.succeed(LegacyPgDeltaSslProbe, { requireSsl: () => Effect.succeed(false) }), + Layer.succeed(LegacyPgDeltaSslProbe, { + requireSsl: () => Effect.succeed(false), + requireSslForHost: () => Effect.succeed(false), + }), BunServices.layer, ); return { layer, out, execInheritCalls, dbExec, cache, localPostgresImageChecks }; diff --git a/apps/cli/src/legacy/commands/db/dump/dump.env.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pg-dump.env.ts similarity index 98% rename from apps/cli/src/legacy/commands/db/dump/dump.env.ts rename to apps/cli/src/legacy/commands/db/shared/legacy-pg-dump.env.ts index 8c83a06102..770aca703a 100644 --- a/apps/cli/src/legacy/commands/db/dump/dump.env.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pg-dump.env.ts @@ -3,8 +3,8 @@ import type { LegacyPgConnInput } from "../../../shared/legacy-db-connection.ser /** * Pure pg_dump environment builders, ported 1:1 from Go's `pkg/migration/dump.go`. * No Effect or service dependencies, so the schema/role/config lists and the - * `os.Expand` dry-run expansion stay unit-testable in isolation. Promote to - * `legacy/shared/` if `db diff` / `db pull` ever need the same env builders. + * `os.Expand` dry-run expansion stay unit-testable in isolation. Shared by the + * `db` command family (`db dump`, and `db pull`'s initial-migra schema dump). */ /** `migration.InternalSchemas` (`pkg/migration/dump.go:18-49`). Used by schema dumps. */ diff --git a/apps/cli/src/legacy/commands/db/dump/dump.env.unit.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pg-dump.env.unit.test.ts similarity index 98% rename from apps/cli/src/legacy/commands/db/dump/dump.env.unit.test.ts rename to apps/cli/src/legacy/commands/db/shared/legacy-pg-dump.env.unit.test.ts index 4ff33a2d9f..1488772664 100644 --- a/apps/cli/src/legacy/commands/db/dump/dump.env.unit.test.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pg-dump.env.unit.test.ts @@ -15,12 +15,12 @@ import { legacyQuoteUpperCase, legacyToDumpEnv, type LegacyDumpOptions, -} from "./dump.env.ts"; +} from "./legacy-pg-dump.env.ts"; import { legacyDumpDataScript, legacyDumpRoleScript, legacyDumpSchemaScript, -} from "./dump.scripts.ts"; +} from "./legacy-pg-dump.scripts.ts"; const CONN: LegacyPgConnInput = { host: "db.example.supabase.co", diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pg-dump.run.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pg-dump.run.ts new file mode 100644 index 0000000000..5d6918bddd --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pg-dump.run.ts @@ -0,0 +1,54 @@ +import { Effect, Option } from "effect"; + +import { LegacyNetworkIdFlag } from "../../../../shared/legacy/global-flags.ts"; +import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; +import { legacyGetRegistryImageUrl } from "../../../shared/legacy-docker-registry.ts"; +import { LegacyDockerRun } from "../../../shared/legacy-docker-run.service.ts"; + +/** + * Runs a pg_dump / pg_dumpall bash script in a one-shot container, streaming its + * stdout chunk-by-chunk to `onStdout` and teeing stderr live, returning the exit + * code + captured stderr for failure classification. Mirrors Go's `dockerExec` + * (`apps/cli-go/internal/db/dump/dump.go`): host networking by default (overridden + * by the global `--network-id`), no security-opt, and the Linux-only + * `host.docker.internal:host-gateway` extra host. + * + * Shared by `db dump` (streams to `--file`/stdout) and `db pull`'s initial-migra + * schema dump (streams to the migration file). The pooler-fallback *decision* + * stays with the caller — this helper runs a single attempt and surfaces its + * exit/stderr so the caller can classify with `legacyIsIPv6ConnectivityError`. + */ +export const legacyStreamPgDump = Effect.fnUntraced(function* (params: { + /** Resolved Postgres image tag (pre-registry-URL); the helper applies the registry mirror. */ + readonly image: string; + /** The bash pg_dump/pg_dumpall script (`legacyDump{Schema,Data,Role}Script`). */ + readonly script: string; + readonly env: Readonly>; + /** Receives each stdout chunk in arrival order; its failure aborts the run as `E`. */ + readonly onStdout: (chunk: Uint8Array) => Effect.Effect; +}) { + const docker = yield* LegacyDockerRun; + const runtimeInfo = yield* RuntimeInfo; + const networkIdFlag = yield* LegacyNetworkIdFlag; + + const networkId = Option.getOrUndefined(networkIdFlag); + const network = + networkId !== undefined && networkId.length > 0 + ? { _tag: "named" as const, name: networkId } + : { _tag: "host" as const }; + const extraHosts = runtimeInfo.platform === "linux" ? ["host.docker.internal:host-gateway"] : []; + + return yield* docker.runStream( + { + image: legacyGetRegistryImageUrl(params.image), + cmd: ["bash", "-c", params.script, "--"], + env: params.env, + binds: [], + workingDir: Option.none(), + securityOpt: [], + extraHosts, + network, + }, + { onStdout: params.onStdout, teeStderr: true }, + ); +}); diff --git a/apps/cli/src/legacy/commands/db/dump/dump.scripts.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pg-dump.scripts.ts similarity index 96% rename from apps/cli/src/legacy/commands/db/dump/dump.scripts.ts rename to apps/cli/src/legacy/commands/db/shared/legacy-pg-dump.scripts.ts index cf9659adcf..48dd02fbf7 100644 --- a/apps/cli/src/legacy/commands/db/dump/dump.scripts.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pg-dump.scripts.ts @@ -1,6 +1,6 @@ // Verbatim copies of the Go pg_dump scripts (`apps/cli-go/pkg/migration/scripts/`). -// These embed the dump pipelines byte-for-byte; `dump.scripts.unit.test.ts` asserts -// equality against the Go `.sh` sources. Do not hand-edit — regenerate from Go. +// These embed the dump pipelines byte-for-byte; `legacy-pg-dump.env.unit.test.ts` +// asserts equality against the Go `.sh` sources. Do not hand-edit — regenerate from Go. export const legacyDumpSchemaScript = '#!/usr/bin/env bash\nset -euo pipefail\n\nexport PGHOST="$PGHOST"\nexport PGPORT="$PGPORT"\nexport PGUSER="$PGUSER"\nexport PGPASSWORD="$PGPASSWORD"\nexport PGDATABASE="$PGDATABASE"\n\n# Explanation of pg_dump flags:\n#\n# --schema-only omit data like migration history, pgsodium key, etc.\n# --exclude-schema omit internal schemas as they are maintained by platform\n#\n# Explanation of sed substitutions:\n#\n# - do not emit psql meta commands\n# - do not alter superuser role "supabase_admin"\n# - do not alter foreign data wrappers owner\n# - do not include ACL changes on internal schemas\n# - do not include RLS policies on cron extension schema\n# - do not include event triggers\n# - do not create pgtle schema and extension comments\n# - do not create publication "supabase_realtime"\n# - do not set transaction_timeout which requires pg17\npg_dump \\\n --schema-only \\\n --quote-all-identifier \\\n --role "postgres" \\\n --exclude-schema "${EXCLUDED_SCHEMAS:-}" \\\n ${EXTRA_FLAGS:-} \\\n| sed -E \'s/^\\\\(un)?restrict .*$/-- &/\' \\\n| sed -E \'s/^CREATE SCHEMA "/CREATE SCHEMA IF NOT EXISTS "/\' \\\n| sed -E \'s/^CREATE TABLE "/CREATE TABLE IF NOT EXISTS "/\' \\\n| sed -E \'s/^CREATE SEQUENCE "/CREATE SEQUENCE IF NOT EXISTS "/\' \\\n| sed -E \'s/^CREATE VIEW "/CREATE OR REPLACE VIEW "/\' \\\n| sed -E \'s/^CREATE FUNCTION "/CREATE OR REPLACE FUNCTION "/\' \\\n| sed -E \'s/^CREATE TRIGGER "/CREATE OR REPLACE TRIGGER "/\' \\\n| sed -E \'s/^CREATE PUBLICATION "supabase_realtime/-- &/\' \\\n| sed -E \'s/^CREATE EVENT TRIGGER /-- &/\' \\\n| sed -E \'s/^ WHEN TAG IN /-- &/\' \\\n| sed -E \'s/^ EXECUTE FUNCTION /-- &/\' \\\n| sed -E \'s/^ALTER EVENT TRIGGER /-- &/\' \\\n| sed -E \'s/^ALTER PUBLICATION "supabase_realtime_/-- &/\' \\\n| sed -E \'s/^ALTER FOREIGN DATA WRAPPER (.+) OWNER TO /-- &/\' \\\n| sed -E \'s/^ALTER DEFAULT PRIVILEGES FOR ROLE "supabase_admin"/-- &/\' \\\n| sed -E \'s/^GRANT ALL ON FOREIGN DATA WRAPPER (.+) TO "postgres" WITH GRANT OPTION/-- &/\' \\\n| sed -E "s/^GRANT (.+) ON (.+) \\"(${EXCLUDED_SCHEMAS:-})\\"/-- &/" \\\n| sed -E "s/^REVOKE (.+) ON (.+) \\"(${EXCLUDED_SCHEMAS:-})\\"/-- &/" \\\n| sed -E \'s/^(CREATE EXTENSION IF NOT EXISTS "pg_tle").+/\\1;/\' \\\n| sed -E \'s/^(CREATE EXTENSION IF NOT EXISTS "pgsodium").+/\\1;/\' \\\n| sed -E \'s/^(CREATE EXTENSION IF NOT EXISTS "pgmq").+/\\1;/\' \\\n| sed -E \'s/^COMMENT ON EXTENSION (.+)/-- &/\' \\\n| sed -E \'s/^CREATE POLICY "cron_job_/-- &/\' \\\n| sed -E \'s/^ALTER TABLE "cron"/-- &/\' \\\n| sed -E \'s/^SET transaction_timeout = 0;/-- &/\' \\\n| sed -E "${EXTRA_SED:-}"\n'; diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.deno-templates.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.deno-templates.ts index 625967555d..e43e9828a8 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.deno-templates.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.deno-templates.ts @@ -15,19 +15,19 @@ /** `templates/pgdelta.ts` — diffs SOURCE→TARGET and prints SQL statements. */ export const legacyPgDeltaDiffScript = - 'import {\n createPlan,\n deserializeCatalog,\n formatSqlStatements,\n} from "npm:@supabase/pg-delta@1.0.0-alpha.20";\nimport { supabase } from "npm:@supabase/pg-delta@1.0.0-alpha.20/integrations/supabase";\n\nasync function resolveInput(ref: string | undefined) {\n if (!ref) {\n return null;\n }\n if (ref.startsWith("postgres://") || ref.startsWith("postgresql://")) {\n return ref;\n }\n const json = await Deno.readTextFile(ref);\n return deserializeCatalog(JSON.parse(json));\n}\n\nconst source = Deno.env.get("SOURCE");\nconst target = Deno.env.get("TARGET");\n\nconst includedSchemas = Deno.env.get("INCLUDED_SCHEMAS");\nif (includedSchemas) {\n const schemas = includedSchemas.split(",");\n const schemaFilter = {\n or: [{ "*/schema": schemas }, { "schema/name": schemas }],\n };\n // CompositionPattern `and` is valid FilterDSL; Deno\'s structural typing is strict on `or` branches.\n supabase.filter = {\n and: [supabase.filter!, schemaFilter],\n } as typeof supabase.filter;\n}\n\nconst formatOptionsRaw = Deno.env.get("FORMAT_OPTIONS");\nlet formatOptions = undefined;\nif (formatOptionsRaw) {\n formatOptions = JSON.parse(formatOptionsRaw);\n}\n\ntry {\n const result = await createPlan(\n await resolveInput(source),\n await resolveInput(target),\n {\n ...supabase,\n skipDefaultPrivilegeSubtraction: true,\n },\n );\n let statements = result?.plan.statements ?? [];\n if (formatOptions != null) {\n statements = formatSqlStatements(statements, formatOptions);\n }\n if (Deno.env.get("PGDELTA_DEBUG")) {\n console.error(\n JSON.stringify({\n statementCount: statements.length,\n source: source ? "connected" : "null",\n target: target ? "connected" : "null",\n includedSchemas: includedSchemas ?? null,\n skipDefaultPrivilegeSubtraction: true,\n }),\n );\n }\n for (const sql of statements) {\n console.log(`${sql};`);\n }\n} catch (e) {\n console.error(e);\n // Force close event loop\n throw new Error("");\n}\n'; + 'import {\n createPlan,\n deserializeCatalog,\n formatSqlStatements,\n} from "npm:@supabase/pg-delta@1.0.0-alpha.20";\nimport { supabase } from "npm:@supabase/pg-delta@1.0.0-alpha.20/integrations/supabase";\n\nasync function resolveInput(ref: string | undefined) {\n if (!ref) {\n return null;\n }\n if (ref.startsWith("postgres://") || ref.startsWith("postgresql://")) {\n return ref;\n }\n const json = await Deno.readTextFile(ref);\n return deserializeCatalog(JSON.parse(json));\n}\n\nconst source = Deno.env.get("SOURCE");\nconst target = Deno.env.get("TARGET");\n\nconst includedSchemas = Deno.env.get("INCLUDED_SCHEMAS");\nif (includedSchemas) {\n const schemas = includedSchemas.split(",");\n const schemaFilter = {\n or: [{ "*/schema": schemas }, { "schema/name": schemas }],\n };\n // CompositionPattern `and` is valid FilterDSL; Deno\'s structural typing is strict on `or` branches.\n supabase.filter = {\n and: [supabase.filter!, schemaFilter],\n } as typeof supabase.filter;\n}\n\nconst formatOptionsRaw = Deno.env.get("FORMAT_OPTIONS");\nlet formatOptions = undefined;\nif (formatOptionsRaw) {\n formatOptions = JSON.parse(formatOptionsRaw);\n}\n\ntry {\n const result = await createPlan(\n await resolveInput(source),\n await resolveInput(target),\n {\n ...supabase,\n skipDefaultPrivilegeSubtraction: true,\n },\n );\n let statements = result?.plan.statements ?? [];\n if (formatOptions != null) {\n statements = formatSqlStatements(statements, formatOptions);\n }\n if (Deno.env.get("PGDELTA_DEBUG")) {\n console.error(\n JSON.stringify({\n statementCount: statements.length,\n source: source ? "connected" : "null",\n target: target ? "connected" : "null",\n includedSchemas: includedSchemas ?? null,\n skipDefaultPrivilegeSubtraction: true,\n }),\n );\n }\n for (const sql of statements) {\n console.log(`${sql};`);\n }\n} catch (e) {\n console.error(e);\n // Force close event loop\n throw new Error("");\n}\n// Force close the event loop on the success path too. When SOURCE/TARGET are\n// live database URLs the plan opens connections whose keepalive handles can keep\n// the Edge Runtime worker alive after the diff has been written, so the container\n// never exits and the CLI — which follows this container\'s logs — hangs\n// indefinitely at 0% CPU (supabase/pg-toolbelt#312).\nthrow new Error("");\n'; /** `templates/pgdelta_declarative_export.ts` — exports declarative file payloads. */ export const legacyPgDeltaDeclarativeExportScript = - '// This script is executed inside Edge Runtime by the CLI to export a target\n// schema as declarative file payloads. It accepts either live DB URLs or\n// catalog-file references for SOURCE/TARGET, which enables cached sync flows.\nimport {\n createPlan,\n deserializeCatalog,\n exportDeclarativeSchema,\n} from "npm:@supabase/pg-delta@1.0.0-alpha.20";\nimport { supabase } from "npm:@supabase/pg-delta@1.0.0-alpha.20/integrations/supabase";\n\nasync function resolveInput(ref: string | undefined) {\n if (!ref) {\n return null;\n }\n if (ref.startsWith("postgres://") || ref.startsWith("postgresql://")) {\n return ref;\n }\n const json = await Deno.readTextFile(ref);\n return deserializeCatalog(JSON.parse(json));\n}\n\nconst source = Deno.env.get("SOURCE");\nconst target = Deno.env.get("TARGET");\n\nconst includedSchemas = Deno.env.get("INCLUDED_SCHEMAS");\nif (includedSchemas) {\n const schemas = includedSchemas.split(",");\n const schemaFilter = {\n or: [{ "*/schema": schemas }, { "schema/name": schemas }],\n };\n supabase.filter = {\n and: [supabase.filter!, schemaFilter],\n } as unknown as typeof supabase.filter;\n}\n\nconst formatOptionsRaw = Deno.env.get("FORMAT_OPTIONS");\nlet formatOptions = undefined;\nif (formatOptionsRaw) {\n formatOptions = JSON.parse(formatOptionsRaw);\n}\ntry {\n const result = await createPlan(\n await resolveInput(source),\n await resolveInput(target),\n {\n ...supabase,\n skipDefaultPrivilegeSubtraction: true,\n },\n );\n if (!result) {\n console.log(\n JSON.stringify({\n version: 1,\n mode: "declarative",\n files: [],\n }),\n );\n } else {\n const output = exportDeclarativeSchema(result, {\n integration: supabase,\n formatOptions,\n });\n console.log(\n JSON.stringify(output, (_key, value) =>\n typeof value === "bigint" ? Number(value) : value,\n ),\n );\n }\n} catch (e) {\n console.error(e);\n // Force close event loop\n throw new Error("");\n}\n'; + '// This script is executed inside Edge Runtime by the CLI to export a target\n// schema as declarative file payloads. It accepts either live DB URLs or\n// catalog-file references for SOURCE/TARGET, which enables cached sync flows.\nimport {\n createPlan,\n deserializeCatalog,\n exportDeclarativeSchema,\n} from "npm:@supabase/pg-delta@1.0.0-alpha.20";\nimport { supabase } from "npm:@supabase/pg-delta@1.0.0-alpha.20/integrations/supabase";\n\nasync function resolveInput(ref: string | undefined) {\n if (!ref) {\n return null;\n }\n if (ref.startsWith("postgres://") || ref.startsWith("postgresql://")) {\n return ref;\n }\n const json = await Deno.readTextFile(ref);\n return deserializeCatalog(JSON.parse(json));\n}\n\nconst source = Deno.env.get("SOURCE");\nconst target = Deno.env.get("TARGET");\n\nconst includedSchemas = Deno.env.get("INCLUDED_SCHEMAS");\nif (includedSchemas) {\n const schemas = includedSchemas.split(",");\n const schemaFilter = {\n or: [{ "*/schema": schemas }, { "schema/name": schemas }],\n };\n supabase.filter = {\n and: [supabase.filter!, schemaFilter],\n } as unknown as typeof supabase.filter;\n}\n\nconst formatOptionsRaw = Deno.env.get("FORMAT_OPTIONS");\nlet formatOptions = undefined;\nif (formatOptionsRaw) {\n formatOptions = JSON.parse(formatOptionsRaw);\n}\ntry {\n const result = await createPlan(\n await resolveInput(source),\n await resolveInput(target),\n {\n ...supabase,\n skipDefaultPrivilegeSubtraction: true,\n },\n );\n if (!result) {\n console.log(\n JSON.stringify({\n version: 1,\n mode: "declarative",\n files: [],\n }),\n );\n } else {\n const output = exportDeclarativeSchema(result, {\n integration: supabase,\n formatOptions,\n });\n console.log(\n JSON.stringify(output, (_key, value) =>\n typeof value === "bigint" ? Number(value) : value,\n ),\n );\n }\n} catch (e) {\n console.error(e);\n // Force close event loop\n throw new Error("");\n}\n// Force close the event loop on the success path too. When SOURCE/TARGET are\n// live database URLs the plan opens connections whose keepalive handles can keep\n// the Edge Runtime worker alive after the export has been written, so the\n// container never exits and the CLI — which follows this container\'s logs —\n// hangs indefinitely at 0% CPU (supabase/pg-toolbelt#312).\nthrow new Error("");\n'; /** `templates/pgdelta_catalog_export.ts` — serializes a catalog snapshot for caching. */ export const legacyPgDeltaCatalogExportScript = - '// This script serializes a database catalog for caching/reuse in declarative\n// sync workflows, so later diff/export operations can run from file references.\nimport {\n createManagedPool,\n extractCatalog,\n serializeCatalog,\n stringifyCatalogSnapshot,\n} from "npm:@supabase/pg-delta@1.0.0-alpha.20";\n\nconst target = Deno.env.get("TARGET");\nconst role = Deno.env.get("ROLE") ?? undefined;\n\nif (!target) {\n console.error("TARGET is required");\n throw new Error("");\n}\nconst { pool, close } = await createManagedPool(target, { role });\n\ntry {\n const catalog = await extractCatalog(pool);\n console.log(stringifyCatalogSnapshot(serializeCatalog(catalog)));\n} catch (e) {\n console.error(e);\n throw new Error("");\n} finally {\n await close();\n}\n'; + '// This script serializes a database catalog for caching/reuse in declarative\n// sync workflows, so later diff/export operations can run from file references.\nimport {\n createManagedPool,\n extractCatalog,\n serializeCatalog,\n stringifyCatalogSnapshot,\n} from "npm:@supabase/pg-delta@1.0.0-alpha.20";\n\nconst target = Deno.env.get("TARGET");\nconst role = Deno.env.get("ROLE") ?? undefined;\n\nif (!target) {\n console.error("TARGET is required");\n throw new Error("");\n}\nconst { pool, close } = await createManagedPool(target, { role });\n\ntry {\n const catalog = await extractCatalog(pool);\n console.log(stringifyCatalogSnapshot(serializeCatalog(catalog)));\n} catch (e) {\n console.error(e);\n // Force close event loop\n throw new Error("");\n} finally {\n await close();\n}\n// Force close the event loop on the success path too. The connection pool can\n// leave keepalive handles registered even after close() resolves, which keeps\n// the Edge Runtime worker (and therefore the container) alive after the catalog\n// has already been written to stdout. The CLI streams this container\'s logs with\n// Follow:true, so a worker that never exits hangs the parent `__catalog`\n// subprocess — and the declarative-sync command that spawned it — indefinitely\n// at 0% CPU (supabase/pg-toolbelt#312).\nthrow new Error("");\n'; /** `internal/pgdelta/templates/pgdelta_declarative_apply.ts` — applies declarative files to TARGET. */ export const legacyPgDeltaDeclarativeApplyScript = - '// This script applies declarative schema files to a target database and emits\n// structured JSON so the Go caller can report success/failure deterministically.\nimport {\n applyDeclarativeSchema,\n loadDeclarativeSchema,\n} from "npm:@supabase/pg-delta@1.0.0-alpha.20/declarative";\n\nconst schemaPath = Deno.env.get("SCHEMA_PATH");\nconst target = Deno.env.get("TARGET");\n\nif (!schemaPath) {\n throw new Error("SCHEMA_PATH is required");\n}\nif (!target) {\n throw new Error("TARGET is required");\n}\n\ntry {\n const content = await loadDeclarativeSchema(schemaPath);\n if (content.length === 0) {\n console.log(JSON.stringify({ status: "success", totalStatements: 0 }));\n } else {\n const result = await applyDeclarativeSchema({\n content,\n targetUrl: target,\n });\n const apply = result?.apply;\n if (!apply) {\n throw new Error("pg-delta apply returned no result");\n }\n const payload = {\n status: apply.status,\n totalStatements: result.totalStatements ?? 0,\n totalRounds: apply.totalRounds ?? 0,\n totalApplied: apply.totalApplied ?? 0,\n totalSkipped: apply.totalSkipped ?? 0,\n errors: apply.errors ?? [],\n stuckStatements: apply.stuckStatements ?? [],\n // validationErrors is populated when the final\n // check_function_bodies=on pass catches issues that didn\'t surface during\n // the initial apply rounds (e.g. a function body that references a\n // column whose type changed). Without surfacing this field, callers see\n // status=error with empty errors/stuckStatements and no actionable info.\n validationErrors: apply.validationErrors ?? [],\n diagnostics: result.diagnostics ?? [],\n };\n console.log(JSON.stringify(payload));\n if (apply.status !== "success") {\n throw new Error("pg-delta apply failed with status: " + apply.status);\n }\n }\n} catch (e) {\n throw e instanceof Error ? e : new Error(String(e));\n}\n'; + '// This script applies declarative schema files to a target database and emits\n// structured JSON so the Go caller can report success/failure deterministically.\nimport {\n applyDeclarativeSchema,\n loadDeclarativeSchema,\n} from "npm:@supabase/pg-delta@1.0.0-alpha.20/declarative";\n\nconst schemaPath = Deno.env.get("SCHEMA_PATH");\nconst target = Deno.env.get("TARGET");\n\nif (!schemaPath) {\n throw new Error("SCHEMA_PATH is required");\n}\nif (!target) {\n throw new Error("TARGET is required");\n}\n\ntry {\n const content = await loadDeclarativeSchema(schemaPath);\n if (content.length === 0) {\n console.log(JSON.stringify({ status: "success", totalStatements: 0 }));\n } else {\n const result = await applyDeclarativeSchema({\n content,\n targetUrl: target,\n });\n const apply = result?.apply;\n if (!apply) {\n throw new Error("pg-delta apply returned no result");\n }\n const payload = {\n status: apply.status,\n totalStatements: result.totalStatements ?? 0,\n totalRounds: apply.totalRounds ?? 0,\n totalApplied: apply.totalApplied ?? 0,\n totalSkipped: apply.totalSkipped ?? 0,\n errors: apply.errors ?? [],\n stuckStatements: apply.stuckStatements ?? [],\n // validationErrors is populated when the final\n // check_function_bodies=on pass catches issues that didn\'t surface during\n // the initial apply rounds (e.g. a function body that references a\n // column whose type changed). Without surfacing this field, callers see\n // status=error with empty errors/stuckStatements and no actionable info.\n validationErrors: apply.validationErrors ?? [],\n diagnostics: result.diagnostics ?? [],\n };\n console.log(JSON.stringify(payload));\n if (apply.status !== "success") {\n throw new Error("pg-delta apply failed with status: " + apply.status);\n }\n }\n} catch (e) {\n throw e instanceof Error ? e : new Error(String(e));\n}\n// Force close the event loop on the success path. applyDeclarativeSchema opens a\n// connection to TARGET whose keepalive handles can keep the Edge Runtime worker\n// alive after the result JSON has been written, so the container never exits and\n// the CLI — which follows this container\'s logs — hangs indefinitely at 0% CPU\n// (supabase/pg-toolbelt#312). The catch above re-throws the real error, so this\n// only runs once a successful apply has been reported on stdout.\nthrow new Error("");\n'; /** * The npm dist-tag/version used for `@supabase/pg-delta` when diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.integration.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.integration.test.ts index a90d2476e4..f6c816c461 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.integration.test.ts @@ -48,6 +48,7 @@ function fakeEdgeRuntime(outcome: { stdout?: string; stderr?: string; fail?: str // "not required" — matching the no-SSL-env passthrough these tests assert. const probe = Layer.succeed(LegacyPgDeltaSslProbe, { requireSsl: () => Effect.succeed(false), + requireSslForHost: () => Effect.succeed(false), }); const failError = (exit: Exit.Exit) => diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pooler-fallback.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pooler-fallback.ts new file mode 100644 index 0000000000..9278b05691 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pooler-fallback.ts @@ -0,0 +1,101 @@ +import { Effect, Option } from "effect"; + +import { Output } from "../../../../shared/output/output.service.ts"; +import { legacyIsIPv6ConnectivityError } from "../../../shared/legacy-connect-errors.ts"; +import type { LegacyPgConnInput } from "../../../shared/legacy-db-connection.service.ts"; +import type { LegacyDbConnType } from "../../../shared/legacy-db-target-flags.ts"; +import { + legacyIsDirectDbHost, + legacyRunWithPoolerFallback as legacyRunWithSharedPoolerFallback, +} from "../../../shared/legacy-pooler-fallback.ts"; + +export { legacyEmitPoolerFallbackWarning } from "../../../shared/legacy-pooler-fallback.ts"; + +/** The exit/stderr pair a dump attempt surfaces for pooler-fallback classification. */ +interface LegacyPoolerFallbackResult { + readonly exitCode: number; + readonly stderr: string; +} + +/** + * Go's `PoolerFallbackConfig` host gate (`internal/db/dump/pooler_fallback.go:82-96`): + * a dump/diff is only rerouted through the pooler when it was a `--linked` run against + * a *direct* Supabase DB host (`db..`, never local/pooler). + * `ProjectRefFromDirectDbHost` already excludes local, and `PoolerFallbackEligible == + * linked` makes local impossible — the `!isLocal` check is belt-and-braces. Shared by + * the result-based dump/pull retry ({@link legacyRunWithPoolerFallback}) and the + * error-based diff retry in `db pull`; each ANDs in its own IPv6 classification of the + * relevant stderr/error. + */ +export const legacyIsDirectLinkedHost = (params: { + readonly connType: LegacyDbConnType; + readonly host: string; + readonly isLocal: boolean; + readonly projectHost: string; +}): boolean => + params.connType === "linked" && + !params.isLocal && + legacyIsDirectDbHost(params.host, params.projectHost); + +/** + * Container-level IPv6 → IPv4-pooler retry shared by `db dump` and `db pull`'s initial + * remote-schema dump — the single port of Go's `RunWithPoolerFallback` + * (`internal/db/dump/pooler_fallback.go:31-66`). Runs the first attempt's `result` + * through the host gate + IPv6 classification; when eligible and a pooler connection + * resolves, emits Go's warning and retries once via `runWithConn`, returning the + * retry's result. Otherwise returns the original `result` unchanged, so the caller's + * failure classification reads the correct stderr in both cases (Go returns the retry + * error on a failed retry, the original error on no fallback). + * + * The one load-bearing per-command difference is `reprintOnRetry`: Go prints the + * "Dumping ..." line *inside* `db dump`'s run closure, so it re-prints on the retry + * (`dump.go:39-45`); `db pull` prints it once *before* `RunWithPoolerFallback` + * (`pull.go:146`), so it does not. Callers pass the re-print effect (`db dump`) or + * `Effect.void` (`db pull`). + * + * Two Go behaviours are intentionally *not* reproduced here (pre-existing TS gaps, + * unchanged by this hoist): the `resetOutput` truncation between attempts (each caller + * (re)truncates its own file per attempt / streams to stdout) and the + * `SuggestIPv6Pooler` URL enrichment on the no-fallback hint (callers fall back to the + * generic `legacyIpv6Suggestion`). + */ +export const legacyRunWithPoolerFallback = Effect.fnUntraced(function* (params: { + /** The first attempt's result; returned unchanged when no fallback fires. */ + readonly result: LegacyPoolerFallbackResult; + readonly connType: LegacyDbConnType; + /** The direct connection host that failed (`resolved.conn.host`). */ + readonly host: string; + readonly isLocal: boolean; + /** `cliConfig.projectHost` — the direct-DB-host suffix (`supabase.co`/`.red`). */ + readonly projectHost: string; + /** + * Resolves the IPv4 pooler connection, already error-neutralised to `None` (the + * caller pipes `resolver.resolvePoolerFallback(...)` through `orElseSucceed(None)`) — + * Go treats any fallback-resolution error as "no fallback" and surfaces the original + * dump failure. A **thunk**: Go only resolves the pooler (creating a temp role) once + * the error is eligible, so this is invoked only after the gate passes, never on the + * happy path. + */ + readonly resolvePooler: () => Effect.Effect>; + /** Re-runs the dump against a connection (`db dump`/`db pull` each adapt their runner). */ + readonly runWithConn: ( + conn: LegacyPgConnInput, + ) => Effect.Effect; + /** `db dump` re-prints "Dumping ..." on retry; `db pull` passes `Effect.void`. */ + readonly reprintOnRetry: Effect.Effect; +}) { + return yield* legacyRunWithSharedPoolerFallback({ + run: Effect.succeed(params.result), + retry: (pooler) => params.reprintOnRetry.pipe(Effect.andThen(params.runWithConn(pooler))), + directHost: params.host, + eligible: legacyIsDirectLinkedHost({ + connType: params.connType, + host: params.host, + isLocal: params.isLocal, + projectHost: params.projectHost, + }), + resolveFallback: Effect.suspend(params.resolvePooler), + classifyResult: (result) => + result.exitCode !== 0 && legacyIsIPv6ConnectivityError(result.stderr), + }); +}); 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 be575f1409..370af5aacf 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 @@ -859,6 +859,103 @@ describe("legacy functions serve integration", () => { }); }); + it.live("binds git-root workspace imports for serve", () => { + deployMockState.runHandler = (command, args) => { + if (command !== "docker") { + throw new Error(`unexpected process: ${command}`); + } + if (args[0] === "container" && args[1] === "inspect") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "container" && args[1] === "rm") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "run") { + return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; + } + if (args[0] === "exec") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + throw new Error(`unexpected docker args: ${args.join(" ")}`); + }; + + const childSpawner = mockDockerLogSpawner([ + { + exitCode: 1, + stderr: "workspace import logs failed", + }, + ]); + + return Effect.gen(function* () { + const sharedPath = join(tempRoot.current, "packages", "shared", "src", "index.ts"); + + yield* Effect.promise(() => mkdir(join(tempRoot.current, ".git"), { recursive: true })); + yield* Effect.promise(() => + writeProjectConfig( + [ + 'project_id = "test-project"', + "[functions.hello]", + 'entrypoint = "./functions/hello/index.ts"', + 'import_map = "./functions/hello/deno.json"', + "", + ].join("\n"), + ), + ); + yield* Effect.promise(() => + writeProjectFile("packages/shared/src/index.ts", 'export const shared = "hello"\n'), + ); + yield* Effect.promise(() => + writeFunctionFile( + "hello", + "index.ts", + [ + 'import { shared } from "@repo/shared"', + "Deno.serve(() => new Response(shared))", + "", + ].join("\n"), + ), + ); + yield* Effect.promise(() => + writeFunctionFile( + "hello", + "deno.json", + JSON.stringify({ + imports: { + "@repo/shared": "../../../packages/shared/src/index.ts", + }, + }), + ), + ); + + const { layer } = setupServe({ childSpawner }); + const error = yield* legacyFunctionsServe(baseFlags()).pipe( + Effect.provide(layer), + Effect.flip, + ); + + expect(error).toBeInstanceOf(Error); + if (error instanceof Error) { + expect(error.message).toContain("workspace import logs failed"); + } + + const dockerRun = deployMockState.runCalls.find( + (call) => call.command === "docker" && call.args[0] === "run", + ); + expect(dockerRun).toBeDefined(); + if (dockerRun === undefined) { + throw new Error("expected docker run invocation"); + } + const resolvedSharedPath = realpathSync(sharedPath); + expect( + extractFlagValues(dockerRun.args, "-v").some( + (value) => + value.startsWith(`${resolvedSharedPath}:`) && + value.endsWith("/packages/shared/src/index.ts:ro"), + ), + ).toBe(true); + }); + }); + it.live("restarts the runtime when watched files change", () => { deployMockState.runHandler = (command, args) => { if (command !== "docker") { 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 d760643230..6396c658c6 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 @@ -5,6 +5,7 @@ import { Effect, FileSystem, Option, Path } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; +import { findGitRootPath } from "../../../../shared/git/git-root.ts"; import { LegacyDebugLogger } from "../../../shared/legacy-debug-logger.service.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; import { LegacyYesFlag } from "../../../../shared/legacy/global-flags.ts"; @@ -219,37 +220,19 @@ const loadSigningKeysConfig = Effect.fnUntraced(function* (cwd: string) { } satisfies ResolvedSigningKeysConfig; }); -const findGitRoot = Effect.fnUntraced(function* (start: string) { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - - let current = path.resolve(start); - const root = path.parse(current).root; - - while (true) { - if (yield* fs.exists(path.join(current, ".git")).pipe(Effect.orElseSucceed(() => false))) { - return Option.some(current); - } - if (current === root) { - return Option.none(); - } - current = path.dirname(current); - } -}); - const isGitIgnored = Effect.fnUntraced(function* (filePath: string, searchFrom: string) { const path = yield* Path.Path; - const gitRoot = yield* findGitRoot(searchFrom); - if (Option.isNone(gitRoot)) { + const gitRoot = yield* Effect.tryPromise(() => findGitRootPath(searchFrom)).pipe(Effect.orDie); + if (gitRoot === undefined) { return Option.none(); } const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const relative = path.relative(gitRoot.value, filePath).replaceAll("\\", "/"); + const relative = path.relative(gitRoot, filePath).replaceAll("\\", "/"); const command = ChildProcess.make( "git", // `--` terminates flag parsing so a path beginning with `-` is never read as a git option. - ["-C", gitRoot.value, "check-ignore", "--quiet", "--", relative], + ["-C", gitRoot, "check-ignore", "--quiet", "--", relative], { detached: true, stdin: "ignore", diff --git a/apps/cli/src/legacy/commands/gen/types/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/gen/types/SIDE_EFFECTS.md index fbfc293fa2..1bc6151b77 100644 --- a/apps/cli/src/legacy/commands/gen/types/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/gen/types/SIDE_EFFECTS.md @@ -5,7 +5,7 @@ | Path | Format | When | | ----------------------------------------- | ---------- | ---------------------------------------------------------------------------------------- | | `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and `--linked` or `--project-id` | -| `/supabase/config.toml` | TOML | when `--local` (required) or `--db-url` (best-effort) is specified | +| `/supabase/config.toml` | TOML | when selecting schemas from config; required for `--local`, best-effort otherwise | | `/supabase/.temp/rest-version` | plain text | `--local` only, when `db.major_version > 14` — forces v9 compat if the tag contains `v9` | | `/supabase/.temp/pgmeta-version` | plain text | `--local` only — overrides the pg-meta docker image tag | @@ -16,50 +16,66 @@ | — | — | — | No files are written. Container env (including the DB URL and TLS CA bundle) is -passed via `docker run --env KEY=VALUE` arguments, mirroring Go's +passed via container CLI `run --env KEY=VALUE` arguments, mirroring Go's `container.Config.Env`; no temporary env-file is created. ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ------------------------------------- | ------------ | ------------ | -------------------------------- | -| `GET` | `/v1/projects/{ref}/types/typescript` | Bearer token | none | TypeScript type definitions text | - -Called only for `--linked`, `--project-id`, and the implicit linked-project -fallback. `--local` and `--db-url` do not call the Management API. +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ------------------------------------------- | ------------ | ---------------------- | ------------------------------------------ | +| `GET` | `/v1/projects/{ref}/types/typescript` | Bearer token | none | TypeScript type definitions text | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | (presence only; `404` ⇒ branch ref) | +| `GET` | `/v1/branches/{branch_id_or_ref}` | Bearer token | none | `db_host`, `db_port`, `db_user`, `db_pass` | +| `POST` | `/v1/projects/{ref}/cli/login-role` | Bearer token | `{ read_only: false }` | temporary `role` and `password` | +| `GET` | `/v1/projects/{ref}/config/database/pooler` | Bearer token | none | primary pooler `connection_string` | + +The TypeScript endpoint is called for `--linked`, `--project-id`, and the implicit +linked-project fallback when `--lang=typescript`. For other languages on those +project-ref paths, the project endpoint is probed first: a `404` means the ref is a +preview branch (any 404 body), so the branch endpoint supplies the branch database +host/port and credentials for pg-meta. Otherwise the database connection is resolved +for the ref and the login-role endpoint supplies temporary credentials for pg-meta. +On an IPv4-only network where the direct database host is unreachable, project-ref +pg-meta generation retries once through the IPv4 pooler only when the current target +host is the project's direct `db.` host and the pooler URL matches the expected +tenant and pooler domain. An explicit `--project-id` ref fetches the primary pooler +config for that ref to build the fallback connection (the saved workdir +`.temp/pooler-url` is ignored because the ref may differ from the linked workdir). +`--local` and `--db-url` do not call the Management API. ## Subprocesses -| Command | When | Purpose | -| ----------------------------------------------------------------------------- | --------------------- | -------------------------------------------------- | -| `docker container inspect supabase_db_` | `--local` | assert `supabase start` is running | -| `docker run --rm --network --env … node dist/server/server.js` | `--local`, `--db-url` | run pg-meta to generate types from a live database | +| Command | When | Purpose | +| -------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | -------------------------------------------------- | +| `docker`/`podman container inspect supabase_db_` | `--local` | assert `supabase start` is running | +| `docker`/`podman run --rm --network --env … node dist/server/server.js` | `--local`, `--db-url`, project-ref paths with non-TypeScript `--lang` | run pg-meta to generate types from a live database | A raw TCP `SSLRequest` probe is also opened to the target database host/port to -detect TLS support before launching pg-meta (mirrors Go's `isRequireSSL`). +detect TLS support before launching pg-meta (mirrors Go's `isRequireSSL`) with the +default 10s pg-delta probe timeout. ## Environment Variables -| Variable | Purpose | Required? | -| ---------------------------------- | ----------------------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token for linked/project-id mode | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | -| `SUPABASE_DB_PASSWORD` | local database password for `--local` | no (defaults to `postgres`) | -| `SUPABASE_SERVICES_HOSTNAME` | host used for the local TLS probe | no (defaults to `127.0.0.1`) | -| `SUPABASE_INTERNAL_IMAGE_REGISTRY` | pg-meta image registry override (`docker.io` → Docker Hub) | no (defaults to the ECR registry) | -| `SUPABASE_CA_SKIP_VERIFY` | when `true`, prints a TLS-verification-disabled warning to stderr | no | +| Variable | Purpose | Required? | +| ---------------------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token for linked/project-id mode | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| `SUPABASE_DB_PASSWORD` | database password for `--local` and the `--linked` workdir project | no (defaults to `postgres`; **ignored** for ad-hoc `--project-id`, which always mints a temporary login role) | +| `SUPABASE_SERVICES_HOSTNAME` | host used for the local TLS probe | no (defaults to `127.0.0.1`) | +| `SUPABASE_INTERNAL_IMAGE_REGISTRY` | pg-meta image registry override (`docker.io` → Docker Hub; any other value → that registry) | no (defaults to the ECR registry) | +| `SUPABASE_CA_SKIP_VERIFY` | when `true`, prints a TLS-verification-disabled warning to stderr | no | ## Exit Codes -| Code | Condition | -| ---- | ---------------------------------------------------------------- | -| `0` | success — types printed to stdout | -| `1` | no target specified (must use one flag) | -| `1` | mutually exclusive flags combined | -| `1` | `--postgrest-v9-compat` used without `--db-url` | -| `1` | invalid `--query-timeout` duration or invalid `--db-url` | -| `1` | `supabase start` not running (`--local`) or db inspection failed | -| `1` | API error, TLS probe failure, or pg-meta container non-zero exit | +| Code | Condition | +| ---- | --------------------------------------------------------------------------------------------------------------------------- | +| `0` | success — types printed to stdout | +| `1` | no target specified (must use one flag) | +| `1` | mutually exclusive flags combined | +| `1` | pg-meta-only flags used with remote TypeScript generation, except implicit TypeScript `--query-timeout` warns and continues | +| `1` | invalid `--query-timeout` duration or invalid `--db-url` | +| `1` | `supabase start` not running (`--local`) or db inspection failed | +| `1` | API error, TLS probe failure, or pg-meta container non-zero exit | ## Output @@ -79,12 +95,18 @@ Not applicable. ## Notes - Exactly one of `--local`, `--linked`, `--project-id`, or `--db-url` must be specified. -- `--lang` flag accepts `typescript` (default), `go`, `swift`, or `python`. Non-typescript - languages require a direct database connection (`--local` or `--db-url`). +- `--lang` flag accepts `typescript` (default), `go`, `swift`, or `python`. Project-ref + paths use the Management API for TypeScript, and use a project database host + + temporary login role + pg-meta for other languages. - `--schema` / `-s` accepts a comma-separated list of schemas to include. -- `--swift-access-control` accepts `internal` (default) or `public`. -- `--postgrest-v9-compat` generates types compatible with PostgREST v9 and below (requires `--db-url`). -- `--query-timeout` sets the maximum timeout for the database query (default 15s, direct connection only). +- `--swift-access-control` accepts `internal` (default) or `public`, and requires + `--lang swift`. +- `--postgrest-v9-compat` generates types compatible with PostgREST v9 and below for pg-meta + generation (`--local`, `--db-url`, or non-TypeScript project-ref paths). +- `--query-timeout` sets the maximum timeout for pg-meta database queries (default 15s). + On remote TypeScript generation, explicit `--linked` or `--project-id` invocations + error because pg-meta is not used; the implicit linked TypeScript fallback prints a + warning and ignores the flag. - The legacy positional language argument (`supabase gen types typescript`) is still accepted; any other positional language requires an explicit `--lang` flag. - The linked-project telemetry cache is written only when a project ref is resolved diff --git a/apps/cli/src/legacy/commands/gen/types/types.e2e.test.ts b/apps/cli/src/legacy/commands/gen/types/types.e2e.test.ts new file mode 100644 index 0000000000..8fb849a120 --- /dev/null +++ b/apps/cli/src/legacy/commands/gen/types/types.e2e.test.ts @@ -0,0 +1,391 @@ +import { spawn } from "node:child_process"; +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { describe, expect, test } from "vitest"; +import { + makeTempHome, + makeTempStackProject, + runSupabase, +} from "../../../../../tests/helpers/cli.ts"; +import { dockerfileServiceImage } from "../../../../shared/services/dockerfile-images.ts"; +import { localDbContainerId, localNetworkId } from "../../../shared/legacy-docker-ids.ts"; +import { legacyGetRegistryImageUrl } from "../../../shared/legacy-docker-registry.ts"; + +const TYPEGEN_LANGS = ["typescript", "go", "swift", "python"] as const; +type TypegenLang = (typeof TYPEGEN_LANGS)[number]; + +const LOCAL_POSTGRES_IMAGE = legacyGetRegistryImageUrl(dockerfileServiceImage("pg")); +const LOCAL_POSTGRES_TIMEOUT_MS = 120_000; +const TYPEGEN_TIMEOUT_MS = 90_000; +const REMOTE_E2E_FLAG = "SUPABASE_TYPEGEN_E2E_REMOTE"; +const REMOTE_PROJECT_REF_ENV = "SUPABASE_TEST_PROJECT_REF"; +const OUTPUT_TAIL_LENGTH = 4_000; + +interface CommandResult { + readonly stdout: string; + readonly stderr: string; + readonly exitCode: number; +} + +function tokenlessEnv(profilePath: string, projectDir: string) { + return { + SUPABASE_ACCESS_TOKEN: "", + SUPABASE_DB_PASSWORD: "postgres", + SUPABASE_PROFILE: profilePath, + SUPABASE_WORKDIR: projectDir, + }; +} + +function remoteEnv(accessToken: string, projectDir: string) { + return { + SUPABASE_ACCESS_TOKEN: accessToken, + SUPABASE_DB_PASSWORD: "", + SUPABASE_WORKDIR: projectDir, + }; +} + +async function writeOfflineProfile(projectDir: string): Promise { + const profilePath = join(projectDir, "offline-profile.yaml"); + await writeFile( + profilePath, + [ + "name: cli-typegen-e2e", + 'api_url: "http://127.0.0.1:1"', + 'dashboard_url: "http://127.0.0.1:1/dashboard"', + 'docs_url: "http://127.0.0.1:1/docs"', + 'project_host: "example.invalid"', + 'pooler_host: ""', + "", + ].join("\n"), + ); + return profilePath; +} + +async function writeLocalConfig(projectDir: string, projectId: string, dbPort: number) { + const supabaseDir = join(projectDir, "supabase"); + await mkdir(supabaseDir, { recursive: true }); + await writeFile( + join(supabaseDir, "config.toml"), + [ + `project_id = "${projectId}"`, + "", + "[api]", + 'schemas = ["public"]', + "", + "[db]", + `port = ${dbPort}`, + "major_version = 17", + "", + ].join("\n"), + ); +} + +function combinedOutput(result: { stdout: string; stderr: string }) { + return `${result.stdout}\n${result.stderr}`; +} + +function expectSucceeded( + command: string, + result: { stdout: string; stderr: string; exitCode: number }, +) { + expect(result.exitCode, `${command}\n${combinedOutput(result)}`).toBe(0); +} + +function outputTail(output: string) { + return output.length > OUTPUT_TAIL_LENGTH + ? output.slice(output.length - OUTPUT_TAIL_LENGTH) + : output; +} + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function runCommand( + command: string, + args: ReadonlyArray, + options: { readonly timeoutMs?: number } = {}, +): Promise { + return new Promise((resolve) => { + const child = spawn(command, args, { + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + let settled = false; + let timedOut = false; + const timer = + options.timeoutMs === undefined + ? undefined + : setTimeout(() => { + timedOut = true; + child.kill("SIGKILL"); + }, options.timeoutMs); + + child.stdout?.on("data", (data: Buffer) => { + stdout += data.toString(); + }); + child.stderr?.on("data", (data: Buffer) => { + stderr += data.toString(); + }); + child.once("error", (error) => { + if (settled) return; + settled = true; + if (timer !== undefined) clearTimeout(timer); + resolve({ stdout, stderr: `${stderr}${String(error)}`, exitCode: 1 }); + }); + child.once("close", (code) => { + if (settled) return; + settled = true; + if (timer !== undefined) clearTimeout(timer); + resolve({ + stdout, + stderr: timedOut ? `${stderr}\nTimed out after ${options.timeoutMs}ms` : stderr, + exitCode: code ?? 1, + }); + }); + }); +} + +function runDocker(args: ReadonlyArray, options?: { readonly timeoutMs?: number }) { + return runCommand("docker", args, options); +} + +async function expectDockerSucceeded(args: ReadonlyArray, timeoutMs?: number) { + const result = await runDocker(args, { timeoutMs }); + expectSucceeded(`docker ${args.join(" ")}`, result); + return result; +} + +async function waitForLocalPostgres(containerName: string) { + const startedAt = Date.now(); + let lastResult: CommandResult = { stdout: "", stderr: "", exitCode: 1 }; + let consecutiveReadyChecks = 0; + while (Date.now() - startedAt < LOCAL_POSTGRES_TIMEOUT_MS) { + lastResult = await runDocker( + [ + "exec", + "-e", + "PGPASSWORD=postgres", + containerName, + "psql", + "-U", + "postgres", + "-d", + "postgres", + "-tAc", + "select 1", + ], + { timeoutMs: 5_000 }, + ); + if (lastResult.exitCode === 0 && lastResult.stdout.trim() === "1") { + consecutiveReadyChecks += 1; + } else { + consecutiveReadyChecks = 0; + } + if (consecutiveReadyChecks >= 2) { + return; + } + await sleep(1_000); + } + + const logs = await runDocker(["logs", containerName], { timeoutMs: 10_000 }); + throw new Error( + [ + `Timed out waiting for ${containerName}`, + outputTail(combinedOutput(lastResult)), + outputTail(combinedOutput(logs)), + ].join("\n"), + ); +} + +async function startLocalPostgres(input: { readonly projectId: string; readonly dbPort: number }) { + const containerName = localDbContainerId(input.projectId); + const networkName = localNetworkId(input.projectId); + + await expectDockerSucceeded(["network", "create", networkName], 30_000); + await expectDockerSucceeded( + [ + "run", + "--detach", + "--rm", + "--name", + containerName, + "--network", + networkName, + "--network-alias", + "db", + "-p", + `${input.dbPort}:5432`, + "-e", + "POSTGRES_PASSWORD=postgres", + LOCAL_POSTGRES_IMAGE, + "postgres", + "-D", + "/etc/postgresql", + "-c", + "wal_level=logical", + "-c", + "max_wal_senders=5", + "-c", + "max_replication_slots=5", + ], + LOCAL_POSTGRES_TIMEOUT_MS, + ); + await waitForLocalPostgres(containerName); + + return { containerName, networkName }; +} + +async function seedSmokeTable(containerName: string) { + await expectDockerSucceeded( + [ + "exec", + "-e", + "PGPASSWORD=postgres", + containerName, + "psql", + "-U", + "postgres", + "-d", + "postgres", + "-v", + "ON_ERROR_STOP=1", + "-c", + [ + "create table if not exists public.typegen_smoke (", + "id bigint generated by default as identity primary key,", + "name text not null,", + "is_active boolean not null default true,", + "created_at timestamptz not null default now()", + ");", + ].join(" "), + ], + 30_000, + ); +} + +async function cleanupLocalPostgres(input: { + readonly containerName: string; + readonly networkName: string; +}) { + await runDocker(["rm", "-f", input.containerName], { timeoutMs: 30_000 }); + await runDocker(["network", "rm", input.networkName], { timeoutMs: 30_000 }); +} + +function expectNoRemoteAuthPath(result: { stdout: string; stderr: string }) { + const output = combinedOutput(result); + expect(output).not.toContain("Access token not provided"); + expect(output).not.toContain("api.supabase.com"); + expect(output).not.toContain("127.0.0.1:1"); +} + +function expectLanguageShape(lang: TypegenLang, stdout: string) { + expect(stdout.trim().length, `${lang} stdout`).toBeGreaterThan(0); + switch (lang) { + case "typescript": + expect(stdout).toContain("export type Database"); + break; + case "go": + expect(stdout).toMatch(/\btype\b/); + break; + case "swift": + expect(stdout).toMatch(/\bstruct\b/); + break; + case "python": + expect(stdout).toContain("from __future__ import annotations"); + break; + } +} + +function expectLocalSmokeTable(lang: TypegenLang, stdout: string) { + if (lang === "typescript") { + expect(stdout).toContain("typegen_smoke"); + return; + } + expect(stdout).toContain("TypegenSmoke"); +} + +describe("legacy gen types e2e", () => { + test( + "generates all supported languages from a tokenless local stack", + { timeout: LOCAL_POSTGRES_TIMEOUT_MS + TYPEGEN_TIMEOUT_MS * TYPEGEN_LANGS.length }, + async () => { + const home = makeTempHome(); + const project = await makeTempStackProject("supabase-typegen-local-e2e-"); + const projectId = `typegen${project.ports.dbPort}`; + const profilePath = await writeOfflineProfile(project.dir); + const env = tokenlessEnv(profilePath, project.dir); + const localPostgres = { + containerName: localDbContainerId(projectId), + networkName: localNetworkId(projectId), + }; + + try { + await writeLocalConfig(project.dir, projectId, project.ports.dbPort); + await cleanupLocalPostgres(localPostgres); + await startLocalPostgres({ projectId, dbPort: project.ports.dbPort }); + await seedSmokeTable(localPostgres.containerName); + + for (const lang of TYPEGEN_LANGS) { + const result = await runSupabase( + ["gen", "types", "--local", "--lang", lang, "--schema", "public"], + { + cwd: project.dir, + home: home.dir, + env, + entrypoint: "legacy", + exitTimeoutMs: TYPEGEN_TIMEOUT_MS, + }, + ); + expectSucceeded(`supabase gen types --local --lang ${lang}`, result); + expectNoRemoteAuthPath(result); + expectLanguageShape(lang, result.stdout); + expectLocalSmokeTable(lang, result.stdout); + } + } finally { + await cleanupLocalPostgres(localPostgres); + } + }, + ); + + const remoteProjectRef = process.env[REMOTE_PROJECT_REF_ENV]; + const remoteAccessToken = process.env["SUPABASE_ACCESS_TOKEN"]; + const remoteEnabled = process.env[REMOTE_E2E_FLAG] === "1"; + + const remoteTest = remoteEnabled ? test : test.skip; + + remoteTest( + "generates all supported languages from a remote project", + { timeout: TYPEGEN_TIMEOUT_MS * TYPEGEN_LANGS.length }, + async () => { + const home = makeTempHome(); + const project = await makeTempStackProject("supabase-typegen-remote-e2e-"); + if ( + remoteProjectRef === undefined || + remoteProjectRef.length === 0 || + remoteAccessToken === undefined || + remoteAccessToken.length === 0 + ) { + throw new Error( + `Set ${REMOTE_E2E_FLAG}=1, ${REMOTE_PROJECT_REF_ENV}, and SUPABASE_ACCESS_TOKEN to run remote typegen e2e.`, + ); + } + + for (const lang of TYPEGEN_LANGS) { + const result = await runSupabase( + ["gen", "types", "--project-id", remoteProjectRef, "--lang", lang, "--schema", "public"], + { + cwd: project.dir, + home: home.dir, + env: remoteEnv(remoteAccessToken, project.dir), + entrypoint: "legacy", + exitTimeoutMs: TYPEGEN_TIMEOUT_MS, + }, + ); + expectSucceeded(`supabase gen types --project-id --lang ${lang}`, result); + expectLanguageShape(lang, result.stdout); + } + }, + ); +}); diff --git a/apps/cli/src/legacy/commands/gen/types/types.handler.ts b/apps/cli/src/legacy/commands/gen/types/types.handler.ts index 156d5367b2..6008a3849e 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.handler.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.handler.ts @@ -1,7 +1,11 @@ import { loadProjectConfig } from "@supabase/config"; import { ChildProcessSpawner } from "effect/unstable/process"; import { Effect, FileSystem, Option, Path, Stdio, Stream } from "effect"; -import { LegacyDebugFlag, LegacyNetworkIdFlag } from "../../../../shared/legacy/global-flags.ts"; +import { + LegacyDebugFlag, + LegacyDnsResolverFlag, + LegacyNetworkIdFlag, +} from "../../../../shared/legacy/global-flags.ts"; import { Output } from "../../../../shared/output/output.service.ts"; import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; import { LegacyProjectNotLinkedError } from "../../../config/legacy-project-ref.errors.ts"; @@ -10,23 +14,36 @@ import { PROJECT_NOT_LINKED_MESSAGE, } from "../../../config/legacy-project-ref.service.ts"; import { spawnContainerCli } from "../../../shared/legacy-container-cli.ts"; +import { + legacyIsIPv6ConnectivityError, + legacyIsIPv6ConnectivityErrorCause, +} from "../../../shared/legacy-connect-errors.ts"; import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; +import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; +import type { LegacyDbConfigFlags } from "../../../shared/legacy-db-config.types.ts"; +import { legacyPoolerConfigFromConnectionString } from "../../../shared/legacy-db-config.parse.ts"; +import type { LegacyPgConnInput } from "../../../shared/legacy-db-connection.service.ts"; +import { legacyToPostgresURL } from "../../../shared/legacy-postgres-url.ts"; import { legacyTempPaths } from "../../../shared/legacy-temp-paths.ts"; import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { LegacyPgDeltaSslProbe } from "../../../shared/legacy-pgdelta-ssl-probe.service.ts"; +import { + legacyIsDirectDbHost, + legacyRunWithPoolerFallback, +} from "../../../shared/legacy-pooler-fallback.ts"; import type { LegacyGenTypesFlags } from "./types.command.ts"; import { LegacyGenTypesNetworkError, LegacyGenTypesUnexpectedStatusError } from "./types.errors.ts"; import { legacyGetHostname } from "../../../shared/legacy-hostname.ts"; import { LegacyPlatformApiFactory } from "../../../auth/legacy-platform-api-factory.service.ts"; import { - buildPostgresUrl, defaultSchemas, + buildPostgresUrl, localDbContainerId, localDbPassword, localNetworkId, parseDatabaseUrl, parseQueryTimeoutSeconds, - probeTlsSupport, legacyRootCaBundle, resolvePgmetaImage, } from "./types.shared.ts"; @@ -38,6 +55,30 @@ const mapProjectTypesError = mapLegacyHttpError({ statusMessage: (_status, body) => `failed to retrieve generated types: ${body}`, }); +const mapProjectDatabaseHostError = mapLegacyHttpError({ + networkError: LegacyGenTypesNetworkError, + statusError: LegacyGenTypesUnexpectedStatusError, + networkMessage: (cause) => `failed to get project database config: ${cause}`, + statusMessage: (status, body) => `unexpected project database config status ${status}: ${body}`, +}); + +const mapBranchDatabaseConfigError = mapLegacyHttpError({ + networkError: LegacyGenTypesNetworkError, + statusError: LegacyGenTypesUnexpectedStatusError, + networkMessage: (cause) => `failed to get preview branch database config: ${cause}`, + statusMessage: (status, body) => + `unexpected preview branch database config status ${status}: ${body}`, +}); + +// A 404 from `GET /v1/projects/{ref}` means the ref is a preview branch rather +// than a project, so fall back to the branch config endpoint. Mirror the link +// handler, which treats *any* 404 as the branch case +// (`link.handler.ts:46-50` / Go's `checkRemoteProjectStatus`); do not narrow on +// the response body, since the Management API's 404 wording is not guaranteed. +function isProjectNotFound(cause: unknown) { + return cause instanceof LegacyGenTypesUnexpectedStatusError && cause.status === 404; +} + function ensureMutuallyExclusive( group: ReadonlyArray, present: ReadonlyArray, @@ -167,12 +208,15 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le const path = yield* Path.Path; const stdio = yield* Stdio.Stdio; const networkId = yield* LegacyNetworkIdFlag; + const dnsResolver = yield* LegacyDnsResolverFlag; const debug = yield* LegacyDebugFlag; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const rawArgs = yield* stdio.args; const platformApi = yield* LegacyPlatformApiFactory; const projectRef = yield* LegacyProjectRefResolver; const linkedProjectCache = yield* LegacyLinkedProjectCache; + const dbConfig = yield* LegacyDbConfigResolver; + const sslProbe = yield* LegacyPgDeltaSslProbe; yield* ensureMutuallyExclusive( ["local", "linked", "project-id", "db-url"], @@ -183,34 +227,6 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le ...(Option.isSome(flags.dbUrl) ? ["db-url"] : []), ], ); - yield* ensureMutuallyExclusive( - ["linked", "project-id", "swift-access-control"], - [ - ...(flags.linked ? ["linked"] : []), - ...(Option.isSome(flags.projectId) ? ["project-id"] : []), - ...(hasExplicitLongFlag(rawArgs, "swift-access-control") ? ["swift-access-control"] : []), - ], - ); - yield* ensureMutuallyExclusive( - ["linked", "project-id", "postgrest-v9-compat"], - [ - ...(flags.linked ? ["linked"] : []), - ...(Option.isSome(flags.projectId) ? ["project-id"] : []), - ...(flags.postgrestV9Compat ? ["postgrest-v9-compat"] : []), - ], - ); - yield* ensureMutuallyExclusive( - ["linked", "project-id", "query-timeout"], - [ - ...(flags.linked ? ["linked"] : []), - ...(Option.isSome(flags.projectId) ? ["project-id"] : []), - ...(hasExplicitLongFlag(rawArgs, "query-timeout") ? ["query-timeout"] : []), - ], - ); - - if (flags.postgrestV9Compat && Option.isNone(flags.dbUrl)) { - return yield* Effect.fail(new Error("--postgrest-v9-compat must used together with --db-url")); - } const legacyLang = findLegacyPositionalLanguage(rawArgs); if ( Option.isSome(legacyLang) && @@ -226,20 +242,88 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le const queryTimeoutSeconds = yield* parseQueryTimeoutSeconds(flags.queryTimeout); const lang = flags.lang; const swiftAccessControl = flags.swiftAccessControl; + const usesPgMeta = flags.local || Option.isSome(flags.dbUrl) || flags.lang !== "typescript"; - const loadConfig = () => loadProjectConfig(cliConfig.workdir); + if (hasExplicitLongFlag(rawArgs, "swift-access-control") && lang !== "swift") { + return yield* Effect.fail( + new Error("--swift-access-control can only be used with --lang swift"), + ); + } + if (flags.postgrestV9Compat && !usesPgMeta) { + return yield* Effect.fail( + new Error("--postgrest-v9-compat can only be used with pg-meta type generation"), + ); + } + if (hasExplicitLongFlag(rawArgs, "query-timeout") && !usesPgMeta) { + if (flags.linked || Option.isSome(flags.projectId)) { + return yield* Effect.fail( + new Error("--query-timeout can only be used with pg-meta type generation"), + ); + } + yield* output.raw( + "Warning: --query-timeout is ignored for remote TypeScript type generation.\n", + "stderr", + ); + } - const runProjectTypes = (projectRef: string, includedSchemas: ReadonlyArray) => + const loadConfig = () => loadProjectConfig(cliConfig.workdir); + const loadConfigForRef = (projectRef: string) => + loadProjectConfig(cliConfig.workdir, { projectRef }); + + const schemasFromConfig = (apiSchemas: ReadonlyArray | undefined) => + defaultSchemas(apiSchemas); + + const runProjectTypes = ( + projectRef: string, + includedSchemas: ReadonlyArray, + // True for an explicit `--project-id ` (an ad-hoc remote project that may + // differ from the current workdir); false for `--linked` / the linked fallback. + adHocProjectRef: boolean, + ) => Effect.gen(function* () { + const api = yield* platformApi.make; + if (lang !== "typescript") { - return yield* Effect.fail( - new Error( - `Unable to generate ${lang} types for selected project. Try using --db-url flag instead.`, + const projectResult = yield* api.v1.getProject({ ref: projectRef }).pipe( + Effect.catch(mapProjectDatabaseHostError), + Effect.as("project" as const), + Effect.catch((cause) => + isProjectNotFound(cause) + ? runPreviewBranchTypes(projectRef, includedSchemas).pipe( + Effect.as("branch" as const), + ) + : Effect.fail(cause), ), ); + if (projectResult === "branch") return; + + const resolveFlags: LegacyDbConfigFlags = { + dbUrl: Option.none(), + connType: "linked", + dnsResolver, + linkedProjectRef: Option.some(projectRef), + adHocProjectRef, + }; + const resolved = yield* dbConfig.resolve(resolveFlags); + const conn = resolved.conn; + yield* runPgMeta({ + url: legacyToPostgresURL(conn), + host: conn.host, + port: conn.port, + probeHost: conn.host, + probePort: conn.port, + networkMode: "host", + includedSchemas: includedSchemas.join(","), + postgrestV9Compat: flags.postgrestV9Compat, + poolerFallback: { + directHost: conn.host, + eligible: !resolved.isLocal && legacyIsDirectDbHost(conn.host, cliConfig.projectHost), + resolve: dbConfig.resolvePoolerFallback(resolveFlags), + }, + }); + return; } - const api = yield* platformApi.make; const response = yield* api.v1 .generateTypescriptTypes({ ref: projectRef, @@ -250,6 +334,58 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le yield* output.raw(response.types); }).pipe(Effect.ensuring(linkedProjectCache.cache(projectRef))); + const runPreviewBranchTypes = (branchRef: string, includedSchemas: ReadonlyArray) => + Effect.gen(function* () { + const api = yield* platformApi.make; + const branch = yield* api.v1 + .getABranchConfig({ branch_id_or_ref: branchRef }) + .pipe(Effect.catch(mapBranchDatabaseConfigError)); + + if (branch.db_user === undefined || branch.db_pass === undefined) { + return yield* Effect.fail(new Error("Preview branch database credentials are unavailable")); + } + const branchUser = branch.db_user; + const branchPassword = branch.db_pass; + + const poolerFallback = api.v1.getPoolerConfig({ ref: branch.ref }).pipe( + Effect.map((configs) => { + const primary = configs.find((config) => config.database_type === "PRIMARY"); + if (primary === undefined) return Option.none(); + const parsed = legacyPoolerConfigFromConnectionString( + branch.ref, + primary.connection_string, + cliConfig.poolerHost, + ); + return parsed._tag === "ok" + ? Option.some({ ...parsed.conn, password: branchPassword }) + : Option.none(); + }), + Effect.orElseSucceed(() => Option.none()), + ); + + yield* runPgMeta({ + url: legacyToPostgresURL({ + host: branch.db_host, + port: branch.db_port, + user: branchUser, + password: branchPassword, + database: "postgres", + }), + host: branch.db_host, + port: branch.db_port, + probeHost: branch.db_host, + probePort: branch.db_port, + networkMode: "host", + includedSchemas: includedSchemas.join(","), + postgrestV9Compat: flags.postgrestV9Compat, + poolerFallback: { + directHost: branch.db_host, + eligible: legacyIsDirectDbHost(branch.db_host, cliConfig.projectHost), + resolve: poolerFallback, + }, + }); + }); + const runPgMeta = (input: { readonly url: string; readonly host: string; @@ -260,71 +396,114 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le readonly includedSchemas: string; readonly postgrestV9Compat: boolean; readonly pgmetaVersionOverride?: string; + readonly poolerFallback?: { + readonly directHost: string; + readonly eligible: boolean; + readonly resolve: Effect.Effect, unknown>; + }; }) => Effect.scoped( Effect.gen(function* () { - yield* output.raw(`Connecting to ${input.host} ${input.port}\n`, "stderr"); - - // Mirrors Go's container.Config.Env ([]string of "KEY=VALUE"). We pass each - // entry as a `--env KEY=VALUE` argument rather than a `--env-file`: env-files - // split on newlines, so they cannot carry the multi-line PEM CA bundle, and a - // value containing a newline could inject an extra variable. Passing argv - // elements keeps each entry as exactly one variable regardless of its contents. - const env = [ - `PG_META_DB_URL=${input.url}`, - `PG_CONN_TIMEOUT_SECS=${queryTimeoutSeconds}`, - `PG_QUERY_TIMEOUT_SECS=${queryTimeoutSeconds}`, - `PG_META_GENERATE_TYPES=${lang}`, - `PG_META_GENERATE_TYPES_INCLUDED_SCHEMAS=${input.includedSchemas}`, - `PG_META_GENERATE_TYPES_SWIFT_ACCESS_CONTROL=${swiftAccessControl}`, - `PG_META_GENERATE_TYPES_DETECT_ONE_TO_ONE_RELATIONSHIPS=${String(!input.postgrestV9Compat)}`, - ]; - - // Go's isRequireSSL emits this warning to stderr when the probe runs with - // certificate verification disabled. Our wire-level SSLRequest probe never - // verifies certificates, so honour the same env var for stderr parity. - if (process.env["SUPABASE_CA_SKIP_VERIFY"] === "true") { - yield* output.raw( - "WARNING: TLS certificate verification disabled for SSL probe (SUPABASE_CA_SKIP_VERIFY=true)\n", - "stderr", - ); - } - - const useTls = yield* probeTlsSupport(input.probeHost, input.probePort); - if (useTls && !debug) { - env.push(`PG_META_DB_SSL_ROOT_CERT=${legacyRootCaBundle()}`); - } - - // Go's DockerStart applies `--network-id` over any base network mode (even the - // "host" mode used for --db-url), so honour the override here too. - const networkMode = Option.isSome(networkId) ? networkId.value : input.networkMode; - const args = [ - "run", - "--rm", - "--network", - networkMode, - ...env.flatMap((entry) => ["--env", entry]), - resolvePgmetaImage(input.pgmetaVersionOverride), - "node", - "dist/server/server.js", - ]; - const child = yield* spawnContainerCli(spawner, args, { - stdin: "ignore", - stdout: "pipe", - stderr: "pipe", - }); - - const [exitCode] = yield* Effect.all( - [ - child.exitCode.pipe(Effect.map(Number)), - forwardByteStream(child.stdout, (text) => output.raw(text, "stdout")), - forwardByteStream(child.stderr, (text) => output.raw(text, "stderr")), - ], - { concurrency: "unbounded" }, - ); - - if (exitCode !== 0) { - return yield* Effect.fail(new Error(`error running container: exit ${exitCode}`)); + const buildRun = (target: { + readonly url: string; + readonly host: string; + readonly port: number; + readonly probeHost: string; + readonly probePort: number; + }) => + Effect.gen(function* () { + yield* output.raw(`Connecting to ${target.host} ${target.port}\n`, "stderr"); + + // Mirrors Go's container.Config.Env ([]string of "KEY=VALUE"). We pass each + // entry as a `--env KEY=VALUE` argument rather than a `--env-file`: env-files + // split on newlines, so they cannot carry the multi-line PEM CA bundle, and a + // value containing a newline could inject an extra variable. Passing argv + // elements keeps each entry as exactly one variable regardless of its contents. + const env = [ + `PG_META_DB_URL=${target.url}`, + `PG_CONN_TIMEOUT_SECS=${queryTimeoutSeconds}`, + `PG_QUERY_TIMEOUT_SECS=${queryTimeoutSeconds}`, + `PG_META_GENERATE_TYPES=${lang}`, + `PG_META_GENERATE_TYPES_INCLUDED_SCHEMAS=${input.includedSchemas}`, + `PG_META_GENERATE_TYPES_SWIFT_ACCESS_CONTROL=${swiftAccessControl}`, + `PG_META_GENERATE_TYPES_DETECT_ONE_TO_ONE_RELATIONSHIPS=${String(!input.postgrestV9Compat)}`, + ]; + + // Go's isRequireSSL emits this warning to stderr when the probe runs with + // certificate verification disabled. Our wire-level SSLRequest probe never + // verifies certificates, so honour the same env var for stderr parity. + if (process.env["SUPABASE_CA_SKIP_VERIFY"] === "true") { + yield* output.raw( + "WARNING: TLS certificate verification disabled for SSL probe (SUPABASE_CA_SKIP_VERIFY=true)\n", + "stderr", + ); + } + + const useTls = yield* sslProbe.requireSslForHost(target.probeHost, target.probePort); + if (useTls && !debug) { + env.push(`PG_META_DB_SSL_ROOT_CERT=${legacyRootCaBundle()}`); + } + + // Go's DockerStart applies `--network-id` over any base network mode (even the + // "host" mode used for --db-url), so honour the override here too. + const networkMode = Option.isSome(networkId) ? networkId.value : input.networkMode; + const args = [ + "run", + "--rm", + "--network", + networkMode, + ...env.flatMap((entry) => ["--env", entry]), + resolvePgmetaImage(input.pgmetaVersionOverride), + "node", + "dist/server/server.js", + ]; + const child = yield* spawnContainerCli(spawner, args, { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + + let stderrText = ""; + const [exitCode] = yield* Effect.all( + [ + child.exitCode.pipe(Effect.map(Number)), + forwardByteStream(child.stdout, (text) => output.raw(text, "stdout")), + forwardByteStream(child.stderr, (text) => + Effect.sync(() => { + stderrText += text; + }).pipe(Effect.andThen(output.raw(text, "stderr"))), + ), + ], + { concurrency: "unbounded" }, + ); + return { exitCode, stderrText }; + }); + + const runTarget = (conn: LegacyPgConnInput) => + buildRun({ + url: legacyToPostgresURL(conn), + host: conn.host, + port: conn.port, + probeHost: conn.host, + probePort: conn.port, + }); + + const result = + input.poolerFallback === undefined + ? yield* buildRun(input) + : yield* legacyRunWithPoolerFallback({ + run: buildRun(input), + retry: runTarget, + directHost: input.poolerFallback.directHost, + eligible: input.poolerFallback.eligible, + resolveFallback: input.poolerFallback.resolve, + classifyError: legacyIsIPv6ConnectivityErrorCause, + classifyResult: (result) => + result.exitCode !== 0 && legacyIsIPv6ConnectivityError(result.stderrText), + }); + + if (result.exitCode !== 0) { + return yield* Effect.fail(new Error(`error running container: exit ${result.exitCode}`)); } }), ); @@ -436,21 +615,23 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le } if (flags.linked) { - const loaded = yield* loadConfig(); const ref = yield* projectRef.resolve(Option.none()); + const loaded = schemas.length > 0 ? null : yield* loadConfigForRef(ref); yield* runProjectTypes( ref, - schemas.length > 0 ? schemas : defaultSchemas(loaded?.config.api.schemas), + schemas.length > 0 ? schemas : schemasFromConfig(loaded?.config.api.schemas), + false, ); return; } if (Option.isSome(flags.projectId)) { - const loaded = yield* loadConfig(); const ref = yield* projectRef.resolve(flags.projectId); + const loaded = schemas.length > 0 ? null : yield* loadConfigForRef(ref); yield* runProjectTypes( ref, - schemas.length > 0 ? schemas : defaultSchemas(loaded?.config.api.schemas), + schemas.length > 0 ? schemas : schemasFromConfig(loaded?.config.api.schemas), + true, ); return; } @@ -468,10 +649,11 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le return Effect.fail(cause); }), ); - const loaded = yield* loadConfig(); + const loaded = schemas.length > 0 ? null : yield* loadConfigForRef(resolvedRef); yield* runProjectTypes( resolvedRef, - schemas.length > 0 ? schemas : defaultSchemas(loaded?.config.api.schemas), + schemas.length > 0 ? schemas : schemasFromConfig(loaded?.config.api.schemas), + false, ); }).pipe(Effect.ensuring(telemetryState.flush)); }); 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 a66868941f..5407d1bd2d 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 @@ -1,15 +1,25 @@ -import { existsSync, mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { createServer } from "node:net"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; +import type { + V1CreateLoginRoleOutput, + V1GetABranchConfigOutput, + V1GetPoolerConfigOutput, + V1GetProjectOutput, +} from "@supabase/api/effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import { CliOutput, Command } from "effect/unstable/cli"; +import * as HttpClientError from "effect/unstable/http/HttpClientError"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import { Deferred, Effect, Exit, Layer, Option, PlatformError, Sink, Stdio, Stream } from "effect"; import { LEGACY_GLOBAL_FLAGS, LegacyDebugFlag, + LegacyDnsResolverFlag, LegacyNetworkIdFlag, LegacyOutputFlag, } from "../../../../shared/legacy/global-flags.ts"; @@ -36,6 +46,19 @@ import { textCliOutputFormatter } from "../../../../shared/output/text-formatter 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 type { LegacyPgConnInput } from "../../../shared/legacy-db-connection.service.ts"; +import type { LegacyDbConfigError } from "../../../shared/legacy-db-config.service.ts"; +import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; +import { LegacyDbConfigLoadError } from "../../../shared/legacy-db-config.errors.ts"; +import { + LegacyPgDeltaSslProbe, + LegacyPgDeltaSslProbeError, +} from "../../../shared/legacy-pgdelta-ssl-probe.service.ts"; +import { legacyPgDeltaSslProbeLayer } from "../../../shared/legacy-pgdelta-ssl-probe.layer.ts"; +import type { + LegacyDbConfigFlags, + LegacyResolvedDbConfig, +} from "../../../shared/legacy-db-config.types.ts"; import { legacyGenCommand } from "../gen.command.ts"; import type { LegacyGenTypesFlags } from "./types.command.ts"; import { legacyGenTypes } from "./types.handler.ts"; @@ -112,6 +135,71 @@ function defaultFlags(overrides: Partial = {}): LegacyGenTy }; } +function statusApiError(status: number, body: string) { + const request = HttpClientRequest.get("https://api.supabase.test/v1/projects/ref"); + const response = HttpClientResponse.fromWeb( + request, + new Response(body, { + status, + headers: { "content-type": "application/json" }, + }), + ); + return new HttpClientError.HttpClientError({ + reason: new HttpClientError.StatusCodeError({ request, response }), + }); +} + +function remoteResolvedConfig( + conn: LegacyPgConnInput, + ref = LEGACY_VALID_REF, +): LegacyResolvedDbConfig { + return { conn, isLocal: false, ref: Option.some(ref) }; +} + +function mockDbConfigResolver( + opts: { + readonly resolve?: ( + flags: LegacyDbConfigFlags, + ) => Effect.Effect; + readonly poolerFallback?: Option.Option; + readonly poolerFallbackFails?: boolean; + } = {}, +) { + const resolves: Array = []; + const poolerFallbacks: Array = []; + const layer = Layer.succeed(LegacyDbConfigResolver, { + resolve: (flags) => + Effect.gen(function* () { + resolves.push(flags); + return yield* ( + opts.resolve?.(flags) ?? + Effect.succeed( + remoteResolvedConfig({ + host: "127.0.0.1", + port: 5432, + user: "postgres", + password: "postgres", + database: "postgres", + }), + ) + ); + }), + resolvePoolerFallback: (flags) => + opts.poolerFallbackFails === true + ? Effect.fail(new LegacyDbConfigLoadError({ message: "pooler fallback failed" })) + : Effect.sync(() => { + poolerFallbacks.push(flags); + return opts.poolerFallback ?? Option.none(); + }), + }); + return { layer, resolves, poolerFallbacks }; +} + +type BranchConfig = typeof V1GetABranchConfigOutput.Type; +type LoginRole = typeof V1CreateLoginRoleOutput.Type; +type PoolerConfig = typeof V1GetPoolerConfigOutput.Type; +type Project = typeof V1GetProjectOutput.Type; + function setup( opts: { readonly workdir?: string; @@ -135,6 +223,23 @@ function setup( readonly ref: string; readonly included_schemas?: string; }) => Effect.Effect<{ readonly types: string }, unknown>; + readonly getABranchConfig?: (input: { + readonly branch_id_or_ref: string; + }) => Effect.Effect; + readonly getPoolerConfig?: (input: { + readonly ref: string; + }) => Effect.Effect; + readonly getProject?: (input: { readonly ref: string }) => Effect.Effect; + readonly createLoginRole?: (input: { + readonly ref: string; + readonly read_only: boolean; + }) => Effect.Effect; + readonly dbConfigResolve?: ( + flags: LegacyDbConfigFlags, + ) => Effect.Effect; + readonly poolerFallback?: Option.Option; + readonly poolerFallbackFails?: boolean; + readonly sslProbeLayer?: Layer.Layer; } = {}, ) { const workdir = opts.workdir ?? mkdtempSync(join(tmpdir(), "supabase-gen-types-")); @@ -147,6 +252,11 @@ function setup( }); const telemetry = mockLegacyTelemetryStateTracked(); const linkedProjectCache = mockLegacyLinkedProjectCacheTracked(); + const dbConfig = mockDbConfigResolver({ + resolve: opts.dbConfigResolve, + poolerFallback: opts.poolerFallback, + poolerFallbackFails: opts.poolerFallbackFails, + }); const processControl = mockProcessControl(); const child = mockChildProcessSpawner({ stdout: [...(opts.childStdout ?? [])], @@ -156,6 +266,67 @@ function setup( }); const api = mockLegacyPlatformApiService({ v1: { + getABranchConfig: + opts.getABranchConfig ?? + (({ branch_id_or_ref }) => + Effect.succeed({ + ref: branch_id_or_ref, + postgres_version: "15.1", + postgres_engine: "15", + release_channel: "ga", + status: "ACTIVE_HEALTHY", + db_host: "127.0.0.1", + db_port: 5432, + db_user: "postgres", + db_pass: "postgres", + jwt_secret: "secret", + })), + getProject: + opts.getProject ?? + (({ ref }) => + Effect.succeed({ + id: ref, + ref, + organization_id: "org-id", + organization_slug: "org", + name: "demo", + region: "us-east-1", + created_at: "2025-01-01T00:00:00Z", + status: "ACTIVE_HEALTHY", + database: { + host: `db.${ref}.supabase.co`, + version: "15.1", + postgres_engine: "15", + release_channel: "ga", + }, + })), + getPoolerConfig: + opts.getPoolerConfig ?? + (() => + Effect.succeed([ + { + identifier: "primary", + database_type: "PRIMARY", + is_using_scram_auth: true, + db_user: "postgres", + db_host: "db.example", + db_port: 5432, + db_name: "postgres", + connection_string: "postgres://postgres:[YOUR-PASSWORD]@127.0.0.1:6543/postgres", + connectionString: "postgres://postgres:[YOUR-PASSWORD]@127.0.0.1:6543/postgres", + default_pool_size: null, + max_client_conn: null, + pool_mode: "transaction", + }, + ])), + createLoginRole: + opts.createLoginRole ?? + (() => + Effect.succeed({ + role: "postgres", + password: "postgres", + ttl_seconds: 3600, + })), generateTypescriptTypes: opts.generateTypescriptTypes ?? (({ included_schemas }) => @@ -184,10 +355,16 @@ function setup( Stdio.layerTest({ args: Effect.succeed(opts.args ?? ["gen", "types"]) }), Layer.succeed(LegacyOutputFlag, opts.goOutput ?? Option.none()), Layer.succeed(LegacyDebugFlag, opts.debug ?? false), + Layer.succeed(LegacyDnsResolverFlag, "native" as const), Layer.succeed(LegacyNetworkIdFlag, opts.networkId ?? Option.none()), + opts.sslProbeLayer ?? + legacyPgDeltaSslProbeLayer.pipe( + Layer.provide(Layer.succeed(LegacyDebugFlag, opts.debug ?? false)), + ), Layer.succeed(LegacyPlatformApiFactory, { make: LegacyPlatformApi.pipe(Effect.provide(api.layer)), }), + dbConfig.layer, ); return { @@ -195,6 +372,7 @@ function setup( out, telemetry, linkedProjectCache, + dbConfig, processControl, child, api, @@ -338,7 +516,10 @@ function mockDockerMissingChildProcessSpawner( async function withSslProbeServer( run: (port: number) => Promise, response: "N" | "S" = "N", + options: { readonly host?: string; readonly port?: number } = {}, ): Promise { + const host = options.host ?? "127.0.0.1"; + const port = options.port ?? 0; const server = createServer((socket) => { socket.once("data", () => { socket.write(Buffer.from(response)); @@ -348,7 +529,7 @@ async function withSslProbeServer( await new Promise((resolve, reject) => { server.once("error", reject); - server.listen(0, "127.0.0.1", () => resolve()); + server.listen(port, host, () => resolve()); }); const address = server.address(); @@ -366,6 +547,15 @@ async function withSslProbeServer( } } +const nonTypescriptProjectRefScenarios = [ + { lang: "go", stdout: "type PublicMovies struct {}" }, + { lang: "swift", stdout: "struct PublicMovies: Codable {}" }, + { lang: "python", stdout: "class PublicMovies(BaseModel):" }, +] as const satisfies ReadonlyArray<{ + readonly lang: Exclude; + readonly stdout: string; +}>; + const legacyTestRoot = Command.make("supabase").pipe( Command.withGlobalFlags(LEGACY_GLOBAL_FLAGS), Command.withSubcommands([legacyGenCommand]), @@ -605,91 +795,1255 @@ describe("legacy gen types", () => { }); }); - it.live("rejects combining --linked with --swift-access-control", () => { + it.live("rejects --swift-access-control for non-Swift generation", () => { const { layer } = setup({ - args: ["gen", "types", "--linked", "--swift-access-control", "public"], + args: ["gen", "types", "--local", "--lang", "python", "--swift-access-control", "public"], }); return Effect.gen(function* () { const exit = yield* legacyGenTypes( - defaultFlags({ linked: true, swiftAccessControl: "public" }), + defaultFlags({ local: true, lang: "python", swiftAccessControl: "public" }), ).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(String(exit.cause)).toContain( - "if any flags in the group [linked project-id swift-access-control] are set none of the others can be; [linked swift-access-control] were all set", + "--swift-access-control can only be used with --lang swift", ); } }); }); - it.live("rejects combining --linked with --postgrest-v9-compat", () => { - const { layer } = setup({ args: ["gen", "types", "--linked", "--postgrest-v9-compat"] }); + it.live("rejects --postgrest-v9-compat for remote TypeScript generation", () => { + const { layer } = setup({ + args: ["gen", "types", "--project-id", LEGACY_VALID_REF, "--postgrest-v9-compat"], + }); return Effect.gen(function* () { const exit = yield* legacyGenTypes( - defaultFlags({ linked: true, postgrestV9Compat: true }), + defaultFlags({ projectId: Option.some(LEGACY_VALID_REF), postgrestV9Compat: true }), ).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(String(exit.cause)).toContain( - "if any flags in the group [linked project-id postgrest-v9-compat] are set none of the others can be; [linked postgrest-v9-compat] were all set", + "--postgrest-v9-compat can only be used with pg-meta type generation", ); } }); }); - it.live("rejects combining --linked with --query-timeout", () => { - const { layer } = setup({ args: ["gen", "types", "--linked", "--query-timeout", "20s"] }); + it.live("rejects --query-timeout for remote TypeScript generation", () => { + const { layer } = setup({ + args: ["gen", "types", "--project-id", LEGACY_VALID_REF, "--query-timeout", "20s"], + }); return Effect.gen(function* () { - const exit = yield* legacyGenTypes(defaultFlags({ linked: true, queryTimeout: "20s" })).pipe( - Effect.provide(layer), - Effect.exit, - ); + const exit = yield* legacyGenTypes( + defaultFlags({ projectId: Option.some(LEGACY_VALID_REF), queryTimeout: "20s" }), + ).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(String(exit.cause)).toContain( - "if any flags in the group [linked project-id query-timeout] are set none of the others can be; [linked query-timeout] were all set", + "--query-timeout can only be used with pg-meta type generation", ); } }); }); - it.live("requires --db-url when --postgrest-v9-compat is set", () => { - const { layer } = setup({ args: ["gen", "types", "--local", "--postgrest-v9-compat"] }); + it.live("rejects --query-timeout for explicit linked remote TypeScript generation", () => { + const { layer } = setup({ + args: ["gen", "types", "--linked", "--query-timeout", "20s"], + projectId: Option.some(LEGACY_VALID_REF), + }); return Effect.gen(function* () { - const exit = yield* legacyGenTypes( - defaultFlags({ local: true, postgrestV9Compat: true }), - ).pipe(Effect.provide(layer), Effect.exit); + const exit = yield* legacyGenTypes(defaultFlags({ linked: true, queryTimeout: "20s" })).pipe( + Effect.provide(layer), + Effect.exit, + ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(String(exit.cause)).toContain( - "--postgrest-v9-compat must used together with --db-url", + "--query-timeout can only be used with pg-meta type generation", ); } }); }); - it.live("rejects non-typescript project generation", () => { - const { layer } = setup({ args: ["gen", "types", "--lang", "go"] }); + it.live( + "warns and continues for implicit linked TypeScript generation with --query-timeout", + () => { + const { layer, out, api } = setup({ + args: ["gen", "types", "--query-timeout", "20s"], + projectId: Option.some(LEGACY_VALID_REF), + projectTypes: "ok", + }); + + return Effect.gen(function* () { + yield* legacyGenTypes(defaultFlags({ queryTimeout: "20s" })).pipe(Effect.provide(layer)); + + expect(out.stderrText).toContain( + "Warning: --query-timeout is ignored for remote TypeScript type generation.", + ); + expect(api.requests).toContainEqual({ + method: "generateTypescriptTypes", + input: { ref: LEGACY_VALID_REF, included_schemas: "public" }, + }); + }); + }, + ); + + it.live("allows --postgrest-v9-compat for local pg-meta generation", () => + Effect.tryPromise({ + try: () => + withSslProbeServer(async (port) => { + const docker = captureDockerRun(); + const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-local-v9-flag-")); + writeConfig( + workdir, + [ + 'project_id = "demo"', + "", + "[api]", + 'schemas = ["public"]', + "", + "[db]", + `port = ${port}`, + ].join("\n"), + ); + + const { layer } = setup({ + workdir, + args: ["gen", "types", "--local", "--postgrest-v9-compat"], + childStdout: ["generated"], + onSpawn: docker.onSpawn, + }); + + await Effect.runPromise( + legacyGenTypes(defaultFlags({ local: true, postgrestV9Compat: true })).pipe( + Effect.provide(layer), + ), + ); + + expect( + docker.env.has("PG_META_GENERATE_TYPES_DETECT_ONE_TO_ONE_RELATIONSHIPS=false"), + ).toBe(true); + }), + catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + }), + ); + + for (const scenario of nonTypescriptProjectRefScenarios) { + it.live(`generates ${scenario.lang} types from a project ref through the DB resolver`, () => + Effect.tryPromise({ + try: () => + withSslProbeServer(async (port) => { + const docker = captureDockerRun(); + const { layer, out, child, api, linkedProjectCache, dbConfig } = setup({ + args: ["gen", "types", "--lang", scenario.lang, "--project-id", LEGACY_VALID_REF], + childStdout: [scenario.stdout], + dbConfigResolve: (input) => + Effect.succeed( + remoteResolvedConfig( + { + host: "127.0.0.1", + port, + user: `cli_login_${LEGACY_VALID_REF}`, + password: "temporary-password", + database: "postgres", + }, + (input.linkedProjectRef !== undefined + ? Option.getOrUndefined(input.linkedProjectRef) + : undefined) ?? LEGACY_VALID_REF, + ), + ), + getABranchConfig: ({ branch_id_or_ref }) => + Effect.fail(new Error(`unexpected preview branch lookup for ${branch_id_or_ref}`)), + getProject: ({ ref }) => + Effect.succeed({ + id: ref, + ref, + organization_id: "org-id", + organization_slug: "org", + name: "demo", + region: "us-east-1", + created_at: "2025-01-01T00:00:00Z", + status: "ACTIVE_HEALTHY", + database: { + host: `127.0.0.1:${port}`, + version: "15.1", + postgres_engine: "15", + release_channel: "ga", + }, + }), + createLoginRole: ({ ref }) => + Effect.fail(new Error(`unexpected login role creation for ${ref}`)), + onSpawn: docker.onSpawn, + }); + + await Effect.runPromise( + legacyGenTypes( + defaultFlags({ + projectId: Option.some(LEGACY_VALID_REF), + lang: scenario.lang, + }), + ).pipe(Effect.provide(layer)), + ); + + expect(api.requests).toContainEqual({ + method: "getProject", + input: { ref: LEGACY_VALID_REF }, + }); + expect(api.requests).not.toContainEqual( + expect.objectContaining({ method: "createLoginRole" }), + ); + expect(api.requests).not.toContainEqual( + expect.objectContaining({ method: "getABranchConfig" }), + ); + expect(api.requests).not.toContainEqual( + expect.objectContaining({ method: "generateTypescriptTypes" }), + ); + expect(child.spawned[0]?.args).toContain("--network"); + expect(child.spawned[0]?.args).toContain("host"); + expect(out.stderrText).toContain(`Connecting to 127.0.0.1 ${port}`); + expect( + docker.env.has( + `PG_META_DB_URL=postgresql://cli_login_${LEGACY_VALID_REF}:temporary-password@127.0.0.1:${port}/postgres?connect_timeout=10`, + ), + ).toBe(true); + expect(dbConfig.resolves).toHaveLength(1); + expect(dbConfig.resolves[0]?.connType).toBe("linked"); + // --project-id is an ad-hoc remote ref: the resolver must not inherit + // the workdir's ambient password / saved pooler URL. + expect(dbConfig.resolves[0]?.adHocProjectRef).toBe(true); + const linkedProjectRef = dbConfig.resolves[0]?.linkedProjectRef; + expect( + linkedProjectRef !== undefined ? Option.getOrUndefined(linkedProjectRef) : undefined, + ).toBe(LEGACY_VALID_REF); + expect(docker.env.has(`PG_META_GENERATE_TYPES=${scenario.lang}`)).toBe(true); + expect(docker.env.has("PG_META_GENERATE_TYPES_INCLUDED_SCHEMAS=public")).toBe(true); + expect(out.stdoutText).toContain(scenario.stdout); + expect(linkedProjectCache.cached).toBe(true); + }), + catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + }), + ); + } + + it.live("resolves the linked workdir DB without ad-hoc project-ref semantics", () => + Effect.tryPromise({ + try: () => + withSslProbeServer(async (port) => { + const docker = captureDockerRun(); + const { layer, dbConfig } = setup({ + args: ["gen", "types", "--lang", "go", "--linked"], + projectId: Option.some(LEGACY_VALID_REF), + childStdout: ["type PublicMovies struct {}"], + dbConfigResolve: () => + Effect.succeed( + remoteResolvedConfig({ + host: "127.0.0.1", + port, + user: "postgres", + password: "workdir-password", + database: "postgres", + }), + ), + onSpawn: docker.onSpawn, + }); + + await Effect.runPromise( + legacyGenTypes(defaultFlags({ linked: true, lang: "go" })).pipe(Effect.provide(layer)), + ); + + expect(dbConfig.resolves).toHaveLength(1); + expect(dbConfig.resolves[0]?.connType).toBe("linked"); + // --linked is the workdir's own project: keep workdir-scoped credentials. + expect(dbConfig.resolves[0]?.adHocProjectRef).toBe(false); + }), + catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + }), + ); + + it.live("preserves resolver URL options for remote non-TypeScript typegen", () => + Effect.tryPromise({ + try: () => + withSslProbeServer(async (port) => { + const docker = captureDockerRun(); + const { layer } = setup({ + args: ["gen", "types", "--lang", "go", "--project-id", LEGACY_VALID_REF], + childStdout: ["type PublicMovies struct {}"], + dbConfigResolve: () => + Effect.succeed( + remoteResolvedConfig({ + host: "127.0.0.1", + port, + user: `postgres.${LEGACY_VALID_REF}`, + password: "pooler-password", + database: "postgres", + options: `reference=${LEGACY_VALID_REF}`, + }), + ), + onSpawn: docker.onSpawn, + }); + + await Effect.runPromise( + legacyGenTypes( + defaultFlags({ + projectId: Option.some(LEGACY_VALID_REF), + lang: "go", + }), + ).pipe(Effect.provide(layer)), + ); + + expect( + docker.env.has( + `PG_META_DB_URL=postgresql://postgres.${LEGACY_VALID_REF}:pooler-password@127.0.0.1:${port}/postgres?connect_timeout=10&options=reference%3D${LEGACY_VALID_REF}`, + ), + ).toBe(true); + }), + catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + }), + ); + + it.live("retries remote pg-meta through the IPv4 pooler on a container IPv6 failure", () => + Effect.tryPromise({ + try: () => + withSslProbeServer(async (port) => { + const child = mockSequentialChildProcessSpawner([ + { + exitCode: 1, + stderr: [ + 'could not translate host name "db.abcdefghijklmnopqrst.supabase.co" to address: No address associated with hostname', + ], + }, + { exitCode: 0, stdout: ["type RetriedViaPooler struct {}"] }, + ]); + const poolerConn: LegacyPgConnInput = { + host: "127.0.0.1", + port, + user: `postgres.${LEGACY_VALID_REF}`, + password: "pooler-password", + database: "postgres", + }; + const { layer, out, dbConfig } = setup({ + args: ["gen", "types", "--lang", "go", "--project-id", LEGACY_VALID_REF], + childLayer: child.layer, + sslProbeLayer: Layer.succeed(LegacyPgDeltaSslProbe, { + requireSsl: () => Effect.succeed(false), + requireSslForHost: () => Effect.succeed(false), + }), + dbConfigResolve: () => + Effect.succeed( + remoteResolvedConfig({ + host: `db.${LEGACY_VALID_REF}.supabase.co`, + port, + user: "postgres", + password: "direct-password", + database: "postgres", + }), + ), + poolerFallback: Option.some(poolerConn), + }); + + await Effect.runPromise( + legacyGenTypes( + defaultFlags({ + projectId: Option.some(LEGACY_VALID_REF), + lang: "go", + }), + ).pipe(Effect.provide(layer)), + ); + + expect(out.stdoutText).toContain("type RetriedViaPooler struct {}"); + expect(out.stderrText).toContain("does not support IPv6"); + expect(out.stderrText).toContain("Retrying via the IPv4 connection pooler."); + expect(child.spawned).toHaveLength(2); + expect( + dockerEnv(child.spawned[0]?.args ?? []).has( + `PG_META_DB_URL=postgresql://postgres:direct-password@db.${LEGACY_VALID_REF}.supabase.co:${port}/postgres?connect_timeout=10`, + ), + ).toBe(true); + expect( + dockerEnv(child.spawned[1]?.args ?? []).has( + `PG_META_DB_URL=postgresql://postgres.${LEGACY_VALID_REF}:pooler-password@127.0.0.1:${port}/postgres?connect_timeout=10`, + ), + ).toBe(true); + expect(dbConfig.poolerFallbacks).toHaveLength(1); + expect(dbConfig.poolerFallbacks[0]?.connType).toBe("linked"); + expect(dbConfig.poolerFallbacks[0]?.adHocProjectRef).toBe(true); + }), + catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + }), + ); + + it.live("retries remote pg-meta through the IPv4 pooler on Node ENETUNREACH stderr", () => + Effect.tryPromise({ + try: () => + withSslProbeServer(async (port) => { + const child = mockSequentialChildProcessSpawner([ + { + exitCode: 1, + stderr: ["connect ENETUNREACH 2600:1f18::1:5432 - Local (:::0)"], + }, + { exitCode: 0, stdout: ["type RetriedViaPooler struct {}"] }, + ]); + const poolerConn: LegacyPgConnInput = { + host: "127.0.0.1", + port, + user: `postgres.${LEGACY_VALID_REF}`, + password: "pooler-password", + database: "postgres", + }; + const { layer, out, dbConfig } = setup({ + args: ["gen", "types", "--lang", "go", "--project-id", LEGACY_VALID_REF], + childLayer: child.layer, + sslProbeLayer: Layer.succeed(LegacyPgDeltaSslProbe, { + requireSsl: () => Effect.succeed(false), + requireSslForHost: () => Effect.succeed(false), + }), + dbConfigResolve: () => + Effect.succeed( + remoteResolvedConfig({ + host: `db.${LEGACY_VALID_REF}.supabase.co`, + port, + user: "postgres", + password: "direct-password", + database: "postgres", + }), + ), + poolerFallback: Option.some(poolerConn), + }); + + await Effect.runPromise( + legacyGenTypes( + defaultFlags({ + projectId: Option.some(LEGACY_VALID_REF), + lang: "go", + }), + ).pipe(Effect.provide(layer)), + ); + + expect(out.stdoutText).toContain("type RetriedViaPooler struct {}"); + expect(child.spawned).toHaveLength(2); + expect(dbConfig.poolerFallbacks).toHaveLength(1); + }), + catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + }), + ); + + it.live("does not retry remote pg-meta when the container failure is not IPv6", () => + Effect.tryPromise({ + try: () => + withSslProbeServer(async (port) => { + const child = mockSequentialChildProcessSpawner([ + { exitCode: 1, stderr: ["permission denied for schema public"] }, + ]); + const { layer, dbConfig } = setup({ + args: ["gen", "types", "--lang", "go", "--project-id", LEGACY_VALID_REF], + childLayer: child.layer, + sslProbeLayer: Layer.succeed(LegacyPgDeltaSslProbe, { + requireSsl: () => Effect.succeed(false), + requireSslForHost: () => Effect.succeed(false), + }), + dbConfigResolve: () => + Effect.succeed( + remoteResolvedConfig({ + host: `db.${LEGACY_VALID_REF}.supabase.co`, + port, + user: "postgres", + password: "direct-password", + database: "postgres", + }), + ), + poolerFallback: Option.some({ + host: "127.0.0.1", + port, + user: `postgres.${LEGACY_VALID_REF}`, + password: "pooler-password", + database: "postgres", + }), + }); + + const exit = await Effect.runPromise( + legacyGenTypes( + defaultFlags({ + projectId: Option.some(LEGACY_VALID_REF), + lang: "go", + }), + ).pipe(Effect.provide(layer), Effect.exit), + ); + + expect(Exit.isFailure(exit)).toBe(true); + expect(child.spawned).toHaveLength(1); + expect(dbConfig.poolerFallbacks).toHaveLength(0); + }), + catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + }), + ); + + it.live( + "does not run pooler fallback a second time when the retry also exits with IPv6 stderr", + () => + Effect.tryPromise({ + try: () => + withSslProbeServer(async (port) => { + const child = mockSequentialChildProcessSpawner([ + { + exitCode: 1, + stderr: [ + `could not translate host name "db.${LEGACY_VALID_REF}.supabase.co" to address: No address associated with hostname`, + ], + }, + { + exitCode: 1, + stderr: [ + `could not translate host name "db.${LEGACY_VALID_REF}.supabase.co" to address: No address associated with hostname`, + ], + }, + ]); + const { layer, dbConfig } = setup({ + args: ["gen", "types", "--lang", "go", "--project-id", LEGACY_VALID_REF], + childLayer: child.layer, + sslProbeLayer: Layer.succeed(LegacyPgDeltaSslProbe, { + requireSsl: () => Effect.succeed(false), + requireSslForHost: () => Effect.succeed(false), + }), + dbConfigResolve: () => + Effect.succeed( + remoteResolvedConfig({ + host: `db.${LEGACY_VALID_REF}.supabase.co`, + port, + user: "postgres", + password: "direct-password", + database: "postgres", + }), + ), + poolerFallback: Option.some({ + host: "127.0.0.1", + port, + user: `postgres.${LEGACY_VALID_REF}`, + password: "pooler-password", + database: "postgres", + }), + }); + + const exit = await Effect.runPromise( + legacyGenTypes( + defaultFlags({ + projectId: Option.some(LEGACY_VALID_REF), + lang: "go", + }), + ).pipe(Effect.provide(layer), Effect.exit), + ); + + expect(Exit.isFailure(exit)).toBe(true); + expect(child.spawned).toHaveLength(2); + expect(dbConfig.poolerFallbacks).toHaveLength(1); + }), + catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + }), + ); + + it.live( + "does not retry remote pg-meta when the resolved connection is already a pooler host", + () => + Effect.tryPromise({ + try: () => + Effect.runPromise( + Effect.gen(function* () { + const child = mockSequentialChildProcessSpawner([ + { + exitCode: 1, + stderr: [ + `could not translate host name "db.${LEGACY_VALID_REF}.supabase.co" to address: No address associated with hostname`, + ], + }, + ]); + const { layer, out, dbConfig } = setup({ + args: ["gen", "types", "--lang", "go", "--project-id", LEGACY_VALID_REF], + childLayer: child.layer, + sslProbeLayer: Layer.succeed(LegacyPgDeltaSslProbe, { + requireSsl: () => Effect.succeed(false), + requireSslForHost: () => Effect.succeed(false), + }), + dbConfigResolve: () => + Effect.succeed( + remoteResolvedConfig({ + host: "aws-0-us-east-1.pooler.supabase.com", + port: 5432, + user: `postgres.${LEGACY_VALID_REF}`, + password: "pooler-password", + database: "postgres", + }), + ), + poolerFallback: Option.some({ + host: "aws-0-us-east-1.pooler.supabase.com", + port: 5432, + user: `postgres.${LEGACY_VALID_REF}`, + password: "pooler-password", + database: "postgres", + }), + }); + + const exit = yield* legacyGenTypes( + defaultFlags({ + projectId: Option.some(LEGACY_VALID_REF), + lang: "go", + }), + ).pipe(Effect.provide(layer), Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + expect(child.spawned).toHaveLength(1); + expect(dbConfig.poolerFallbacks).toHaveLength(0); + expect(out.stderrText).not.toContain("Retrying via the IPv4 connection pooler."); + }), + ), + catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + }), + ); + + it.live("retries remote pg-meta when the TLS probe fails with ENETUNREACH", () => + Effect.tryPromise({ + try: () => + Effect.runPromise( + Effect.gen(function* () { + let probeCalls = 0; + const child = mockSequentialChildProcessSpawner([ + { exitCode: 0, stdout: ["type RetriedAfterProbeFailure struct {}"] }, + ]); + const { layer, out, dbConfig } = setup({ + args: ["gen", "types", "--lang", "go", "--project-id", LEGACY_VALID_REF], + childLayer: child.layer, + sslProbeLayer: Layer.succeed(LegacyPgDeltaSslProbe, { + requireSsl: () => Effect.succeed(false), + requireSslForHost: () => + Effect.gen(function* () { + probeCalls += 1; + if (probeCalls === 1) { + return yield* Effect.fail( + new LegacyPgDeltaSslProbeError({ + message: "network is unreachable", + cause: Object.assign(new Error(), { code: "ENETUNREACH" }), + }), + ); + } + return false; + }), + }), + dbConfigResolve: () => + Effect.succeed( + remoteResolvedConfig({ + host: `db.${LEGACY_VALID_REF}.supabase.co`, + port: 5432, + user: "postgres", + password: "direct-password", + database: "postgres", + }), + ), + poolerFallback: Option.some({ + host: "aws-0-us-east-1.pooler.supabase.com", + port: 5432, + user: `postgres.${LEGACY_VALID_REF}`, + password: "pooler-password", + database: "postgres", + }), + }); + + yield* legacyGenTypes( + defaultFlags({ + projectId: Option.some(LEGACY_VALID_REF), + lang: "go", + }), + ).pipe(Effect.provide(layer)); + + expect(out.stdoutText).toContain("type RetriedAfterProbeFailure struct {}"); + expect(probeCalls).toBe(2); + expect(child.spawned).toHaveLength(1); + expect(dbConfig.poolerFallbacks).toHaveLength(1); + }), + ), + catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + }), + ); + + it.live("does not retry remote pg-meta when the TLS probe fails with ECONNREFUSED", () => + Effect.tryPromise({ + try: () => + Effect.runPromise( + Effect.gen(function* () { + const child = mockSequentialChildProcessSpawner([ + { exitCode: 0, stdout: ["should not spawn"] }, + ]); + const { layer, dbConfig } = setup({ + args: ["gen", "types", "--lang", "go", "--project-id", LEGACY_VALID_REF], + childLayer: child.layer, + sslProbeLayer: Layer.succeed(LegacyPgDeltaSslProbe, { + requireSsl: () => Effect.succeed(false), + requireSslForHost: () => + Effect.fail( + new LegacyPgDeltaSslProbeError({ + message: "connection refused", + cause: Object.assign(new Error(), { code: "ECONNREFUSED" }), + }), + ), + }), + dbConfigResolve: () => + Effect.succeed( + remoteResolvedConfig({ + host: `db.${LEGACY_VALID_REF}.supabase.co`, + port: 5432, + user: "postgres", + password: "direct-password", + database: "postgres", + }), + ), + poolerFallback: Option.some({ + host: "aws-0-us-east-1.pooler.supabase.com", + port: 5432, + user: `postgres.${LEGACY_VALID_REF}`, + password: "pooler-password", + database: "postgres", + }), + }); + + const exit = yield* legacyGenTypes( + defaultFlags({ + projectId: Option.some(LEGACY_VALID_REF), + lang: "go", + }), + ).pipe(Effect.provide(layer), Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + expect(child.spawned).toHaveLength(0); + expect(dbConfig.poolerFallbacks).toHaveLength(0); + }), + ), + catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + }), + ); + + it.live("preserves the original remote pg-meta error when pooler fallback resolution fails", () => + Effect.tryPromise({ + try: () => + withSslProbeServer(async (port) => { + const child = mockSequentialChildProcessSpawner([ + { + exitCode: 1, + stderr: [ + 'could not translate host name "db.abcdefghijklmnopqrst.supabase.co" to address: No address associated with hostname', + ], + }, + ]); + const { layer } = setup({ + args: ["gen", "types", "--lang", "go", "--project-id", LEGACY_VALID_REF], + childLayer: child.layer, + sslProbeLayer: Layer.succeed(LegacyPgDeltaSslProbe, { + requireSsl: () => Effect.succeed(false), + requireSslForHost: () => Effect.succeed(false), + }), + dbConfigResolve: () => + Effect.succeed( + remoteResolvedConfig({ + host: `db.${LEGACY_VALID_REF}.supabase.co`, + port, + user: "postgres", + password: "direct-password", + database: "postgres", + }), + ), + poolerFallbackFails: true, + }); + + const exit = await Effect.runPromise( + legacyGenTypes( + defaultFlags({ + projectId: Option.some(LEGACY_VALID_REF), + lang: "go", + }), + ).pipe(Effect.provide(layer), Effect.exit), + ); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(String(exit.cause)).toContain("error running container: exit 1"); + expect(String(exit.cause)).not.toContain("pooler fallback failed"); + } + expect(child.spawned).toHaveLength(1); + }), + catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + }), + ); + + it.live("uses remote config schemas for explicit project-ref pg-meta typegen", () => + Effect.tryPromise({ + try: () => + withSslProbeServer(async (port) => { + const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-remote-config-")); + writeConfig( + workdir, + [ + 'project_id = "base"', + "", + "[api]", + 'schemas = ["public"]', + "", + "[remotes.staging]", + `project_id = "${LEGACY_VALID_REF}"`, + "", + "[remotes.staging.api]", + 'schemas = ["private"]', + "", + ].join("\n"), + ); + const docker = captureDockerRun(); + const { layer } = setup({ + workdir, + args: ["gen", "types", "--lang", "go", "--project-id", LEGACY_VALID_REF], + childStdout: ["type PrivateMovies struct {}"], + dbConfigResolve: () => + Effect.succeed( + remoteResolvedConfig({ + host: "127.0.0.1", + port, + user: "postgres", + password: "direct-password", + database: "postgres", + }), + ), + onSpawn: docker.onSpawn, + }); + + try { + await Effect.runPromise( + legacyGenTypes( + defaultFlags({ + projectId: Option.some(LEGACY_VALID_REF), + lang: "go", + }), + ).pipe(Effect.provide(layer)), + ); + } finally { + rmSync(workdir, { recursive: true, force: true }); + } + + expect(docker.env.has("PG_META_GENERATE_TYPES_INCLUDED_SCHEMAS=public,private")).toBe( + true, + ); + expect(docker.env.has("PG_META_GENERATE_TYPES_INCLUDED_SCHEMAS=public")).toBe(false); + }), + catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + }), + ); + + it.live("uses remote config schemas for linked pg-meta typegen", () => + Effect.tryPromise({ + try: () => + withSslProbeServer(async (port) => { + const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-linked-config-")); + writeConfig( + workdir, + [ + 'project_id = "base"', + "", + "[api]", + 'schemas = ["public"]', + "", + "[remotes.staging]", + `project_id = "${LEGACY_VALID_REF}"`, + "", + "[remotes.staging.api]", + 'schemas = ["private"]', + "", + ].join("\n"), + ); + const docker = captureDockerRun(); + const { layer } = setup({ + workdir, + projectId: Option.some(LEGACY_VALID_REF), + args: ["gen", "types", "--lang", "go", "--linked"], + childStdout: ["type PrivateMovies struct {}"], + dbConfigResolve: () => + Effect.succeed( + remoteResolvedConfig({ + host: "127.0.0.1", + port, + user: "postgres", + password: "direct-password", + database: "postgres", + }), + ), + onSpawn: docker.onSpawn, + }); + + try { + await Effect.runPromise( + legacyGenTypes( + defaultFlags({ + linked: true, + lang: "go", + }), + ).pipe(Effect.provide(layer)), + ); + } finally { + rmSync(workdir, { recursive: true, force: true }); + } + + expect(docker.env.has("PG_META_GENERATE_TYPES_INCLUDED_SCHEMAS=public,private")).toBe( + true, + ); + expect(docker.env.has("PG_META_GENERATE_TYPES_INCLUDED_SCHEMAS=public")).toBe(false); + }), + catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + }), + ); + + it.live("allows pg-meta flags for remote non-TypeScript project refs", () => + Effect.tryPromise({ + try: () => + withSslProbeServer(async (port) => { + const docker = captureDockerRun(); + const { layer } = setup({ + args: [ + "gen", + "types", + "--lang", + "swift", + "--project-id", + LEGACY_VALID_REF, + "--swift-access-control", + "public", + "--query-timeout", + "20s", + "--postgrest-v9-compat", + ], + childStdout: ["struct PublicMovies: Codable {}"], + dbConfigResolve: () => + Effect.succeed( + remoteResolvedConfig({ + host: "127.0.0.1", + port, + user: "postgres", + password: "postgres", + database: "postgres", + }), + ), + onSpawn: docker.onSpawn, + }); + + await Effect.runPromise( + legacyGenTypes( + defaultFlags({ + projectId: Option.some(LEGACY_VALID_REF), + lang: "swift", + swiftAccessControl: "public", + queryTimeout: "20s", + postgrestV9Compat: true, + }), + ).pipe(Effect.provide(layer)), + ); + + expect(docker.env.has("PG_META_GENERATE_TYPES_SWIFT_ACCESS_CONTROL=public")).toBe(true); + expect(docker.env.has("PG_QUERY_TIMEOUT_SECS=20")).toBe(true); + expect( + docker.env.has("PG_META_GENERATE_TYPES_DETECT_ONE_TO_ONE_RELATIONSHIPS=false"), + ).toBe(true); + }), + catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + }), + ); + + it.live("falls back to preview branch config for non-TypeScript project refs", () => + Effect.tryPromise({ + try: () => + withSslProbeServer(async (port) => { + const docker = captureDockerRun(); + const { layer, api, dbConfig } = setup({ + args: ["gen", "types", "--lang", "python", "--project-id", LEGACY_VALID_REF], + childStdout: ["class PublicMovies(BaseModel):"], + getProject: () => + Effect.fail(statusApiError(404, `{"message":"Preview branch not found"}`)), + getABranchConfig: ({ branch_id_or_ref }) => + Effect.succeed({ + ref: branch_id_or_ref, + postgres_version: "15.1", + postgres_engine: "15", + release_channel: "ga", + status: "ACTIVE_HEALTHY", + db_host: "127.0.0.1", + db_port: port, + db_user: "branch_user", + db_pass: "branch-password", + jwt_secret: "secret", + }), + createLoginRole: ({ ref }) => + Effect.fail(new Error(`unexpected login role creation for ${ref}`)), + onSpawn: docker.onSpawn, + }); + + await Effect.runPromise( + legacyGenTypes( + defaultFlags({ + projectId: Option.some(LEGACY_VALID_REF), + lang: "python", + }), + ).pipe(Effect.provide(layer)), + ); + + expect(api.requests).toContainEqual({ + method: "getProject", + input: { ref: LEGACY_VALID_REF }, + }); + expect(api.requests).toContainEqual({ + method: "getABranchConfig", + input: { branch_id_or_ref: LEGACY_VALID_REF }, + }); + expect(api.requests).not.toContainEqual( + expect.objectContaining({ method: "createLoginRole" }), + ); + expect(dbConfig.resolves).toHaveLength(0); + expect( + docker.env.has( + `PG_META_DB_URL=postgresql://branch_user:branch-password@127.0.0.1:${port}/postgres?connect_timeout=10`, + ), + ).toBe(true); + }), + catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + }), + ); + + it.live("retries preview branch pg-meta through the branch IPv4 pooler", () => + Effect.tryPromise({ + try: () => + Effect.runPromise( + Effect.gen(function* () { + const poolerHost = "aws-0-us-east-1.pooler.supabase.com"; + const child = mockSequentialChildProcessSpawner([ + { + exitCode: 1, + stderr: [ + `could not translate host name "db.${LEGACY_VALID_REF}.supabase.co" to address: No address associated with hostname`, + ], + }, + { exitCode: 0, stdout: ["class RetriedViaBranchPooler(BaseModel):"] }, + ]); + const { layer, api } = setup({ + args: ["gen", "types", "--lang", "python", "--project-id", LEGACY_VALID_REF], + childLayer: child.layer, + sslProbeLayer: Layer.succeed(LegacyPgDeltaSslProbe, { + requireSsl: () => Effect.succeed(false), + requireSslForHost: () => Effect.succeed(false), + }), + getProject: () => Effect.fail(statusApiError(404, `{"message":"Not found"}`)), + getABranchConfig: ({ branch_id_or_ref }) => + Effect.succeed({ + ref: branch_id_or_ref, + postgres_version: "15.1", + postgres_engine: "15", + release_channel: "ga", + status: "ACTIVE_HEALTHY", + db_host: `db.${branch_id_or_ref}.supabase.co`, + db_port: 5432, + db_user: "branch_user", + db_pass: "branch-password", + jwt_secret: "secret", + }), + getPoolerConfig: ({ ref }) => + Effect.succeed([ + { + identifier: "primary", + database_type: "PRIMARY", + is_using_scram_auth: true, + db_user: "postgres", + db_host: "db.example", + db_port: 5432, + db_name: "postgres", + connection_string: `postgres://postgres.${ref}:[YOUR-PASSWORD]@${poolerHost}:6543/postgres`, + connectionString: `postgres://postgres.${ref}:[YOUR-PASSWORD]@${poolerHost}:6543/postgres`, + default_pool_size: null, + max_client_conn: null, + pool_mode: "transaction", + }, + ]), + }); + + yield* legacyGenTypes( + defaultFlags({ + projectId: Option.some(LEGACY_VALID_REF), + lang: "python", + }), + ).pipe(Effect.provide(layer)); + + expect(api.requests).toContainEqual({ + method: "getPoolerConfig", + input: { ref: LEGACY_VALID_REF }, + }); + expect(child.spawned).toHaveLength(2); + expect( + dockerEnv(child.spawned[1]?.args ?? []).has( + `PG_META_DB_URL=postgresql://postgres.${LEGACY_VALID_REF}:branch-password@${poolerHost}:5432/postgres?connect_timeout=10`, + ), + ).toBe(true); + }), + ), + catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + }), + ); + + it.live("skips preview branch pooler fallback when the pooler URL fails validation", () => + Effect.tryPromise({ + try: () => + Effect.runPromise( + Effect.gen(function* () { + const child = mockSequentialChildProcessSpawner([ + { + exitCode: 1, + stderr: [ + `could not translate host name "db.${LEGACY_VALID_REF}.supabase.co" to address: No address associated with hostname`, + ], + }, + ]); + const { layer, api } = setup({ + args: ["gen", "types", "--lang", "python", "--project-id", LEGACY_VALID_REF], + childLayer: child.layer, + sslProbeLayer: Layer.succeed(LegacyPgDeltaSslProbe, { + requireSsl: () => Effect.succeed(false), + requireSslForHost: () => Effect.succeed(false), + }), + getProject: () => Effect.fail(statusApiError(404, `{"message":"Not found"}`)), + getABranchConfig: ({ branch_id_or_ref }) => + Effect.succeed({ + ref: branch_id_or_ref, + postgres_version: "15.1", + postgres_engine: "15", + release_channel: "ga", + status: "ACTIVE_HEALTHY", + db_host: `db.${branch_id_or_ref}.supabase.co`, + db_port: 5432, + db_user: "branch_user", + db_pass: "branch-password", + jwt_secret: "secret", + }), + getPoolerConfig: ({ ref }) => + Effect.succeed([ + { + identifier: "primary", + database_type: "PRIMARY", + is_using_scram_auth: true, + db_user: "postgres", + db_host: "db.example", + db_port: 5432, + db_name: "postgres", + connection_string: `postgres://postgres.${ref}:[YOUR-PASSWORD]@pooler.example.com:6543/postgres`, + connectionString: `postgres://postgres.${ref}:[YOUR-PASSWORD]@pooler.example.com:6543/postgres`, + default_pool_size: null, + max_client_conn: null, + pool_mode: "transaction", + }, + ]), + }); + + const exit = yield* legacyGenTypes( + defaultFlags({ + projectId: Option.some(LEGACY_VALID_REF), + lang: "python", + }), + ).pipe(Effect.provide(layer), Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + expect(api.requests).toContainEqual({ + method: "getPoolerConfig", + input: { ref: LEGACY_VALID_REF }, + }); + expect(child.spawned).toHaveLength(1); + }), + ), + catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + }), + ); + + it.live("falls back to preview branch config for any project 404 body", () => + Effect.tryPromise({ + try: () => + withSslProbeServer(async (port) => { + const docker = captureDockerRun(); + const { layer, api, dbConfig } = setup({ + args: ["gen", "types", "--lang", "python", "--project-id", LEGACY_VALID_REF], + childStdout: ["class PublicMovies(BaseModel):"], + // The Management API's 404 wording is not guaranteed; a generic body + // must still route to the branch config endpoint. + getProject: () => Effect.fail(statusApiError(404, `{"message":"Not found"}`)), + getABranchConfig: ({ branch_id_or_ref }) => + Effect.succeed({ + ref: branch_id_or_ref, + postgres_version: "15.1", + postgres_engine: "15", + release_channel: "ga", + status: "ACTIVE_HEALTHY", + db_host: "127.0.0.1", + db_port: port, + db_user: "branch_user", + db_pass: "branch-password", + jwt_secret: "secret", + }), + onSpawn: docker.onSpawn, + }); + + await Effect.runPromise( + legacyGenTypes( + defaultFlags({ + projectId: Option.some(LEGACY_VALID_REF), + lang: "python", + }), + ).pipe(Effect.provide(layer)), + ); + + expect(api.requests).toContainEqual({ + method: "getABranchConfig", + input: { branch_id_or_ref: LEGACY_VALID_REF }, + }); + expect(dbConfig.resolves).toHaveLength(0); + expect( + docker.env.has( + `PG_META_DB_URL=postgresql://branch_user:branch-password@127.0.0.1:${port}/postgres?connect_timeout=10`, + ), + ).toBe(true); + }), + catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + }), + ); + + it.live("fails clearly when preview branch config does not include DB credentials", () => { + const { layer } = setup({ + args: ["gen", "types", "--lang", "python", "--project-id", LEGACY_VALID_REF], + getProject: () => Effect.fail(statusApiError(404, `{"message":"Preview branch not found"}`)), + getABranchConfig: ({ branch_id_or_ref }) => + Effect.succeed({ + ref: branch_id_or_ref, + postgres_version: "15.1", + postgres_engine: "15", + release_channel: "ga", + status: "ACTIVE_HEALTHY", + db_host: "127.0.0.1", + db_port: 5432, + jwt_secret: "secret", + }), + }); return Effect.gen(function* () { const exit = yield* legacyGenTypes( defaultFlags({ projectId: Option.some(LEGACY_VALID_REF), - lang: "go", + lang: "python", }), ).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(String(exit.cause)).toContain("Try using --db-url flag instead."); + expect(String(exit.cause)).toContain("Preview branch database credentials are unavailable"); } }); }); diff --git a/apps/cli/src/legacy/commands/gen/types/types.layers.ts b/apps/cli/src/legacy/commands/gen/types/types.layers.ts index 098328a0d1..1c2039b0f4 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.layers.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.layers.ts @@ -7,7 +7,12 @@ import { legacyCliConfigLayer } from "../../../config/legacy-cli-config.layer.ts import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; import { legacyProjectRefLayer } from "../../../config/legacy-project-ref.layer.ts"; import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { legacyDbConfigLayer } from "../../../shared/legacy-db-config.layer.ts"; +import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; +import { legacyDbConnectionLayer } from "../../../shared/legacy-db-connection.layer.ts"; import { legacyDebugLoggerLayer } from "../../../shared/legacy-debug-logger.layer.ts"; +import { legacyPgDeltaSslProbeLayer } from "../../../shared/legacy-pgdelta-ssl-probe.layer.ts"; +import { LegacyPgDeltaSslProbe } from "../../../shared/legacy-pgdelta-ssl-probe.service.ts"; import { LegacyIdentityStitch, legacyIdentityStitchLayer, @@ -43,8 +48,16 @@ export const legacyGenTypesRuntimeLayer = (() => { Layer.provide(legacyDebugLoggerLayer), Layer.provide(legacyIdentityStitchLayer), ); + const dbConfig = legacyDbConfigLayer.pipe( + Layer.provide(cliConfig), + Layer.provide(legacyDbConnectionLayer), + Layer.provide(legacyDebugLoggerLayer), + Layer.provide(legacyIdentityStitchLayer), + ); const built = Layer.mergeAll( + dbConfig, + legacyDbConnectionLayer, cliConfig, platformApiFactory, legacyProjectRefLayer.pipe(Layer.provide(platformApiFactory), Layer.provide(cliConfig)), @@ -54,6 +67,7 @@ export const legacyGenTypesRuntimeLayer = (() => { Layer.provide(httpClient), Layer.provide(legacyIdentityStitchLayer), ), + legacyPgDeltaSslProbeLayer, legacyTelemetryStateLayer, // The one per-command identity stitcher (Go's single root-context `sync.Once`), // exposed at top level so `withLegacyCommandInstrumentation` can read @@ -77,6 +91,8 @@ type LegacyGenTypesServices = | LegacyPlatformApiFactory | LegacyCliConfig | LegacyProjectRefResolver + | LegacyDbConfigResolver + | LegacyPgDeltaSslProbe | LegacyLinkedProjectCache | LegacyTelemetryState | LegacyIdentityStitch diff --git a/apps/cli/src/legacy/commands/gen/types/types.shared.ts b/apps/cli/src/legacy/commands/gen/types/types.shared.ts index c6a363b84b..4480a03ada 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.shared.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.shared.ts @@ -1,6 +1,6 @@ -import { connect as connectSocket } from "node:net"; -import { DEFAULT_VERSIONS, dockerImageForService } from "@supabase/stack/effect"; import { Effect } from "effect"; +import { dockerfileServiceImage } from "../../../../shared/services/dockerfile-images.ts"; +import { legacyGetRegistryImageUrl } from "../../../shared/legacy-docker-registry.ts"; import { LegacyInvalidGenTypesDatabaseUrlError, LegacyInvalidGenTypesDurationError, @@ -140,66 +140,23 @@ export function buildPostgresUrl(input: { } export function resolvePgmetaImage(versionOverride?: string) { - const version = - versionOverride && versionOverride.trim().length > 0 - ? versionOverride.trim().replace(/^v/i, "") - : DEFAULT_VERSIONS.pgmeta; - const registry = process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]?.toLowerCase(); - if (registry === "docker.io") { - return `supabase/postgres-meta:v${version}`; + const defaultImage = dockerfileServiceImage("pgmeta"); + if (versionOverride === undefined || versionOverride.trim().length === 0) { + return legacyGetRegistryImageUrl(defaultImage); } - return dockerImageForService("pgmeta", version); + return legacyGetRegistryImageUrl( + replaceImageTag(defaultImage, `v${versionOverride.trim().replace(/^v/i, "")}`), + ); } export function legacyRootCaBundle() { return `${caStaging2021}${caProd2021}${caProd2025}`; } -export function probeTlsSupport(host: string, port: number): Effect.Effect { - return Effect.tryPromise({ - try: () => - new Promise((resolve, reject) => { - let settled = false; - const socket = connectSocket({ host, port }); - - const finish = (result: boolean | Error) => { - if (settled) return; - settled = true; - socket.destroy(); - if (result instanceof Error) { - reject(result); - return; - } - resolve(result); - }; - - socket.setTimeout(5_000); - socket.once("connect", () => { - const packet = Buffer.alloc(8); - packet.writeInt32BE(8, 0); - packet.writeInt32BE(80877103, 4); - socket.write(packet); - }); - socket.once("data", (chunk) => { - const response = Number(chunk.at(0) ?? 0); - if (response === 0x53) { - finish(true); - return; - } - if (response === 0x4e) { - finish(false); - return; - } - finish(new Error(`unexpected SSL probe response: ${String.fromCharCode(response ?? 0)}`)); - }); - socket.once("timeout", () => finish(new Error("i/o timeout"))); - socket.once("error", (error) => finish(error)); - socket.once("close", () => { - if (!settled) { - finish(new Error("connection closed during SSL probe")); - } - }); - }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }); +function replaceImageTag(image: string, tag: string): string { + const tagSeparator = image.lastIndexOf(":"); + if (tagSeparator === -1) { + return image; + } + return `${image.slice(0, tagSeparator + 1)}${tag}`; } diff --git a/apps/cli/src/legacy/commands/gen/types/types.unit.test.ts b/apps/cli/src/legacy/commands/gen/types/types.unit.test.ts index 9034c57309..44bf4d9598 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.unit.test.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.unit.test.ts @@ -1,4 +1,3 @@ -import { createServer, type Server, type Socket } from "node:net"; import { describe, expect, it } from "@effect/vitest"; import { Effect, Exit } from "effect"; import { legacyGetHostname } from "../../../shared/legacy-hostname.ts"; @@ -12,7 +11,6 @@ import { localNetworkId, parseDatabaseUrl, parseQueryTimeoutSeconds, - probeTlsSupport, resolvePgmetaImage, } from "./types.shared.ts"; @@ -34,29 +32,6 @@ function withEnv(key: string, value: string | undefined, run: () => T): T { } } -async function withTcpServer( - handler: (socket: Socket) => void, - run: (port: number) => Promise, -): Promise { - const server: Server = createServer(handler); - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(0, "127.0.0.1", () => resolve()); - }); - const address = server.address(); - if (address === null || typeof address === "string") { - server.close(); - throw new Error("failed to bind tcp server"); - } - try { - return await run(address.port); - } finally { - await new Promise((resolve, reject) => - server.close((error) => (error ? reject(error) : resolve())), - ); - } -} - describe("parseQueryTimeoutSeconds", () => { it.effect("parses compound Go durations", () => Effect.gen(function* () { @@ -182,6 +157,29 @@ describe("resolvePgmetaImage", () => { expect(image).not.toBe("supabase/postgres-meta:v1.2.3"); expect(image).toContain("postgres-meta:v1.2.3"); }); + + it("defaults to the ECR mirror when no registry override is set", () => { + const image = withEnv("SUPABASE_INTERNAL_IMAGE_REGISTRY", undefined, () => + resolvePgmetaImage("1.2.3"), + ); + expect(image).toBe("public.ecr.aws/supabase/postgres-meta:v1.2.3"); + }); + + it("honors SUPABASE_INTERNAL_IMAGE_REGISTRY for a non docker.io registry (e.g. ghcr.io)", () => { + // Regression: setup-cli exports `ghcr.io` on shared CI runners to dodge ECR + // rate limits, but gen types used to ignore it and still pull from ECR. + const image = withEnv("SUPABASE_INTERNAL_IMAGE_REGISTRY", "ghcr.io", () => + resolvePgmetaImage("1.2.3"), + ); + expect(image).toBe("ghcr.io/supabase/postgres-meta:v1.2.3"); + }); + + it("rewrites to an arbitrary configured mirror registry", () => { + const image = withEnv("SUPABASE_INTERNAL_IMAGE_REGISTRY", "my.registry.example", () => + resolvePgmetaImage("1.2.3"), + ); + expect(image).toBe("my.registry.example/supabase/postgres-meta:v1.2.3"); + }); }); describe("schema and id helpers", () => { @@ -239,65 +237,3 @@ describe("schema and id helpers", () => { expect(legacyRootCaBundle().length).toBeGreaterThan(0); }); }); - -describe("probeTlsSupport", () => { - it.effect("detects TLS support from an 'S' response", () => - Effect.gen(function* () { - const result = yield* Effect.tryPromise(() => - withTcpServer( - (socket) => - socket.once("data", () => { - socket.write(Buffer.from("S")); - socket.end(); - }), - (port) => Effect.runPromise(probeTlsSupport("127.0.0.1", port)), - ), - ); - expect(result).toBe(true); - }), - ); - - it.effect("detects a refused TLS connection from an 'N' response", () => - Effect.gen(function* () { - const result = yield* Effect.tryPromise(() => - withTcpServer( - (socket) => - socket.once("data", () => { - socket.write(Buffer.from("N")); - socket.end(); - }), - (port) => Effect.runPromise(probeTlsSupport("127.0.0.1", port)), - ), - ); - expect(result).toBe(false); - }), - ); - - it.effect("fails on an unexpected probe response", () => - Effect.gen(function* () { - const exit = yield* Effect.tryPromise(() => - withTcpServer( - (socket) => - socket.once("data", () => { - socket.write(Buffer.from("X")); - socket.end(); - }), - (port) => Effect.runPromise(probeTlsSupport("127.0.0.1", port)), - ), - ).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - }), - ); - - it.effect("fails when the connection closes before responding", () => - Effect.gen(function* () { - const exit = yield* Effect.tryPromise(() => - withTcpServer( - (socket) => socket.destroy(), - (port) => Effect.runPromise(probeTlsSupport("127.0.0.1", port)), - ), - ).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - }), - ); -}); diff --git a/apps/cli/src/legacy/commands/logout/logout.handler.ts b/apps/cli/src/legacy/commands/logout/logout.handler.ts index 4539c9fb14..8b4a606249 100644 --- a/apps/cli/src/legacy/commands/logout/logout.handler.ts +++ b/apps/cli/src/legacy/commands/logout/logout.handler.ts @@ -2,8 +2,9 @@ import { Effect } from "effect"; import { LegacyCredentials } from "../../auth/legacy-credentials.service.ts"; import { LegacyTelemetryState } from "../../telemetry/legacy-telemetry-state.service.ts"; -import { LegacyYesFlag } from "../../../shared/legacy/global-flags.ts"; +import { legacyResolveYes } from "../../../shared/legacy/global-flags.ts"; import { Output } from "../../../shared/output/output.service.ts"; +import { legacyPromptYesNo } from "../../shared/legacy-prompt-yes-no.ts"; import { LegacyLogoutCancelledError, LEGACY_LOGOUT_CANCELLED_MESSAGE } from "./logout.errors.ts"; const LOGGED_OUT_MSG = "Access token deleted successfully. You are now logged out."; @@ -12,16 +13,28 @@ export const legacyLogout = Effect.fn("legacy.logout")(function* () { const output = yield* Output; const credentials = yield* LegacyCredentials; const telemetryState = yield* LegacyTelemetryState; - const yes = yield* LegacyYesFlag; + // `--yes` OR `SUPABASE_YES` (Go's `viper.GetBool("YES")`, root.go:318-320): Go + // reads it before scanning stdin, so the env var auto-confirms logout too. + const yes = yield* legacyResolveYes; + + const confirmLabel = + "Do you want to log out? This will remove the access token from your system."; const body = Effect.gen(function* () { - // Confirm prompt, honoring the global `--yes` (`logout.go:15`). - const confirmed = yes - ? true - : yield* output.promptConfirm( - "Do you want to log out? This will remove the access token from your system.", - { defaultValue: false }, - ); + // Confirm prompt, honoring the global `--yes`/`SUPABASE_YES` (`logout.go:15-16`). + const confirmed = yield* Effect.gen(function* () { + if (yes) return true; + // Machine (json/stream-json) mode has no Go equivalent — fail loudly on a + // non-interactive prompt rather than silently defaulting, preserving the + // existing contract. + if (output.format !== "text") { + return yield* output.promptConfirm(confirmLabel, { defaultValue: false }); + } + // Text mode mirrors Go's `PromptYesNo(..., false)` (`logout.go:16`): it scans + // piped stdin before falling back to the default (`console.go:64-82`), so + // `printf 'y\n' | supabase logout` deletes the token. + return yield* legacyPromptYesNo(output, yes, confirmLabel, false); + }); if (!confirmed) { return yield* Effect.fail( new LegacyLogoutCancelledError({ message: LEGACY_LOGOUT_CANCELLED_MESSAGE }), diff --git a/apps/cli/src/legacy/commands/logout/logout.integration.test.ts b/apps/cli/src/legacy/commands/logout/logout.integration.test.ts index 39b3701f36..a1dbbb0b84 100644 --- a/apps/cli/src/legacy/commands/logout/logout.integration.test.ts +++ b/apps/cli/src/legacy/commands/logout/logout.integration.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, Exit, Layer } from "effect"; -import { mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { mockOutput, mockStdin, mockTty } from "../../../../tests/helpers/mocks.ts"; +import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; import { mockLegacyCredentialsTracked, mockLegacyTelemetryStateTracked, @@ -15,6 +16,10 @@ interface SetupOpts { readonly yes?: boolean; readonly deleteOutcome?: "ok" | "notLoggedIn" | "deleteError"; readonly promptConfirmFail?: boolean; + /** stdin interactivity; defaults to a TTY so prompt-driven tests reach the confirm. */ + readonly stdinIsTty?: boolean; + /** Piped (non-TTY) stdin answers, one consumed per confirmation prompt. */ + readonly pipedAnswers?: ReadonlyArray; } function setupLegacyLogout(opts: SetupOpts = {}) { @@ -30,6 +35,12 @@ function setupLegacyLogout(opts: SetupOpts = {}) { credentials.layer, telemetry.layer, Layer.succeed(LegacyYesFlag, opts.yes ?? false), + mockTty({ stdinIsTty: opts.stdinIsTty ?? true, stdoutIsTty: false }), + mockStdin( + opts.stdinIsTty ?? true, + opts.pipedAnswers ? `${opts.pipedAnswers.join("\n")}\n` : undefined, + ), + Layer.succeed(CliArgs, { args: [] }), ); return { layer, out, telemetry, credentials }; } @@ -69,6 +80,53 @@ describe("legacy logout integration", () => { }).pipe(Effect.provide(layer)); }); + it.live("empty non-interactive stdin takes Go's default (false) and cancels", () => { + // Go's `PromptYesNo(..., false)` scans stdin and falls back to the default when + // the scan is empty (`logout.go:16`, `console.go:64-82`). With no piped input + // logout cancels — without hanging on the clack confirm. + const { layer, credentials } = setupLegacyLogout({ stdinIsTty: false }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyLogout()); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyLogoutCancelledError"); + } + expect(credentials.deletedAll).toBe(false); + }).pipe(Effect.provide(layer)); + }); + + it.live("honors a piped 'y' on non-interactive stdin and logs out", () => { + // Regression: Go scans piped stdin before defaulting (`console.go:74-82`), so + // `printf 'y\n' | supabase logout` deletes the token even on a non-terminal. + const { layer, credentials } = setupLegacyLogout({ stdinIsTty: false, pipedAnswers: ["y"] }); + return Effect.gen(function* () { + yield* legacyLogout(); + expect(credentials.deletedAll).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("honors SUPABASE_YES and logs out even when a piped 'n' is present", () => { + // Go reads `viper.GetBool("YES")` (incl. the SUPABASE_YES env var) BEFORE + // scanning stdin (`console.go:71`), so `SUPABASE_YES=1 printf 'n\n' | supabase + // logout` auto-confirms and deletes rather than consuming the piped `n`. The + // handler resolves `yes` via legacyResolveYes, not the raw --yes flag. + const prev = process.env["SUPABASE_YES"]; + process.env["SUPABASE_YES"] = "1"; + const { layer, credentials } = setupLegacyLogout({ stdinIsTty: false, pipedAnswers: ["n"] }); + return Effect.gen(function* () { + yield* legacyLogout(); + expect(credentials.deletedAll).toBe(true); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (prev === undefined) delete process.env["SUPABASE_YES"]; + else process.env["SUPABASE_YES"] = prev; + }), + ), + Effect.provide(layer), + ); + }); + it.live("not logged in: prints to stderr, exits 0, and does not sweep credentials", () => { const { layer, out, credentials } = setupLegacyLogout({ yes: true, diff --git a/apps/cli/src/legacy/commands/logout/logout.layers.ts b/apps/cli/src/legacy/commands/logout/logout.layers.ts index a2b44c218d..6498228c50 100644 --- a/apps/cli/src/legacy/commands/logout/logout.layers.ts +++ b/apps/cli/src/legacy/commands/logout/logout.layers.ts @@ -5,6 +5,7 @@ import { legacyCliConfigLayer } from "../../config/legacy-cli-config.layer.ts"; import { legacyDebugLoggerLayer } from "../../shared/legacy-debug-logger.layer.ts"; import { legacyTelemetryStateLayer } from "../../telemetry/legacy-telemetry-state.layer.ts"; import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; +import { stdinLayer } from "../../../shared/runtime/stdin.layer.ts"; /** * Lean runtime for `logout`. Like `unlink`, it must NOT use @@ -14,8 +15,10 @@ import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.lay * * `legacyCliConfigLayer` is provided to `legacyCredentialsLayer` and also exposed * at the top level (`Layer.provide` does not share to siblings inside a merge — - * legacy CLAUDE.md item 5). `Analytics`, `Output`, `Stdio`, `FileSystem`, - * `Path`, `TelemetryRuntime`, and `LegacyYesFlag` come from the root layer. + * legacy CLAUDE.md item 5). `Analytics`, `Output`, `Stdio`, `Tty`, `FileSystem`, + * `Path`, `TelemetryRuntime`, and `LegacyYesFlag` come from the root layer; + * `stdinLayer` (the shared piped-stdin reader for the logout confirm) builds its + * `Stdin` from the root `Stdio`/`Tty`, like the migration runtimes. */ const cliConfig = legacyCliConfigLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); const credentials = legacyCredentialsLayer.pipe( @@ -28,4 +31,5 @@ export const legacyLogoutRuntimeLayer = Layer.mergeAll( cliConfig, legacyTelemetryStateLayer, commandRuntimeLayer(["logout"]), + stdinLayer, ); diff --git a/apps/cli/src/legacy/commands/network-bans/get/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/network-bans/get/SIDE_EFFECTS.md index fdb5dbaf68..f9042c22a7 100644 --- a/apps/cli/src/legacy/commands/network-bans/get/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/network-bans/get/SIDE_EFFECTS.md @@ -9,10 +9,10 @@ ## Files Written -| Path | Format | When | -| ------------------------------------------------ | ------ | ----------------------------------------------------------------------------- | -| `~/.supabase//linked-project.json` | JSON | always (after ref resolution), via `Effect.ensuring` — on success and failure | -| `~/.supabase/telemetry.json` | JSON | always, via `Effect.ensuring` — on success and failure | +| Path | Format | When | +| ------------------------------------------------ | ------ | -------------------------------------------------------------------------------------------------------------------------- | +| `~/.supabase//linked-project.json` | JSON | once the `--experimental` gate is open, after ref resolution, via `Effect.ensuring` — on success and failure | +| `~/.supabase/telemetry.json` | JSON | once the `--experimental` gate is open, via `Effect.ensuring` — on success and failure. Not written if the gate is closed. | ## API Routes @@ -24,21 +24,23 @@ The Management API exposes this read operation as `POST .../network-bans/retriev ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | -------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | -| `PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` → prompt) | +| Variable | Purpose | Required? | +| ----------------------- | -------------------------------------------------------- | -------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| `PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` → prompt) | +| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | ## Exit Codes -| Code | Condition | -| ---- | --------------------------------------------------------------------------------------- | -| `0` | success — network bans printed to stdout | -| `1` | project ref unresolved (`LegacyProjectNotLinkedError` / `LegacyInvalidProjectRefError`) | -| `1` | API non-2xx (`LegacyNetworkBansGetUnexpectedStatusError`) | -| `1` | transport failure (`LegacyNetworkBansGetNetworkError`) | -| `1` | `--output env` requested (`LegacyNetworkBansEnvNotSupportedError`) | +| Code | Condition | +| ---- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success — network bans printed to stdout | +| `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`LegacyExperimentalRequiredError`) — checked before ref resolution/API/telemetry | +| `1` | project ref unresolved (`LegacyProjectNotLinkedError` / `LegacyInvalidProjectRefError`) | +| `1` | API non-2xx (`LegacyNetworkBansGetUnexpectedStatusError`) | +| `1` | transport failure (`LegacyNetworkBansGetNetworkError`) | +| `1` | `--output env` requested (`LegacyNetworkBansEnvNotSupportedError`) | ## Telemetry Events Fired @@ -80,5 +82,10 @@ One `result` event whose `data` is the full response object. - The Go `--output` flag wins over the TS `--output-format` flag when both are provided. - `linked-project.json` is written **after** the project ref is resolved, regardless of whether the subsequent API call succeeds (mirrors Go's `PersistentPostRun`). -- `telemetry.json` is written on every invocation, including failures. +- `telemetry.json` is written on every invocation, including failures, but only once the `--experimental` gate is open. - Network bans are temporary blocks on IPs with abusive traffic patterns (e.g. multiple failed auth attempts). +- `network-bans` is an experimental command (Go `root.go:63`, `bansCmd`): `get` requires + `--experimental` (or `SUPABASE_EXPERIMENTAL`), matching Go's root-level `PersistentPreRunE` + gate (`root.go:91-96`), which runs before the `IsManagementAPI` login check (`root.go:105-109`). + A closed gate exits 1 before project-ref resolution, the API call, the `linked-project.json` + write, the `telemetry.json` write, and the `cli_command_executed` event. diff --git a/apps/cli/src/legacy/commands/network-bans/get/get.command.ts b/apps/cli/src/legacy/commands/network-bans/get/get.command.ts index 0789bcf660..33333bbda8 100644 --- a/apps/cli/src/legacy/commands/network-bans/get/get.command.ts +++ b/apps/cli/src/legacy/commands/network-bans/get/get.command.ts @@ -1,9 +1,15 @@ +import { Effect } 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 { legacyRequireExperimental } from "../../../shared/legacy-experimental-gate.ts"; +import { LEGACY_RESOURCE_OUTPUT_FORMATS } from "../../../shared/legacy-go-output-flag.ts"; import { legacyManagementApiRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts"; -import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; +import { + legacyValidateOutputFormat, + withLegacyCommandInstrumentation, +} from "../../../telemetry/legacy-command-instrumentation.ts"; import { legacyNetworkBansGet } from "./get.handler.ts"; const config = { @@ -19,10 +25,23 @@ export const legacyNetworkBansGetCommand = Command.make("get", config).pipe( Command.withDescription("Get the current network bans."), Command.withShortDescription("Get the current network bans"), Command.withHandler((flags) => - legacyNetworkBansGet(flags).pipe( - withLegacyCommandInstrumentation({ flags }), - withJsonErrorHandling, - ), + Effect.gen(function* () { + // Cobra parses flags — rejecting an out-of-enum `-o` (`internal/utils/enum.go:21-27`) + // — before `PersistentPreRunE` ever runs (`cobra@v1.10.2/command.go:919,985`), so an + // invalid `-o` value must win over a missing `--experimental` flag. + yield* legacyValidateOutputFormat(LEGACY_RESOURCE_OUTPUT_FORMATS); + // Go gates `bansCmd` (network-bans) behind `--experimental` in PersistentPreRunE + // (root.go:91-96) BEFORE the `IsManagementAPI` login check (root.go:105-109). + // `legacyManagementApiRuntimeLayer` eagerly resolves an access token as part + // of building its `LegacyPlatformApi` layer, so it must be provided AFTER + // the gate (inline here) rather than via `Command.provide` on the whole + // command — `Command.provide` would build the layer, and fail on a missing + // token, before this generator's first `yield*` ever runs. + yield* legacyRequireExperimental; + return yield* legacyNetworkBansGet(flags).pipe( + withLegacyCommandInstrumentation({ flags }), + Effect.provide(legacyManagementApiRuntimeLayer(["network-bans", "get"])), + ); + }).pipe(withJsonErrorHandling), ), - Command.provide(legacyManagementApiRuntimeLayer(["network-bans", "get"])), ); diff --git a/apps/cli/src/legacy/commands/network-bans/network-bans.experimental-gate.integration.test.ts b/apps/cli/src/legacy/commands/network-bans/network-bans.experimental-gate.integration.test.ts new file mode 100644 index 0000000000..867514f1df --- /dev/null +++ b/apps/cli/src/legacy/commands/network-bans/network-bans.experimental-gate.integration.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, Layer } from "effect"; +import { CliOutput, Command } from "effect/unstable/cli"; + +import { textCliOutputFormatter } from "../../../shared/output/text-formatter.ts"; +import { LEGACY_GLOBAL_FLAGS } from "../../../shared/legacy/global-flags.ts"; +import { TelemetryRuntime } from "../../../shared/telemetry/runtime.service.ts"; +import { makeTelemetryIdentity } from "../../../shared/telemetry/identity.ts"; +import { mockOutput, mockRuntimeInfo, processEnvLayer } from "../../../../tests/helpers/mocks.ts"; +import { + buildLegacyTestRuntime, + mockLegacyCliConfig, + mockLegacyPlatformApi, + useLegacyTempWorkdir, +} from "../../../../tests/helpers/legacy-mocks.ts"; +import { legacyNetworkBansCommand } from "./network-bans.command.ts"; + +// See postgres-config.experimental-gate.integration.test.ts for the full +// rationale: this proves `--experimental` is wired into the actual +// `.command.ts` handler pipeline AND runs before +// `legacyManagementApiRuntimeLayer`'s eager access-token resolution +// (Go's `IsExperimental` check precedes `IsManagementAPI` in +// `apps/cli-go/cmd/root.go:91-109`). + +const tempRoot = useLegacyTempWorkdir("supabase-network-bans-experimental-int-"); + +const testRoot = Command.make("supabase").pipe( + Command.withGlobalFlags(LEGACY_GLOBAL_FLAGS), + Command.withSubcommands([legacyNetworkBansCommand]), +); + +function setup() { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ + response: { status: 200, body: { banned_ipv4_addresses: [] } }, + }); + const runtime = buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + // `RuntimeInfo` is ambient (not provided by `legacyManagementApiRuntimeLayer` + // itself), so the real `legacyCredentialsLayer` built inline inside the + // command for the "gate open" case resolves ITS `RuntimeInfo` from this + // layer. Point homeDir at this test's isolated tempRoot so the layer's + // file-based token fallback (`/.supabase/access-token`) can't pick + // up a stray token left at the shared default `/tmp/supabase-cli-test-home`. + runtimeInfo: mockRuntimeInfo({ homeDir: tempRoot.current }), + }); + const layer = Layer.mergeAll( + runtime, + CliOutput.layer(textCliOutputFormatter()), + // The "gate open" case reaches the real `legacyManagementApiRuntimeLayer` + // (provided inline inside the command, not by this test's mocked runtime), + // which reads credentials/env directly — an ambient SUPABASE_ACCESS_TOKEN, + // SUPABASE_EXPERIMENTAL, or OS keyring entry on the machine running the + // test would make these assertions non-deterministic. Wipe process.env + // down to just this and disable the keyring fallback. + processEnvLayer({ SUPABASE_NO_KEYRING: "1" }), + Layer.succeed( + TelemetryRuntime, + TelemetryRuntime.of({ + configDir: `${tempRoot.current}/.supabase`, + tracesDir: `${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 { layer, api }; +} + +describe("legacy network-bans experimental gate (Go PersistentPreRunE parity)", () => { + const leaves: ReadonlyArray<{ readonly name: string; readonly args: ReadonlyArray }> = [ + { name: "get", args: ["network-bans", "get"] }, + { name: "remove", args: ["network-bans", "remove"] }, + ]; + + for (const { name, args } of leaves) { + it.live( + `${name} fails with LegacyExperimentalRequiredError when --experimental is unset`, + () => { + const { layer, api } = setup(); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + Command.runWith(testRoot, { version: "0.0.0-test" })(args), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyExperimentalRequiredError"); + } + expect(api.requests).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live(`${name} does not fail with the gate error once --experimental is set`, () => { + const { layer, api } = setup(); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + Command.runWith(testRoot, { version: "0.0.0-test" })([...args, "--experimental"]), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const causeText = JSON.stringify(exit.cause); + expect(causeText).not.toContain("LegacyExperimentalRequiredError"); + expect(causeText).toContain("LegacyPlatformAuthRequiredError"); + } + expect(api.requests).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }); + } +}); diff --git a/apps/cli/src/legacy/commands/network-bans/remove/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/network-bans/remove/SIDE_EFFECTS.md index f1252d674b..333bf103a1 100644 --- a/apps/cli/src/legacy/commands/network-bans/remove/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/network-bans/remove/SIDE_EFFECTS.md @@ -9,10 +9,10 @@ ## Files Written -| Path | Format | When | -| ------------------------------------------------ | ------ | ----------------------------------------------------------------------------- | -| `~/.supabase//linked-project.json` | JSON | always (after ref resolution), via `Effect.ensuring` — on success and failure | -| `~/.supabase/telemetry.json` | JSON | always, via `Effect.ensuring` — on success and failure | +| Path | Format | When | +| ------------------------------------------------ | ------ | -------------------------------------------------------------------------------------------------------------------------- | +| `~/.supabase//linked-project.json` | JSON | once the `--experimental` gate is open, after ref resolution, via `Effect.ensuring` — on success and failure | +| `~/.supabase/telemetry.json` | JSON | once the `--experimental` gate is open, via `Effect.ensuring` — on success and failure. Not written if the gate is closed. | ## API Routes @@ -24,21 +24,23 @@ ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | -------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | -| `PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` → prompt) | +| Variable | Purpose | Required? | +| ----------------------- | -------------------------------------------------------- | -------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| `PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` → prompt) | +| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | ## Exit Codes -| Code | Condition | -| ---- | --------------------------------------------------------------------------------------- | -| `0` | success — network ban removed | -| `1` | invalid IP supplied via `--db-unban-ip` (`LegacyNetworkBansInvalidIpError`) | -| `1` | project ref unresolved (`LegacyProjectNotLinkedError` / `LegacyInvalidProjectRefError`) | -| `1` | API non-2xx (`LegacyNetworkBansRemoveUnexpectedStatusError`) | -| `1` | transport failure (`LegacyNetworkBansRemoveNetworkError`) | +| Code | Condition | +| ---- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success — network ban removed | +| `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`LegacyExperimentalRequiredError`) — checked before ref resolution/API/telemetry | +| `1` | invalid IP supplied via `--db-unban-ip` (`LegacyNetworkBansInvalidIpError`) | +| `1` | project ref unresolved (`LegacyProjectNotLinkedError` / `LegacyInvalidProjectRefError`) | +| `1` | API non-2xx (`LegacyNetworkBansRemoveUnexpectedStatusError`) | +| `1` | transport failure (`LegacyNetworkBansRemoveNetworkError`) | ## Telemetry Events Fired @@ -78,4 +80,9 @@ One `result` event on success when the Go `--output` flag is unset. - Requires `--db-unban-ip` flag to specify IP(s) to unban (repeatable). When omitted, the caller's own IP is unbanned (`requester_ip: true`). - Requires `--project-ref` or a linked project (`.supabase/config.json`). - `linked-project.json` is written **after** the project ref is resolved, regardless of whether the subsequent API call succeeds (mirrors Go's `PersistentPostRun`). -- `telemetry.json` is written on every invocation, including failures. +- `telemetry.json` is written on every invocation, including failures, but only once the `--experimental` gate is open. +- `network-bans` is an experimental command (Go `root.go:63`, `bansCmd`): `remove` requires + `--experimental` (or `SUPABASE_EXPERIMENTAL`), matching Go's root-level `PersistentPreRunE` + gate (`root.go:91-96`), which runs before the `IsManagementAPI` login check (`root.go:105-109`). + A closed gate exits 1 before project-ref resolution, the API call, the `linked-project.json` + write, the `telemetry.json` write, and the `cli_command_executed` event. diff --git a/apps/cli/src/legacy/commands/network-bans/remove/remove.command.ts b/apps/cli/src/legacy/commands/network-bans/remove/remove.command.ts index dde28a344d..02beb81d15 100644 --- a/apps/cli/src/legacy/commands/network-bans/remove/remove.command.ts +++ b/apps/cli/src/legacy/commands/network-bans/remove/remove.command.ts @@ -1,9 +1,15 @@ +import { Effect } 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 { legacyRequireExperimental } from "../../../shared/legacy-experimental-gate.ts"; +import { LEGACY_RESOURCE_OUTPUT_FORMATS } from "../../../shared/legacy-go-output-flag.ts"; import { legacyManagementApiRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts"; -import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; +import { + legacyValidateOutputFormat, + withLegacyCommandInstrumentation, +} from "../../../telemetry/legacy-command-instrumentation.ts"; import { legacyNetworkBansRemove } from "./remove.handler.ts"; const config = { @@ -23,10 +29,23 @@ export const legacyNetworkBansRemoveCommand = Command.make("remove", config).pip Command.withDescription("Remove a network ban."), Command.withShortDescription("Remove a network ban"), Command.withHandler((flags) => - legacyNetworkBansRemove(flags).pipe( - withLegacyCommandInstrumentation({ flags }), - withJsonErrorHandling, - ), + Effect.gen(function* () { + // Cobra parses flags — rejecting an out-of-enum `-o` (`internal/utils/enum.go:21-27`) + // — before `PersistentPreRunE` ever runs (`cobra@v1.10.2/command.go:919,985`), so an + // invalid `-o` value must win over a missing `--experimental` flag. + yield* legacyValidateOutputFormat(LEGACY_RESOURCE_OUTPUT_FORMATS); + // Go gates `bansCmd` (network-bans) behind `--experimental` in PersistentPreRunE + // (root.go:91-96) BEFORE the `IsManagementAPI` login check (root.go:105-109). + // `legacyManagementApiRuntimeLayer` eagerly resolves an access token as part + // of building its `LegacyPlatformApi` layer, so it must be provided AFTER + // the gate (inline here) rather than via `Command.provide` on the whole + // command — `Command.provide` would build the layer, and fail on a missing + // token, before this generator's first `yield*` ever runs. + yield* legacyRequireExperimental; + return yield* legacyNetworkBansRemove(flags).pipe( + withLegacyCommandInstrumentation({ flags }), + Effect.provide(legacyManagementApiRuntimeLayer(["network-bans", "remove"])), + ); + }).pipe(withJsonErrorHandling), ), - Command.provide(legacyManagementApiRuntimeLayer(["network-bans", "remove"])), ); diff --git a/apps/cli/src/legacy/commands/network-bans/remove/remove.handler.ts b/apps/cli/src/legacy/commands/network-bans/remove/remove.handler.ts index 4825dcfc89..cf8a743dd7 100644 --- a/apps/cli/src/legacy/commands/network-bans/remove/remove.handler.ts +++ b/apps/cli/src/legacy/commands/network-bans/remove/remove.handler.ts @@ -33,15 +33,18 @@ export const legacyNetworkBansRemove = Effect.fn("legacy.network-bans.remove")(f const telemetryState = yield* LegacyTelemetryState; yield* Effect.gen(function* () { - for (const ip of flags.dbUnbanIp) { - if (isIP(ip) === 0) { - return yield* new LegacyNetworkBansInvalidIpError({ input: ip }); - } - } - + // Go resolves the project ref in `PersistentPreRunE` (`cmd/root.go:108-114`), + // before `RunE`'s `--db-unban-ip` validation (`internal/bans/update/update.go:12-25`) + // ever runs — so a bad ref must surface before a bad IP, not after. const ref = yield* resolver.resolve(flags.projectRef); yield* Effect.gen(function* () { + for (const ip of flags.dbUnbanIp) { + if (isIP(ip) === 0) { + return yield* new LegacyNetworkBansInvalidIpError({ input: ip }); + } + } + yield* api.v1 .deleteNetworkBans({ ref, diff --git a/apps/cli/src/legacy/commands/network-bans/remove/remove.integration.test.ts b/apps/cli/src/legacy/commands/network-bans/remove/remove.integration.test.ts index f3f426de20..47e9bf7185 100644 --- a/apps/cli/src/legacy/commands/network-bans/remove/remove.integration.test.ts +++ b/apps/cli/src/legacy/commands/network-bans/remove/remove.integration.test.ts @@ -165,6 +165,60 @@ describe("legacy network-bans remove integration", () => { }).pipe(Effect.provide(layer)); }); + it.live( + "surfaces the unresolved-ref error, not the invalid-IP error, when both are wrong", + () => { + // Go resolves the project ref in PersistentPreRunE, before RunE's IP + // validation ever runs (cmd/root.go:108-114 vs internal/bans/update/update.go:12-25), + // so a bad ref must win over a bad IP — this is the regression CLI-1856 guards. + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ response: { status: 200, body: null } }); + const cliConfig = mockLegacyCliConfig({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = buildLegacyTestRuntime({ out, api, cliConfig }); + + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacyNetworkBansRemove({ + projectRef: Option.none(), + dbUnbanIp: ["12.3.4"], + }), + ); + expect(Exit.isFailure(exit)).toBe(true); + expect(api.requests).toHaveLength(0); + if (Exit.isFailure(exit)) { + const errJson = JSON.stringify(exit.cause); + expect(errJson).toContain("LegacyProjectNotLinkedError"); + expect(errJson).not.toContain("LegacyNetworkBansInvalidIpError"); + } + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "surfaces the invalid-project-ref error, not the invalid-IP error, when both are wrong", + () => { + const { layer, api } = setup(); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacyNetworkBansRemove({ + projectRef: Option.some("BADREF"), + dbUnbanIp: ["12.3.4"], + }), + ); + expect(Exit.isFailure(exit)).toBe(true); + expect(api.requests).toHaveLength(0); + if (Exit.isFailure(exit)) { + const errJson = JSON.stringify(exit.cause); + expect(errJson).toContain("LegacyInvalidProjectRefError"); + expect(errJson).not.toContain("LegacyNetworkBansInvalidIpError"); + } + }).pipe(Effect.provide(layer)); + }, + ); + it.live("fails with LegacyNetworkBansRemoveUnexpectedStatusError on HTTP 503", () => { const { layer } = setup({ status: 503 }); return Effect.gen(function* () { diff --git a/apps/cli/src/legacy/commands/postgres-config/delete/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/postgres-config/delete/SIDE_EFFECTS.md index 892ae2b29c..69bd23294e 100644 --- a/apps/cli/src/legacy/commands/postgres-config/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/postgres-config/delete/SIDE_EFFECTS.md @@ -9,10 +9,10 @@ ## Files Written -| Path | Format | When | -| ------------------------------------------------ | ------ | ----------------------------------------------------------------------------- | -| `~/.supabase//linked-project.json` | JSON | always (after ref resolution), via `Effect.ensuring` - on success and failure | -| `~/.supabase/telemetry.json` | JSON | always, via `Effect.ensuring` - on success and failure | +| Path | Format | When | +| ------------------------------------------------ | ------ | -------------------------------------------------------------------------------------------------------------------------- | +| `~/.supabase//linked-project.json` | JSON | once the `--experimental` gate is open, after ref resolution, via `Effect.ensuring` - on success and failure | +| `~/.supabase/telemetry.json` | JSON | once the `--experimental` gate is open, via `Effect.ensuring` - on success and failure. Not written if the gate is closed. | ## API Routes @@ -25,24 +25,26 @@ This command does not call a delete endpoint. It mirrors Go: fetch current confi ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | --------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring -> `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | -| `PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` -> prompt) | +| Variable | Purpose | Required? | +| ----------------------- | -------------------------------------------------------- | -------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring -> `~/.supabase/access-token`) | +| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| `PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` -> prompt) | +| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | ## Exit Codes -| Code | Condition | -| ---- | ------------------------------------------------------------------------------------------------------------ | -| `0` | success - Postgres config updated with the deleted keys removed | -| `1` | project ref unresolved (`LegacyProjectNotLinkedError` / `LegacyInvalidProjectRefError`) | -| `1` | initial GET non-2xx (`LegacyPostgresConfigGetUnexpectedStatusError`) | -| `1` | initial GET transport failure (`LegacyPostgresConfigGetNetworkError`) | -| `1` | PUT non-2xx (`LegacyPostgresConfigDeleteUnexpectedStatusError`) | -| `1` | PUT transport failure (`LegacyPostgresConfigDeleteNetworkError`) | -| `1` | request serialization failure (`LegacyPostgresConfigDeleteSerializeError`) | -| `1` | invalid JSON response (`LegacyPostgresConfigGetUnmarshalError` / `LegacyPostgresConfigDeleteUnmarshalError`) | +| Code | Condition | +| ---- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success - Postgres config updated with the deleted keys removed | +| `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`LegacyExperimentalRequiredError`) - checked before ref resolution/API/telemetry | +| `1` | project ref unresolved (`LegacyProjectNotLinkedError` / `LegacyInvalidProjectRefError`) | +| `1` | initial GET non-2xx (`LegacyPostgresConfigGetUnexpectedStatusError`) | +| `1` | initial GET transport failure (`LegacyPostgresConfigGetNetworkError`) | +| `1` | PUT non-2xx (`LegacyPostgresConfigDeleteUnexpectedStatusError`) | +| `1` | PUT transport failure (`LegacyPostgresConfigDeleteNetworkError`) | +| `1` | request serialization failure (`LegacyPostgresConfigDeleteSerializeError`) | +| `1` | invalid JSON response (`LegacyPostgresConfigGetUnmarshalError` / `LegacyPostgresConfigDeleteUnmarshalError`) | ## Telemetry Events Fired @@ -98,4 +100,9 @@ One `result` event on success. - Each config key is trimmed with `strings.TrimSpace` before deletion, matching Go. - `--no-restart` injects `restart_database = false` into the final `PUT` body. - `linked-project.json` is written after the project ref resolves, regardless of whether the fetch or update succeeds. -- `telemetry.json` is written on every invocation, including failures. +- `telemetry.json` is written on every invocation, including failures, but only once the `--experimental` gate is open. +- `postgres-config` is an experimental command (Go `root.go:63`): `delete` requires `--experimental` + (or `SUPABASE_EXPERIMENTAL`), matching Go's root-level `PersistentPreRunE` gate (`root.go:91-96`), + which runs before the `IsManagementAPI` login check (`root.go:105-109`). A closed gate exits 1 + before project-ref resolution, the API calls, the `linked-project.json` write, the + `telemetry.json` write, and the `cli_command_executed` event. diff --git a/apps/cli/src/legacy/commands/postgres-config/delete/delete.command.ts b/apps/cli/src/legacy/commands/postgres-config/delete/delete.command.ts index b757be3ccb..c4dcbc169c 100644 --- a/apps/cli/src/legacy/commands/postgres-config/delete/delete.command.ts +++ b/apps/cli/src/legacy/commands/postgres-config/delete/delete.command.ts @@ -1,20 +1,33 @@ +import { Effect } 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 { legacyRequireExperimental } from "../../../shared/legacy-experimental-gate.ts"; +import { LEGACY_RESOURCE_OUTPUT_FORMATS } from "../../../shared/legacy-go-output-flag.ts"; import { legacyManagementApiRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts"; -import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; +import { legacyParseStringSliceFlag } from "../../../shared/legacy-string-slice-flag.ts"; +import { + legacyValidateOutputFormat, + withLegacyCommandInstrumentation, +} from "../../../telemetry/legacy-command-instrumentation.ts"; import { legacyPostgresConfigDelete } from "./delete.handler.ts"; +export const legacyPostgresConfigDeleteConfigFlag = Flag.string("config").pipe( + Flag.withDescription("Config keys to delete (comma-separated)"), + Flag.atLeast(0), + Flag.mapTryCatch( + (rawValues) => legacyParseStringSliceFlag(rawValues), + (err) => (err instanceof Error ? err.message : String(err)), + ), +); + const config = { projectRef: Flag.string("project-ref").pipe( Flag.withDescription("Project ref of the Supabase project."), Flag.optional, ), - config: Flag.string("config").pipe( - Flag.withDescription("Config keys to delete (comma-separated)"), - Flag.atLeast(0), - ), + config: legacyPostgresConfigDeleteConfigFlag, noRestart: Flag.boolean("no-restart").pipe( Flag.withDescription("Do not restart the database after deleting config."), ), @@ -26,10 +39,23 @@ export const legacyPostgresConfigDeleteCommand = Command.make("delete", config). Command.withDescription("Delete specific Postgres database config overrides."), Command.withShortDescription("Delete Postgres database config overrides"), Command.withHandler((flags) => - legacyPostgresConfigDelete(flags).pipe( - withLegacyCommandInstrumentation({ flags }), - withJsonErrorHandling, - ), + Effect.gen(function* () { + // Cobra parses flags — rejecting an out-of-enum `-o` (`internal/utils/enum.go:21-27`) + // — before `PersistentPreRunE` ever runs (`cobra@v1.10.2/command.go:919,985`), so an + // invalid `-o` value must win over a missing `--experimental` flag. + yield* legacyValidateOutputFormat(LEGACY_RESOURCE_OUTPUT_FORMATS); + // Go gates `postgresCmd` behind `--experimental` in PersistentPreRunE + // (root.go:91-96) BEFORE the `IsManagementAPI` login check (root.go:105-109). + // `legacyManagementApiRuntimeLayer` eagerly resolves an access token as part + // of building its `LegacyPlatformApi` layer, so it must be provided AFTER + // the gate (inline here) rather than via `Command.provide` on the whole + // command — `Command.provide` would build the layer, and fail on a missing + // token, before this generator's first `yield*` ever runs. + yield* legacyRequireExperimental; + return yield* legacyPostgresConfigDelete(flags).pipe( + withLegacyCommandInstrumentation({ flags }), + Effect.provide(legacyManagementApiRuntimeLayer(["postgres-config", "delete"])), + ); + }).pipe(withJsonErrorHandling), ), - Command.provide(legacyManagementApiRuntimeLayer(["postgres-config", "delete"])), ); diff --git a/apps/cli/src/legacy/commands/postgres-config/delete/delete.command.unit.test.ts b/apps/cli/src/legacy/commands/postgres-config/delete/delete.command.unit.test.ts new file mode 100644 index 0000000000..79b7d0df2a --- /dev/null +++ b/apps/cli/src/legacy/commands/postgres-config/delete/delete.command.unit.test.ts @@ -0,0 +1,46 @@ +import { BunServices } from "@effect/platform-bun"; +import { Effect, Exit } from "effect"; +import { describe, expect, test } from "vitest"; +import { legacyPostgresConfigDeleteConfigFlag } from "./delete.command.ts"; + +describe("legacy postgres-config delete --config flag (pflag StringSlice parity)", () => { + test("splits a comma-separated value into multiple keys", async () => { + const [, values] = await Effect.runPromise( + legacyPostgresConfigDeleteConfigFlag + .parse({ + flags: { config: ["max_connections,statement_timeout"] }, + arguments: [], + }) + .pipe(Effect.provide(BunServices.layer)), + ); + + expect(values).toEqual(["max_connections", "statement_timeout"]); + }); + + test("accumulates repeated occurrences, each CSV-split", async () => { + const [, values] = await Effect.runPromise( + legacyPostgresConfigDeleteConfigFlag + .parse({ + flags: { config: ["max_connections,statement_timeout", "custom_key"] }, + arguments: [], + }) + .pipe(Effect.provide(BunServices.layer)), + ); + + expect(values).toEqual(["max_connections", "statement_timeout", "custom_key"]); + }); + + test("rejects malformed CSV (bare quote)", async () => { + const exit = await Effect.runPromise( + legacyPostgresConfigDeleteConfigFlag + .parse({ + flags: { config: ['max"connections'] }, + arguments: [], + }) + .pipe(Effect.provide(BunServices.layer)) + .pipe(Effect.exit), + ); + + expect(Exit.isFailure(exit)).toBe(true); + }); +}); diff --git a/apps/cli/src/legacy/commands/postgres-config/get/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/postgres-config/get/SIDE_EFFECTS.md index 94e0953515..6582161bd1 100644 --- a/apps/cli/src/legacy/commands/postgres-config/get/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/postgres-config/get/SIDE_EFFECTS.md @@ -9,10 +9,10 @@ ## Files Written -| Path | Format | When | -| ------------------------------------------------ | ------ | ----------------------------------------------------------------------------- | -| `~/.supabase//linked-project.json` | JSON | always (after ref resolution), via `Effect.ensuring` - on success and failure | -| `~/.supabase/telemetry.json` | JSON | always, via `Effect.ensuring` - on success and failure | +| Path | Format | When | +| ------------------------------------------------ | ------ | -------------------------------------------------------------------------------------------------------------------------- | +| `~/.supabase//linked-project.json` | JSON | once the `--experimental` gate is open, after ref resolution, via `Effect.ensuring` - on success and failure | +| `~/.supabase/telemetry.json` | JSON | once the `--experimental` gate is open, via `Effect.ensuring` - on success and failure. Not written if the gate is closed. | ## API Routes @@ -22,21 +22,23 @@ ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | --------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring -> `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | -| `PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` -> prompt) | +| Variable | Purpose | Required? | +| ----------------------- | -------------------------------------------------------- | -------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring -> `~/.supabase/access-token`) | +| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| `PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` -> prompt) | +| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | ## Exit Codes -| Code | Condition | -| ---- | --------------------------------------------------------------------------------------- | -| `0` | success - Postgres config printed | -| `1` | project ref unresolved (`LegacyProjectNotLinkedError` / `LegacyInvalidProjectRefError`) | -| `1` | API non-2xx (`LegacyPostgresConfigGetUnexpectedStatusError`) | -| `1` | transport failure (`LegacyPostgresConfigGetNetworkError`) | -| `1` | invalid JSON response (`LegacyPostgresConfigGetUnmarshalError`) | +| Code | Condition | +| ---- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success - Postgres config printed | +| `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`LegacyExperimentalRequiredError`) - checked before ref resolution/API/telemetry | +| `1` | project ref unresolved (`LegacyProjectNotLinkedError` / `LegacyInvalidProjectRefError`) | +| `1` | API non-2xx (`LegacyPostgresConfigGetUnexpectedStatusError`) | +| `1` | transport failure (`LegacyPostgresConfigGetNetworkError`) | +| `1` | invalid JSON response (`LegacyPostgresConfigGetUnmarshalError`) | ## Telemetry Events Fired @@ -89,4 +91,9 @@ One `result` event on success. - The Go `--output` flag wins over the TS `--output-format` flag when both are provided. - Requires `--project-ref` or a linked project (`.supabase/config.json`). - `linked-project.json` is written after the project ref resolves, regardless of whether the fetch succeeds. -- `telemetry.json` is written on every invocation, including failures. +- `telemetry.json` is written on every invocation, including failures, but only once the `--experimental` gate is open. +- `postgres-config` is an experimental command (Go `root.go:63`): `get` requires `--experimental` + (or `SUPABASE_EXPERIMENTAL`), matching Go's root-level `PersistentPreRunE` gate (`root.go:91-96`), + which runs before the `IsManagementAPI` login check (`root.go:105-109`). A closed gate exits 1 + before project-ref resolution, the API call, the `linked-project.json` write, the + `telemetry.json` write, and the `cli_command_executed` event. diff --git a/apps/cli/src/legacy/commands/postgres-config/get/get.command.ts b/apps/cli/src/legacy/commands/postgres-config/get/get.command.ts index 845f0641bb..3606f50e2a 100644 --- a/apps/cli/src/legacy/commands/postgres-config/get/get.command.ts +++ b/apps/cli/src/legacy/commands/postgres-config/get/get.command.ts @@ -1,9 +1,15 @@ +import { Effect } 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 { legacyRequireExperimental } from "../../../shared/legacy-experimental-gate.ts"; +import { LEGACY_RESOURCE_OUTPUT_FORMATS } from "../../../shared/legacy-go-output-flag.ts"; import { legacyManagementApiRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts"; -import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; +import { + legacyValidateOutputFormat, + withLegacyCommandInstrumentation, +} from "../../../telemetry/legacy-command-instrumentation.ts"; import { legacyPostgresConfigGet } from "./get.handler.ts"; const config = { @@ -19,10 +25,23 @@ export const legacyPostgresConfigGetCommand = Command.make("get", config).pipe( Command.withDescription("Get the current Postgres database config overrides."), Command.withShortDescription("Get Postgres database config"), Command.withHandler((flags) => - legacyPostgresConfigGet(flags).pipe( - withLegacyCommandInstrumentation({ flags }), - withJsonErrorHandling, - ), + Effect.gen(function* () { + // Cobra parses flags — rejecting an out-of-enum `-o` (`internal/utils/enum.go:21-27`) + // — before `PersistentPreRunE` ever runs (`cobra@v1.10.2/command.go:919,985`), so an + // invalid `-o` value must win over a missing `--experimental` flag. + yield* legacyValidateOutputFormat(LEGACY_RESOURCE_OUTPUT_FORMATS); + // Go gates `postgresCmd` behind `--experimental` in PersistentPreRunE + // (root.go:91-96) BEFORE the `IsManagementAPI` login check (root.go:105-109). + // `legacyManagementApiRuntimeLayer` eagerly resolves an access token as part + // of building its `LegacyPlatformApi` layer, so it must be provided AFTER + // the gate (inline here) rather than via `Command.provide` on the whole + // command — `Command.provide` would build the layer, and fail on a missing + // token, before this generator's first `yield*` ever runs. + yield* legacyRequireExperimental; + return yield* legacyPostgresConfigGet(flags).pipe( + withLegacyCommandInstrumentation({ flags }), + Effect.provide(legacyManagementApiRuntimeLayer(["postgres-config", "get"])), + ); + }).pipe(withJsonErrorHandling), ), - Command.provide(legacyManagementApiRuntimeLayer(["postgres-config", "get"])), ); diff --git a/apps/cli/src/legacy/commands/postgres-config/postgres-config.experimental-gate.integration.test.ts b/apps/cli/src/legacy/commands/postgres-config/postgres-config.experimental-gate.integration.test.ts new file mode 100644 index 0000000000..3b9c42e048 --- /dev/null +++ b/apps/cli/src/legacy/commands/postgres-config/postgres-config.experimental-gate.integration.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, Layer } from "effect"; +import { CliOutput, Command } from "effect/unstable/cli"; + +import { textCliOutputFormatter } from "../../../shared/output/text-formatter.ts"; +import { LEGACY_GLOBAL_FLAGS } from "../../../shared/legacy/global-flags.ts"; +import { TelemetryRuntime } from "../../../shared/telemetry/runtime.service.ts"; +import { makeTelemetryIdentity } from "../../../shared/telemetry/identity.ts"; +import { mockOutput, mockRuntimeInfo, processEnvLayer } from "../../../../tests/helpers/mocks.ts"; +import { + buildLegacyTestRuntime, + mockLegacyCliConfig, + mockLegacyPlatformApi, + useLegacyTempWorkdir, +} from "../../../../tests/helpers/legacy-mocks.ts"; +import { legacyPostgresConfigCommand } from "./postgres-config.command.ts"; + +// This suite proves the `--experimental` gate is wired into the actual +// `.command.ts` handler pipeline (not just the shared helper in isolation), +// and — critically — that it runs BEFORE `legacyManagementApiRuntimeLayer` +// resolves an access token. Go's root `PersistentPreRunE` checks +// `IsExperimental` before the `IsManagementAPI` login check +// (`apps/cli-go/cmd/root.go:91-109`); `legacyManagementApiRuntimeLayer` +// eagerly fails on a missing token as part of its own layer construction, so +// wiring the gate anywhere except immediately before that layer is attached +// would let a missing-token error mask the missing-`--experimental` error. + +const tempRoot = useLegacyTempWorkdir("supabase-postgres-config-experimental-int-"); + +const testRoot = Command.make("supabase").pipe( + Command.withGlobalFlags(LEGACY_GLOBAL_FLAGS), + Command.withSubcommands([legacyPostgresConfigCommand]), +); + +function setup() { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ + response: { status: 200, body: { max_connections: 100 } }, + }); + const runtime = buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + // `RuntimeInfo` is ambient (not provided by `legacyManagementApiRuntimeLayer` + // itself), so the real `legacyCredentialsLayer` built inline inside the + // command for the "gate open" case resolves ITS `RuntimeInfo` from this + // layer. Point homeDir at this test's isolated tempRoot so the layer's + // file-based token fallback (`/.supabase/access-token`) can't pick + // up a stray token left at the shared default `/tmp/supabase-cli-test-home`. + runtimeInfo: mockRuntimeInfo({ homeDir: tempRoot.current }), + }); + const layer = Layer.mergeAll( + runtime, + CliOutput.layer(textCliOutputFormatter()), + // The "gate open" case reaches the real `legacyManagementApiRuntimeLayer` + // (provided inline inside the command, not by this test's mocked runtime), + // which reads credentials/env directly — an ambient SUPABASE_ACCESS_TOKEN, + // SUPABASE_EXPERIMENTAL, or OS keyring entry on the machine running the + // test would make these assertions non-deterministic. Wipe process.env + // down to just this and disable the keyring fallback. + processEnvLayer({ SUPABASE_NO_KEYRING: "1" }), + Layer.succeed( + TelemetryRuntime, + TelemetryRuntime.of({ + configDir: `${tempRoot.current}/.supabase`, + tracesDir: `${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 { layer, api }; +} + +describe("legacy postgres-config experimental gate (Go PersistentPreRunE parity)", () => { + const leaves: ReadonlyArray<{ readonly name: string; readonly args: ReadonlyArray }> = [ + { name: "get", args: ["postgres-config", "get"] }, + { name: "update", args: ["postgres-config", "update"] }, + { name: "delete", args: ["postgres-config", "delete"] }, + ]; + + for (const { name, args } of leaves) { + it.live( + `${name} fails with LegacyExperimentalRequiredError when --experimental is unset`, + () => { + const { layer, api } = setup(); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + Command.runWith(testRoot, { version: "0.0.0-test" })(args), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyExperimentalRequiredError"); + } + // The gate must run before any API call (and before the eager + // access-token resolution inside `legacyManagementApiRuntimeLayer`) — + // a closed gate makes zero network requests. + expect(api.requests).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live(`${name} does not fail with the gate error once --experimental is set`, () => { + const { layer, api } = setup(); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + Command.runWith(testRoot, { version: "0.0.0-test" })([...args, "--experimental"]), + ); + // No real access token is configured in this test environment, so the + // command still fails — but past the gate, at the auth-resolution step + // that `legacyManagementApiRuntimeLayer` performs, never with the + // experimental gate error once the flag is on. + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const causeText = JSON.stringify(exit.cause); + expect(causeText).not.toContain("LegacyExperimentalRequiredError"); + expect(causeText).toContain("LegacyPlatformAuthRequiredError"); + } + expect(api.requests).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }); + } +}); diff --git a/apps/cli/src/legacy/commands/postgres-config/update/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/postgres-config/update/SIDE_EFFECTS.md index 030a6a322d..edd0de4c4a 100644 --- a/apps/cli/src/legacy/commands/postgres-config/update/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/postgres-config/update/SIDE_EFFECTS.md @@ -9,10 +9,10 @@ ## Files Written -| Path | Format | When | -| ------------------------------------------------ | ------ | ----------------------------------------------------------------------------- | -| `~/.supabase//linked-project.json` | JSON | always (after ref resolution), via `Effect.ensuring` - on success and failure | -| `~/.supabase/telemetry.json` | JSON | always, via `Effect.ensuring` - on success and failure | +| Path | Format | When | +| ------------------------------------------------ | ------ | -------------------------------------------------------------------------------------------------------------------------- | +| `~/.supabase//linked-project.json` | JSON | once the `--experimental` gate is open, after ref resolution, via `Effect.ensuring` - on success and failure | +| `~/.supabase/telemetry.json` | JSON | once the `--experimental` gate is open, via `Effect.ensuring` - on success and failure. Not written if the gate is closed. | ## API Routes @@ -25,25 +25,27 @@ The initial `GET` is skipped when `--replace-existing-overrides` is set. Otherwi ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | --------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring -> `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | -| `PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` -> prompt) | +| Variable | Purpose | Required? | +| ----------------------- | -------------------------------------------------------- | -------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring -> `~/.supabase/access-token`) | +| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| `PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` -> prompt) | +| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | ## Exit Codes -| Code | Condition | -| ---- | ------------------------------------------------------------------------------------------------------------ | -| `0` | success - Postgres config updated | -| `1` | malformed `--config` (`LegacyPostgresConfigInvalidConfigValueError`) | -| `1` | project ref unresolved (`LegacyProjectNotLinkedError` / `LegacyInvalidProjectRefError`) | -| `1` | initial GET non-2xx (`LegacyPostgresConfigGetUnexpectedStatusError`) | -| `1` | initial GET transport failure (`LegacyPostgresConfigGetNetworkError`) | -| `1` | PUT non-2xx (`LegacyPostgresConfigUpdateUnexpectedStatusError`) | -| `1` | PUT transport failure (`LegacyPostgresConfigUpdateNetworkError`) | -| `1` | request serialization failure (`LegacyPostgresConfigUpdateSerializeError`) | -| `1` | invalid JSON response (`LegacyPostgresConfigGetUnmarshalError` / `LegacyPostgresConfigUpdateUnmarshalError`) | +| Code | Condition | +| ---- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success - Postgres config updated | +| `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`LegacyExperimentalRequiredError`) - checked before ref resolution/API/telemetry | +| `1` | malformed `--config` (`LegacyPostgresConfigInvalidConfigValueError`) | +| `1` | project ref unresolved (`LegacyProjectNotLinkedError` / `LegacyInvalidProjectRefError`) | +| `1` | initial GET non-2xx (`LegacyPostgresConfigGetUnexpectedStatusError`) | +| `1` | initial GET transport failure (`LegacyPostgresConfigGetNetworkError`) | +| `1` | PUT non-2xx (`LegacyPostgresConfigUpdateUnexpectedStatusError`) | +| `1` | PUT transport failure (`LegacyPostgresConfigUpdateNetworkError`) | +| `1` | request serialization failure (`LegacyPostgresConfigUpdateSerializeError`) | +| `1` | invalid JSON response (`LegacyPostgresConfigGetUnmarshalError` / `LegacyPostgresConfigUpdateUnmarshalError`) | ## Telemetry Events Fired @@ -100,4 +102,9 @@ One `result` event on success. - Keys ending in `_timeout` are always stringified before the `PUT`, matching the Go timeout-normalization branch. - `--no-restart` injects `restart_database = false` into the final request body. - `linked-project.json` is written after the project ref resolves, regardless of whether the fetch or update succeeds. -- `telemetry.json` is written on every invocation, including failures. +- `telemetry.json` is written on every invocation, including failures, but only once the `--experimental` gate is open. +- `postgres-config` is an experimental command (Go `root.go:63`): `update` requires `--experimental` + (or `SUPABASE_EXPERIMENTAL`), matching Go's root-level `PersistentPreRunE` gate (`root.go:91-96`), + which runs before the `IsManagementAPI` login check (`root.go:105-109`). A closed gate exits 1 + before project-ref resolution, the API calls, the `linked-project.json` write, the + `telemetry.json` write, and the `cli_command_executed` event. diff --git a/apps/cli/src/legacy/commands/postgres-config/update/update.command.ts b/apps/cli/src/legacy/commands/postgres-config/update/update.command.ts index 09b6be8303..2f4afb50fe 100644 --- a/apps/cli/src/legacy/commands/postgres-config/update/update.command.ts +++ b/apps/cli/src/legacy/commands/postgres-config/update/update.command.ts @@ -1,20 +1,33 @@ +import { Effect } 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 { legacyRequireExperimental } from "../../../shared/legacy-experimental-gate.ts"; +import { LEGACY_RESOURCE_OUTPUT_FORMATS } from "../../../shared/legacy-go-output-flag.ts"; import { legacyManagementApiRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts"; -import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; +import { legacyParseStringSliceFlag } from "../../../shared/legacy-string-slice-flag.ts"; +import { + legacyValidateOutputFormat, + withLegacyCommandInstrumentation, +} from "../../../telemetry/legacy-command-instrumentation.ts"; import { legacyPostgresConfigUpdate } from "./update.handler.ts"; +export const legacyPostgresConfigUpdateConfigFlag = Flag.string("config").pipe( + Flag.withDescription("Config overrides specified as a 'key=value' pair"), + Flag.atLeast(0), + Flag.mapTryCatch( + (rawValues) => legacyParseStringSliceFlag(rawValues), + (err) => (err instanceof Error ? err.message : String(err)), + ), +); + const config = { projectRef: Flag.string("project-ref").pipe( Flag.withDescription("Project ref of the Supabase project."), Flag.optional, ), - config: Flag.string("config").pipe( - Flag.withDescription("Config overrides specified as a 'key=value' pair"), - Flag.atLeast(0), - ), + config: legacyPostgresConfigUpdateConfigFlag, replaceExistingOverrides: Flag.boolean("replace-existing-overrides").pipe( Flag.withDescription( "If true, replaces all existing overrides with the ones provided. If false (default), merges existing overrides with the ones provided.", @@ -31,10 +44,23 @@ export const legacyPostgresConfigUpdateCommand = Command.make("update", config). Command.withDescription("Update Postgres database config."), Command.withShortDescription("Update Postgres database config"), Command.withHandler((flags) => - legacyPostgresConfigUpdate(flags).pipe( - withLegacyCommandInstrumentation({ flags }), - withJsonErrorHandling, - ), + Effect.gen(function* () { + // Cobra parses flags — rejecting an out-of-enum `-o` (`internal/utils/enum.go:21-27`) + // — before `PersistentPreRunE` ever runs (`cobra@v1.10.2/command.go:919,985`), so an + // invalid `-o` value must win over a missing `--experimental` flag. + yield* legacyValidateOutputFormat(LEGACY_RESOURCE_OUTPUT_FORMATS); + // Go gates `postgresCmd` behind `--experimental` in PersistentPreRunE + // (root.go:91-96) BEFORE the `IsManagementAPI` login check (root.go:105-109). + // `legacyManagementApiRuntimeLayer` eagerly resolves an access token as part + // of building its `LegacyPlatformApi` layer, so it must be provided AFTER + // the gate (inline here) rather than via `Command.provide` on the whole + // command — `Command.provide` would build the layer, and fail on a missing + // token, before this generator's first `yield*` ever runs. + yield* legacyRequireExperimental; + return yield* legacyPostgresConfigUpdate(flags).pipe( + withLegacyCommandInstrumentation({ flags }), + Effect.provide(legacyManagementApiRuntimeLayer(["postgres-config", "update"])), + ); + }).pipe(withJsonErrorHandling), ), - Command.provide(legacyManagementApiRuntimeLayer(["postgres-config", "update"])), ); diff --git a/apps/cli/src/legacy/commands/postgres-config/update/update.command.unit.test.ts b/apps/cli/src/legacy/commands/postgres-config/update/update.command.unit.test.ts new file mode 100644 index 0000000000..75a028729e --- /dev/null +++ b/apps/cli/src/legacy/commands/postgres-config/update/update.command.unit.test.ts @@ -0,0 +1,46 @@ +import { BunServices } from "@effect/platform-bun"; +import { Effect, Exit } from "effect"; +import { describe, expect, test } from "vitest"; +import { legacyPostgresConfigUpdateConfigFlag } from "./update.command.ts"; + +describe("legacy postgres-config update --config flag (pflag StringSlice parity)", () => { + test("splits a comma-separated value into multiple key=value pairs", async () => { + const [, values] = await Effect.runPromise( + legacyPostgresConfigUpdateConfigFlag + .parse({ + flags: { config: ["max_connections=100,statement_timeout=600"] }, + arguments: [], + }) + .pipe(Effect.provide(BunServices.layer)), + ); + + expect(values).toEqual(["max_connections=100", "statement_timeout=600"]); + }); + + test("accumulates repeated occurrences, each CSV-split", async () => { + const [, values] = await Effect.runPromise( + legacyPostgresConfigUpdateConfigFlag + .parse({ + flags: { config: ["max_connections=100,statement_timeout=600", "custom_key=alpha"] }, + arguments: [], + }) + .pipe(Effect.provide(BunServices.layer)), + ); + + expect(values).toEqual(["max_connections=100", "statement_timeout=600", "custom_key=alpha"]); + }); + + test("rejects malformed CSV (unterminated quote)", async () => { + const exit = await Effect.runPromise( + legacyPostgresConfigUpdateConfigFlag + .parse({ + flags: { config: ['"max_connections=100'] }, + arguments: [], + }) + .pipe(Effect.provide(BunServices.layer)) + .pipe(Effect.exit), + ); + + expect(Exit.isFailure(exit)).toBe(true); + }); +}); diff --git a/apps/cli/src/legacy/commands/seed/buckets/buckets.command.ts b/apps/cli/src/legacy/commands/seed/buckets/buckets.command.ts index 20c9b269fe..8fe2122f71 100644 --- a/apps/cli/src/legacy/commands/seed/buckets/buckets.command.ts +++ b/apps/cli/src/legacy/commands/seed/buckets/buckets.command.ts @@ -1,4 +1,4 @@ -import { Effect } from "effect"; +import { Effect, Layer } from "effect"; import { Command } from "effect/unstable/cli"; import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; @@ -6,6 +6,7 @@ import { withJsonErrorHandling } from "../../../../shared/output/json-error-hand import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; import { LegacySeedLinkedFlag, LegacySeedLocalFlag } from "../seed.flags.ts"; import { legacyAssertSeedTargetsExclusive } from "./buckets.flags.ts"; +import { stdinLayer } from "../../../../shared/runtime/stdin.layer.ts"; import { legacyStorageGatewayRuntimeLayer } from "../../../shared/legacy-storage-runtime.layer.ts"; import { legacySeedBuckets } from "./buckets.handler.ts"; @@ -36,5 +37,7 @@ export const legacyBucketsCommand = Command.make("buckets").pipe( return yield* legacySeedBuckets(flags).pipe(withLegacyCommandInstrumentation({ flags })); }).pipe(withJsonErrorHandling), ), - Command.provide(legacyStorageGatewayRuntimeLayer(["seed", "buckets"])), + Command.provide( + Layer.mergeAll(legacyStorageGatewayRuntimeLayer(["seed", "buckets"]), stdinLayer), + ), ); diff --git a/apps/cli/src/legacy/commands/seed/buckets/buckets.integration.test.ts b/apps/cli/src/legacy/commands/seed/buckets/buckets.integration.test.ts index 674cf9f82e..986fcc4af6 100644 --- a/apps/cli/src/legacy/commands/seed/buckets/buckets.integration.test.ts +++ b/apps/cli/src/legacy/commands/seed/buckets/buckets.integration.test.ts @@ -8,7 +8,7 @@ import { Effect, Exit, Layer, Option } from "effect"; import * as HttpClient from "effect/unstable/http/HttpClient"; import type * as HttpClientError from "effect/unstable/http/HttpClientError"; -import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { mockOutput, mockStdin, mockTty } from "../../../../../tests/helpers/mocks.ts"; import { LEGACY_VALID_REF, legacyJsonResponse, @@ -53,6 +53,8 @@ function setupLegacySeedBuckets( readonly format?: OutputFormat; readonly confirm?: ReadonlyArray; readonly promptConfirmFail?: boolean; + /** Piped (non-TTY) stdin answers, one consumed per confirmation prompt. */ + readonly pipedAnswers?: ReadonlyArray; readonly args?: ReadonlyArray; readonly yes?: boolean; /** Project ref returned by loadProjectRef for --linked tests. */ @@ -181,6 +183,9 @@ function setupLegacySeedBuckets( telemetry.layer, mockLegacyCliConfig({ workdir }), BunServices.layer, + // Seed-bucket prompts model an interactive user answering via `confirm`. + mockTty({ stdinIsTty: true, stdoutIsTty: false }), + mockStdin(true, opts.pipedAnswers ? `${opts.pipedAnswers.join("\n")}\n` : undefined), Layer.succeed(CliArgs, { args: opts.args ?? ["seed", "buckets"] }), Layer.succeed(LegacyYesFlag, opts.yes ?? false), projectRefLayer, diff --git a/apps/cli/src/legacy/commands/services/services.handler.ts b/apps/cli/src/legacy/commands/services/services.handler.ts index 28b4dc2461..f6dd731220 100644 --- a/apps/cli/src/legacy/commands/services/services.handler.ts +++ b/apps/cli/src/legacy/commands/services/services.handler.ts @@ -3,6 +3,10 @@ import { LegacyCliConfig } from "../../config/legacy-cli-config.service.ts"; import { LegacyCredentials } from "../../auth/legacy-credentials.service.ts"; import { LegacyLinkedProjectCache } from "../../telemetry/legacy-linked-project-cache.service.ts"; import { LegacyTelemetryState } from "../../telemetry/legacy-telemetry-state.service.ts"; +import { legacyReadDbToml } from "../../shared/legacy-db-config.toml-read.ts"; +import { legacyResolveDbImage } from "../../shared/legacy-db-image.ts"; +import { legacyResolveEdgeRuntimeImage } from "../../shared/legacy-edge-runtime-image.ts"; +import { legacyTempPaths } from "../../shared/legacy-temp-paths.ts"; import { LegacyOutputFlag } from "../../../shared/legacy/global-flags.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { encodeGoJson, encodeToml, encodeYaml } from "../../shared/legacy-go-output.encoders.ts"; @@ -11,6 +15,9 @@ import { fetchLinkedServiceVersions, formatServicesWarning, listLocalServiceVersions, + type LocalServiceImageOverrides, + type LocalServiceVersionName, + type LocalServiceVersionOverrides, mergeRemoteServiceVersions, renderServicesTable, renderServicesWarning, @@ -55,8 +62,53 @@ export const legacyServices = Effect.fn("legacy.services")(function* (_flags: Le yield* Effect.gen(function* () { const accessTokenExit = yield* credentials.getAccessToken.pipe(Effect.exit); const accessToken = Exit.isSuccess(accessTokenExit) ? accessTokenExit.value : Option.none(); + const tomlValues = yield* legacyReadDbToml( + fs, + path, + cliConfig.workdir, + Option.getOrUndefined(linkedProjectRef), + ).pipe( + Effect.catch((error) => + output.raw(`${formatConfigLoadError(error)}\n`, "stderr").pipe(Effect.as(null)), + ), + ); + const serviceVersions = + tomlValues === null + ? {} + : yield* readLegacyServiceVersionOverrides( + fs, + path, + cliConfig.workdir, + tomlValues.majorVersion, + ); + const postgresImage = + tomlValues === null + ? undefined + : yield* legacyResolveDbImage( + fs, + path, + cliConfig.workdir, + tomlValues.majorVersion, + Option.getOrUndefined(tomlValues.orioledbVersion), + ); + const edgeRuntimeImage = + tomlValues === null + ? undefined + : yield* legacyResolveEdgeRuntimeImage(fs, path, cliConfig.workdir, tomlValues.denoVersion); + const imageOverrides: LocalServiceImageOverrides = {}; + if (postgresImage !== undefined) { + imageOverrides.postgres = postgresImage; + } + if (edgeRuntimeImage !== undefined) { + imageOverrides["edge-runtime"] = edgeRuntimeImage; + } + const localImageOptions = { + imageOverrides, + normalizeVersionTags: false, + serviceVersions, + }; - let rows = listLocalServiceVersions(); + let rows = listLocalServiceVersions(localImageOptions); if (Option.isSome(linkedProjectRef) && Option.isSome(accessToken)) { const remote = yield* fetchLinkedServiceVersions({ apiUrl: cliConfig.apiUrl, @@ -65,7 +117,7 @@ export const legacyServices = Effect.fn("legacy.services")(function* (_flags: Le accessToken: accessToken.value, userAgent: cliConfig.userAgent, }); - rows = mergeRemoteServiceVersions(remote); + rows = mergeRemoteServiceVersions(remote, localImageOptions); } const warning = renderServicesWarning(rows); @@ -110,3 +162,46 @@ export const legacyServices = Effect.fn("legacy.services")(function* (_flags: Le yield* output.raw(renderServicesTable(rows)); }).pipe(Effect.ensuring(cacheLinkedProject), Effect.ensuring(telemetryState.flush)); }); + +function formatConfigLoadError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +const LEGACY_VERSION_FILES = [ + ["auth", "gotrue-version", (majorVersion: number | undefined) => (majorVersion ?? 17) > 14], + ["postgrest", "rest-version", (majorVersion: number | undefined) => (majorVersion ?? 17) > 14], + ["storage", "storage-version"], + ["realtime", "realtime-version"], + ["studio", "studio-version"], + ["pgmeta", "pgmeta-version"], + ["analytics", "logflare-version"], + ["pooler", "pooler-version"], +] as const satisfies ReadonlyArray< + readonly [LocalServiceVersionName, string, ((majorVersion: number | undefined) => boolean)?] +>; + +const readLegacyServiceVersionOverrides = Effect.fnUntraced(function* ( + fs: FileSystem.FileSystem, + path: Path.Path, + workdir: string, + majorVersion: number | undefined, +) { + const paths = legacyTempPaths(path, workdir); + const versions: LocalServiceVersionOverrides = {}; + + for (const [service, fileName, shouldRead] of LEGACY_VERSION_FILES) { + if (shouldRead !== undefined && !shouldRead(majorVersion)) { + continue; + } + + const version = yield* fs.readFileString(path.join(paths.tempDir, fileName)).pipe( + Effect.map((content) => content.trim()), + Effect.orElseSucceed(() => ""), + ); + if (version.length > 0) { + versions[service] = version; + } + } + + return versions; +}); diff --git a/apps/cli/src/legacy/commands/services/services.integration.test.ts b/apps/cli/src/legacy/commands/services/services.integration.test.ts index 564e8f6e11..7e03294354 100644 --- a/apps/cli/src/legacy/commands/services/services.integration.test.ts +++ b/apps/cli/src/legacy/commands/services/services.integration.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "@effect/vitest"; @@ -19,7 +19,10 @@ import { processEnvLayer, } from "../../../../tests/helpers/mocks.ts"; import { mockLegacyTelemetryStateTracked } from "../../../../tests/helpers/legacy-mocks.ts"; -import { listLocalServiceVersions } from "../../../shared/services/services.shared.ts"; +import { + listLocalServiceVersions, + postgresImageForDbMajorVersion, +} from "../../../shared/services/services.shared.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"; @@ -41,6 +44,7 @@ function setup( opts: { format?: "text" | "json" | "stream-json"; goOutput?: Option.Option<"env" | "pretty" | "json" | "toml" | "yaml">; + workdir?: string; } = {}, ) { const out = mockOutput({ @@ -69,7 +73,7 @@ function setup( poolerHost: "supabase.com", accessToken: Option.none(), projectId: Option.none(), - workdir: process.cwd(), + workdir: opts.workdir ?? process.cwd(), userAgent: "SupabaseCLI/test", }), ), @@ -100,6 +104,38 @@ const legacyTestRoot = Command.make("supabase").pipe( Command.withSubcommands([legacyServicesCommand]), ); +function makeProjectWithConfig(config: string): string { + const workdir = mkdtempSync(join(tmpdir(), "supabase-services-config-")); + const configDir = join(workdir, "supabase"); + mkdirSync(configDir, { recursive: true }); + writeFileSync(join(configDir, "config.toml"), config); + return workdir; +} + +function makeProjectWithConfigFiles(opts: { toml: string; json: string }): string { + const workdir = makeProjectWithConfig(opts.toml); + writeFileSync(join(workdir, "supabase", "config.json"), opts.json); + return workdir; +} + +function makeProjectWithDbMajorVersion(majorVersion: number): string { + return makeProjectWithConfig(`[db]\nmajor_version = ${majorVersion}\n`); +} + +function writeTempFile(workdir: string, name: string, content: string): void { + const tempDir = join(workdir, "supabase", ".temp"); + mkdirSync(tempDir, { recursive: true }); + writeFileSync(join(tempDir, name), content); +} + +function postgresVersionForDbMajorVersion(majorVersion: number): string { + const image = postgresImageForDbMajorVersion(majorVersion); + if (image === undefined) { + throw new Error(`Missing Postgres image for db major ${majorVersion}.`); + } + return image.slice(image.lastIndexOf(":") + 1); +} + function expectFailureTag(exit: Exit.Exit, tag: string) { expect(Exit.isFailure(exit)).toBe(true); if (!Exit.isFailure(exit)) { @@ -127,7 +163,7 @@ describe("legacy services", () => { CliOutput.layer(textCliOutputFormatter()), out.layer, analytics.layer, - processEnvLayer({ SUPABASE_HOME: workdir }), + processEnvLayer({ SUPABASE_HOME: workdir, SUPABASE_NO_KEYRING: "1" }), mockRuntimeInfo({ cwd: workdir, homeDir: workdir }), mockTty({ stdinIsTty: false, stdoutIsTty: false }), Stdio.layerTest({ args: Effect.succeed(args) }), @@ -197,6 +233,143 @@ describe("legacy services", () => { }); }); + it.live("reports the configured Postgres version for local projects", () => { + const workdir = makeProjectWithDbMajorVersion(15); + const { layer, out } = setup({ goOutput: Option.some("json"), workdir }); + + return Effect.gen(function* () { + yield* legacyServices({}).pipe(Effect.provide(layer)); + + const rows = JSON.parse(out.stdoutText) as Array<{ + name: string; + local: string; + remote: string; + }>; + expect(rows).toContainEqual( + expect.objectContaining({ + name: "supabase/postgres", + local: postgresVersionForDbMajorVersion(15), + }), + ); + }).pipe(Effect.ensuring(Effect.sync(() => rmSync(workdir, { recursive: true, force: true })))); + }); + + it.live("ignores config.json and reads legacy config.toml for local image selection", () => { + const workdir = makeProjectWithConfigFiles({ + toml: "[db]\nmajor_version = 15\n", + json: JSON.stringify({ db: { major_version: 14 } }), + }); + const { layer, out } = setup({ goOutput: Option.some("json"), workdir }); + + return Effect.gen(function* () { + yield* legacyServices({}).pipe(Effect.provide(layer)); + + const rows = JSON.parse(out.stdoutText) as Array<{ + name: string; + local: string; + remote: string; + }>; + expect(rows).toContainEqual( + expect.objectContaining({ + name: "supabase/postgres", + local: postgresVersionForDbMajorVersion(15), + }), + ); + }).pipe(Effect.ensuring(Effect.sync(() => rmSync(workdir, { recursive: true, force: true })))); + }); + + it.live("applies linked-project remote config overrides when choosing the local image", () => { + const workdir = makeProjectWithConfig(` +[db] +major_version = 17 + +[remotes.linked] +project_id = "abcdefghijklmnopqrst" + +[remotes.linked.db] +major_version = 15 +`); + writeTempFile(workdir, "project-ref", "abcdefghijklmnopqrst"); + const { layer, out } = setup({ goOutput: Option.some("json"), workdir }); + + return Effect.gen(function* () { + yield* legacyServices({}).pipe(Effect.provide(layer)); + + const rows = JSON.parse(out.stdoutText) as Array<{ + name: string; + local: string; + remote: string; + }>; + expect(rows).toContainEqual( + expect.objectContaining({ + name: "supabase/postgres", + local: postgresVersionForDbMajorVersion(15), + }), + ); + }).pipe(Effect.ensuring(Effect.sync(() => rmSync(workdir, { recursive: true, force: true })))); + }); + + it.live("reports pinned legacy temp service versions", () => { + const workdir = makeProjectWithDbMajorVersion(15); + writeTempFile(workdir, "postgres-version", "15.1.0.117\n"); + writeTempFile(workdir, "gotrue-version", "2.74.2\n"); + writeTempFile(workdir, "storage-version", "v1.28.0\n"); + const { layer, out } = setup({ goOutput: Option.some("json"), workdir }); + + return Effect.gen(function* () { + yield* legacyServices({}).pipe(Effect.provide(layer)); + + const rows = JSON.parse(out.stdoutText) as Array<{ + name: string; + local: string; + remote: string; + }>; + expect(rows).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "supabase/postgres", local: "15.1.0.117" }), + expect.objectContaining({ name: "supabase/gotrue", local: "2.74.2" }), + expect.objectContaining({ name: "supabase/storage-api", local: "v1.28.0" }), + ]), + ); + }).pipe(Effect.ensuring(Effect.sync(() => rmSync(workdir, { recursive: true, force: true })))); + }); + + it.live("reports the Deno 1 edge-runtime image instead of the temp pin", () => { + const workdir = makeProjectWithConfig("[edge_runtime]\ndeno_version = 1\n"); + writeTempFile(workdir, "edge-runtime-version", "v9.9.9\n"); + const { layer, out } = setup({ goOutput: Option.some("json"), workdir }); + + return Effect.gen(function* () { + yield* legacyServices({}).pipe(Effect.provide(layer)); + + const rows = JSON.parse(out.stdoutText) as Array<{ + name: string; + local: string; + remote: string; + }>; + expect(rows).toContainEqual( + expect.objectContaining({ + name: "supabase/edge-runtime", + local: "v1.68.4", + }), + ); + }).pipe(Effect.ensuring(Effect.sync(() => rmSync(workdir, { recursive: true, force: true })))); + }); + + it.live("prints config load errors and falls back to the default matrix", () => { + const workdir = makeProjectWithConfig("[db]\nmajor_version = "); + writeTempFile(workdir, "storage-version", "v9.9.9\n"); + const { layer, out } = setup({ workdir }); + + return Effect.gen(function* () { + yield* legacyServices({}).pipe(Effect.provide(layer)); + + expect(out.stdoutText).toContain("supabase/postgres"); + expect(out.stdoutText).not.toContain("v9.9.9"); + expect(out.stderrText).not.toBe(""); + }).pipe(Effect.ensuring(Effect.sync(() => rmSync(workdir, { recursive: true, force: true })))); + }); + it.live("emits structured JSON for --output pretty combined with --output-format json", () => { // Regression guard (CLI-1546): a Go `--output pretty` must defer to the TS // `--output-format json` flag instead of forcing the human-readable table. diff --git a/apps/cli/src/legacy/commands/ssl-enforcement/get/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/ssl-enforcement/get/SIDE_EFFECTS.md index d06a2d9671..ccf0e03d43 100644 --- a/apps/cli/src/legacy/commands/ssl-enforcement/get/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/ssl-enforcement/get/SIDE_EFFECTS.md @@ -9,10 +9,10 @@ ## Files Written -| Path | Format | When | -| ------------------------------------------------ | ------ | ----------------------------------------------------------------------------- | -| `~/.supabase//linked-project.json` | JSON | always (after ref resolution), via `Effect.ensuring` — on success and failure | -| `~/.supabase/telemetry.json` | JSON | always, via `Effect.ensuring` — on success and failure | +| Path | Format | When | +| ------------------------------------------------ | ------ | -------------------------------------------------------------------------------------------------------------------------- | +| `~/.supabase//linked-project.json` | JSON | once the `--experimental` gate is open, after ref resolution, via `Effect.ensuring` — on success and failure | +| `~/.supabase/telemetry.json` | JSON | once the `--experimental` gate is open, via `Effect.ensuring` — on success and failure. Not written if the gate is closed. | ## API Routes @@ -22,20 +22,22 @@ ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | -------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | -| `PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` → prompt) | +| Variable | Purpose | Required? | +| ----------------------- | -------------------------------------------------------- | -------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| `PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` → prompt) | +| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | ## Exit Codes -| Code | Condition | -| ---- | --------------------------------------------------------------------------------------- | -| `0` | success — SSL enforcement status printed to stdout | -| `1` | project ref unresolved (`LegacyProjectNotLinkedError` / `LegacyInvalidProjectRefError`) | -| `1` | API non-200 (`LegacySslEnforcementGetUnexpectedStatusError`) | -| `1` | transport failure (`LegacySslEnforcementGetNetworkError`) | +| Code | Condition | +| ---- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success — SSL enforcement status printed to stdout | +| `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`LegacyExperimentalRequiredError`) — checked before ref resolution/API/telemetry | +| `1` | project ref unresolved (`LegacyProjectNotLinkedError` / `LegacyInvalidProjectRefError`) | +| `1` | API non-200 (`LegacySslEnforcementGetUnexpectedStatusError`) | +| `1` | transport failure (`LegacySslEnforcementGetNetworkError`) | ## Telemetry Events Fired @@ -99,4 +101,10 @@ One `result` event: - The Go `--output` flag wins over the TS `--output-format` flag when both are provided. - `linked-project.json` is written **after** the project ref is resolved, regardless of whether the subsequent API call succeeds (mirrors Go's `PersistentPostRun`). -- `telemetry.json` is written on every invocation, including failures. +- `telemetry.json` is written on every invocation, including failures, but only once the + `--experimental` gate is open. +- `ssl-enforcement` is an experimental command (Go `root.go:63`): `get` requires `--experimental` + (or `SUPABASE_EXPERIMENTAL`), matching Go's root-level `PersistentPreRunE` gate (`root.go:91-96`), + which runs before the `IsManagementAPI` login check (`root.go:105-109`). A closed gate exits 1 + before project-ref resolution, the API call, the `linked-project.json` write, the + `telemetry.json` write, and the `cli_command_executed` event. diff --git a/apps/cli/src/legacy/commands/ssl-enforcement/get/get.command.ts b/apps/cli/src/legacy/commands/ssl-enforcement/get/get.command.ts index 2b402215de..cafd98327f 100644 --- a/apps/cli/src/legacy/commands/ssl-enforcement/get/get.command.ts +++ b/apps/cli/src/legacy/commands/ssl-enforcement/get/get.command.ts @@ -1,9 +1,15 @@ +import { Effect } 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 { legacyRequireExperimental } from "../../../shared/legacy-experimental-gate.ts"; +import { LEGACY_RESOURCE_OUTPUT_FORMATS } from "../../../shared/legacy-go-output-flag.ts"; import { legacyManagementApiRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts"; -import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; +import { + legacyValidateOutputFormat, + withLegacyCommandInstrumentation, +} from "../../../telemetry/legacy-command-instrumentation.ts"; import { legacySslEnforcementGet } from "./get.handler.ts"; const config = { @@ -19,10 +25,23 @@ export const legacySslEnforcementGetCommand = Command.make("get", config).pipe( Command.withDescription("Get the current SSL enforcement configuration."), Command.withShortDescription("Get SSL enforcement configuration"), Command.withHandler((flags) => - legacySslEnforcementGet(flags).pipe( - withLegacyCommandInstrumentation({ flags }), - withJsonErrorHandling, - ), + Effect.gen(function* () { + // Cobra parses flags — rejecting an out-of-enum `-o` (`internal/utils/enum.go:21-27`) + // — before `PersistentPreRunE` ever runs (`cobra@v1.10.2/command.go:919,985`), so an + // invalid `-o` value must win over a missing `--experimental` flag. + yield* legacyValidateOutputFormat(LEGACY_RESOURCE_OUTPUT_FORMATS); + // Go gates `sslEnforcementCmd` behind `--experimental` in PersistentPreRunE + // (root.go:91-96) BEFORE the `IsManagementAPI` login check (root.go:105-109). + // `legacyManagementApiRuntimeLayer` eagerly resolves an access token as part + // of building its `LegacyPlatformApi` layer, so it must be provided AFTER + // the gate (inline here) rather than via `Command.provide` on the whole + // command — `Command.provide` would build the layer, and fail on a missing + // token, before this generator's first `yield*` ever runs. + yield* legacyRequireExperimental; + return yield* legacySslEnforcementGet(flags).pipe( + withLegacyCommandInstrumentation({ flags }), + Effect.provide(legacyManagementApiRuntimeLayer(["ssl-enforcement", "get"])), + ); + }).pipe(withJsonErrorHandling), ), - Command.provide(legacyManagementApiRuntimeLayer(["ssl-enforcement", "get"])), ); diff --git a/apps/cli/src/legacy/commands/ssl-enforcement/ssl-enforcement.experimental-gate.integration.test.ts b/apps/cli/src/legacy/commands/ssl-enforcement/ssl-enforcement.experimental-gate.integration.test.ts new file mode 100644 index 0000000000..68a32afe5d --- /dev/null +++ b/apps/cli/src/legacy/commands/ssl-enforcement/ssl-enforcement.experimental-gate.integration.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, Layer } from "effect"; +import { CliOutput, Command } from "effect/unstable/cli"; + +import { textCliOutputFormatter } from "../../../shared/output/text-formatter.ts"; +import { LEGACY_GLOBAL_FLAGS } from "../../../shared/legacy/global-flags.ts"; +import { TelemetryRuntime } from "../../../shared/telemetry/runtime.service.ts"; +import { makeTelemetryIdentity } from "../../../shared/telemetry/identity.ts"; +import { mockOutput, mockRuntimeInfo, processEnvLayer } from "../../../../tests/helpers/mocks.ts"; +import { + buildLegacyTestRuntime, + mockLegacyCliConfig, + mockLegacyPlatformApi, + useLegacyTempWorkdir, +} from "../../../../tests/helpers/legacy-mocks.ts"; +import { legacySslEnforcementCommand } from "./ssl-enforcement.command.ts"; + +// See postgres-config.experimental-gate.integration.test.ts for the full +// rationale: this proves `--experimental` is wired into the actual +// `.command.ts` handler pipeline AND runs before +// `legacyManagementApiRuntimeLayer`'s eager access-token resolution +// (Go's `IsExperimental` check precedes `IsManagementAPI` in +// `apps/cli-go/cmd/root.go:91-109`). + +const tempRoot = useLegacyTempWorkdir("supabase-ssl-enforcement-experimental-int-"); + +const testRoot = Command.make("supabase").pipe( + Command.withGlobalFlags(LEGACY_GLOBAL_FLAGS), + Command.withSubcommands([legacySslEnforcementCommand]), +); + +function setup() { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ + response: { + status: 200, + body: { currentConfig: { database: true }, appliedSuccessfully: true }, + }, + }); + const runtime = buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + // `RuntimeInfo` is ambient (not provided by `legacyManagementApiRuntimeLayer` + // itself), so the real `legacyCredentialsLayer` built inline inside the + // command for the "gate open" case resolves ITS `RuntimeInfo` from this + // layer. Point homeDir at this test's isolated tempRoot so the layer's + // file-based token fallback (`/.supabase/access-token`) can't pick + // up a stray token left at the shared default `/tmp/supabase-cli-test-home`. + runtimeInfo: mockRuntimeInfo({ homeDir: tempRoot.current }), + }); + const layer = Layer.mergeAll( + runtime, + CliOutput.layer(textCliOutputFormatter()), + // The "gate open" case reaches the real `legacyManagementApiRuntimeLayer` + // (provided inline inside the command, not by this test's mocked runtime), + // which reads credentials/env directly — an ambient SUPABASE_ACCESS_TOKEN, + // SUPABASE_EXPERIMENTAL, or OS keyring entry on the machine running the + // test would make these assertions non-deterministic. Wipe process.env + // down to just this and disable the keyring fallback. + processEnvLayer({ SUPABASE_NO_KEYRING: "1" }), + Layer.succeed( + TelemetryRuntime, + TelemetryRuntime.of({ + configDir: `${tempRoot.current}/.supabase`, + tracesDir: `${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 { layer, api }; +} + +describe("legacy ssl-enforcement experimental gate (Go PersistentPreRunE parity)", () => { + const leaves: ReadonlyArray<{ readonly name: string; readonly args: ReadonlyArray }> = [ + { name: "get", args: ["ssl-enforcement", "get"] }, + { name: "update", args: ["ssl-enforcement", "update", "--enable-db-ssl-enforcement"] }, + ]; + + for (const { name, args } of leaves) { + it.live( + `${name} fails with LegacyExperimentalRequiredError when --experimental is unset`, + () => { + const { layer, api } = setup(); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + Command.runWith(testRoot, { version: "0.0.0-test" })(args), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyExperimentalRequiredError"); + } + expect(api.requests).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live(`${name} does not fail with the gate error once --experimental is set`, () => { + const { layer, api } = setup(); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + Command.runWith(testRoot, { version: "0.0.0-test" })([...args, "--experimental"]), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const causeText = JSON.stringify(exit.cause); + expect(causeText).not.toContain("LegacyExperimentalRequiredError"); + expect(causeText).toContain("LegacyPlatformAuthRequiredError"); + } + expect(api.requests).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }); + } +}); diff --git a/apps/cli/src/legacy/commands/ssl-enforcement/update/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/ssl-enforcement/update/SIDE_EFFECTS.md index f6caab93da..6f067ad6b5 100644 --- a/apps/cli/src/legacy/commands/ssl-enforcement/update/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/ssl-enforcement/update/SIDE_EFFECTS.md @@ -9,10 +9,10 @@ ## Files Written -| Path | Format | When | -| ------------------------------------------------ | ------ | ----------------------------------------------------------------------------------------- | -| `~/.supabase//linked-project.json` | JSON | after the project ref is resolved (only if flag validation passes), via `Effect.ensuring` | -| `~/.supabase/telemetry.json` | JSON | always, via `Effect.ensuring` — including flag-validation failures | +| Path | Format | When | +| ------------------------------------------------ | ------ | -------------------------------------------------------------------------------------------------------------------------------------- | +| `~/.supabase//linked-project.json` | JSON | once the `--experimental` gate is open, after the project ref is resolved (only if flag validation passes), via `Effect.ensuring` | +| `~/.supabase/telemetry.json` | JSON | once the `--experimental` gate is open, via `Effect.ensuring` — including flag-validation failures. Not written if the gate is closed. | ## API Routes @@ -22,22 +22,24 @@ ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | -------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | -| `PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` → prompt) | +| Variable | Purpose | Required? | +| ----------------------- | -------------------------------------------------------- | -------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| `PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` → prompt) | +| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | ## Exit Codes -| Code | Condition | -| ---- | ----------------------------------------------------------------------------------------------------------------------------- | -| `0` | success — SSL enforcement status (post-update) printed to stdout | -| `1` | neither `--enable-db-ssl-enforcement` nor `--disable-db-ssl-enforcement` set (`LegacySslEnforcementNoEnableDisableFlagError`) | -| `1` | both `--enable-db-ssl-enforcement` and `--disable-db-ssl-enforcement` set (`LegacySslEnforcementMutuallyExclusiveFlagsError`) | -| `1` | project ref unresolved (`LegacyProjectNotLinkedError` / `LegacyInvalidProjectRefError`) | -| `1` | API non-200 (`LegacySslEnforcementUpdateUnexpectedStatusError`) | -| `1` | transport failure (`LegacySslEnforcementUpdateNetworkError`) | +| Code | Condition | +| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success — SSL enforcement status (post-update) printed to stdout | +| `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`LegacyExperimentalRequiredError`) — checked in `.command.ts`, before the handler (and its flag validation/telemetry) ever runs | +| `1` | neither `--enable-db-ssl-enforcement` nor `--disable-db-ssl-enforcement` set (`LegacySslEnforcementNoEnableDisableFlagError`) | +| `1` | both `--enable-db-ssl-enforcement` and `--disable-db-ssl-enforcement` set (`LegacySslEnforcementMutuallyExclusiveFlagsError`) | +| `1` | project ref unresolved (`LegacyProjectNotLinkedError` / `LegacyInvalidProjectRefError`) | +| `1` | API non-200 (`LegacySslEnforcementUpdateUnexpectedStatusError`) | +| `1` | transport failure (`LegacySslEnforcementUpdateNetworkError`) | ## Telemetry Events Fired @@ -96,5 +98,12 @@ One `result` event: `--disable-db-ssl-enforcement` is the user-facing way to send `database: false`. - `linked-project.json` is **not** written if flag validation fails (no ref is resolved). `telemetry.json` is written regardless, matching Go's - `PersistentPostRun` semantics. + `PersistentPostRun` semantics — but only once the `--experimental` gate is open, since + the flag-validation/telemetry-writing handler never runs at all when the gate is closed. - The Go `--output` flag wins over the TS `--output-format` flag when both are provided. +- `ssl-enforcement` is an experimental command (Go `root.go:63`): `update` requires + `--experimental` (or `SUPABASE_EXPERIMENTAL`), matching Go's root-level `PersistentPreRunE` + gate (`root.go:91-96`), which runs before the `IsManagementAPI` login check + (`root.go:105-109`). A closed gate exits 1 before the enable/disable mutex check, project-ref + resolution, the API call, the `linked-project.json` write, the `telemetry.json` write, and the + `cli_command_executed` event. diff --git a/apps/cli/src/legacy/commands/ssl-enforcement/update/update.command.ts b/apps/cli/src/legacy/commands/ssl-enforcement/update/update.command.ts index c7a907223c..c9bdff5164 100644 --- a/apps/cli/src/legacy/commands/ssl-enforcement/update/update.command.ts +++ b/apps/cli/src/legacy/commands/ssl-enforcement/update/update.command.ts @@ -1,9 +1,15 @@ +import { Effect } 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 { legacyRequireExperimental } from "../../../shared/legacy-experimental-gate.ts"; +import { LEGACY_RESOURCE_OUTPUT_FORMATS } from "../../../shared/legacy-go-output-flag.ts"; import { legacyManagementApiRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts"; -import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; +import { + legacyValidateOutputFormat, + withLegacyCommandInstrumentation, +} from "../../../telemetry/legacy-command-instrumentation.ts"; import { legacySslEnforcementUpdate } from "./update.handler.ts"; const config = { @@ -29,10 +35,23 @@ export const legacySslEnforcementUpdateCommand = Command.make("update", config). Command.withDescription("Update SSL enforcement configuration."), Command.withShortDescription("Update SSL enforcement configuration"), Command.withHandler((flags) => - legacySslEnforcementUpdate(flags).pipe( - withLegacyCommandInstrumentation({ flags }), - withJsonErrorHandling, - ), + Effect.gen(function* () { + // Cobra parses flags — rejecting an out-of-enum `-o` (`internal/utils/enum.go:21-27`) + // — before `PersistentPreRunE` ever runs (`cobra@v1.10.2/command.go:919,985`), so an + // invalid `-o` value must win over a missing `--experimental` flag. + yield* legacyValidateOutputFormat(LEGACY_RESOURCE_OUTPUT_FORMATS); + // Go gates `sslEnforcementCmd` behind `--experimental` in PersistentPreRunE + // (root.go:91-96) BEFORE the `IsManagementAPI` login check (root.go:105-109). + // `legacyManagementApiRuntimeLayer` eagerly resolves an access token as part + // of building its `LegacyPlatformApi` layer, so it must be provided AFTER + // the gate (inline here) rather than via `Command.provide` on the whole + // command — `Command.provide` would build the layer, and fail on a missing + // token, before this generator's first `yield*` ever runs. + yield* legacyRequireExperimental; + return yield* legacySslEnforcementUpdate(flags).pipe( + withLegacyCommandInstrumentation({ flags }), + Effect.provide(legacyManagementApiRuntimeLayer(["ssl-enforcement", "update"])), + ); + }).pipe(withJsonErrorHandling), ), - Command.provide(legacyManagementApiRuntimeLayer(["ssl-enforcement", "update"])), ); diff --git a/apps/cli/src/legacy/commands/sso/add/add.command.ts b/apps/cli/src/legacy/commands/sso/add/add.command.ts index 190a6ed155..4030c121a3 100644 --- a/apps/cli/src/legacy/commands/sso/add/add.command.ts +++ b/apps/cli/src/legacy/commands/sso/add/add.command.ts @@ -3,6 +3,7 @@ import type * as CliCommand from "effect/unstable/cli/Command"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { legacyManagementApiRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts"; +import { legacyParseStringSliceFlag } from "../../../shared/legacy-string-slice-flag.ts"; import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; import { legacySsoAdd } from "./add.handler.ts"; @@ -13,6 +14,18 @@ const NAME_ID_FORMATS = [ "urn:oasis:names:tc:SAML:2.0:nameid-format:transient", ] as const; +export const legacySsoAddDomainsFlag = Flag.string("domains").pipe( + Flag.atLeast(0), + Flag.withDescription( + "Comma separated list of email domains to associate with the added identity provider.", + ), + Flag.mapTryCatch( + (rawValues) => legacyParseStringSliceFlag(rawValues), + (err) => (err instanceof Error ? err.message : String(err)), + ), + Flag.withDefault([] as ReadonlyArray), +); + const config = { projectRef: Flag.string("project-ref").pipe( Flag.withDescription("Project ref of the Supabase project."), @@ -24,13 +37,7 @@ const config = { Flag.withAlias("t"), Flag.withDescription("Type of identity provider (according to supported protocol)."), ), - domains: Flag.string("domains").pipe( - Flag.atLeast(0), - Flag.withDescription( - "Comma separated list of email domains to associate with the added identity provider.", - ), - Flag.withDefault([] as ReadonlyArray), - ), + domains: legacySsoAddDomainsFlag, metadataFile: Flag.string("metadata-file").pipe( Flag.withDescription( "File containing a SAML 2.0 Metadata XML document describing the identity provider.", diff --git a/apps/cli/src/legacy/commands/sso/add/add.command.unit.test.ts b/apps/cli/src/legacy/commands/sso/add/add.command.unit.test.ts new file mode 100644 index 0000000000..9b96a497e7 --- /dev/null +++ b/apps/cli/src/legacy/commands/sso/add/add.command.unit.test.ts @@ -0,0 +1,59 @@ +import { BunServices } from "@effect/platform-bun"; +import { Effect, Exit } from "effect"; +import { describe, expect, test } from "vitest"; +import { legacySsoAddDomainsFlag } from "./add.command.ts"; + +describe("legacy sso add --domains flag (pflag StringSlice parity)", () => { + test("splits a comma-separated value into multiple domains", async () => { + const [, domains] = await Effect.runPromise( + legacySsoAddDomainsFlag + .parse({ + flags: { domains: ["example.com,example.org"] }, + arguments: [], + }) + .pipe(Effect.provide(BunServices.layer)), + ); + + expect(domains).toEqual(["example.com", "example.org"]); + }); + + test("accumulates repeated occurrences, each CSV-split", async () => { + const [, domains] = await Effect.runPromise( + legacySsoAddDomainsFlag + .parse({ + flags: { domains: ["example.com,example.org", "example.net"] }, + arguments: [], + }) + .pipe(Effect.provide(BunServices.layer)), + ); + + expect(domains).toEqual(["example.com", "example.org", "example.net"]); + }); + + test("defaults to an empty array when unset", async () => { + const [, domains] = await Effect.runPromise( + legacySsoAddDomainsFlag + .parse({ + flags: {}, + arguments: [], + }) + .pipe(Effect.provide(BunServices.layer)), + ); + + expect(domains).toEqual([]); + }); + + test("rejects malformed CSV (unterminated quote)", async () => { + const exit = await Effect.runPromise( + legacySsoAddDomainsFlag + .parse({ + flags: { domains: ['"example.com'] }, + arguments: [], + }) + .pipe(Effect.provide(BunServices.layer)) + .pipe(Effect.exit), + ); + + expect(Exit.isFailure(exit)).toBe(true); + }); +}); diff --git a/apps/cli/src/legacy/commands/sso/update/update.command.ts b/apps/cli/src/legacy/commands/sso/update/update.command.ts index 05d838f2f3..cf6732ddca 100644 --- a/apps/cli/src/legacy/commands/sso/update/update.command.ts +++ b/apps/cli/src/legacy/commands/sso/update/update.command.ts @@ -3,6 +3,7 @@ import type * as CliCommand from "effect/unstable/cli/Command"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { legacyManagementApiRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts"; +import { legacyParseStringSliceFlag } from "../../../shared/legacy-string-slice-flag.ts"; import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; import { legacySsoUpdate } from "./update.handler.ts"; @@ -13,30 +14,46 @@ const NAME_ID_FORMATS = [ "urn:oasis:names:tc:SAML:2.0:nameid-format:transient", ] as const; +export const legacySsoUpdateDomainsFlag = Flag.string("domains").pipe( + Flag.atLeast(0), + Flag.withDescription("Replace domains with this comma separated list of email domains."), + Flag.mapTryCatch( + (rawValues) => legacyParseStringSliceFlag(rawValues), + (err) => (err instanceof Error ? err.message : String(err)), + ), + Flag.withDefault([] as ReadonlyArray), +); + +export const legacySsoUpdateAddDomainsFlag = Flag.string("add-domains").pipe( + Flag.atLeast(0), + Flag.withDescription("Add this comma separated list of email domains to the identity provider."), + Flag.mapTryCatch( + (rawValues) => legacyParseStringSliceFlag(rawValues), + (err) => (err instanceof Error ? err.message : String(err)), + ), + Flag.withDefault([] as ReadonlyArray), +); + +export const legacySsoUpdateRemoveDomainsFlag = Flag.string("remove-domains").pipe( + Flag.atLeast(0), + Flag.withDescription( + "Remove this comma separated list of email domains from the identity provider.", + ), + Flag.mapTryCatch( + (rawValues) => legacyParseStringSliceFlag(rawValues), + (err) => (err instanceof Error ? err.message : String(err)), + ), + Flag.withDefault([] as ReadonlyArray), +); + const config = { projectRef: Flag.string("project-ref").pipe( Flag.withDescription("Project ref of the Supabase project."), Flag.optional, ), - domains: Flag.string("domains").pipe( - Flag.atLeast(0), - Flag.withDescription("Replace domains with this comma separated list of email domains."), - Flag.withDefault([] as ReadonlyArray), - ), - addDomains: Flag.string("add-domains").pipe( - Flag.atLeast(0), - Flag.withDescription( - "Add this comma separated list of email domains to the identity provider.", - ), - Flag.withDefault([] as ReadonlyArray), - ), - removeDomains: Flag.string("remove-domains").pipe( - Flag.atLeast(0), - Flag.withDescription( - "Remove this comma separated list of email domains from the identity provider.", - ), - Flag.withDefault([] as ReadonlyArray), - ), + domains: legacySsoUpdateDomainsFlag, + addDomains: legacySsoUpdateAddDomainsFlag, + removeDomains: legacySsoUpdateRemoveDomainsFlag, metadataFile: Flag.string("metadata-file").pipe( Flag.withDescription( "File containing a SAML 2.0 Metadata XML document describing the identity provider.", diff --git a/apps/cli/src/legacy/commands/sso/update/update.command.unit.test.ts b/apps/cli/src/legacy/commands/sso/update/update.command.unit.test.ts new file mode 100644 index 0000000000..0afeb0e4d2 --- /dev/null +++ b/apps/cli/src/legacy/commands/sso/update/update.command.unit.test.ts @@ -0,0 +1,63 @@ +import { BunServices } from "@effect/platform-bun"; +import { Effect, Exit } from "effect"; +import { describe, expect, test } from "vitest"; +import { + legacySsoUpdateAddDomainsFlag, + legacySsoUpdateDomainsFlag, + legacySsoUpdateRemoveDomainsFlag, +} from "./update.command.ts"; + +describe("legacy sso update domain flags (pflag StringSlice parity)", () => { + test("--domains splits a comma-separated value into multiple domains", async () => { + const [, domains] = await Effect.runPromise( + legacySsoUpdateDomainsFlag + .parse({ + flags: { domains: ["example.com,example.org"] }, + arguments: [], + }) + .pipe(Effect.provide(BunServices.layer)), + ); + + expect(domains).toEqual(["example.com", "example.org"]); + }); + + test("--add-domains splits a comma-separated value into multiple domains", async () => { + const [, addDomains] = await Effect.runPromise( + legacySsoUpdateAddDomainsFlag + .parse({ + flags: { "add-domains": ["example.com,example.org"] }, + arguments: [], + }) + .pipe(Effect.provide(BunServices.layer)), + ); + + expect(addDomains).toEqual(["example.com", "example.org"]); + }); + + test("--remove-domains splits a comma-separated value into multiple domains", async () => { + const [, removeDomains] = await Effect.runPromise( + legacySsoUpdateRemoveDomainsFlag + .parse({ + flags: { "remove-domains": ["example.com,example.org"] }, + arguments: [], + }) + .pipe(Effect.provide(BunServices.layer)), + ); + + expect(removeDomains).toEqual(["example.com", "example.org"]); + }); + + test("rejects malformed CSV (bare quote)", async () => { + const exit = await Effect.runPromise( + legacySsoUpdateDomainsFlag + .parse({ + flags: { domains: ['example"com'] }, + arguments: [], + }) + .pipe(Effect.provide(BunServices.layer)) + .pipe(Effect.exit), + ); + + expect(Exit.isFailure(exit)).toBe(true); + }); +}); diff --git a/apps/cli/src/legacy/commands/status/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/status/SIDE_EFFECTS.md index e27d895e35..089b2340c3 100644 --- a/apps/cli/src/legacy/commands/status/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/status/SIDE_EFFECTS.md @@ -80,3 +80,5 @@ Not applicable. - `-o env` output format uses KEY=VALUE pairs. - `-o json` output format uses a JSON object. - `-o pretty` (default) uses the human-readable table format. +- `--exclude` (hidden, repeatable) omits named containers from the output; matches Go's `cmd/status.go:39-40`. +- `--ignore-health-check` (hidden, boolean) exits `0` even when a service is unhealthy; matches Go's `cmd/status.go:41-42`. diff --git a/apps/cli/src/legacy/commands/status/status.command.ts b/apps/cli/src/legacy/commands/status/status.command.ts index f10c14fafe..201bce98f0 100644 --- a/apps/cli/src/legacy/commands/status/status.command.ts +++ b/apps/cli/src/legacy/commands/status/status.command.ts @@ -8,6 +8,16 @@ const config = { Flag.withDescription("Override specific variable names."), Flag.withDefault([] as ReadonlyArray), ), + exclude: Flag.string("exclude").pipe( + Flag.atLeast(0), + Flag.withDescription("Names of containers to omit from output."), + Flag.withDefault([] as ReadonlyArray), + Flag.withHidden, + ), + ignoreHealthCheck: Flag.boolean("ignore-health-check").pipe( + Flag.withDescription("Ignore unhealthy services and exit 0"), + Flag.withHidden, + ), } as const; export type LegacyStatusFlags = CliCommand.Command.Config.Infer; diff --git a/apps/cli/src/legacy/commands/status/status.handler.ts b/apps/cli/src/legacy/commands/status/status.handler.ts index da50886773..566427260c 100644 --- a/apps/cli/src/legacy/commands/status/status.handler.ts +++ b/apps/cli/src/legacy/commands/status/status.handler.ts @@ -6,5 +6,7 @@ export const legacyStatus = Effect.fn("legacy.status")(function* (flags: LegacyS const proxy = yield* LegacyGoProxy; const args: string[] = ["status"]; for (const override of flags.overrideName) args.push("--override-name", override); + for (const name of flags.exclude) args.push("--exclude", name); + if (flags.ignoreHealthCheck) args.push("--ignore-health-check"); yield* proxy.exec(args); }); diff --git a/apps/cli/src/legacy/commands/status/status.integration.test.ts b/apps/cli/src/legacy/commands/status/status.integration.test.ts new file mode 100644 index 0000000000..1c87049978 --- /dev/null +++ b/apps/cli/src/legacy/commands/status/status.integration.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; +import { LegacyGoProxy } from "../../../shared/legacy/go-proxy.service.ts"; +import { legacyStatus } from "./status.handler.ts"; +import type { LegacyStatusFlags } from "./status.command.ts"; + +function setupLegacyStatus() { + const calls: Array> = []; + const layer = Layer.succeed(LegacyGoProxy, { + exec: (args) => + Effect.sync(() => { + calls.push(args); + }), + execCapture: () => Effect.succeed(""), + }); + return { layer, calls }; +} + +const baseFlags: LegacyStatusFlags = { + overrideName: [], + exclude: [], + ignoreHealthCheck: false, +}; + +describe("legacy status", () => { + it.live("forwards no extra flags when defaults are used", () => { + const { layer, calls } = setupLegacyStatus(); + return Effect.gen(function* () { + yield* legacyStatus(baseFlags); + expect(calls).toEqual([["status"]]); + }).pipe(Effect.provide(layer)); + }); + + it.live("forwards --override-name for each provided override", () => { + const { layer, calls } = setupLegacyStatus(); + return Effect.gen(function* () { + yield* legacyStatus({ + ...baseFlags, + overrideName: ["api.url=NEXT_PUBLIC_SUPABASE_URL"], + }); + expect(calls).toEqual([["status", "--override-name", "api.url=NEXT_PUBLIC_SUPABASE_URL"]]); + }).pipe(Effect.provide(layer)); + }); + + it.live("forwards the hidden --exclude and --ignore-health-check flags", () => { + const { layer, calls } = setupLegacyStatus(); + return Effect.gen(function* () { + yield* legacyStatus({ + overrideName: [], + exclude: ["db", "kong"], + ignoreHealthCheck: true, + }); + expect(calls).toEqual([ + ["status", "--exclude", "db", "--exclude", "kong", "--ignore-health-check"], + ]); + }).pipe(Effect.provide(layer)); + }); +}); 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 019c658cdf..d16c06060a 100644 --- a/apps/cli/src/legacy/commands/storage/cp/cp.command.ts +++ b/apps/cli/src/legacy/commands/storage/cp/cp.command.ts @@ -66,11 +66,11 @@ export const legacyStorageCpCommand = Command.make("cp", config).pipe( ]), Command.withHandler((flags) => Effect.gen(function* () { + // Gate before the mutex check below — order matters; see + // legacyRequireExperimental's doc comment for why. + yield* legacyRequireExperimental; const cliArgs = yield* CliArgs; yield* legacyAssertStorageTargetsExclusive(cliArgs.args); - // Go gates `storageCmd` behind `--experimental` in PersistentPreRunE - // (root.go:91-96), after flag-group validation and before RunE/PostRun. - yield* legacyRequireExperimental; const telemetryFlags = { recursive: flags.recursive, cacheControl: flags.cacheControl, diff --git a/apps/cli/src/legacy/commands/storage/ls/ls.command.ts b/apps/cli/src/legacy/commands/storage/ls/ls.command.ts index 4fe760a559..42abfa7da6 100644 --- a/apps/cli/src/legacy/commands/storage/ls/ls.command.ts +++ b/apps/cli/src/legacy/commands/storage/ls/ls.command.ts @@ -40,14 +40,11 @@ export const legacyStorageLsCommand = Command.make("ls", config).pipe( ]), Command.withHandler((flags) => Effect.gen(function* () { - // Enforce --linked/--local mutual exclusivity BEFORE instrumentation, so a - // flag-validation rejection doesn't emit `cli_command_executed` (Go rejects - // it at cobra flag validation, before RunE/PostRun). + // Gate before the mutex check below — order matters; see + // legacyRequireExperimental's doc comment for why. + yield* legacyRequireExperimental; const cliArgs = yield* CliArgs; yield* legacyAssertStorageTargetsExclusive(cliArgs.args); - // Go gates `storageCmd` behind `--experimental` in PersistentPreRunE - // (root.go:91-96), after flag-group validation and before RunE/PostRun. - yield* legacyRequireExperimental; const telemetryFlags = { recursive: flags.recursive, linked: flags.linked, diff --git a/apps/cli/src/legacy/commands/storage/mv/mv.command.ts b/apps/cli/src/legacy/commands/storage/mv/mv.command.ts index b6eca4193d..25fefced7d 100644 --- a/apps/cli/src/legacy/commands/storage/mv/mv.command.ts +++ b/apps/cli/src/legacy/commands/storage/mv/mv.command.ts @@ -38,11 +38,11 @@ export const legacyStorageMvCommand = Command.make("mv", config).pipe( ]), Command.withHandler((flags) => Effect.gen(function* () { + // Gate before the mutex check below — order matters; see + // legacyRequireExperimental's doc comment for why. + yield* legacyRequireExperimental; const cliArgs = yield* CliArgs; yield* legacyAssertStorageTargetsExclusive(cliArgs.args); - // Go gates `storageCmd` behind `--experimental` in PersistentPreRunE - // (root.go:91-96), after flag-group validation and before RunE/PostRun. - yield* legacyRequireExperimental; const telemetryFlags = { recursive: flags.recursive, linked: flags.linked, diff --git a/apps/cli/src/legacy/commands/storage/rm/rm.command.ts b/apps/cli/src/legacy/commands/storage/rm/rm.command.ts index ebd45bbf79..9b29eb1afb 100644 --- a/apps/cli/src/legacy/commands/storage/rm/rm.command.ts +++ b/apps/cli/src/legacy/commands/storage/rm/rm.command.ts @@ -1,9 +1,10 @@ -import { Effect } from "effect"; +import { Effect, Layer } from "effect"; import { Argument, Command, Flag } from "effect/unstable/cli"; import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; +import { stdinLayer } from "../../../../shared/runtime/stdin.layer.ts"; import { legacyRequireExperimental } from "../../../shared/legacy-experimental-gate.ts"; import { legacyStorageGatewayRuntimeLayer } from "../../../shared/legacy-storage-runtime.layer.ts"; import { @@ -41,11 +42,11 @@ export const legacyStorageRmCommand = Command.make("rm", config).pipe( ]), Command.withHandler((flags) => Effect.gen(function* () { + // Gate before the mutex check below — order matters; see + // legacyRequireExperimental's doc comment for why. + yield* legacyRequireExperimental; const cliArgs = yield* CliArgs; yield* legacyAssertStorageTargetsExclusive(cliArgs.args); - // Go gates `storageCmd` behind `--experimental` in PersistentPreRunE - // (root.go:91-96), after flag-group validation and before RunE/PostRun. - yield* legacyRequireExperimental; const telemetryFlags = { recursive: flags.recursive, linked: flags.linked, @@ -59,5 +60,5 @@ export const legacyStorageRmCommand = Command.make("rm", config).pipe( }).pipe(withLegacyCommandInstrumentation({ flags: telemetryFlags })); }).pipe(withJsonErrorHandling), ), - Command.provide(legacyStorageGatewayRuntimeLayer(["storage", "rm"])), + Command.provide(Layer.mergeAll(legacyStorageGatewayRuntimeLayer(["storage", "rm"]), stdinLayer)), ); diff --git a/apps/cli/src/legacy/commands/storage/rm/rm.integration.test.ts b/apps/cli/src/legacy/commands/storage/rm/rm.integration.test.ts index cc35dfd5e5..c279b49460 100644 --- a/apps/cli/src/legacy/commands/storage/rm/rm.integration.test.ts +++ b/apps/cli/src/legacy/commands/storage/rm/rm.integration.test.ts @@ -119,6 +119,52 @@ describe("legacy storage rm", () => { }); }); + it.live("honors a piped 'y' on non-TTY stdin and deletes", () => { + // Go scans piped stdin before defaulting (`console.go:74-82`); a piped `y` + // overrides the `n` default and deletes, even on a non-terminal. + const { layer, requests, out } = setupLegacyStorage(tmp.current, { + toml: 'project_id = "test"\n', + local: true, + stdinIsTty: false, + pipedAnswers: ["y"], + routes: [{ method: "DELETE", match: DELETE_OBJECT("private"), body: [{ name: "a.pdf" }] }], + }); + return Effect.gen(function* () { + const exit = yield* legacyStorageRm({ + files: ["ss:///private/a.pdf"], + recursive: false, + linked: true, + local: true, + }).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + expect(requests.some((r) => r.method === "DELETE")).toBe(true); + // The consumed answer is echoed after the label (Go's non-TTY `PromptText`). + expect(out.stderrText).toContain("[y/N] y"); + }); + }); + + it.live("falls back to the default (no) on an unparseable piped answer", () => { + // Go's `parseYesNo` returns nil for unrecognized input (`console.go:84-93`), so + // `PromptYesNo` keeps the `n` default and the deletion is skipped. + const { layer, requests } = setupLegacyStorage(tmp.current, { + toml: 'project_id = "test"\n', + local: true, + stdinIsTty: false, + pipedAnswers: ["maybe"], + routes: [{ method: "DELETE", match: DELETE_OBJECT("private"), body: [] }], + }); + return Effect.gen(function* () { + const exit = yield* legacyStorageRm({ + files: ["ss:///private/a.pdf"], + recursive: false, + linked: true, + local: true, + }).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + expect(requests.some((r) => r.method === "DELETE")).toBe(false); + }); + }); + it.live("uses the default (no) when non-interactive and skips deletion", () => { const { layer, requests } = setupLegacyStorage(tmp.current, { toml: 'project_id = "test"\n', diff --git a/apps/cli/src/legacy/commands/storage/storage.e2e.test.ts b/apps/cli/src/legacy/commands/storage/storage.e2e.test.ts index 39fabd1937..6827ede843 100644 --- a/apps/cli/src/legacy/commands/storage/storage.e2e.test.ts +++ b/apps/cli/src/legacy/commands/storage/storage.e2e.test.ts @@ -39,10 +39,12 @@ describe("supabase storage (legacy)", () => { }); test("rejects passing both --local and --linked", { timeout: E2E_TIMEOUT_MS }, async () => { - // Go validates flag groups (mutual exclusivity) BEFORE the experimental gate - // in PersistentPreRunE, so this fails on the mutex even without --experimental. + // Go's PersistentPreRunE runs the experimental gate BEFORE cobra's + // ValidateFlagGroups() mutex check (cobra@v1.10.2/command.go:985,1010), so + // --experimental must be set here to reach the mutex check at all — + // otherwise the experimental-gate error wins (see the next test). const { exitCode, stdout, stderr } = await runSupabase( - ["storage", "ls", "--local", "--linked", "ss:///"], + ["storage", "ls", "--local", "--linked", "ss:///", "--experimental"], { entrypoint: "legacy", cwd: projectDir }, ); expect(exitCode).toBe(1); diff --git a/apps/cli/src/legacy/commands/storage/storage.experimental-gate.integration.test.ts b/apps/cli/src/legacy/commands/storage/storage.experimental-gate.integration.test.ts new file mode 100644 index 0000000000..4e455a5c76 --- /dev/null +++ b/apps/cli/src/legacy/commands/storage/storage.experimental-gate.integration.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from "@effect/vitest"; +import { BunServices } from "@effect/platform-bun"; +import { Cause, Effect, Exit, Layer, Option } from "effect"; +import { CliOutput, Command } from "effect/unstable/cli"; + +import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; +import { textCliOutputFormatter } from "../../../shared/output/text-formatter.ts"; +import { LEGACY_GLOBAL_FLAGS } from "../../../shared/legacy/global-flags.ts"; +import { + mockAnalytics, + mockOutput, + mockProcessControl, + mockRuntimeInfo, + mockTty, + processEnvLayer, +} from "../../../../tests/helpers/mocks.ts"; +import { makeTelemetryIdentity } from "../../../shared/telemetry/identity.ts"; +import { TelemetryRuntime } from "../../../shared/telemetry/runtime.service.ts"; +import { LegacyExperimentalRequiredError } from "../../shared/legacy-experimental-gate.ts"; +import { legacyStorageCommand } from "./storage.command.ts"; +import { LegacyStorageMutuallyExclusiveFlagsError } from "./storage.errors.ts"; + +// Go gates `storageCmd` behind `--experimental` in `PersistentPreRunE` +// (`apps/cli-go/cmd/root.go:91-96`), which cobra runs BEFORE +// `ValidateFlagGroups()` (mutual-exclusivity checks, `cobra@v1.10.2/command.go:985,1010`). +// So `supabase storage ls --linked --local` without `--experimental` must +// surface the experimental-gate error in Go, not the mutex error — this suite +// proves that ordering is wired into the actual `.command.ts` handler +// pipeline for all four leaves, not just the shared helper in isolation. + +const testRoot = Command.make("supabase").pipe( + Command.withGlobalFlags(LEGACY_GLOBAL_FLAGS), + Command.withSubcommands([legacyStorageCommand]), +); + +function setup(args: ReadonlyArray) { + const out = mockOutput({ format: "text" }); + const layer = Layer.mergeAll( + BunServices.layer, + CliOutput.layer(textCliOutputFormatter()), + out.layer, + Layer.succeed(CliArgs, { args }), + // `legacyStorageGatewayRuntimeLayer`'s cliConfig/credentials layers read + // real env/files when built. Neither check under test ever reaches that + // lazy factory, but isolate ambient env defensively anyway. + processEnvLayer({ SUPABASE_NO_KEYRING: "1" }), + mockRuntimeInfo(), + mockProcessControl().layer, + mockTty({ stdinIsTty: false, stdoutIsTty: false }), + mockAnalytics().layer, + Layer.succeed( + TelemetryRuntime, + TelemetryRuntime.of({ + configDir: "/tmp/supabase-storage-experimental-gate-test/.supabase", + tracesDir: "/tmp/supabase-storage-experimental-gate-test/.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 { layer }; +} + +describe("legacy storage experimental gate vs mutual-exclusivity ordering (Go PersistentPreRunE parity)", () => { + const leaves: ReadonlyArray<{ readonly name: string; readonly args: ReadonlyArray }> = [ + { name: "ls", args: ["storage", "ls", "ss:///bucket"] }, + { name: "cp", args: ["storage", "cp", "ss:///bucket/a", "ss:///bucket/b"] }, + { name: "mv", args: ["storage", "mv", "ss:///bucket/a", "ss:///bucket/b"] }, + { name: "rm", args: ["storage", "rm", "ss:///bucket/a"] }, + ]; + + for (const { name, args } of leaves) { + const conflictingArgs = [...args, "--linked", "--local"]; + + it.live( + `${name} --linked --local without --experimental fails with the gate error, not the mutex error`, + () => { + const { layer } = setup(conflictingArgs); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + Command.runWith(testRoot, { version: "0.0.0-test" })(conflictingArgs), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const failure = Cause.findErrorOption(exit.cause); + expect( + Option.isSome(failure) && failure.value instanceof LegacyExperimentalRequiredError, + ).toBe(true); + } + }).pipe(Effect.provide(layer)); + }, + ); + + it.live(`${name} --linked --local with --experimental fails with the mutex error`, () => { + const withExperimental = [...conflictingArgs, "--experimental"]; + const { layer } = setup(withExperimental); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + Command.runWith(testRoot, { version: "0.0.0-test" })(withExperimental), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const failure = Cause.findErrorOption(exit.cause); + expect( + Option.isSome(failure) && + failure.value instanceof LegacyStorageMutuallyExclusiveFlagsError, + ).toBe(true); + } + }).pipe(Effect.provide(layer)); + }); + } +}); diff --git a/apps/cli/src/legacy/shared/legacy-connect-errors.ts b/apps/cli/src/legacy/shared/legacy-connect-errors.ts index 9aa4675915..504deb471e 100644 --- a/apps/cli/src/legacy/shared/legacy-connect-errors.ts +++ b/apps/cli/src/legacy/shared/legacy-connect-errors.ts @@ -5,6 +5,8 @@ * warrants retrying through the IPv4 transaction pooler. */ +import { isIPv6 } from "node:net"; + import { legacyAqua } from "./legacy-colors.ts"; /** @@ -27,6 +29,7 @@ export function legacyIpv6Suggestion(): string { // Go's `ipv6LiteralPattern` (`connect.go:181`): an IPv6 address in brackets // (Go dial form) or parens (libpq form). Run against the original-case message. const IPV6_LITERAL_PATTERN = /(?:\[[0-9a-fA-F:]+\]|\([0-9a-fA-F:]+\))/; +const NODE_ENETUNREACH_PATTERN = /\benetunreach\s+([0-9a-fA-F:]+):\d+(?:\s|$)/i; /** * Port of Go's `isIPv6ConnectivityError` (`connect.go:189-208`). Lower-cases the @@ -40,8 +43,58 @@ export function legacyIsIPv6ConnectivityError(message: string): boolean { if (lower.includes("address family for hostname not supported")) return true; if (lower.includes("no address associated with hostname")) return true; if (lower.includes("network is unreachable")) return true; + const nodeEnetunreachMatch = NODE_ENETUNREACH_PATTERN.exec(message); + if (nodeEnetunreachMatch?.[1] !== undefined) return isIPv6(nodeEnetunreachMatch[1]); if (lower.includes("no route to host") || lower.includes("cannot assign requested address")) { return IPV6_LITERAL_PATTERN.test(message); } return false; } + +function hasStringCode(error: unknown): error is { + readonly code: string; + readonly address?: unknown; +} { + return ( + typeof error === "object" && error !== null && "code" in error && typeof error.code === "string" + ); +} + +/** + * Classifies Node socket/getaddrinfo causes that carry errno-style `code` fields. + * `ENOTFOUND` is intentionally broader than Go's text classifier (it can include + * typo'd hosts); callers must combine this with a direct `db.` host gate. + */ +export function legacyIsIPv6ConnectivityErrorCause(error: unknown): boolean { + if (error instanceof AggregateError) { + return error.errors.some((cause) => legacyIsIPv6ConnectivityErrorCause(cause)); + } + if ( + typeof error === "object" && + error !== null && + "cause" in error && + error.cause !== undefined && + legacyIsIPv6ConnectivityErrorCause(error.cause) + ) { + return true; + } + + if (hasStringCode(error)) { + switch (error.code) { + case "ENETUNREACH": + case "ENOTFOUND": + return true; + case "EHOSTUNREACH": + case "EADDRNOTAVAIL": + return typeof error.address === "string" && isIPv6(error.address); + case "ECONNREFUSED": + case "ENOENT": + case "ETIMEDOUT": + return false; + default: + break; + } + } + + return legacyIsIPv6ConnectivityError(error instanceof Error ? error.message : String(error)); +} diff --git a/apps/cli/src/legacy/shared/legacy-connect-errors.unit.test.ts b/apps/cli/src/legacy/shared/legacy-connect-errors.unit.test.ts index b8edbdfe10..05ff60186c 100644 --- a/apps/cli/src/legacy/shared/legacy-connect-errors.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-connect-errors.unit.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest"; -import { legacyIsIPv6ConnectivityError } from "./legacy-connect-errors.ts"; +import { + legacyIsIPv6ConnectivityError, + legacyIsIPv6ConnectivityErrorCause, +} from "./legacy-connect-errors.ts"; describe("legacyIsIPv6ConnectivityError", () => { it("classifies the getaddrinfo IPv6-only failures (case-insensitive)", () => { @@ -28,8 +31,85 @@ describe("legacyIsIPv6ConnectivityError", () => { expect(legacyIsIPv6ConnectivityError("cannot assign requested address")).toBe(false); }); + it("classifies Node ENETUNREACH stderr for IPv6 literals", () => { + expect( + legacyIsIPv6ConnectivityError("connect ENETUNREACH 2600:1f18::1:5432 - Local (:::0)"), + ).toBe(true); + expect(legacyIsIPv6ConnectivityError("connect ENETUNREACH 10.0.0.1:5432")).toBe(false); + }); + it("does not classify unrelated errors", () => { expect(legacyIsIPv6ConnectivityError("permission denied for schema public")).toBe(false); expect(legacyIsIPv6ConnectivityError("")).toBe(false); }); }); + +describe("legacyIsIPv6ConnectivityErrorCause", () => { + it("classifies Node getaddrinfo and network-unreachable errors", () => { + expect( + legacyIsIPv6ConnectivityErrorCause(Object.assign(new Error(), { code: "ENETUNREACH" })), + ).toBe(true); + expect( + legacyIsIPv6ConnectivityErrorCause(Object.assign(new Error(), { code: "ENOTFOUND" })), + ).toBe(true); + }); + + it("requires an IPv6 literal address for ambiguous Node dial errors", () => { + expect( + legacyIsIPv6ConnectivityErrorCause( + Object.assign(new Error(), { code: "EHOSTUNREACH", address: "2600:1f18::1" }), + ), + ).toBe(true); + expect( + legacyIsIPv6ConnectivityErrorCause( + Object.assign(new Error(), { code: "EADDRNOTAVAIL", address: "2a05:d014::1" }), + ), + ).toBe(true); + expect( + legacyIsIPv6ConnectivityErrorCause( + Object.assign(new Error(), { code: "EHOSTUNREACH", address: "10.0.0.1" }), + ), + ).toBe(false); + }); + + it("recurses through AggregateError causes", () => { + expect( + legacyIsIPv6ConnectivityErrorCause( + new AggregateError([ + Object.assign(new Error(), { code: "ECONNREFUSED" }), + Object.assign(new Error(), { code: "ENETUNREACH" }), + ]), + ), + ).toBe(true); + }); + + it("recurses through wrapped cause fields", () => { + expect( + legacyIsIPv6ConnectivityErrorCause( + Object.assign(new Error("probe failed"), { + cause: Object.assign(new Error(), { code: "ENETUNREACH" }), + }), + ), + ).toBe(true); + }); + + it("does not classify unrelated process and timeout failures", () => { + expect( + legacyIsIPv6ConnectivityErrorCause(Object.assign(new Error(), { code: "ECONNREFUSED" })), + ).toBe(false); + expect(legacyIsIPv6ConnectivityErrorCause(Object.assign(new Error(), { code: "ENOENT" }))).toBe( + false, + ); + expect( + legacyIsIPv6ConnectivityErrorCause(Object.assign(new Error(), { code: "ETIMEDOUT" })), + ).toBe(false); + }); + + it("falls back to the existing message classifier for wrapped libpq wording", () => { + expect( + legacyIsIPv6ConnectivityErrorCause( + new Error("could not translate host name: no address associated with hostname"), + ), + ).toBe(true); + }); +}); diff --git a/apps/cli/src/legacy/shared/legacy-db-config.integration.test.ts b/apps/cli/src/legacy/shared/legacy-db-config.integration.test.ts index b8c0c4d860..78549ac06f 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.integration.test.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.integration.test.ts @@ -12,7 +12,7 @@ import { mockTelemetryRuntime, mockTty, } from "../../../tests/helpers/mocks.ts"; -import { mockLegacyCliConfig } from "../../../tests/helpers/legacy-mocks.ts"; +import { LEGACY_VALID_TOKEN, mockLegacyCliConfig } from "../../../tests/helpers/legacy-mocks.ts"; import { LegacyDebugFlag, LegacyDnsResolverFlag, @@ -25,7 +25,11 @@ import { legacyIdentityStitchLayer } from "./legacy-identity-stitch.ts"; import { legacyDbConfigLayer } from "./legacy-db-config.layer.ts"; import { LegacyDbConfigResolver } from "./legacy-db-config.service.ts"; import type { LegacyDbConfigFlags } from "./legacy-db-config.types.ts"; -import { LegacyDbConnection } from "./legacy-db-connection.service.ts"; +import { + LegacyDbConnection, + type LegacyDbSession, + type LegacyPgConnInput, +} from "./legacy-db-connection.service.ts"; // `--local` / `--db-url` never touch the Management API stack, so the resolver // builds with simple ambient stubs. The `--linked` sub-flow (login-role, @@ -40,10 +44,22 @@ const mockDbConnection = Layer.succeed(LegacyDbConnection, { connect: () => Effect.die("unexpected connect() in --local/--db-url resolver test"), }); -function buildResolver(workdir: string) { +function buildResolver( + workdir: string, + opts: { + readonly projectHost?: string; + readonly poolerHost?: string; + readonly dbConnection?: Layer.Layer; + } = {}, +) { const deps = Layer.mergeAll( - mockLegacyCliConfig({ workdir, projectHost: "supabase.co", projectId: Option.none() }), - mockDbConnection, + mockLegacyCliConfig({ + workdir, + projectHost: opts.projectHost ?? "supabase.co", + poolerHost: opts.poolerHost, + projectId: Option.none(), + }), + opts.dbConnection ?? mockDbConnection, mockDebugLogger, mockOutput().layer, mockAnalytics().layer, @@ -76,11 +92,25 @@ function withWorkdir(toml?: string) { return dir; } -const resolve = (workdir: string, flags: LegacyDbConfigFlags) => +const resolve = ( + workdir: string, + flags: LegacyDbConfigFlags, + opts?: Parameters[1], +) => Effect.gen(function* () { const resolver = yield* LegacyDbConfigResolver; return yield* resolver.resolve(flags); - }).pipe(Effect.provide(buildResolver(workdir))); + }).pipe(Effect.provide(buildResolver(workdir, opts))); + +const resolvePoolerFallback = ( + workdir: string, + flags: LegacyDbConfigFlags, + opts?: Parameters[1], +) => + Effect.gen(function* () { + const resolver = yield* LegacyDbConfigResolver; + return yield* resolver.resolvePoolerFallback(flags); + }).pipe(Effect.provide(buildResolver(workdir, opts))); const localFlags: LegacyDbConfigFlags = { dbUrl: Option.none(), @@ -357,4 +387,425 @@ describe("legacyDbConfigResolver (linked config ordering)", () => { ), ); }); + + it.effect("ad-hoc project refs ignore the linked workdir password and saved pooler URL", () => { + const linkedRef = "abcdefghijklmnopqrst"; + const adHocRef = "qrstabcdefghijklmnop"; + const dir = withWorkdir( + [`project_id = "${linkedRef}"`, "[db]", "major_version = 15", ""].join("\n"), + ); + mkdirSync(join(dir, "supabase", ".temp"), { recursive: true }); + writeFileSync(join(dir, "supabase", ".temp", "project-ref"), linkedRef); + writeFileSync( + join(dir, "supabase", ".temp", "pooler-url"), + `postgres://postgres.${linkedRef}:saved-workdir-password@stale.pooler.supabase.com:6543/postgres`, + ); + + const previousAccessToken = process.env["SUPABASE_ACCESS_TOKEN"]; + const previousPassword = process.env["SUPABASE_DB_PASSWORD"]; + const previousFetch = globalThis.fetch; + const requests: Array<{ readonly method: string; readonly path: string }> = []; + const connections: Array<{ + readonly conn: LegacyPgConnInput; + readonly dnsResolver: "native" | "https"; + readonly isLocal: boolean; + }> = []; + const session: LegacyDbSession = { + exec: () => Effect.void, + query: () => Effect.succeed([]), + extensionExists: () => Effect.succeed(false), + copyToCsv: () => Effect.succeed(new Uint8Array()), + queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "SELECT 1" }), + }; + const dbConnection = Layer.succeed(LegacyDbConnection, { + connect: (conn, options) => + Effect.sync(() => { + connections.push({ conn, ...options }); + return session; + }), + }); + const fetchMock = Object.assign( + async (input: string | URL | Request, init?: RequestInit): Promise => { + const url = new URL(typeof input === "string" || input instanceof URL ? input : input.url); + const method = init?.method ?? (input instanceof Request ? input.method : "GET"); + requests.push({ method, path: url.pathname }); + + if ( + method === "GET" && + url.pathname === `/v1/projects/${adHocRef}/config/database/pooler` + ) { + return new Response( + JSON.stringify([ + { + identifier: "primary", + database_type: "PRIMARY", + is_using_scram_auth: true, + db_user: "postgres", + db_host: "db.example", + db_port: 5432, + db_name: "postgres", + connection_string: `postgres://postgres.${adHocRef}:[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.com:6543/postgres`, + connectionString: `postgres://postgres.${adHocRef}:[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.com:6543/postgres`, + default_pool_size: null, + max_client_conn: null, + pool_mode: "transaction", + }, + ]), + { status: 200, headers: { "content-type": "application/json" } }, + ); + } + + if (method === "POST" && url.pathname === `/v1/projects/${adHocRef}/cli/login-role`) { + return new Response( + JSON.stringify({ + role: "cli_login_role", + password: "temporary-role-password", + ttl_seconds: 3600, + }), + { status: 201, headers: { "content-type": "application/json" } }, + ); + } + + return new Response(JSON.stringify({ message: "unexpected request" }), { + status: 404, + headers: { "content-type": "application/json" }, + }); + }, + { preconnect: previousFetch.preconnect }, + ); + + process.env["SUPABASE_ACCESS_TOKEN"] = LEGACY_VALID_TOKEN; + process.env["SUPABASE_DB_PASSWORD"] = "ambient-linked-password"; + globalThis.fetch = fetchMock; + + return resolve( + dir, + { + ...linkedFlags, + linkedProjectRef: Option.some(adHocRef), + adHocProjectRef: true, + }, + { projectHost: "invalid", dbConnection }, + ).pipe( + Effect.tap((r) => + Effect.sync(() => { + expect(r.conn).toEqual({ + host: "aws-0-us-east-1.pooler.supabase.com", + port: 5432, + user: `cli_login_role.${adHocRef}`, + password: "temporary-role-password", + database: "postgres", + }); + expect(r.ref).toEqual(Option.some(adHocRef)); + expect(requests).toEqual([ + { + method: "GET", + path: `/v1/projects/${adHocRef}/config/database/pooler`, + }, + { + method: "POST", + path: `/v1/projects/${adHocRef}/cli/login-role`, + }, + ]); + expect(connections).toEqual([ + { + conn: { + host: "aws-0-us-east-1.pooler.supabase.com", + port: 5432, + user: `cli_login_role.${adHocRef}`, + password: "temporary-role-password", + database: "postgres", + }, + isLocal: false, + dnsResolver: "native", + }, + ]); + }), + ), + Effect.ensuring( + Effect.sync(() => { + globalThis.fetch = previousFetch; + if (previousAccessToken === undefined) delete process.env["SUPABASE_ACCESS_TOKEN"]; + else process.env["SUPABASE_ACCESS_TOKEN"] = previousAccessToken; + if (previousPassword === undefined) delete process.env["SUPABASE_DB_PASSWORD"]; + else process.env["SUPABASE_DB_PASSWORD"] = previousPassword; + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); + + it.effect( + "ad-hoc pooler fallback ignores the linked workdir password and saved pooler URL", + () => { + const linkedRef = "abcdefghijklmnopqrst"; + const adHocRef = "qrstabcdefghijklmnop"; + const dir = withWorkdir( + [`project_id = "${linkedRef}"`, "[db]", "major_version = 15", ""].join("\n"), + ); + mkdirSync(join(dir, "supabase", ".temp"), { recursive: true }); + writeFileSync(join(dir, "supabase", ".temp", "project-ref"), linkedRef); + writeFileSync( + join(dir, "supabase", ".temp", "pooler-url"), + `postgres://postgres.${linkedRef}:saved-workdir-password@stale.pooler.supabase.com:6543/postgres`, + ); + + const previousAccessToken = process.env["SUPABASE_ACCESS_TOKEN"]; + const previousPassword = process.env["SUPABASE_DB_PASSWORD"]; + const previousFetch = globalThis.fetch; + const requests: Array<{ readonly method: string; readonly path: string }> = []; + const connections: Array<{ + readonly conn: LegacyPgConnInput; + readonly dnsResolver: "native" | "https"; + readonly isLocal: boolean; + }> = []; + const session: LegacyDbSession = { + exec: () => Effect.void, + query: () => Effect.succeed([]), + extensionExists: () => Effect.succeed(false), + copyToCsv: () => Effect.succeed(new Uint8Array()), + queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "SELECT 1" }), + }; + const dbConnection = Layer.succeed(LegacyDbConnection, { + connect: (conn, options) => + Effect.sync(() => { + connections.push({ conn, ...options }); + return session; + }), + }); + const fetchMock = Object.assign( + async (input: string | URL | Request, init?: RequestInit): Promise => { + const url = new URL( + typeof input === "string" || input instanceof URL ? input : input.url, + ); + const method = init?.method ?? (input instanceof Request ? input.method : "GET"); + requests.push({ method, path: url.pathname }); + + if ( + method === "GET" && + url.pathname === `/v1/projects/${adHocRef}/config/database/pooler` + ) { + return new Response( + JSON.stringify([ + { + identifier: "primary", + database_type: "PRIMARY", + is_using_scram_auth: true, + db_user: "postgres", + db_host: "db.example", + db_port: 5432, + db_name: "postgres", + connection_string: `postgres://postgres.${adHocRef}:[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.com:6543/postgres`, + connectionString: `postgres://postgres.${adHocRef}:[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.com:6543/postgres`, + default_pool_size: null, + max_client_conn: null, + pool_mode: "transaction", + }, + ]), + { status: 200, headers: { "content-type": "application/json" } }, + ); + } + + if (method === "POST" && url.pathname === `/v1/projects/${adHocRef}/cli/login-role`) { + return new Response( + JSON.stringify({ + role: "cli_login_role", + password: "temporary-role-password", + ttl_seconds: 3600, + }), + { status: 201, headers: { "content-type": "application/json" } }, + ); + } + + return new Response(JSON.stringify({ message: "unexpected request" }), { + status: 404, + headers: { "content-type": "application/json" }, + }); + }, + { preconnect: previousFetch.preconnect }, + ); + + process.env["SUPABASE_ACCESS_TOKEN"] = LEGACY_VALID_TOKEN; + process.env["SUPABASE_DB_PASSWORD"] = "ambient-linked-password"; + globalThis.fetch = fetchMock; + + return resolvePoolerFallback( + dir, + { + ...linkedFlags, + linkedProjectRef: Option.some(adHocRef), + adHocProjectRef: true, + }, + { dbConnection }, + ).pipe( + Effect.tap((connOpt) => + Effect.sync(() => { + expect(Option.isSome(connOpt)).toBe(true); + if (Option.isSome(connOpt)) { + expect(connOpt.value).toEqual({ + host: "aws-0-us-east-1.pooler.supabase.com", + port: 5432, + user: `cli_login_role.${adHocRef}`, + password: "temporary-role-password", + database: "postgres", + }); + } + expect(requests).toEqual([ + { + method: "GET", + path: `/v1/projects/${adHocRef}/config/database/pooler`, + }, + { + method: "POST", + path: `/v1/projects/${adHocRef}/cli/login-role`, + }, + ]); + expect(connections).toEqual([ + { + conn: { + host: "aws-0-us-east-1.pooler.supabase.com", + port: 5432, + user: `cli_login_role.${adHocRef}`, + password: "temporary-role-password", + database: "postgres", + }, + isLocal: false, + dnsResolver: "native", + }, + ]); + }), + ), + Effect.ensuring( + Effect.sync(() => { + globalThis.fetch = previousFetch; + if (previousAccessToken === undefined) delete process.env["SUPABASE_ACCESS_TOKEN"]; + else process.env["SUPABASE_ACCESS_TOKEN"] = previousAccessToken; + if (previousPassword === undefined) delete process.env["SUPABASE_DB_PASSWORD"]; + else process.env["SUPABASE_DB_PASSWORD"] = previousPassword; + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }, + ); + + it.effect("linked pooler fallback fetches API config when the saved pooler URL is stale", () => { + const linkedRef = "abcdefghijklmnopqrst"; + const dir = withWorkdir( + [`project_id = "${linkedRef}"`, "[db]", "major_version = 15", ""].join("\n"), + ); + mkdirSync(join(dir, "supabase", ".temp"), { recursive: true }); + writeFileSync(join(dir, "supabase", ".temp", "project-ref"), linkedRef); + writeFileSync( + join(dir, "supabase", ".temp", "pooler-url"), + "postgres://postgres.qrstabcdefghijklmnop:saved-workdir-password@aws-0-us-east-1.pooler.supabase.com:6543/postgres", + ); + + const previousAccessToken = process.env["SUPABASE_ACCESS_TOKEN"]; + const previousPassword = process.env["SUPABASE_DB_PASSWORD"]; + const previousFetch = globalThis.fetch; + const requests: Array<{ readonly method: string; readonly path: string }> = []; + const connections: Array<{ + readonly conn: LegacyPgConnInput; + readonly dnsResolver: "native" | "https"; + readonly isLocal: boolean; + }> = []; + const session: LegacyDbSession = { + exec: () => Effect.void, + query: () => Effect.succeed([]), + extensionExists: () => Effect.succeed(false), + copyToCsv: () => Effect.succeed(new Uint8Array()), + queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "SELECT 1" }), + }; + const dbConnection = Layer.succeed(LegacyDbConnection, { + connect: (conn, options) => + Effect.sync(() => { + connections.push({ conn, ...options }); + return session; + }), + }); + const fetchMock = Object.assign( + async (input: string | URL | Request, init?: RequestInit): Promise => { + const url = new URL(typeof input === "string" || input instanceof URL ? input : input.url); + const method = init?.method ?? (input instanceof Request ? input.method : "GET"); + requests.push({ method, path: url.pathname }); + + if ( + method === "GET" && + url.pathname === `/v1/projects/${linkedRef}/config/database/pooler` + ) { + return new Response( + JSON.stringify([ + { + identifier: "primary", + database_type: "PRIMARY", + is_using_scram_auth: true, + db_user: "postgres", + db_host: "db.example", + db_port: 5432, + db_name: "postgres", + connection_string: `postgres://postgres.${linkedRef}:[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.com:6543/postgres`, + connectionString: `postgres://postgres.${linkedRef}:[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.com:6543/postgres`, + default_pool_size: null, + max_client_conn: null, + pool_mode: "transaction", + }, + ]), + { status: 200, headers: { "content-type": "application/json" } }, + ); + } + + return new Response(JSON.stringify({ message: "unexpected request" }), { + status: 404, + headers: { "content-type": "application/json" }, + }); + }, + { preconnect: previousFetch.preconnect }, + ); + + process.env["SUPABASE_ACCESS_TOKEN"] = LEGACY_VALID_TOKEN; + process.env["SUPABASE_DB_PASSWORD"] = "linked-password"; + globalThis.fetch = fetchMock; + + return resolvePoolerFallback( + dir, + { + ...linkedFlags, + linkedProjectRef: Option.some(linkedRef), + }, + { projectHost: "supabase.co", dbConnection }, + ).pipe( + Effect.tap((connOpt) => + Effect.sync(() => { + expect(Option.isSome(connOpt)).toBe(true); + if (Option.isSome(connOpt)) { + expect(connOpt.value).toEqual({ + host: "aws-0-us-east-1.pooler.supabase.com", + port: 5432, + user: `postgres.${linkedRef}`, + password: "linked-password", + database: "postgres", + }); + } + expect(requests).toEqual([ + { + method: "GET", + path: `/v1/projects/${linkedRef}/config/database/pooler`, + }, + ]); + expect(connections).toEqual([]); + }), + ), + Effect.ensuring( + Effect.sync(() => { + globalThis.fetch = previousFetch; + if (previousAccessToken === undefined) delete process.env["SUPABASE_ACCESS_TOKEN"]; + else process.env["SUPABASE_ACCESS_TOKEN"] = previousAccessToken; + if (previousPassword === undefined) delete process.env["SUPABASE_DB_PASSWORD"]; + else process.env["SUPABASE_DB_PASSWORD"] = previousPassword; + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); }); diff --git a/apps/cli/src/legacy/shared/legacy-db-config.layer.ts b/apps/cli/src/legacy/shared/legacy-db-config.layer.ts index d6d9e355cf..9bc7e00f45 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.layer.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.layer.ts @@ -1,7 +1,6 @@ import * as net from "node:net"; import { BunServices } from "@effect/platform-bun"; import { Duration, Effect, FileSystem, Layer, Option, Path } from "effect"; -import { getDomain } from "tldts"; import { LegacyPlatformApiFactory } from "../auth/legacy-platform-api-factory.service.ts"; import { LegacyCliConfig } from "../config/legacy-cli-config.service.ts"; @@ -29,6 +28,7 @@ import { } from "./legacy-management-api-runtime.layer.ts"; import * as Errors from "./legacy-db-config.errors.ts"; import { + legacyPoolerConfigFromConnectionString, parseLegacyConnectionString, redactLegacyConnectionString, } from "./legacy-db-config.parse.ts"; @@ -245,57 +245,14 @@ export const legacyDbConfigLayer = Layer.effect( connectionString: string, ): Effect.Effect> => Effect.gen(function* () { - const sanitized = connectionString.replaceAll("[YOUR-PASSWORD]", ""); - const parsed = parseLegacyConnectionString(sanitized); - if (parsed === undefined) { - yield* debug.debug("failed to parse pooler URL"); - return Option.none(); - } - // Preserve the libpq `options` startup param (Go keeps it in - // `pgconn.Config.RuntimeParams`): legacy pooler URLs route by tenant via - // `?options=reference=`, so the actual connection must carry it. - const optionsParam = parsed.options ?? ""; - // Username must encode the project ref: either `.` or the - // `?options=reference=` query param. - const dotIndex = parsed.user.indexOf("."); - if (dotIndex === -1) { - for (const option of optionsParam.split(",")) { - const [key, value] = option.split("="); - // Mirror Go's `strings.Cut` `found` guard (connect.go:83): only reject - // when the `reference` option is present *with* a value that mismatches. - // A bare `reference` token (no `=`) or a missing `reference` key is - // accepted, exactly as Go does — do not reject on absence. - if (key === "reference" && value !== undefined && value !== ref) { - yield* debug.debug(`Pooler options does not match project ref: ${ref}`); - return Option.none(); - } - } - } else if (parsed.user.slice(dotIndex + 1) !== ref) { - yield* debug.debug(`Pooler username does not match project ref: ${ref}`); - return Option.none(); - } - // MITM guard: the pooler domain must belong to the active profile. The - // expected host comes from the resolved profile (built-in table or a YAML - // profile's `pooler_host:`), so custom/staging pooler domains are honored. - const expectedPoolerHost = cliConfig.poolerHost; - const domain = getDomain(parsed.host); - if (domain === null) { - yield* debug.debug("failed to parse pooler TLD"); - return Option.none(); - } - if ( - expectedPoolerHost.length > 0 && - expectedPoolerHost.toLowerCase() !== domain.toLowerCase() - ) { - yield* debug.debug(`Pooler domain does not belong to current profile: ${domain}`); - return Option.none(); - } - // Supavisor transaction mode does not support prepared statements; use port 5432. - return Option.some({ - ...parsed, - port: DIRECT_PORT, - ...(optionsParam.length > 0 ? { options: optionsParam } : {}), - }); + const result = legacyPoolerConfigFromConnectionString( + ref, + connectionString, + cliConfig.poolerHost, + ); + if (result._tag === "ok") return Option.some(result.conn); + yield* debug.debug(result.reason); + return Option.none(); }); // Resolve the DB password with viper's precedence: `--password` flag → @@ -326,6 +283,10 @@ export const legacyDbConfigLayer = Layer.effect( // the resolve-time IPv6 path (`NewDbConfigWithPassword` → `GetPoolerConfig`) uses // the saved URL only and errors otherwise, so this defaults off. fetchFromApi = false, + // For an ad-hoc `--project-id` ref the saved `.temp/pooler-url` belongs to the + // (possibly different) linked workdir, so ignore it and resolve the pooler for + // `ref` from the Management API instead. + ignoreSavedUrl = false, ): Effect.Effect< Option.Option, LegacyDbConfigError, @@ -335,8 +296,12 @@ export const legacyDbConfigLayer = Layer.effect( // Linked-path read: merge the `[remotes.]` override (Go's pooler // resolution runs after LoadConfig(ref) already merged), so this matches the // ref-aware read on the main linked branch rather than validating base config. + // For an ad-hoc `--project-id` ref, skip the saved workdir pooler URL because + // it belongs to the linked project, not necessarily the explicit ref. const tomlValues = yield* legacyReadDbToml(fs, path, cliConfig.workdir, ref); - let connectionString = Option.getOrUndefined(tomlValues.poolerConnectionString); + let connectionString = ignoreSavedUrl + ? undefined + : Option.getOrUndefined(tomlValues.poolerConnectionString); if (connectionString === undefined) { if (!fetchFromApi) return Option.none(); // No saved pooler URL → fetch the primary pooler config from the Management @@ -349,7 +314,20 @@ export const legacyDbConfigLayer = Layer.effect( if (primary === undefined) return Option.none(); connectionString = primary.connection_string; } - const pooler = yield* poolerConfigFrom(ref, connectionString); + let pooler = Option.none(); + if (connectionString !== undefined) { + pooler = yield* poolerConfigFrom(ref, connectionString); + } + if (Option.isNone(pooler) && fetchFromApi) { + const api = yield* (yield* LegacyPlatformApiFactory).make; + const configsOpt = yield* api.v1.getPoolerConfig({ ref }).pipe(Effect.option); + if (Option.isSome(configsOpt)) { + const primary = configsOpt.value.find((config) => config.database_type === "PRIMARY"); + if (primary !== undefined) { + pooler = yield* poolerConfigFrom(ref, primary.connection_string); + } + } + } if (Option.isNone(pooler)) return Option.none(); const poolerConn = pooler.value; if (password.length > 0) { @@ -371,11 +349,17 @@ export const legacyDbConfigLayer = Layer.effect( ref: string, dnsResolver: "native" | "https", passwordFlag: Option.Option, + adHocProjectRef = false, ): Effect.Effect => Effect.gen(function* () { // Read lazily (per invocation) rather than at layer build, so tests and - // env-substitution see the current value. - const dbPassword = yield* resolveDbPassword(passwordFlag); + // env-substitution see the current value. For an ad-hoc `--project-id` ref, + // honor only an explicit `--password` flag and ignore the ambient + // `SUPABASE_DB_PASSWORD` (which belongs to the current workdir, not this ref), + // so we always mint a temporary login role instead of leaking it. + const dbPassword = adHocProjectRef + ? (Option.getOrUndefined(passwordFlag) ?? "") + : yield* resolveDbPassword(passwordFlag); const host = `db.${ref}.${cliConfig.projectHost}`; const base: LegacyPgConnInput = { host, @@ -394,8 +378,17 @@ export const legacyDbConfigLayer = Layer.effect( return yield* initLoginRole(ref, base); } - // Direct host unreachable (IPv6-only network) → try the pooler. - const poolerConn = yield* resolvePoolerConn(ref, dnsResolver, base.password); + // Direct host unreachable (IPv6-only network) → try the pooler. For an ad-hoc + // `--project-id` ref the command already holds a Management API token, so fall + // back to the API pooler config (and ignore the workdir's saved pooler URL) + // rather than failing with the IPv6 "run supabase link" suggestion. + const poolerConn = yield* resolvePoolerConn( + ref, + dnsResolver, + base.password, + adHocProjectRef, + adHocProjectRef, + ); if (Option.isNone(poolerConn)) { return yield* Effect.fail( new Errors.LegacyDbConfigIpv6Error({ @@ -477,7 +470,7 @@ export const legacyDbConfigLayer = Layer.effect( // workdir fails with ErrNotLinked, a bad ref with the invalid-ref error, and an // unreadable ref file surfaces the filesystem problem — matching Go for every // caller of this resolver (`test db --linked`, dump, declarative). - const ref = yield* projectRef.loadProjectRef(Option.none()); + const ref = yield* projectRef.loadProjectRef(flags.linkedProjectRef ?? Option.none()); // Go's `ParseDatabaseConfig` runs `LoadProjectRef` → `LoadConfig` → // `NewDbConfigWithPassword` (`internal/utils/flags/db_url.go:81-92`), so // the `[remotes.]`-merged config (e.g. an unsupported remote @@ -492,6 +485,7 @@ export const legacyDbConfigLayer = Layer.effect( ref, flags.dnsResolver, flags.password ?? Option.none(), + flags.adHocProjectRef ?? false, ); // NB: the linked-project telemetry cache (GET /v1/projects/{ref}) is NOT // issued here. Go caches it in `PersistentPostRun` @@ -537,14 +531,17 @@ export const legacyDbConfigLayer = Layer.effect( if (flags.connType !== "linked") return Option.none(); return yield* Effect.gen(function* () { const projectRef = yield* LegacyProjectRefResolver; - const refOpt = yield* projectRef.resolveOptional(Option.none()); + const refOpt = yield* projectRef.resolveOptional(flags.linkedProjectRef ?? Option.none()); if (Option.isNone(refOpt)) return Option.none(); const ref = refOpt.value; if (!PROJECT_REF_PATTERN.test(ref)) return Option.none(); - const password = yield* resolveDbPassword(flags.password ?? Option.none()); + const adHocProjectRef = flags.adHocProjectRef ?? false; + const password = adHocProjectRef + ? (Option.getOrUndefined(flags.password ?? Option.none()) ?? "") + : yield* resolveDbPassword(flags.password ?? Option.none()); // Container-fallback: fetch the primary pooler config from the Management API // when no `.temp/pooler-url` is saved (Go's `ResolvePoolerConfigForFallback`). - return yield* resolvePoolerConn(ref, flags.dnsResolver, password, true); + return yield* resolvePoolerConn(ref, flags.dnsResolver, password, true, adHocProjectRef); }).pipe( Effect.provide( legacyLinkedDbResolverRuntimeLayer(["db", "dump"]).pipe(Layer.provide(ambientLayer)), diff --git a/apps/cli/src/legacy/shared/legacy-db-config.parse.ts b/apps/cli/src/legacy/shared/legacy-db-config.parse.ts index 2b84378b3f..81bb1761ad 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.parse.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.parse.ts @@ -1,6 +1,7 @@ import { existsSync } from "node:fs"; import { homedir, userInfo } from "node:os"; import { join } from "node:path"; +import { getDomain } from "tldts"; import type { LegacyPgConnInput } from "./legacy-db-connection.service.ts"; import { legacyPgpassPassword } from "./legacy-pgpass.ts"; import { legacyServiceSettings } from "./legacy-pgservicefile.ts"; @@ -399,6 +400,63 @@ export function parseLegacyConnectionString( return parseKeywordValueDsn(trimmed, env); } +export type LegacyPoolerConfigResult = + | { readonly _tag: "ok"; readonly conn: LegacyPgConnInput } + | { readonly _tag: "invalid"; readonly reason: string }; + +/** + * Parse + validate a Supabase transaction-pooler URL. Mirrors Go's + * `GetPoolerConfig`: strip the dashboard password placeholder, require the + * project ref in the tenant user/options, verify the pooler domain belongs to + * the active profile, and force transaction-pooler port 5432. + */ +export function legacyPoolerConfigFromConnectionString( + ref: string, + connectionString: string, + expectedPoolerHost: string, +): LegacyPoolerConfigResult { + const sanitized = connectionString.replaceAll("[YOUR-PASSWORD]", ""); + const parsed = parseLegacyConnectionString(sanitized); + if (parsed === undefined) { + return { _tag: "invalid", reason: "failed to parse pooler URL" }; + } + + const optionsParam = parsed.options ?? ""; + const dotIndex = parsed.user.indexOf("."); + if (dotIndex === -1) { + for (const option of optionsParam.split(",")) { + const separatorIndex = option.indexOf("="); + const key = separatorIndex === -1 ? option : option.slice(0, separatorIndex); + const value = separatorIndex === -1 ? undefined : option.slice(separatorIndex + 1); + if (key === "reference" && value !== undefined && value !== ref) { + return { _tag: "invalid", reason: `Pooler options does not match project ref: ${ref}` }; + } + } + } else if (parsed.user.slice(dotIndex + 1) !== ref) { + return { _tag: "invalid", reason: `Pooler username does not match project ref: ${ref}` }; + } + + const domain = getDomain(parsed.host); + if (domain === null) { + return { _tag: "invalid", reason: "failed to parse pooler TLD" }; + } + if (expectedPoolerHost.length > 0 && expectedPoolerHost.toLowerCase() !== domain.toLowerCase()) { + return { + _tag: "invalid", + reason: `Pooler domain does not belong to current profile: ${domain}`, + }; + } + + return { + _tag: "ok", + conn: { + ...parsed, + port: DIRECT_PORT, + ...(optionsParam.length > 0 ? { options: optionsParam } : {}), + }, + }; +} + /** Parse the WHATWG `postgres(ql)://` URL form. */ function parseUrlConnectionString( value: string, diff --git a/apps/cli/src/legacy/shared/legacy-db-config.parse.unit.test.ts b/apps/cli/src/legacy/shared/legacy-db-config.parse.unit.test.ts index 693217f92b..dd706b3326 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.parse.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.parse.unit.test.ts @@ -4,6 +4,7 @@ import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { + legacyPoolerConfigFromConnectionString, parseLegacyConnectionString, redactLegacyConnectionString, } from "./legacy-db-config.parse.ts"; @@ -1009,3 +1010,80 @@ describe("redactLegacyConnectionString", () => { expect(redacted).not.toContain("bad"); }); }); + +describe("legacyPoolerConfigFromConnectionString", () => { + it("strips the placeholder password, validates the tenant, preserves options, and rewrites to port 5432", () => { + expect( + legacyPoolerConfigFromConnectionString( + "abcdefghijklmnopqrst", + "postgres://postgres.abcdefghijklmnopqrst:[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.com:6543/postgres?options=reference%3Dabcdefghijklmnopqrst", + "supabase.com", + ), + ).toEqual({ + _tag: "ok", + conn: { + host: "aws-0-us-east-1.pooler.supabase.com", + port: 5432, + user: "postgres.abcdefghijklmnopqrst", + password: "", + database: "postgres", + options: "reference=abcdefghijklmnopqrst", + }, + }); + }); + + it("rejects a username tenant mismatch", () => { + expect( + legacyPoolerConfigFromConnectionString( + "abcdefghijklmnopqrst", + "postgres://postgres.wrongrefabcdefghijkl@aws-0-us-east-1.pooler.supabase.com:6543/postgres", + "supabase.com", + ), + ).toEqual({ + _tag: "invalid", + reason: "Pooler username does not match project ref: abcdefghijklmnopqrst", + }); + }); + + it("rejects an options reference mismatch when the username has no tenant suffix", () => { + expect( + legacyPoolerConfigFromConnectionString( + "abcdefghijklmnopqrst", + "postgres://postgres@aws-0-us-east-1.pooler.supabase.com:6543/postgres?options=reference%3Dwrongrefabcdefghijkl", + "supabase.com", + ), + ).toEqual({ + _tag: "invalid", + reason: "Pooler options does not match project ref: abcdefghijklmnopqrst", + }); + }); + + it("rejects a pooler host outside the expected profile domain", () => { + expect( + legacyPoolerConfigFromConnectionString( + "abcdefghijklmnopqrst", + "postgres://postgres.abcdefghijklmnopqrst@aws-0-us-east-1.pooler.example.com:6543/postgres", + "supabase.com", + ), + ).toEqual({ + _tag: "invalid", + reason: "Pooler domain does not belong to current profile: example.com", + }); + }); + + it("skips the profile-domain guard when the expected pooler host is empty", () => { + expect( + legacyPoolerConfigFromConnectionString( + "abcdefghijklmnopqrst", + "postgres://postgres.abcdefghijklmnopqrst@aws-0-us-east-1.pooler.example.com:6543/postgres", + "", + ), + ).toMatchObject({ + _tag: "ok", + conn: { + host: "aws-0-us-east-1.pooler.example.com", + port: 5432, + }, + }); + }); +}); diff --git a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts index ac8e8e573a..0e4bc28028 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts @@ -143,7 +143,7 @@ const DEFAULT_PORT = 54322; const DEFAULT_SHADOW_PORT = 54320; const DEFAULT_MAJOR_VERSION = 17; const DEFAULT_PASSWORD = "postgres"; -/** `[edge_runtime] deno_version` default (`config.toml` template). 2 → v1.74.1. */ +/** `[edge_runtime] deno_version` default (`config.toml` template). 2 → the current edge-runtime image. */ const DEFAULT_DENO_VERSION = 2; /** Default declarative schema dir (`utils.DeclarativeDir`, `misc.go:102`). */ @@ -480,17 +480,37 @@ function legacyJoinSupabaseSeedPath(pattern: string): string { const DEFAULT_SUPABASE_ENV = "development"; /** - * Load the project's nested `.env` files into a lookup map, mirroring Go's - * `loadNestedEnv` + `loadDefaultEnv` (`pkg/config/config.go:1047-1085`). Go walks - * from the `supabase/` directory up to the repo root and, in each directory, - * loads `.env..local`, `.env.local` (skipped when `SUPABASE_ENV=test`), - * `.env.`, then `.env` via `godotenv.Load`, which never overrides a value - * already set. So the shell environment wins over the files, the `supabase/` - * directory wins over the repo root, and earlier filenames win within a - * directory. A malformed `.env` — or one that exists but cannot be read — - * aborts: Go's `loadEnvIfExists` swallows only `os.ErrNotExist` and returns - * every other error. The path is named without leaking file contents - * (CWE-209-safe). + * Keys {@link legacyApplyProjectEnv} copies from the project `.env` into + * `process.env`. Kept to an allowlist of values that are read *only* via + * `process.env` (no project-env map path) and must reflect `supabase/.env` — + * currently just `SUPABASE_INTERNAL_IMAGE_REGISTRY` (`legacyGetRegistryImageUrl`). + * Everything else is read from {@link legacyLoadProjectEnv}'s returned map + * (`envLookup`, `legacyResolveYesWithProjectEnv`, `resolveDbPassword`) or resolved + * eagerly from the shell before any `.env` load — Go's root globals (workdir / + * profile / `SUPABASE_ENV` / project-ref) are frozen before `loadNestedEnv`, so + * writing them here would let our lazily-built resolvers diverge from Go (retarget + * the project, switch the env-file set, or leak into the Go `--experimental` proxy). + */ +const LEGACY_PROCESS_ENV_APPLY_KEYS = ["SUPABASE_INTERNAL_IMAGE_REGISTRY"] as const; + +/** + * Load the project's nested `.env` files into a lookup map. **Pure**: it reads the + * files and returns the merged map, with no `process.env` side effect — so the + * `SUPABASE_YES` / `SUPABASE_DB_PASSWORD` readers that call it + * (`legacyResolveYesWithProjectEnv`, `resolveDbPassword`) never mutate the global + * environment. Commands that need an allowlisted key visible to a synchronous + * `process.env` reader (`db dump` / `db pull` → `legacyGetRegistryImageUrl`) opt + * into {@link legacyApplyProjectEnv} around the container work instead. + * + * Partially mirrors Go's `loadNestedEnv` + `loadDefaultEnv` + * (`pkg/config/config.go:1047-1085`). Go walks from the `supabase/` directory up to + * the repo root and, in each directory, loads `.env..local`, `.env.local` + * (skipped when `SUPABASE_ENV=test`), `.env.`, then `.env` via `godotenv.Load`, + * which never overrides a value already set. So the shell environment wins over the + * files, the `supabase/` directory wins over the repo root, and earlier filenames + * win within a directory. A malformed `.env` — or one that exists but cannot be + * read — aborts: Go's `loadEnvIfExists` swallows only `os.ErrNotExist` and returns + * every other error. The path is named without leaking file contents (CWE-209-safe). */ export const legacyLoadProjectEnv = Effect.fnUntraced(function* ( fs: FileSystem.FileSystem, @@ -539,6 +559,42 @@ export const legacyLoadProjectEnv = Effect.fnUntraced(function* ( return loaded; }); +/** + * Apply the allowlisted project-`.env` keys (see {@link LEGACY_PROCESS_ENV_APPLY_KEYS}) + * to `process.env` **for the duration of the current scope**, then revert. This is + * the opt-in counterpart to the pure {@link legacyLoadProjectEnv}: `db dump` / + * `db pull` run it around their pg_dump / diff container work so a + * `SUPABASE_INTERNAL_IMAGE_REGISTRY` set in `supabase/.env` reaches + * `legacyGetRegistryImageUrl` (which reads `process.env` synchronously) — mirroring + * the `os.Setenv` half of Go's `loadNestedEnv`. Kept out of the shared loader so + * SUPABASE_YES / db-password reads stay side-effect-free. + * + * Never overrides an existing `process.env` value (Go's `godotenv.Load` never + * overrides; `loaded` already excludes keys present in `process.env`, and this + * re-checks). The `acquireRelease` finalizer deletes only the keys it set when the + * scope closes, so in-process test workers don't leak env between cases. + */ +export const legacyApplyProjectEnv = (loaded: Record) => + Effect.forEach( + LEGACY_PROCESS_ENV_APPLY_KEYS, + (key) => { + const value = loaded[key]; + if (value === undefined || process.env[key] !== undefined) { + return Effect.void; + } + return Effect.acquireRelease( + Effect.sync(() => { + process.env[key] = value; + }), + () => + Effect.sync(() => { + delete process.env[key]; + }), + ); + }, + { discard: true }, + ); + function nonEmptyString(value: unknown): Option.Option { return typeof value === "string" && value.length > 0 ? Option.some(value) : Option.none(); } diff --git a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.unit.test.ts b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.unit.test.ts index ade3dc5918..986c8a6075 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.unit.test.ts @@ -6,6 +6,7 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, Exit, FileSystem, Option, Path } from "effect"; import { + legacyApplyProjectEnv, legacyLoadProjectEnv, legacyReadDbToml, legacyResolveDeclarativeDir, @@ -1669,6 +1670,93 @@ describe("legacyReadDbToml", () => { }, ); + it.effect("legacyLoadProjectEnv is pure: returns every key and never touches process.env", () => { + // The loader is a pure read: it returns all project-.env keys in the map (config + // env() resolution + the SUPABASE_YES / db-password readers use the map) and does + // NOT mutate process.env. Applying to process.env is the separate, opt-in + // legacyApplyProjectEnv (below), so a mere `load` for SUPABASE_YES has no global + // side effect. + const saved: Record = {}; + for (const k of ["SUPABASE_INTERNAL_IMAGE_REGISTRY", "SUPABASE_PROJECT_ID", "SUPABASE_ENV"]) { + saved[k] = process.env[k]; + delete process.env[k]; + } + const dir = mkdtempSync(join(tmpdir(), "legacy-db-toml-")); + mkdirSync(join(dir, "supabase"), { recursive: true }); + writeFileSync( + join(dir, "supabase", ".env"), + "SUPABASE_INTERNAL_IMAGE_REGISTRY=my-mirror.example.com\nSUPABASE_PROJECT_ID=envonlyref\nSUPABASE_ENV=staging\n", + ); + return loadEnv(dir).pipe( + Effect.tap((env) => + Effect.sync(() => { + // The returned map carries all keys. + expect(env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]).toBe("my-mirror.example.com"); + expect(env["SUPABASE_PROJECT_ID"]).toBe("envonlyref"); + expect(env["SUPABASE_ENV"]).toBe("staging"); + // ...but process.env is untouched, including the allowlisted registry key. + expect(process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]).toBeUndefined(); + expect(process.env["SUPABASE_PROJECT_ID"]).toBeUndefined(); + expect(process.env["SUPABASE_ENV"]).toBeUndefined(); + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); + + it.effect( + "legacyApplyProjectEnv sets only the allowlisted key in-scope, never overrides, reverts on close", + () => { + // Go's loadNestedEnv os.Setenv's the project .env, but its root globals + // (project-ref, SUPABASE_ENV, workdir/profile) are resolved from the shell + // BEFORE loadNestedEnv. Our resolvers read process.env lazily, so we apply only + // the allowlisted `SUPABASE_INTERNAL_IMAGE_REGISTRY` (the one process.env-only + // reader): a .env project-ref must not retarget the lazy ref/pooler resolvers, + // and a .env SUPABASE_ENV must not switch the env-file set. + const saved: Record = {}; + for (const k of ["SUPABASE_INTERNAL_IMAGE_REGISTRY", "SUPABASE_PROJECT_ID", "SUPABASE_ENV"]) { + saved[k] = process.env[k]; + delete process.env[k]; + } + const loaded = { + SUPABASE_INTERNAL_IMAGE_REGISTRY: "my-mirror.example.com", + SUPABASE_PROJECT_ID: "envonlyref", + SUPABASE_ENV: "staging", + }; + return Effect.gen(function* () { + // Inside the scope: only the registry key is applied; the ref/env selector are not. + yield* Effect.scoped( + Effect.gen(function* () { + yield* legacyApplyProjectEnv(loaded); + expect(process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]).toBe("my-mirror.example.com"); + expect(process.env["SUPABASE_PROJECT_ID"]).toBeUndefined(); + expect(process.env["SUPABASE_ENV"]).toBeUndefined(); + }), + ); + // After the scope closes the applied key is reverted (no test-worker leak). + expect(process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]).toBeUndefined(); + + // An existing process.env value is never overridden, and is NOT deleted on close. + process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"] = "shell-wins.example.com"; + yield* Effect.scoped(legacyApplyProjectEnv(loaded)); + expect(process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]).toBe("shell-wins.example.com"); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + }), + ), + ); + }, + ); + it.effect("ignores a [db.pooler] connection_string in config.toml (Go reads .temp only)", () => { // The Go config field is tagged `toml:"-"`, so a connection_string in config.toml // is never honored; only supabase/.temp/pooler-url counts. diff --git a/apps/cli/src/legacy/shared/legacy-db-config.types.ts b/apps/cli/src/legacy/shared/legacy-db-config.types.ts index 951bc86107..11af4fc5d2 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.types.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.types.ts @@ -32,6 +32,29 @@ export interface LegacyDbConfigFlags { * flag (e.g. `test db`) omit it; the resolver then falls back to env only. */ readonly password?: Option.Option; + /** + * Optional explicit linked project ref override. Commands such as + * `gen types --project-id ` need the linked DB resolver's temp-role and + * pooler fallback behavior without requiring the current workdir to be linked. + * Absent for the normal `--linked` path, which still reads `.temp/project-ref`. + */ + readonly linkedProjectRef?: Option.Option; + /** + * Marks `linkedProjectRef` as an ad-hoc remote target supplied explicitly + * (e.g. `gen types --project-id `) rather than the current linked + * workdir. The ref may belong to a different project than the cwd, so the + * resolver must NOT inherit workdir-scoped credentials or cached state: + * - it ignores the ambient `SUPABASE_DB_PASSWORD` (shell / `.env*`) so it + * always mints a temporary login role instead of handing pg-meta an + * unrelated password, and + * - on an IPv4-only network it skips the saved `.temp/pooler-url` (which + * belongs to the linked workdir) and fetches the primary pooler config + * for `ref` from the Management API instead of failing with the IPv6 + * "run supabase link" suggestion. + * Absent / false for the normal `--linked` path, which is the workdir's own + * project and may legitimately reuse those env vars and saved files. + */ + readonly adHocProjectRef?: boolean; } /** diff --git a/apps/cli/src/legacy/shared/legacy-docker-registry.ts b/apps/cli/src/legacy/shared/legacy-docker-registry.ts index b18bf522f7..2534067128 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-registry.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-registry.ts @@ -13,11 +13,24 @@ * use `legacyGetRegistryImageUrlCandidates`: ECR stays the fast default, with * GHCR and the source image as fallbacks for transient registry throttling. */ +const LEGACY_INTERNAL_IMAGE_REGISTRY_ENV = "SUPABASE_INTERNAL_IMAGE_REGISTRY"; const DEFAULT_REGISTRY = "public.ecr.aws"; +const DEFAULT_SUPABASE_REGISTRY = `${DEFAULT_REGISTRY}/supabase`; const GHCR_REGISTRY = "ghcr.io"; +const GHCR_SUPABASE_REGISTRY = `${GHCR_REGISTRY}/supabase`; +const DOCKER_HUB_REGISTRY = "docker.io"; + +function dedupe(values: ReadonlyArray): ReadonlyArray { + return [...new Set(values)]; +} + +function getLastImageSegment(imageName: string): string { + const parts = imageName.split("/"); + return parts[parts.length - 1] ?? imageName; +} function legacyGetRegistryOverride(): string | undefined { - const registry = process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]; + const registry = process.env[LEGACY_INTERNAL_IMAGE_REGISTRY_ENV]?.trim(); return registry === undefined || registry.length === 0 ? undefined : registry.toLowerCase(); } @@ -27,35 +40,29 @@ function legacyGetRegistry(): string { export function legacyGetRegistryImageUrl(imageName: string): string { const registry = legacyGetRegistry(); - if (registry === "docker.io") { + if (registry === DOCKER_HUB_REGISTRY) { return imageName; } - const parts = imageName.split("/"); - const lastPart = parts[parts.length - 1] ?? imageName; - return `${registry}/supabase/${lastPart}`; + return `${registry}/supabase/${getLastImageSegment(imageName)}`; } export function legacyGetRegistryImageUrlCandidates(imageName: string): ReadonlyArray { if (legacyGetRegistryOverride() !== undefined) { return [legacyGetRegistryImageUrl(imageName)]; } - const parts = imageName.split("/"); - const lastPart = parts[parts.length - 1] ?? imageName; + + const lastPart = getLastImageSegment(imageName); return dedupe([ - `${DEFAULT_REGISTRY}/supabase/${lastPart}`, - `${GHCR_REGISTRY}/supabase/${lastPart}`, + legacyGetRegistryImageUrl(imageName), + `${GHCR_SUPABASE_REGISTRY}/${lastPart}`, dockerHubFallbackImage(imageName, lastPart), ]); } -function dedupe(values: ReadonlyArray): ReadonlyArray { - return [...new Set(values)]; -} - function dockerHubFallbackImage(imageName: string, lastPart: string): string { if ( - imageName.startsWith(`${DEFAULT_REGISTRY}/supabase/`) || - imageName.startsWith(`${GHCR_REGISTRY}/supabase/`) + imageName.startsWith(`${DEFAULT_SUPABASE_REGISTRY}/`) || + imageName.startsWith(`${GHCR_SUPABASE_REGISTRY}/`) ) { return `supabase/${lastPart}`; } diff --git a/apps/cli/src/legacy/shared/legacy-docker-registry.unit.test.ts b/apps/cli/src/legacy/shared/legacy-docker-registry.unit.test.ts index 5c38fddc55..21ef80a438 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-registry.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-registry.unit.test.ts @@ -24,6 +24,12 @@ describe("legacyGetRegistryImageUrl", () => { ); }); + it("treats a blank registry override as unset", () => { + expect(withRegistry(" ", () => legacyGetRegistryImageUrl("supabase/pg_prove:3.36"))).toBe( + "public.ecr.aws/supabase/pg_prove:3.36", + ); + }); + it("returns the image unchanged for docker.io (case-insensitive)", () => { expect( withRegistry("docker.io", () => legacyGetRegistryImageUrl("supabase/pg_prove:3.36")), @@ -74,5 +80,10 @@ describe("legacyGetRegistryImageUrl", () => { legacyGetRegistryImageUrlCandidates("supabase/postgres:17.6.1.138"), ), ).toEqual(["supabase/postgres:17.6.1.138"]); + expect( + withRegistry("my.mirror.example", () => + legacyGetRegistryImageUrlCandidates("supabase/postgres:17.6.1.138"), + ), + ).toEqual(["my.mirror.example/supabase/postgres:17.6.1.138"]); }); }); diff --git a/apps/cli/src/legacy/shared/legacy-edge-runtime-image.ts b/apps/cli/src/legacy/shared/legacy-edge-runtime-image.ts index da146970ba..6bc21cbfb1 100644 --- a/apps/cli/src/legacy/shared/legacy-edge-runtime-image.ts +++ b/apps/cli/src/legacy/shared/legacy-edge-runtime-image.ts @@ -6,14 +6,14 @@ import { Effect, type FileSystem, type Path } from "effect"; * declarative pg-delta scripts that run inside the edge-runtime container. * * The default tag is baked into the Go binary via the embedded Dockerfile - * (`FROM supabase/edge-runtime:v1.74.1 AS edgeruntime`), mirrored here as a + * (`FROM supabase/edge-runtime:v1.74.2 AS edgeruntime`), mirrored here as a * constant. A pinned tag in `supabase/.temp/edge-runtime-version` overrides it * (written by `supabase start`). `edge_runtime.deno_version = 1` selects the - * legacy `deno1` image instead (default `deno_version = 2` keeps v1.74.1). + * legacy `deno1` image instead (default `deno_version = 2` keeps v1.74.2). */ -// `FROM supabase/edge-runtime:v1.74.1 AS edgeruntime` (embedded Dockerfile). -export const LEGACY_EDGE_RUNTIME_IMAGE = "supabase/edge-runtime:v1.74.1"; +// `FROM supabase/edge-runtime:v1.74.2 AS edgeruntime` (embedded Dockerfile). +export const LEGACY_EDGE_RUNTIME_IMAGE = "supabase/edge-runtime:v1.74.2"; // `deno1` (`pkg/config/constants.go:15`) — used when `deno_version = 1`. const LEGACY_EDGE_RUNTIME_DENO1_IMAGE = "supabase/edge-runtime:v1.68.4"; @@ -26,7 +26,7 @@ function replaceImageTag(image: string, tag: string): string { /** * Resolve the edge-runtime image, honoring the pinned tag in * `supabase/.temp/edge-runtime-version` and the `deno_version` selector - * (default 2 → v1.74.1; 1 → `deno1`). The version pin is applied first (Go's + * (default 2 → v1.74.2; 1 → `deno1`). The version pin is applied first (Go's * `Load`), then `deno_version = 1` overrides to `deno1` (Go's validate pass). */ export const legacyResolveEdgeRuntimeImage = Effect.fnUntraced(function* ( diff --git a/apps/cli/src/legacy/shared/legacy-edge-runtime-image.unit.test.ts b/apps/cli/src/legacy/shared/legacy-edge-runtime-image.unit.test.ts index 5565da4153..9b15eecf24 100644 --- a/apps/cli/src/legacy/shared/legacy-edge-runtime-image.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-edge-runtime-image.unit.test.ts @@ -15,12 +15,12 @@ const resolve = (workdir: string, denoVersion: number) => }).pipe(Effect.provide(BunServices.layer)); describe("legacyResolveEdgeRuntimeImage", () => { - it.effect("returns the default v1.74.1 image when nothing is pinned", () => { + it.effect("returns the default v1.74.2 image when nothing is pinned", () => { const dir = mkdtempSync(join(tmpdir(), "legacy-edge-img-")); return resolve(dir, 2).pipe( Effect.tap((image) => Effect.sync(() => { - expect(image).toBe("supabase/edge-runtime:v1.74.1"); + expect(image).toBe("supabase/edge-runtime:v1.74.2"); rmSync(dir, { recursive: true, force: true }); }), ), diff --git a/apps/cli/src/legacy/shared/legacy-experimental-gate.ts b/apps/cli/src/legacy/shared/legacy-experimental-gate.ts index 9d2973fcef..325f2e96bb 100644 --- a/apps/cli/src/legacy/shared/legacy-experimental-gate.ts +++ b/apps/cli/src/legacy/shared/legacy-experimental-gate.ts @@ -14,11 +14,15 @@ import { legacyResolveExperimental } from "../../shared/legacy/global-flags.ts"; * * `IsExperimental` is true for the commands registered in the `experimental` * slice and their direct children (`root.go:56-74`), which includes `storageCmd`. - * Go enforces this in `PersistentPreRunE` — after cobra's arg + flag-group - * validation but before `RunE`/`PersistentPostRun` — so a closed gate must NOT - * emit `cli_command_executed` or write the telemetry/linked-project files. Each - * native experimental leaf therefore calls this in its `.command.ts` AFTER the - * mutual-exclusivity check and BEFORE `withLegacyCommandInstrumentation`. + * Go enforces this in `PersistentPreRunE`, which cobra runs BEFORE + * `ValidateFlagGroups()` (mutual-exclusivity checks) and `RunE`/`PersistentPostRun` + * (`cobra@v1.10.2/command.go:985,1010,1014`) — so a closed gate must NOT run + * mutual-exclusivity checks, emit `cli_command_executed`, or write the + * telemetry/linked-project files. (Cobra's positional-argument count/type + * validation, `ValidateArgs`, runs even earlier, at `command.go:968` — the gate + * does not preempt that.) Each native experimental leaf therefore calls this in + * its `.command.ts` before any mutual-exclusivity check and before + * `withLegacyCommandInstrumentation`. * * The message byte-matches Go's `errors.New(...)`; the value is resolved with the * `SUPABASE_EXPERIMENTAL` viper fallback (see {@link legacyResolveExperimental}). diff --git a/apps/cli/src/legacy/shared/legacy-pgdelta-ssl-probe.integration.test.ts b/apps/cli/src/legacy/shared/legacy-pgdelta-ssl-probe.integration.test.ts new file mode 100644 index 0000000000..5f94b66971 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-pgdelta-ssl-probe.integration.test.ts @@ -0,0 +1,67 @@ +import { createServer } from "node:net"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, Layer } from "effect"; + +import { LegacyDebugFlag } from "../../shared/legacy/global-flags.ts"; +import { legacyPgDeltaSslProbeLayer } from "./legacy-pgdelta-ssl-probe.layer.ts"; +import { + LegacyPgDeltaSslProbe, + LegacyPgDeltaSslProbeError, +} from "./legacy-pgdelta-ssl-probe.service.ts"; + +async function withClosingServer(run: (port: number) => Promise): Promise { + const server = createServer((socket) => { + socket.destroy(); + }); + + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => resolve()); + }); + + const address = server.address(); + if (address === null || typeof address === "string") { + server.close(); + throw new Error("failed to bind closing server"); + } + + try { + return await run(address.port); + } finally { + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + } +} + +describe("legacyPgDeltaSslProbeLayer", () => { + it.live("fails promptly when the socket closes before an SSL response byte", () => + Effect.tryPromise({ + try: () => + withClosingServer((port) => + Effect.runPromise( + Effect.gen(function* () { + const probe = yield* LegacyPgDeltaSslProbe; + const exit = yield* probe.requireSslForHost("127.0.0.1", port).pipe( + Effect.timeoutOrElse({ + duration: "1 second", + orElse: () => Effect.fail(new Error("probe did not settle after socket close")), + }), + Effect.exit, + ); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(String(exit.cause)).toContain(LegacyPgDeltaSslProbeError.name); + expect(String(exit.cause)).toContain("closed before the server responded"); + } + }).pipe( + Effect.provide(legacyPgDeltaSslProbeLayer), + Effect.provide(Layer.succeed(LegacyDebugFlag, false)), + ), + ), + ), + catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + }), + ); +}); diff --git a/apps/cli/src/legacy/shared/legacy-pgdelta-ssl-probe.layer.ts b/apps/cli/src/legacy/shared/legacy-pgdelta-ssl-probe.layer.ts index c68555b3a2..4d2e0f02b5 100644 --- a/apps/cli/src/legacy/shared/legacy-pgdelta-ssl-probe.layer.ts +++ b/apps/cli/src/legacy/shared/legacy-pgdelta-ssl-probe.layer.ts @@ -49,6 +49,10 @@ export function legacyParseSslProbeTarget(dbUrl: string): LegacySslProbeTarget { return { host, port, timeoutMs }; } +function legacySslProbeTargetForHost(host: string, port: number): LegacySslProbeTarget { + return { host, port, timeoutMs: DEFAULT_PROBE_TIMEOUT_MS }; +} + /** * Interprets the server's single-byte `SSLRequest` reply: `S` → speaks TLS, * `N` → refused TLS (Go's `"server refused TLS connection"`). Any other byte is a @@ -76,6 +80,64 @@ export const legacyPgDeltaSslProbeLayer = Layer.effect( // Go disables SSL in debug mode (`require := !viper.GetBool("DEBUG")`), so a // server that speaks TLS still reports "not required" under `--debug`. const debug = yield* LegacyDebugFlag; + const probeTarget = (target: LegacySslProbeTarget) => + Effect.gen(function* () { + const outcome = yield* Effect.callback<"tls" | "refused", LegacyPgDeltaSslProbeError>( + (resume) => { + const socket = net.connect({ host: target.host, port: target.port }); + let settled = false; + const settle = ( + effect: Effect.Effect<"tls" | "refused", LegacyPgDeltaSslProbeError>, + ) => { + if (settled) return; + settled = true; + socket.destroy(); + resume(effect); + }; + socket.setTimeout(target.timeoutMs); + socket.once("connect", () => socket.write(SSL_REQUEST_PACKET)); + socket.once("data", (buf: Buffer) => { + try { + settle(Effect.succeed(legacyInterpretSslProbeByte(buf[0]))); + } catch (cause) { + settle( + Effect.fail( + cause instanceof LegacyPgDeltaSslProbeError + ? cause + : new LegacyPgDeltaSslProbeError({ message: String(cause), cause }), + ), + ); + } + }); + socket.once("timeout", () => + settle( + Effect.fail( + new LegacyPgDeltaSslProbeError({ + message: `SSL probe timed out connecting to ${target.host}:${target.port}`, + }), + ), + ), + ); + socket.once("close", () => + settle( + Effect.fail( + new LegacyPgDeltaSslProbeError({ + message: `SSL probe connection to ${target.host}:${target.port} closed before the server responded`, + }), + ), + ), + ); + socket.once("error", (err: Error) => + settle( + Effect.fail(new LegacyPgDeltaSslProbeError({ message: err.message, cause: err })), + ), + ); + return Effect.sync(() => socket.destroy()); + }, + ); + if (outcome === "refused") return false; + return !debug; + }); return LegacyPgDeltaSslProbe.of({ requireSsl: (dbUrl) => Effect.gen(function* () { @@ -88,51 +150,9 @@ export const legacyPgDeltaSslProbeLayer = Layer.effect( }`, }), }); - const outcome = yield* Effect.callback<"tls" | "refused", LegacyPgDeltaSslProbeError>( - (resume) => { - const socket = net.connect({ host: target.host, port: target.port }); - let settled = false; - const settle = ( - effect: Effect.Effect<"tls" | "refused", LegacyPgDeltaSslProbeError>, - ) => { - if (settled) return; - settled = true; - socket.destroy(); - resume(effect); - }; - socket.setTimeout(target.timeoutMs); - socket.once("connect", () => socket.write(SSL_REQUEST_PACKET)); - socket.once("data", (buf: Buffer) => { - try { - settle(Effect.succeed(legacyInterpretSslProbeByte(buf[0]))); - } catch (cause) { - settle( - Effect.fail( - cause instanceof LegacyPgDeltaSslProbeError - ? cause - : new LegacyPgDeltaSslProbeError({ message: String(cause) }), - ), - ); - } - }); - socket.once("timeout", () => - settle( - Effect.fail( - new LegacyPgDeltaSslProbeError({ - message: `SSL probe timed out connecting to ${target.host}:${target.port}`, - }), - ), - ), - ); - socket.once("error", (err: Error) => - settle(Effect.fail(new LegacyPgDeltaSslProbeError({ message: err.message }))), - ); - return Effect.sync(() => socket.destroy()); - }, - ); - if (outcome === "refused") return false; - return !debug; + return yield* probeTarget(target); }), + requireSslForHost: (host, port) => probeTarget(legacySslProbeTargetForHost(host, port)), }); }), ); diff --git a/apps/cli/src/legacy/shared/legacy-pgdelta-ssl-probe.service.ts b/apps/cli/src/legacy/shared/legacy-pgdelta-ssl-probe.service.ts index 808bcd441b..d5dca259f5 100644 --- a/apps/cli/src/legacy/shared/legacy-pgdelta-ssl-probe.service.ts +++ b/apps/cli/src/legacy/shared/legacy-pgdelta-ssl-probe.service.ts @@ -23,11 +23,16 @@ export interface LegacyPgDeltaSslProbeShape { * debug mode). Fails for any other connection error, matching Go's `return false, err`. */ readonly requireSsl: (dbUrl: string) => Effect.Effect; + readonly requireSslForHost: ( + host: string, + port: number, + ) => Effect.Effect; } /** A non-TLS-refusal connection failure during the SSL probe (Go's propagated `err`). */ export class LegacyPgDeltaSslProbeError extends Data.TaggedError("LegacyPgDeltaSslProbeError")<{ readonly message: string; + readonly cause?: unknown; }> {} export class LegacyPgDeltaSslProbe extends Context.Service< diff --git a/apps/cli/src/legacy/shared/legacy-pgdelta-ssl.unit.test.ts b/apps/cli/src/legacy/shared/legacy-pgdelta-ssl.unit.test.ts index d9949c8d06..c91cbc1afe 100644 --- a/apps/cli/src/legacy/shared/legacy-pgdelta-ssl.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-pgdelta-ssl.unit.test.ts @@ -69,6 +69,10 @@ const probeLayer = (requireSsl: boolean | "error") => requireSsl === "error" ? Effect.fail(new LegacyPgDeltaSslProbeError({ message: "connection refused" })) : Effect.succeed(requireSsl), + requireSslForHost: () => + requireSsl === "error" + ? Effect.fail(new LegacyPgDeltaSslProbeError({ message: "connection refused" })) + : Effect.succeed(requireSsl), }); const prepare = (cwd: string, ref: string, requireSsl: boolean | "error" = false) => diff --git a/apps/cli/src/legacy/shared/legacy-pooler-fallback.ts b/apps/cli/src/legacy/shared/legacy-pooler-fallback.ts new file mode 100644 index 0000000000..611d6630d5 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-pooler-fallback.ts @@ -0,0 +1,71 @@ +import { Effect, Option } from "effect"; + +import { Output } from "../../shared/output/output.service.ts"; +import { legacyYellow } from "./legacy-colors.ts"; +import type { LegacyPgConnInput } from "./legacy-db-connection.service.ts"; + +export function legacyIsDirectDbHost(host: string, projectHost: string): boolean { + return host.startsWith("db.") && host.endsWith(`.${projectHost}`); +} + +export interface LegacyPoolerFallbackOptions { + readonly run: Effect.Effect; + readonly retry: (pooler: LegacyPgConnInput) => Effect.Effect; + readonly directHost: string; + readonly eligible: boolean; + readonly resolveFallback: Effect.Effect, unknown, RF>; + readonly classifyError?: (error: E) => boolean; + readonly classifyResult?: (result: A) => boolean; +} + +/** + * Go's IPv6 pooler-fallback warning (`internal/utils/connect.go:283-289`), to stderr, + * `Yellow`-wrapped, byte-for-byte. Emitted just before the IPv4 pooler retry. + */ +export const legacyEmitPoolerFallbackWarning = (host: string): Effect.Effect => + Effect.gen(function* () { + const output = yield* Output; + yield* output.raw( + `${legacyYellow( + `Warning: Direct connection to ${host} is unavailable because this environment does not support IPv6.\nRetrying via the IPv4 connection pooler.`, + )}\n`, + "stderr", + ); + }); + +export function legacyRunWithPoolerFallback( + options: LegacyPoolerFallbackOptions, +): Effect.Effect { + const resolveFallback = options.resolveFallback.pipe( + Effect.orElseSucceed(() => Option.none()), + ); + + const retryOrReturn = (result: A) => + Effect.gen(function* () { + const pooler = yield* resolveFallback; + if (Option.isNone(pooler)) return result; + yield* legacyEmitPoolerFallbackWarning(options.directHost); + return yield* options.retry(pooler.value); + }); + + const retryOrFail = (error: E) => + Effect.gen(function* () { + const pooler = yield* resolveFallback; + if (Option.isNone(pooler)) return yield* Effect.fail(error); + yield* legacyEmitPoolerFallbackWarning(options.directHost); + return yield* options.retry(pooler.value); + }); + + const shouldRetryResult = (result: A): boolean => + options.eligible && (options.classifyResult?.(result) ?? false); + const shouldRetryError = (error: E): boolean => + options.eligible && (options.classifyError?.(error) ?? false); + + return options.run.pipe( + Effect.matchEffect({ + onFailure: (error) => (shouldRetryError(error) ? retryOrFail(error) : Effect.fail(error)), + onSuccess: (result) => + shouldRetryResult(result) ? retryOrReturn(result) : Effect.succeed(result), + }), + ); +} diff --git a/apps/cli/src/legacy/shared/legacy-pooler-fallback.unit.test.ts b/apps/cli/src/legacy/shared/legacy-pooler-fallback.unit.test.ts new file mode 100644 index 0000000000..313b7db349 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-pooler-fallback.unit.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, Layer, Option } from "effect"; + +import { Output } from "../../shared/output/output.service.ts"; +import type { LegacyPgConnInput } from "./legacy-db-connection.service.ts"; +import { legacyRunWithPoolerFallback } from "./legacy-pooler-fallback.ts"; + +interface AttemptResult { + readonly exitCode: number; + readonly stderr: string; +} + +const poolerConn: LegacyPgConnInput = { + host: "aws-0-us-east-1.pooler.supabase.com", + port: 5432, + user: "postgres.abcdefghijklmnopqrst", + password: "secret", + database: "postgres", +}; + +function captureOutput() { + const chunks: Array<{ text: string; stream: "stdout" | "stderr" }> = []; + return { + layer: Layer.succeed(Output, { + format: "text", + interactive: true, + intro: () => Effect.void, + outro: () => Effect.void, + info: () => Effect.void, + warn: () => Effect.void, + error: () => Effect.void, + event: () => Effect.void, + task: () => + Effect.succeed({ + message: () => Effect.void, + succeed: () => Effect.void, + fail: () => Effect.void, + info: () => Effect.void, + cancel: () => Effect.void, + clear: () => Effect.void, + }), + promptText: () => Effect.die("unexpected promptText"), + promptPassword: () => Effect.die("unexpected promptPassword"), + promptConfirm: () => Effect.die("unexpected promptConfirm"), + promptSelect: () => Effect.die("unexpected promptSelect"), + promptMultiSelect: () => Effect.die("unexpected promptMultiSelect"), + progress: () => + Effect.succeed({ + start: () => Effect.void, + advance: () => Effect.void, + message: () => Effect.void, + stop: () => Effect.void, + }), + success: () => Effect.void, + fail: () => Effect.void, + raw: (text: string, stream: "stdout" | "stderr" = "stdout") => + Effect.sync(() => { + chunks.push({ text, stream }); + }), + rawBytes: (bytes: Uint8Array, stream: "stdout" | "stderr" = "stdout") => + Effect.sync(() => { + chunks.push({ text: new TextDecoder().decode(bytes), stream }); + }), + }), + get stderrText() { + return chunks + .filter((chunk) => chunk.stream === "stderr") + .map((chunk) => chunk.text) + .join(""); + }, + }; +} + +describe("legacyRunWithPoolerFallback", () => { + it.live("returns the retry outcome verbatim without re-classifying it", () => { + const out = captureOutput(); + let fallbackResolutions = 0; + let retryRuns = 0; + + return Effect.gen(function* () { + const result = yield* legacyRunWithPoolerFallback({ + run: Effect.succeed({ exitCode: 1, stderr: "network is unreachable" }), + retry: () => + Effect.sync(() => { + retryRuns += 1; + return { exitCode: 1, stderr: "network is unreachable" }; + }), + directHost: "db.abcdefghijklmnopqrst.supabase.co", + eligible: true, + resolveFallback: Effect.sync(() => { + fallbackResolutions += 1; + return Option.some(poolerConn); + }), + classifyResult: (result: AttemptResult) => result.exitCode !== 0, + }); + + expect(result).toEqual({ exitCode: 1, stderr: "network is unreachable" }); + expect(fallbackResolutions).toBe(1); + expect(retryRuns).toBe(1); + expect(out.stderrText).toContain( + "Warning: Direct connection to db.abcdefghijklmnopqrst.supabase.co is unavailable", + ); + }).pipe(Effect.provide(out.layer)); + }); + + it.live("propagates a result-path retry failure without retrying a second time", () => { + const out = captureOutput(); + const retryError = new Error("network is unreachable"); + let fallbackResolutions = 0; + let retryRuns = 0; + + return Effect.gen(function* () { + const exit = yield* legacyRunWithPoolerFallback({ + run: Effect.succeed({ exitCode: 1, stderr: "network is unreachable" }), + retry: () => + Effect.sync(() => { + retryRuns += 1; + }).pipe(Effect.andThen(Effect.fail(retryError))), + directHost: "db.abcdefghijklmnopqrst.supabase.co", + eligible: true, + resolveFallback: Effect.sync(() => { + fallbackResolutions += 1; + return Option.some(poolerConn); + }), + classifyResult: (result: AttemptResult) => result.exitCode !== 0, + classifyError: (error: Error) => error === retryError, + }).pipe(Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + expect(fallbackResolutions).toBe(1); + expect(retryRuns).toBe(1); + expect(out.stderrText.match(/Retrying via the IPv4 connection pooler/g)).toHaveLength(1); + }).pipe(Effect.provide(out.layer)); + }); + + it.live("does not resolve a fallback when the failure is not eligible", () => { + const out = captureOutput(); + let fallbackResolutions = 0; + + return Effect.gen(function* () { + const result = yield* legacyRunWithPoolerFallback({ + run: Effect.succeed({ exitCode: 1, stderr: "network is unreachable" }), + retry: () => Effect.succeed({ exitCode: 0, stderr: "" }), + directHost: "aws-0-us-east-1.pooler.supabase.com", + eligible: false, + resolveFallback: Effect.sync(() => { + fallbackResolutions += 1; + return Option.some(poolerConn); + }), + classifyResult: (result: AttemptResult) => result.exitCode !== 0, + }); + + expect(result).toEqual({ exitCode: 1, stderr: "network is unreachable" }); + expect(fallbackResolutions).toBe(0); + expect(out.stderrText).toBe(""); + }).pipe(Effect.provide(out.layer)); + }); + + it.live("falls back from the error channel when the caller classifies the cause", () => { + const out = captureOutput(); + const directError = new Error("probe failed"); + + return Effect.gen(function* () { + const result = yield* legacyRunWithPoolerFallback({ + run: Effect.fail(directError), + retry: () => Effect.succeed({ exitCode: 0, stderr: "" }), + directHost: "db.abcdefghijklmnopqrst.supabase.co", + eligible: true, + resolveFallback: Effect.succeed(Option.some(poolerConn)), + classifyError: (error: Error) => error === directError, + }); + + expect(result).toEqual({ exitCode: 0, stderr: "" }); + expect(out.stderrText).toContain("Retrying via the IPv4 connection pooler."); + }).pipe(Effect.provide(out.layer)); + }); +}); diff --git a/apps/cli/src/legacy/shared/legacy-prompt-yes-no.ts b/apps/cli/src/legacy/shared/legacy-prompt-yes-no.ts index 13ad746906..64ba6368a6 100644 --- a/apps/cli/src/legacy/shared/legacy-prompt-yes-no.ts +++ b/apps/cli/src/legacy/shared/legacy-prompt-yes-no.ts @@ -1,16 +1,40 @@ -import { Effect } from "effect"; +import { Effect, Option } from "effect"; import { Output } from "../../shared/output/output.service.ts"; +import { Stdin } from "../../shared/runtime/stdin.service.ts"; +import { Tty } from "../../shared/runtime/tty.service.ts"; + +/** Go's non-TTY `Console.ReadLine` timeout (`internal/utils/console.go:36`). */ +const LEGACY_NON_TTY_TIMEOUT_MILLIS = 100; + +/** + * Port of Go's `parseYesNo` (`apps/cli-go/internal/utils/console.go:84-93`): + * case-insensitive and trimmed. `y`/`yes` → `true`, `n`/`no` → `false`, + * anything else → `undefined` (caller falls back to the default). + */ +export const legacyParseYesNo = (input: string): boolean | undefined => { + const s = input.trim().toLowerCase(); + if (s === "y" || s === "yes") { + return true; + } + if (s === "n" || s === "no") { + return false; + } + return undefined; +}; /** * Confirm-or-default prompt mirroring Go's `console.PromptYesNo` - * (`apps/cli-go/internal/utils/console.go`), shared by `seed buckets` and - * `storage rm`: + * (`apps/cli-go/internal/utils/console.go:64-82`), shared by `config push`, + * `db pull`, `seed buckets`, and `storage rm`: * - when `yes` is set, echoes `