diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 0000000000..e9c0d32e94 --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,6 @@ +self-hosted-runner: + labels: + - blacksmith-32vcpu-ubuntu-2404 + - blacksmith-8vcpu-ubuntu-2404 + - blacksmith-6vcpu-macos-latest + - blacksmith-8vcpu-windows-2025 diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 0889e6501e..ee905b6558 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,16 +5,22 @@ updates: schedule: interval: "cron" cronjob: "0 0 * * *" + commit-message: + prefix: "chore(ci): " groups: actions-major: patterns: - "*" + ignore: + - dependency-name: "supabase/setup-cli" + update-types: + - "version-update:semver-major" cooldown: default-days: 7 - package-ecosystem: "gomod" directories: - - "/" - - "pkg" + - "/apps/cli-go" + - "/apps/cli-go/pkg" schedule: interval: "cron" cronjob: "0 0 * * *" @@ -57,3 +63,5 @@ updates: - dependency-name: "timberio/vector" cooldown: default-days: 7 + exclude: + - "supabase/*" diff --git a/.github/workflows/apply-release-notes.yml b/.github/workflows/apply-release-notes.yml new file mode 100644 index 0000000000..0c75eb0ca2 --- /dev/null +++ b/.github/workflows/apply-release-notes.yml @@ -0,0 +1,113 @@ +name: Apply release notes + +# Approval-based publish. When a member of the supabase/cli team approves a +# release-notes PR (head ref `release-notes/v`), this workflow pushes +# the proposed notes to the GitHub Release body for the corresponding tag, +# comments the release URL on the PR, and closes the PR without merging. The +# release-notes PR targets `develop` (not `main`) so an accidental merge can +# never rewrite `main`'s history; the file is not meant to land on any branch. +# +# Mirrors the fast-forward job in release.yml, which already gates on a +# `pull_request_review` + `approved` event. + +on: + pull_request_review: + types: [submitted] + +permissions: + contents: read + +jobs: + authorize: + # `state == 'open'` makes re-approvals on an already-closed PR a no-op + # (a reviewer can re-approve from the GitHub UI even after close). + if: | + github.event.review.state == 'approved' && + startsWith(github.event.pull_request.head.ref, 'release-notes/') && + github.event.pull_request.base.ref == 'develop' && + github.event.pull_request.state == 'open' + runs-on: ubuntu-latest + permissions: + pull-requests: write + outputs: + authorized: ${{ steps.check.outputs.authorized }} + steps: + # App token: needs `orgs/.../teams/.../memberships` read (the org-installed + # App has it), repo write to edit the release, and PR write to comment + # and close. Matches release.yml's fast-forward step. + - 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 }} + + - name: Authorize approver against supabase/cli team + id: check + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + APPROVER: ${{ github.event.review.user.login }} + PR_NUMBER: ${{ github.event.pull_request.number }} + # Fail closed: any response other than an active membership means the + # approval is ignored. We post a comment so the reviewer sees why their + # approval didn't apply, then exit 0 so the workflow isn't flagged red. + run: | + set -euo pipefail + status=$(gh api \ + -H "Accept: application/vnd.github+json" \ + "orgs/supabase/teams/cli/memberships/${APPROVER}" \ + --jq '.state' 2>/dev/null || true) + if [ "$status" != "active" ]; then + echo "Approver @${APPROVER} is not an active supabase/cli team member (state='${status:-none}'); ignoring approval." >&2 + gh pr comment "$PR_NUMBER" --repo "${{ github.repository }}" --body \ + "@${APPROVER} is not an active \`supabase/cli\` team member, so this approval was ignored. Ask a team member to approve to publish the notes." + echo "authorized=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "authorized=true" >> "$GITHUB_OUTPUT" + + apply: + needs: authorize + if: needs.authorize.outputs.authorized == 'true' + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: 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 }} + + # 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 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 1 + persist-credentials: false + + - uses: ./.github/actions/setup + + - name: Apply notes, comment, and close + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + HEAD_REF: ${{ github.event.pull_request.head.ref }} + PR_NUMBER: ${{ github.event.pull_request.number }} + APPROVER: ${{ github.event.review.user.login }} + # The branch is named `release-notes/v`, so the tag is just + # the basename. apply-release-notes.ts validates the file's existence. + run: | + set -euo pipefail + tag="${HEAD_REF##release-notes/}" + if [[ ! "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-(beta|alpha)\.[0-9]+)?$ ]]; then + echo "Unexpected head ref '$HEAD_REF'; cannot derive tag." >&2 + exit 1 + fi + echo "==> Applying notes for $tag" + pnpm exec bun apps/cli/scripts/apply-release-notes.ts --tag "$tag" + release_url="https://github.com/${{ github.repository }}/releases/tag/${tag}" + gh pr comment "$PR_NUMBER" --repo "${{ github.repository }}" --body \ + "Applied to [${tag}](${release_url}) after approval by @${APPROVER}." + gh pr close "$PR_NUMBER" --repo "${{ github.repository }}" --delete-branch diff --git a/.github/workflows/automerge.yml b/.github/workflows/automerge.yml index cafc5eedca..0089bccc5e 100644 --- a/.github/workflows/automerge.yml +++ b/.github/workflows/automerge.yml @@ -24,7 +24,7 @@ jobs: - name: Generate token id: app-token if: ${{ steps.meta.outputs.update-type == null || steps.meta.outputs.update-type == 'version-update:semver-patch' || (!startsWith(steps.meta.outputs.previous-version, '0.') && steps.meta.outputs.update-type == 'version-update:semver-minor') }} - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + 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 }} diff --git a/.github/workflows/build-cli-artifacts.yml b/.github/workflows/build-cli-artifacts.yml new file mode 100644 index 0000000000..db2812719b --- /dev/null +++ b/.github/workflows/build-cli-artifacts.yml @@ -0,0 +1,88 @@ +name: Build CLI Artifacts + +on: + workflow_call: + inputs: + version: + description: CLI package version to build + required: true + type: string + shell: + description: CLI shell to package as the shipped supabase binary + required: true + type: string + ref: + description: Optional git ref or SHA to check out before building + required: false + type: string + default: "" + secrets: + SENTRY_DSN: + required: false + POSTHOG_API_KEY: + required: false + POSTHOG_ENDPOINT: + required: false + +permissions: + contents: read + +jobs: + build: + name: Build CLI artifacts + runs-on: blacksmith-32vcpu-ubuntu-2404 + env: + BUN_SHELL: ${{ inputs.shell }} + VERSION: ${{ inputs.version }} + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }} + POSTHOG_ENDPOINT: ${{ secrets.POSTHOG_ENDPOINT }} + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ inputs.ref }} + persist-credentials: false + + - name: Setup + uses: ./.github/actions/setup + + - name: Setup Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.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 + + - name: Install nfpm + run: | + echo 'deb [trusted=yes] https://repo.goreleaser.com/apt/ /' | sudo tee /etc/apt/sources.list.d/goreleaser.list + sudo apt-get update + sudo apt-get install -y nfpm + + - name: Sync versions + run: pnpm exec bun apps/cli/scripts/sync-versions.ts --version "${VERSION}" + + - name: Build selected shell + run: pnpm exec bun apps/cli/scripts/build.ts --version "${VERSION}" --shell "${BUN_SHELL}" + + - name: Verify build artifacts + run: | + for pkg in cli-darwin-arm64 cli-darwin-x64 cli-linux-arm64 cli-linux-arm64-musl cli-linux-x64 cli-linux-x64-musl cli-windows-arm64 cli-windows-x64; do + echo "Checking packages/$pkg/bin/..." + ls -la "packages/$pkg/bin/" + done + echo "Checking dist/..." + ls -la dist/ + + - name: Upload build artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: cli-build-${{ inputs.shell }}-${{ inputs.version }} + path: | + packages/cli-*/bin/ + dist/ diff --git a/.github/workflows/cli-go-api-sync.yml b/.github/workflows/cli-go-api-sync.yml index 7bd7f4bf87..28dc0ee79a 100644 --- a/.github/workflows/cli-go-api-sync.yml +++ b/.github/workflows/cli-go-api-sync.yml @@ -39,7 +39,7 @@ jobs: - name: Generate token id: app-token - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + 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 }} diff --git a/.github/workflows/cli-go-ci.yml b/.github/workflows/cli-go-ci.yml index 58482c2e39..0be482bcb5 100644 --- a/.github/workflows/cli-go-ci.yml +++ b/.github/workflows/cli-go-ci.yml @@ -70,7 +70,7 @@ jobs: # Linter requires no cache cache: false - - uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0 + - uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee # v9.2.1 with: args: --timeout 5m --verbose version: latest diff --git a/.github/workflows/cli-go-codeql.yml b/.github/workflows/cli-go-codeql.yml index 9b5f9168e7..57222ed09b 100644 --- a/.github/workflows/cli-go-codeql.yml +++ b/.github/workflows/cli-go-codeql.yml @@ -69,7 +69,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2 + uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -97,7 +97,7 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2 + uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 with: category: "/language:${{matrix.language}}" defaults: diff --git a/.github/workflows/cli-go-mirror-image.yml b/.github/workflows/cli-go-mirror-image.yml index 541f89cc3d..489d29657a 100644 --- a/.github/workflows/cli-go-mirror-image.yml +++ b/.github/workflows/cli-go-mirror-image.yml @@ -34,14 +34,14 @@ jobs: run: | echo "image=${TAG##*/}" >> $GITHUB_OUTPUT - name: configure aws credentials - uses: aws-actions/configure-aws-credentials@ec61189d14ec14c8efccab744f656cffd0e33f37 # v6.1.0 + uses: aws-actions/configure-aws-credentials@99214aa6889fcddfa57764031d71add364327e59 # v6.1.3 with: role-to-assume: ${{ secrets.PROD_AWS_ROLE }} aws-region: us-east-1 - - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: registry: public.ecr.aws - - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: registry: ghcr.io username: ${{ github.actor }} diff --git a/.github/workflows/cli-go-pg-prove.yml b/.github/workflows/cli-go-pg-prove.yml index 1dff403bfc..d94a47448f 100644 --- a/.github/workflows/cli-go-pg-prove.yml +++ b/.github/workflows/cli-go-pg-prove.yml @@ -13,8 +13,8 @@ jobs: outputs: image_tag: supabase/pg_prove:${{ steps.version.outputs.pg_prove }} steps: - - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - - uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 with: load: true context: https://github.com/horrendo/pg_prove.git @@ -44,15 +44,15 @@ jobs: image_digest: ${{ steps.build.outputs.digest }} steps: - run: docker context create builders - - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 with: endpoint: builders - - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - id: build - uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 with: push: true context: https://github.com/horrendo/pg_prove.git @@ -68,8 +68,8 @@ jobs: - build_image runs-on: ubuntu-latest steps: - - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} diff --git a/.github/workflows/cli-go-publish-migra.yml b/.github/workflows/cli-go-publish-migra.yml index d62bc19ebc..37f5ca2ad5 100644 --- a/.github/workflows/cli-go-publish-migra.yml +++ b/.github/workflows/cli-go-publish-migra.yml @@ -13,8 +13,8 @@ jobs: outputs: image_tag: supabase/migra:${{ steps.version.outputs.migra }} steps: - - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - - uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 with: load: true context: https://github.com/djrobstep/migra.git @@ -44,15 +44,15 @@ jobs: image_digest: ${{ steps.build.outputs.digest }} steps: - run: docker context create builders - - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 with: endpoint: builders - - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - id: build - uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 with: push: true context: https://github.com/djrobstep/migra.git @@ -68,8 +68,8 @@ jobs: - build_image runs-on: ubuntu-latest steps: - - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 94d46b7686..af52b854fd 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -18,7 +18,7 @@ jobs: fetch-depth: 0 persist-credentials: false - id: app-token - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + 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 }} diff --git a/.github/workflows/lint-pull-request.yml b/.github/workflows/lint-pull-request.yml index 4d24fa4509..32e676df3c 100644 --- a/.github/workflows/lint-pull-request.yml +++ b/.github/workflows/lint-pull-request.yml @@ -1,5 +1,7 @@ name: Lint Pull Request +# Release-notes PRs (head ref `release-notes/*`) skip CI; only +# apply-release-notes.yml runs for those. on: pull_request_target: types: @@ -23,7 +25,11 @@ concurrency: jobs: main: - if: github.event_name != 'pull_request_target' || github.event.pull_request.draft == false + if: | + github.event_name == 'merge_group' || + (github.event_name == 'pull_request_target' && + !startsWith(github.event.pull_request.head.ref, 'release-notes/') && + github.event.pull_request.draft == false) name: Lint Pull Request runs-on: ubuntu-latest steps: diff --git a/.github/workflows/propose-release-notes.yml b/.github/workflows/propose-release-notes.yml new file mode 100644 index 0000000000..37da05c755 --- /dev/null +++ b/.github/workflows/propose-release-notes.yml @@ -0,0 +1,80 @@ +name: Propose release notes + +# Runs after backfill-release-notes lands the raw semantic-release block in the +# GitHub Release body. Re-derives that block, asks Claude to rewrite it into +# user-centric notes per tools/release/release-notes-prompt.md, and opens a PR +# adding release-notes/v.md. Merging the PR triggers +# apply-release-notes.yml, which pushes the file's contents to the GH Release. +# +# Stable releases only on the automatic release pipeline β€” prerelease tags +# (-beta./-alpha.) keep the raw body unless this workflow is triggered +# manually from the Actions tab (workflow_dispatch). + +on: + workflow_call: + inputs: + tag: + description: Release tag to propose notes for (e.g. v2.101.0) + required: true + type: string + non_blocking: + description: Do not fail the workflow run when proposing fails (release pipeline) + required: false + type: boolean + default: false + secrets: + ANTHROPIC_API_KEY: + required: true + GH_APP_PRIVATE_KEY: + required: true + workflow_dispatch: + inputs: + tag: + description: Release tag to propose notes for (e.g. v2.101.0 or v2.99.0-beta.1) + required: true + type: string + +permissions: + contents: read + +jobs: + propose: + # workflow_call (release pipeline): skip prereleases. workflow_dispatch: + # allow any tag so reviewers can opt in for beta/alpha from the Actions tab. + if: ${{ github.event_name == 'workflow_dispatch' || (!contains(inputs.tag, '-beta.') && !contains(inputs.tag, '-alpha.')) }} + runs-on: ubuntu-latest + continue-on-error: ${{ inputs.non_blocking || false }} + permissions: + contents: write + pull-requests: write + env: + TAG: ${{ inputs.tag }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + steps: + # App token gets us push to a protected default branch *and* PR creation + # under the App identity, matching the rest of release.yml. + - 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 }} + + - uses: useblacksmith/checkout@41cdeedae8edb2e684ba22896a5fd2a3cb85db6b # v1 + with: + # Full history + tags so backfill-release-notes.ts can reach the + # commit graph it needs (semantic-release walks notes back to the + # last release on the channel). + fetch-depth: 0 + token: ${{ steps.app-token.outputs.token }} + + - uses: ./.github/actions/setup + + - name: Configure git identity + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Propose release notes + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: pnpm exec bun apps/cli/scripts/propose-release-notes.ts --tag "${TAG}" --apply diff --git a/.github/workflows/publish-preview-cli-packages.yml b/.github/workflows/publish-preview-cli-packages.yml new file mode 100644 index 0000000000..2f79390d19 --- /dev/null +++ b/.github/workflows/publish-preview-cli-packages.yml @@ -0,0 +1,154 @@ +name: Publish Preview CLI Packages + +# Release-notes PRs (head ref `release-notes/*`) are markdown-only and are not +# meant to produce installable preview packages. +on: + pull_request: + types: + - opened + - synchronize + - reopened + - ready_for_review + branches: + - develop + +permissions: + actions: read + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref }} + cancel-in-progress: true + +jobs: + build: + if: | + !startsWith(github.head_ref, 'release-notes/') && + github.event.pull_request.draft == false + name: Build preview CLI packages + uses: ./.github/workflows/build-cli-artifacts.yml + with: + version: 0.0.0-pr.${{ github.event.pull_request.number }} + shell: legacy + + publish: + needs: build + if: | + !startsWith(github.head_ref, 'release-notes/') && + github.event.pull_request.draft == false && + needs.build.result == 'success' + name: Publish preview package + runs-on: ubuntu-latest + env: + PREVIEW_VERSION: 0.0.0-pr.${{ github.event.pull_request.number }} + PR_NUMBER: ${{ github.event.pull_request.number }} + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Setup + uses: ./.github/actions/setup + + - name: Download preview build artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: cli-build-legacy-${{ env.PREVIEW_VERSION }} + + - name: Prepare package files + run: | + set -euo pipefail + pnpm exec bun apps/cli/scripts/sync-versions.ts --version "${PREVIEW_VERSION}" + pnpm --dir apps/cli build:shim + find packages -path '*/bin/supabase*' -type f -exec chmod +x {} + + + - name: Publish preview package + run: | + pnpm exec pkg-pr-new publish \ + --pnpm \ + --bin \ + --comment=off \ + --json pkg-pr-new.json \ + --no-template \ + './packages/cli-darwin-arm64' \ + './packages/cli-darwin-x64' \ + './packages/cli-linux-arm64' \ + './packages/cli-linux-arm64-musl' \ + './packages/cli-linux-x64' \ + './packages/cli-linux-x64-musl' \ + './packages/cli-windows-arm64' \ + './packages/cli-windows-x64' \ + './apps/cli' + + - name: Smoke test preview command + run: | + set -euo pipefail + preview_url="https://pkg.pr.new/supabase@${PR_NUMBER}" + echo "Preview command: npx ${preview_url}" + npx --yes "${preview_url}" --version + + comment: + needs: publish + if: | + !startsWith(github.head_ref, 'release-notes/') && + github.event.pull_request.draft == false && + needs.publish.result == 'success' + name: Post preview command comment + runs-on: ubuntu-latest + permissions: + pull-requests: write + env: + GH_TOKEN: ${{ github.token }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PR_NUMBER: ${{ github.event.pull_request.number }} + steps: + - name: Post preview command comment + run: | + set -euo pipefail + + marker="" + preview_url="https://pkg.pr.new/supabase@${PR_NUMBER}" + short_sha="${HEAD_SHA:0:7}" + comment_file="$(mktemp)" + + cat > "${comment_file}" </dev/null; then + echo "::warning::Unable to update the preview package PR comment." + exit 0 + fi + else + if ! gh api \ + --method POST \ + "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \ + --field "body=@${comment_file}" \ + >/dev/null; then + echo "::warning::Unable to create the preview package PR comment." + exit 0 + fi + fi diff --git a/.github/workflows/release-shared.yml b/.github/workflows/release-shared.yml index 4c9fe3b8f7..8505ba61e0 100644 --- a/.github/workflows/release-shared.yml +++ b/.github/workflows/release-shared.yml @@ -51,77 +51,31 @@ on: required: false GH_APP_PRIVATE_KEY: required: false + ANTHROPIC_API_KEY: + required: false jobs: build: - runs-on: blacksmith-32vcpu-ubuntu-2404 - env: - BUN_SHELL: ${{ inputs.shell }} - VERSION: ${{ inputs.version }} + name: Build CLI artifacts + uses: ./.github/workflows/build-cli-artifacts.yml + with: + version: ${{ inputs.version }} + shell: ${{ inputs.shell }} + secrets: SENTRY_DSN: ${{ secrets.SENTRY_DSN }} POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }} POSTHOG_ENDPOINT: ${{ secrets.POSTHOG_ENDPOINT }} - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - - name: Setup - uses: ./.github/actions/setup - - - name: Setup Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.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 - - - name: Install nfpm - run: | - echo 'deb [trusted=yes] https://repo.goreleaser.com/apt/ /' | sudo tee /etc/apt/sources.list.d/goreleaser.list - sudo apt-get update - sudo apt-get install -y nfpm - - - name: Sync versions - run: pnpm exec bun apps/cli/scripts/sync-versions.ts --version "${VERSION}" - - - name: Build selected shell - run: pnpm exec bun apps/cli/scripts/build.ts --version "${VERSION}" --shell "${BUN_SHELL}" - - - name: Verify build artifacts - run: | - for pkg in cli-darwin-arm64 cli-darwin-x64 cli-linux-arm64 cli-linux-arm64-musl cli-linux-x64 cli-linux-x64-musl cli-windows-arm64 cli-windows-x64; do - echo "Checking packages/$pkg/bin/..." - ls -la "packages/$pkg/bin/" - done - echo "Checking dist/..." - ls -la dist/ - - - name: Upload build artifacts - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - with: - name: cli-build-${{ inputs.shell }}-${{ inputs.version }} - path: | - packages/cli-*/bin/ - dist/ smoke-test: needs: build strategy: fail-fast: false - # macos-15-intel is the slowest smoke leg and the only one not on - # Blacksmith (Blacksmith macOS is ARM-only). Drop it from the matrix - # on prereleases (PR smoke + develop -> beta) so beta wall-clock isn't - # gated by it; stable releases on main still run the full matrix. - # The matrix list is built via fromJSON because GitHub Actions does - # not allow the `matrix` context in a job-level `if:` (matrix - # expansion happens after job conditions are evaluated). matrix: - runner: ${{ fromJSON(inputs.prerelease && '["blacksmith-8vcpu-ubuntu-2404","blacksmith-6vcpu-macos-latest","blacksmith-8vcpu-windows-2025"]' || '["blacksmith-8vcpu-ubuntu-2404","blacksmith-6vcpu-macos-latest","macos-15-intel","blacksmith-8vcpu-windows-2025"]') }} + runner: + - blacksmith-8vcpu-ubuntu-2404 + - blacksmith-6vcpu-macos-latest + - macos-15-intel + - blacksmith-8vcpu-windows-2025 + - windows-11-arm runs-on: ${{ matrix.runner }} env: NPM_TAG: ${{ inputs.npm_tag }} @@ -136,7 +90,7 @@ jobs: uses: ./.github/actions/setup - name: Download build artifacts - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: cli-build-${{ inputs.shell }}-${{ inputs.version }} @@ -156,7 +110,7 @@ jobs: - name: Setup QEMU for cross-platform Docker if: runner.os == 'Linux' - uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 + uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0 # Cache the smoke-test base images across runs. Without this, eight # parallel `docker run` calls in smoke-test-linux.ts race on first-time @@ -166,7 +120,7 @@ jobs: - name: Cache smoke-test docker images if: runner.os == 'Linux' id: smoke-docker-cache - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.cache/smoke-docker-images.tar key: smoke-docker-images-debian-bookworm-slim-amazonlinux-2023-alpine-3.21-v1 @@ -234,16 +188,26 @@ jobs: contents: write id-token: write steps: + - name: Generate release repository 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 + permission-workflows: write + - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: true + token: ${{ steps.app-token.outputs.token }} - name: Setup uses: ./.github/actions/setup - name: Download build artifacts - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: cli-build-${{ inputs.shell }}-${{ inputs.version }} @@ -253,6 +217,11 @@ jobs: - name: Publish to npm run: pnpm exec bun apps/cli/scripts/publish.ts --tag "${NPM_TAG}" + - name: Configure git for release pushes + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + # Push the version tag to origin as soon as npm has the bytes, before any # downstream step that can fail. Without this, a failure in the GH-release # step (or anything after) leaves origin with no tag for the version that @@ -264,8 +233,6 @@ jobs: run: | set -euo pipefail tag="v${VERSION}" - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" if git ls-remote --tags origin "refs/tags/${tag}" | grep -q .; then echo "Tag ${tag} already on origin; skipping push." else @@ -312,7 +279,7 @@ jobs: done - name: Create draft GitHub Release - uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1 + uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 with: tag_name: v${{ inputs.version }} name: v${{ inputs.version }} @@ -340,6 +307,7 @@ jobs: dist/supabase_linux_amd64.tar.gz dist/supabase_windows_arm64.tar.gz dist/supabase_windows_amd64.tar.gz + install - name: Publish GitHub Release (immutable) env: @@ -357,6 +325,22 @@ jobs: apply: true non_blocking: true + # Once the raw semantic-release block is in the release body, ask Claude to + # rewrite it into user-centric notes and open a PR for human approval. Stable + # releases only on this path β€” prereleases keep the raw body. Non-blocking so + # an LLM hiccup never gates a published release; reviewers can propose beta/ + # alpha notes manually from the Actions tab (workflow_dispatch). + propose-release-notes: + uses: ./.github/workflows/propose-release-notes.yml + needs: backfill-release-notes + if: ${{ !inputs.dry_run && !inputs.prerelease && needs.backfill-release-notes.result == 'success' }} + with: + tag: v${{ inputs.version }} + non_blocking: true + secrets: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + GH_APP_PRIVATE_KEY: ${{ secrets.GH_APP_PRIVATE_KEY }} + publish-homebrew: needs: publish if: ${{ !inputs.dry_run && inputs.publish_brew_scoop }} @@ -374,13 +358,13 @@ jobs: uses: ./.github/actions/setup - name: Download build artifacts - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: cli-build-${{ inputs.shell }}-${{ inputs.version }} - name: Generate Homebrew tap token id: app-token - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + 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 }} @@ -419,13 +403,13 @@ jobs: uses: ./.github/actions/setup - name: Download build artifacts - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: cli-build-${{ inputs.shell }}-${{ inputs.version }} - name: Generate Scoop bucket token id: app-token - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + 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 }} diff --git a/.github/workflows/release-smoke-test.yml b/.github/workflows/release-smoke-test.yml new file mode 100644 index 0000000000..038092a77c --- /dev/null +++ b/.github/workflows/release-smoke-test.yml @@ -0,0 +1,47 @@ +name: Release Smoke Test + +on: + workflow_dispatch: + inputs: + version: + description: Version to build and smoke test + required: false + type: string + default: 0.0.0-smoke + shell: + description: CLI shell to package as the shipped supabase binary + required: false + type: choice + options: + - legacy + - next + default: legacy + npm_tag: + description: npm tag to use for local package smoke tests + required: false + type: choice + options: + - latest + - alpha + - beta + default: beta + +permissions: + # release-shared.yml declares privileged publish jobs. They are gated by + # dry_run here, but GitHub validates nested-workflow permissions at startup. + contents: write + id-token: write + +jobs: + smoke: + name: Run release smoke tests + uses: ./.github/workflows/release-shared.yml + with: + version: ${{ inputs.version }} + shell: ${{ inputs.shell }} + npm_tag: ${{ inputs.npm_tag }} + channel: beta + prerelease: true + dry_run: true + secrets: + GH_APP_PRIVATE_KEY: ${{ secrets.GH_APP_PRIVATE_KEY }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 57c7418c22..fe7dc96ac7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -51,7 +51,7 @@ jobs: contents: write steps: - id: app-token - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + 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 }} @@ -90,9 +90,9 @@ jobs: # same App used for fast-forward + brew/scoop pushes. - id: app-token if: github.event_name == 'push' - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: - app-id: ${{ secrets.APP_ID }} + client-id: ${{ vars.GH_APP_CLIENT_ID }} private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} permission-contents: write # `persist-credentials: false` is required: otherwise checkout caches the @@ -189,9 +189,15 @@ jobs: name: Release needs: plan if: needs.plan.outputs.should_release == 'true' + # pull-requests: write is required by the nested propose-release-notes + # workflow (release-shared.yml -> propose-release-notes.yml). For nested + # reusable workflows, a called job's permissions can't exceed those granted + # to the calling job, so this must be declared here even though the propose + # job uses an App token for its actual PR creation. permissions: contents: write id-token: write + pull-requests: write uses: ./.github/workflows/release-shared.yml with: version: ${{ needs.plan.outputs.version }} @@ -208,6 +214,7 @@ jobs: POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }} POSTHOG_ENDPOINT: ${{ secrets.POSTHOG_ENDPOINT }} GH_APP_PRIVATE_KEY: ${{ secrets.GH_APP_PRIVATE_KEY }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} # Posts to the release Slack channel once the pipeline succeeds. Listing # `release` in `needs` without a status function in `if:` keeps the implicit @@ -223,6 +230,26 @@ jobs: needs.plan.outputs.channel == 'stable' uses: ./.github/workflows/slack-notify.yml with: + status: success + version: ${{ needs.plan.outputs.version }} + channel: ${{ needs.plan.outputs.channel }} + secrets: + SLACK_RELEASE_WEBHOOK: ${{ secrets.SLACK_RELEASE_WEBHOOK }} + + # Reports a broken release on every channel. `failure()` evaluates against the + # `needs` chain, so this fires whenever `plan` or `release` (and anything in + # the reusable release-shared workflow) fails. Skipped jobs β€” e.g. the + # fast-forward path or a release that never started β€” don't count as failures, + # so this stays quiet there. Dry runs are excluded; an operator running one is + # already watching it live. When `plan` fails its outputs are empty, so the + # message falls back to the workflow run link as the actionable detail. + notify-slack-failure: + name: Notify Slack (failure) + needs: [plan, release] + if: failure() && needs.plan.outputs.dry_run != 'true' + uses: ./.github/workflows/slack-notify.yml + with: + status: failure version: ${{ needs.plan.outputs.version }} channel: ${{ needs.plan.outputs.channel }} secrets: diff --git a/.github/workflows/slack-notify.yml b/.github/workflows/slack-notify.yml index d92968c8b2..7fe2216917 100644 --- a/.github/workflows/slack-notify.yml +++ b/.github/workflows/slack-notify.yml @@ -11,6 +11,11 @@ on: description: Release channel (alpha | beta | stable), used to label the message required: false type: string + status: + description: Notification kind (success | failure). Failure messages report a broken release on any channel. + required: false + type: string + default: success secrets: SLACK_RELEASE_WEBHOOK: required: true @@ -26,6 +31,7 @@ jobs: env: VERSION: ${{ inputs.version }} CHANNEL: ${{ inputs.channel }} + STATUS: ${{ inputs.status }} REPO: ${{ github.repository }} RUN_ID: ${{ github.run_id }} SHA: ${{ github.sha }} @@ -34,16 +40,50 @@ jobs: set -euo pipefail SHORT_SHA="${SHA:0:7}" - HEADER="πŸš€ Supabase CLI v${VERSION} released" - if [[ "$CHANNEL" == "beta" ]]; then - HEADER="πŸš€ Supabase CLI v${VERSION} released (beta)" - fi - - CHANGELOG_URL="https://github.com/${REPO}/releases/tag/v${VERSION}" COMMIT_URL="https://github.com/${REPO}/commit/${SHA}" RUN_URL="https://github.com/${REPO}/actions/runs/${RUN_ID}" - payload=$(cat < --json` to discover available targets before running ## Pull Requests PR titles must follow conventional-commits format because the `Lint Pull Request` workflow runs `amannn/action-semantic-pull-request` against the title. Use `(): ` (e.g. `fix(cli): …`, `test(cli): …`, `feat(api): …`). A bare descriptive title like "Build TypeScript CLI as compiled Bun binaries" will fail the lint. When a PR is created (including by the Claude Code UI or someone else), check the title against this rule and update it if needed. +Avoid semantic-release-triggering types for non-release changes. For CI, docs, tests, tooling, agent instructions, and other repository-maintenance changes, do not use `fix`, `feat`, `perf`, or breaking-change markers just to satisfy the PR title linter. Prefer non-releasing conventional types such as `chore`, `docs`, `test`, or `ci` when the change should not produce a package release. Do not include a validation, test plan, or list of checks in PR descriptions. CI enforces validation for PRs, so PR descriptions should focus on what changed, why it changed, and any reviewer-relevant context that CI cannot infer. ## Refactoring Policy diff --git a/README.md b/README.md index 8b9629770b..1e0b1d528e 100644 --- a/README.md +++ b/README.md @@ -1,214 +1,167 @@ -# Supabase CLI +

+ + + + Supabase CLI + + +

-[![Coverage Status](https://coveralls.io/repos/github/supabase/cli/badge.svg?branch=develop)](https://coveralls.io/github/supabase/cli?branch=develop) [![Bitbucket Pipelines](https://img.shields.io/bitbucket/pipelines/supabase-cli/setup-cli/master?style=flat-square&label=Bitbucket%20Canary)](https://bitbucket.org/supabase-cli/setup-cli/pipelines) [![Gitlab Pipeline Status](https://img.shields.io/gitlab/pipeline-status/sweatybridge%2Fsetup-cli?label=Gitlab%20Canary) -](https://gitlab.com/sweatybridge/setup-cli/-/pipelines) +

+ Develop locally and deploy to the Supabase Platform from your terminal. +

-[Supabase](https://supabase.io) is an open source Firebase alternative. We're building the features of Firebase using enterprise-grade open source tools. +

+ npm + Build + License + Discord +

-This repository contains all the functionality for Supabase CLI. +--- -- [x] Running Supabase locally -- [x] Managing database migrations -- [x] Creating and deploying Supabase Functions -- [x] Generating types directly from your database schema -- [x] Making authenticated HTTP requests to [Management API](https://supabase.com/docs/reference/api/introduction) +Supabase CLI brings the Supabase Platform to your terminal. Run the full local stack, manage database migrations, deploy Edge Functions, generate types, and automate project workflows. -## Getting started +## Installation -### Install the CLI +```sh +# YOLO +curl -fsSL https://raw.githubusercontent.com/supabase/cli/main/install | bash -Available via [NPM](https://www.npmjs.com) as dev dependency. To install: +# npm +npm install -D supabase # or bun/pnpm/yarn add -D supabase +npm install -D supabase@beta # beta channel -```bash -npm i supabase --save-dev -``` +# macOS and Linux +brew install supabase/tap/supabase # always up to date +brew install supabase # official formula, may be delayed +brew install supabase/tap/supabase-beta # beta channel -To install the beta release channel: +# Windows +scoop bucket add supabase https://github.com/supabase/scoop-bucket.git +scoop install supabase +scoop install supabase-beta # beta channel -```bash -npm i supabase@beta --save-dev +# Linux packages +# Download .apk, .deb, .rpm, or .pkg.tar.zst from GitHub Releases. ``` -When installing with yarn 4, you need to disable experimental fetch with the following nodejs config. - -``` -NODE_OPTIONS=--no-experimental-fetch yarn add supabase -``` - -> **Note** -For Bun versions below v1.0.17, you must add `supabase` as a [trusted dependency](https://bun.sh/guides/install/trusted) before running `bun add -D supabase`. - -
- macOS - - Available via [Homebrew](https://brew.sh). To install: - - ```sh - brew install supabase/tap/supabase - ``` - - To install the beta release channel: - - ```sh - brew install supabase/tap/supabase-beta - brew link --overwrite supabase-beta - ``` - - To upgrade: - - ```sh - brew upgrade supabase - ``` - - Beta channel: - - ```sh - brew upgrade supabase-beta - ``` -
+Linux packages are available from [Releases](https://github.com/supabase/cli/releases). Community-maintained packages are also available through [pkgx](https://pkgx.sh/) and [Nixpkgs](https://nixos.org/). -
- Windows +## Start Local Development - Available via [Scoop](https://scoop.sh). To install: +Create a Supabase workspace and start the local stack: - ```powershell - scoop bucket add supabase https://github.com/supabase/scoop-bucket.git - scoop install supabase - ``` - - To install the beta release channel: - - ```powershell - scoop install supabase-beta - ``` - - To upgrade: - - ```powershell - scoop update supabase - ``` - - Beta channel: +```sh +supabase init +supabase start +supabase status +``` - ```powershell - scoop update supabase-beta - ``` -
+The local stack includes Postgres, Auth, Realtime, Storage, Edge Functions, and the Supabase APIs. -
- Linux +Start from a template: - Available via [Homebrew](https://brew.sh) and Linux packages. +```sh +supabase bootstrap +``` - #### via Homebrew +## Link A Project - To install: +Connect your local workspace to a hosted Supabase project: - ```sh - brew install supabase/tap/supabase - ``` +```sh +supabase login +supabase link +``` - To install the beta release channel: +## Manage Your Database - ```sh - brew install supabase/tap/supabase-beta - brew link --overwrite supabase-beta - ``` +Create migrations, compare schemas, and apply changes locally or to your linked project: - To upgrade: +```sh +supabase migration new create_profiles +supabase db diff +supabase db push +supabase db reset +``` - ```sh - brew upgrade supabase - ``` +## Deploy Edge Functions - Beta channel: +Build, serve, and deploy functions from the same project workspace: - ```sh - brew upgrade supabase-beta - ``` +```sh +supabase functions new hello-world +supabase functions serve +supabase functions deploy hello-world +``` - #### via Linux packages +## Generate Types - Linux packages are provided in [Releases](https://github.com/supabase/cli/releases). To install, download the `.apk`/`.deb`/`.rpm`/`.pkg.tar.zst` file depending on your package manager and run the respective commands. +Generate TypeScript types from your local database or linked project: - ```sh - sudo apk add --allow-untrusted <...>.apk - ``` +```sh +supabase gen types --local +supabase gen types --linked +``` - ```sh - sudo dpkg -i <...>.deb - ``` +## Reference - ```sh - sudo rpm -i <...>.rpm - ``` +Use `--help` on any command to explore flags and examples: - ```sh - sudo pacman -U <...>.pkg.tar.zst - ``` -
+```sh +supabase db --help +supabase functions deploy --help +``` -
- Other Platforms +- [CLI reference](https://supabase.com/docs/reference/cli/about) +- [Local development guide](https://supabase.com/docs/guides/local-development) +- [Supabase docs](https://supabase.com/docs) - You can also install the CLI via [go modules](https://go.dev/ref/mod#go-install) without the help of package managers. +## Developing - ```sh - go install github.com/supabase/cli@latest - ``` +This repository is a pnpm monorepo. The published package lives in `apps/cli`. - Add a symlink to the binary in `$PATH` for easier access: +```sh +pnpm install +cd apps/cli - ```sh - ln -s "$(go env GOPATH)/bin/cli" /usr/bin/supabase - ``` +pnpm dev:next -- --help +pnpm check:all +pnpm test:core +``` - This works on other non-standard Linux distros. -
+Useful source entry points: -
- Community Maintained Packages +| Path | Purpose | +| ----------------- | -------------------------------------- | +| `apps/cli` | TypeScript/Bun CLI package | +| `apps/cli-go` | Go CLI source used by the legacy shell | +| `packages/stack` | Local Supabase stack runtime | +| `packages/config` | Config schema and generated types | +| `packages/api` | Typed Supabase Management API client | - Available via [pkgx](https://pkgx.sh/). Package script [here](https://github.com/pkgxdev/pantry/blob/main/projects/supabase.com/cli/package.yml). - To install in your working directory: +After a fresh clone, install the reference repositories used for agent and developer inspection: - ```bash - pkgx install supabase - ``` +```sh +pnpm repos:install +``` - Available via [Nixpkgs](https://nixos.org/). Package script [here](https://github.com/NixOS/nixpkgs/blob/master/pkgs/development/tools/supabase-cli/default.nix). -
+## Contributing -### Run the CLI +We love focused pull requests with a clear problem, a small surface area, and tests that match the user-facing behavior. Before opening a PR, run the checks for the workspace you touched. -```bash -supabase bootstrap +```sh +pnpm check:all +pnpm test ``` -Or using npx: +PR titles must use conventional commits, for example: -```bash -npx supabase bootstrap +```text +fix(cli): handle linked projects without cached service versions ``` -The bootstrap command will guide you through the process of setting up a Supabase project using one of the [starter](https://github.com/supabase-community/supabase-samples/blob/main/samples.json) templates. - -## Docs - -Command & config reference can be found [here](https://supabase.com/docs/reference/cli/about). - -## Breaking changes - -We follow semantic versioning for changes that directly impact CLI commands, flags, and configurations. +## License -However, due to dependencies on other service images, we cannot guarantee that schema migrations, seed.sql, and generated types will always work for the same CLI major version. If you need such guarantees, we encourage you to pin a specific version of CLI in package.json. - -## Developing - -To run from source: - -```sh -# Go >= 1.22 -go run . help -``` +Supabase CLI packages are released under the MIT license. diff --git a/apps/cli-e2e/fixtures/scenarios/seed-buckets-creates-buckets-defined-in-config/interactions.json b/apps/cli-e2e/fixtures/scenarios/seed-buckets-creates-buckets-defined-in-config/interactions.json index d4e5ef05b9..78125524cf 100644 --- a/apps/cli-e2e/fixtures/scenarios/seed-buckets-creates-buckets-defined-in-config/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/seed-buckets-creates-buckets-defined-in-config/interactions.json @@ -62,5 +62,37 @@ "name": "my-bucket" } } + }, + { + "request": { + "method": "POST", + "path": "/storage/v1/vector/ListVectorBuckets", + "query": {}, + "headers": { + "accept-encoding": "gzip", + "apikey": "__JWT__", + "authorization": "Bearer __ACCESS_TOKEN__", + "content-length": "3", + "content-type": "application/json", + "host": "localhost:__PORT__", + "user-agent": "SupabaseCLI/" + }, + "body": {} + }, + "response": { + "status": 200, + "headers": { + "access-control-allow-origin": "*", + "content-type": "application/json; charset=utf-8", + "sb-gateway-mode": "direct", + "sb-gateway-version": "1", + "sb-project-ref": "__PROJECT_REF__", + "sb-request-id": "__UUID__", + "x-content-type-options": "nosniff" + }, + "body": { + "vectorBuckets": [] + } + } } ] diff --git a/apps/cli-e2e/src/server/placeholder.ts b/apps/cli-e2e/src/server/placeholder.ts index d51e58365e..37dd711e00 100644 --- a/apps/cli-e2e/src/server/placeholder.ts +++ b/apps/cli-e2e/src/server/placeholder.ts @@ -61,6 +61,33 @@ export function applyPlaceholders(input: string): { output: string } { return { output }; } +// The project-ref placeholder (`__PROJECT_REF__`) is 15 characters, but the +// Management API schema constrains project refs to `^[a-z]{20}$` (minLength 20). +// The Go CLI doesn't validate response bodies, so it tolerates the short +// placeholder; the TS port decodes responses against the generated schema and +// rejects it (e.g. `link` calling `getProject`). When serving a recorded +// response we therefore substitute any field whose value is *exactly* the +// placeholder back to a valid 20-char ref. Substring occurrences such as +// `db.__PROJECT_REF__.supabase.red` are left untouched so tests that assert on +// the literal placeholder host keep matching. +const PROJECT_REF_VALUE = /"__PROJECT_REF__"/g; +const PLACEHOLDER_PROJECT_REF = "abcdefghijklmnopqrst"; + +/** Extract the 20-char project ref from a `/v1/projects/` request path, + * falling back to a stable placeholder ref for refless endpoints (e.g. the + * project list). */ +export function projectRefFromPath(urlPath: string): string { + const match = urlPath.match(/\/projects\/([a-z]{20})(?:\/|$)/); + return match?.[1] ?? PLACEHOLDER_PROJECT_REF; +} + +/** Replace exact-match `__PROJECT_REF__` string values in a serialized JSON + * body with a schema-valid 20-char ref. Operates on the JSON string so only + * full quoted values are rewritten, never substrings. */ +export function restoreProjectRef(json: string, ref: string): string { + return json.replace(PROJECT_REF_VALUE, `"${ref}"`); +} + /** Normalize dynamic segments in a URL path to stable unnumbered placeholders. * Apply this to both the stored fixture path and the incoming request path so * both sides of a scenario comparison transform identically. */ diff --git a/apps/cli-e2e/src/server/replay-server.ts b/apps/cli-e2e/src/server/replay-server.ts index 4194d31d88..7b0491e2bb 100644 --- a/apps/cli-e2e/src/server/replay-server.ts +++ b/apps/cli-e2e/src/server/replay-server.ts @@ -9,7 +9,13 @@ import type { FixtureStore, } from "./fixture-loader.ts"; import { loadFixtures, loadScenario } from "./fixture-loader.ts"; -import { applyPlaceholders, fixtureKey, normalizeUrlPath } from "./placeholder.ts"; +import { + applyPlaceholders, + fixtureKey, + normalizeUrlPath, + projectRefFromPath, + restoreProjectRef, +} from "./placeholder.ts"; import { matchFixture, resetCounters, sortBody, type SequenceCounters } from "./request-matcher.ts"; import type { PgFixture, PgMockHandle } from "./pg-mock.ts"; @@ -470,10 +476,15 @@ async function proxyAndRecord( scenario, }); - return buildApiResponse(responseBody, upstreamStatus, { - ...responseHeaders, - "content-type": responseContentType, - }); + return buildApiResponse( + responseBody, + upstreamStatus, + { + ...responseHeaders, + "content-type": responseContentType, + }, + projectRefFromPath(pathname), + ); } /** Record a Docker interaction once its streamed body has fully drained. Errors @@ -750,11 +761,14 @@ function nextFixtureIndex(keyDir: string): number { return max + 1; } -/** Build an API response, respecting HTTP no-body status codes (204, 304, 205). */ +/** Build an API response, respecting HTTP no-body status codes (204, 304, 205). + * `projectRef` is the ref from the request path, used to restore short + * `__PROJECT_REF__` placeholders to schema-valid 20-char refs in JSON bodies. */ function buildApiResponse( body: unknown, status: number, headers: Record, + projectRef: string, ): Response { if (status === 204 || status === 304 || status === 205) { return new Response(null, { status, headers }); @@ -770,7 +784,10 @@ function buildApiResponse( if (body === null) { return new Response(null, { status, headers }); } - return Response.json(body, { status, headers }); + return new Response(restoreProjectRef(JSON.stringify(body), projectRef), { + status, + headers: { "content-type": "application/json", ...headers }, + }); } function serveFromFixtures( @@ -791,6 +808,7 @@ function serveFromFixtures( result.entry.response.body, result.entry.response.status, result.entry.response.headers, + projectRefFromPath(pathname), ); } @@ -882,6 +900,7 @@ function serveFromScenario( expected.response.body, expected.response.status, expected.response.headers, + projectRefFromPath(pathname), ); } diff --git a/apps/cli-e2e/src/tests/project-lifecycle.e2e.test.ts b/apps/cli-e2e/src/tests/project-lifecycle.e2e.test.ts index cc99e95d14..1fa0431dcb 100644 --- a/apps/cli-e2e/src/tests/project-lifecycle.e2e.test.ts +++ b/apps/cli-e2e/src/tests/project-lifecycle.e2e.test.ts @@ -158,10 +158,10 @@ describe("unlink", () => { }); // The success path (pre-populate project-ref β†’ unlink succeeds) is omitted: the - // ts-legacy unlink handler is a Phase 0 proxy to the Go binary, which attempts - // a system keyring delete on exit. On Linux CI (no D-Bus session bus) the - // keyring call returns an unhandled error and the binary exits 1. The error path - // above already gives meaningful coverage for a proxy command. + // unlink handler deletes the database-password keyring entry on success. On + // Linux CI (no D-Bus session bus) the keyring call returns an unhandled error + // and the command exits 1. The not-linked error path above gives meaningful + // coverage; deeper success-path behaviour is covered by unlink.integration.test.ts. testParity(["unlink"]); }); diff --git a/apps/cli-e2e/src/tests/telemetry.e2e.test.ts b/apps/cli-e2e/src/tests/telemetry.e2e.test.ts index 5d428a2e93..8dbb4de63f 100644 --- a/apps/cli-e2e/src/tests/telemetry.e2e.test.ts +++ b/apps/cli-e2e/src/tests/telemetry.e2e.test.ts @@ -62,7 +62,7 @@ describe("telemetry", () => { writeFileSync(telemetryPath, "{{not valid json}}"); const result = await run(["telemetry", "status"]); expect(result.exitCode).toBe(0); - expect(result.stdout).toMatch(/Telemetry is (enabled|disabled)\./); + expect(result.stdout).toContain("Telemetry is enabled."); expect(() => JSON.parse(readFileSync(telemetryPath, "utf8"))).not.toThrow(); }); diff --git a/apps/cli-go/go.mod b/apps/cli-go/go.mod index d208a593b3..7c9ee6dff7 100644 --- a/apps/cli-go/go.mod +++ b/apps/cli-go/go.mod @@ -19,8 +19,8 @@ require ( github.com/docker/docker v28.5.2+incompatible github.com/docker/go-connections v0.7.0 github.com/docker/go-units v0.5.0 - github.com/fsnotify/fsnotify v1.9.0 - github.com/getsentry/sentry-go v0.44.1 + github.com/fsnotify/fsnotify v1.10.1 + github.com/getsentry/sentry-go v0.46.2 github.com/go-errors/errors v1.5.1 github.com/go-git/go-git/v5 v5.19.1 github.com/go-playground/validator/v10 v10.30.2 @@ -29,7 +29,7 @@ require ( github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/go-github/v62 v62.0.0 github.com/google/go-querystring v1.2.0 - github.com/google/jsonschema-go v0.4.2 + github.com/google/jsonschema-go v0.4.3 github.com/google/uuid v1.6.0 github.com/h2non/gock v1.2.0 github.com/jackc/pgconn v1.14.3 @@ -42,7 +42,7 @@ require ( github.com/multigres/multigres v0.0.0-20260126223308-f5a52171bbc4 github.com/oapi-codegen/nullable v1.1.0 github.com/olekukonko/tablewriter v1.1.4 - github.com/posthog/posthog-go v1.11.2 + github.com/posthog/posthog-go v1.13.0 github.com/spf13/afero v1.15.0 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 @@ -54,7 +54,7 @@ require ( github.com/withfig/autocomplete-tools/packages/cobra v1.2.0 github.com/zalando/go-keyring v0.2.8 go.opentelemetry.io/otel v1.44.0 - golang.org/x/mod v0.35.0 + golang.org/x/mod v0.36.0 golang.org/x/net v0.55.0 golang.org/x/oauth2 v0.36.0 golang.org/x/term v0.43.0 @@ -334,7 +334,7 @@ require ( github.com/nishanths/predeclared v0.2.2 // indirect github.com/nunnatsa/ginkgolinter v0.19.1 // indirect github.com/oapi-codegen/oapi-codegen/v2 v2.4.1 // indirect - github.com/oapi-codegen/runtime v1.3.1 // indirect + github.com/oapi-codegen/runtime v1.4.1 // indirect github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect diff --git a/apps/cli-go/go.sum b/apps/cli-go/go.sum index 7db471ece1..caa2f47bdb 100644 --- a/apps/cli-go/go.sum +++ b/apps/cli-go/go.sum @@ -337,8 +337,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= -github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= -github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= +github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/fvbommel/sortorder v1.1.0 h1:fUmoe+HLsBTctBDoaBwpQo5N+nrCp8g/BjKb/6ZQmYw= github.com/fvbommel/sortorder v1.1.0/go.mod h1:uk88iVf1ovNn1iLfgUVU2F9o5eO30ui720w+kxuqRs0= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= @@ -349,8 +349,8 @@ github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9 github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/getkin/kin-openapi v0.131.0 h1:NO2UeHnFKRYhZ8wg6Nyh5Cq7dHk4suQQr72a4pMrDxE= github.com/getkin/kin-openapi v0.131.0/go.mod h1:3OlG51PCYNsPByuiMB0t4fjnNlIDnaEDsjiKUV8nL58= -github.com/getsentry/sentry-go v0.44.1 h1:/cPtrA5qB7uMRrhgSn9TYtcEF36auGP3Y6+ThvD/yaI= -github.com/getsentry/sentry-go v0.44.1/go.mod h1:XDotiNZbgf5U8bPDUAfvcFmOnMQQceESxyKaObSssW0= +github.com/getsentry/sentry-go v0.46.2 h1:1jhYwrKGa3sIpo/y5iDNXS5wDoT7I1KNzMHrnK6ojns= +github.com/getsentry/sentry-go v0.46.2/go.mod h1:evVbw2qotNUdYG8KxXbAdjOQWWvWIwKxpjdZZIvcIPw= github.com/ghostiam/protogetter v0.3.15 h1:1KF5sXel0HE48zh1/vn0Loiw25A9ApyseLzQuif1mLY= github.com/ghostiam/protogetter v0.3.15/go.mod h1:WZ0nw9pfzsgxuRsPOFQomgDVSWtDLJRfQJEhsGbmQMA= github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= @@ -527,8 +527,8 @@ github.com/google/go-github/v62 v62.0.0/go.mod h1:EMxeUqGJq2xRu9DYBMwel/mr7kZrzU github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= -github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +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= @@ -865,8 +865,8 @@ github.com/oapi-codegen/nullable v1.1.0 h1:eAh8JVc5430VtYVnq00Hrbpag9PFRGWLjxR1/ github.com/oapi-codegen/nullable v1.1.0/go.mod h1:KUZ3vUzkmEKY90ksAmit2+5juDIhIZhfDl+0PwOQlFY= github.com/oapi-codegen/oapi-codegen/v2 v2.4.1 h1:ykgG34472DWey7TSjd8vIfNykXgjOgYJZoQbKfEeY/Q= github.com/oapi-codegen/oapi-codegen/v2 v2.4.1/go.mod h1:N5+lY1tiTDV3V1BeHtOxeWXHoPVeApvsvjJqegfoaz8= -github.com/oapi-codegen/runtime v1.3.1 h1:RgDY6J4OGQLbRXhG/Xpt3vSVqYpHQS7hN4m85+5xB9g= -github.com/oapi-codegen/runtime v1.3.1/go.mod h1:kOdeacKy7t40Rclb1je37ZLFboFxh+YLy0zaPCMibPY= +github.com/oapi-codegen/runtime v1.4.1 h1:9nwLoI+KrWxzbBcp0jO/R8uXqbik/HUyCvPeU68Y/qo= +github.com/oapi-codegen/runtime v1.4.1/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU= github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY= github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw= github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c= @@ -941,8 +941,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI 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.11.2 h1:ApKTtOhIeWhUBc4ByO+mlbg2o0iZaEGJnJHX2QDnn5Q= -github.com/posthog/posthog-go v1.11.2/go.mod h1:xsVOW9YImilUcazwPNEq4PJDqEZf2KeCS758zXjwkPg= +github.com/posthog/posthog-go v1.13.0 h1:+i+t6txCczJcGZj7ME2ry4sLhPYvq3q7RYuUZ0z6NpQ= +github.com/posthog/posthog-go v1.13.0/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/prometheus/client_golang v0.9.0-pre1.0.20180209125602-c332b6f63c06/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= @@ -1287,8 +1287,8 @@ 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.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= -golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= diff --git a/apps/cli-go/internal/db/diff/diff.go b/apps/cli-go/internal/db/diff/diff.go index 05991423d6..46d465aff2 100644 --- a/apps/cli-go/internal/db/diff/diff.go +++ b/apps/cli-go/internal/db/diff/diff.go @@ -230,7 +230,7 @@ func migrateBaseDatabase(ctx context.Context, config pgconn.Config, migrations [ } func diffWithStream(ctx context.Context, env []string, script string, stdout io.Writer) error { - cmd := []string{"edge-runtime", "start", "--main-service=."} + cmd := utils.EdgeRuntimeStartCmd() if viper.GetBool("DEBUG") { cmd = append(cmd, "--verbose") } diff --git a/apps/cli-go/internal/start/start.go b/apps/cli-go/internal/start/start.go index 881f8dab73..6ce6a4434d 100644 --- a/apps/cli-go/internal/start/start.go +++ b/apps/cli-go/internal/start/start.go @@ -1107,29 +1107,7 @@ EOF ctx, container.Config{ Image: utils.Config.Studio.Image, - Env: []string{ - "CURRENT_CLI_VERSION=" + utils.Version, - "STUDIO_PG_META_URL=http://" + utils.PgmetaId + ":8080", - "POSTGRES_PASSWORD=" + dbConfig.Password, - "SUPABASE_URL=http://" + utils.KongId + ":8000", - "SUPABASE_PUBLIC_URL=" + utils.Config.Studio.ApiUrl, - "AUTH_JWT_SECRET=" + utils.Config.Auth.JwtSecret.Value, - "SUPABASE_ANON_KEY=" + utils.Config.Auth.AnonKey.Value, - "SUPABASE_SERVICE_KEY=" + utils.Config.Auth.ServiceRoleKey.Value, - "LOGFLARE_PRIVATE_ACCESS_TOKEN=" + utils.Config.Analytics.ApiKey, - "OPENAI_API_KEY=" + utils.Config.Studio.OpenaiApiKey.Value, - "PGRST_DB_SCHEMAS=" + strings.Join(utils.Config.Api.Schemas, ","), - "PGRST_DB_EXTRA_SEARCH_PATH=" + strings.Join(utils.Config.Api.ExtraSearchPath, ","), - fmt.Sprintf("PGRST_DB_MAX_ROWS=%d", utils.Config.Api.MaxRows), - fmt.Sprintf("LOGFLARE_URL=http://%v:4000", utils.LogflareId), - fmt.Sprintf("NEXT_PUBLIC_ENABLE_LOGS=%v", utils.Config.Analytics.Enabled), - fmt.Sprintf("NEXT_ANALYTICS_BACKEND_PROVIDER=%v", utils.Config.Analytics.Backend), - "EDGE_FUNCTIONS_MANAGEMENT_FOLDER=" + utils.ToDockerPath(filepath.Join(workdir, utils.FunctionsDir)), - "SNIPPETS_MANAGEMENT_FOLDER=" + containerSnippetsPath, - // Ref: https://github.com/vercel/next.js/issues/51684#issuecomment-1612834913 - "HOSTNAME=0.0.0.0", - "POSTGRES_USER_READ_WRITE=postgres", - }, + Env: buildStudioEnv(dbConfig, workdir, containerSnippetsPath), Healthcheck: &container.HealthConfig{ Test: []string{"CMD-SHELL", `node --eval="fetch('http://127.0.0.1:3000/api/platform/profile').then((r) => {if (!r.ok) throw new Error(r.status)})"`}, Interval: 10 * time.Second, @@ -1280,6 +1258,36 @@ func formatMapForEnvConfig(input map[string]string, output *bytes.Buffer) { } } +func buildStudioEnv(dbConfig pgconn.Config, workdir, containerSnippetsPath string) []string { + return []string{ + "CURRENT_CLI_VERSION=" + utils.Version, + "STUDIO_PG_META_URL=http://" + utils.PgmetaId + ":8080", + "POSTGRES_PASSWORD=" + dbConfig.Password, + "SUPABASE_URL=http://" + utils.KongId + ":8000", + "SUPABASE_PUBLIC_URL=" + utils.Config.Studio.ApiUrl, + "AUTH_JWT_SECRET=" + utils.Config.Auth.JwtSecret.Value, + "SUPABASE_ANON_KEY=" + utils.Config.Auth.AnonKey.Value, + "SUPABASE_SERVICE_KEY=" + utils.Config.Auth.ServiceRoleKey.Value, + "SUPABASE_PUBLISHABLE_KEY=" + utils.Config.Auth.PublishableKey.Value, + "SUPABASE_SECRET_KEY=" + utils.Config.Auth.SecretKey.Value, + "S3_PROTOCOL_ACCESS_KEY_ID=" + utils.Config.Storage.S3Credentials.AccessKeyId, + "S3_PROTOCOL_ACCESS_KEY_SECRET=" + utils.Config.Storage.S3Credentials.SecretAccessKey, + "LOGFLARE_PRIVATE_ACCESS_TOKEN=" + utils.Config.Analytics.ApiKey, + "OPENAI_API_KEY=" + utils.Config.Studio.OpenaiApiKey.Value, + "PGRST_DB_SCHEMAS=" + strings.Join(utils.Config.Api.Schemas, ","), + "PGRST_DB_EXTRA_SEARCH_PATH=" + strings.Join(utils.Config.Api.ExtraSearchPath, ","), + fmt.Sprintf("PGRST_DB_MAX_ROWS=%d", utils.Config.Api.MaxRows), + fmt.Sprintf("LOGFLARE_URL=http://%v:4000", utils.LogflareId), + fmt.Sprintf("NEXT_PUBLIC_ENABLE_LOGS=%v", utils.Config.Analytics.Enabled), + fmt.Sprintf("NEXT_ANALYTICS_BACKEND_PROVIDER=%v", utils.Config.Analytics.Backend), + "EDGE_FUNCTIONS_MANAGEMENT_FOLDER=" + utils.ToDockerPath(filepath.Join(workdir, utils.FunctionsDir)), + "SNIPPETS_MANAGEMENT_FOLDER=" + containerSnippetsPath, + // Ref: https://github.com/vercel/next.js/issues/51684#issuecomment-1612834913 + "HOSTNAME=0.0.0.0", + "POSTGRES_USER_READ_WRITE=postgres", + } +} + func buildGotrueEnv(dbConfig pgconn.Config) []string { var testOTP bytes.Buffer if len(utils.Config.Auth.Sms.TestOTP) > 0 { diff --git a/apps/cli-go/internal/start/start_test.go b/apps/cli-go/internal/start/start_test.go index d8d4deb982..afafbd815e 100644 --- a/apps/cli-go/internal/start/start_test.go +++ b/apps/cli-go/internal/start/start_test.go @@ -241,6 +241,10 @@ func TestDatabaseStart(t *testing.T) { Get("/storage/v1/bucket"). Reply(http.StatusOK). JSON([]storage.BucketResponse{}) + gock.New(utils.Config.Api.ExternalUrl). + Post("/storage/v1/vector/ListVectorBuckets"). + Reply(http.StatusOK). + JSON(storage.ListVectorBucketsResponse{}) // Run test err = run(ctx, fsys, []string{}, pgconn.Config{Host: utils.DbId}, conn.Intercept) // Check error @@ -383,6 +387,50 @@ func TestBuildGotrueEnv(t *testing.T) { }) } +func TestBuildStudioEnv(t *testing.T) { + originalConfig := utils.Config + originalKongId := utils.KongId + originalPgmetaId := utils.PgmetaId + originalLogflareId := utils.LogflareId + originalVersion := utils.Version + t.Cleanup(func() { + utils.Config = originalConfig + utils.KongId = originalKongId + utils.PgmetaId = originalPgmetaId + utils.LogflareId = originalLogflareId + utils.Version = originalVersion + }) + + utils.Config = config.NewConfig() + utils.Config.Studio.ApiUrl = "http://127.0.0.1:54321" + utils.Config.Auth.JwtSecret.Value = "jwt-secret" + utils.Config.Auth.AnonKey.Value = "anon-key" + utils.Config.Auth.ServiceRoleKey.Value = "service-role-key" + utils.Config.Auth.PublishableKey.Value = "sb_publishable_test" + utils.Config.Auth.SecretKey.Value = "sb_secret_test" + utils.Config.Storage.S3Credentials.AccessKeyId = "s3-access-key" + utils.Config.Storage.S3Credentials.SecretAccessKey = "s3-secret-key" + utils.KongId = "test-kong" + utils.PgmetaId = "test-pgmeta" + utils.LogflareId = "test-logflare" + utils.Version = "test-version" + + env := envToMap(buildStudioEnv( + pgconn.Config{Password: "postgres"}, + "/project", + "/project/supabase/.temp/snippets", + )) + + assert.Equal(t, "anon-key", env["SUPABASE_ANON_KEY"]) + assert.Equal(t, "service-role-key", env["SUPABASE_SERVICE_KEY"]) + assert.Equal(t, "sb_publishable_test", env["SUPABASE_PUBLISHABLE_KEY"]) + assert.Equal(t, "sb_secret_test", env["SUPABASE_SECRET_KEY"]) + assert.Equal(t, "s3-access-key", env["S3_PROTOCOL_ACCESS_KEY_ID"]) + assert.Equal(t, "s3-secret-key", env["S3_PROTOCOL_ACCESS_KEY_SECRET"]) + assert.Equal(t, "http://test-kong:8000", env["SUPABASE_URL"]) + assert.Equal(t, "http://test-pgmeta:8080", env["STUDIO_PG_META_URL"]) +} + func TestFormatMapForEnvConfig(t *testing.T) { t.Run("It produces the correct format and removes the trailing comma", func(t *testing.T) { testcases := []struct { diff --git a/apps/cli-go/internal/telemetry/state.go b/apps/cli-go/internal/telemetry/state.go index 8d69996468..825ce5fc7c 100644 --- a/apps/cli-go/internal/telemetry/state.go +++ b/apps/cli-go/internal/telemetry/state.go @@ -32,6 +32,16 @@ type State struct { SchemaVersion int `json:"schema_version"` } +type rawState struct { + Enabled *bool `json:"enabled"` + Consent *string `json:"consent"` + DeviceID string `json:"device_id"` + SessionID string `json:"session_id"` + SessionLastActive json.RawMessage `json:"session_last_active"` + DistinctID string `json:"distinct_id,omitempty"` + SchemaVersion int `json:"schema_version"` +} + func telemetryPath() (string, error) { if home := strings.TrimSpace(os.Getenv("SUPABASE_HOME")); home != "" { return filepath.Join(home, "telemetry.json"), nil @@ -43,6 +53,71 @@ func telemetryPath() (string, error) { return filepath.Join(home, ".supabase", "telemetry.json"), nil } +func parseConsent(raw rawState) (bool, bool, error) { + if raw.Consent != nil { + switch *raw.Consent { + case "granted": + return true, true, nil + case "denied": + return false, true, nil + default: + return false, false, errors.Errorf("%w: invalid consent", errMalformedState) + } + } + if raw.Enabled == nil { + return false, false, errors.Errorf("%w: missing enabled", errMalformedState) + } + return *raw.Enabled, false, nil +} + +func parseSessionLastActive(raw json.RawMessage, allowUnixMillis bool) (time.Time, error) { + var text string + if err := json.Unmarshal(raw, &text); err == nil { + parsed, err := time.Parse(time.RFC3339Nano, text) + if err != nil { + return time.Time{}, errors.Errorf("%w: invalid session_last_active", errMalformedState) + } + return parsed, nil + } + if allowUnixMillis { + var millis int64 + if err := json.Unmarshal(raw, &millis); err == nil { + return time.UnixMilli(millis).UTC(), nil + } + } + return time.Time{}, errors.Errorf("%w: invalid session_last_active", errMalformedState) +} + +func decodeState(contents []byte) (State, error) { + var raw rawState + if err := json.Unmarshal(contents, &raw); err != nil { + return State{}, errors.Errorf("%w: %v", errMalformedState, err) + } + enabled, allowUnixMillis, err := parseConsent(raw) + if err != nil { + return State{}, err + } + sessionLastActive, err := parseSessionLastActive(raw.SessionLastActive, allowUnixMillis) + if err != nil { + return State{}, err + } + if raw.DeviceID == "" || raw.SessionID == "" { + return State{}, errors.Errorf("%w: missing identity", errMalformedState) + } + schemaVersion := raw.SchemaVersion + if schemaVersion == 0 { + schemaVersion = SchemaVersion + } + return State{ + Enabled: enabled, + DeviceID: raw.DeviceID, + SessionID: raw.SessionID, + SessionLastActive: sessionLastActive, + DistinctID: raw.DistinctID, + SchemaVersion: schemaVersion, + }, nil +} + func LoadState(fsys afero.Fs) (State, error) { path, err := telemetryPath() if err != nil { @@ -52,11 +127,7 @@ func LoadState(fsys afero.Fs) (State, error) { if err != nil { return State{}, err } - var state State - if err := json.Unmarshal(contents, &state); err != nil { - return State{}, errors.Errorf("%w: %v", errMalformedState, err) - } - return state, nil + return decodeState(contents) } func SaveState(state State, fsys afero.Fs) error { diff --git a/apps/cli-go/internal/telemetry/state_test.go b/apps/cli-go/internal/telemetry/state_test.go index 9cd03dd967..7ca3a178c6 100644 --- a/apps/cli-go/internal/telemetry/state_test.go +++ b/apps/cli-go/internal/telemetry/state_test.go @@ -79,6 +79,54 @@ func TestLoadOrCreateState(t *testing.T) { assert.Equal(t, now, state.SessionLastActive) }) + t.Run("reads disabled TypeScript telemetry config", func(t *testing.T) { + t.Setenv("SUPABASE_HOME", "/tmp/supabase-home") + fsys := afero.NewMemMapFs() + path, err := telemetryPath() + require.NoError(t, err) + require.NoError(t, fsys.MkdirAll("/tmp/supabase-home", 0755)) + require.NoError(t, afero.WriteFile( + fsys, + path, + []byte(`{"consent":"denied","device_id":"ts-device","session_id":"ts-session","session_last_active":1776770348993,"distinct_id":"user-123"}`), + 0644, + )) + + state, created, err := LoadOrCreateState(fsys, now) + + require.NoError(t, err) + assert.False(t, created) + assert.False(t, state.Enabled) + assert.Equal(t, "ts-device", state.DeviceID) + assert.Equal(t, "ts-session", state.SessionID) + assert.Equal(t, "user-123", state.DistinctID) + assert.Equal(t, SchemaVersion, state.SchemaVersion) + }) + + t.Run("reads enabled TypeScript telemetry config", func(t *testing.T) { + t.Setenv("SUPABASE_HOME", "/tmp/supabase-home") + fsys := afero.NewMemMapFs() + path, err := telemetryPath() + require.NoError(t, err) + require.NoError(t, fsys.MkdirAll("/tmp/supabase-home", 0755)) + require.NoError(t, afero.WriteFile( + fsys, + path, + []byte(`{"consent":"granted","device_id":"ts-device","session_id":"ts-session","session_last_active":1776770348993,"distinct_id":"user-123"}`), + 0644, + )) + + state, created, err := LoadOrCreateState(fsys, now) + + require.NoError(t, err) + assert.False(t, created) + assert.True(t, state.Enabled) + assert.Equal(t, "ts-device", state.DeviceID) + assert.Equal(t, "ts-session", state.SessionID) + assert.Equal(t, "user-123", state.DistinctID) + assert.Equal(t, SchemaVersion, state.SchemaVersion) + }) + t.Run("recovers from corrupted state file", func(t *testing.T) { // Each entry simulates a real-world corruption shape we've observed. corruptions := map[string][]byte{ diff --git a/apps/cli-go/internal/utils/edgeruntime.go b/apps/cli-go/internal/utils/edgeruntime.go index 06a42b464c..d7070f3f9e 100644 --- a/apps/cli-go/internal/utils/edgeruntime.go +++ b/apps/cli-go/internal/utils/edgeruntime.go @@ -3,6 +3,8 @@ package utils import ( "bytes" "context" + "fmt" + "net" "strings" "github.com/docker/docker/api/types/container" @@ -11,10 +13,35 @@ import ( "github.com/spf13/viper" ) +// getFreeHostPort asks the OS for an unused TCP port on the host. +func getFreeHostPort() (int, error) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return 0, errors.Errorf("failed to allocate free port: %w", err) + } + defer listener.Close() + return listener.Addr().(*net.TCPAddr).Port, nil +} + +// EdgeRuntimeStartCmd builds the base command for launching a one-shot Edge +// Runtime script. The runtime's HTTP listener is bound to a free host port so +// concurrent or leftover containers (which share the host network namespace +// because diff containers run with NetworkMode=host) don't collide on the +// edge-runtime default port, which surfaces as "Address already in use (os +// error 98)". See https://github.com/supabase/cli/issues/5407. +func EdgeRuntimeStartCmd() []string { + cmd := []string{"edge-runtime", "start", "--main-service=."} + // Skip the flag on the rare allocation failure to preserve prior behavior. + if port, err := getFreeHostPort(); err == nil { + cmd = append(cmd, fmt.Sprintf("--port=%d", port)) + } + return cmd +} + // RunEdgeRuntimeScript executes a TypeScript program inside the configured Edge // Runtime container and streams stdout/stderr back to the caller. func RunEdgeRuntimeScript(ctx context.Context, env []string, script string, binds []string, errPrefix string, stdout, stderr *bytes.Buffer) error { - cmd := []string{"edge-runtime", "start", "--main-service=."} + cmd := EdgeRuntimeStartCmd() if viper.GetBool("DEBUG") { cmd = append(cmd, "--verbose") } diff --git a/apps/cli-go/internal/utils/edgeruntime_test.go b/apps/cli-go/internal/utils/edgeruntime_test.go new file mode 100644 index 0000000000..8bed5b0974 --- /dev/null +++ b/apps/cli-go/internal/utils/edgeruntime_test.go @@ -0,0 +1,47 @@ +package utils + +import ( + "strconv" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEdgeRuntimeStartCmd(t *testing.T) { + t.Run("binds an explicit free port", func(t *testing.T) { + cmd := EdgeRuntimeStartCmd() + // Base command must always be present. + assert.Equal(t, []string{"edge-runtime", "start", "--main-service=."}, cmd[:3]) + // A --port flag avoids collisions on the edge-runtime default port (#5407). + var portFlag string + for _, arg := range cmd { + if strings.HasPrefix(arg, "--port=") { + portFlag = arg + } + } + require.NotEmpty(t, portFlag, "expected a --port flag to be set") + port, err := strconv.Atoi(strings.TrimPrefix(portFlag, "--port=")) + require.NoError(t, err) + assert.Greater(t, port, 0) + assert.LessOrEqual(t, port, 65535) + }) + + t.Run("allocates a distinct port per invocation", func(t *testing.T) { + first := getPortArg(t, EdgeRuntimeStartCmd()) + second := getPortArg(t, EdgeRuntimeStartCmd()) + assert.NotEqual(t, first, second) + }) +} + +func getPortArg(t *testing.T, cmd []string) string { + t.Helper() + for _, arg := range cmd { + if strings.HasPrefix(arg, "--port=") { + return arg + } + } + require.FailNow(t, "missing --port flag") + return "" +} diff --git a/apps/cli-go/pkg/api/client.gen.go b/apps/cli-go/pkg/api/client.gen.go index 5de49ee896..43da53e9ae 100644 --- a/apps/cli-go/pkg/api/client.gen.go +++ b/apps/cli-go/pkg/api/client.gen.go @@ -12011,7 +12011,7 @@ func (r V1RevokeTokenResponse) StatusCode() int { type V1ExchangeOauthTokenResponse struct { Body []byte HTTPResponse *http.Response - JSON201 *OAuthTokenResponse + JSON200 *OAuthTokenResponse } // Status returns HTTPResponse.Status @@ -17534,12 +17534,12 @@ func ParseV1ExchangeOauthTokenResponse(rsp *http.Response) (*V1ExchangeOauthToke } switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 201: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: var dest OAuthTokenResponse if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.JSON201 = &dest + response.JSON200 = &dest } diff --git a/apps/cli-go/pkg/api/types.gen.go b/apps/cli-go/pkg/api/types.gen.go index c3287bc286..840f1b82b9 100644 --- a/apps/cli-go/pkg/api/types.gen.go +++ b/apps/cli-go/pkg/api/types.gen.go @@ -3327,9 +3327,24 @@ type PgsodiumConfigResponse struct { // PostgresConfigResponse defines model for PostgresConfigResponse. type PostgresConfigResponse struct { // CheckpointTimeout Default unit: s - CheckpointTimeout *string `json:"checkpoint_timeout,omitempty"` - EffectiveCacheSize *string `json:"effective_cache_size,omitempty"` - HotStandbyFeedback *bool `json:"hot_standby_feedback,omitempty"` + CheckpointTimeout *string `json:"checkpoint_timeout,omitempty"` + CronLogStatement *bool `json:"cron.log_statement,omitempty"` + EffectiveCacheSize *string `json:"effective_cache_size,omitempty"` + HotStandbyFeedback *bool `json:"hot_standby_feedback,omitempty"` + + // LogAutovacuumMinDuration Default unit: ms + LogAutovacuumMinDuration *string `json:"log_autovacuum_min_duration,omitempty"` + LogCheckpoints *bool `json:"log_checkpoints,omitempty"` + LogConnections *bool `json:"log_connections,omitempty"` + LogDisconnections *bool `json:"log_disconnections,omitempty"` + LogDuration *bool `json:"log_duration,omitempty"` + LogLockWaits *bool `json:"log_lock_waits,omitempty"` + LogRecoveryConflictWaits *bool `json:"log_recovery_conflict_waits,omitempty"` + LogReplicationCommands *bool `json:"log_replication_commands,omitempty"` + + // LogStartupProgressInterval Default unit: ms + LogStartupProgressInterval *string `json:"log_startup_progress_interval,omitempty"` + LogTempFiles *string `json:"log_temp_files,omitempty"` LogicalDecodingWorkMem *string `json:"logical_decoding_work_mem,omitempty"` MaintenanceWorkMem *string `json:"maintenance_work_mem,omitempty"` MaxConnections *int `json:"max_connections,omitempty"` @@ -4229,9 +4244,24 @@ type UpdatePgsodiumConfigBody struct { // UpdatePostgresConfigBody defines model for UpdatePostgresConfigBody. type UpdatePostgresConfigBody struct { // CheckpointTimeout Default unit: s - CheckpointTimeout *string `json:"checkpoint_timeout,omitempty"` - EffectiveCacheSize *string `json:"effective_cache_size,omitempty"` - HotStandbyFeedback *bool `json:"hot_standby_feedback,omitempty"` + CheckpointTimeout *string `json:"checkpoint_timeout,omitempty"` + CronLogStatement *bool `json:"cron.log_statement,omitempty"` + EffectiveCacheSize *string `json:"effective_cache_size,omitempty"` + HotStandbyFeedback *bool `json:"hot_standby_feedback,omitempty"` + + // LogAutovacuumMinDuration Default unit: ms + LogAutovacuumMinDuration *string `json:"log_autovacuum_min_duration,omitempty"` + LogCheckpoints *bool `json:"log_checkpoints,omitempty"` + LogConnections *bool `json:"log_connections,omitempty"` + LogDisconnections *bool `json:"log_disconnections,omitempty"` + LogDuration *bool `json:"log_duration,omitempty"` + LogLockWaits *bool `json:"log_lock_waits,omitempty"` + LogRecoveryConflictWaits *bool `json:"log_recovery_conflict_waits,omitempty"` + LogReplicationCommands *bool `json:"log_replication_commands,omitempty"` + + // LogStartupProgressInterval Default unit: ms + LogStartupProgressInterval *string `json:"log_startup_progress_interval,omitempty"` + LogTempFiles *string `json:"log_temp_files,omitempty"` LogicalDecodingWorkMem *string `json:"logical_decoding_work_mem,omitempty"` MaintenanceWorkMem *string `json:"maintenance_work_mem,omitempty"` MaxConnections *int `json:"max_connections,omitempty"` diff --git a/apps/cli-go/pkg/config/config_test.go b/apps/cli-go/pkg/config/config_test.go index d7bca3948d..2a1a189076 100644 --- a/apps/cli-go/pkg/config/config_test.go +++ b/apps/cli-go/pkg/config/config_test.go @@ -37,6 +37,7 @@ func TestConfigParsing(t *testing.T) { err := config.Load("", fs.MapFS{}) // Check error assert.NoError(t, err) + assert.True(t, config.Storage.VectorBuckets.Enabled) }) t.Run("auth external url defaults from api external url", func(t *testing.T) { diff --git a/apps/cli-go/pkg/config/templates/Dockerfile b/apps/cli-go/pkg/config/templates/Dockerfile index 76c42d1bbd..753d10ee35 100644 --- a/apps/cli-go/pkg/config/templates/Dockerfile +++ b/apps/cli-go/pkg/config/templates/Dockerfile @@ -5,15 +5,15 @@ FROM library/kong:2.8.1 AS kong FROM axllent/mailpit:v1.22.3 AS mailpit FROM postgrest/postgrest:v14.12 AS postgrest FROM supabase/postgres-meta:v0.96.6 AS pgmeta -FROM supabase/studio:2026.05.25-sha-65c570e AS studio +FROM supabase/studio:2026.06.03-sha-0bca601 AS studio FROM darthsim/imgproxy:v3.8.0 AS imgproxy FROM supabase/edge-runtime:v1.74.0 AS edgeruntime FROM timberio/vector:0.53.0-alpine AS vector -FROM supabase/supavisor:2.9.5 AS supavisor +FROM supabase/supavisor:2.9.7 AS supavisor FROM supabase/gotrue:v2.189.0 AS gotrue -FROM supabase/realtime:v2.102.1 AS realtime -FROM supabase/storage-api:v1.60.2 AS storage -FROM supabase/logflare:1.42.0 AS logflare +FROM supabase/realtime:v2.103.2 AS realtime +FROM supabase/storage-api:v1.60.4 AS storage +FROM supabase/logflare:1.43.3 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 c172cc4f4e..0c146d117c 100644 --- a/apps/cli-go/pkg/config/templates/config.toml +++ b/apps/cli-go/pkg/config/templates/config.toml @@ -145,7 +145,7 @@ max_catalogs = 2 # Store vector embeddings in S3 for large and durable datasets [storage.vector] -enabled = false +enabled = true max_buckets = 10 max_indexes = 5 diff --git a/apps/cli-go/pkg/go.mod b/apps/cli-go/pkg/go.mod index 0c2cc154fc..aa7e9d52a8 100644 --- a/apps/cli-go/pkg/go.mod +++ b/apps/cli-go/pkg/go.mod @@ -20,13 +20,13 @@ require ( github.com/jackc/pgx/v4 v4.18.3 github.com/joho/godotenv v1.5.1 github.com/oapi-codegen/nullable v1.1.0 - github.com/oapi-codegen/runtime v1.3.1 + github.com/oapi-codegen/runtime v1.4.1 github.com/spf13/afero v1.15.0 github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 github.com/tidwall/jsonc v0.3.3 - golang.org/x/mod v0.34.0 - google.golang.org/grpc v1.80.0 + golang.org/x/mod v0.36.0 + google.golang.org/grpc v1.81.1 ) require ( @@ -34,7 +34,7 @@ require ( 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/ethereum/go-ethereum v1.17.0 // indirect - github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/fsnotify/fsnotify v1.10.1 // indirect github.com/google/uuid v1.6.0 // indirect github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 // indirect github.com/jackc/chunkreader/v2 v2.0.1 // indirect @@ -51,8 +51,8 @@ require ( github.com/stretchr/objx v0.5.2 // indirect github.com/subosito/gotenv v1.6.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.47.0 // indirect - golang.org/x/sys v0.40.0 // indirect - golang.org/x/text v0.33.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.34.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/apps/cli-go/pkg/go.sum b/apps/cli-go/pkg/go.sum index dc8be0f4e4..12a49f10de 100644 --- a/apps/cli-go/pkg/go.sum +++ b/apps/cli-go/pkg/go.sum @@ -29,8 +29,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/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= -github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= +github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= 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-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= @@ -132,8 +132,8 @@ github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32 h1:W6apQkHrMkS0Muv8G/TipAy github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms= github.com/oapi-codegen/nullable v1.1.0 h1:eAh8JVc5430VtYVnq00Hrbpag9PFRGWLjxR1/3KntMs= github.com/oapi-codegen/nullable v1.1.0/go.mod h1:KUZ3vUzkmEKY90ksAmit2+5juDIhIZhfDl+0PwOQlFY= -github.com/oapi-codegen/runtime v1.3.1 h1:RgDY6J4OGQLbRXhG/Xpt3vSVqYpHQS7hN4m85+5xB9g= -github.com/oapi-codegen/runtime v1.3.1/go.mod h1:kOdeacKy7t40Rclb1je37ZLFboFxh+YLy0zaPCMibPY= +github.com/oapi-codegen/runtime v1.4.1 h1:9nwLoI+KrWxzbBcp0jO/R8uXqbik/HUyCvPeU68Y/qo= +github.com/oapi-codegen/runtime v1.4.1/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU= 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/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -217,15 +217,15 @@ golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 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.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= -golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= 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.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= -golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -255,8 +255,8 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.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= @@ -272,8 +272,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= @@ -290,8 +290,8 @@ golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= -google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= +google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= +google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= diff --git a/apps/cli/docs/go-cli-porting-status.md b/apps/cli/docs/go-cli-porting-status.md index f17de55366..ff70e62590 100644 --- a/apps/cli/docs/go-cli-porting-status.md +++ b/apps/cli/docs/go-cli-porting-status.md @@ -65,7 +65,7 @@ These commands exist in the TS CLI today but have no direct top-level equivalent | Old command | TS status | TS command path or `missing` | Missing flags/params | Extra TS flags/params | Notes | | ----------- | --------- | ------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `init` | `partial` | [`../src/next/commands/init/init.command.ts`](../src/next/commands/init/init.command.ts) | `--force`, `--interactive`, `--use-orioledb` | `-` | TS init creates a minimal `supabase/config.json` with only a `"$schema"` reference and ensures repo-local `.supabase/` state can stay gitignored, but it does not yet expose the old Go flag surface. | +| `init` | `ported` | [`../src/next/commands/init/init.command.ts`](../src/next/commands/init/init.command.ts) | `-` | `-` | TS init now writes the Go-style `supabase/config.toml` scaffold, updates `supabase/.gitignore` only when invoked inside a git repo, supports `--force`, `--interactive`, and `--use-orioledb`, and shares the same native implementation with the legacy shell command. | | `link` | `partial` | [`../src/next/commands/link/link.command.ts`](../src/next/commands/link/link.command.ts) | `--password`, `--skip-pooler` | `-` | TS link supports `--project-ref`, interactive project selection, and zero-config linking. It stores linked remote metadata in repo-local `.supabase/project.json`, but it does not yet manage direct database-password or pooler-specific link flows. | | `unlink` | `ported` | [`../src/next/commands/unlink/unlink.command.ts`](../src/next/commands/unlink/unlink.command.ts) | `-` | `-` | TS unlink matches the current Go surface and removes the repo-local linked project metadata for the active checkout. | | `login` | `ported` | [`../src/next/commands/login/login.command.ts`](../src/next/commands/login/login.command.ts) | `-` | `-` | Flag surface matches the old CLI: `--token`, `--name`, `--no-browser`. TS also supports env-var and piped-stdin token input without adding new flags. | @@ -261,16 +261,19 @@ Legend: | `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` | `wrapped` | [`../src/legacy/commands/login/login.command.ts`](../src/legacy/commands/login/login.command.ts) | -| `logout` | `wrapped` | [`../src/legacy/commands/logout/logout.command.ts`](../src/legacy/commands/logout/logout.command.ts) | -| `link` | `wrapped` | [`../src/legacy/commands/link/link.command.ts`](../src/legacy/commands/link/link.command.ts) | -| `unlink` | `wrapped` | [`../src/legacy/commands/unlink/unlink.command.ts`](../src/legacy/commands/unlink/unlink.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` | `wrapped` | [`../src/legacy/commands/bootstrap/bootstrap.command.ts`](../src/legacy/commands/bootstrap/bootstrap.command.ts) | -| `init` | `wrapped` | [`../src/legacy/commands/init/init.command.ts`](../src/legacy/commands/init/init.command.ts) | +| `init` | `ported` | [`../src/legacy/commands/init/init.command.ts`](../src/legacy/commands/init/init.command.ts) | | `services` | `wrapped` | [`../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` | `wrapped` | [`../src/legacy/commands/migration/list/list.command.ts`](../src/legacy/commands/migration/list/list.command.ts) | | `migration new` | `wrapped` | [`../src/legacy/commands/migration/new/new.command.ts`](../src/legacy/commands/migration/new/new.command.ts) | | `migration repair` | `wrapped` | [`../src/legacy/commands/migration/repair/repair.command.ts`](../src/legacy/commands/migration/repair/repair.command.ts) | diff --git a/apps/cli/package.json b/apps/cli/package.json index fa98c8d27c..0bc2ba0687 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -37,10 +37,13 @@ "fix:all": "nx run-many -t lint:fix fmt:fix knip:fix --projects=$npm_package_name" }, "devDependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.3.146", + "@anthropic-ai/sdk": "^0.97.1", "@clack/prompts": "^1.4.0", "@effect/atom-react": "catalog:", "@effect/platform-bun": "catalog:", "@effect/vitest": "catalog:", + "@modelcontextprotocol/sdk": "^1.29.0", "@napi-rs/keyring": "^1.3.0", "@parcel/watcher": "^2.5.6", "@supabase/api": "workspace:*", @@ -120,7 +123,10 @@ "oxfmt", "oxlint", "oxlint-tsgolint", - "semantic-release" + "semantic-release", + "@anthropic-ai/claude-agent-sdk", + "@anthropic-ai/sdk", + "@modelcontextprotocol/sdk" ] }, "nx": { diff --git a/apps/cli/scripts/apply-release-notes.ts b/apps/cli/scripts/apply-release-notes.ts new file mode 100644 index 0000000000..9027208231 --- /dev/null +++ b/apps/cli/scripts/apply-release-notes.ts @@ -0,0 +1,37 @@ +#!/usr/bin/env bun +// Push the contents of release-notes/v.md to the GitHub Release body +// for tag v. Invoked from apply-release-notes.yml after a +// release-notes PR is merged to main. +// +// Usage: +// bun apps/cli/scripts/apply-release-notes.ts --tag v2.101.0 +import { $ } from "bun"; +import { existsSync } from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { parseArgs } from "node:util"; + +const { values } = parseArgs({ + options: { + tag: { type: "string" }, + }, + strict: true, +}); + +const tag = values.tag; +if (!tag) { + console.error("--tag is required (e.g. --tag v2.101.0)"); + process.exit(2); +} +const version = tag.replace(/^v/, ""); + +const repoRoot = (await $`git rev-parse --show-toplevel`.text()).trim(); +const notesPath = path.join(repoRoot, "release-notes", `v${version}.md`); +if (!existsSync(notesPath)) { + console.error(`No notes file at ${path.relative(repoRoot, notesPath)}`); + process.exit(1); +} + +console.error(`==> Updating GitHub Release body for ${tag}`); +await $`gh release edit ${tag} --notes-file ${notesPath}`.cwd(repoRoot); +console.error(`==> Done`); diff --git a/apps/cli/scripts/propose-release-notes.ts b/apps/cli/scripts/propose-release-notes.ts new file mode 100644 index 0000000000..37abf57fbd --- /dev/null +++ b/apps/cli/scripts/propose-release-notes.ts @@ -0,0 +1,222 @@ +#!/usr/bin/env bun +// Generate a user-centric GitHub Release body for a Supabase CLI tag +// by running the Claude Agent SDK against tools/release/release-notes-prompt.md +// with the raw semantic-release block substituted in. +// +// Pipeline shape: +// 1. `backfill-release-notes.ts --tag ` produces the raw semantic-release +// markdown (without writing anything to the GH release). We always +// re-derive this so the proposer is decoupled from whatever happens to +// sit in the release body at the moment. +// 2. The raw block is inlined into tools/release/release-notes-prompt.md in +// place of the {{PASTE_SEMANTIC_RELEASE_BLOCK_HERE}} placeholder. +// 3. The Claude Agent SDK runs the rendered prompt with WebFetch + Bash so +// it can investigate PR bodies, linked issues, and changed files (the +// prompt's investigation step is real work, not boilerplate). +// 4. The agent's final assistant message is written to +// release-notes/v.md. +// 5. Unless --dry-run is passed, the script commits the file on a branch +// `release-notes/v` and opens a PR. Approving the PR (as a +// supabase/cli team member) triggers apply-release-notes.yml, which +// pushes the file's contents to the GH release body and closes the PR +// without merging. The PR targets `develop` (not `main`) so an +// accidental merge can never rewrite `main`'s history; in practice the +// file never lands on any branch. +// +// Usage: +// bun apps/cli/scripts/propose-release-notes.ts --tag v2.101.0 --dry-run +// bun apps/cli/scripts/propose-release-notes.ts --tag v2.101.0 --apply +// +// --tag Required. Release tag (e.g. v2.101.0 or v2.99.0-beta.1). +// --dry-run Print the proposed notes to stdout. Does not write any files, +// does not touch git. +// --apply Write release-notes/v.md, commit on a branch, push, +// and open a PR. Default behavior when neither flag is passed +// is `--dry-run`. +// --render-only Print the rendered prompt (template + raw notes block) +// and exit before any LLM call. Useful for prompt iteration +// and for verifying the pipeline shape without spending tokens. +// --model Optional. Override the Claude model (default: claude-haiku-4-5-20251001). +import { query, type Options } from "@anthropic-ai/claude-agent-sdk"; +import { $ } from "bun"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { parseArgs } from "node:util"; + +const { values } = parseArgs({ + options: { + tag: { type: "string" }, + "dry-run": { type: "boolean", default: false }, + apply: { type: "boolean", default: false }, + "render-only": { type: "boolean", default: false }, + model: { type: "string", default: "claude-haiku-4-5-20251001" }, + }, + strict: true, +}); + +const tag = values.tag; +if (!tag) { + console.error("--tag is required (e.g. --tag v2.101.0)"); + process.exit(2); +} +const version = tag.replace(/^v/, ""); +const apply = values.apply === true && values["dry-run"] !== true; + +const repoRoot = (await $`git rev-parse --show-toplevel`.text()).trim(); +const promptPath = path.join(repoRoot, "tools/release/release-notes-prompt.md"); +const backfillScript = path.join(repoRoot, "apps/cli/scripts/backfill-release-notes.ts"); +const notesDir = path.join(repoRoot, "release-notes"); +const notesPath = path.join(notesDir, `v${version}.md`); + +console.error(`==> Re-deriving raw semantic-release notes for ${tag}`); +const rawNotes = (await $`bun ${backfillScript} --tag ${tag}`.cwd(repoRoot).text()).trim(); +if (!rawNotes) { + console.error(`backfill-release-notes produced no output for ${tag}`); + process.exit(1); +} + +const promptTemplate = await readFile(promptPath, "utf8"); +const placeholder = "{{PASTE_SEMANTIC_RELEASE_BLOCK_HERE}}"; +if (!promptTemplate.includes(placeholder)) { + console.error(`Prompt template at ${promptPath} is missing ${placeholder}`); + process.exit(1); +} +const rendered = promptTemplate.replace(placeholder, rawNotes); + +if (values["render-only"]) { + process.stdout.write(rendered); + process.exit(0); +} + +console.error(`==> Running Claude Agent SDK (model=${values.model})`); +const options: Options = { + model: values.model, + // The agent needs WebFetch / WebSearch to investigate PR bodies and linked + // issues per the prompt's step 3, and Bash so it can use `gh` for + // authenticated GitHub queries instead of HTML scraping. Edit/Write are + // intentionally excluded β€” the script owns the final file output. + allowedTools: ["WebFetch", "WebSearch", "Bash"], + // Don't load the repo's CLAUDE.md or settings.json β€” the prompt is + // self-contained and we don't want unrelated agent context bleeding in. + settingSources: [], + cwd: repoRoot, + effort: "low", +}; + +let finalText = ""; +let cost = 0; +const stream = query({ prompt: rendered, options }); +for await (const msg of stream) { + if (msg.type === "result") { + if (msg.subtype === "success") { + finalText = msg.result; + cost = msg.total_cost_usd; + } else { + console.error(`Agent failed: ${msg.subtype}`); + if (msg.errors?.length) console.error(msg.errors.join("\n")); + process.exit(1); + } + } +} + +if (!finalText.trim()) { + console.error("Agent returned no result text"); + process.exit(1); +} + +// Append the raw notes to the final text to ensure the output is complete. +const normalized = finalText.endsWith("\n") ? finalText : `${finalText}\n`; +console.error(`==> Agent finished (cost ~$${cost.toFixed(4)})`); + +if (!apply) { + process.stdout.write(normalized); + process.exit(0); +} + +await mkdir(notesDir, { recursive: true }); +if (existsSync(notesPath)) { + console.error( + `Refusing to overwrite existing ${path.relative(repoRoot, notesPath)}. ` + + `Delete it or rerun with --dry-run to preview.`, + ); + process.exit(1); +} +await writeFile(notesPath, normalized); +console.error(`==> Wrote ${path.relative(repoRoot, notesPath)}`); + +const branch = `release-notes/v${version}`; +// Always cut the notes branch from origin/develop β€” the PR base. The workflow +// can be dispatched from an arbitrary feature branch that has diverged from +// the base by many commits; branching off the checked-out ref would drag +// every one of those commits into the PR (so the PR shows N changed files +// instead of just the proposed notes). The notes file is untracked at this +// point, so resetting HEAD to origin/develop leaves it untouched in the +// working tree. We target `develop` rather than `main` so that an accidental +// merge of this approval-only PR lands on the integration branch instead of +// rewriting `main`'s history. +await $`git fetch --no-tags origin develop`.cwd(repoRoot).nothrow(); +await $`git checkout -B ${branch} origin/develop`.cwd(repoRoot); +await $`git add ${notesPath}`.cwd(repoRoot); +const commitMessage = `docs(release): propose user-facing notes for ${tag}`; +await $`git commit -m ${commitMessage}`.cwd(repoRoot); + +console.error(`==> Pushing ${branch}`); +let pushed = false; +for (let attempt = 0; attempt < 4; attempt++) { + const result = await $`git push -u origin ${branch}`.cwd(repoRoot).nothrow(); + if (result.exitCode === 0) { + pushed = true; + break; + } + const wait = 2 ** (attempt + 1) * 1000; + console.error(`Push failed (attempt ${attempt + 1}/4); retrying in ${wait / 1000}s`); + await new Promise((r) => setTimeout(r, wait)); +} +if (!pushed) { + console.error("git push failed after 4 attempts"); + process.exit(1); +} + +// Idempotently ensure the `do not merge` label exists on the repo, then attach +// it on PR creation. The label is a visual reminder for reviewers β€” the +// approval-based apply workflow never invokes the merge button β€” but the +// publish flow itself does not depend on it. +const labelName = "do not merge"; +await $`gh label create ${labelName} --color B60205 --description ${"Approve to apply; do not merge."} --force` + .cwd(repoRoot) + .nothrow(); + +const releaseUrl = `https://github.com/supabase/cli/releases/tag/${tag}`; +const prBody = `Proposed user-facing release notes for \`${tag}\`, generated by \`apps/cli/scripts/propose-release-notes.ts\` against \`tools/release/release-notes-prompt.md\`. + +## How to update the notes + +Edit \`release-notes/v${version}.md\` directly on this branch β€” use the GitHub web editor or push commits to \`${branch}\` β€” before approving. The applied notes will reflect the file at the approved commit. + +## How to publish + +Approve this PR as a \`supabase/cli\` team member. The \`.github/workflows/apply-release-notes.yml\` workflow will then: + +1. Overwrite the GitHub Release body for [\`${tag}\`](${releaseUrl}) with the contents of \`release-notes/v${version}.md\`. +2. Comment the release URL on this PR. +3. Close this PR and delete the \`${branch}\` branch. + +**This PR is not merged** β€” the \`do not merge\` label is a reminder. It targets \`develop\` so that even an accidental merge never rewrites \`main\`. Nothing is meant to land on any branch. + +Approvals from anyone outside the \`supabase/cli\` team are ignored; the workflow will post a comment explaining that and leave the release untouched. + +## How to abandon + +Close the PR without approving. The auto-generated semantic-release body for \`${tag}\` stays in place. + +## Re-generation + +After this PR is closed, rerun the **Propose release notes** workflow from the Actions tab against \`${tag}\` to get a fresh proposal. +`; + +await $`gh pr create --title ${`docs(release): notes for ${tag}`} --body ${prBody} --base develop --head ${branch} --label ${labelName}`.cwd( + repoRoot, +); +console.error(`==> PR opened for ${branch}`); diff --git a/apps/cli/src/legacy/auth/legacy-credentials.layer.ts b/apps/cli/src/legacy/auth/legacy-credentials.layer.ts index 725d07db12..d558403e55 100644 --- a/apps/cli/src/legacy/auth/legacy-credentials.layer.ts +++ b/apps/cli/src/legacy/auth/legacy-credentials.layer.ts @@ -2,9 +2,15 @@ import { Effect, FileSystem, Layer, Option, Path, Redacted } from "effect"; import { RuntimeInfo } from "../../shared/runtime/runtime-info.service.ts"; import { normalizeKeyringToken } from "../../shared/auth/keyring-token.ts"; +import { LegacyDebugLogger } from "../shared/legacy-debug-logger.service.ts"; import { LegacyCliConfig } from "../config/legacy-cli-config.service.ts"; import { LegacyCredentials } from "./legacy-credentials.service.ts"; -import { LegacyInvalidAccessTokenError } from "./legacy-errors.ts"; +import { + LegacyCredentialDeleteError, + LegacyDeleteTokenError, + LegacyInvalidAccessTokenError, + LegacyNotLoggedInError, +} from "./legacy-errors.ts"; const KEYRING_SERVICE = "Supabase CLI"; const LEGACY_KEYRING_ACCOUNT = "access-token"; @@ -14,7 +20,12 @@ const ACCESS_TOKEN_PATTERN = /^sbp_(oauth_)?[a-f0-9]{40}$/; const INVALID_TOKEN_MESSAGE = "Invalid access token format. Must be like `sbp_0102...1920`."; +// Go's `utils.ErrNotLoggedIn` (`access_token.go:19`). +const NOT_LOGGED_IN_MESSAGE = "You were not logged in, nothing to do."; + type KeyringModule = typeof import("@napi-rs/keyring"); +type KeyringEntry = InstanceType; +type RuntimePlatform = NodeJS.Platform; const detectWsl = (fs: FileSystem.FileSystem): Effect.Effect => Effect.gen(function* () { @@ -29,14 +40,22 @@ const detectWsl = (fs: FileSystem.FileSystem): Effect.Effect => const tryKeyringRead = ( module: KeyringModule, account: string, + platform: RuntimePlatform, ): Effect.Effect> => Effect.try({ try: () => { const entry = new module.Entry(KEYRING_SERVICE, account); - const value = entry.getPassword(); - return value && value.length > 0 - ? Option.some(normalizeKeyringToken(value)) - : Option.none(); + const value = readEntryPassword(entry); + if (value && value.length > 0) return Option.some(normalizeKeyringToken(value)); + + if (platform === "win32") { + const goWindowsValue = readGoWindowsTarget(module, account); + if (goWindowsValue && goWindowsValue.length > 0) { + return Option.some(normalizeKeyringToken(goWindowsValue)); + } + } + + return Option.none(); }, catch: () => Option.none(), }).pipe(Effect.orElseSucceed(() => Option.none())); @@ -45,9 +64,14 @@ const tryKeyringWrite = ( module: KeyringModule, account: string, token: string, + platform: RuntimePlatform, ): Effect.Effect => Effect.try({ try: () => { + if (platform === "win32") { + return writeGoWindowsTarget(module, account, token); + } + const entry = new module.Entry(KEYRING_SERVICE, account); entry.setPassword(token); return true; @@ -55,33 +79,263 @@ const tryKeyringWrite = ( catch: () => false, }).pipe(Effect.orElseSucceed(() => false)); -const tryKeyringDelete = (module: KeyringModule, account: string): Effect.Effect => +const tryKeyringDelete = ( + module: KeyringModule, + account: string, + platform: RuntimePlatform, +): Effect.Effect => Effect.try({ try: () => { + let deleted = false; + const entry = new module.Entry(KEYRING_SERVICE, account); - const value = entry.getPassword(); - if (!value) return false; - entry.deleteCredential(); - return true; + const value = readEntryPassword(entry); + if (value) { + entry.deleteCredential(); + deleted = true; + } + + if (platform === "win32" && readGoWindowsTarget(module, account)) { + deleted = deleteGoWindowsTarget(module, account) || deleted; + } + + return deleted; }, catch: () => false, }).pipe(Effect.orElseSucceed(() => false)); +function readEntryPassword(entry: KeyringEntry): string | null { + try { + return entry.getPassword(); + } catch { + return null; + } +} + +function goWindowsCredentialTarget(account: string): string { + return `${KEYRING_SERVICE}:${account}`; +} + +function readGoWindowsTarget(module: KeyringModule, account: string): string | null { + try { + const credentials = module.findCredentials(KEYRING_SERVICE, goWindowsCredentialTarget(account)); + const credential = credentials.find((item) => item.account === account); + return credential ? normalizeGoWindowsPassword(credential.password) : null; + } catch { + return null; + } +} + +function normalizeGoWindowsPassword(value: string): string { + const direct = normalizeKeyringToken(value); + if (ACCESS_TOKEN_PATTERN.test(direct)) return direct; + + // Go writes Windows CredentialBlob values as raw UTF-8 bytes. The TS keyring + // search API can surface those bytes packed into UTF-16 code units, so unpack + // each code unit back into the original byte sequence before validation. + const bytes: number[] = []; + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + bytes.push(code & 0xff); + const high = (code >> 8) & 0xff; + if (high !== 0) bytes.push(high); + } + return Buffer.from(bytes).toString("utf8"); +} + +function writeGoWindowsTarget(module: KeyringModule, account: string, token: string): boolean { + try { + const entry = module.Entry.withTarget( + goWindowsCredentialTarget(account), + KEYRING_SERVICE, + account, + ); + entry.setSecret(Buffer.from(token, "utf8")); + return true; + } catch { + return false; + } +} + +function deleteGoWindowsTarget(module: KeyringModule, account: string): boolean { + try { + const entry = module.Entry.withTarget( + goWindowsCredentialTarget(account), + KEYRING_SERVICE, + account, + ); + return entry.deleteCredential(); + } catch { + return false; + } +} +// Delete the project database-password entry (keyed by project ref), surfacing a +// real failure while ignoring the "nothing to delete" cases β€” mirroring Go's +// unlink, which ignores both `keyring.ErrNotFound` AND `credentials.ErrNotSupported` +// (backend unavailable) and only surfaces other errors (`unlink.go:36-40`). +// +// The plain `Entry(service, projectRef)` is the macOS/Linux form and the Windows +// default. On Windows, Go also writes a separate target-shaped credential; it is +// detected via `findCredentials` (a plain `getPassword` does not read the Go +// target reliably) and deleted through the `withTarget` entry. The `withTarget` +// entry is only constructed on Windows β€” on macOS its first argument is an +// invalid keychain domain and throws. +// +// Each entry is probed before `deleteCredential()`: on macOS deleting an absent +// entry blocks on a Keychain authorization prompt, and an absent read means +// there is nothing to delete (ignorable, per Go). Only a real delete failure is +// surfaced as `LegacyCredentialDeleteError`. +const deleteKeyringEntryStrict = ( + module: KeyringModule, + account: string, + platform: RuntimePlatform, +): Effect.Effect => + Effect.gen(function* () { + let deleted = false; + + const plain = new module.Entry(KEYRING_SERVICE, account); + if (readEntryPassword(plain)) { + yield* Effect.try({ + try: () => { + plain.deleteCredential(); + }, + catch: (cause) => + new LegacyCredentialDeleteError({ + message: `failed to delete project credential: ${String(cause)}`, + }), + }); + deleted = true; + } + + if (platform === "win32" && readGoWindowsTarget(module, account)) { + const target = module.Entry.withTarget( + goWindowsCredentialTarget(account), + KEYRING_SERVICE, + account, + ); + yield* Effect.try({ + try: () => { + target.deleteCredential(); + }, + catch: (cause) => + new LegacyCredentialDeleteError({ + message: `failed to delete project credential: ${String(cause)}`, + }), + }); + deleted = true; + } + + return deleted; + }); + +// Delete the access-token profile entry, distinguishing the three outcomes Go's +// `credentials.StoreProvider.Delete(profile)` collapses into via the +// `access_token.go:110-117` error mapping: +// - `"deleted"` β€” an entry existed and was removed (β†’ logged out, exit 0); +// - `"notFound"` β€” no entry existed (β†’ Go's `ErrNotLoggedIn`, exit 0); +// - `LegacyDeleteTokenError` β€” a real `deleteCredential()` failure (exit 1). +// Like `deleteKeyringEntryStrict`, the entry is probed first so deleting an +// absent macOS entry never blocks on a Keychain prompt, and the Windows +// target-shaped credential is handled separately. +const deleteProfileKeyringEntry = ( + module: KeyringModule, + account: string, + platform: RuntimePlatform, +): Effect.Effect<"deleted" | "notFound", LegacyDeleteTokenError> => + Effect.gen(function* () { + let found = false; + + const plain = new module.Entry(KEYRING_SERVICE, account); + if (readEntryPassword(plain)) { + yield* Effect.try({ + try: () => { + plain.deleteCredential(); + }, + catch: (cause) => + new LegacyDeleteTokenError({ + message: `failed to delete access token from keyring: ${String(cause)}`, + }), + }); + found = true; + } + + if (platform === "win32" && readGoWindowsTarget(module, account)) { + const target = module.Entry.withTarget( + goWindowsCredentialTarget(account), + KEYRING_SERVICE, + account, + ); + yield* Effect.try({ + try: () => { + target.deleteCredential(); + }, + catch: (cause) => + new LegacyDeleteTokenError({ + message: `failed to delete access token from keyring: ${String(cause)}`, + }), + }); + found = true; + } + + return found ? "deleted" : "notFound"; + }); + +// Best-effort wipe of every entry in the `"Supabase CLI"` keyring namespace β€” +// the project database-password credentials `link` writes. Mirrors Go's +// `keyring.DeleteAll(namespace)` (`store.go:71`). Never fails: per-entry delete +// errors are swallowed so a single stuck credential can't abort logout. +// +// On Windows, Go stores credentials under the target-shaped name +// `Supabase CLI:` rather than the plain `Entry(service, account)` form +// (see `writeGoWindowsTarget`). So each discovered account is deleted in BOTH +// forms β€” the plain entry and, on win32, the Go target entry β€” mirroring the +// individual deletes in `deleteProfileKeyringEntry`. Without this, a Go-written +// project credential would survive `logout` on Windows. +const deleteAllKeyringEntries = ( + module: KeyringModule, + platform: RuntimePlatform, +): Effect.Effect => + Effect.sync(() => { + let entries: ReadonlyArray<{ account: string }>; + try { + entries = module.findCredentials(KEYRING_SERVICE); + } catch { + return; + } + for (const { account } of entries) { + try { + new module.Entry(KEYRING_SERVICE, account).deleteCredential(); + } catch { + // best-effort per entry + } + if (platform === "win32" && readGoWindowsTarget(module, account)) { + deleteGoWindowsTarget(module, account); + } + } + }); + const makeLegacyCredentials = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const runtimeInfo = yield* RuntimeInfo; const cliConfig = yield* LegacyCliConfig; + const debugLogger = yield* LegacyDebugLogger; const profileAccount = cliConfig.profile; // ~/.supabase/access-token β€” fallback file path const fallbackDir = path.join(runtimeInfo.homeDir, ".supabase"); const fallbackPath = path.join(fallbackDir, "access-token"); + // `SUPABASE_NO_KEYRING=1` disables the OS keyring entirely (matches `next/`'s + // credentials layer and the cli-e2e harness, which sets it). Without this, any + // unconditional keyring access β€” e.g. `unlink`'s credential delete β€” blocks on a + // Keychain authorization prompt in non-interactive / CI contexts. + const noKeyring = process.env["SUPABASE_NO_KEYRING"] === "1"; const wsl = yield* detectWsl(fs); - const keyringModule = wsl - ? Option.none() - : yield* Effect.tryPromise(() => import("@napi-rs/keyring")).pipe(Effect.option); + const keyringModule = + wsl || noKeyring + ? Option.none() + : yield* Effect.tryPromise(() => import("@napi-rs/keyring")).pipe(Effect.option); const validate = (token: string): Effect.Effect => ACCESS_TOKEN_PATTERN.test(token) @@ -90,9 +344,24 @@ const makeLegacyCredentials = Effect.gen(function* () { const readKeyring = Effect.gen(function* () { if (Option.isNone(keyringModule)) return Option.none(); - const profileResult = yield* tryKeyringRead(keyringModule.value, profileAccount); - if (Option.isSome(profileResult)) return profileResult; - return yield* tryKeyringRead(keyringModule.value, LEGACY_KEYRING_ACCOUNT); + const profileResult = yield* tryKeyringRead( + keyringModule.value, + profileAccount, + runtimeInfo.platform, + ); + if (Option.isSome(profileResult)) { + yield* debugLogger.debug(`Using access token for profile: ${profileAccount}`); + return profileResult; + } + const legacyResult = yield* tryKeyringRead( + keyringModule.value, + LEGACY_KEYRING_ACCOUNT, + runtimeInfo.platform, + ); + if (Option.isSome(legacyResult)) { + yield* debugLogger.debug("Using access token from credentials store..."); + } + return legacyResult; }); const readFile = Effect.gen(function* () { @@ -107,6 +376,7 @@ const makeLegacyCredentials = Effect.gen(function* () { getAccessToken: Effect.gen(function* () { // Env takes precedence (matches access_token.go:38). if (Option.isSome(cliConfig.accessToken)) { + yield* debugLogger.debug("Using access token from env var..."); yield* validate(Redacted.value(cliConfig.accessToken.value)); return Option.some(cliConfig.accessToken.value); } @@ -121,6 +391,7 @@ const makeLegacyCredentials = Effect.gen(function* () { // Filesystem fallback at ~/.supabase/access-token. const fileValue = yield* readFile; if (Option.isSome(fileValue)) { + yield* debugLogger.debug(`Using access token from file: ${fallbackPath}`); yield* validate(fileValue.value); return Option.some(Redacted.make(fileValue.value)); } @@ -132,7 +403,12 @@ const makeLegacyCredentials = Effect.gen(function* () { Effect.gen(function* () { yield* validate(token); if (Option.isSome(keyringModule)) { - const ok = yield* tryKeyringWrite(keyringModule.value, profileAccount, token); + const ok = yield* tryKeyringWrite( + keyringModule.value, + profileAccount, + token, + runtimeInfo.platform, + ); if (ok) return; } yield* fs.makeDirectory(fallbackDir, { recursive: true, mode: 0o700 }).pipe(Effect.orDie); @@ -140,18 +416,62 @@ const makeLegacyCredentials = Effect.gen(function* () { }), deleteAccessToken: Effect.gen(function* () { - let anyDeleted = false; - if (Option.isSome(keyringModule)) { - if (yield* tryKeyringDelete(keyringModule.value, profileAccount)) anyDeleted = true; - if (yield* tryKeyringDelete(keyringModule.value, LEGACY_KEYRING_ACCOUNT)) anyDeleted = true; - } + // Reproduce Go's `utils.DeleteAccessToken` (`access_token.go:100-119`) in + // its exact order. + + // 1. Always remove the fallback token file first. A missing file is + // ignored (Go's `errors.Is(err, os.ErrNotExist)`); any other removal + // failure aborts before the keyring is touched. const exists = yield* fs.exists(fallbackPath).pipe(Effect.orElseSucceed(() => false)); if (exists) { - yield* fs.remove(fallbackPath).pipe(Effect.orDie); - anyDeleted = true; + yield* fs.remove(fallbackPath).pipe( + Effect.catch((error) => + Effect.fail( + new LegacyDeleteTokenError({ + message: `failed to remove access token file: ${error.message}`, + }), + ), + ), + ); + } + + // 2. Best-effort delete of the legacy `access-token` keyring account. + // Go debug-logs and ignores any error here β€” never affects the result. + if (Option.isSome(keyringModule)) { + yield* tryKeyringDelete(keyringModule.value, LEGACY_KEYRING_ACCOUNT, runtimeInfo.platform); + } + + // 3. Delete the profile keyring account β€” this alone decides the outcome. + // No keyring backend (WSL / `SUPABASE_NO_KEYRING` / unsupported) maps to + // Go's `ErrNotSupported`/`ErrUnsupportedPlatform` β†’ `ErrNotLoggedIn`. + if (Option.isNone(keyringModule)) { + return yield* Effect.fail(new LegacyNotLoggedInError({ message: NOT_LOGGED_IN_MESSAGE })); + } + const outcome = yield* deleteProfileKeyringEntry( + keyringModule.value, + profileAccount, + runtimeInfo.platform, + ); + if (outcome === "notFound") { + return yield* Effect.fail(new LegacyNotLoggedInError({ message: NOT_LOGGED_IN_MESSAGE })); } - return anyDeleted; }), + + deleteAllProjectCredentials: Effect.gen(function* () { + if (Option.isNone(keyringModule)) return; + yield* deleteAllKeyringEntries(keyringModule.value, runtimeInfo.platform); + }), + + deleteProjectCredential: (projectRef: string) => + Effect.gen(function* () { + // WSL / no keyring module: treated as `ErrNotSupported` β€” a no-op success. + if (Option.isNone(keyringModule)) return false; + return yield* deleteKeyringEntryStrict( + keyringModule.value, + projectRef, + runtimeInfo.platform, + ); + }), }); }); diff --git a/apps/cli/src/legacy/auth/legacy-credentials.layer.unit.test.ts b/apps/cli/src/legacy/auth/legacy-credentials.layer.unit.test.ts index 2069c9c141..805b5631d9 100644 --- a/apps/cli/src/legacy/auth/legacy-credentials.layer.unit.test.ts +++ b/apps/cli/src/legacy/auth/legacy-credentials.layer.unit.test.ts @@ -4,15 +4,24 @@ import { join } from "node:path"; import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; -import { Effect, Layer, Option, Redacted } from "effect"; +import { Effect, FileSystem, Layer, Option, PlatformError, Redacted } from "effect"; import { afterEach, beforeEach, vi } from "vitest"; -import { LegacyProfileFlag, LegacyWorkdirFlag } from "../../shared/legacy/global-flags.ts"; +import { + LegacyDebugFlag, + LegacyProfileFlag, + LegacyWorkdirFlag, +} from "../../shared/legacy/global-flags.ts"; import { mockRuntimeInfo, processEnvLayer } from "../../../tests/helpers/mocks.ts"; import { legacyCliConfigLayer } from "../config/legacy-cli-config.layer.ts"; +import { legacyDebugLoggerLayer } from "../shared/legacy-debug-logger.layer.ts"; import { legacyCredentialsLayer } from "./legacy-credentials.layer.ts"; import { LegacyCredentials } from "./legacy-credentials.service.ts"; -import { LegacyInvalidAccessTokenError } from "./legacy-errors.ts"; +import { + LegacyDeleteTokenError, + LegacyInvalidAccessTokenError, + LegacyNotLoggedInError, +} from "./legacy-errors.ts"; // --------------------------------------------------------------------------- // Keyring mock @@ -20,18 +29,47 @@ import { LegacyInvalidAccessTokenError } from "./legacy-errors.ts"; const passwords = new Map(); let throwOnSetPassword = false; +let throwOnSetSecret = false; const throwOnGetPasswordAccounts = new Set(); +const throwOnDeleteAccounts = new Set(); +const withTargetCalls: string[] = []; vi.mock("@napi-rs/keyring", () => ({ + findCredentials: (service: string, target?: string) => + Array.from(passwords.entries()) + .filter(([key]) => + // No target β†’ model the Windows `CredEnumerate("*")` sweep, + // which matches both the plain (`/…`) and the Go target-shaped + // (`:/…`) entries by the leading segment. With a + // target β†’ narrow to that specific Go target (used by readGoWindowsTarget). + target === undefined + ? key.split("/")[0]!.startsWith(service) + : key.startsWith(`${target}/`), + ) + .map(([key, password]) => ({ + account: key.split("/").at(-1)!, + password, + })), Entry: class Entry { service: string; account: string; - constructor(service: string, account: string) { + target?: string; + constructor(service: string, account: string, target?: string) { this.service = service; this.account = account; + this.target = target; + } + static withTarget(target: string, service: string, account: string) { + withTargetCalls.push(`${target}/${service}/${account}`); + return new this(service, account, target); + } + key(): string { + return this.target === undefined + ? `${this.service}/${this.account}` + : `${this.target}/${this.service}/${this.account}`; } getPassword(): string | null { - const key = `${this.service}/${this.account}`; + const key = this.key(); if (throwOnGetPasswordAccounts.has(key)) { throw new Error("Keyring unavailable"); } @@ -39,10 +77,15 @@ vi.mock("@napi-rs/keyring", () => ({ } setPassword(value: string): void { if (throwOnSetPassword) throw new Error("Keyring unavailable"); - passwords.set(`${this.service}/${this.account}`, value); + passwords.set(this.key(), value); + } + setSecret(value: Uint8Array): void { + if (throwOnSetSecret) throw new Error("Keyring unavailable"); + passwords.set(this.key(), Buffer.from(value).toString("utf8")); } deleteCredential(): boolean { - const key = `${this.service}/${this.account}`; + const key = this.key(); + if (throwOnDeleteAccounts.has(key)) throw new Error("Keyring delete failed"); if (!passwords.has(key)) throw new Error("not found"); passwords.delete(key); return true; @@ -56,11 +99,24 @@ vi.mock("@napi-rs/keyring", () => ({ let tempHome: string; -function makeLayer(opts: { env?: Record; home?: string } = {}) { +function makeLayer( + opts: { + env?: Record; + home?: string; + platform?: NodeJS.Platform; + debug?: boolean; + } = {}, +) { const home = opts.home ?? tempHome; const env = { HOME: home, ...opts.env }; - const runtimeInfoLayer = mockRuntimeInfo({ homeDir: home, cwd: home }); + const runtimeInfoLayer = mockRuntimeInfo({ + homeDir: home, + cwd: home, + platform: opts.platform, + }); const cliConfigLayer = legacyCliConfigLayer.pipe( + Layer.provide(legacyDebugLoggerLayer), + Layer.provide(Layer.succeed(LegacyDebugFlag, opts.debug ?? false)), Layer.provide(Layer.succeed(LegacyProfileFlag, "supabase")), Layer.provide(Layer.succeed(LegacyWorkdirFlag, Option.none())), Layer.provide(runtimeInfoLayer), @@ -69,6 +125,8 @@ function makeLayer(opts: { env?: Record; home?: stri ); return legacyCredentialsLayer.pipe( Layer.provide(cliConfigLayer), + Layer.provide(legacyDebugLoggerLayer), + Layer.provide(Layer.succeed(LegacyDebugFlag, opts.debug ?? false)), Layer.provide(runtimeInfoLayer), Layer.provide(BunServices.layer), Layer.provide(processEnvLayer(env)), @@ -78,7 +136,10 @@ function makeLayer(opts: { env?: Record; home?: stri beforeEach(() => { passwords.clear(); throwOnSetPassword = false; + throwOnSetSecret = false; throwOnGetPasswordAccounts.clear(); + throwOnDeleteAccounts.clear(); + withTargetCalls.length = 0; tempHome = mkdtempSync(join(tmpdir(), "supabase-legacy-creds-")); }); @@ -90,6 +151,15 @@ const VALID_TOKEN = "sbp_" + "a".repeat(40); const VALID_OAUTH_TOKEN = "sbp_oauth_" + "b".repeat(40); const encodeGoKeyringBase64 = (token: string) => `go-keyring-base64:${Buffer.from(token).toString("base64")}`; +const goWindowsKey = (account: string) => `Supabase CLI:${account}/Supabase CLI/${account}`; +const encodeGoWindowsPassword = (token: string) => { + const bytes = Buffer.from(token, "utf8"); + let encoded = ""; + for (let index = 0; index < bytes.length; index += 2) { + encoded += String.fromCharCode(bytes[index]! | ((bytes[index + 1] ?? 0) << 8)); + } + return encoded; +}; const expectSomeToken = (token: Option.Option>, expected: string) => { expect(Option.isSome(token)).toBe(true); @@ -98,6 +168,10 @@ const expectSomeToken = (token: Option.Option>, expect } }; +function captureStderr() { + return vi.spyOn(process.stderr, "write").mockImplementation(() => true); +} + describe("legacyCredentialsLayer.getAccessToken", () => { it.effect("returns the SUPABASE_ACCESS_TOKEN env value (highest precedence)", () => { passwords.set("Supabase CLI/supabase", "sbp_" + "9".repeat(40)); @@ -117,6 +191,22 @@ describe("legacyCredentialsLayer.getAccessToken", () => { }).pipe(Effect.provide(makeLayer())); }); + it.effect("debug logs the access token source", () => { + passwords.set("Supabase CLI/supabase", VALID_TOKEN); + const stderr = captureStderr(); + return Effect.gen(function* () { + const { getAccessToken } = yield* LegacyCredentials; + const token = yield* getAccessToken; + expectSomeToken(token, VALID_TOKEN); + expect(stderr.mock.calls.map(([chunk]) => String(chunk)).join("")).toContain( + "Using access token for profile: supabase\n", + ); + }).pipe( + Effect.ensuring(Effect.sync(() => stderr.mockRestore())), + Effect.provide(makeLayer({ debug: true })), + ); + }); + it.effect("decodes Go keyring base64 values from the keyring profile account", () => { passwords.set("Supabase CLI/supabase", encodeGoKeyringBase64(VALID_TOKEN)); return Effect.gen(function* () { @@ -126,6 +216,26 @@ describe("legacyCredentialsLayer.getAccessToken", () => { }).pipe(Effect.provide(makeLayer())); }); + it.effect("reads Windows credentials created by Go keyring", () => { + passwords.set(goWindowsKey("supabase"), encodeGoWindowsPassword(VALID_TOKEN)); + return Effect.gen(function* () { + const { getAccessToken } = yield* LegacyCredentials; + const token = yield* getAccessToken; + expectSomeToken(token, VALID_TOKEN); + expect(withTargetCalls).toEqual([]); + }).pipe(Effect.provide(makeLayer({ platform: "win32" }))); + }); + + it.effect("does not search Go Windows targets on other platforms", () => { + passwords.set(goWindowsKey("supabase"), VALID_TOKEN); + return Effect.gen(function* () { + const { getAccessToken } = yield* LegacyCredentials; + const token = yield* getAccessToken; + expect(token).toEqual(Option.none()); + expect(withTargetCalls).toEqual([]); + }).pipe(Effect.provide(makeLayer({ platform: "linux" }))); + }); + it.effect("falls through to the legacy access-token keyring entry", () => { passwords.set("Supabase CLI/access-token", VALID_OAUTH_TOKEN); return Effect.gen(function* () { @@ -202,6 +312,27 @@ describe("legacyCredentialsLayer.saveAccessToken", () => { }).pipe(Effect.provide(makeLayer())), ); + it.effect("writes Windows credentials where Go keyring reads them", () => + Effect.gen(function* () { + const { saveAccessToken } = yield* LegacyCredentials; + yield* saveAccessToken(VALID_TOKEN); + expect(passwords.get(goWindowsKey("supabase"))).toBe(VALID_TOKEN); + expect(passwords.has("Supabase CLI/supabase")).toBe(false); + }).pipe(Effect.provide(makeLayer({ platform: "win32" }))), + ); + + it.effect("falls back to the shared token file when Windows target writes fail", () => { + throwOnSetSecret = true; + return Effect.gen(function* () { + const { saveAccessToken } = yield* LegacyCredentials; + yield* saveAccessToken(VALID_TOKEN); + expect(passwords.has(goWindowsKey("supabase"))).toBe(false); + expect(passwords.has("Supabase CLI/supabase")).toBe(false); + const content = readFileSync(join(tempHome, ".supabase", "access-token"), "utf-8"); + expect(content).toBe(VALID_TOKEN); + }).pipe(Effect.provide(makeLayer({ platform: "win32" }))); + }); + it.effect("falls back to the filesystem when the keyring write throws", () => { throwOnSetPassword = true; return Effect.gen(function* () { @@ -213,29 +344,203 @@ describe("legacyCredentialsLayer.saveAccessToken", () => { }); }); +// Go's `utils.DeleteAccessToken` (`access_token.go:100-119`) collapses three +// outcomes β€” logged out / not-logged-in / real failure β€” into the file + +// legacy-keyring + profile-keyring sequence. These cases assert the TS port +// reproduces that ordering and tri-state exactly (parity note 1). describe("legacyCredentialsLayer.deleteAccessToken", () => { - it.effect("returns false when no token is stored anywhere", () => - Effect.gen(function* () { + const seedTokenFile = (home: string, token = VALID_TOKEN) => { + const supaDir = join(home, ".supabase"); + mkdirSync(supaDir, { recursive: true }); + writeFileSync(join(supaDir, "access-token"), token, { mode: 0o600 }); + }; + const tokenFileExists = (home: string) => existsSync(join(home, ".supabase", "access-token")); + + it.effect("logged in via keyring profile entry β†’ deletes file + entry, succeeds", () => { + passwords.set("Supabase CLI/supabase", VALID_TOKEN); + passwords.set("Supabase CLI/access-token", VALID_OAUTH_TOKEN); + seedTokenFile(tempHome); + return Effect.gen(function* () { const { deleteAccessToken } = yield* LegacyCredentials; - expect(yield* deleteAccessToken).toBe(false); - }).pipe(Effect.provide(makeLayer())), + yield* deleteAccessToken; + expect(passwords.has("Supabase CLI/supabase")).toBe(false); + expect(passwords.has("Supabase CLI/access-token")).toBe(false); + expect(tokenFileExists(tempHome)).toBe(false); + }).pipe(Effect.provide(makeLayer())); + }); + + it.effect( + "keyring profile entry absent β†’ LegacyNotLoggedInError even though the file was removed", + () => { + seedTokenFile(tempHome); + return Effect.gen(function* () { + const { deleteAccessToken } = yield* LegacyCredentials; + const exit = yield* Effect.exit(deleteAccessToken); + expect(exit._tag).toBe("Failure"); + if (exit._tag === "Failure") { + expect(JSON.stringify(exit.cause)).toContain("LegacyNotLoggedInError"); + expect(JSON.stringify(exit.cause)).toContain("You were not logged in, nothing to do."); + } + // File is still removed first (Go's deliberate ordering). + expect(tokenFileExists(tempHome)).toBe(false); + }).pipe(Effect.provide(makeLayer())); + }, + ); + + it.effect( + "keyring unavailable (SUPABASE_NO_KEYRING) with token in file β†’ removes file, still NotLoggedIn", + () => { + seedTokenFile(tempHome); + return Effect.gen(function* () { + const { deleteAccessToken } = yield* LegacyCredentials; + const exit = yield* Effect.exit(deleteAccessToken); + expect(exit._tag).toBe("Failure"); + if (exit._tag === "Failure") { + expect(JSON.stringify(exit.cause)).toContain("LegacyNotLoggedInError"); + } + expect(tokenFileExists(tempHome)).toBe(false); + }).pipe(Effect.provide(makeLayer({ env: { SUPABASE_NO_KEYRING: "1" } }))); + }, ); - it.effect("removes both keyring entries plus the filesystem file", () => { + it.effect( + "file remove error (non-ENOENT) β†’ LegacyDeleteTokenError before touching keyring", + () => { + const home = tempHome; + const env = { HOME: home }; + const tokenPath = join(home, ".supabase", "access-token"); + // Seed a profile keyring entry to prove the keyring is never touched once + // the file removal fails. + passwords.set("Supabase CLI/supabase", VALID_TOKEN); + const runtimeInfoLayer = mockRuntimeInfo({ homeDir: home, cwd: home }); + const fsLayer = Layer.succeed( + FileSystem.FileSystem, + FileSystem.makeNoop({ + exists: (p) => Effect.succeed(p === tokenPath), + remove: () => + Effect.fail( + PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "remove", + description: "permission denied", + pathOrDescriptor: tokenPath, + }), + ), + }), + ); + const cliConfigLayer = legacyCliConfigLayer.pipe( + Layer.provide(legacyDebugLoggerLayer), + Layer.provide(Layer.succeed(LegacyDebugFlag, false)), + Layer.provide(Layer.succeed(LegacyProfileFlag, "supabase")), + Layer.provide(Layer.succeed(LegacyWorkdirFlag, Option.none())), + Layer.provide(runtimeInfoLayer), + Layer.provide(BunServices.layer), + Layer.provide(processEnvLayer(env)), + ); + const layer = legacyCredentialsLayer.pipe( + Layer.provide(cliConfigLayer), + Layer.provide(legacyDebugLoggerLayer), + Layer.provide(Layer.succeed(LegacyDebugFlag, false)), + Layer.provide(runtimeInfoLayer), + Layer.provide(fsLayer), + Layer.provide(BunServices.layer), + Layer.provide(processEnvLayer(env)), + ); + return Effect.gen(function* () { + const { deleteAccessToken } = yield* LegacyCredentials; + const exit = yield* Effect.exit(deleteAccessToken); + expect(exit._tag).toBe("Failure"); + if (exit._tag === "Failure") { + expect(JSON.stringify(exit.cause)).toContain("LegacyDeleteTokenError"); + expect(JSON.stringify(exit.cause)).toContain("failed to remove access token file"); + } + expect(passwords.has("Supabase CLI/supabase")).toBe(true); + }).pipe(Effect.provide(layer)); + }, + ); + + it.effect("real profile-keyring delete error β†’ LegacyDeleteTokenError", () => { + passwords.set("Supabase CLI/supabase", VALID_TOKEN); + throwOnDeleteAccounts.add("Supabase CLI/supabase"); + return Effect.gen(function* () { + const { deleteAccessToken } = yield* LegacyCredentials; + const exit = yield* Effect.exit(deleteAccessToken); + expect(exit._tag).toBe("Failure"); + if (exit._tag === "Failure") { + expect(JSON.stringify(exit.cause)).toContain("LegacyDeleteTokenError"); + expect(JSON.stringify(exit.cause)).toContain("failed to delete access token from keyring"); + } + }).pipe(Effect.provide(makeLayer())); + }); + + it.effect("win32: deletes both the plain and the Go Windows target entries", () => { + passwords.set("Supabase CLI/supabase", VALID_TOKEN); + passwords.set(goWindowsKey("supabase"), VALID_TOKEN); + return Effect.gen(function* () { + const { deleteAccessToken } = yield* LegacyCredentials; + yield* deleteAccessToken; + expect(passwords.has("Supabase CLI/supabase")).toBe(false); + expect(passwords.has(goWindowsKey("supabase"))).toBe(false); + }).pipe(Effect.provide(makeLayer({ platform: "win32" }))); + }); + + it.effect("legacy-keyring delete error is swallowed and does not change the outcome", () => { passwords.set("Supabase CLI/supabase", VALID_TOKEN); passwords.set("Supabase CLI/access-token", VALID_OAUTH_TOKEN); - const supaDir = join(tempHome, ".supabase"); - mkdirSync(supaDir, { recursive: true }); - writeFileSync(join(supaDir, "access-token"), VALID_TOKEN, { mode: 0o600 }); + throwOnDeleteAccounts.add("Supabase CLI/access-token"); return Effect.gen(function* () { const { deleteAccessToken } = yield* LegacyCredentials; - expect(yield* deleteAccessToken).toBe(true); + yield* deleteAccessToken; expect(passwords.has("Supabase CLI/supabase")).toBe(false); - expect(passwords.has("Supabase CLI/access-token")).toBe(false); - expect(existsSync(join(supaDir, "access-token"))).toBe(false); }).pipe(Effect.provide(makeLayer())); }); }); -// Suppress unused-import nag β€” referenced in JSDoc. +describe("legacyCredentialsLayer.deleteAllProjectCredentials", () => { + it.effect("deletes every Supabase CLI keyring entry", () => { + passwords.set("Supabase CLI/abcdefghijklmnopqrs1", "secret-1"); + passwords.set("Supabase CLI/abcdefghijklmnopqrs2", "secret-2"); + return Effect.gen(function* () { + const { deleteAllProjectCredentials } = yield* LegacyCredentials; + yield* deleteAllProjectCredentials; + expect(passwords.has("Supabase CLI/abcdefghijklmnopqrs1")).toBe(false); + expect(passwords.has("Supabase CLI/abcdefghijklmnopqrs2")).toBe(false); + }).pipe(Effect.provide(makeLayer())); + }); + + it.effect("no-ops when the keyring is unavailable", () => { + passwords.set("Supabase CLI/abcdefghijklmnopqrs1", "secret-1"); + return Effect.gen(function* () { + const { deleteAllProjectCredentials } = yield* LegacyCredentials; + yield* deleteAllProjectCredentials; + expect(passwords.has("Supabase CLI/abcdefghijklmnopqrs1")).toBe(true); + }).pipe(Effect.provide(makeLayer({ env: { SUPABASE_NO_KEYRING: "1" } }))); + }); + + it.effect("win32: deletes Go target-shaped project credentials", () => { + // Go stores Windows project credentials under `Supabase CLI:`, not the + // plain `Supabase CLI/` form. The sweep must delete the target form too. + passwords.set(goWindowsKey("abcdefghijklmnopqrs1"), "secret-1"); + return Effect.gen(function* () { + const { deleteAllProjectCredentials } = yield* LegacyCredentials; + yield* deleteAllProjectCredentials; + expect(passwords.has(goWindowsKey("abcdefghijklmnopqrs1"))).toBe(false); + }).pipe(Effect.provide(makeLayer({ platform: "win32" }))); + }); + + it.effect("never fails even when an individual delete throws", () => { + passwords.set("Supabase CLI/abcdefghijklmnopqrs1", "secret-1"); + throwOnDeleteAccounts.add("Supabase CLI/abcdefghijklmnopqrs1"); + return Effect.gen(function* () { + const { deleteAllProjectCredentials } = yield* LegacyCredentials; + const exit = yield* Effect.exit(deleteAllProjectCredentials); + expect(exit._tag).toBe("Success"); + }).pipe(Effect.provide(makeLayer())); + }); +}); + +// Suppress unused-import nag β€” referenced in JSDoc / used in assertions above. void LegacyInvalidAccessTokenError; +void LegacyDeleteTokenError; +void LegacyNotLoggedInError; diff --git a/apps/cli/src/legacy/auth/legacy-credentials.service.ts b/apps/cli/src/legacy/auth/legacy-credentials.service.ts index 911b0f07d5..def4668443 100644 --- a/apps/cli/src/legacy/auth/legacy-credentials.service.ts +++ b/apps/cli/src/legacy/auth/legacy-credentials.service.ts @@ -1,7 +1,12 @@ import type { Effect, Option, Redacted } from "effect"; import { Context } from "effect"; -import type { LegacyInvalidAccessTokenError } from "./legacy-errors.ts"; +import type { + LegacyCredentialDeleteError, + LegacyDeleteTokenError, + LegacyInvalidAccessTokenError, + LegacyNotLoggedInError, +} from "./legacy-errors.ts"; interface LegacyCredentialsShape { readonly getAccessToken: Effect.Effect< @@ -9,7 +14,47 @@ interface LegacyCredentialsShape { LegacyInvalidAccessTokenError >; readonly saveAccessToken: (token: string) => Effect.Effect; - readonly deleteAccessToken: Effect.Effect; + /** + * Deletes the access token, reproducing Go's `utils.DeleteAccessToken` + * (`apps/cli-go/internal/utils/access_token.go:100-119`) exactly: + * + * 1. Remove `~/.supabase/access-token` first. A non-`ENOENT` removal error + * fails `LegacyDeleteTokenError`; a missing file is ignored. + * 2. Best-effort delete of the legacy `access-token` keyring account β€” any + * error other than not-found is swallowed and never affects the outcome. + * 3. Delete the profile keyring account (account = profile name). This + * **alone** decides the result: + * - keyring unavailable (no module / WSL / `SUPABASE_NO_KEYRING`) or the + * entry is absent β†’ `LegacyNotLoggedInError`; + * - a real delete error β†’ `LegacyDeleteTokenError`; + * - success β†’ `void`. + * + * The deliberate Go quirk this preserves: on a no-keyring host the file is + * still removed, yet the call fails `LegacyNotLoggedInError` because the + * profile-keyring delete reports not-supported. + */ + readonly deleteAccessToken: Effect.Effect; + /** + * Deletes **every** entry in the `"Supabase CLI"` keyring namespace (project + * database passwords stored by `link`). Best-effort: never fails, and is a + * no-op when the keyring is unavailable. Mirrors Go's + * `credentials.StoreProvider.DeleteAll()` (`store.go:67-78`), used by + * `supabase logout` after the access token is removed. + */ + readonly deleteAllProjectCredentials: Effect.Effect; + /** + * Deletes the stored database-password credential for a project from the OS + * keyring (keyring service `"Supabase CLI"`, account = the **project ref** β€” + * distinct from the access-token entry). Used by `supabase unlink`. + * + * Returns `true` when an entry was removed, `false` when none existed or the + * keyring is unavailable (WSL). Fails with `LegacyCredentialDeleteError` only + * for real keyring errors (e.g. permission denied), mirroring Go's unlink + * which ignores `ErrNotFound` / `ErrNotSupported` but surfaces everything else. + */ + readonly deleteProjectCredential: ( + projectRef: string, + ) => Effect.Effect; } export class LegacyCredentials extends Context.Service()( diff --git a/apps/cli/src/legacy/auth/legacy-errors.ts b/apps/cli/src/legacy/auth/legacy-errors.ts index ab5c936940..effab2b544 100644 --- a/apps/cli/src/legacy/auth/legacy-errors.ts +++ b/apps/cli/src/legacy/auth/legacy-errors.ts @@ -11,3 +11,37 @@ export class LegacyPlatformAuthRequiredError extends Data.TaggedError( )<{ readonly message: string; }> {} + +/** + * Raised by `deleteProjectCredential` when removing a stored database-password + * credential from the OS keyring fails for a reason other than "entry not + * found" (which is ignored). Mirrors `supabase unlink`'s behaviour of collecting + * non-`ErrNotFound` / non-`ErrNotSupported` keyring errors + * (`apps/cli-go/internal/unlink/unlink.go:36-40`). + */ +export class LegacyCredentialDeleteError extends Data.TaggedError("LegacyCredentialDeleteError")<{ + readonly message: string; +}> {} + +/** + * Raised by `deleteAccessToken` when there is no access token to delete, i.e. + * the profile keyring entry is absent or the keyring backend is unavailable + * (WSL / `SUPABASE_NO_KEYRING` / unsupported platform). Mirrors Go's + * `utils.ErrNotLoggedIn` (`apps/cli-go/internal/utils/access_token.go:19`), + * which `supabase logout` surfaces as `You were not logged in, nothing to do.` + * on stderr while still exiting 0. + */ +export class LegacyNotLoggedInError extends Data.TaggedError("LegacyNotLoggedInError")<{ + readonly message: string; +}> {} + +/** + * Raised by `deleteAccessToken` when removing the token fails for a real reason + * β€” a non-`ENOENT` failure removing `~/.supabase/access-token`, or a non + * not-found error deleting the profile keyring entry. Mirrors Go's + * `failed to remove access token file: …` / `failed to delete access token from + * keyring: …` errors (`access_token.go:100-119`), which exit 1. + */ +export class LegacyDeleteTokenError extends Data.TaggedError("LegacyDeleteTokenError")<{ + readonly message: string; +}> {} diff --git a/apps/cli/src/legacy/auth/legacy-http-debug.layer.ts b/apps/cli/src/legacy/auth/legacy-http-debug.layer.ts index cd1ec08c50..56cc90e704 100644 --- a/apps/cli/src/legacy/auth/legacy-http-debug.layer.ts +++ b/apps/cli/src/legacy/auth/legacy-http-debug.layer.ts @@ -2,42 +2,20 @@ import { Effect, Layer } from "effect"; import { FetchHttpClient } from "effect/unstable/http"; import * as HttpClient from "effect/unstable/http/HttpClient"; -import { LegacyDebugFlag } from "../../shared/legacy/global-flags.ts"; - -const pad = (n: number): string => String(n).padStart(2, "0"); - -/** Formats a timestamp matching Go's `log.LstdFlags`: `YYYY/MM/DD HH:MM:SS`. */ -function formatTimestamp(now: Date): string { - return ( - `${now.getFullYear()}/${pad(now.getMonth() + 1)}/${pad(now.getDate())} ` + - `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}` - ); -} +import { LegacyDebugLogger } from "../shared/legacy-debug-logger.service.ts"; /** - * Wraps `FetchHttpClient.layer` so that, when `--debug` is set, every HTTP - * request is logged to stderr in the exact format Go uses - * (`apps/cli-go/internal/debug/http.go`): `HTTP : \n`. - * - * When `--debug` is unset, this is identity over `FetchHttpClient.layer` β€” no - * runtime overhead beyond a single boolean check at layer-construction time. + * Wraps `FetchHttpClient.layer` so every HTTP request can go through the + * legacy Go-parity debug side channel. The logger itself owns the `--debug` + * guard and byte-for-byte line formatting. */ -export const legacyHttpClientLayer = Layer.unwrap( +export const legacyHttpClientLayer = Layer.effect( + HttpClient.HttpClient, Effect.gen(function* () { - const debug = yield* LegacyDebugFlag; - if (!debug) { - return FetchHttpClient.layer; - } - - return Layer.effect( - HttpClient.HttpClient, - Effect.gen(function* () { - const base = yield* HttpClient.HttpClient; - return HttpClient.mapRequest(base, (req) => { - process.stderr.write(`HTTP ${formatTimestamp(new Date())} ${req.method}: ${req.url}\n`); - return req; - }); - }), - ).pipe(Layer.provide(FetchHttpClient.layer)); + const logger = yield* LegacyDebugLogger; + const base = yield* HttpClient.HttpClient; + return HttpClient.mapRequestEffect(base, (req) => + logger.http(req.method, req.url).pipe(Effect.as(req)), + ); }), -); +).pipe(Layer.provide(FetchHttpClient.layer)); diff --git a/apps/cli/src/legacy/auth/legacy-platform-api.layer.ts b/apps/cli/src/legacy/auth/legacy-platform-api.layer.ts index 654c97fb5e..45a5f70755 100644 --- a/apps/cli/src/legacy/auth/legacy-platform-api.layer.ts +++ b/apps/cli/src/legacy/auth/legacy-platform-api.layer.ts @@ -3,7 +3,9 @@ import { Effect, FileSystem, Layer, Option, Path } from "effect"; import * as HttpClient from "effect/unstable/http/HttpClient"; import type * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; +import { CLI_VERSION } from "../../shared/cli/version.ts"; import { LegacyCliConfig } from "../config/legacy-cli-config.service.ts"; +import { LegacyDebugLogger } from "../shared/legacy-debug-logger.service.ts"; import { Analytics } from "../../shared/telemetry/analytics.service.ts"; import { TelemetryRuntime } from "../../shared/telemetry/runtime.service.ts"; import { LegacyCredentials } from "./legacy-credentials.service.ts"; @@ -67,6 +69,7 @@ const makeLegacyPlatformApiServices = Effect.gen(function* () { const runtime = yield* TelemetryRuntime; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const debugLogger = yield* LegacyDebugLogger; let stitchAttempted = false; const needsIdentityStitch = @@ -111,9 +114,13 @@ const makeLegacyPlatformApiServices = Effect.gen(function* () { yield* fs.writeFileString(telemetryPath, JSON.stringify(state)); }); - const transformClient = (client: HttpClient.HttpClient) => - Effect.succeed( - HttpClient.transform(client, (requestEffect) => + const transformClient = (client: HttpClient.HttpClient) => { + const debugClient = HttpClient.mapRequestEffect(client, (request) => + debugLogger.http(request.method, request.url).pipe(Effect.as(request)), + ); + + return Effect.succeed( + HttpClient.transform(debugClient, (requestEffect) => requestEffect.pipe( Effect.tap((response) => { const gotrueId = gotrueIdFromResponse(response); @@ -123,14 +130,26 @@ const makeLegacyPlatformApiServices = Effect.gen(function* () { ), ), ); + }; - // Env takes precedence over keyring/file (already inside LegacyCredentials), but - // LegacyCliConfig.accessToken is the env value alone β€” read in the same order Go uses. const configuredToken = cliConfig.accessToken; - const storedToken = Option.isSome(configuredToken) - ? configuredToken - : yield* credentials.getAccessToken; - + const resolveAccessToken = Effect.gen(function* () { + if (Option.isSome(configuredToken)) { + yield* debugLogger.debug("Using access token from env var..."); + return configuredToken; + } + return yield* credentials.getAccessToken; + }); + + const authGateToken = yield* resolveAccessToken; + if (Option.isNone(authGateToken)) { + return yield* Effect.fail( + new LegacyPlatformAuthRequiredError({ message: MISSING_TOKEN_MESSAGE }), + ); + } + yield* debugLogger.debug(`Supabase CLI ${CLI_VERSION}`); + yield* debugLogger.debug(`Using profile: ${cliConfig.profile} (${cliConfig.projectHost})`); + const storedToken = yield* resolveAccessToken; if (Option.isNone(storedToken)) { return yield* Effect.fail( new LegacyPlatformAuthRequiredError({ message: MISSING_TOKEN_MESSAGE }), diff --git a/apps/cli/src/legacy/auth/legacy-platform-api.layer.unit.test.ts b/apps/cli/src/legacy/auth/legacy-platform-api.layer.unit.test.ts index dca599d975..24ff6c6b6c 100644 --- a/apps/cli/src/legacy/auth/legacy-platform-api.layer.unit.test.ts +++ b/apps/cli/src/legacy/auth/legacy-platform-api.layer.unit.test.ts @@ -7,10 +7,13 @@ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "no import { access, mkdir, readFile, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; +import { vi } from "vitest"; +import { LegacyDebugFlag } from "../../shared/legacy/global-flags.ts"; import { Analytics } from "../../shared/telemetry/analytics.service.ts"; import { TelemetryRuntime } from "../../shared/telemetry/runtime.service.ts"; import { LegacyCliConfig } from "../config/legacy-cli-config.service.ts"; +import { legacyDebugLoggerLayer } from "../shared/legacy-debug-logger.layer.ts"; import { LegacyCredentials } from "./legacy-credentials.service.ts"; import { legacyPlatformApiLayer } from "./legacy-platform-api.layer.ts"; import { LegacyPlatformApi } from "./legacy-platform-api.service.ts"; @@ -18,11 +21,17 @@ import { LegacyPlatformApi } from "./legacy-platform-api.service.ts"; const VALID_TOKEN = "sbp_" + "a".repeat(40); const SESSION_LAST_ACTIVE = 1_777_200_000_000; -function mockCliConfig(opts: { accessToken?: string; apiUrl?: string; userAgent?: string }) { +function mockCliConfig(opts: { + accessToken?: string; + apiUrl?: string; + userAgent?: string; + profile?: string; + projectHost?: string; +}) { return Layer.succeed(LegacyCliConfig, { - profile: "supabase", + profile: opts.profile ?? "supabase", apiUrl: opts.apiUrl ?? "https://api.supabase.com", - projectHost: "supabase.co", + projectHost: opts.projectHost ?? "supabase.co", accessToken: opts.accessToken === undefined ? Option.none() : Option.some(Redacted.make(opts.accessToken)), projectId: Option.none(), @@ -35,7 +44,9 @@ function mockCredentials(token: Option.Option) { return Layer.succeed(LegacyCredentials, { getAccessToken: Effect.succeed(Option.map(token, Redacted.make)), saveAccessToken: () => Effect.void, - deleteAccessToken: Effect.succeed(false), + deleteAccessToken: Effect.void, + deleteAllProjectCredentials: Effect.void, + deleteProjectCredential: () => Effect.succeed(false), }); } @@ -47,6 +58,7 @@ function mockTelemetryRuntime( isFirstRun?: boolean; isTty?: boolean; isCi?: boolean; + debug?: boolean; } = {}, ) { return Layer.succeed( @@ -150,6 +162,7 @@ function withBaseDeps( isFirstRun?: boolean; isTty?: boolean; isCi?: boolean; + debug?: boolean; } = {}, ) { const analytics = opts.analytics ?? mockAnalytics(); @@ -165,6 +178,8 @@ function withBaseDeps( isCi: opts.isCi, }), ), + Layer.provide(legacyDebugLoggerLayer), + Layer.provide(Layer.succeed(LegacyDebugFlag, opts.debug ?? false)), Layer.provide(nodeFileSystemLayer()), Layer.provide(nodePathLayer()), ); @@ -280,6 +295,35 @@ describe("legacyPlatformApiLayer", () => { }).pipe(Effect.provide(layer)); }); + it.effect("debug logs the CLI profile and fully resolved request URL", () => { + const http = captureRequests(); + const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + const layer = legacyPlatformApiLayer.pipe( + Layer.provide( + mockCliConfig({ + accessToken: VALID_TOKEN, + apiUrl: "https://api.supabase.green", + profile: "supabase-staging", + projectHost: "supabase.red", + }), + ), + Layer.provide(mockCredentials(Option.none())), + Layer.provide(http.layer), + withBaseDeps({ debug: true }), + ); + return Effect.gen(function* () { + const api = yield* LegacyPlatformApi; + yield* api.v1.listAllProjects(); + const output = stderr.mock.calls.map(([chunk]) => String(chunk)).join(""); + expect(output).toContain("Supabase CLI "); + expect(output).toContain("Using profile: supabase-staging (supabase.red)\n"); + expect(output.match(/Using access token from env var\.\.\.\n/g)).toHaveLength(2); + expect(output).toMatch( + /\d{4}\/\d{2}\/\d{2} \d{2}:\d{2}:\d{2} HTTP GET: https:\/\/api\.supabase\.green\/v1\/projects\n/, + ); + }).pipe(Effect.ensuring(Effect.sync(() => stderr.mockRestore())), Effect.provide(layer)); + }); + it.effect("stitches identity from X-Gotrue-Id responses outside CI", () => { const configDir = tempTelemetryConfig(); const analytics = mockAnalytics(); diff --git a/apps/cli/src/legacy/commands/init/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/init/SIDE_EFFECTS.md index bf948c36f4..32f8b64e6b 100644 --- a/apps/cli/src/legacy/commands/init/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/init/SIDE_EFFECTS.md @@ -2,43 +2,46 @@ ## Files Read -| Path | Format | When | -| ---- | ------ | ---- | -| β€” | β€” | β€” | +| Path | Format | When | +| ------------------------- | ---------- | ------------------------------------------------------------------------------------------------ | +| `supabase/config.toml` | TOML | checked first to fail fast unless `--force` is set | +| `.git/` | directory | checked upward from the invocation cwd to decide whether `supabase/.gitignore` should be managed | +| `supabase/.gitignore` | text | only when inside a git repo and the file already exists | +| `.vscode/settings.json` | JSONC/JSON | when VS Code settings are generated and the file already exists | +| `.vscode/extensions.json` | JSONC/JSON | when VS Code settings are generated and the file already exists | ## Files Written -| Path | Format | When | -| ------------------------- | ------ | -------------------------------------------------------- | -| `supabase/config.toml` | TOML | always on success; created from default template | -| `supabase/.gitignore` | text | always on success; gitignores runtime state | -| `.vscode/settings.json` | JSON | when `--with-vscode-settings` flag is set (deprecated) | -| `.vscode/extensions.json` | JSON | when `--with-vscode-workspace` flag is set (deprecated) | -| `.idea/deno.xml` | XML | when `--with-intellij-settings` flag is set (deprecated) | +| Path | Format | When | +| ------------------------- | ------ | --------------------------------------------------------------------------------------------------------------- | +| `supabase/config.toml` | TOML | always on success; created from default template | +| `supabase/.gitignore` | text | when inside a git repo and the template is not already present | +| `.vscode/settings.json` | JSON | when interactive VS Code setup is accepted, or when `--with-vscode-settings` / `--with-vscode-workspace` is set | +| `.vscode/extensions.json` | JSON | when interactive VS Code setup is accepted, or when `--with-vscode-settings` / `--with-vscode-workspace` is set | +| `.idea/deno.xml` | XML | when interactive IntelliJ setup is accepted, or when `--with-intellij-settings` is set | ## API Routes | Method | Path | Auth | Request body | Response (used fields) | | ------ | ---- | ---- | ------------ | ---------------------- | -| β€” | β€” | β€” | β€” | β€” | +| - | - | - | - | - | ## Environment Variables -| Variable | Purpose | Required? | -| --------- | ------------------------------------------ | --------- | -| `WORKDIR` | overrides working directory (set to `"."`) | no | +None. ## Exit Codes -| Code | Condition | -| ---- | -------------------------------------------------------------------- | -| `0` | success β€” prints "Finished supabase init." | -| `1` | `supabase/config.toml` already exists and `--force` was not provided | -| `1` | permission denied writing config file | +| Code | Condition | +| ---- | ------------------------------------------------------------------------------------ | +| `0` | success - prints "Finished supabase init." | +| `1` | `supabase/config.toml` already exists and `--force` was not provided | +| `1` | permission denied writing config file | +| `1` | an existing `.vscode/settings.json` / `.vscode/extensions.json` is not valid JSON(C) | ## Output -### `--output-format text` (Go CLI compatible) +### Legacy Output On success: @@ -48,19 +51,15 @@ Finished supabase init. In interactive mode (`-i`/`--interactive`), may prompt for IDE settings preferences. -### `--output-format json` - -Not applicable β€” init produces no machine-readable output. - -### `--output-format stream-json` - -Not applicable β€” init produces no structured output. +Success is emitted as raw text even when the legacy shell is invoked with non-text output modes. ## Notes -- Sets `WORKDIR` to `"."` in `PersistentPreRunE` to prevent recursing to parent directories. +- Uses the invocation cwd directly and does not recurse upward looking for an existing project. - The `--force` flag overwrites an existing `supabase/config.toml`. - The `--use-orioledb` flag sets `UseOrioleDB` in init params; requires `--experimental` flag. - The `--interactive` / `-i` flag enables IDE settings prompts (only effective in TTY). -- The `--with-vscode-settings`, `--with-vscode-workspace`, and `--with-intellij-settings` flags are hidden backward-compat aliases. -- No authentication required β€” purely local file creation. +- The `--with-vscode-settings` and `--with-vscode-workspace` flags are hidden backward-compat aliases for the same VS Code helper and both write `.vscode/settings.json` and `.vscode/extensions.json`. +- The `--with-intellij-settings` flag is a hidden backward-compat alias for generating `.idea/deno.xml`. +- An existing `.vscode/settings.json` / `.vscode/extensions.json` is parsed tolerantly through a JSONC boundary that strips line/block comments and trailing commas (matching Go's `jsonc.ToJSONInPlace`), then the template is merged on top (template keys win). An empty file is treated as absent and the template is written verbatim. A non-empty file that is not valid JSON(C) aborts the command with `InitParseSettingsError` and is left untouched rather than being overwritten. +- No authentication required - purely local file creation. diff --git a/apps/cli/src/legacy/commands/init/init.command.ts b/apps/cli/src/legacy/commands/init/init.command.ts index 8c80348b1f..32e8661d4c 100644 --- a/apps/cli/src/legacy/commands/init/init.command.ts +++ b/apps/cli/src/legacy/commands/init/init.command.ts @@ -1,5 +1,8 @@ import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; +import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; +import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; +import { withLegacyCommandInstrumentation } from "../../telemetry/legacy-command-instrumentation.ts"; import { legacyInit } from "./init.handler.ts"; const config = { @@ -32,5 +35,8 @@ export type LegacyInitFlags = CliCommand.Command.Config.Infer; export const legacyInitCommand = Command.make("init", config).pipe( Command.withDescription("Initialize a local project."), Command.withShortDescription("Initialize a local project"), - Command.withHandler((flags) => legacyInit(flags)), + Command.withHandler((flags) => + legacyInit(flags).pipe(withLegacyCommandInstrumentation({ flags }), withJsonErrorHandling), + ), + Command.provide(commandRuntimeLayer(["init"])), ); diff --git a/apps/cli/src/legacy/commands/init/init.handler.ts b/apps/cli/src/legacy/commands/init/init.handler.ts index 03211123b9..aaa984d397 100644 --- a/apps/cli/src/legacy/commands/init/init.handler.ts +++ b/apps/cli/src/legacy/commands/init/init.handler.ts @@ -1,15 +1,47 @@ -import { Effect } from "effect"; -import { LegacyGoProxy } from "../../../shared/legacy/go-proxy.service.ts"; +import { resolve } from "node:path"; +import { Effect, Option } from "effect"; +import { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts"; +import { initProject } from "../../../shared/init/project-init.ts"; +import { + InitAlreadyExistsError, + InitExperimentalRequiredError, +} from "../../../shared/init/project-init.errors.ts"; +import { Output } from "../../../shared/output/output.service.ts"; +import { LegacyExperimentalFlag, LegacyWorkdirFlag } from "../../../shared/legacy/global-flags.ts"; import type { LegacyInitFlags } from "./init.command.ts"; export const legacyInit = Effect.fn("legacy.init")(function* (flags: LegacyInitFlags) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["init"]; - if (flags.interactive) args.push("--interactive"); - if (flags.useOrioledb) args.push("--use-orioledb"); - if (flags.force) args.push("--force"); - if (flags.withVscodeWorkspace) args.push("--with-vscode-workspace"); - if (flags.withVscodeSettings) args.push("--with-vscode-settings"); - if (flags.withIntellijSettings) args.push("--with-intellij-settings"); - yield* proxy.exec(args); + const output = yield* Output; + const runtimeInfo = yield* RuntimeInfo; + const experimental = yield* LegacyExperimentalFlag; + const workdir = yield* LegacyWorkdirFlag; + + if (flags.useOrioledb && !experimental) { + return yield* Effect.fail( + new InitExperimentalRequiredError({ + detail: "--use-orioledb is only available when experimental features are enabled.", + suggestion: "Rerun the command with `--experimental --use-orioledb`.", + }), + ); + } + + const result = yield* initProject({ + cwd: Option.isSome(workdir) ? resolve(runtimeInfo.cwd, workdir.value) : runtimeInfo.cwd, + force: flags.force, + useOrioledb: flags.useOrioledb, + interactive: flags.interactive, + withVscodeSettings: flags.withVscodeWorkspace || flags.withVscodeSettings, + withIntellijSettings: flags.withIntellijSettings, + }); + + if (!result.created) { + return yield* Effect.fail( + new InitAlreadyExistsError({ + detail: `Config already exists at ${result.configPath}.`, + suggestion: "Run `supabase init --force` to overwrite the existing config.", + }), + ); + } + + yield* output.raw("Finished supabase init.\n"); }); diff --git a/apps/cli/src/legacy/commands/init/init.integration.test.ts b/apps/cli/src/legacy/commands/init/init.integration.test.ts new file mode 100644 index 0000000000..45b6911baf --- /dev/null +++ b/apps/cli/src/legacy/commands/init/init.integration.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from "@effect/vitest"; +import { BunServices } from "@effect/platform-bun"; +import { mkdtempSync } from "node:fs"; +import { readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Cause, Effect, Exit, Layer, Option } from "effect"; +import { LegacyExperimentalFlag, LegacyWorkdirFlag } from "../../../shared/legacy/global-flags.ts"; +import { mockOutput, mockRuntimeInfo, mockTty } from "../../../../tests/helpers/mocks.ts"; +import { legacyInit } from "./init.handler.ts"; + +function makeTempDir(): string { + return mkdtempSync(join(tmpdir(), "supabase-legacy-init-")); +} + +function setup( + cwd: string, + opts: { + experimental?: boolean; + workdir?: Option.Option; + interactive?: boolean; + stdinIsTty?: boolean; + } = {}, +) { + const out = mockOutput({ format: "text", interactive: opts.interactive ?? false }); + return { + out, + layer: Layer.mergeAll( + BunServices.layer, + out.layer, + mockRuntimeInfo({ cwd }), + mockTty({ + stdinIsTty: opts.stdinIsTty ?? false, + stdoutIsTty: opts.interactive ?? false, + }), + Layer.succeed(LegacyExperimentalFlag, opts.experimental ?? false), + Layer.succeed(LegacyWorkdirFlag, opts.workdir ?? Option.none()), + ), + }; +} + +function expectFailureTag(exit: Exit.Exit, tag: string) { + expect(Exit.isFailure(exit)).toBe(true); + if (!Exit.isFailure(exit)) { + return; + } + + const failure = Cause.findErrorOption(exit.cause); + expect(Option.isSome(failure)).toBe(true); + if (Option.isSome(failure)) { + expect((failure.value as { _tag: string })._tag).toBe(tag); + } +} + +describe("legacy init", () => { + it.live("creates config.toml natively without the Go proxy", () => { + const tempDir = makeTempDir(); + + return Effect.gen(function* () { + const { layer, out } = setup(tempDir); + + yield* legacyInit({ + interactive: false, + useOrioledb: false, + force: false, + withVscodeWorkspace: false, + withVscodeSettings: false, + withIntellijSettings: false, + }).pipe(Effect.provide(layer)); + + const content = yield* Effect.tryPromise(() => + readFile(join(tempDir, "supabase", "config.toml"), "utf8"), + ); + expect(content).toContain("major_version = 17"); + expect(out.stdoutText).toBe("Finished supabase init.\n"); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }); + + it.live("requires --experimental when --use-orioledb is set", () => { + const tempDir = makeTempDir(); + + return Effect.gen(function* () { + const { layer } = setup(tempDir, { experimental: false }); + + const exit = yield* legacyInit({ + interactive: false, + useOrioledb: true, + force: false, + withVscodeWorkspace: false, + withVscodeSettings: false, + withIntellijSettings: false, + }).pipe(Effect.provide(layer), Effect.exit); + + expectFailureTag(exit, "InitExperimentalRequiredError"); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }); + + it.live("supports the hidden IDE flags natively", () => { + const tempDir = makeTempDir(); + + return Effect.gen(function* () { + const { layer, out } = setup(tempDir); + + yield* legacyInit({ + interactive: false, + useOrioledb: false, + force: false, + withVscodeWorkspace: true, + withVscodeSettings: false, + withIntellijSettings: true, + }).pipe(Effect.provide(layer)); + + expect( + yield* Effect.tryPromise(() => + readFile(join(tempDir, ".vscode", "extensions.json"), "utf8"), + ), + ).toContain('"recommendations"'); + expect( + yield* Effect.tryPromise(() => readFile(join(tempDir, ".vscode", "settings.json"), "utf8")), + ).toContain('"deno.enablePaths"'); + expect( + yield* Effect.tryPromise(() => readFile(join(tempDir, ".idea", "deno.xml"), "utf8")), + ).toContain(''); + expect(out.stdoutText).toContain("Generated VS Code settings in .vscode/settings.json."); + expect(out.stdoutText).toContain("Generated IntelliJ settings in .idea/deno.xml."); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }); + + it.live("respects the legacy --workdir global flag", () => { + const tempDir = makeTempDir(); + const workdir = join(tempDir, "nested"); + + return Effect.gen(function* () { + const { layer } = setup(tempDir, { workdir: Option.some("nested") }); + + yield* legacyInit({ + interactive: false, + useOrioledb: false, + force: false, + withVscodeWorkspace: false, + withVscodeSettings: false, + withIntellijSettings: false, + }).pipe(Effect.provide(layer)); + + const content = yield* Effect.tryPromise(() => + readFile(join(workdir, "supabase", "config.toml"), "utf8"), + ); + expect(content).toContain("major_version = 17"); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }); +}); diff --git a/apps/cli/src/legacy/commands/link/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/link/SIDE_EFFECTS.md index c7d2d33b6d..dab9fddf79 100644 --- a/apps/cli/src/legacy/commands/link/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/link/SIDE_EFFECTS.md @@ -1,72 +1,96 @@ # `supabase link` +Native TypeScript port of Go's `internal/link`. Writes flat state files under +`/supabase/.temp/` β€” it does **not** use the `next/` `.supabase/project.json` model. + ## Files Read -| Path | Format | When | -| -------------------------- | ------------------------- | ---------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `.supabase/config.json` | JSON | when present, to load existing local config | -| `supabase/config.toml` | TOML | to load project configuration for the link operation | +| Path | Format | When | +| -------------------------- | ------------------- | ------------------------------------------------------------------------------------------------- | +| `supabase/config.toml` | TOML (`project_id`) | for ref resolution when `--project-ref` / `SUPABASE_PROJECT_ID` are unset (via `LegacyCliConfig`) | +| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` is unset and the keyring is unavailable | + +> The on-disk `supabase/.temp/project-ref` file is **not** read for ref resolution β€” Go passes an +> empty in-memory FS to `ParseProjectRef` (`cmd/link.go:30`), so `link` never falls back to it. ## Files Written -| Path | Format | When | -| ------------------------ | ------ | ------------------------------------------------------- | -| `.supabase/project.json` | JSON | always on success; stores linked project ref and config | -| `supabase/config.toml` | TOML | when config differs from remote; updated with remote | +All under `/supabase/.temp/` (plain text, created with parent dirs as needed): + +| Path | When | +| --------------------- | ----------------------------------------------------------------------------------------------------- | +| `project-ref` | always, after services link (mandatory β€” a write failure fails the command) | +| `postgres-version` | when the project status is 200 and `database.version` is non-empty | +| `storage-migration` | best-effort β€” storage config `migrationVersion` | +| `pooler-url` | best-effort β€” processed PRIMARY pooler connection string; **removed** when `--skip-pooler` | +| `rest-version` | best-effort β€” PostgREST swagger `info.version`, prefixed `v` | +| `gotrue-version` | best-effort β€” GoTrue `/auth/v1/health` version | +| `storage-version` | best-effort β€” Storage `/storage/v1/version` body, prefixed `v` | +| `linked-project.json` | best-effort β€” `{ref,name,organization_id,organization_slug}` (only for a resolvable, non-404 project) | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | --------------------------------------------- | ------------ | ------------ | ------------------------------------------- | -| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{status, database.host, database.version}` | -| `GET` | `/v1/projects/{ref}/api-keys` | Bearer token | none | `[{name, api_key}]` | -| `GET` | `/v1/projects/{ref}/config/database/postgres` | Bearer token | none | `{max_connections, ...}` | +Management API (base `LegacyCliConfig.apiUrl`, `Authorization: Bearer `): -## Environment Variables +| Method | Path | When | +| ------ | ------------------------------------------- | ------------------------------------------ | +| `GET` | `/v1/projects/{ref}` | always (404 tolerated for branch projects) | +| `GET` | `/v1/projects/{ref}/api-keys?reveal=true` | always | +| `GET` | `/v1/projects/{ref}/config/storage` | best-effort | +| `GET` | `/v1/projects/{ref}/config/database/pooler` | best-effort (unless `--skip-pooler`) | +| `GET` | `/v1/projects` | only when prompting on a TTY | -| 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` | override `--project-ref` flag | no | -| `DB_PASSWORD` | database password (bound from `--password` flag) | no | +Tenant service gateway (`https://.`, `apikey: ` + `Authorization: Bearer `): -## Exit Codes +| Method | Path | When | +| ------ | --------------------- | ----------- | +| `GET` | `/rest/v1/` | best-effort | +| `GET` | `/auth/v1/health` | best-effort | +| `GET` | `/storage/v1/version` | best-effort | -| Code | Condition | -| ---- | ---------------------------------------------------------------- | -| `0` | success β€” project linked, prints "Finished supabase link." | -| `1` | authentication error β€” no valid token found | -| `1` | project not found β€” API returns 404 | -| `1` | project not active or unhealthy | -| `1` | missing `--project-ref` in non-TTY mode without `PROJECT_ID` env | -| `1` | network / connection failure | +> The discarded Go config probes (`/config/database/postgres`, `/postgrest`, `/config/auth`, +> `/network-restrictions`) are **omitted**: they only populated in-process config that standalone +> `link` discards, and they emit nothing observable. -## Output +## Environment Variables -### `--output-format text` (Go CLI compatible) +| Variable | Purpose | +| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| `SUPABASE_PROJECT_ID` | project-ref resolution (flag β†’ env β†’ TTY prompt) | +| `SUPABASE_ACCESS_TOKEN` | Management API bearer auth (env β†’ keyring β†’ `~/.supabase/access-token`) | +| `SUPABASE_DB_PASSWORD` | bound to `--password`; **accepted but a no-op** for `link` (the DB-connection path that would consume it is dead code in Go) | -On success, prints a confirmation message: +## Exit Codes -``` -Finished supabase link. -``` +| Code | Condition | +| ---- | -------------------------------------------------------------------------------------------------- | +| `0` | success β€” project linked (incl. the 404 branch path); prints `Finished supabase link.` | +| `1` | non-TTY with no `--project-ref` / `SUPABASE_PROJECT_ID` (`required flag(s) "project-ref" not set`) | +| `1` | malformed project ref | +| `1` | project paused (`INACTIVE`) | +| `1` | project status non-200/404 | +| `1` | api-keys auth failure / missing key | +| `1` | `project-ref` file write failure | -Interactive mode may prompt for project selection and database password. +> Best-effort service-link and telemetry errors never affect the exit code. + +## Output -### `--output-format json` +### `--output-format text` (Go-compatible) -Not applicable β€” link is an interactive command. +- stderr: `Selected project: ` (prompt path); `WARNING: Project status is instead of Active Healthy. Some operations might fail.`; the dashboard unpause suggestion on a paused project. +- stdout: `Finished supabase link.` -### `--output-format stream-json` +### `--output-format json` / `stream-json` -Not applicable β€” link is an interactive command. +Emits a structured success (`{ project_ref }`) and suppresses the human `Finished` line. Warnings still go to stderr. -## Notes +## Known divergence -- In non-TTY mode without `PROJECT_ID` env, `--project-ref` is required. -- The `--skip-pooler` flag uses a direct database connection instead of the connection pooler. -- The `--password` flag sets the database password, bound to `DB_PASSWORD` viper key. -- After linking, the project ref is written to `.supabase/project.json` (and legacy `.supabase/` state). -- The `PostRun` hook always prints "Finished supabase link." to stdout on success. +- The cosmetic `WARNING: Local database version differs from the linked project.` message (Go's + `linkPostgresVersion`) is **not** reproduced: it requires loading the local `config.toml` + `[db].major_version` with CLI defaults, which the legacy shell does not surface. The + `postgres-version` file (the meaningful side effect) is still written. +- The `Finished supabase link.` line is emitted as **plain text**; Go renders `supabase link` in + ANSI cyan via `utils.Aqua`. This matches the established legacy-port convention (color helpers are + rendered plain); ANSI-stripping scripts are unaffected. diff --git a/apps/cli/src/legacy/commands/link/link.command.ts b/apps/cli/src/legacy/commands/link/link.command.ts index 650e8b40bc..0353f4d79d 100644 --- a/apps/cli/src/legacy/commands/link/link.command.ts +++ b/apps/cli/src/legacy/commands/link/link.command.ts @@ -1,5 +1,9 @@ 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 { legacyManagementApiRuntimeLayer } from "../../shared/legacy-management-api-runtime.layer.ts"; +import { withLegacyCommandInstrumentation } from "../../telemetry/legacy-command-instrumentation.ts"; import { legacyLink } from "./link.handler.ts"; const config = { @@ -22,5 +26,14 @@ export type LegacyLinkFlags = CliCommand.Command.Config.Infer; export const legacyLinkCommand = Command.make("link", config).pipe( Command.withDescription("Link to a Supabase project."), Command.withShortDescription("Link to a Supabase project"), - Command.withHandler((flags) => legacyLink(flags)), + Command.withHandler((flags) => + legacyLink(flags).pipe( + // Only `--project-ref` is `markFlagTelemetrySafe` in Go (cmd/link.go:52). + // The boolean `--skip-pooler` is logged verbatim regardless; `--password` + // stays redacted. + withLegacyCommandInstrumentation({ flags, safeFlags: ["project-ref"] }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyManagementApiRuntimeLayer(["link"])), ); diff --git a/apps/cli/src/legacy/commands/link/link.e2e.test.ts b/apps/cli/src/legacy/commands/link/link.e2e.test.ts new file mode 100644 index 0000000000..63e6e72c08 --- /dev/null +++ b/apps/cli/src/legacy/commands/link/link.e2e.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from "vitest"; +import { runSupabase } from "../../../../tests/helpers/cli.ts"; + +const E2E_TIMEOUT_MS = 30_000; +const TEST_TOKEN = "sbp_" + "a".repeat(40); + +describe("supabase link (legacy)", () => { + // Golden-path surface test: in a real subprocess with no TTY, no --project-ref + // and no SUPABASE_PROJECT_ID, ref resolution fails before any API call with the + // cobra-style required-flag error. Validates dispatch + ref-resolution wiring + // without needing a network fixture. + test( + "without a resolvable project ref exits 1 with the required-flag error", + { timeout: E2E_TIMEOUT_MS }, + async () => { + const { exitCode, stdout, stderr } = await runSupabase(["link"], { + entrypoint: "legacy", + env: { SUPABASE_ACCESS_TOKEN: TEST_TOKEN }, + }); + expect(exitCode).toBe(1); + expect(`${stdout}${stderr}`).toContain(`required flag(s) "project-ref" not set`); + }, + ); +}); diff --git a/apps/cli/src/legacy/commands/link/link.errors.ts b/apps/cli/src/legacy/commands/link/link.errors.ts new file mode 100644 index 0000000000..504965d46a --- /dev/null +++ b/apps/cli/src/legacy/commands/link/link.errors.ts @@ -0,0 +1,54 @@ +import { Data } from "effect"; + +/** Transport failure while fetching `GET /v1/projects/{ref}`. */ +export class LegacyLinkProjectStatusNetworkError extends Data.TaggedError( + "LegacyLinkProjectStatusNetworkError", +)<{ + readonly message: string; +}> {} + +/** + * `GET /v1/projects/{ref}` returned a non-200, non-404 status. Byte-matches Go's + * `"Unexpected error retrieving remote project status: " + body` (`link.go:252`). + */ +export class LegacyLinkProjectStatusError extends Data.TaggedError("LegacyLinkProjectStatusError")<{ + readonly status: number; + readonly body: string; + readonly message: string; +}> {} + +/** + * The remote project is paused (`status == INACTIVE`). Message `"project is paused"` + * with the dashboard unpause suggestion attached, mirroring Go's `errProjectPaused` + * + `utils.CmdSuggestion` (`link.go:256-258`). + */ +export class LegacyProjectPausedError extends Data.TaggedError("LegacyProjectPausedError")<{ + readonly message: string; + readonly suggestion: string; +}> {} + +/** Transport failure while fetching `GET /v1/projects/{ref}/api-keys`. */ +export class LegacyLinkApiKeysNetworkError extends Data.TaggedError( + "LegacyLinkApiKeysNetworkError", +)<{ + readonly message: string; +}> {} + +/** + * `GET /v1/projects/{ref}/api-keys` returned a non-200 status. Byte-matches Go's + * `ErrAuthToken` (`"Authorization failed for the access token and project ref pair"`) + * formatted with the response body (`client.go:78`). + */ +export class LegacyLinkAuthTokenError extends Data.TaggedError("LegacyLinkAuthTokenError")<{ + readonly status: number; + readonly body: string; + readonly message: string; +}> {} + +/** + * The api-keys response contained no usable anon/service-role key. Byte-matches + * Go's `errMissingKey` (`"Anon key not found."`, `client.go:15`). + */ +export class LegacyLinkMissingKeyError extends Data.TaggedError("LegacyLinkMissingKeyError")<{ + readonly message: string; +}> {} diff --git a/apps/cli/src/legacy/commands/link/link.handler.ts b/apps/cli/src/legacy/commands/link/link.handler.ts index 65784f65b8..1a81005f7e 100644 --- a/apps/cli/src/legacy/commands/link/link.handler.ts +++ b/apps/cli/src/legacy/commands/link/link.handler.ts @@ -1,12 +1,303 @@ -import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../shared/legacy/go-proxy.service.ts"; +import type { ApiClient } from "@supabase/api/effect"; +import { Effect, FileSystem, Option, Path } from "effect"; +import type { PlatformError } from "effect/PlatformError"; +import * as HttpClientError from "effect/unstable/http/HttpClientError"; + +import { LegacyPlatformApi } from "../../auth/legacy-platform-api.service.ts"; +import { LegacyCliConfig } from "../../config/legacy-cli-config.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 { Output } from "../../../shared/output/output.service.ts"; +import { Analytics } from "../../../shared/telemetry/analytics.service.ts"; +import { withAnalyticsContext } from "../../../shared/telemetry/analytics-context.ts"; +import { + EventProjectLinked, + GroupOrganization, + GroupProject, +} from "../../../shared/telemetry/event-catalog.ts"; +import { legacyDashboardUrl } from "../../shared/legacy-profile.ts"; +import { mapLegacyHttpError, sanitizeLegacyErrorBody } from "../../shared/legacy-http-errors.ts"; +import { legacyTempPaths } from "../../shared/legacy-temp-paths.ts"; +import { + legacyFetchGotrueVersion, + legacyFetchPostgrestVersion, + legacyFetchStorageVersion, +} from "../../shared/legacy-tenant-versions.ts"; +import { + LegacyLinkApiKeysNetworkError, + LegacyLinkAuthTokenError, + LegacyLinkMissingKeyError, + LegacyLinkProjectStatusError, + LegacyLinkProjectStatusNetworkError, + LegacyProjectPausedError, +} from "./link.errors.ts"; import type { LegacyLinkFlags } from "./link.command.ts"; +type LegacyLinkProject = Effect.Success>; + +// Classify a `getProject` failure: a 404 means the project is a branch (resolve +// to `None`, link continues); any other status surfaces the body; transport +// failures surface a network error. Mirrors `checkRemoteProjectStatus` +// (`link.go:240-253`). +const classifyProjectError = ( + cause: unknown, +): Effect.Effect< + Option.Option, + LegacyLinkProjectStatusError | LegacyLinkProjectStatusNetworkError +> => { + if (HttpClientError.isHttpClientError(cause) && cause.response !== undefined) { + const status = cause.response.status; + if (status === 404) { + return Effect.succeedNone; + } + return cause.response.text.pipe( + Effect.orElseSucceed(() => ""), + // Cap + strip control chars, matching `mapLegacyHttpError`'s defence-in-depth + // so an oversized / control-char body can't bloat JSON output or inject ANSI. + Effect.map(sanitizeLegacyErrorBody), + Effect.flatMap((body) => + Effect.fail( + new LegacyLinkProjectStatusError({ + status, + body, + message: `Unexpected error retrieving remote project status: ${body}`, + }), + ), + ), + ); + } + return Effect.fail( + new LegacyLinkProjectStatusNetworkError({ + message: `failed to retrieve remote project status: ${String(cause)}`, + }), + ); +}; + +interface ApiKeyEntry { + readonly api_key?: string | null; + readonly type?: string | null; + readonly name: string; + readonly secret_jwt_template?: Record | null; +} + +type WriteTempFile = (filePath: string, content: string) => Effect.Effect; + +// Mirrors `tenant.NewApiKey` (`apps/cli-go/internal/utils/tenant/client.go:28-57`): +// publishable -> anon, secret w/ role=service_role -> service_role, else legacy +// name-based fallback (`anon` / `service_role`). +function extractServiceKeys(keys: ReadonlyArray): { + anon: string; + serviceRole: string; +} { + let anon = ""; + let serviceRole = ""; + for (const key of keys) { + const value = key.api_key; + if (value === undefined || value === null) continue; + if (key.type === "publishable") { + anon = value; + continue; + } + if (key.type === "secret") { + const role = key.secret_jwt_template?.["role"]; + if (typeof role === "string" && role.toLowerCase() === "service_role") { + serviceRole = value; + } + continue; + } + if (key.name === "anon" && anon.length === 0) { + anon = value; + } else if (key.name === "service_role" && serviceRole.length === 0) { + serviceRole = value; + } + } + return { anon, serviceRole }; +} + +const mapApiKeysError = mapLegacyHttpError({ + networkError: LegacyLinkApiKeysNetworkError, + statusError: LegacyLinkAuthTokenError, + networkMessage: (cause) => `failed to get api keys: ${cause}`, + statusMessage: (_status, body) => + `Authorization failed for the access token and project ref pair: ${body}`, +}); + export const legacyLink = Effect.fn("legacy.link")(function* (flags: LegacyLinkFlags) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["link"]; - if (Option.isSome(flags.projectRef)) args.push("--project-ref", flags.projectRef.value); - if (Option.isSome(flags.password)) args.push("--password", flags.password.value); - if (flags.skipPooler) args.push("--skip-pooler"); - yield* proxy.exec(args); + const output = yield* Output; + const api = yield* LegacyPlatformApi; + const cliConfig = yield* LegacyCliConfig; + const resolver = yield* LegacyProjectRefResolver; + const linkedProjectCache = yield* LegacyLinkedProjectCache; + const telemetryState = yield* LegacyTelemetryState; + const analytics = yield* Analytics; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const ref = yield* resolver.resolveForLink(flags.projectRef); + const paths = legacyTempPaths(path, cliConfig.workdir); + + const writeTempFile: WriteTempFile = (filePath, content) => + fs + .makeDirectory(path.dirname(filePath), { recursive: true }) + .pipe(Effect.andThen(() => fs.writeFileString(filePath, content))); + + // Mirror Go's PersistentPostRun (`apps/cli-go/cmd/root.go:176`): persist the + // linked-project cache and telemetry state whether the link succeeds or fails. + // `link` itself writes `linked-project.json` on success (below), so `cache` + // only fires for the failure / 404 paths. + yield* Effect.gen(function* () { + // 1. Check remote project status (404 tolerated for branch projects). + const project = yield* api.v1 + .getProject({ ref }) + .pipe(Effect.asSome, Effect.catch(classifyProjectError)); + + if (Option.isSome(project)) { + const status = project.value.status; + if (status === "INACTIVE") { + return yield* Effect.fail( + new LegacyProjectPausedError({ + message: "project is paused", + suggestion: `An admin must unpause it from the Supabase dashboard at ${legacyDashboardUrl( + cliConfig.profile, + )}/project/${ref}`, + }), + ); + } + if (status !== "ACTIVE_HEALTHY") { + yield* output.raw( + `WARNING: Project status is ${status} instead of Active Healthy. Some operations might fail.\n`, + "stderr", + ); + } + // Update postgres image version to match the remote project (link.go:269). + const version = project.value.database.version; + if (version.length > 0) { + yield* writeTempFile(paths.postgresVersion, version); + } + } + + // 2. Resolve service keys (auth check). + const keys = yield* api.v1 + .getProjectApiKeys({ ref, reveal: true }) + .pipe(Effect.catch(mapApiKeysError)); + const { anon, serviceRole } = extractServiceKeys(keys); + if (anon.length === 0 && serviceRole.length === 0) { + return yield* Effect.fail(new LegacyLinkMissingKeyError({ message: "Anon key not found." })); + } + + // 3. Link services β€” best-effort. Every error is swallowed so a single + // unreachable service never fails the link (link.go:91-100). + yield* linkStorageMigration(api, ref, paths.storageMigration, writeTempFile); + yield* linkPooler({ + api, + ref, + skipPooler: flags.skipPooler, + fs, + poolerUrlPath: paths.poolerUrl, + writeTempFile, + }); + const tenantOpts = { + ref, + projectHost: cliConfig.projectHost, + serviceKey: serviceRole, + userAgent: cliConfig.userAgent, + }; + yield* legacyFetchPostgrestVersion(tenantOpts).pipe( + Effect.flatMap((v) => + Option.isSome(v) ? writeTempFile(paths.restVersion, v.value) : Effect.void, + ), + Effect.ignore, + ); + yield* legacyFetchGotrueVersion(tenantOpts).pipe( + Effect.flatMap((v) => + Option.isSome(v) ? writeTempFile(paths.gotrueVersion, v.value) : Effect.void, + ), + Effect.ignore, + ); + yield* legacyFetchStorageVersion(tenantOpts).pipe( + Effect.flatMap((v) => + Option.isSome(v) ? writeTempFile(paths.storageVersion, v.value) : Effect.void, + ), + Effect.ignore, + ); + + // 4. Save project ref (mandatory β€” a write failure fails the command). + yield* writeTempFile(paths.projectRef, ref); + + // 5. Telemetry + linked-project cache (only for resolvable projects, i.e. + // not the 404 branch path). `link.go:40-67`. + if (Option.isSome(project)) { + const p = project.value; + // SaveLinkedProject β€” best-effort (debug-logged in Go, never fatal). + yield* writeTempFile( + paths.linkedProjectCache, + JSON.stringify({ + ref: p.ref, + name: p.name, + organization_id: p.organization_id, + organization_slug: p.organization_slug, + }), + ).pipe(Effect.ignore); + + const groups = { organization: p.organization_id, project: p.ref } as const; + if (p.organization_id.length > 0) { + yield* analytics.groupIdentify(GroupOrganization, p.organization_id, { + organization_slug: p.organization_slug, + }); + } + if (p.ref.length > 0) { + yield* analytics.groupIdentify(GroupProject, p.ref, { + name: p.name, + organization_slug: p.organization_slug, + }); + } + yield* analytics.capture(EventProjectLinked, {}).pipe(withAnalyticsContext({ groups })); + } + + // 6. PostRun: `Finished supabase link.` to stdout (text), structured success + // otherwise. + if (output.format === "text") { + yield* output.raw("Finished supabase link.\n"); + } else { + yield* output.success("", { project_ref: ref }); + } + }).pipe(Effect.ensuring(linkedProjectCache.cache(ref)), Effect.ensuring(telemetryState.flush)); }); + +const linkStorageMigration = ( + api: ApiClient, + ref: string, + storageMigrationPath: string, + writeTempFile: WriteTempFile, +) => + api.v1.getStorageConfig({ ref }).pipe( + Effect.flatMap((config) => writeTempFile(storageMigrationPath, config.migrationVersion)), + Effect.ignore, + ); + +const linkPooler = (opts: { + api: ApiClient; + ref: string; + skipPooler: boolean; + fs: FileSystem.FileSystem; + poolerUrlPath: string; + writeTempFile: WriteTempFile; +}) => + Effect.gen(function* () { + if (opts.skipPooler) { + // Use direct connection: drop any cached pooler URL (link.go:81-84). + yield* opts.fs.remove(opts.poolerUrlPath, { recursive: true }).pipe(Effect.ignore); + return; + } + const configs = yield* opts.api.v1.getPoolerConfig({ ref: opts.ref }); + const primary = configs.find((c) => c.database_type === "PRIMARY"); + if (primary === undefined) return; + // Strip the [YOUR-PASSWORD] placeholder; force session mode 5432 unless the + // pooler already reports session mode (link.go:221-229). + let connectionString = primary.connection_string.replaceAll(":[YOUR-PASSWORD]", ""); + if (primary.pool_mode !== "session") { + connectionString = connectionString.replaceAll(":6543/", ":5432/"); + } + yield* opts.writeTempFile(opts.poolerUrlPath, connectionString); + }).pipe(Effect.ignore); diff --git a/apps/cli/src/legacy/commands/link/link.integration.test.ts b/apps/cli/src/legacy/commands/link/link.integration.test.ts new file mode 100644 index 0000000000..870adb758c --- /dev/null +++ b/apps/cli/src/legacy/commands/link/link.integration.test.ts @@ -0,0 +1,510 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, Layer, Option } from "effect"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; + +import { mockAnalytics, mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { + LEGACY_VALID_REF, + buildLegacyTestRuntime, + legacyStatusCodeFailure, + legacyTransportFailure, + mockLegacyCliConfig, + mockLegacyLinkedProjectCacheTracked, + mockLegacyPlatformApiService, + mockLegacyTelemetryStateTracked, + useLegacyTempWorkdir, +} from "../../../../tests/helpers/legacy-mocks.ts"; +import { legacyLink } from "./link.handler.ts"; +import type { LegacyLinkFlags } from "./link.command.ts"; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const HEALTHY_PROJECT = { + id: LEGACY_VALID_REF, + ref: LEGACY_VALID_REF, + name: "My Project", + organization_id: "org_123", + organization_slug: "acme", + status: "ACTIVE_HEALTHY", + region: "us-east-1", + created_at: "2026-01-01T00:00:00Z", + database: { + host: "db.example.co", + version: "15.1.0.117", + postgres_engine: "15", + release_channel: "ga", + }, +}; + +const SERVICE_KEYS = [ + { + name: "service_role", + api_key: "service-role-key", + type: "secret", + secret_jwt_template: { role: "service_role" }, + }, + { name: "anon", api_key: "anon-key", type: "publishable" }, +]; + +const POOLER_PRIMARY = [ + { + identifier: "primary", + database_type: "PRIMARY", + db_user: "postgres", + db_host: "pooler.example.co", + db_port: 6543, + db_name: "postgres", + connection_string: "postgresql://postgres.ref:[YOUR-PASSWORD]@pooler.example.co:6543/postgres", + connectionString: "", + default_pool_size: null, + max_client_conn: null, + pool_mode: "transaction", + }, +]; + +// --------------------------------------------------------------------------- +// Setup +// --------------------------------------------------------------------------- + +interface V1StubResult { + readonly ok?: unknown; + readonly fail?: unknown; +} + +interface SetupOpts { + format?: "text" | "json" | "stream-json"; + project?: V1StubResult; + apiKeys?: V1StubResult; + storageConfig?: V1StubResult; + poolerConfig?: V1StubResult; + tenant?: "ok" | "fail"; + restVersion?: string; + gotrueVersion?: string; + storageVersion?: string; +} + +const tempRoot = useLegacyTempWorkdir("supabase-link-int-"); + +function stub(result: V1StubResult | undefined, defaultOk: unknown) { + if (result?.fail !== undefined) return () => Effect.fail(result.fail); + return () => Effect.succeed(result?.ok ?? defaultOk); +} + +function tenantHttpLayer(opts: SetupOpts): Layer.Layer { + return Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.gen(function* () { + if (opts.tenant === "fail") { + return yield* Effect.fail(legacyTransportFailure(request)); + } + const url = request.url; + if (url.includes("/rest/v1/")) { + return HttpClientResponse.fromWeb( + request, + new Response(JSON.stringify({ info: { version: opts.restVersion ?? "11.1.0" } }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + } + if (url.includes("/auth/v1/health")) { + return HttpClientResponse.fromWeb( + request, + new Response(JSON.stringify({ version: opts.gotrueVersion ?? "v2.74.2" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + } + if (url.includes("/storage/v1/version")) { + return HttpClientResponse.fromWeb( + request, + new Response(opts.storageVersion ?? "1.28.0", { status: 200 }), + ); + } + return HttpClientResponse.fromWeb(request, new Response("", { status: 404 })); + }), + ), + ); +} + +function setup(opts: SetupOpts = {}) { + const out = mockOutput({ format: opts.format ?? "text" }); + const analytics = mockAnalytics(); + const telemetry = mockLegacyTelemetryStateTracked(); + const linkedCache = mockLegacyLinkedProjectCacheTracked(); + const apiMock = mockLegacyPlatformApiService({ + v1: { + getProject: stub(opts.project, HEALTHY_PROJECT), + getProjectApiKeys: stub(opts.apiKeys, SERVICE_KEYS), + getStorageConfig: stub(opts.storageConfig, { migrationVersion: "2026-01-01-000000" }), + getPoolerConfig: stub(opts.poolerConfig, POOLER_PRIMARY), + }, + }); + const cliConfig = mockLegacyCliConfig({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = buildLegacyTestRuntime({ + out, + api: { layer: apiMock.layer, httpClientLayer: tenantHttpLayer(opts) }, + cliConfig, + analytics, + telemetry: telemetry.layer, + linkedProjectCache: linkedCache.layer, + }); + return { layer, out, analytics, telemetry, linkedCache, apiMock, workdir: tempRoot.current }; +} + +const flags = (overrides: Partial = {}): LegacyLinkFlags => ({ + projectRef: Option.some(LEGACY_VALID_REF), + password: Option.none(), + skipPooler: false, + ...overrides, +}); + +function tempFile(workdir: string, name: string): string { + return join(workdir, "supabase", ".temp", name); +} + +function readTemp(workdir: string, name: string): string { + return readFileSync(tempFile(workdir, name), "utf8"); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("legacy link integration", () => { + it.live("links a project, writing the project-ref and version files", () => { + const { layer, out, workdir } = setup(); + return Effect.gen(function* () { + yield* legacyLink(flags()); + expect(readTemp(workdir, "project-ref")).toBe(LEGACY_VALID_REF); + expect(readTemp(workdir, "postgres-version")).toBe("15.1.0.117"); + expect(readTemp(workdir, "storage-migration")).toBe("2026-01-01-000000"); + expect(readTemp(workdir, "rest-version")).toBe("v11.1.0"); + expect(readTemp(workdir, "gotrue-version")).toBe("v2.74.2"); + expect(readTemp(workdir, "storage-version")).toBe("v1.28.0"); + // [YOUR-PASSWORD] stripped + transaction-mode port rewritten to 5432. + expect(readTemp(workdir, "pooler-url")).toBe( + "postgresql://postgres.ref@pooler.example.co:5432/postgres", + ); + expect(out.stdoutText).toContain("Finished supabase link."); + }).pipe(Effect.provide(layer)); + }); + + it.live("writes linked-project.json with ref/name/org metadata", () => { + const { layer, workdir } = setup(); + return Effect.gen(function* () { + yield* legacyLink(flags()); + const linked = JSON.parse(readTemp(workdir, "linked-project.json")); + expect(linked).toEqual({ + ref: LEGACY_VALID_REF, + name: "My Project", + organization_id: "org_123", + organization_slug: "acme", + }); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits cli_project_linked + org/project groupIdentify keyed by org id", () => { + const { layer, analytics } = setup(); + return Effect.gen(function* () { + yield* legacyLink(flags()); + expect(analytics.captured.map((c) => c.event)).toContain("cli_project_linked"); + expect(analytics.groupIdentified).toEqual([ + { + groupType: "organization", + groupKey: "org_123", + properties: { organization_slug: "acme" }, + }, + { + groupType: "project", + groupKey: LEGACY_VALID_REF, + properties: { name: "My Project", organization_slug: "acme" }, + }, + ]); + }).pipe(Effect.provide(layer)); + }); + + it.live("resolves the ref from SUPABASE_PROJECT_ID when no flag is given", () => { + const out = mockOutput({ format: "text" }); + const apiMock = mockLegacyPlatformApiService({ + v1: { + getProject: () => Effect.succeed(HEALTHY_PROJECT), + getProjectApiKeys: () => Effect.succeed(SERVICE_KEYS), + getStorageConfig: () => Effect.succeed({ migrationVersion: "m" }), + getPoolerConfig: () => Effect.succeed(POOLER_PRIMARY), + }, + }); + const cliConfig = mockLegacyCliConfig({ + workdir: tempRoot.current, + projectId: Option.some(LEGACY_VALID_REF), + }); + const layer = buildLegacyTestRuntime({ + out, + api: { layer: apiMock.layer, httpClientLayer: tenantHttpLayer({}) }, + cliConfig, + }); + return Effect.gen(function* () { + yield* legacyLink(flags({ projectRef: Option.none() })); + expect(readTemp(tempRoot.current, "project-ref")).toBe(LEGACY_VALID_REF); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails in non-TTY with no --project-ref and no PROJECT_ID", () => { + const out = mockOutput({ format: "text" }); + const apiMock = mockLegacyPlatformApiService({ v1: {} }); + const cliConfig = mockLegacyCliConfig({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = buildLegacyTestRuntime({ + out, + api: { layer: apiMock.layer, httpClientLayer: tenantHttpLayer({}) }, + cliConfig, + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyLink(flags({ projectRef: Option.none() }))); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyProjectRefRequiredError"); + expect(json).toContain(`required flag(s) \\"project-ref\\" not set`); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with LegacyInvalidProjectRefError for a malformed ref", () => { + const { layer } = setup(); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyLink(flags({ projectRef: Option.some("BADREF") }))); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyInvalidProjectRefError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("tolerates a 404 project (branch linking): writes project-ref, skips telemetry", () => { + const { layer, workdir, analytics } = setup({ + project: { fail: legacyStatusCodeFailure(404) }, + }); + return Effect.gen(function* () { + yield* legacyLink(flags()); + expect(readTemp(workdir, "project-ref")).toBe(LEGACY_VALID_REF); + // No postgres-version / linked-project.json and no telemetry for a 404. + expect(existsSync(tempFile(workdir, "postgres-version"))).toBe(false); + expect(existsSync(tempFile(workdir, "linked-project.json"))).toBe(false); + expect(analytics.captured.map((c) => c.event)).not.toContain("cli_project_linked"); + expect(analytics.groupIdentified).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with project-paused error + dashboard suggestion when INACTIVE", () => { + const { layer } = setup({ + project: { ok: { ...HEALTHY_PROJECT, status: "INACTIVE" } }, + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyLink(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyProjectPausedError"); + expect(json).toContain("project is paused"); + expect(json).toContain( + `An admin must unpause it from the Supabase dashboard at https://supabase.com/dashboard/project/${LEGACY_VALID_REF}`, + ); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("warns to stderr when status is not ACTIVE_HEALTHY but still links", () => { + const { layer, out, workdir } = setup({ + project: { ok: { ...HEALTHY_PROJECT, status: "COMING_UP" } }, + }); + return Effect.gen(function* () { + yield* legacyLink(flags()); + expect(out.stderrText).toContain( + "WARNING: Project status is COMING_UP instead of Active Healthy. Some operations might fail.", + ); + expect(readTemp(workdir, "project-ref")).toBe(LEGACY_VALID_REF); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with LegacyLinkProjectStatusError on an unexpected status", () => { + const { layer } = setup({ project: { fail: legacyStatusCodeFailure(500) } }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyLink(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyLinkProjectStatusError"); + expect(json).toContain("Unexpected error retrieving remote project status"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with auth error when api-keys returns non-200", () => { + const { layer } = setup({ apiKeys: { fail: legacyStatusCodeFailure(401) } }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyLink(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyLinkAuthTokenError"); + expect(json).toContain("Authorization failed for the access token and project ref pair"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with missing-key error when api-keys are empty", () => { + const { layer } = setup({ apiKeys: { ok: [] } }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyLink(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyLinkMissingKeyError"); + expect(json).toContain("Anon key not found."); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("resolves keys by legacy name when no type field is present", () => { + // Untyped keys exercise the `name`-based fallback in extractServiceKeys. + const { layer, out, workdir } = setup({ + apiKeys: { + ok: [ + { name: "anon", api_key: "anon-key" }, + { name: "service_role", api_key: "service-role-key" }, + ], + }, + }); + return Effect.gen(function* () { + yield* legacyLink(flags()); + expect(readTemp(workdir, "project-ref")).toBe(LEGACY_VALID_REF); + expect(out.stdoutText).toContain("Finished supabase link."); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with missing-key error when the only secret key is not service_role", () => { + // A `secret` key whose JWT role is not `service_role` is skipped, leaving no + // usable key β€” exercises the secret-branch `continue` + missing-key path. + const { layer } = setup({ + apiKeys: { + ok: [ + { + name: "other", + api_key: "other-key", + type: "secret", + secret_jwt_template: { role: "authenticated" }, + }, + ], + }, + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyLink(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyLinkMissingKeyError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("ignores best-effort service errors without failing the link", () => { + const { layer, out, workdir } = setup({ + storageConfig: { fail: legacyStatusCodeFailure(500) }, + poolerConfig: { fail: legacyStatusCodeFailure(503) }, + tenant: "fail", + }); + return Effect.gen(function* () { + yield* legacyLink(flags()); + // Link still succeeds and writes the project-ref. + expect(readTemp(workdir, "project-ref")).toBe(LEGACY_VALID_REF); + expect(out.stdoutText).toContain("Finished supabase link."); + // The best-effort files are absent because their services errored. + expect(existsSync(tempFile(workdir, "storage-migration"))).toBe(false); + expect(existsSync(tempFile(workdir, "rest-version"))).toBe(false); + }).pipe(Effect.provide(layer)); + }); + + it.live("removes pooler-url and skips the pooler fetch when --skip-pooler is set", () => { + const { layer, workdir, apiMock } = setup(); + mkdirSync(join(workdir, "supabase", ".temp"), { recursive: true }); + writeFileSync(tempFile(workdir, "pooler-url"), "stale-pooler-url"); + return Effect.gen(function* () { + yield* legacyLink(flags({ skipPooler: true })); + expect(existsSync(tempFile(workdir, "pooler-url"))).toBe(false); + expect(apiMock.requests.map((r) => r.method)).not.toContain("getPoolerConfig"); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when writing the project-ref file errors", () => { + // Make `/supabase` a file so creating supabase/.temp fails for every + // temp write. The project status carries no version, so the first mandatory + // write to hit the broken path is project-ref (mirrors Go's read-only FS test). + const out = mockOutput({ format: "text" }); + const apiMock = mockLegacyPlatformApiService({ + v1: { + getProject: () => + Effect.succeed({ + ...HEALTHY_PROJECT, + database: { ...HEALTHY_PROJECT.database, version: "" }, + }), + getProjectApiKeys: () => Effect.succeed(SERVICE_KEYS), + getStorageConfig: () => Effect.succeed({ migrationVersion: "m" }), + getPoolerConfig: () => Effect.succeed(POOLER_PRIMARY), + }, + }); + const cliConfig = mockLegacyCliConfig({ workdir: tempRoot.current, projectId: Option.none() }); + const layer = buildLegacyTestRuntime({ + out, + api: { layer: apiMock.layer, httpClientLayer: tenantHttpLayer({ tenant: "fail" }) }, + cliConfig, + }); + writeFileSync(join(tempRoot.current, "supabase"), "not-a-dir"); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyLink(flags())); + expect(Exit.isFailure(exit)).toBe(true); + expect(existsSync(tempFile(tempRoot.current, "project-ref"))).toBe(false); + }).pipe(Effect.provide(layer)); + }); + + it.live("flushes telemetry and runs the linked-project cache via ensuring", () => { + const { layer, telemetry, linkedCache } = setup(); + return Effect.gen(function* () { + yield* legacyLink(flags()); + expect(telemetry.flushed).toBe(true); + expect(linkedCache.cached).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("json output: emits a structured success and suppresses the Finished line", () => { + const { layer, out, workdir } = setup({ format: "json" }); + return Effect.gen(function* () { + yield* legacyLink(flags()); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.data).toMatchObject({ project_ref: LEGACY_VALID_REF }); + expect(out.stdoutText).not.toContain("Finished supabase link."); + expect(readTemp(workdir, "project-ref")).toBe(LEGACY_VALID_REF); + }).pipe(Effect.provide(layer)); + }); + + it.live("stream-json output: emits a structured success", () => { + const { layer, out } = setup({ format: "stream-json" }); + return Effect.gen(function* () { + yield* legacyLink(flags()); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.data).toMatchObject({ project_ref: LEGACY_VALID_REF }); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/cli/src/legacy/commands/login/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/login/SIDE_EFFECTS.md index 75cd2e5d69..ef6af8dcdb 100644 --- a/apps/cli/src/legacy/commands/login/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/login/SIDE_EFFECTS.md @@ -1,66 +1,83 @@ # `supabase login` +Native TypeScript port of Go's `internal/login`. Writes the access token, then +stitches/clears the telemetry identity and captures `cli_login_completed`. + ## Files Read -| Path | Format | When | -| -------------------------- | ------------------------- | -------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | not read; login writes this file | -| stdin | plain text (token string) | when piped input is detected in non-TTY mode | +| Path | Format | When | +| --------------------------------------- | ------------------------- | -------------------------------------------------------------------------- | +| stdin | plain text (token string) | non-TTY only, when `--token` is unset and `SUPABASE_ACCESS_TOKEN` is unset | +| OS keyring / `~/.supabase/access-token` | token string | written, not read, on the login path | ## Files Written -| Path | Format | When | -| -------------------------- | ------------------------- | ------------------------------------------------ | -| `~/.supabase/access-token` | plain text (token string) | when keyring is unavailable; stores access token | +| Path | Format | When | +| ----------------------------------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| OS keyring (`Supabase CLI` / profile) | token string | always on success when the keyring is available | +| `~/.supabase/access-token` | plain text (mode `0600`) | on success when the keyring is unavailable (WSL / `SUPABASE_NO_KEYRING`) | +| `/telemetry.json` | JSON | always (PersistentPostRun flush); `distinct_id` set on stitch, removed on clear | +| `~/.supabase/profile` | plain text (profile name) | on success only, when a profile is explicitly set (`--profile` β‰  default, else `SUPABASE_PROFILE`) β€” Go's `PostRunE`/`SaveProfileName` | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ------------------------------- | ---- | ------------ | -------------------------------------------------------- | -| `GET` | `/platform/cli/login/{session}` | none | none | `{access_token, public_key, nonce}` (for automated flow) | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ------------------------------------------------------------- | ------------------------ | ------------ | ------------------------------------------------------------- | +| `GET` | `{dashboardUrl}/cli/login?session_id&token_name&public_key` | none (opened in browser) | none | β€” (not fetched by the CLI) | +| `GET` | `{apiHost}/platform/cli/login/{sessionId}?device_code=` | none | none | `{access_token, public_key, nonce}` (10s timeout, expect 200) | +| `GET` | `{apiHost}/v1/profile` | Bearer (saved token) | none | `{gotrue_id}` (best-effort, for the telemetry stitch) | ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ------------------------------------------------ | ------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | token provided via env (bypass interactive flow) | no (falls back to `--token` flag or browser flow) | +| Variable | Purpose | Required? | +| ---------------------------------------------- | ------------------------------------------------------ | --------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | non-interactive token source | no (falls back to `--token` β†’ piped stdin β†’ browser flow) | +| `SUPABASE_NO_KEYRING` | disables the OS keyring, forcing the file fallback | no | +| `CLAUDECODE` / `CLAUDE_CODE` | enables the Claude Code plugin hint (TTY stdout only) | no | +| `DO_NOT_TRACK` / `SUPABASE_TELEMETRY_DISABLED` | suppress analytics delivery (state file still written) | no | ## Exit Codes -| Code | Condition | -| ---- | --------------------------------------------------------------- | -| `0` | success β€” token saved | -| `1` | non-TTY environment with no `--token` flag and no piped stdin | -| `1` | invalid token format (must be `sbp_*`) | -| `1` | automated browser flow: API polling failure or decryption error | +| Code | Condition | +| ---- | ------------------------------------------------------------------------------------------- | +| `0` | success (token path or browser path) | +| `1` | invalid `--token` (`cannot save provided token: …`) | +| `1` | non-TTY with no token (`Cannot use automatic login flow inside non-TTY environments. …`) | +| `1` | keygen failure, verification retries exhausted, or decryption failure (browser path) | +| `1` | failure to persist `~/.supabase/profile` (Go blocks subsequent CI commands on save failure) | -## Output +Browser-open failure is non-fatal (logged, ignored β€” `login.go:206-208`). -### `--output-format text` (Go CLI compatible) +## Telemetry Events Fired -On success, prints the browser URL for the automated login flow (if TTY) or a confirmation message after token is saved. +| Event | When | Notable properties / identity | +| ---------------------- | ------------------------------------------ | -------------------------------------------------------- | +| `cli_login_completed` | after the token persists | rides the stitched `gotrue_id` (`alias` + `distinct_id`) | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` | -``` -Hello from Supabase! Press Enter to open browser and login... - -Token saved successfully. -``` +## Output + +### `--output-format text` (Go CLI compatible) -For `--token` flag flow, prints a success confirmation to stdout. +Token path (stdout): `You are now logged in. Happy coding!` -### `--output-format json` +Browser path (stdout): `Hello from Supabase! Press Enter to open browser and login automatically.`, +then (after Enter) `Here is your login link in case browser did not open `; with +`--no-browser`: `Here is your login link, open it in the browser `. On success: +`Token created successfully.` then `You are now logged in. Happy coding!`. -Not applicable β€” login is an interactive command. No machine-readable JSON output defined. +stderr: verification prompt `Enter your verification code`, retry notices +`\nRetry (n/2): `, and the Claude Code hint (when applicable). -### `--output-format stream-json` +### `--output-format json` / `stream-json` -Not applicable β€” login is an interactive command. +Emits a single structured `success` result (`You are now logged in.`); human banners +are suppressed. Interactive prompts (browser path) fail with `NonInteractiveError`. ## Notes -- In TTY mode without `--token`, the command opens a browser and polls the Supabase platform for a session token. -- In non-TTY mode (CI), the command requires `--token` or piped stdin. Otherwise it fails with `ErrMissingToken`. -- The `--name` flag overrides the token name used for keyring storage. -- The `--no-browser` flag skips automatic browser opening even in TTY mode. -- Token is stored in the OS keyring when available; falls back to `~/.supabase/access-token`. -- The `PostRunE` hook saves `--profile` value via `utils.SaveProfileName` if `PROFILE` is set. +- Token resolution priority: `--token` β†’ `SUPABASE_ACCESS_TOKEN` β†’ piped stdin (non-TTY) β†’ browser flow (TTY). +- The login-session query string is built without URL-encoding, matching Go (`login.go:197-198`). +- Telemetry stitch always replaces a stale `distinct_id` (Go's `StitchLogin`), independent of the platform-API auto-stitch. The stitch _aliases_ only β€” Go's login never calls `identify`. +- On success, an explicitly-set profile is persisted to `~/.supabase/profile` (Go's `PostRunE`); `LegacyCliConfig` reads it back as the lowest-precedence profile source. +- Aqua/Bold styling from Go renders as plain text (parity on a non-TTY). diff --git a/apps/cli/src/legacy/commands/login/login-api.layer.ts b/apps/cli/src/legacy/commands/login/login-api.layer.ts new file mode 100644 index 0000000000..78d2eaa9fd --- /dev/null +++ b/apps/cli/src/legacy/commands/login/login-api.layer.ts @@ -0,0 +1,84 @@ +import { Effect, Layer, Option } from "effect"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; + +import { LegacyCliConfig } from "../../config/legacy-cli-config.service.ts"; +import { LegacyLoginApi, type LegacyLoginSessionResponse } from "./login-api.service.ts"; +import { LegacyLoginVerificationError } from "./login.errors.ts"; + +const POLL_TIMEOUT = "10 seconds"; + +function readString(obj: unknown, key: string): string { + if (typeof obj === "object" && obj !== null && key in obj) { + const value = (obj as Record)[key]; + return typeof value === "string" ? value : ""; + } + return ""; +} + +export const legacyLoginApiLayer = Layer.effect( + LegacyLoginApi, + Effect.gen(function* () { + const httpClient = yield* HttpClient.HttpClient; + const cliConfig = yield* LegacyCliConfig; + + return LegacyLoginApi.of({ + fetchLoginSession: (apiHost: string, sessionId: string, deviceCode: string) => + Effect.gen(function* () { + const url = `${apiHost}/platform/cli/login/${sessionId}?device_code=${deviceCode}`; + const request = HttpClientRequest.get(url).pipe( + HttpClientRequest.setHeader("User-Agent", cliConfig.userAgent), + ); + const response = yield* httpClient.execute(request); + if (response.status !== 200) { + const body = yield* response.text.pipe(Effect.orElseSucceed(() => "")); + return yield* Effect.fail( + new LegacyLoginVerificationError({ + message: `Error status ${response.status}: ${body}`, + }), + ); + } + const body = yield* response.json; + const session: LegacyLoginSessionResponse = { + access_token: readString(body, "access_token"), + public_key: readString(body, "public_key"), + nonce: readString(body, "nonce"), + }; + return session; + }).pipe( + // Map transport / JSON-decode failures to the retry-driving error. + // The explicit non-200 `LegacyLoginVerificationError` above passes + // through untouched (it is not an `HttpClientError`). + Effect.catchTag("HttpClientError", (cause) => + Effect.fail( + new LegacyLoginVerificationError({ + message: `failed to execute http request: ${cause.message}`, + }), + ), + ), + Effect.timeoutOrElse({ + duration: POLL_TIMEOUT, + orElse: () => + Effect.fail( + new LegacyLoginVerificationError({ + message: "failed to execute http request: request timed out", + }), + ), + }), + ), + + fetchGotrueId: (apiHost: string, token: string) => + Effect.gen(function* () { + const request = HttpClientRequest.get(`${apiHost}/v1/profile`).pipe( + HttpClientRequest.setHeader("Authorization", `Bearer ${token}`), + HttpClientRequest.setHeader("User-Agent", cliConfig.userAgent), + ); + const response = yield* httpClient.execute(request); + if (response.status !== 200) return Option.none(); + const body = yield* response.json; + const gotrueId = readString(body, "gotrue_id"); + return gotrueId.length > 0 ? Option.some(gotrueId) : Option.none(); + }).pipe(Effect.orElseSucceed(() => Option.none())), + }); + }), +); diff --git a/apps/cli/src/legacy/commands/login/login-api.service.ts b/apps/cli/src/legacy/commands/login/login-api.service.ts new file mode 100644 index 0000000000..1234f54e59 --- /dev/null +++ b/apps/cli/src/legacy/commands/login/login-api.service.ts @@ -0,0 +1,39 @@ +import type { Effect, Option } from "effect"; +import { Context } from "effect"; + +import type { LegacyLoginVerificationError } from "./login.errors.ts"; + +/** + * Subset of Go's `AccessTokenResponse` (`login.go:39-45`) the decrypt step + * consumes. `id` / `created_at` are returned by the API but unused. + */ +export type LegacyLoginSessionResponse = { + readonly access_token: string; + readonly public_key: string; + readonly nonce: string; +}; + +interface LegacyLoginApiShape { + /** + * Polls `GET {apiHost}/platform/cli/login/{sessionId}?device_code=` + * (Go's `pollForAccessToken`, `login.go:132-157`). Expects HTTP 200 with a + * 10s timeout; any transport / status / parse failure becomes a + * `LegacyLoginVerificationError` that drives the retry loop. + */ + readonly fetchLoginSession: ( + apiHost: string, + sessionId: string, + deviceCode: string, + ) => Effect.Effect; + /** + * Best-effort fetch of the authenticated user's `gotrue_id` from + * `GET {apiHost}/v1/profile` (Go's `getProfileGotrueID`, `login.go:301-310`). + * Returns `None` on any failure so the caller clears the telemetry + * `distinct_id`, matching Go's `handleTelemetryAfterLogin` error branch. + */ + readonly fetchGotrueId: (apiHost: string, token: string) => Effect.Effect>; +} + +export class LegacyLoginApi extends Context.Service()( + "supabase/legacy/LoginApi", +) {} diff --git a/apps/cli/src/legacy/commands/login/login-claude-hint.ts b/apps/cli/src/legacy/commands/login/login-claude-hint.ts new file mode 100644 index 0000000000..40e3113ccd --- /dev/null +++ b/apps/cli/src/legacy/commands/login/login-claude-hint.ts @@ -0,0 +1,25 @@ +/** + * Port of Go's `utils.SuggestClaudePlugin` (`apps/cli-go/internal/utils/misc.go:43-57`). + * + * Returns the Claude Code plugin-install hint **only** when both: + * 1. the CLI is running inside Claude Code (`CLAUDECODE` / `CLAUDE_CODE` env β€” + * Go's `agent.IsClaudeCode`), and + * 2. stdout is an interactive terminal (Go's `term.IsTerminal(stdout)`). + * + * Otherwise returns `""`. Pure: env + TTY state are passed in so the helper is + * trivially unit-testable and free of service dependencies. + */ +const CLAUDE_CODE_HINT = ``; + +export function legacyIsClaudeCode(env: NodeJS.ProcessEnv = process.env): boolean { + return (env["CLAUDECODE"] ?? "") !== "" || (env["CLAUDE_CODE"] ?? "") !== ""; +} + +export function legacySuggestClaudePlugin(opts: { + readonly stdoutIsTty: boolean; + readonly env?: NodeJS.ProcessEnv; +}): string { + if (!legacyIsClaudeCode(opts.env)) return ""; + if (!opts.stdoutIsTty) return ""; + return CLAUDE_CODE_HINT; +} diff --git a/apps/cli/src/legacy/commands/login/login-claude-hint.unit.test.ts b/apps/cli/src/legacy/commands/login/login-claude-hint.unit.test.ts new file mode 100644 index 0000000000..6b5e9a7e46 --- /dev/null +++ b/apps/cli/src/legacy/commands/login/login-claude-hint.unit.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; + +import { legacyIsClaudeCode, legacySuggestClaudePlugin } from "./login-claude-hint.ts"; + +const HINT = ``; + +describe("legacySuggestClaudePlugin", () => { + it("returns the hint when running inside Claude Code with a TTY stdout", () => { + expect(legacySuggestClaudePlugin({ stdoutIsTty: true, env: { CLAUDECODE: "1" } })).toBe(HINT); + expect(legacySuggestClaudePlugin({ stdoutIsTty: true, env: { CLAUDE_CODE: "1" } })).toBe(HINT); + }); + + it("returns empty string when stdout is not a TTY", () => { + expect(legacySuggestClaudePlugin({ stdoutIsTty: false, env: { CLAUDECODE: "1" } })).toBe(""); + }); + + it("returns empty string when not running inside Claude Code", () => { + expect(legacySuggestClaudePlugin({ stdoutIsTty: true, env: {} })).toBe(""); + expect(legacySuggestClaudePlugin({ stdoutIsTty: true, env: { CLAUDECODE: "" } })).toBe(""); + }); +}); + +describe("legacyIsClaudeCode", () => { + it("detects CLAUDECODE / CLAUDE_CODE env presence", () => { + expect(legacyIsClaudeCode({ CLAUDECODE: "1" })).toBe(true); + expect(legacyIsClaudeCode({ CLAUDE_CODE: "yes" })).toBe(true); + expect(legacyIsClaudeCode({})).toBe(false); + expect(legacyIsClaudeCode({ CLAUDECODE: "" })).toBe(false); + }); +}); diff --git a/apps/cli/src/legacy/commands/login/login-crypto.layer.ts b/apps/cli/src/legacy/commands/login/login-crypto.layer.ts new file mode 100644 index 0000000000..c63eeac9b6 --- /dev/null +++ b/apps/cli/src/legacy/commands/login/login-crypto.layer.ts @@ -0,0 +1,58 @@ +import { Buffer } from "node:buffer"; +import { createDecipheriv, createECDH, randomUUID, type ECDH } from "node:crypto"; +import { hostname, userInfo } from "node:os"; +import { Effect, Layer } from "effect"; + +import { LegacyLoginCrypto, type LegacyEncryptedPayload } from "./login-crypto.service.ts"; +import { LegacyLoginCryptoError, LegacyLoginDecryptError } from "./login.errors.ts"; + +const DECRYPTION_ERROR_MSG = "cannot decrypt access token"; + +export const legacyLoginCryptoLayer = Layer.sync(LegacyLoginCrypto, () => + LegacyLoginCrypto.of({ + generateKeyPair: Effect.try({ + try: () => { + const ecdh = createECDH("prime256v1"); + ecdh.generateKeys(); + return { ecdh, publicKeyHex: ecdh.getPublicKey("hex", "uncompressed") }; + }, + catch: (cause) => + new LegacyLoginCryptoError({ message: `cannot generate crypto keys: ${String(cause)}` }), + }), + generateSessionId: Effect.sync(() => randomUUID()), + defaultTokenName: Effect.sync(() => { + const ts = Math.floor(Date.now() / 1000); + try { + const user = userInfo().username; + const host = hostname(); + if (user && host) return `cli_${user}@${host}_${ts}`; + } catch { + /* fall through to the fallback name (Go's generateTokenNameWithFallback) */ + } + return `cli_${ts}`; + }), + decryptToken: (ecdh: ECDH, payload: LegacyEncryptedPayload) => + Effect.try({ + try: () => { + const sharedSecret = ecdh.computeSecret(Buffer.from(payload.publicKey, "hex")); + // Go's `aesgcm.Open` expects the 16-byte GCM tag appended to the + // ciphertext; Node wants it supplied separately via `setAuthTag`. + const ciphertextHex = payload.ciphertext.slice(0, -32); + const authTagHex = payload.ciphertext.slice(-32); + const decipher = createDecipheriv( + "aes-256-gcm", + sharedSecret, + Buffer.from(payload.nonce, "hex"), + ); + decipher.setAuthTag(Buffer.from(authTagHex, "hex")); + const decrypted = Buffer.concat([ + decipher.update(Buffer.from(ciphertextHex, "hex")), + decipher.final(), + ]); + return decrypted.toString("utf-8"); + }, + catch: (cause) => + new LegacyLoginDecryptError({ message: `${DECRYPTION_ERROR_MSG}: ${String(cause)}` }), + }), + }), +); diff --git a/apps/cli/src/legacy/commands/login/login-crypto.service.ts b/apps/cli/src/legacy/commands/login/login-crypto.service.ts new file mode 100644 index 0000000000..0022dbcff4 --- /dev/null +++ b/apps/cli/src/legacy/commands/login/login-crypto.service.ts @@ -0,0 +1,42 @@ +import type { ECDH } from "node:crypto"; +import type { Effect } from "effect"; +import { Context } from "effect"; + +import type { LegacyLoginCryptoError, LegacyLoginDecryptError } from "./login.errors.ts"; + +export type LegacyEncryptedPayload = { + readonly ciphertext: string; + readonly publicKey: string; + readonly nonce: string; +}; + +interface LegacyLoginCryptoShape { + /** + * Generates a P-256 (prime256v1) ECDH keypair and the uncompressed, + * hex-encoded public key sent to the dashboard. Mirrors Go's + * `LoginEncryption.generateKeys` + `encodedPublicKey` (`login.go:71-84`). + */ + readonly generateKeyPair: Effect.Effect< + { readonly ecdh: ECDH; readonly publicKeyHex: string }, + LegacyLoginCryptoError + >; + /** Fresh login session UUID (Go's `uuid.New().String()`, `login.go:187`). */ + readonly generateSessionId: Effect.Effect; + /** + * Default token name `cli_@_`, falling back to `cli_` + * when the username/hostname lookup fails (`login.go:249-271`). + */ + readonly defaultTokenName: Effect.Effect; + /** + * Derives the ECDH shared secret and AES-256-GCM decrypts the access token. + * Mirrors Go's `decryptAccessToken` (`login.go:86-128`). + */ + readonly decryptToken: ( + ecdh: ECDH, + payload: LegacyEncryptedPayload, + ) => Effect.Effect; +} + +export class LegacyLoginCrypto extends Context.Service()( + "supabase/legacy/LoginCrypto", +) {} diff --git a/apps/cli/src/legacy/commands/login/login.command.ts b/apps/cli/src/legacy/commands/login/login.command.ts index 129b75b2f5..869cdb0516 100644 --- a/apps/cli/src/legacy/commands/login/login.command.ts +++ b/apps/cli/src/legacy/commands/login/login.command.ts @@ -1,5 +1,9 @@ import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; + +import { withLegacyCommandInstrumentation } from "../../telemetry/legacy-command-instrumentation.ts"; +import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; +import { legacyLoginRuntimeLayer } from "./login.layers.ts"; import { legacyLogin } from "./login.handler.ts"; const config = { @@ -21,5 +25,8 @@ export type LegacyLoginFlags = CliCommand.Command.Config.Infer; export const legacyLoginCommand = Command.make("login", config).pipe( Command.withDescription("Authenticate using an access token."), Command.withShortDescription("Authenticate using an access token"), - Command.withHandler((flags) => legacyLogin(flags)), + Command.withHandler((flags) => + legacyLogin(flags).pipe(withLegacyCommandInstrumentation({ flags }), withJsonErrorHandling), + ), + Command.provide(legacyLoginRuntimeLayer), ); diff --git a/apps/cli/src/legacy/commands/login/login.e2e.test.ts b/apps/cli/src/legacy/commands/login/login.e2e.test.ts new file mode 100644 index 0000000000..3158d3a3ea --- /dev/null +++ b/apps/cli/src/legacy/commands/login/login.e2e.test.ts @@ -0,0 +1,46 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, test } from "vitest"; + +import { makeTempHome, runSupabase } from "../../../../tests/helpers/cli.ts"; + +const E2E_TIMEOUT_MS = 30_000; +const VALID_TOKEN = "sbp_" + "a".repeat(40); + +describe("supabase login (legacy)", () => { + // Golden path: --token persists the access token and reports success. The e2e + // harness sets SUPABASE_NO_KEYRING=1, so the token lands in the isolated + // HOME's ~/.supabase/access-token rather than the OS keyring. + test( + "login --token persists the token and prints the logged-in message", + { timeout: E2E_TIMEOUT_MS }, + async () => { + using home = makeTempHome(); + const { exitCode, stdout } = await runSupabase(["login", "--token", VALID_TOKEN], { + entrypoint: "legacy", + home: home.dir, + env: { HOME: home.dir }, + }); + expect(exitCode).toBe(0); + expect(stdout).toContain("You are now logged in. Happy coding!"); + expect(existsSync(join(home.dir, ".supabase", "access-token"))).toBe(true); + }, + ); + + // Non-TTY with no token cannot use the automatic flow. + test( + "login with no token in a non-TTY exits non-zero with the missing-token message", + { timeout: E2E_TIMEOUT_MS }, + async () => { + using home = makeTempHome(); + const { exitCode, stdout, stderr } = await runSupabase(["login"], { + entrypoint: "legacy", + home: home.dir, + env: { HOME: home.dir }, + }); + expect(exitCode).not.toBe(0); + expect(`${stdout}${stderr}`).toContain("Cannot use automatic login flow"); + }, + ); +}); diff --git a/apps/cli/src/legacy/commands/login/login.errors.ts b/apps/cli/src/legacy/commands/login/login.errors.ts new file mode 100644 index 0000000000..65b9c5aab1 --- /dev/null +++ b/apps/cli/src/legacy/commands/login/login.errors.ts @@ -0,0 +1,45 @@ +import { Data } from "effect"; + +/** + * Go's `ErrMissingToken` (`apps/cli-go/cmd/login.go:16`). Go Aqua-styles the + * `--token` / `SUPABASE_ACCESS_TOKEN` substrings, but the legacy port renders + * styling as plain text (Go strips color on a non-TTY), so this is byte-exact. + */ +export const LEGACY_LOGIN_MISSING_TOKEN_MESSAGE = + `Cannot use automatic login flow inside non-TTY environments. ` + + `Please provide --token flag or set the SUPABASE_ACCESS_TOKEN environment variable.`; + +/** Token-path save failure β€” Go's `cannot save provided token: %w` (`login.go:171`). */ +export class LegacyLoginSaveTokenError extends Data.TaggedError("LegacyLoginSaveTokenError")<{ + readonly message: string; +}> {} + +/** Non-TTY environment with no token supplied (`login.go:34-35`). */ +export class LegacyLoginMissingTokenError extends Data.TaggedError("LegacyLoginMissingTokenError")<{ + readonly message: string; +}> {} + +/** + * A single login-session poll/parse failure. Carries the underlying message so + * the retry notifier can print `\nRetry (n/2): ` exactly like Go's + * `newErrorCallback` (`login.go:159-166`); also the value `verifyWithRetries` + * surfaces after the final attempt. + */ +export class LegacyLoginVerificationError extends Data.TaggedError("LegacyLoginVerificationError")<{ + readonly message: string; +}> {} + +/** All verification retries exhausted (`login.go:214-216`). */ +export class LegacyLoginFailedError extends Data.TaggedError("LegacyLoginFailedError")<{ + readonly message: string; +}> {} + +/** ECDH / AES-GCM decryption failure β€” Go's `cannot decrypt access token` (`login.go:47`). */ +export class LegacyLoginDecryptError extends Data.TaggedError("LegacyLoginDecryptError")<{ + readonly message: string; +}> {} + +/** ECDH keypair generation failure β€” Go's `cannot generate crypto keys` (`login.go:66`). */ +export class LegacyLoginCryptoError extends Data.TaggedError("LegacyLoginCryptoError")<{ + readonly message: string; +}> {} diff --git a/apps/cli/src/legacy/commands/login/login.handler.ts b/apps/cli/src/legacy/commands/login/login.handler.ts index 01f82f4c96..ea32aef833 100644 --- a/apps/cli/src/legacy/commands/login/login.handler.ts +++ b/apps/cli/src/legacy/commands/login/login.handler.ts @@ -1,12 +1,231 @@ -import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../shared/legacy/go-proxy.service.ts"; +import { Effect, FileSystem, Option, Path, Redacted } from "effect"; + +import { LegacyCredentials } from "../../auth/legacy-credentials.service.ts"; +import { LegacyCliConfig } from "../../config/legacy-cli-config.service.ts"; +import { saveLegacyProfileName } from "../../config/legacy-profile-file.ts"; +import { LegacyTelemetryState } from "../../telemetry/legacy-telemetry-state.service.ts"; +import { legacyDashboardUrl } from "../../shared/legacy-profile.ts"; +import { LegacyProfileFlag } from "../../../shared/legacy/global-flags.ts"; +import { Output } from "../../../shared/output/output.service.ts"; +import type { NonInteractiveError } from "../../../shared/output/errors.ts"; +import { Browser } from "../../../shared/runtime/browser.service.ts"; +import { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts"; +import { Stdin } from "../../../shared/runtime/stdin.service.ts"; +import { Tty } from "../../../shared/runtime/tty.service.ts"; +import { Analytics } from "../../../shared/telemetry/analytics.service.ts"; +import { withAnalyticsContext } from "../../../shared/telemetry/analytics-context.ts"; +import { EventLoginCompleted } from "../../../shared/telemetry/event-catalog.ts"; +import { LegacyLoginApi, type LegacyLoginSessionResponse } from "./login-api.service.ts"; +import { LegacyLoginCrypto } from "./login-crypto.service.ts"; +import { legacySuggestClaudePlugin } from "./login-claude-hint.ts"; +import { + LEGACY_LOGIN_MISSING_TOKEN_MESSAGE, + LegacyLoginFailedError, + LegacyLoginMissingTokenError, + LegacyLoginSaveTokenError, +} from "./login.errors.ts"; import type { LegacyLoginFlags } from "./login.command.ts"; +// Go's `maxRetries` (`login.go:130`): the initial probe plus 2 retries (3 total). +const MAX_LOGIN_RETRIES = 2; + +const LOGGED_IN_MSG = "You are now logged in. Happy coding!\n"; + export const legacyLogin = Effect.fn("legacy.login")(function* (flags: LegacyLoginFlags) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["login"]; - if (Option.isSome(flags.token)) args.push("--token", flags.token.value); - if (Option.isSome(flags.name)) args.push("--name", flags.name.value); - if (flags.noBrowser) args.push("--no-browser"); - yield* proxy.exec(args); + const output = yield* Output; + const cliConfig = yield* LegacyCliConfig; + const credentials = yield* LegacyCredentials; + const crypto = yield* LegacyLoginCrypto; + const loginApi = yield* LegacyLoginApi; + const telemetryState = yield* LegacyTelemetryState; + const analytics = yield* Analytics; + const browser = yield* Browser; + const tty = yield* Tty; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const runtimeInfo = yield* RuntimeInfo; + const profileFlag = yield* LegacyProfileFlag; + + const apiHost = cliConfig.apiUrl; + const claudeHint = legacySuggestClaudePlugin({ stdoutIsTty: tty.stdoutIsTty }); + + // Mirrors Go's login `PostRunE` (`cmd/login.go:42-48`): when a profile was + // explicitly chosen (`--profile` over its default, else `SUPABASE_PROFILE`), + // persist it to `~/.supabase/profile` on success so later commands resolve the + // same profile. The raw token is written (Go's `viper.GetString("PROFILE")`), + // so a YAML-path profile round-trips. A write failure is fatal (Go: "Failure + // to save should block subsequent commands on CI"). + const envProfile = process.env["SUPABASE_PROFILE"]; + const profileToken = + profileFlag !== "supabase" + ? profileFlag + : envProfile !== undefined && envProfile.length > 0 + ? envProfile + : undefined; + const saveProfileName = + profileToken === undefined + ? Effect.void + : saveLegacyProfileName(fs, path, runtimeInfo.homeDir, profileToken); + + // Mirrors Go's `handleTelemetryAfterLogin` (`login.go:273-299`): fetch the + // gotrue id (best-effort), stitch or clear the telemetry identity, then always + // capture `cli_login_completed`. The capture rides the just-stitched identity + // so PostHog attributes it to the user (Go's `s.distinctID()` after StitchLogin). + // + // NOTE: Go's `StitchLogin` only *aliases* (`service.go:137`) β€” it does NOT + // call `identify`. Do not add `analytics.identify` here; that is a `next/` + // shell behavior and would emit an event Go never sends. + const postLoginTelemetry = (token: string) => + Effect.gen(function* () { + const gotrueId = yield* loginApi.fetchGotrueId(apiHost, token); + if (Option.isSome(gotrueId)) { + yield* telemetryState.stitchLogin(gotrueId.value); + yield* analytics + .capture(EventLoginCompleted) + .pipe(withAnalyticsContext({ distinct_id: gotrueId.value })); + } else { + yield* telemetryState.clearDistinctId; + yield* analytics.capture(EventLoginCompleted); + } + }); + + const tokenPath = (token: string) => + Effect.gen(function* () { + yield* credentials.saveAccessToken(token).pipe( + Effect.catchTag("LegacyInvalidAccessTokenError", (cause) => + Effect.fail( + new LegacyLoginSaveTokenError({ + message: `cannot save provided token: ${cause.message}`, + }), + ), + ), + ); + yield* postLoginTelemetry(token); + + if (output.format !== "text") { + yield* output.success("You are now logged in."); + return; + } + yield* output.raw(LOGGED_IN_MSG, "stdout"); + if (claudeHint.length > 0) yield* output.raw(`${claudeHint}\n`, "stderr"); + }); + + const browserPath = Effect.gen(function* () { + const { ecdh, publicKeyHex } = yield* crypto.generateKeyPair; + const sessionId = yield* crypto.generateSessionId; + const tokenName = Option.isSome(flags.name) ? flags.name.value : yield* crypto.defaultTokenName; + + // Go concatenates the query string without URL-encoding (`login.go:197-198`). + const loginUrl = + `${legacyDashboardUrl(cliConfig.profile)}/cli/login` + + `?session_id=${sessionId}&token_name=${tokenName}&public_key=${publicKeyHex}`; + + // The banners are human-facing text β€” suppressed in json / stream-json so + // stdout stays payload-only. The prompts still run (and fail cleanly with + // `NonInteractiveError` in a non-interactive machine mode). + const isText = output.format === "text"; + if (!flags.noBrowser) { + if (isText) { + yield* output.raw( + "Hello from Supabase! Press Enter to open browser and login automatically.\n", + "stdout", + ); + } + yield* output.promptText(""); + if (isText) { + yield* output.raw( + `Here is your login link in case browser did not open ${loginUrl}\n\n`, + "stdout", + ); + } + yield* Effect.ignore(browser.open(loginUrl)); + } else if (isText) { + yield* output.raw( + `Here is your login link, open it in the browser ${loginUrl}\n\n`, + "stdout", + ); + } + + // Verify + retry, mirroring Go's `pollForAccessToken` backoff + // (`login.go:132-166`): the notifier prints `\nRetry (n/2): ` after the + // first 2 failures; the 3rd failure gives up without a notice. + const verifyWithRetries = ( + failuresSoFar: number, + ): Effect.Effect => + Effect.gen(function* () { + const code = yield* output.promptText("Enter your verification code: ", { + validate: (v) => (v.trim().length > 0 ? undefined : "Verification code is required"), + }); + return yield* loginApi.fetchLoginSession(apiHost, sessionId, code.trim()); + }).pipe( + Effect.catchTag("LegacyLoginVerificationError", (err) => + Effect.gen(function* () { + const failures = failuresSoFar + 1; + if (failures > MAX_LOGIN_RETRIES) { + return yield* Effect.fail(new LegacyLoginFailedError({ message: err.message })); + } + yield* output.raw( + `${err.message}\nRetry (${failures}/${MAX_LOGIN_RETRIES}): `, + "stderr", + ); + return yield* verifyWithRetries(failures); + }), + ), + ); + + const session = yield* verifyWithRetries(0); + + const token = yield* crypto.decryptToken(ecdh, { + ciphertext: session.access_token, + publicKey: session.public_key, + nonce: session.nonce, + }); + // Go returns the raw save error here (`login.go:222-224`) β€” not the + // "cannot save provided token" wrapper used on the token path. + yield* credentials.saveAccessToken(token); + yield* postLoginTelemetry(token); + + if (output.format !== "text") { + yield* output.success("You are now logged in.", { token_name: tokenName }); + return; + } + yield* output.raw(`Token ${tokenName} created successfully.\n\n`, "stdout"); + yield* output.raw(LOGGED_IN_MSG, "stdout"); + if (claudeHint.length > 0) yield* output.raw(`${claudeHint}\n`, "stderr"); + }); + + const body = Effect.gen(function* () { + // Token resolution priority: --token β†’ SUPABASE_ACCESS_TOKEN β†’ piped stdin + // (non-TTY only). Matches `cmd/login.go:31-39` + `login.go:236-247`. + const resolved = yield* resolveToken(flags); + if (Option.isSome(resolved)) { + return yield* tokenPath(resolved.value); + } + return yield* browserPath; + }); + + // `Effect.tap` runs the profile save only on success (Go's `PostRunE`); + // `Effect.ensuring` persists telemetry state on success and failure alike + // (PersistentPostRun parity, `cmd/root.go:176`). + return yield* body.pipe( + Effect.tap(() => saveProfileName), + Effect.ensuring(telemetryState.flush), + ); +}); + +const resolveToken = Effect.fnUntraced(function* (flags: LegacyLoginFlags) { + if (Option.isSome(flags.token)) return Option.some(flags.token.value); + const cliConfig = yield* LegacyCliConfig; + if (Option.isSome(cliConfig.accessToken)) { + return Option.some(Redacted.value(cliConfig.accessToken.value)); + } + const stdin = yield* Stdin; + if (!stdin.isTTY) { + const piped = yield* stdin.readPipedText; + if (Option.isSome(piped)) return Option.some(piped.value); + return yield* Effect.fail( + new LegacyLoginMissingTokenError({ message: LEGACY_LOGIN_MISSING_TOKEN_MESSAGE }), + ); + } + return Option.none(); }); diff --git a/apps/cli/src/legacy/commands/login/login.integration.test.ts b/apps/cli/src/legacy/commands/login/login.integration.test.ts new file mode 100644 index 0000000000..1902ce5721 --- /dev/null +++ b/apps/cli/src/legacy/commands/login/login.integration.test.ts @@ -0,0 +1,354 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, Layer, Option, Redacted } from "effect"; +import * as HttpClient from "effect/unstable/http/HttpClient"; + +import { + mockAnalytics, + mockBrowser, + mockOutput, + mockRuntimeInfo, + mockStdin, + mockTty, +} from "../../../../tests/helpers/mocks.ts"; +import { LegacyProfileFlag } from "../../../shared/legacy/global-flags.ts"; +import { + LEGACY_VALID_TOKEN, + buildLegacyTestRuntime, + mockLegacyCliConfig, + mockLegacyCredentialsTracked, + mockLegacyLoginApi, + mockLegacyLoginCrypto, + mockLegacyPlatformApiService, + mockLegacyTelemetryStateTracked, + useLegacyTempWorkdir, +} from "../../../../tests/helpers/legacy-mocks.ts"; +import { EventLoginCompleted } from "../../../shared/telemetry/event-catalog.ts"; +import { legacyLogin } from "./login.handler.ts"; +import type { LegacyLoginFlags } from "./login.command.ts"; + +const tempRoot = useLegacyTempWorkdir("supabase-login-int-"); + +const noopHttpClient = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("unexpected HttpClient.execute in login test")), +); + +interface SetupOpts { + readonly format?: "text" | "json" | "stream-json"; + readonly isTTY?: boolean; + readonly stdoutIsTty?: boolean; + readonly accessTokenEnv?: string; + readonly pipedStdin?: string; + readonly gotrueId?: string; + readonly profileFails?: boolean; + readonly failTimes?: number; + readonly decryptFails?: boolean; + readonly keygenFails?: boolean; + readonly tokenName?: string; + readonly saveFails?: boolean; + readonly promptTextFail?: boolean; + readonly profileFlag?: string; + readonly homeDir?: string; +} + +function flags(overrides: Partial = {}): LegacyLoginFlags { + return { + token: Option.none(), + name: Option.none(), + noBrowser: false, + ...overrides, + }; +} + +function setupLegacyLogin(opts: SetupOpts = {}) { + const isTTY = opts.isTTY ?? false; + const out = mockOutput({ format: opts.format ?? "text", promptTextFail: opts.promptTextFail }); + const telemetry = mockLegacyTelemetryStateTracked(); + const credentials = mockLegacyCredentialsTracked({ saveFails: opts.saveFails }); + const crypto = mockLegacyLoginCrypto({ + decryptFails: opts.decryptFails, + keygenFails: opts.keygenFails, + tokenName: opts.tokenName, + }); + const loginApi = mockLegacyLoginApi({ + failTimes: opts.failTimes, + gotrueId: opts.gotrueId, + profileFails: opts.profileFails, + }); + const analytics = mockAnalytics(); + const cliConfig = mockLegacyCliConfig({ + workdir: tempRoot.current, + accessToken: + opts.accessTokenEnv !== undefined + ? Option.some(Redacted.make(opts.accessTokenEnv)) + : Option.none(), + }); + const tty = mockTty({ stdinIsTty: isTTY, stdoutIsTty: opts.stdoutIsTty ?? false }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api: { + layer: mockLegacyPlatformApiService({ v1: {} }).layer, + httpClientLayer: noopHttpClient, + }, + cliConfig, + analytics, + telemetry: telemetry.layer, + tty, + ...(opts.homeDir !== undefined + ? { runtimeInfo: mockRuntimeInfo({ homeDir: opts.homeDir }) } + : {}), + }), + credentials.layer, + crypto.layer, + loginApi.layer, + mockStdin(isTTY, opts.pipedStdin), + mockBrowser(), + Layer.succeed(LegacyProfileFlag, opts.profileFlag ?? "supabase"), + ); + return { layer, out, credentials, crypto, loginApi, telemetry, analytics }; +} + +describe("legacy login integration", () => { + it.live("saves the token from --token and reports logged in", () => { + const { layer, out, credentials, analytics } = setupLegacyLogin(); + return Effect.gen(function* () { + yield* legacyLogin(flags({ token: Option.some(LEGACY_VALID_TOKEN) })); + expect(credentials.savedToken).toBe(LEGACY_VALID_TOKEN); + expect(out.stdoutText).toContain("You are now logged in. Happy coding!"); + expect(analytics.captured.map((c) => c.event)).toContain(EventLoginCompleted); + }).pipe(Effect.provide(layer)); + }); + + it.live("saves the token from SUPABASE_ACCESS_TOKEN env when no flag is given", () => { + const { layer, credentials } = setupLegacyLogin({ accessTokenEnv: LEGACY_VALID_TOKEN }); + return Effect.gen(function* () { + yield* legacyLogin(flags()); + expect(credentials.savedToken).toBe(LEGACY_VALID_TOKEN); + }).pipe(Effect.provide(layer)); + }); + + it.live("saves the token piped via stdin in non-TTY", () => { + const { layer, credentials } = setupLegacyLogin({ + isTTY: false, + pipedStdin: LEGACY_VALID_TOKEN, + }); + return Effect.gen(function* () { + yield* legacyLogin(flags()); + expect(credentials.savedToken).toBe(LEGACY_VALID_TOKEN); + }).pipe(Effect.provide(layer)); + }); + + it.live("rejects an invalid --token with 'cannot save provided token:'", () => { + const { layer } = setupLegacyLogin({ saveFails: true }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyLogin(flags({ token: Option.some("not-a-token") }))); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyLoginSaveTokenError"); + expect(json).toContain("cannot save provided token:"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails in non-TTY with no token", () => { + const { layer } = setupLegacyLogin({ isTTY: false }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyLogin(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyLoginMissingTokenError"); + expect(json).toContain("Cannot use automatic login flow inside non-TTY environments"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("browser flow: generates link, opens browser, decrypts, saves, prints created", () => { + const { layer, out, credentials } = setupLegacyLogin({ isTTY: true, tokenName: "my-machine" }); + return Effect.gen(function* () { + yield* legacyLogin(flags()); + expect(out.stdoutText).toContain( + "Hello from Supabase! Press Enter to open browser and login automatically.", + ); + expect(out.stdoutText).toContain("/cli/login?session_id=test-session-id"); + expect(out.stdoutText).toContain("Token my-machine created successfully."); + expect(out.stdoutText).toContain("You are now logged in. Happy coding!"); + expect(credentials.savedToken).toBe(LEGACY_VALID_TOKEN); + }).pipe(Effect.provide(layer)); + }); + + it.live("browser flow with --no-browser prints the link without the open-browser banner", () => { + const { layer, out } = setupLegacyLogin({ isTTY: true }); + return Effect.gen(function* () { + yield* legacyLogin(flags({ noBrowser: true })); + expect(out.stdoutText).toContain("Here is your login link, open it in the browser"); + expect(out.stdoutText).not.toContain("Press Enter to open browser"); + }).pipe(Effect.provide(layer)); + }); + + it.live("browser flow uses the default token name when --name is absent", () => { + const { layer, out } = setupLegacyLogin({ isTTY: true }); + return Effect.gen(function* () { + yield* legacyLogin(flags()); + // mockLegacyLoginCrypto default token name. + expect(out.stdoutText).toContain("Token cli_test@host_123 created successfully."); + }).pipe(Effect.provide(layer)); + }); + + it.live("retries verification on poll failure then succeeds", () => { + const { layer, out, loginApi } = setupLegacyLogin({ isTTY: true, failTimes: 2 }); + return Effect.gen(function* () { + yield* legacyLogin(flags()); + expect(out.stderrText).toContain("Retry (1/2): "); + expect(out.stderrText).toContain("Retry (2/2): "); + // 2 failures + 1 success = 3 poll attempts. + expect(loginApi.loginCallCount).toBe(3); + expect(out.stdoutText).toContain("You are now logged in. Happy coding!"); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails after 2 retries are exhausted", () => { + const { layer, out } = setupLegacyLogin({ isTTY: true, failTimes: 3 }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyLogin(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyLoginFailedError"); + } + // The 3rd (final) failure gives up without printing a Retry notice. + expect(out.stderrText).toContain("Retry (2/2): "); + expect(out.stderrText).not.toContain("Retry (3/2): "); + }).pipe(Effect.provide(layer)); + }); + + it.live("decrypt failure surfaces 'cannot decrypt access token'", () => { + const { layer } = setupLegacyLogin({ isTTY: true, decryptFails: true }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyLogin(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyLoginDecryptError"); + expect(json).toContain("cannot decrypt access token"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("telemetry: successful profile fetch stitches the gotrue_id", () => { + const { layer, telemetry, analytics } = setupLegacyLogin({ gotrueId: "gotrue-abc" }); + return Effect.gen(function* () { + yield* legacyLogin(flags({ token: Option.some(LEGACY_VALID_TOKEN) })); + expect(telemetry.stitchedDistinctId).toBe("gotrue-abc"); + expect(telemetry.clearedDistinctId).toBe(false); + expect(analytics.captured.map((c) => c.event)).toContain(EventLoginCompleted); + }).pipe(Effect.provide(layer)); + }); + + it.live( + "telemetry: profile fetch failure clears distinct_id but login still succeeds + still captures", + () => { + const { layer, out, telemetry, analytics } = setupLegacyLogin({ profileFails: true }); + return Effect.gen(function* () { + yield* legacyLogin(flags({ token: Option.some(LEGACY_VALID_TOKEN) })); + expect(telemetry.clearedDistinctId).toBe(true); + expect(telemetry.stitchedDistinctId).toBeUndefined(); + expect(analytics.captured.map((c) => c.event)).toContain(EventLoginCompleted); + expect(out.stdoutText).toContain("You are now logged in. Happy coding!"); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("flushes telemetry state via ensuring", () => { + const { layer, telemetry } = setupLegacyLogin(); + return Effect.gen(function* () { + yield* legacyLogin(flags({ token: Option.some(LEGACY_VALID_TOKEN) })); + expect(telemetry.flushed).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + for (const format of ["json", "stream-json"] as const) { + it.live(`${format}: --token emits a single success result with no human banner`, () => { + const { layer, out } = setupLegacyLogin({ format }); + return Effect.gen(function* () { + yield* legacyLogin(flags({ token: Option.some(LEGACY_VALID_TOKEN) })); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.message).toBe("You are now logged in."); + expect(out.stdoutText).not.toContain("Happy coding!"); + }).pipe(Effect.provide(layer)); + }); + } + + it.live("browser flow: keygen failure exits with LegacyLoginCryptoError", () => { + const { layer } = setupLegacyLogin({ isTTY: true, keygenFails: true }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyLogin(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyLoginCryptoError"); + } + }).pipe(Effect.provide(layer)); + }); + + for (const format of ["json", "stream-json"] as const) { + it.live(`${format}: browser flow emits a success result with token_name`, () => { + const { layer, out } = setupLegacyLogin({ format, isTTY: true, tokenName: "my-machine" }); + return Effect.gen(function* () { + yield* legacyLogin(flags()); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.message).toBe("You are now logged in."); + expect(success?.data).toMatchObject({ token_name: "my-machine" }); + expect(out.stdoutText).not.toContain("Happy coding!"); + }).pipe(Effect.provide(layer)); + }); + } + + it.live( + "prints the Claude Code plugin hint to stderr when in Claude Code with a TTY stdout", + () => { + const prev = process.env["CLAUDECODE"]; + process.env["CLAUDECODE"] = "1"; + const { layer, out } = setupLegacyLogin({ stdoutIsTty: true }); + return Effect.gen(function* () { + yield* legacyLogin(flags({ token: Option.some(LEGACY_VALID_TOKEN) })); + expect(out.stderrText).toContain("claude-code-hint"); + }).pipe( + Effect.provide(layer), + Effect.ensuring( + Effect.sync(() => { + if (prev === undefined) delete process.env["CLAUDECODE"]; + else process.env["CLAUDECODE"] = prev; + }), + ), + ); + }, + ); + + it.live("persists ~/.supabase/profile on success when --profile is set", () => { + const { layer } = setupLegacyLogin({ + profileFlag: "supabase-staging", + homeDir: tempRoot.current, + }); + return Effect.gen(function* () { + yield* legacyLogin(flags({ token: Option.some(LEGACY_VALID_TOKEN) })); + const profilePath = join(tempRoot.current, ".supabase", "profile"); + expect(existsSync(profilePath)).toBe(true); + expect(readFileSync(profilePath, "utf8")).toBe("supabase-staging"); + }).pipe(Effect.provide(layer)); + }); + + it.live("browser flow in json mode fails cleanly at the prompt", () => { + const { layer } = setupLegacyLogin({ format: "json", isTTY: true, promptTextFail: true }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyLogin(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("NonInteractiveError"); + } + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/cli/src/legacy/commands/login/login.layers.ts b/apps/cli/src/legacy/commands/login/login.layers.ts new file mode 100644 index 0000000000..040f3cf952 --- /dev/null +++ b/apps/cli/src/legacy/commands/login/login.layers.ts @@ -0,0 +1,43 @@ +import { Layer } from "effect"; + +import { legacyCredentialsLayer } from "../../auth/legacy-credentials.layer.ts"; +import { legacyHttpClientLayer } from "../../auth/legacy-http-debug.layer.ts"; +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 { browserLayer } from "../../../shared/runtime/browser.layer.ts"; +import { stdinLayer } from "../../../shared/runtime/stdin.layer.ts"; +import { legacyLoginApiLayer } from "./login-api.layer.ts"; +import { legacyLoginCryptoLayer } from "./login-crypto.layer.ts"; + +// `login` is the only command that writes the access token, so it builds its own +// lean runtime instead of `legacyManagementApiRuntimeLayer` β€” it must NOT eagerly +// construct the platform-API client (which fails when no token exists yet). +// +// `legacyCliConfigLayer` is provided to both `legacyCredentialsLayer` and +// `legacyLoginApiLayer`, and exposed at the top level for the handler's direct +// `LegacyCliConfig` reads. `Layer.provide` does not share to siblings inside a +// `Layer.mergeAll` (legacy CLAUDE.md item 5), so the shared sub-layers are +// memoised by reference to avoid building two keyring readers / config loaders. +// `Analytics`, `Output`, `Stdio`, `Tty`, `TelemetryRuntime`, `FileSystem`, and +// `Path` come from the root layer. +const cliConfig = legacyCliConfigLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); +const httpClient = legacyHttpClientLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); +const credentials = legacyCredentialsLayer.pipe( + Layer.provide(cliConfig), + Layer.provide(legacyDebugLoggerLayer), +); +const loginApi = legacyLoginApiLayer.pipe(Layer.provide(httpClient), Layer.provide(cliConfig)); + +export const legacyLoginRuntimeLayer = Layer.mergeAll( + credentials, + cliConfig, + httpClient, + loginApi, + legacyLoginCryptoLayer, + legacyTelemetryStateLayer, + commandRuntimeLayer(["login"]), + browserLayer, + stdinLayer, +); diff --git a/apps/cli/src/legacy/commands/logout/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/logout/SIDE_EFFECTS.md index 784207df75..292843f8f9 100644 --- a/apps/cli/src/legacy/commands/logout/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/logout/SIDE_EFFECTS.md @@ -1,16 +1,21 @@ # `supabase logout` +Native TypeScript port of Go's `internal/logout`. Deletes the access token and +sweeps all stored project credentials. Makes no API calls. + ## Files Read -| Path | Format | When | -| -------------------------- | ------------------------- | --------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when keyring is unavailable; reads stored token to delete | +| Path | Format | When | +| -------------------------- | ------------------------- | ---------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | existence is checked before removal (no token parse) | ## Files Written -| Path | Format | When | -| -------------------------- | ------ | -------------------------------------------------- | -| `~/.supabase/access-token` | β€” | deleted on successful logout when keyring not used | +| Path | Format | When | +| ----------------------------------------------- | ------ | ---------------------------------------------------------------------------- | +| `~/.supabase/access-token` | β€” | deleted first, always (a missing file is ignored) | +| OS keyring (`Supabase CLI` namespace) | β€” | the access-token entries **and** all project DB-password entries are deleted | +| `/telemetry.json` | JSON | always (PersistentPostRun flush) | ## API Routes @@ -20,41 +25,47 @@ ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ------------------------------------------------ | --------- | -| `SUPABASE_ACCESS_TOKEN` | not consumed by logout; env token is not deleted | no | +| Variable | Purpose | Required? | +| ---------------------- | -------------------------------------------------- | --------- | +| `SUPABASE_YES`/`--yes` | auto-confirm the logout prompt | no | +| `SUPABASE_NO_KEYRING` | disables the OS keyring (forces the file fallback) | no | ## Exit Codes -| Code | Condition | -| ---- | ------------------------------------------------------------ | -| `0` | success β€” all stored credentials deleted | -| `0` | not logged in β€” nothing to delete, exits cleanly | -| `1` | user cancels the confirmation prompt (`context.Canceled`) | -| `1` | failure to delete credential file (e.g. `$HOME` not defined) | +| Code | Condition | +| ---- | ---------------------------------------------------------------------------------------------- | +| `0` | success β€” token + project credentials deleted | +| `0` | not logged in β€” profile keyring entry absent / keyring unavailable (prints to stderr) | +| `1` | user declines the confirmation prompt (`context canceled`) | +| `1` | a real removal failure β€” non-`ENOENT` file remove error or a real profile-keyring delete error | -## Output +## Telemetry Events Fired -### `--output-format text` (Go CLI compatible) +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | --------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms` | -Prints a confirmation prompt to stdout. On success, prints a logout confirmation. +## Output -``` -Do you want to log out? [Y/n] -Finished supabase logout. -``` +### `--output-format text` (Go CLI compatible) -### `--output-format json` +stdout (success): `Access token deleted successfully. You are now logged out.` -Not applicable β€” logout is an interactive confirmation command. No machine-readable JSON output defined. +stderr: the confirm prompt `Do you want to log out? This will remove the access token +from your system.`; the not-logged-in notice `You were not logged in, nothing to do.` -### `--output-format stream-json` +### `--output-format json` / `stream-json` -Not applicable β€” logout is an interactive confirmation command. +Emits a single structured `success` result. Without `--yes`, the confirm prompt fails +with `NonInteractiveError` in these modes. ## Notes -- Removes all Supabase CLI credentials: the access token and any stored project database passwords. -- The command prompts for confirmation before deleting credentials (even in non-TTY mode if stdin is connected). -- If keyring reports `ErrUnsupportedPlatform`, falls back to deleting `~/.supabase/access-token` file. -- `SUPABASE_ACCESS_TOKEN` env var is not affected by logout (it is not written to disk by the CLI). +- **Deliberate Go quirk (parity):** `deleteAccessToken` removes the file first, but the + outcome is decided solely by the profile-keyring delete. On a no-keyring host + (WSL / `SUPABASE_NO_KEYRING`) or when the token lived only in the file, the file is + removed yet logout still reports `You were not logged in, nothing to do.` and exits 0. +- The legacy `access-token` keyring entry delete is best-effort β€” its failure never + changes the outcome. +- Project DB-password credentials are swept only after a successful token delete; the + sweep is best-effort and never fails (Go's `StoreProvider.DeleteAll`). diff --git a/apps/cli/src/legacy/commands/logout/logout.command.ts b/apps/cli/src/legacy/commands/logout/logout.command.ts index d400ebbe97..3cb1cc3efd 100644 --- a/apps/cli/src/legacy/commands/logout/logout.command.ts +++ b/apps/cli/src/legacy/commands/logout/logout.command.ts @@ -1,8 +1,15 @@ import { Command } from "effect/unstable/cli"; + +import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; +import { withLegacyCommandInstrumentation } from "../../telemetry/legacy-command-instrumentation.ts"; +import { legacyLogoutRuntimeLayer } from "./logout.layers.ts"; import { legacyLogout } from "./logout.handler.ts"; export const legacyLogoutCommand = Command.make("logout").pipe( Command.withDescription("Log out and delete access tokens locally."), Command.withShortDescription("Log out and delete access tokens locally"), - Command.withHandler(() => legacyLogout()), + Command.withHandler(() => + legacyLogout().pipe(withLegacyCommandInstrumentation(), withJsonErrorHandling), + ), + Command.provide(legacyLogoutRuntimeLayer), ); diff --git a/apps/cli/src/legacy/commands/logout/logout.e2e.test.ts b/apps/cli/src/legacy/commands/logout/logout.e2e.test.ts new file mode 100644 index 0000000000..a5a0f50fed --- /dev/null +++ b/apps/cli/src/legacy/commands/logout/logout.e2e.test.ts @@ -0,0 +1,55 @@ +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, test } from "vitest"; + +import { makeTempHome, runSupabase } from "../../../../tests/helpers/cli.ts"; + +const E2E_TIMEOUT_MS = 30_000; +const VALID_TOKEN = "sbp_" + "a".repeat(40); + +function seedTokenFile(home: string): string { + const supaDir = join(home, ".supabase"); + mkdirSync(supaDir, { recursive: true }); + const tokenPath = join(supaDir, "access-token"); + writeFileSync(tokenPath, VALID_TOKEN, { mode: 0o600 }); + return tokenPath; +} + +describe("supabase logout (legacy)", () => { + // Deliberate Go quirk (parity note 1): under SUPABASE_NO_KEYRING=1 the profile + // keyring delete is unsupported, so logout removes the file token yet still + // reports "not logged in" and exits 0. + test( + "logout --yes removes a file token but reports not-logged-in under no-keyring", + { timeout: E2E_TIMEOUT_MS }, + async () => { + using home = makeTempHome(); + const tokenPath = seedTokenFile(home.dir); + const { exitCode, stderr } = await runSupabase(["logout", "--yes"], { + entrypoint: "legacy", + home: home.dir, + env: { HOME: home.dir }, + }); + expect(exitCode).toBe(0); + expect(stderr).toContain("You were not logged in, nothing to do."); + expect(existsSync(tokenPath)).toBe(false); + }, + ); + + // No token at all: same not-logged-in message, exit 0. + test( + "logout --yes with no token reports not-logged-in and exits 0", + { timeout: E2E_TIMEOUT_MS }, + async () => { + using home = makeTempHome(); + const { exitCode, stderr } = await runSupabase(["logout", "--yes"], { + entrypoint: "legacy", + home: home.dir, + env: { HOME: home.dir }, + }); + expect(exitCode).toBe(0); + expect(stderr).toContain("You were not logged in, nothing to do."); + }, + ); +}); diff --git a/apps/cli/src/legacy/commands/logout/logout.errors.ts b/apps/cli/src/legacy/commands/logout/logout.errors.ts new file mode 100644 index 0000000000..1f3dd768f6 --- /dev/null +++ b/apps/cli/src/legacy/commands/logout/logout.errors.ts @@ -0,0 +1,14 @@ +import { Data } from "effect"; + +/** + * Raised when the user declines the logout confirmation prompt. Go returns + * `errors.New(context.Canceled)` (`apps/cli-go/internal/logout/logout.go:18`), + * which the root error handler renders as `context canceled` on stderr with + * exit code 1 (`cmd/root.go:288-301` skips the debug suggestion for + * `context.Canceled`). + */ +export class LegacyLogoutCancelledError extends Data.TaggedError("LegacyLogoutCancelledError")<{ + readonly message: string; +}> {} + +export const LEGACY_LOGOUT_CANCELLED_MESSAGE = "context canceled"; diff --git a/apps/cli/src/legacy/commands/logout/logout.handler.ts b/apps/cli/src/legacy/commands/logout/logout.handler.ts index 954a958af8..5c4cd5855b 100644 --- a/apps/cli/src/legacy/commands/logout/logout.handler.ts +++ b/apps/cli/src/legacy/commands/logout/logout.handler.ts @@ -1,7 +1,65 @@ import { Effect } from "effect"; -import { LegacyGoProxy } from "../../../shared/legacy/go-proxy.service.ts"; + +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 { Output } from "../../../shared/output/output.service.ts"; +import { LegacyLogoutCancelledError, LEGACY_LOGOUT_CANCELLED_MESSAGE } from "./logout.errors.ts"; + +const LOGGED_OUT_MSG = "Access token deleted successfully. You are now logged out."; export const legacyLogout = Effect.fn("legacy.logout")(function* () { - const proxy = yield* LegacyGoProxy; - yield* proxy.exec(["logout"]); + const output = yield* Output; + const credentials = yield* LegacyCredentials; + const telemetryState = yield* LegacyTelemetryState; + const yes = yield* LegacyYesFlag; + + 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 }, + ); + if (!confirmed) { + return yield* Effect.fail( + new LegacyLogoutCancelledError({ message: LEGACY_LOGOUT_CANCELLED_MESSAGE }), + ); + } + + // Delete the access token. `LegacyNotLoggedInError` is the not-logged-in + // path (print to stderr, exit 0, and do NOT sweep project credentials β€” + // Go returns before `DeleteAll`, `logout.go:21-23`). `LegacyDeleteTokenError` + // propagates as exit 1 (`logout.go:24-26`). + const notLoggedIn = yield* credentials.deleteAccessToken.pipe( + Effect.as(false), + Effect.catchTag("LegacyNotLoggedInError", (err) => + Effect.gen(function* () { + if (output.format !== "text") { + // Machine modes have no Go equivalent (Go is text-only). Emit the + // message as the structured result so consumers can distinguish the + // not-logged-in outcome from a real logout instead of an empty blob. + yield* output.success(err.message); + } else { + yield* output.raw(`${err.message}\n`, "stderr"); + } + return true; + }), + ), + ); + if (notLoggedIn) return; + + // Best-effort sweep of all stored project DB passwords (`logout.go:29-31`). + yield* credentials.deleteAllProjectCredentials; + + if (output.format !== "text") { + yield* output.success(LOGGED_OUT_MSG); + return; + } + yield* output.raw(`${LOGGED_OUT_MSG}\n`, "stdout"); + }); + + // PersistentPostRun parity: persist telemetry state on success and failure. + return yield* body.pipe(Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/logout/logout.integration.test.ts b/apps/cli/src/legacy/commands/logout/logout.integration.test.ts new file mode 100644 index 0000000000..376d88d9f2 --- /dev/null +++ b/apps/cli/src/legacy/commands/logout/logout.integration.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, Layer } from "effect"; + +import { mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { + mockLegacyCredentialsTracked, + mockLegacyTelemetryStateTracked, +} from "../../../../tests/helpers/legacy-mocks.ts"; +import { LegacyYesFlag } from "../../../shared/legacy/global-flags.ts"; +import { legacyLogout } from "./logout.handler.ts"; + +interface SetupOpts { + readonly format?: "text" | "json" | "stream-json"; + readonly confirm?: boolean; + readonly yes?: boolean; + readonly deleteOutcome?: "ok" | "notLoggedIn" | "deleteError"; + readonly promptConfirmFail?: boolean; +} + +function setupLegacyLogout(opts: SetupOpts = {}) { + const out = mockOutput({ + format: opts.format ?? "text", + confirmLogout: opts.confirm ?? false, + promptConfirmFail: opts.promptConfirmFail, + }); + const telemetry = mockLegacyTelemetryStateTracked(); + const credentials = mockLegacyCredentialsTracked({ deleteOutcome: opts.deleteOutcome ?? "ok" }); + const layer = Layer.mergeAll( + out.layer, + credentials.layer, + telemetry.layer, + Layer.succeed(LegacyYesFlag, opts.yes ?? false), + ); + return { layer, out, telemetry, credentials }; +} + +describe("legacy logout integration", () => { + it.live("confirms then deletes the token + all project credentials", () => { + const { layer, out, credentials } = setupLegacyLogout({ confirm: true }); + return Effect.gen(function* () { + yield* legacyLogout(); + expect(credentials.deletedAll).toBe(true); + expect(out.stdoutText).toContain( + "Access token deleted successfully. You are now logged out.", + ); + }).pipe(Effect.provide(layer)); + }); + + it.live("--yes skips the prompt and logs out", () => { + const { layer, out, credentials } = setupLegacyLogout({ yes: true }); + return Effect.gen(function* () { + yield* legacyLogout(); + expect(credentials.deletedAll).toBe(true); + expect(out.stdoutText).toContain( + "Access token deleted successfully. You are now logged out.", + ); + }).pipe(Effect.provide(layer)); + }); + + it.live("declining the prompt cancels with a failure and does not sweep credentials", () => { + const { layer, credentials } = setupLegacyLogout({ confirm: 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("not logged in: prints to stderr, exits 0, and does not sweep credentials", () => { + const { layer, out, credentials } = setupLegacyLogout({ + yes: true, + deleteOutcome: "notLoggedIn", + }); + return Effect.gen(function* () { + yield* legacyLogout(); + expect(out.stderrText).toContain("You were not logged in, nothing to do."); + expect(out.stdoutText).not.toContain("Access token deleted successfully."); + expect(credentials.deletedAll).toBe(false); + }).pipe(Effect.provide(layer)); + }); + + it.live("delete failure propagates as a failure", () => { + const { layer, credentials } = setupLegacyLogout({ yes: true, deleteOutcome: "deleteError" }); + 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("LegacyDeleteTokenError"); + } + expect(credentials.deletedAll).toBe(false); + }).pipe(Effect.provide(layer)); + }); + + it.live("flushes telemetry state on success", () => { + const { layer, telemetry } = setupLegacyLogout({ yes: true }); + return Effect.gen(function* () { + yield* legacyLogout(); + expect(telemetry.flushed).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("flushes telemetry state on cancel", () => { + const { layer, telemetry } = setupLegacyLogout({ confirm: false }); + return Effect.gen(function* () { + yield* Effect.exit(legacyLogout()); + expect(telemetry.flushed).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + for (const format of ["json", "stream-json"] as const) { + it.live(`${format} with --yes emits a single success result`, () => { + const { layer, out } = setupLegacyLogout({ format, yes: true }); + return Effect.gen(function* () { + yield* legacyLogout(); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.message).toBe("Access token deleted successfully. You are now logged out."); + expect(out.stdoutText).not.toContain("Access token deleted successfully."); + }).pipe(Effect.provide(layer)); + }); + } + + for (const format of ["json", "stream-json"] as const) { + it.live(`${format} not-logged-in emits the not-logged-in message as the result`, () => { + const { layer, out, credentials } = setupLegacyLogout({ + format, + yes: true, + deleteOutcome: "notLoggedIn", + }); + return Effect.gen(function* () { + yield* legacyLogout(); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.message).toBe("You were not logged in, nothing to do."); + expect(credentials.deletedAll).toBe(false); + }).pipe(Effect.provide(layer)); + }); + } + + it.live("json mode without --yes fails cleanly at the confirm prompt", () => { + const { layer } = setupLegacyLogout({ format: "json", yes: false, promptConfirmFail: true }); + 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("NonInteractiveError"); + } + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/cli/src/legacy/commands/logout/logout.layers.ts b/apps/cli/src/legacy/commands/logout/logout.layers.ts new file mode 100644 index 0000000000..a2b44c218d --- /dev/null +++ b/apps/cli/src/legacy/commands/logout/logout.layers.ts @@ -0,0 +1,31 @@ +import { Layer } from "effect"; + +import { legacyCredentialsLayer } from "../../auth/legacy-credentials.layer.ts"; +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"; + +/** + * Lean runtime for `logout`. Like `unlink`, it must NOT use + * `legacyManagementApiRuntimeLayer` β€” that layer eagerly builds the platform-API + * client, which fails with "Access token not provided" when logging out without + * a token. It provides only what the handler + instrumentation consume. + * + * `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. + */ +const cliConfig = legacyCliConfigLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); +const credentials = legacyCredentialsLayer.pipe( + Layer.provide(cliConfig), + Layer.provide(legacyDebugLoggerLayer), +); + +export const legacyLogoutRuntimeLayer = Layer.mergeAll( + credentials, + cliConfig, + legacyTelemetryStateLayer, + commandRuntimeLayer(["logout"]), +); diff --git a/apps/cli/src/legacy/commands/telemetry/disable/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/telemetry/disable/SIDE_EFFECTS.md new file mode 100644 index 0000000000..1be0ffbf9f --- /dev/null +++ b/apps/cli/src/legacy/commands/telemetry/disable/SIDE_EFFECTS.md @@ -0,0 +1,57 @@ +# `supabase telemetry disable` + +## Files Read + +| Path | Format | When | +| ---------------------------- | ------ | --------------------------------------------------------------------------- | +| `~/.supabase/telemetry.json` | JSON | when the file exists, to preserve prior identity fields before rewriting it | + +When `SUPABASE_HOME` is set, the command uses `$SUPABASE_HOME/telemetry.json` +instead of `~/.supabase/telemetry.json`. + +## Files Written + +| Path | Format | When | +| ---------------------------- | ------ | ------ | +| `~/.supabase/telemetry.json` | JSON | always | + +## API Routes + +`disable` is fully local. No network calls are made. + +## Environment Variables + +| Variable | Purpose | Required? | +| --------------- | ------------------------------------------ | ------------------------------ | +| `SUPABASE_HOME` | override the telemetry state-file location | no (defaults to `~/.supabase`) | + +## Exit Codes + +| Code | Condition | +| ---- | ------------------------------------------------------------------------- | +| `0` | success | +| `1` | filesystem read/write failure while loading or persisting telemetry state | + +## Telemetry Events Fired + +None. The command disables analytics capture so toggling telemetry does not emit +`cli_command_executed`, matching Go. + +## Output + +On success, every output mode writes the same raw stdout line: + +```text +Telemetry is disabled. +``` + +If `--output-format json` or `stream-json` is set, only failures are rendered +through the shared JSON error wrapper; successful output remains the plain +stdout line above. + +## Notes + +- Existing `device_id`, `session_id`, and `distinct_id` fields are preserved + when the current state file is readable and valid enough to recover them. +- Malformed JSON is treated as missing state and replaced with a fresh disabled + state, matching `apps/cli-go/internal/telemetry/state.go`. diff --git a/apps/cli/src/legacy/commands/telemetry/disable/disable.command.ts b/apps/cli/src/legacy/commands/telemetry/disable/disable.command.ts index 26275cf7eb..4914b2c2a6 100644 --- a/apps/cli/src/legacy/commands/telemetry/disable/disable.command.ts +++ b/apps/cli/src/legacy/commands/telemetry/disable/disable.command.ts @@ -1,5 +1,8 @@ import { Command } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; import { legacyTelemetryDisable } from "./disable.handler.ts"; const config = {}; @@ -8,5 +11,11 @@ export type LegacyTelemetryDisableFlags = CliCommand.Command.Config.Infer legacyTelemetryDisable(flags)), + Command.withHandler((flags) => + legacyTelemetryDisable(flags).pipe( + withLegacyCommandInstrumentation({ analytics: false, flags }), + withJsonErrorHandling, + ), + ), + Command.provide(commandRuntimeLayer(["telemetry", "disable"])), ); diff --git a/apps/cli/src/legacy/commands/telemetry/disable/disable.handler.ts b/apps/cli/src/legacy/commands/telemetry/disable/disable.handler.ts index 656053ed86..a5a3410f49 100644 --- a/apps/cli/src/legacy/commands/telemetry/disable/disable.handler.ts +++ b/apps/cli/src/legacy/commands/telemetry/disable/disable.handler.ts @@ -1,10 +1,12 @@ import { Effect } from "effect"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { setLegacyTelemetryEnabled } from "../../../telemetry/legacy-telemetry-state.layer.ts"; import type { LegacyTelemetryDisableFlags } from "./disable.command.ts"; export const legacyTelemetryDisable = Effect.fn("legacy.telemetry.disable")(function* ( _flags: LegacyTelemetryDisableFlags, ) { - const proxy = yield* LegacyGoProxy; - yield* proxy.exec(["telemetry", "disable"]); + const output = yield* Output; + yield* setLegacyTelemetryEnabled(false); + yield* output.raw("Telemetry is disabled.\n"); }); diff --git a/apps/cli/src/legacy/commands/telemetry/enable/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/telemetry/enable/SIDE_EFFECTS.md new file mode 100644 index 0000000000..3942a107dc --- /dev/null +++ b/apps/cli/src/legacy/commands/telemetry/enable/SIDE_EFFECTS.md @@ -0,0 +1,57 @@ +# `supabase telemetry enable` + +## Files Read + +| Path | Format | When | +| ---------------------------- | ------ | --------------------------------------------------------------------------- | +| `~/.supabase/telemetry.json` | JSON | when the file exists, to preserve prior identity fields before rewriting it | + +When `SUPABASE_HOME` is set, the command uses `$SUPABASE_HOME/telemetry.json` +instead of `~/.supabase/telemetry.json`. + +## Files Written + +| Path | Format | When | +| ---------------------------- | ------ | ------ | +| `~/.supabase/telemetry.json` | JSON | always | + +## API Routes + +`enable` is fully local. No network calls are made. + +## Environment Variables + +| Variable | Purpose | Required? | +| --------------- | ------------------------------------------ | ------------------------------ | +| `SUPABASE_HOME` | override the telemetry state-file location | no (defaults to `~/.supabase`) | + +## Exit Codes + +| Code | Condition | +| ---- | ------------------------------------------------------------------------- | +| `0` | success | +| `1` | filesystem read/write failure while loading or persisting telemetry state | + +## Telemetry Events Fired + +None. The command disables analytics capture so toggling telemetry does not emit +`cli_command_executed`, matching Go. + +## Output + +On success, every output mode writes the same raw stdout line: + +```text +Telemetry is enabled. +``` + +If `--output-format json` or `stream-json` is set, only failures are rendered +through the shared JSON error wrapper; successful output remains the plain +stdout line above. + +## Notes + +- Existing `device_id`, `session_id`, and `distinct_id` fields are preserved + when the current state file is readable and valid enough to recover them. +- Malformed JSON is treated as missing state and replaced with a fresh enabled + state, matching `apps/cli-go/internal/telemetry/state.go`. diff --git a/apps/cli/src/legacy/commands/telemetry/enable/enable.command.ts b/apps/cli/src/legacy/commands/telemetry/enable/enable.command.ts index 1e9f9b42c0..5497f0e929 100644 --- a/apps/cli/src/legacy/commands/telemetry/enable/enable.command.ts +++ b/apps/cli/src/legacy/commands/telemetry/enable/enable.command.ts @@ -1,5 +1,8 @@ import { Command } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; import { legacyTelemetryEnable } from "./enable.handler.ts"; const config = {}; @@ -8,5 +11,11 @@ export type LegacyTelemetryEnableFlags = CliCommand.Command.Config.Infer legacyTelemetryEnable(flags)), + Command.withHandler((flags) => + legacyTelemetryEnable(flags).pipe( + withLegacyCommandInstrumentation({ analytics: false, flags }), + withJsonErrorHandling, + ), + ), + Command.provide(commandRuntimeLayer(["telemetry", "enable"])), ); diff --git a/apps/cli/src/legacy/commands/telemetry/enable/enable.handler.ts b/apps/cli/src/legacy/commands/telemetry/enable/enable.handler.ts index 886495f024..56c65eb8f5 100644 --- a/apps/cli/src/legacy/commands/telemetry/enable/enable.handler.ts +++ b/apps/cli/src/legacy/commands/telemetry/enable/enable.handler.ts @@ -1,10 +1,12 @@ import { Effect } from "effect"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { setLegacyTelemetryEnabled } from "../../../telemetry/legacy-telemetry-state.layer.ts"; import type { LegacyTelemetryEnableFlags } from "./enable.command.ts"; export const legacyTelemetryEnable = Effect.fn("legacy.telemetry.enable")(function* ( _flags: LegacyTelemetryEnableFlags, ) { - const proxy = yield* LegacyGoProxy; - yield* proxy.exec(["telemetry", "enable"]); + const output = yield* Output; + yield* setLegacyTelemetryEnabled(true); + yield* output.raw("Telemetry is enabled.\n"); }); diff --git a/apps/cli/src/legacy/commands/telemetry/status/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/telemetry/status/SIDE_EFFECTS.md new file mode 100644 index 0000000000..2c93acd75b --- /dev/null +++ b/apps/cli/src/legacy/commands/telemetry/status/SIDE_EFFECTS.md @@ -0,0 +1,67 @@ +# `supabase telemetry status` + +## Files Read + +| Path | Format | When | +| ---------------------------- | ------ | ------------------------------------------------------------------ | +| `~/.supabase/telemetry.json` | JSON | when the file exists, to load the current state before printing it | + +When `SUPABASE_HOME` is set, the command uses `$SUPABASE_HOME/telemetry.json` +instead of `~/.supabase/telemetry.json`. + +## Files Written + +| Path | Format | When | +| ---------------------------- | ------ | -------------------------------------------------------------------------------------- | +| `~/.supabase/telemetry.json` | JSON | always, because `status` refreshes `session_last_active` and recreates malformed state | + +## API Routes + +`status` is fully local. No network calls are made. + +## Environment Variables + +| Variable | Purpose | Required? | +| --------------- | ------------------------------------------ | ------------------------------ | +| `SUPABASE_HOME` | override the telemetry state-file location | no (defaults to `~/.supabase`) | + +## Exit Codes + +| Code | Condition | +| ---- | ------------------------------------------------------------------------- | +| `0` | success | +| `1` | filesystem read/write failure while loading or persisting telemetry state | + +Malformed JSON does not fail the command; it is treated as missing state and +replaced with a fresh enabled state. + +## Telemetry Events Fired + +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | ----------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` | + +## Output + +On success, every output mode writes the same raw stdout line: + +```text +Telemetry is enabled. +``` + +or + +```text +Telemetry is disabled. +``` + +If `--output-format json` or `stream-json` is set, only failures are rendered +through the shared JSON error wrapper; successful output remains the plain +stdout line above. + +## Notes + +- `status` always rewrites the state file, matching Go's + `telemetry.Status(...)->LoadOrCreateState(...)` path. +- Existing `device_id`, `session_id`, and `distinct_id` fields are preserved + when the current state file is readable and valid enough to recover them. diff --git a/apps/cli/src/legacy/commands/telemetry/status/status.command.ts b/apps/cli/src/legacy/commands/telemetry/status/status.command.ts index a14273f858..d5bb93f79e 100644 --- a/apps/cli/src/legacy/commands/telemetry/status/status.command.ts +++ b/apps/cli/src/legacy/commands/telemetry/status/status.command.ts @@ -1,5 +1,8 @@ import { Command } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; import { legacyTelemetryStatus } from "./status.handler.ts"; const config = {}; @@ -8,5 +11,11 @@ export type LegacyTelemetryStatusFlags = CliCommand.Command.Config.Infer legacyTelemetryStatus(flags)), + Command.withHandler((flags) => + legacyTelemetryStatus(flags).pipe( + withLegacyCommandInstrumentation({ flags }), + withJsonErrorHandling, + ), + ), + Command.provide(commandRuntimeLayer(["telemetry", "status"])), ); diff --git a/apps/cli/src/legacy/commands/telemetry/status/status.handler.ts b/apps/cli/src/legacy/commands/telemetry/status/status.handler.ts index 5dfb8531ec..b153b5fc85 100644 --- a/apps/cli/src/legacy/commands/telemetry/status/status.handler.ts +++ b/apps/cli/src/legacy/commands/telemetry/status/status.handler.ts @@ -1,10 +1,12 @@ import { Effect } from "effect"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { loadOrCreateLegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.layer.ts"; import type { LegacyTelemetryStatusFlags } from "./status.command.ts"; export const legacyTelemetryStatus = Effect.fn("legacy.telemetry.status")(function* ( _flags: LegacyTelemetryStatusFlags, ) { - const proxy = yield* LegacyGoProxy; - yield* proxy.exec(["telemetry", "status"]); + const output = yield* Output; + const state = yield* loadOrCreateLegacyTelemetryState(); + yield* output.raw(`Telemetry is ${state.enabled ? "enabled" : "disabled"}.\n`); }); diff --git a/apps/cli/src/legacy/commands/telemetry/telemetry.integration.test.ts b/apps/cli/src/legacy/commands/telemetry/telemetry.integration.test.ts new file mode 100644 index 0000000000..ced529bbf0 --- /dev/null +++ b/apps/cli/src/legacy/commands/telemetry/telemetry.integration.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from "@effect/vitest"; +import { BunServices } from "@effect/platform-bun"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { Effect, Layer } from "effect"; +import { Command } from "effect/unstable/cli"; + +import { mockAnalytics, mockOutput, processEnvLayer } from "../../../../tests/helpers/mocks.ts"; +import { legacyTelemetryCommand } from "./telemetry.command.ts"; + +function makeTempDir(): string { + return mkdtempSync(path.join(tmpdir(), "supabase-legacy-telemetry-")); +} + +function telemetryPath(dir: string): string { + return path.join(dir, "telemetry.json"); +} + +function readTelemetryConfig(dir: string): Record { + return JSON.parse(readFileSync(telemetryPath(dir), "utf8")) as Record; +} + +function setup(dir: string) { + const out = mockOutput(); + const analytics = mockAnalytics(); + const layer = Layer.mergeAll( + out.layer, + analytics.layer, + BunServices.layer, + processEnvLayer({ SUPABASE_HOME: dir }), + ); + return { out, layer }; +} + +function legacyTestRoot() { + return Command.make("supabase").pipe(Command.withSubcommands([legacyTelemetryCommand])); +} + +describe("legacy telemetry integration", () => { + it.live("status creates legacy telemetry.json and prints Go-style enabled output", () => { + const dir = makeTempDir(); + const { out, layer } = setup(dir); + + return Effect.gen(function* () { + yield* Command.runWith(legacyTestRoot(), { version: "0.0.0-test" })(["telemetry", "status"]); + expect(out.stdoutText).toBe("Telemetry is enabled.\n"); + expect(existsSync(telemetryPath(dir))).toBe(true); + const config = readTelemetryConfig(dir); + expect(config.enabled).toBe(true); + expect(config.schema_version).toBe(1); + }).pipe( + Effect.provide(layer), + Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), + ) as Effect.Effect; + }); + + it.live("enable preserves prior identity fields and prints Go-style enabled output", () => { + const dir = makeTempDir(); + const { out, layer } = setup(dir); + + writeFileSync( + telemetryPath(dir), + JSON.stringify({ + enabled: false, + device_id: "device-123", + session_id: "session-123", + session_last_active: "2026-01-01T00:00:00.000Z", + distinct_id: "user-123", + schema_version: 1, + }), + ); + + return Effect.gen(function* () { + yield* Command.runWith(legacyTestRoot(), { version: "0.0.0-test" })(["telemetry", "enable"]); + expect(out.stdoutText).toBe("Telemetry is enabled.\n"); + const config = readTelemetryConfig(dir); + expect(config.enabled).toBe(true); + expect(config.device_id).toBe("device-123"); + expect(config.distinct_id).toBe("user-123"); + expect(config.schema_version).toBe(1); + }).pipe( + Effect.provide(layer), + Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), + ) as Effect.Effect; + }); + + it.live("disable preserves prior identity fields and prints Go-style disabled output", () => { + const dir = makeTempDir(); + const { out, layer } = setup(dir); + + writeFileSync( + telemetryPath(dir), + JSON.stringify({ + enabled: true, + device_id: "device-123", + session_id: "session-123", + session_last_active: "2026-01-01T00:00:00.000Z", + distinct_id: "user-123", + schema_version: 1, + }), + ); + + return Effect.gen(function* () { + yield* Command.runWith(legacyTestRoot(), { version: "0.0.0-test" })(["telemetry", "disable"]); + expect(out.stdoutText).toBe("Telemetry is disabled.\n"); + const config = readTelemetryConfig(dir); + expect(config.enabled).toBe(false); + expect(config.device_id).toBe("device-123"); + expect(config.distinct_id).toBe("user-123"); + expect(config.schema_version).toBe(1); + }).pipe( + Effect.provide(layer), + Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), + ) as Effect.Effect; + }); + + it.live("status recovers a malformed legacy telemetry.json instead of failing", () => { + const dir = makeTempDir(); + const { out, layer } = setup(dir); + + writeFileSync(telemetryPath(dir), "{not valid json}"); + + return Effect.gen(function* () { + yield* Command.runWith(legacyTestRoot(), { version: "0.0.0-test" })(["telemetry", "status"]); + expect(out.stdoutText).toBe("Telemetry is enabled.\n"); + const config = readTelemetryConfig(dir); + expect(config.enabled).toBe(true); + expect(config.schema_version).toBe(1); + }).pipe( + Effect.provide(layer), + Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), + ) as Effect.Effect; + }); + + it.live( + "status treats malformed typed fields as a corrupted file and regenerates identity", + () => { + const dir = makeTempDir(); + const { out, layer } = setup(dir); + + writeFileSync( + telemetryPath(dir), + JSON.stringify({ + enabled: false, + device_id: "device-123", + session_id: "session-123", + session_last_active: "not-a-time", + distinct_id: "user-123", + schema_version: 1, + }), + ); + + return Effect.gen(function* () { + yield* Command.runWith(legacyTestRoot(), { version: "0.0.0-test" })([ + "telemetry", + "status", + ]); + expect(out.stdoutText).toBe("Telemetry is enabled.\n"); + const config = readTelemetryConfig(dir); + expect(config.enabled).toBe(true); + expect(config.device_id).not.toBe("device-123"); + expect(config.session_id).not.toBe("session-123"); + expect(config.distinct_id).toBeUndefined(); + expect(config.schema_version).toBe(1); + }).pipe( + Effect.provide(layer), + Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), + ) as Effect.Effect; + }, + ); +}); diff --git a/apps/cli/src/legacy/commands/unlink/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/unlink/SIDE_EFFECTS.md index 293ae28c13..11d9e23724 100644 --- a/apps/cli/src/legacy/commands/unlink/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/unlink/SIDE_EFFECTS.md @@ -1,55 +1,57 @@ # `supabase unlink` +Native TypeScript port of Go's `internal/unlink`. Operates entirely on local state under +`/supabase/.temp/` and the OS keyring β€” no API calls. + ## Files Read -| Path | Format | When | -| ------------------------ | ------ | ---------------------------------------------- | -| `.supabase/project.json` | JSON | to retrieve stored project ref before deletion | +| Path | Format | When | +| ---------------------------- | ---------- | -------------------------------------- | +| `supabase/.temp/project-ref` | plain text | always, to find the linked project ref | + +The ref bytes are read **without trimming** β€” `link` writes the ref with no trailing newline, so the +value round-trips exactly and is reused verbatim for both the stderr message and the keyring key. + +## Files Written / Deleted -## Files Written +| Path | Action | When | +| ----------------- | ------------------- | ------------------------------ | +| `supabase/.temp/` | removed recursively | always (after reading the ref) | -| Path | Format | When | -| ------------------------ | ------ | ------------------ | -| `.supabase/project.json` | β€” | deleted on success | +Also deletes the stored **database-password** credential from the OS keyring (service `"Supabase CLI"`, +account = the **project ref**). A missing entry is ignored; the access-token credential is left untouched. ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ---- | ---- | ------------ | ---------------------- | -| β€” | β€” | β€” | β€” | β€” | +None. ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ---------------------- | --------- | -| `SUPABASE_ACCESS_TOKEN` | not consumed by unlink | no | +None beyond `--workdir` / `SUPABASE_WORKDIR` resolution. ## Exit Codes -| Code | Condition | -| ---- | -------------------------------------------------------------------- | -| `0` | success β€” project unlinked | -| `0` | project unlinked without stored database credentials (keyring empty) | -| `1` | not linked β€” no `.supabase/project.json` found (`ErrNotLinked`) | -| `1` | permission denied removing the project state file | +| Code | Condition | +| ---- | --------------------------------------------------------------------------------------------------------- | +| `0` | success β€” project unlinked; prints `Finished supabase unlink.` | +| `1` | not linked β€” `supabase/.temp/project-ref` absent (`Cannot find project ref. Have you run supabase link?`) | +| `1` | project-ref read error | +| `1` | temp-dir removal error | +| `1` | keyring delete error other than not-found (e.g. permission denied) | ## Output -### `--output-format text` (Go CLI compatible) - -No output on success (exits 0 silently). Error messages go to stderr. - -### `--output-format json` +### `--output-format text` (Go-compatible) -Not applicable β€” unlink produces no JSON output. +- stderr: `Unlinking project: ` +- stdout: `Finished supabase unlink.` -### `--output-format stream-json` +### `--output-format json` / `stream-json` -Not applicable β€” unlink produces no structured output. +Emits a structured success (`{ project_ref }`) and suppresses the human `Finished` line. -## Notes +## Known divergence -- Removes the project ref from `.supabase/project.json` (and the legacy `supabase/.temp/project-ref` path). -- Also removes any stored database password for the project from the OS keyring. -- If the keyring entry does not exist (`ErrNotFound`), the command still succeeds. -- No API calls are made; this is a purely local operation. +The `Finished supabase unlink.` line is emitted as **plain text**; Go renders `supabase unlink` in +ANSI cyan via `utils.Aqua`. This matches the established legacy-port convention (color helpers are +rendered plain); ANSI-stripping scripts are unaffected. diff --git a/apps/cli/src/legacy/commands/unlink/unlink.command.ts b/apps/cli/src/legacy/commands/unlink/unlink.command.ts index 0dd6fa5556..23f578ac75 100644 --- a/apps/cli/src/legacy/commands/unlink/unlink.command.ts +++ b/apps/cli/src/legacy/commands/unlink/unlink.command.ts @@ -1,8 +1,39 @@ +import { Layer } from "effect"; import { Command } from "effect/unstable/cli"; + +import { legacyCredentialsLayer } from "../../auth/legacy-credentials.layer.ts"; +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 { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; +import { withLegacyCommandInstrumentation } from "../../telemetry/legacy-command-instrumentation.ts"; import { legacyUnlink } from "./unlink.handler.ts"; +// `unlink` makes no Management API calls (Go's unlink needs no access token), so it +// deliberately avoids `legacyManagementApiRuntimeLayer` β€” that layer eagerly resolves +// an access token and would fail with "Access token not provided" for a token-less +// `unlink`. It provides only the services the handler + instrumentation consume. +// `legacyCliConfigLayer` is provided to credentials AND exposed at the top level +// (Layer.provide does not share to siblings inside a merge β€” legacy CLAUDE.md item 5). +const cliConfig = legacyCliConfigLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); +const credentials = legacyCredentialsLayer.pipe( + Layer.provide(cliConfig), + Layer.provide(legacyDebugLoggerLayer), +); + +const legacyUnlinkRuntimeLayer = Layer.mergeAll( + credentials, + cliConfig, + legacyTelemetryStateLayer, + commandRuntimeLayer(["unlink"]), +); + export const legacyUnlinkCommand = Command.make("unlink").pipe( Command.withDescription("Unlink a Supabase project."), Command.withShortDescription("Unlink a Supabase project"), - Command.withHandler(() => legacyUnlink()), + Command.withHandler(() => + legacyUnlink().pipe(withLegacyCommandInstrumentation(), withJsonErrorHandling), + ), + Command.provide(legacyUnlinkRuntimeLayer), ); diff --git a/apps/cli/src/legacy/commands/unlink/unlink.e2e.test.ts b/apps/cli/src/legacy/commands/unlink/unlink.e2e.test.ts new file mode 100644 index 0000000000..f7406d8b41 --- /dev/null +++ b/apps/cli/src/legacy/commands/unlink/unlink.e2e.test.ts @@ -0,0 +1,56 @@ +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { describe, expect, test } from "vitest"; +import { runSupabase } from "../../../../tests/helpers/cli.ts"; + +const E2E_TIMEOUT_MS = 30_000; +const TEST_PROJECT_REF = "abcdefghijklmnopqrst"; + +describe("supabase unlink (legacy)", () => { + // Golden path: with a seeded `supabase/.temp/project-ref`, a real subprocess + // removes the temp dir and prints the Finished line. No network is involved. + test( + "removes supabase/.temp and prints Finished when linked", + { timeout: E2E_TIMEOUT_MS }, + async () => { + const projectDir = mkdtempSync(join(tmpdir(), "sb-unlink-e2e-")); + try { + mkdirSync(join(projectDir, "supabase", ".temp"), { recursive: true }); + writeFileSync(join(projectDir, "supabase", ".temp", "project-ref"), TEST_PROJECT_REF); + + const { exitCode, stdout, stderr } = await runSupabase(["unlink"], { + entrypoint: "legacy", + cwd: projectDir, + }); + + expect(exitCode).toBe(0); + expect(stdout).toContain("Finished supabase unlink."); + expect(stderr).toContain(`Unlinking project: ${TEST_PROJECT_REF}`); + expect(existsSync(join(projectDir, "supabase", ".temp"))).toBe(false); + } finally { + rmSync(projectDir, { recursive: true, force: true }); + } + }, + ); + + // The not-linked path exits non-zero with Go's `ErrNotLinked` message. + test( + "without a linked project exits 1 with the not-linked message", + { timeout: E2E_TIMEOUT_MS }, + async () => { + const projectDir = mkdtempSync(join(tmpdir(), "sb-unlink-e2e-")); + try { + const { exitCode, stdout, stderr } = await runSupabase(["unlink"], { + entrypoint: "legacy", + cwd: projectDir, + }); + expect(exitCode).toBe(1); + expect(`${stdout}${stderr}`).toContain("Cannot find project ref"); + } finally { + rmSync(projectDir, { recursive: true, force: true }); + } + }, + ); +}); diff --git a/apps/cli/src/legacy/commands/unlink/unlink.errors.ts b/apps/cli/src/legacy/commands/unlink/unlink.errors.ts new file mode 100644 index 0000000000..8c1e9155f1 --- /dev/null +++ b/apps/cli/src/legacy/commands/unlink/unlink.errors.ts @@ -0,0 +1,18 @@ +import { Data } from "effect"; + +/** + * Reading `supabase/.temp/project-ref` failed for a reason other than the file + * being absent (which maps to `LegacyProjectNotLinkedError`). Byte-matches Go's + * `"failed to load project ref: " + err` (`apps/cli-go/internal/unlink/unlink.go:19`). + */ +export class LegacyUnlinkRefReadError extends Data.TaggedError("LegacyUnlinkRefReadError")<{ + readonly message: string; +}> {} + +/** + * Removing the `supabase/.temp` directory failed. Byte-matches Go's + * `"failed to remove temp directory: " + err` (`unlink.go:32`). + */ +export class LegacyUnlinkTempRemovalError extends Data.TaggedError("LegacyUnlinkTempRemovalError")<{ + readonly message: string; +}> {} diff --git a/apps/cli/src/legacy/commands/unlink/unlink.handler.ts b/apps/cli/src/legacy/commands/unlink/unlink.handler.ts index 0ac063ede0..d49cbabe11 100644 --- a/apps/cli/src/legacy/commands/unlink/unlink.handler.ts +++ b/apps/cli/src/legacy/commands/unlink/unlink.handler.ts @@ -1,7 +1,88 @@ -import { Effect } from "effect"; -import { LegacyGoProxy } from "../../../shared/legacy/go-proxy.service.ts"; +import { Effect, FileSystem, Path, Result } from "effect"; + +import { LegacyCredentials } from "../../auth/legacy-credentials.service.ts"; +import { LegacyCredentialDeleteError } from "../../auth/legacy-errors.ts"; +import { LegacyCliConfig } from "../../config/legacy-cli-config.service.ts"; +import { LegacyProjectNotLinkedError } from "../../config/legacy-project-ref.errors.ts"; +import { PROJECT_NOT_LINKED_MESSAGE } from "../../config/legacy-project-ref.service.ts"; +import { LegacyTelemetryState } from "../../telemetry/legacy-telemetry-state.service.ts"; +import { Output } from "../../../shared/output/output.service.ts"; +import { legacyTempPaths } from "../../shared/legacy-temp-paths.ts"; +import { LegacyUnlinkRefReadError, LegacyUnlinkTempRemovalError } from "./unlink.errors.ts"; export const legacyUnlink = Effect.fn("legacy.unlink")(function* () { - const proxy = yield* LegacyGoProxy; - yield* proxy.exec(["unlink"]); + const output = yield* Output; + const cliConfig = yield* LegacyCliConfig; + const credentials = yield* LegacyCredentials; + const telemetryState = yield* LegacyTelemetryState; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const paths = legacyTempPaths(path, cliConfig.workdir); + + yield* Effect.gen(function* () { + // 1. Load the linked project ref. An absent file is `ErrNotLinked`; any other + // read failure surfaces verbatim (unlink.go:16-19). + const exists = yield* fs.exists(paths.projectRef).pipe(Effect.orElseSucceed(() => false)); + if (!exists) { + return yield* Effect.fail( + new LegacyProjectNotLinkedError({ message: PROJECT_NOT_LINKED_MESSAGE }), + ); + } + // Go reads the raw bytes without trimming β€” `link` writes the ref with no + // trailing newline, so the value round-trips exactly (used for both the + // stderr message and the keyring key). + const projectRef = yield* fs.readFileString(paths.projectRef).pipe( + Effect.mapError( + (cause) => + new LegacyUnlinkRefReadError({ + message: `failed to load project ref: ${String(cause)}`, + }), + ), + ); + + yield* output.raw(`Unlinking project: ${projectRef}\n`, "stderr"); + + // 2. Best-effort: remove the temp dir and delete the stored db-password + // credential. Both are attempted; non-ignored errors are joined (unlink.go:29-41). + const collected: Array = []; + + const removed = yield* fs.remove(paths.tempDir, { recursive: true, force: true }).pipe( + Effect.mapError( + (cause) => + new LegacyUnlinkTempRemovalError({ + message: `failed to remove temp directory: ${String(cause)}`, + }), + ), + Effect.result, + ); + if (Result.isFailure(removed)) collected.push(removed.failure); + + const deleted = yield* credentials.deleteProjectCredential(projectRef).pipe(Effect.result); + if (Result.isFailure(deleted)) collected.push(deleted.failure); + + const [first, ...rest] = collected; + if (first !== undefined) { + // Mirror Go's `errors.Join(allErrors...)` (unlink.go:41): surface every + // collected message, not just the first. Keep the leading failure's tag + // (temp removal precedes the credential delete, matching Go's order). + if (rest.length === 0) { + return yield* Effect.fail(first); + } + const message = collected.map((e) => e.message).join("\n"); + return yield* Effect.fail( + first._tag === "LegacyUnlinkTempRemovalError" + ? new LegacyUnlinkTempRemovalError({ message }) + : new LegacyCredentialDeleteError({ message }), + ); + } + + // 3. PostRun: `Finished supabase unlink.` to stdout (text), structured success + // otherwise. + if (output.format === "text") { + yield* output.raw("Finished supabase unlink.\n"); + } else { + yield* output.success("", { project_ref: projectRef }); + } + }).pipe(Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/unlink/unlink.integration.test.ts b/apps/cli/src/legacy/commands/unlink/unlink.integration.test.ts new file mode 100644 index 0000000000..5841e78098 --- /dev/null +++ b/apps/cli/src/legacy/commands/unlink/unlink.integration.test.ts @@ -0,0 +1,198 @@ +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, FileSystem, Layer, Option } from "effect"; +import { badArgument } from "effect/PlatformError"; +import * as HttpClient from "effect/unstable/http/HttpClient"; + +import { mockAnalytics, mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { + LEGACY_VALID_REF, + buildLegacyTestRuntime, + mockLegacyCliConfig, + mockLegacyCredentialsTracked, + mockLegacyPlatformApiService, + mockLegacyTelemetryStateTracked, + useLegacyTempWorkdir, +} from "../../../../tests/helpers/legacy-mocks.ts"; +import { legacyUnlink } from "./unlink.handler.ts"; + +const tempRoot = useLegacyTempWorkdir("supabase-unlink-int-"); + +const noopHttpClient = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("unexpected HttpClient.execute in unlink test")), +); + +interface SetupOpts { + format?: "text" | "json" | "stream-json"; + deleteFails?: boolean; + removeFails?: boolean; +} + +// Wraps the real Bun FileSystem but forces `remove` to fail, so the +// temp-dir-removal error branch can be exercised deterministically (cross-platform, +// independent of filesystem permissions). +const failingRemoveFsLayer = Layer.effect( + FileSystem.FileSystem, + Effect.gen(function* () { + const real = yield* FileSystem.FileSystem; + return FileSystem.FileSystem.of({ + ...real, + remove: () => + Effect.fail( + badArgument({ + module: "FileSystem", + method: "remove", + description: "permission denied", + }), + ), + }); + }), +).pipe(Layer.provide(BunServices.layer)); + +function seedProjectRef(workdir: string, ref: string) { + mkdirSync(join(workdir, "supabase", ".temp"), { recursive: true }); + writeFileSync(join(workdir, "supabase", ".temp", "project-ref"), ref); +} + +function setup(opts: SetupOpts = {}) { + const out = mockOutput({ format: opts.format ?? "text" }); + const telemetry = mockLegacyTelemetryStateTracked(); + const credentials = mockLegacyCredentialsTracked({ deleteFails: opts.deleteFails }); + const apiMock = mockLegacyPlatformApiService({ v1: {} }); + const cliConfig = mockLegacyCliConfig({ workdir: tempRoot.current, projectId: Option.none() }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api: { layer: apiMock.layer, httpClientLayer: noopHttpClient }, + cliConfig, + analytics: mockAnalytics(), + telemetry: telemetry.layer, + }), + credentials.layer, + ...(opts.removeFails === true ? [failingRemoveFsLayer] : []), + ); + return { layer, out, telemetry, credentials, workdir: tempRoot.current }; +} + +describe("legacy unlink integration", () => { + it.live("unlinks: removes the temp dir, deletes the keyring entry, prints Finished", () => { + const { layer, out, credentials, workdir } = setup(); + seedProjectRef(workdir, LEGACY_VALID_REF); + return Effect.gen(function* () { + yield* legacyUnlink(); + expect(existsSync(join(workdir, "supabase", ".temp"))).toBe(false); + expect(credentials.deletedRefs).toEqual([LEGACY_VALID_REF]); + expect(out.stdoutText).toContain("Finished supabase unlink."); + }).pipe(Effect.provide(layer)); + }); + + it.live("writes 'Unlinking project: ' to stderr", () => { + const { layer, out, workdir } = setup(); + seedProjectRef(workdir, LEGACY_VALID_REF); + return Effect.gen(function* () { + yield* legacyUnlink(); + expect(out.stderrText).toContain(`Unlinking project: ${LEGACY_VALID_REF}`); + }).pipe(Effect.provide(layer)); + }); + + it.live("succeeds when no credential is stored (keyring not-found ignored)", () => { + // The tracked credentials mock returns `true`; a real not-found returns + // `false` without erroring β€” either way unlink succeeds. + const { layer, out, workdir } = setup(); + seedProjectRef(workdir, LEGACY_VALID_REF); + return Effect.gen(function* () { + yield* legacyUnlink(); + expect(out.stdoutText).toContain("Finished supabase unlink."); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with LegacyProjectNotLinkedError when the project-ref file is absent", () => { + const { layer } = setup(); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyUnlink()); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyProjectNotLinkedError"); + expect(json).toContain("Cannot find project ref"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when the keyring delete errors (permission denied)", () => { + const { layer, workdir } = setup({ deleteFails: true }); + seedProjectRef(workdir, LEGACY_VALID_REF); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyUnlink()); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyCredentialDeleteError"); + } + // The temp dir is still removed before the credential delete is attempted. + expect(existsSync(join(workdir, "supabase", ".temp"))).toBe(false); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with LegacyUnlinkTempRemovalError when the temp dir cannot be removed", () => { + const { layer, workdir } = setup({ removeFails: true }); + seedProjectRef(workdir, LEGACY_VALID_REF); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyUnlink()); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyUnlinkTempRemovalError"); + expect(json).toContain("failed to remove temp directory"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("surfaces both messages when temp removal and keyring delete both fail", () => { + const { layer, workdir } = setup({ removeFails: true, deleteFails: true }); + seedProjectRef(workdir, LEGACY_VALID_REF); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyUnlink()); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + // errors.Join parity β€” both failure messages are surfaced, not just the first. + expect(json).toContain("failed to remove temp directory"); + expect(json).toContain("failed to delete project credential"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("flushes telemetry via ensuring", () => { + const { layer, telemetry, workdir } = setup(); + seedProjectRef(workdir, LEGACY_VALID_REF); + return Effect.gen(function* () { + yield* legacyUnlink(); + expect(telemetry.flushed).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("json output: emits a structured success and suppresses the Finished line", () => { + const { layer, out, workdir } = setup({ format: "json" }); + seedProjectRef(workdir, LEGACY_VALID_REF); + return Effect.gen(function* () { + yield* legacyUnlink(); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.data).toMatchObject({ project_ref: LEGACY_VALID_REF }); + expect(out.stdoutText).not.toContain("Finished supabase unlink."); + }).pipe(Effect.provide(layer)); + }); + + it.live("stream-json output: emits a structured success", () => { + const { layer, out, workdir } = setup({ format: "stream-json" }); + seedProjectRef(workdir, LEGACY_VALID_REF); + return Effect.gen(function* () { + yield* legacyUnlink(); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.data).toMatchObject({ project_ref: LEGACY_VALID_REF }); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/cli/src/legacy/config/legacy-cli-config.layer.ts b/apps/cli/src/legacy/config/legacy-cli-config.layer.ts index 9ea6c773d7..d77de843db 100644 --- a/apps/cli/src/legacy/config/legacy-cli-config.layer.ts +++ b/apps/cli/src/legacy/config/legacy-cli-config.layer.ts @@ -3,8 +3,13 @@ import { parse as parseYaml } from "yaml"; import { CLI_VERSION } from "../../shared/cli/version.ts"; import { LegacyProfileFlag, LegacyWorkdirFlag } from "../../shared/legacy/global-flags.ts"; import { legacyProjectHost } from "../shared/legacy-profile.ts"; +import { + LegacyDebugLogger, + type LegacyDebugLoggerShape, +} from "../shared/legacy-debug-logger.service.ts"; import { RuntimeInfo } from "../../shared/runtime/runtime-info.service.ts"; import { LegacyCliConfig, type LegacyProfileName } from "./legacy-cli-config.service.ts"; +import { legacyProfileFilePath } from "./legacy-profile-file.ts"; interface ResolvedProfile { readonly name: string; @@ -47,10 +52,18 @@ function safeParseYaml( } } +function unknownMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + /** * Resolves the profile that produces the API URL. Mirrors Go's `LoadProfile` * (`apps/cli-go/internal/utils/profile.go:96-118`): * + * Profile-name precedence mirrors Go's `getProfileName` (`profile.go:121-136`): + * `--profile` flag (when not the default) β†’ `SUPABASE_PROFILE` env β†’ the + * persisted `~/.supabase/profile` file β†’ `supabase`. The resolved token is then: + * * 1. If the token matches a built-in profile name, use that. * 2. Otherwise treat the token as a path to a YAML config file with `api_url:`. * 3. Fall back to the `supabase` built-in if the file is missing or malformed. @@ -64,9 +77,39 @@ function resolveProfile( flagValue: string, envValue: string | undefined, fs: FileSystem.FileSystem, + path: Path.Path, + homeDir: string, + debugLogger: LegacyDebugLoggerShape, ): Effect.Effect { return Effect.gen(function* () { - const token = flagValue !== "supabase" ? flagValue : (envValue ?? "supabase"); + let token: string; + if (flagValue !== "supabase") { + yield* debugLogger.debug(`Loading profile from flag: ${flagValue}`); + token = flagValue; + } else if (envValue !== undefined && envValue.length > 0) { + // Go reads SUPABASE_PROFILE through viper's PROFILE key, so debug output + // cannot distinguish env from an explicitly changed flag. + yield* debugLogger.debug(`Loading profile from flag: ${envValue}`); + token = envValue; + } else { + // Lowest precedence: the persisted `~/.supabase/profile` file (Go's + // `getProfileName` file fallback, `profile.go:129-131`). + const filePath = legacyProfileFilePath(path, homeDir); + const content = yield* fs.readFileString(filePath).pipe( + Effect.tap(() => debugLogger.debug(`Loading profile from file: ${filePath}`)), + Effect.map(Option.some), + Effect.catch((error) => + debugLogger.debug(unknownMessage(error)).pipe(Effect.as(Option.none())), + ), + ); + token = Option.match(content, { + onNone: () => "supabase", + onSome: (value) => { + const trimmed = value.trim(); + return trimmed.length === 0 ? "supabase" : trimmed; + }, + }); + } if (isBuiltinProfileName(token)) { return resolvedBuiltin(token); @@ -124,6 +167,7 @@ export const legacyCliConfigLayer = Layer.unwrap( Effect.gen(function* () { const profileFlag = yield* LegacyProfileFlag; const workdirFlag = yield* LegacyWorkdirFlag; + const debugLogger = yield* LegacyDebugLogger; return Layer.effect( LegacyCliConfig, @@ -137,7 +181,14 @@ export const legacyCliConfigLayer = Layer.unwrap( name: profile, apiUrl, projectHost, - } = yield* resolveProfile(profileFlag, env["SUPABASE_PROFILE"], fs); + } = yield* resolveProfile( + profileFlag, + env["SUPABASE_PROFILE"], + fs, + path, + runtimeInfo.homeDir, + debugLogger, + ); const rawAccessToken = env["SUPABASE_ACCESS_TOKEN"]; const accessToken = diff --git a/apps/cli/src/legacy/config/legacy-cli-config.layer.unit.test.ts b/apps/cli/src/legacy/config/legacy-cli-config.layer.unit.test.ts index 387a93ece4..7e20040fce 100644 --- a/apps/cli/src/legacy/config/legacy-cli-config.layer.unit.test.ts +++ b/apps/cli/src/legacy/config/legacy-cli-config.layer.unit.test.ts @@ -5,10 +5,15 @@ import { join } from "node:path"; import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; import { Effect, Layer, Option, Redacted } from "effect"; -import { afterEach, beforeEach } from "vitest"; +import { afterEach, beforeEach, vi } from "vitest"; -import { LegacyProfileFlag, LegacyWorkdirFlag } from "../../shared/legacy/global-flags.ts"; +import { + LegacyDebugFlag, + LegacyProfileFlag, + LegacyWorkdirFlag, +} from "../../shared/legacy/global-flags.ts"; import { mockRuntimeInfo, processEnvLayer } from "../../../tests/helpers/mocks.ts"; +import { legacyDebugLoggerLayer } from "../shared/legacy-debug-logger.layer.ts"; import { legacyCliConfigLayer } from "./legacy-cli-config.layer.ts"; import { LegacyCliConfig } from "./legacy-cli-config.service.ts"; @@ -17,13 +22,17 @@ function makeLayer(opts: { workdirFlag?: Option.Option; env?: Record; cwd?: string; + home?: string; + debug?: boolean; }) { const profileFlag = opts.profileFlag ?? "supabase"; const workdirFlag = opts.workdirFlag ?? Option.none(); return legacyCliConfigLayer.pipe( + Layer.provide(legacyDebugLoggerLayer), + Layer.provide(Layer.succeed(LegacyDebugFlag, opts.debug ?? false)), Layer.provide(Layer.succeed(LegacyProfileFlag, profileFlag)), Layer.provide(Layer.succeed(LegacyWorkdirFlag, workdirFlag)), - Layer.provide(mockRuntimeInfo({ cwd: opts.cwd ?? "/test/cwd" })), + Layer.provide(mockRuntimeInfo({ cwd: opts.cwd ?? "/test/cwd", homeDir: opts.home })), Layer.provide(BunServices.layer), Layer.provide(processEnvLayer(opts.env ?? {})), ); @@ -75,6 +84,49 @@ describe("legacyCliConfigLayer", () => { }).pipe(Effect.provide(makeLayer({ profileFlag: "snap", cwd: tempRoot }))), ); + it.effect("reads the persisted ~/.supabase/profile file when no flag/env is set", () => { + const home = join(tempRoot, "home"); + mkdirSync(join(home, ".supabase"), { recursive: true }); + writeFileSync(join(home, ".supabase", "profile"), "supabase-staging\n"); + return Effect.gen(function* () { + const config = yield* LegacyCliConfig; + expect(config.profile).toBe("supabase-staging"); + }).pipe(Effect.provide(makeLayer({ home, cwd: tempRoot }))); + }); + + it.effect("debug logs the persisted profile file source", () => { + const home = join(tempRoot, "home"); + const profilePath = join(home, ".supabase", "profile"); + mkdirSync(join(home, ".supabase"), { recursive: true }); + writeFileSync(profilePath, "supabase-staging\n"); + const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + return Effect.gen(function* () { + const config = yield* LegacyCliConfig; + expect(config.profile).toBe("supabase-staging"); + expect(stderr.mock.calls.map(([chunk]) => String(chunk)).join("")).toContain( + `Loading profile from file: ${profilePath}\n`, + ); + }).pipe( + Effect.ensuring(Effect.sync(() => stderr.mockRestore())), + Effect.provide(makeLayer({ home, cwd: tempRoot, debug: true })), + ); + }); + + it.effect("flag and env take precedence over the persisted profile file", () => { + const home = join(tempRoot, "home"); + mkdirSync(join(home, ".supabase"), { recursive: true }); + writeFileSync(join(home, ".supabase", "profile"), "supabase-staging"); + return Effect.gen(function* () { + const config = yield* LegacyCliConfig; + // SUPABASE_PROFILE wins over the file. + expect(config.profile).toBe("supabase-local"); + }).pipe( + Effect.provide( + makeLayer({ home, cwd: tempRoot, env: { SUPABASE_PROFILE: "supabase-local" } }), + ), + ); + }); + it.effect( "falls back to supabase profile when SUPABASE_PROFILE is neither a known name nor a readable file", () => diff --git a/apps/cli/src/legacy/config/legacy-profile-file.ts b/apps/cli/src/legacy/config/legacy-profile-file.ts new file mode 100644 index 0000000000..cf7ae6a609 --- /dev/null +++ b/apps/cli/src/legacy/config/legacy-profile-file.ts @@ -0,0 +1,41 @@ +import { Data, Effect, FileSystem, Path } from "effect"; + +/** + * Helpers for the persisted profile-name file `~/.supabase/profile`, mirroring + * Go's `getProfileName` file fallback and `SaveProfileName` + * (`apps/cli-go/internal/utils/profile.go:121-152`). + * + * `login` writes this file (on success, when a profile was explicitly set) so a + * later command run without `--profile` / `SUPABASE_PROFILE` resolves the same + * profile; `LegacyCliConfig` reads it as the lowest-precedence profile source. + */ + +/** Raised when persisting the profile name fails β€” Go's `SaveProfileName` error, + * which `login`'s PostRunE returns to block subsequent CI commands + * (`apps/cli-go/cmd/login.go:42-46`). */ +export class LegacyProfileSaveError extends Data.TaggedError("LegacyProfileSaveError")<{ + readonly message: string; +}> {} + +export function legacyProfileFilePath(path: Path.Path, homeDir: string): string { + return path.join(homeDir, ".supabase", "profile"); +} + +/** Writes the profile name to `~/.supabase/profile`. Fatal on failure (Go parity). */ +export const saveLegacyProfileName = ( + fs: FileSystem.FileSystem, + path: Path.Path, + homeDir: string, + name: string, +): Effect.Effect => + Effect.gen(function* () { + const filePath = legacyProfileFilePath(path, homeDir); + yield* fs.makeDirectory(path.dirname(filePath), { recursive: true }); + yield* fs.writeFileString(filePath, name); + }).pipe( + Effect.catch((error) => + Effect.fail( + new LegacyProfileSaveError({ message: `failed to save profile: ${error.message}` }), + ), + ), + ); diff --git a/apps/cli/src/legacy/config/legacy-project-ref.errors.ts b/apps/cli/src/legacy/config/legacy-project-ref.errors.ts index 9b672d3251..a8dab2f7dc 100644 --- a/apps/cli/src/legacy/config/legacy-project-ref.errors.ts +++ b/apps/cli/src/legacy/config/legacy-project-ref.errors.ts @@ -8,3 +8,15 @@ export class LegacyInvalidProjectRefError extends Data.TaggedError("LegacyInvali readonly ref: string; readonly message: string; }> {} + +/** + * Raised by `resolveForLink` on a non-TTY when neither `--project-ref` nor + * `SUPABASE_PROJECT_ID` is set. Byte-matches cobra's required-flag error string + * (`required flag(s) "project-ref" not set`) that `supabase link`'s `PreRunE` + * produces via `cmd.MarkFlagRequired("project-ref")`. + */ +export class LegacyProjectRefRequiredError extends Data.TaggedError( + "LegacyProjectRefRequiredError", +)<{ + readonly message: string; +}> {} diff --git a/apps/cli/src/legacy/config/legacy-project-ref.layer.ts b/apps/cli/src/legacy/config/legacy-project-ref.layer.ts index 39e50d835f..107714986a 100644 --- a/apps/cli/src/legacy/config/legacy-project-ref.layer.ts +++ b/apps/cli/src/legacy/config/legacy-project-ref.layer.ts @@ -3,10 +3,12 @@ import { Effect, FileSystem, Layer, Option, Path } from "effect"; import { LegacyPlatformApi } from "../auth/legacy-platform-api.service.ts"; import { Output } from "../../shared/output/output.service.ts"; import { Tty } from "../../shared/runtime/tty.service.ts"; +import { legacyTempPaths } from "../shared/legacy-temp-paths.ts"; import { LegacyCliConfig } from "./legacy-cli-config.service.ts"; import { LegacyInvalidProjectRefError, LegacyProjectNotLinkedError, + LegacyProjectRefRequiredError, } from "./legacy-project-ref.errors.ts"; import { INVALID_PROJECT_REF_MESSAGE, @@ -34,7 +36,7 @@ export const legacyProjectRefLayer = Layer.effect( const output = yield* Output; const api = yield* LegacyPlatformApi; - const refPath = path.join(cliConfig.workdir, "supabase", ".temp", "project-ref"); + const refPath = legacyTempPaths(path, cliConfig.workdir).projectRef; const readRefFile = Effect.gen(function* () { const exists = yield* fs.exists(refPath).pipe(Effect.orElseSucceed(() => false)); @@ -95,6 +97,25 @@ export const legacyProjectRefLayer = Layer.effect( new LegacyProjectNotLinkedError({ message: PROJECT_NOT_LINKED_MESSAGE }), ); }), + resolveForLink: (flagValue) => + Effect.gen(function* () { + if (Option.isSome(flagValue) && flagValue.value.length > 0) { + return yield* assertValid(flagValue.value); + } + if (Option.isSome(cliConfig.projectId)) { + return yield* assertValid(cliConfig.projectId.value); + } + // Go skips the ref-file fallback for link (MemMapFs at link.go:30). + if (tty.stdinIsTty && output.interactive) { + const chosen = yield* promptForProjectRef("Select a project:"); + return yield* assertValid(chosen); + } + return yield* Effect.fail( + new LegacyProjectRefRequiredError({ + message: `required flag(s) "project-ref" not set`, + }), + ); + }), resolveOptional: (flagValue) => Effect.gen(function* () { if (Option.isSome(flagValue) && flagValue.value.length > 0) { diff --git a/apps/cli/src/legacy/config/legacy-project-ref.layer.unit.test.ts b/apps/cli/src/legacy/config/legacy-project-ref.layer.unit.test.ts index 2499b05f6f..459ffe9f30 100644 --- a/apps/cli/src/legacy/config/legacy-project-ref.layer.unit.test.ts +++ b/apps/cli/src/legacy/config/legacy-project-ref.layer.unit.test.ts @@ -263,6 +263,73 @@ describe("legacyProjectRefLayer", () => { }); }); + describe("resolveForLink", () => { + it.effect("prefers the --project-ref flag", () => { + const { layer } = makeLayer({ workdir: tempRoot, projectId: ANOTHER_REF }); + return Effect.gen(function* () { + const { resolveForLink } = yield* LegacyProjectRefResolver; + const ref = yield* resolveForLink(Option.some(VALID_REF)); + expect(ref).toBe(VALID_REF); + }).pipe(Effect.provide(layer)); + }); + + it.effect("uses SUPABASE_PROJECT_ID when the flag is unset", () => { + const { layer } = makeLayer({ workdir: tempRoot, projectId: VALID_REF }); + return Effect.gen(function* () { + const { resolveForLink } = yield* LegacyProjectRefResolver; + const ref = yield* resolveForLink(Option.none()); + expect(ref).toBe(VALID_REF); + }).pipe(Effect.provide(layer)); + }); + + it.effect("skips the ref file (Go MemMapFs) and fails off-TTY with no flag/projectId", () => { + // A ref file is present, but link must ignore it and fail like cobra's + // required-flag check would. + writeRefFile(tempRoot, VALID_REF); + const { layer } = makeLayer({ workdir: tempRoot }); + return Effect.gen(function* () { + const { resolveForLink } = yield* LegacyProjectRefResolver; + const exit = yield* Effect.exit(resolveForLink(Option.none())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const errorJson = JSON.stringify(exit.cause); + expect(errorJson).toContain("LegacyProjectRefRequiredError"); + expect(errorJson).toContain(`required flag(s) \\"project-ref\\" not set`); + } + }).pipe(Effect.provide(layer)); + }); + + it.effect("prompts via Output.promptSelect on a TTY with no other source", () => { + const projects = [ + { id: VALID_REF, name: "alpha", organization_slug: "acme", region: "us-east-1" }, + ]; + const { layer, out } = makeLayer({ + workdir: tempRoot, + stdinIsTty: true, + projects, + promptSelectResponses: [VALID_REF], + }); + return Effect.gen(function* () { + const { resolveForLink } = yield* LegacyProjectRefResolver; + const ref = yield* resolveForLink(Option.none()); + expect(ref).toBe(VALID_REF); + expect(out.promptSelectCalls[0]?.message).toBe("Select a project:"); + }).pipe(Effect.provide(layer)); + }); + + it.effect("rejects an invalid --project-ref flag", () => { + const { layer } = makeLayer({ workdir: tempRoot }); + return Effect.gen(function* () { + const { resolveForLink } = yield* LegacyProjectRefResolver; + const exit = yield* Effect.exit(resolveForLink(Option.some("BADREF"))); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyInvalidProjectRefError"); + } + }).pipe(Effect.provide(layer)); + }); + }); + describe("promptProjectRef", () => { it.effect("prompts with the given title, returns the choice, and echoes it", () => { const projects = [ diff --git a/apps/cli/src/legacy/config/legacy-project-ref.service.ts b/apps/cli/src/legacy/config/legacy-project-ref.service.ts index cd2e32d53c..e8b156e030 100644 --- a/apps/cli/src/legacy/config/legacy-project-ref.service.ts +++ b/apps/cli/src/legacy/config/legacy-project-ref.service.ts @@ -4,12 +4,32 @@ import { Context } from "effect"; import type { LegacyInvalidProjectRefError, LegacyProjectNotLinkedError, + LegacyProjectRefRequiredError, } from "./legacy-project-ref.errors.ts"; interface LegacyProjectRefResolverShape { readonly resolve: ( flagValue: Option.Option, ) => Effect.Effect; + /** + * Resolution chain used by `supabase link` (`apps/cli-go/cmd/link.go:30` calls + * `flags.ParseProjectRef` with an **empty in-memory FS**, so the on-disk + * `project-ref` file is deliberately skipped): + * + * flag β†’ `cliConfig.projectId` (env `SUPABASE_PROJECT_ID`) β†’ (TTY) prompt. + * + * On a non-TTY with neither the flag nor `PROJECT_ID` set, fails with + * `LegacyProjectRefRequiredError`, reproducing the cobra + * `required flag(s) "project-ref" not set` error that link's `PreRunE` + * triggers via `cmd.MarkFlagRequired("project-ref")` (`link.go:23-27`). + */ + readonly resolveForLink: ( + flagValue: Option.Option, + ) => Effect.Effect< + string, + LegacyProjectNotLinkedError | LegacyInvalidProjectRefError | LegacyProjectRefRequiredError, + never + >; /** * Soft resolution chain (flag -> `cliConfig.projectId` -> ref file) with **no * prompt and no failure**. Mirrors Go's `flags.LoadProjectRef` as used by @@ -43,7 +63,7 @@ export class LegacyProjectRefResolver extends Context.Service< export const PROJECT_REF_PATTERN = /^[a-z]{20}$/; -export const PROJECT_NOT_LINKED_MESSAGE = "Cannot find project ref. Have you run `supabase link`?"; +export const PROJECT_NOT_LINKED_MESSAGE = "Cannot find project ref. Have you run supabase link?"; export const INVALID_PROJECT_REF_MESSAGE = "Invalid project ref format. Must be like `abcdefghijklmnopqrst`."; diff --git a/apps/cli/src/legacy/shared/legacy-debug-logger.layer.ts b/apps/cli/src/legacy/shared/legacy-debug-logger.layer.ts new file mode 100644 index 0000000000..3658df0e70 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-debug-logger.layer.ts @@ -0,0 +1,31 @@ +import { Effect, Layer } from "effect"; + +import { LegacyDebugFlag } from "../../shared/legacy/global-flags.ts"; +import { LegacyDebugLogger } from "./legacy-debug-logger.service.ts"; + +const pad = (n: number): string => String(n).padStart(2, "0"); + +/** Formats a timestamp matching Go's `log.LstdFlags`: `YYYY/MM/DD HH:MM:SS`. */ +function formatTimestamp(now: Date): string { + return ( + `${now.getFullYear()}/${pad(now.getMonth() + 1)}/${pad(now.getDate())} ` + + `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}` + ); +} + +export const legacyDebugLoggerLayer = Layer.effect( + LegacyDebugLogger, + Effect.gen(function* () { + const debug = yield* LegacyDebugFlag; + + const writeLine = (message: string) => + Effect.sync(() => { + if (debug) process.stderr.write(`${message}\n`); + }); + + return LegacyDebugLogger.of({ + debug: writeLine, + http: (method, url) => writeLine(`${formatTimestamp(new Date())} HTTP ${method}: ${url}`), + }); + }), +); diff --git a/apps/cli/src/legacy/shared/legacy-debug-logger.layer.unit.test.ts b/apps/cli/src/legacy/shared/legacy-debug-logger.layer.unit.test.ts new file mode 100644 index 0000000000..5886d2dd3b --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-debug-logger.layer.unit.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; +import { afterEach, vi } from "vitest"; + +import { LegacyDebugFlag } from "../../shared/legacy/global-flags.ts"; +import { legacyDebugLoggerLayer } from "./legacy-debug-logger.layer.ts"; +import { LegacyDebugLogger } from "./legacy-debug-logger.service.ts"; + +function makeLayer(debug: boolean) { + return legacyDebugLoggerLayer.pipe(Layer.provide(Layer.succeed(LegacyDebugFlag, debug))); +} + +function captureStderr() { + return vi.spyOn(process.stderr, "write").mockImplementation(() => true); +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("legacyDebugLoggerLayer", () => { + it.effect("does not write stderr bytes when debug is disabled", () => { + const stderr = captureStderr(); + return Effect.gen(function* () { + const logger = yield* LegacyDebugLogger; + yield* logger.debug("hidden"); + yield* logger.http("GET", "https://api.supabase.green/v1/projects"); + expect(stderr).not.toHaveBeenCalled(); + }).pipe( + Effect.ensuring(Effect.sync(() => stderr.mockRestore())), + Effect.provide(makeLayer(false)), + ); + }); + + it.effect("debug emits the exact newline-terminated message", () => { + const stderr = captureStderr(); + return Effect.gen(function* () { + const logger = yield* LegacyDebugLogger; + yield* logger.debug("Using profile: supabase-staging (supabase.red)"); + expect(stderr.mock.calls.map(([chunk]) => String(chunk)).join("")).toBe( + "Using profile: supabase-staging (supabase.red)\n", + ); + }).pipe( + Effect.ensuring(Effect.sync(() => stderr.mockRestore())), + Effect.provide(makeLayer(true)), + ); + }); + + it.effect("http emits Go timestamp order and method/url format", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, 5, 4, 8, 24, 47)); + const stderr = captureStderr(); + return Effect.gen(function* () { + const logger = yield* LegacyDebugLogger; + yield* logger.http("GET", "https://api.supabase.green/v1/projects"); + expect(stderr.mock.calls.map(([chunk]) => String(chunk)).join("")).toBe( + "2026/06/04 08:24:47 HTTP GET: https://api.supabase.green/v1/projects\n", + ); + }).pipe( + Effect.ensuring(Effect.sync(() => stderr.mockRestore())), + Effect.provide(makeLayer(true)), + ); + }); +}); diff --git a/apps/cli/src/legacy/shared/legacy-debug-logger.service.ts b/apps/cli/src/legacy/shared/legacy-debug-logger.service.ts new file mode 100644 index 0000000000..b1b57d5764 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-debug-logger.service.ts @@ -0,0 +1,11 @@ +import type { Effect } from "effect"; +import { Context } from "effect"; + +export interface LegacyDebugLoggerShape { + readonly debug: (message: string) => Effect.Effect; + readonly http: (method: string, url: string) => Effect.Effect; +} + +export class LegacyDebugLogger extends Context.Service()( + "supabase/legacy/DebugLogger", +) {} diff --git a/apps/cli/src/legacy/shared/legacy-management-api-runtime.layer.ts b/apps/cli/src/legacy/shared/legacy-management-api-runtime.layer.ts index f115f26148..aab3363700 100644 --- a/apps/cli/src/legacy/shared/legacy-management-api-runtime.layer.ts +++ b/apps/cli/src/legacy/shared/legacy-management-api-runtime.layer.ts @@ -1,5 +1,6 @@ import { Layer } from "effect"; import type * as HttpClient from "effect/unstable/http/HttpClient"; +import { FetchHttpClient } from "effect/unstable/http"; import { LegacyCredentials } from "../auth/legacy-credentials.service.ts"; import { legacyCredentialsLayer } from "../auth/legacy-credentials.layer.ts"; @@ -10,6 +11,7 @@ import { LegacyCliConfig } from "../config/legacy-cli-config.service.ts"; import { legacyCliConfigLayer } from "../config/legacy-cli-config.layer.ts"; import { LegacyProjectRefResolver } from "../config/legacy-project-ref.service.ts"; import { legacyProjectRefLayer } from "../config/legacy-project-ref.layer.ts"; +import { legacyDebugLoggerLayer } from "./legacy-debug-logger.layer.ts"; import { LegacyLinkedProjectCache } from "../telemetry/legacy-linked-project-cache.service.ts"; import { legacyLinkedProjectCacheLayer } from "../telemetry/legacy-linked-project-cache.layer.ts"; import { LegacyTelemetryState } from "../telemetry/legacy-telemetry-state.service.ts"; @@ -17,27 +19,18 @@ import { legacyTelemetryStateLayer } from "../telemetry/legacy-telemetry-state.l import { CommandRuntime } from "../../shared/runtime/command-runtime.service.ts"; import { commandRuntimeLayer } from "../../shared/runtime/command-runtime.layer.ts"; -// Shared platform-API stack used by every Management-API legacy subcommand. -// `legacyHttpClientLayer` wraps the default fetch transport with a debug logger when `--debug` is set. -const legacyPlatformApiStack = legacyPlatformApiLayer.pipe( - Layer.provide(legacyCredentialsLayer), - Layer.provide(legacyCliConfigLayer), - Layer.provide(legacyHttpClientLayer), -); - /** * Composes the runtime layer for a Management-API-style `supabase ` * invocation. * - * `legacyCliConfigLayer` must be piped to both `legacyPlatformApiStack` and + * `legacyCliConfigLayer` must be piped to both the platform API stack and * `legacyProjectRefLayer`. `Layer.provide` satisfies a requirement on the target layer; * it does not expose the provided service to siblings of a `Layer.mergeAll(...)`. The * project-ref layer reads `LegacyCliConfig` directly for workdir/projectId resolution, * so without an explicit provide here the bundled runtime panics with * `Service not found: supabase/legacy/CliConfig`. Handlers that yield `LegacyCliConfig` * directly (e.g. `branches get`, `legacySuggestUpgrade`) also need the service exposed - * at the top level of the merged layer, hence the bare `legacyCliConfigLayer` entry - * below. + * at the top level of the merged layer, hence the top-level `cliConfig` entry below. * * `legacyHttpClientLayer` and `LegacyCredentials` are exposed at the top level so * handlers / helpers that bypass the typed Management API client can read them @@ -55,23 +48,33 @@ const legacyPlatformApiStack = legacyPlatformApiLayer.pipe( * @param subcommand - command path segments after `supabase`, e.g. `["backups", "list"]`. */ export function legacyManagementApiRuntimeLayer(subcommand: ReadonlyArray) { - // Memoise the credentials layer so the top-level surface and the linked-project - // cache pipeline share one keyring/file lookup. Same rationale applies to the - // HTTP client + CLI config layers below. - const credentials = legacyCredentialsLayer.pipe(Layer.provide(legacyCliConfigLayer)); + // Memoise the shared layers so the platform API, top-level service surface, + // project resolver, and linked-project cache all reuse the same config / + // credentials / HTTP instances. + const cliConfig = legacyCliConfigLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); + const httpClient = legacyHttpClientLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); + const credentials = legacyCredentialsLayer.pipe( + Layer.provide(cliConfig), + Layer.provide(legacyDebugLoggerLayer), + ); + // `legacyPlatformApiLayer` applies typed API debug logging after generated + // requests have been prefixed with the active profile's API URL. + const platformApiStack = legacyPlatformApiLayer.pipe( + Layer.provide(credentials), + Layer.provide(cliConfig), + Layer.provide(FetchHttpClient.layer), + Layer.provide(legacyDebugLoggerLayer), + ); const built = Layer.mergeAll( - legacyPlatformApiStack, - legacyHttpClientLayer, + platformApiStack, + httpClient, credentials, - legacyCliConfigLayer, - legacyProjectRefLayer.pipe( - Layer.provide(legacyPlatformApiStack), - Layer.provide(legacyCliConfigLayer), - ), + cliConfig, + legacyProjectRefLayer.pipe(Layer.provide(platformApiStack), Layer.provide(cliConfig)), legacyLinkedProjectCacheLayer.pipe( Layer.provide(credentials), - Layer.provide(legacyCliConfigLayer), - Layer.provide(legacyHttpClientLayer), + Layer.provide(cliConfig), + Layer.provide(httpClient), ), legacyTelemetryStateLayer, commandRuntimeLayer([...subcommand]), diff --git a/apps/cli/src/legacy/shared/legacy-temp-paths.ts b/apps/cli/src/legacy/shared/legacy-temp-paths.ts new file mode 100644 index 0000000000..5803200a84 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-temp-paths.ts @@ -0,0 +1,38 @@ +import type { Path } from "effect"; + +/** + * Absolute paths to the files the Go CLI writes under `/supabase/.temp/`. + * Mirrors the `utils.*Path` constants in `apps/cli-go/internal/utils/misc.go:84-98`. + * + * `supabase link` / `supabase unlink` are the authoritative writers and remover + * of this directory, but several layers (`legacy-project-ref.layer.ts`, + * `legacy-linked-project-cache.layer.ts`) also read from it. Centralising the + * joins here keeps the path layout in one place instead of re-inlining + * `path.join(workdir, "supabase", ".temp", …)` at every call site. + */ +export interface LegacyTempPaths { + readonly tempDir: string; + readonly projectRef: string; + readonly poolerUrl: string; + readonly postgresVersion: string; + readonly restVersion: string; + readonly gotrueVersion: string; + readonly storageVersion: string; + readonly storageMigration: string; + readonly linkedProjectCache: string; +} + +export function legacyTempPaths(path: Path.Path, workdir: string): LegacyTempPaths { + const tempDir = path.join(workdir, "supabase", ".temp"); + return { + tempDir, + projectRef: path.join(tempDir, "project-ref"), + poolerUrl: path.join(tempDir, "pooler-url"), + postgresVersion: path.join(tempDir, "postgres-version"), + restVersion: path.join(tempDir, "rest-version"), + gotrueVersion: path.join(tempDir, "gotrue-version"), + storageVersion: path.join(tempDir, "storage-version"), + storageMigration: path.join(tempDir, "storage-migration"), + linkedProjectCache: path.join(tempDir, "linked-project.json"), + }; +} diff --git a/apps/cli/src/legacy/shared/legacy-temp-paths.unit.test.ts b/apps/cli/src/legacy/shared/legacy-temp-paths.unit.test.ts new file mode 100644 index 0000000000..a2ca927e05 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-temp-paths.unit.test.ts @@ -0,0 +1,37 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Path } from "effect"; + +import { legacyTempPaths } from "./legacy-temp-paths.ts"; + +describe("legacyTempPaths", () => { + it.effect("maps a workdir to the supabase/.temp/* layout", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const paths = legacyTempPaths(path, "/home/user/project"); + + expect(paths.tempDir).toBe("/home/user/project/supabase/.temp"); + expect(paths.projectRef).toBe("/home/user/project/supabase/.temp/project-ref"); + expect(paths.poolerUrl).toBe("/home/user/project/supabase/.temp/pooler-url"); + expect(paths.postgresVersion).toBe("/home/user/project/supabase/.temp/postgres-version"); + expect(paths.restVersion).toBe("/home/user/project/supabase/.temp/rest-version"); + expect(paths.gotrueVersion).toBe("/home/user/project/supabase/.temp/gotrue-version"); + expect(paths.storageVersion).toBe("/home/user/project/supabase/.temp/storage-version"); + expect(paths.storageMigration).toBe("/home/user/project/supabase/.temp/storage-migration"); + expect(paths.linkedProjectCache).toBe( + "/home/user/project/supabase/.temp/linked-project.json", + ); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("every temp path is nested under tempDir", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const paths = legacyTempPaths(path, "/tmp/wd"); + const { tempDir, ...rest } = paths; + for (const value of Object.values(rest)) { + expect(value.startsWith(`${tempDir}/`)).toBe(true); + } + }).pipe(Effect.provide(BunServices.layer)), + ); +}); diff --git a/apps/cli/src/legacy/shared/legacy-tenant-versions.ts b/apps/cli/src/legacy/shared/legacy-tenant-versions.ts new file mode 100644 index 0000000000..cd18a29768 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-tenant-versions.ts @@ -0,0 +1,131 @@ +import { Effect, Option } from "effect"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; + +/** + * Best-effort probes for the deployed versions of a project's REST (PostgREST), + * Auth (GoTrue) and Storage services. Mirrors `apps/cli-go/internal/utils/tenant/ + * {postgrest,gotrue,storage}.go`, which `supabase link`'s `LinkServices` calls to + * write `rest-version` / `gotrue-version` / `storage-version` under + * `supabase/.temp/`. + * + * Requests go directly to the project's service gateway + * (`https://.`) using the service-role key, replicating Go's + * `fetcher.NewServiceGateway` auth headers (`apps/cli-go/pkg/fetcher/gateway.go:25-31`): + * - always send `apikey: `; + * - additionally send `Authorization: Bearer ` unless the key is a + * new-style `sb_…` key (which carries auth in the `apikey` header alone). + * + * Every probe is best-effort: any transport error, non-200 status, parse failure, + * or empty/sentinel version resolves to `Option.none()` so the caller skips the + * corresponding file write without failing the link. This matches Go, where each + * job's error is only logged to the debug logger. + */ + +interface TenantVersionOptions { + readonly ref: string; + readonly projectHost: string; + readonly serviceKey: string; + readonly userAgent: string; +} + +// --------------------------------------------------------------------------- +// Pure parsers β€” exported for focused unit coverage. +// --------------------------------------------------------------------------- + +/** + * PostgREST advertises its version in the OpenAPI/Swagger `info.version` field at + * `GET /rest/v1/`. Go takes the first whitespace-delimited token and prefixes it + * with `v` (`postgrest.go:37-40`). + */ +export function parseLegacyPostgrestVersion(body: unknown): Option.Option { + if (typeof body !== "object" || body === null) return Option.none(); + const info = (body as { info?: unknown }).info; + if (typeof info !== "object" || info === null) return Option.none(); + const version = (info as { version?: unknown }).version; + if (typeof version !== "string" || version.trim().length === 0) return Option.none(); + const first = version.trim().split(/\s+/)[0]; + if (first === undefined || first.length === 0) return Option.none(); + return Option.some(`v${first}`); +} + +/** + * GoTrue reports its version in the `version` field of `GET /auth/v1/health` + * (`gotrue.go:28-31`). Returned verbatim (no `v` prefix). + */ +export function parseLegacyGotrueVersion(body: unknown): Option.Option { + if (typeof body !== "object" || body === null) return Option.none(); + const version = (body as { version?: unknown }).version; + if (typeof version !== "string" || version.length === 0) return Option.none(); + return Option.some(version); +} + +/** + * Storage returns its bare version string at `GET /storage/v1/version`. Go treats + * an empty body or the `0.0.0` sentinel as "not found" and otherwise prefixes the + * body with `v` (`storage.go:25-28`). + */ +export function parseLegacyStorageVersion(body: string): Option.Option { + if (body.length === 0 || body === "0.0.0") return Option.none(); + return Option.some(`v${body}`); +} + +// --------------------------------------------------------------------------- +// Effectful probes. +// --------------------------------------------------------------------------- + +function tenantRequest(opts: TenantVersionOptions, pathName: string) { + let request = HttpClientRequest.get(`https://${opts.ref}.${opts.projectHost}${pathName}`).pipe( + HttpClientRequest.setHeader("apikey", opts.serviceKey), + HttpClientRequest.setHeader("User-Agent", opts.userAgent), + ); + if (!opts.serviceKey.startsWith("sb_")) { + request = request.pipe( + HttpClientRequest.setHeader("Authorization", `Bearer ${opts.serviceKey}`), + ); + } + return request; +} + +const fetchJson = (request: HttpClientRequest.HttpClientRequest) => + Effect.gen(function* () { + const httpClient = yield* HttpClient.HttpClient; + const response = yield* httpClient.execute(request); + if (response.status !== 200) return Option.none(); + return Option.some(yield* response.json); + }).pipe(Effect.catch(() => Effect.succeed(Option.none()))); + +const fetchText = (request: HttpClientRequest.HttpClientRequest) => + Effect.gen(function* () { + const httpClient = yield* HttpClient.HttpClient; + const response = yield* httpClient.execute(request); + if (response.status !== 200) return Option.none(); + return Option.some(yield* response.text); + }).pipe(Effect.catch(() => Effect.succeed(Option.none()))); + +export const legacyFetchPostgrestVersion = ( + opts: TenantVersionOptions, +): Effect.Effect, never, HttpClient.HttpClient> => + fetchJson(tenantRequest(opts, "/rest/v1/")).pipe( + Effect.map((body) => + Option.isNone(body) ? Option.none() : parseLegacyPostgrestVersion(body.value), + ), + ); + +export const legacyFetchGotrueVersion = ( + opts: TenantVersionOptions, +): Effect.Effect, never, HttpClient.HttpClient> => + fetchJson(tenantRequest(opts, "/auth/v1/health")).pipe( + Effect.map((body) => + Option.isNone(body) ? Option.none() : parseLegacyGotrueVersion(body.value), + ), + ); + +export const legacyFetchStorageVersion = ( + opts: TenantVersionOptions, +): Effect.Effect, never, HttpClient.HttpClient> => + fetchText(tenantRequest(opts, "/storage/v1/version")).pipe( + Effect.map((body) => + Option.isNone(body) ? Option.none() : parseLegacyStorageVersion(body.value), + ), + ); diff --git a/apps/cli/src/legacy/shared/legacy-tenant-versions.unit.test.ts b/apps/cli/src/legacy/shared/legacy-tenant-versions.unit.test.ts new file mode 100644 index 0000000000..5e0db61189 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-tenant-versions.unit.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { Option } from "effect"; + +import { + parseLegacyGotrueVersion, + parseLegacyPostgrestVersion, + parseLegacyStorageVersion, +} from "./legacy-tenant-versions.ts"; + +describe("parseLegacyPostgrestVersion", () => { + it("prefixes the first token of info.version with v", () => { + expect(parseLegacyPostgrestVersion({ info: { version: "12.2.0" } })).toEqual( + Option.some("v12.2.0"), + ); + }); + + it("uses only the first whitespace-delimited field (Go strings.Fields)", () => { + expect(parseLegacyPostgrestVersion({ info: { version: "12.2.0 (abc123)" } })).toEqual( + Option.some("v12.2.0"), + ); + }); + + it("returns None when info.version is empty or missing", () => { + expect(Option.isNone(parseLegacyPostgrestVersion({ info: { version: "" } }))).toBe(true); + expect(Option.isNone(parseLegacyPostgrestVersion({ info: {} }))).toBe(true); + expect(Option.isNone(parseLegacyPostgrestVersion({}))).toBe(true); + expect(Option.isNone(parseLegacyPostgrestVersion(null))).toBe(true); + }); +}); + +describe("parseLegacyGotrueVersion", () => { + it("returns the version verbatim (no v prefix)", () => { + expect(parseLegacyGotrueVersion({ version: "v2.151.0" })).toEqual(Option.some("v2.151.0")); + expect(parseLegacyGotrueVersion({ version: "2.151.0" })).toEqual(Option.some("2.151.0")); + }); + + it("returns None when version is empty or missing", () => { + expect(Option.isNone(parseLegacyGotrueVersion({ version: "" }))).toBe(true); + expect(Option.isNone(parseLegacyGotrueVersion({}))).toBe(true); + expect(Option.isNone(parseLegacyGotrueVersion(null))).toBe(true); + }); +}); + +describe("parseLegacyStorageVersion", () => { + it("prefixes the body with v", () => { + expect(parseLegacyStorageVersion("1.19.3")).toEqual(Option.some("v1.19.3")); + }); + + it("treats empty body and 0.0.0 sentinel as not found", () => { + expect(Option.isNone(parseLegacyStorageVersion(""))).toBe(true); + expect(Option.isNone(parseLegacyStorageVersion("0.0.0"))).toBe(true); + }); +}); diff --git a/apps/cli/src/legacy/telemetry/legacy-linked-project-cache.layer.ts b/apps/cli/src/legacy/telemetry/legacy-linked-project-cache.layer.ts index 4b0de6378c..06c3e474e3 100644 --- a/apps/cli/src/legacy/telemetry/legacy-linked-project-cache.layer.ts +++ b/apps/cli/src/legacy/telemetry/legacy-linked-project-cache.layer.ts @@ -4,6 +4,7 @@ import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; import { LegacyCredentials } from "../auth/legacy-credentials.service.ts"; import { LegacyCliConfig } from "../config/legacy-cli-config.service.ts"; +import { legacyTempPaths } from "../shared/legacy-temp-paths.ts"; import { LegacyLinkedProjectCache } from "./legacy-linked-project-cache.service.ts"; function readString(obj: unknown, key: string): string { @@ -42,12 +43,7 @@ export const legacyLinkedProjectCacheLayer = Layer.effect( return LegacyLinkedProjectCache.of({ cache: (ref: string) => Effect.gen(function* () { - const cachePath = path.join( - cliConfig.workdir, - "supabase", - ".temp", - "linked-project.json", - ); + const cachePath = legacyTempPaths(path, cliConfig.workdir).linkedProjectCache; const exists = yield* fs.exists(cachePath).pipe(Effect.orElseSucceed(() => false)); if (exists) return; diff --git a/apps/cli/src/legacy/telemetry/legacy-telemetry-state.layer.ts b/apps/cli/src/legacy/telemetry/legacy-telemetry-state.layer.ts index 057fbfba5d..69ec359015 100644 --- a/apps/cli/src/legacy/telemetry/legacy-telemetry-state.layer.ts +++ b/apps/cli/src/legacy/telemetry/legacy-telemetry-state.layer.ts @@ -1,6 +1,8 @@ import { Effect, FileSystem, Layer, Path } from "effect"; import { homedir } from "node:os"; +import { Analytics } from "../../shared/telemetry/analytics.service.ts"; +import { TelemetryRuntime } from "../../shared/telemetry/runtime.service.ts"; import { LegacyTelemetryState } from "./legacy-telemetry-state.service.ts"; interface State { @@ -15,7 +17,7 @@ interface State { const SCHEMA_VERSION = 1; const SESSION_ROTATION_MS = 30 * 60 * 1000; -function telemetryPath(env: Record, pathSvc: Path.Path): string { +function legacyTelemetryPath(env: Record, pathSvc: Path.Path): string { const supabaseHome = env["SUPABASE_HOME"]?.trim(); if (supabaseHome !== undefined && supabaseHome.length > 0) { return pathSvc.join(supabaseHome, "telemetry.json"); @@ -23,12 +25,6 @@ function telemetryPath(env: Record, pathSvc: Path.Pa return pathSvc.join(homedir(), ".supabase", "telemetry.json"); } -function isStringField(value: unknown, key: string): boolean { - if (typeof value !== "object" || value === null) return false; - const field = (value as Record)[key]; - return typeof field === "string" && field.length > 0; -} - interface PriorState { enabled?: boolean; device_id?: string; @@ -37,25 +33,116 @@ interface PriorState { distinct_id?: string; } +function hasOwn(record: Record, key: string): boolean { + return Object.prototype.hasOwnProperty.call(record, key); +} + function readExistingState(text: string): PriorState | undefined { try { const parsed = JSON.parse(text); if (typeof parsed !== "object" || parsed === null) return undefined; const record = parsed as Record; const out: PriorState = {}; - if (typeof record.enabled === "boolean") out.enabled = record.enabled; - if (isStringField(parsed, "device_id")) out.device_id = record.device_id as string; - if (isStringField(parsed, "session_id")) out.session_id = record.session_id as string; - if (isStringField(parsed, "session_last_active")) { - out.session_last_active = record.session_last_active as string; + if (hasOwn(record, "enabled")) { + if (typeof record.enabled !== "boolean") return undefined; + out.enabled = record.enabled; + } + if (hasOwn(record, "device_id")) { + if (typeof record.device_id !== "string") return undefined; + out.device_id = record.device_id; + } + if (hasOwn(record, "session_id")) { + if (typeof record.session_id !== "string") return undefined; + out.session_id = record.session_id; + } + if (hasOwn(record, "session_last_active")) { + if (typeof record.session_last_active !== "string") return undefined; + const parsedTime = new Date(record.session_last_active).getTime(); + if (!Number.isFinite(parsedTime)) return undefined; + out.session_last_active = record.session_last_active; + } + if (hasOwn(record, "distinct_id")) { + if (typeof record.distinct_id !== "string") return undefined; + out.distinct_id = record.distinct_id; + } + if (hasOwn(record, "schema_version")) { + if (!Number.isInteger(record.schema_version)) return undefined; } - if (isStringField(parsed, "distinct_id")) out.distinct_id = record.distinct_id as string; return out; } catch { return undefined; } } +export const loadOrCreateLegacyTelemetryState = Effect.fn("legacy.telemetry.loadOrCreateState")( + function* (opts: { readonly now?: Date } = {}) { + const fs = yield* FileSystem.FileSystem; + const pathSvc = yield* Path.Path; + const filePath = legacyTelemetryPath(process.env, pathSvc); + const exists = yield* fs.exists(filePath); + const existing = exists ? yield* fs.readFileString(filePath) : undefined; + const prior = existing !== undefined ? readExistingState(existing) : undefined; + const now = opts.now ?? new Date(); + const nowIso = now.toISOString(); + + const priorActive = + prior?.session_last_active !== undefined ? new Date(prior.session_last_active).getTime() : 0; + const expired = + !Number.isFinite(priorActive) || now.getTime() - priorActive > SESSION_ROTATION_MS; + + const state: State = { + enabled: prior?.enabled ?? true, + device_id: prior?.device_id ?? crypto.randomUUID(), + session_id: + !expired && prior?.session_id !== undefined ? prior.session_id : crypto.randomUUID(), + session_last_active: nowIso, + ...(prior?.distinct_id !== undefined ? { distinct_id: prior.distinct_id } : {}), + schema_version: SCHEMA_VERSION, + }; + + yield* fs.makeDirectory(pathSvc.dirname(filePath), { recursive: true }); + yield* fs.writeFileString(filePath, JSON.stringify(state)); + return state; + }, +); + +export const setLegacyTelemetryEnabled = Effect.fn("legacy.telemetry.setEnabled")(function* ( + enabled: boolean, + opts: { readonly now?: Date } = {}, +) { + const state = yield* loadOrCreateLegacyTelemetryState(opts); + if (state.enabled === enabled) return state; + + const fs = yield* FileSystem.FileSystem; + const pathSvc = yield* Path.Path; + const nextState: State = { ...state, enabled }; + const filePath = legacyTelemetryPath(process.env, pathSvc); + yield* fs.makeDirectory(pathSvc.dirname(filePath), { recursive: true }); + yield* fs.writeFileString(filePath, JSON.stringify(nextState)); + return nextState; +}); + +/** + * Re-derives the current telemetry state (reusing `loadOrCreateLegacyTelemetryState`'s + * read / session-rotation / merge β€” no third copy of that logic) and writes it + * back with the `distinct_id` field set (`stitchLogin`) or removed + * (`clearDistinctId`). Mirrors Go's `SaveState(s.state, fsys)` after mutating + * `s.state.DistinctID` (`service.go:141-150`). + */ +const persistLegacyDistinctId = Effect.fn("legacy.telemetry.persistDistinctId")(function* ( + distinctId: string | undefined, +) { + const base = yield* loadOrCreateLegacyTelemetryState(); + const fs = yield* FileSystem.FileSystem; + const pathSvc = yield* Path.Path; + const { distinct_id: _drop, ...rest } = base; + const nextState: State = + distinctId !== undefined && distinctId.length > 0 ? { ...rest, distinct_id: distinctId } : rest; + const filePath = legacyTelemetryPath(process.env, pathSvc); + yield* fs.makeDirectory(pathSvc.dirname(filePath), { recursive: true }); + yield* fs.writeFileString(filePath, JSON.stringify(nextState)); +}); + /** * Writes `/telemetry.json` on every command run. * Mirrors Go's `LoadOrCreateState` (`apps/cli-go/internal/telemetry/state.go:74-98`): @@ -77,41 +164,31 @@ export const legacyTelemetryStateLayer = Layer.effect( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const pathSvc = yield* Path.Path; - const env = process.env; + const analytics = yield* Analytics; + const runtime = yield* TelemetryRuntime; + + const provide = (effect: Effect.Effect) => + effect.pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, pathSvc), + ); return LegacyTelemetryState.of({ - flush: Effect.gen(function* () { - const filePath = telemetryPath(env, pathSvc); - - const existing = yield* fs.readFileString(filePath).pipe( - Effect.option, - Effect.map((opt) => (opt._tag === "Some" ? opt.value : undefined)), - ); - const prior = existing !== undefined ? readExistingState(existing) : undefined; - - const now = new Date(); - const nowIso = now.toISOString(); - - const priorActive = - prior?.session_last_active !== undefined - ? new Date(prior.session_last_active).getTime() - : 0; - const expired = - !Number.isFinite(priorActive) || now.getTime() - priorActive > SESSION_ROTATION_MS; - - const state: State = { - enabled: prior?.enabled ?? true, - device_id: prior?.device_id ?? crypto.randomUUID(), - session_id: - !expired && prior?.session_id !== undefined ? prior.session_id : crypto.randomUUID(), - session_last_active: nowIso, - ...(prior?.distinct_id !== undefined ? { distinct_id: prior.distinct_id } : {}), - schema_version: SCHEMA_VERSION, - }; - - yield* fs.makeDirectory(pathSvc.dirname(filePath), { recursive: true }); - yield* fs.writeFileString(filePath, JSON.stringify(state)); - }).pipe(Effect.ignore), + flush: provide(loadOrCreateLegacyTelemetryState()).pipe(Effect.asVoid, Effect.ignore), + stitchLogin: (distinctId: string) => + // Go's `StitchLogin` always sets `state.DistinctID = distinctId` + // (replacing any stale value) and sends the alias through analytics, + // which gates delivery on consent (`service.go:132-143`). The alias is + // fire-and-forget here so a PostHog delivery error never prevents the + // `distinct_id` from being persisted to `telemetry.json`. + Effect.gen(function* () { + yield* analytics.alias(distinctId, runtime.deviceId).pipe(Effect.ignore); + yield* provide(persistLegacyDistinctId(distinctId)); + }).pipe(Effect.ignore), + clearDistinctId: provide(persistLegacyDistinctId(undefined)).pipe( + Effect.asVoid, + Effect.ignore, + ), }); }), ); diff --git a/apps/cli/src/legacy/telemetry/legacy-telemetry-state.layer.unit.test.ts b/apps/cli/src/legacy/telemetry/legacy-telemetry-state.layer.unit.test.ts new file mode 100644 index 0000000000..50a8371df7 --- /dev/null +++ b/apps/cli/src/legacy/telemetry/legacy-telemetry-state.layer.unit.test.ts @@ -0,0 +1,99 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; +import { afterEach, beforeEach } from "vitest"; + +import { mockAnalytics } from "../../../tests/helpers/mocks.ts"; +import { TelemetryRuntime } from "../../shared/telemetry/runtime.service.ts"; +import { legacyTelemetryStateLayer } from "./legacy-telemetry-state.layer.ts"; +import { LegacyTelemetryState } from "./legacy-telemetry-state.service.ts"; + +let tempHome: string; +let prevHome: string | undefined; + +beforeEach(() => { + tempHome = mkdtempSync(join(tmpdir(), "supabase-legacy-telemetry-")); + prevHome = process.env["SUPABASE_HOME"]; + process.env["SUPABASE_HOME"] = tempHome; +}); + +afterEach(() => { + if (prevHome === undefined) delete process.env["SUPABASE_HOME"]; + else process.env["SUPABASE_HOME"] = prevHome; + rmSync(tempHome, { recursive: true, force: true }); +}); + +const runtimeLayer = Layer.succeed(TelemetryRuntime, { + configDir: "/tmp", + tracesDir: "/tmp", + consent: "granted", + showDebug: false, + deviceId: "device-xyz", + sessionId: "session-1", + isFirstRun: false, + isTty: false, + isCi: false, + os: "linux", + arch: "x64", + cliVersion: "0.0.0-dev", +}); + +function makeLayer(analytics: ReturnType) { + return legacyTelemetryStateLayer.pipe( + Layer.provide(BunServices.layer), + Layer.provide(analytics.layer), + Layer.provide(runtimeLayer), + ); +} + +const telemetryPath = () => join(tempHome, "telemetry.json"); +const readState = (): Record => + JSON.parse(readFileSync(telemetryPath(), "utf8")) as Record; +const seedState = (distinctId?: string) => + writeFileSync( + telemetryPath(), + JSON.stringify({ + enabled: true, + device_id: "device-xyz", + session_id: "session-1", + session_last_active: new Date().toISOString(), + ...(distinctId !== undefined ? { distinct_id: distinctId } : {}), + schema_version: 1, + }), + ); + +describe("legacyTelemetryStateLayer.stitchLogin / clearDistinctId", () => { + it.effect("stitchLogin aliases the device id and persists the distinct_id", () => { + const analytics = mockAnalytics(); + return Effect.gen(function* () { + const state = yield* LegacyTelemetryState; + yield* state.stitchLogin("gotrue-1"); + expect(analytics.aliased).toEqual([{ distinctId: "gotrue-1", alias: "device-xyz" }]); + expect(readState().distinct_id).toBe("gotrue-1"); + }).pipe(Effect.provide(makeLayer(analytics))); + }); + + it.effect("stitchLogin replaces a stale distinct_id (parity: stale id is replaced)", () => { + seedState("stale-id"); + const analytics = mockAnalytics(); + return Effect.gen(function* () { + const state = yield* LegacyTelemetryState; + yield* state.stitchLogin("fresh-id"); + expect(readState().distinct_id).toBe("fresh-id"); + }).pipe(Effect.provide(makeLayer(analytics))); + }); + + it.effect("clearDistinctId removes the persisted distinct_id", () => { + seedState("to-clear"); + const analytics = mockAnalytics(); + return Effect.gen(function* () { + const state = yield* LegacyTelemetryState; + yield* state.clearDistinctId; + expect(readState().distinct_id).toBeUndefined(); + }).pipe(Effect.provide(makeLayer(analytics))); + }); +}); diff --git a/apps/cli/src/legacy/telemetry/legacy-telemetry-state.service.ts b/apps/cli/src/legacy/telemetry/legacy-telemetry-state.service.ts index c39e094487..47ac612da8 100644 --- a/apps/cli/src/legacy/telemetry/legacy-telemetry-state.service.ts +++ b/apps/cli/src/legacy/telemetry/legacy-telemetry-state.service.ts @@ -9,6 +9,23 @@ interface LegacyTelemetryStateShape { * Best-effort: any filesystem error is swallowed. */ readonly flush: Effect.Effect; + /** + * Aliases the device id to the resolved gotrue id and persists it as the + * telemetry `distinct_id`. Mirrors Go's `Service.StitchLogin` + * (`service.go:132-143`): the alias is sent through the Analytics layer + * (which gates delivery on consent), and `distinct_id` is **always** written + * to `telemetry.json` β€” replacing any stale value. + * + * Best-effort: filesystem / analytics errors are swallowed. + */ + readonly stitchLogin: (distinctId: string) => Effect.Effect; + /** + * Clears the persisted telemetry `distinct_id`. Mirrors Go's + * `Service.ClearDistinctID` (`service.go:145-151`). + * + * Best-effort: any filesystem error is swallowed. + */ + readonly clearDistinctId: Effect.Effect; } export class LegacyTelemetryState extends Context.Service< diff --git a/apps/cli/src/next/commands/init/init.command.ts b/apps/cli/src/next/commands/init/init.command.ts index 0633cc0976..33e881ef89 100644 --- a/apps/cli/src/next/commands/init/init.command.ts +++ b/apps/cli/src/next/commands/init/init.command.ts @@ -1,29 +1,42 @@ -import { projectConfigStoreLayer } from "@supabase/config"; import { BunServices } from "@effect/platform-bun"; -import { Layer } from "effect"; -import { Command } from "effect/unstable/cli"; +import { Command, Flag } from "effect/unstable/cli"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; import { withCommandInstrumentation } from "../../../shared/telemetry/command-instrumentation.ts"; import { init } from "./init.handler.ts"; -export const initCommand = Command.make("init").pipe( +const config = { + interactive: Flag.boolean("interactive").pipe( + Flag.withDescription("Enables interactive mode to configure IDE settings."), + Flag.withAlias("i"), + ), + experimental: Flag.boolean("experimental").pipe(Flag.withHidden), + useOrioledb: Flag.boolean("use-orioledb").pipe( + Flag.withDescription("Use OrioleDB storage engine for Postgres."), + ), + force: Flag.boolean("force").pipe( + Flag.withDescription("Overwrite existing supabase/config.toml."), + ), +} as const; + +export const initCommand = Command.make("init", config).pipe( Command.withDescription( - "Initialize a local Supabase project.\n\nCreates supabase/config.json with a minimal $schema reference so editor autocomplete works immediately.", + "Initialize a local Supabase project.\n\nCreates supabase/config.toml, supabase/.gitignore, and optionally IDE settings for local development.", ), Command.withShortDescription("Initialize local Supabase project"), Command.withExamples([ { command: "supabase init", - description: "Create a minimal supabase/config.json in the current directory", + description: "Create a Supabase project scaffold in the current directory", + }, + { + command: "supabase init --force", + description: "Overwrite an existing local Supabase config", }, ]), - Command.withHandler(() => init().pipe(withCommandInstrumentation(), withJsonErrorHandling)), - Command.provide(commandRuntimeLayer(["init"])), - Command.provide( - Layer.mergeAll( - BunServices.layer, - projectConfigStoreLayer.pipe(Layer.provide(BunServices.layer)), - ), + Command.withHandler((flags) => + init(flags).pipe(withCommandInstrumentation({ flags }), withJsonErrorHandling), ), + Command.provide(commandRuntimeLayer(["init"])), + Command.provide(BunServices.layer), ); diff --git a/apps/cli/src/next/commands/init/init.e2e.test.ts b/apps/cli/src/next/commands/init/init.e2e.test.ts index 27d191c53e..9b9c45f88a 100644 --- a/apps/cli/src/next/commands/init/init.e2e.test.ts +++ b/apps/cli/src/next/commands/init/init.e2e.test.ts @@ -2,31 +2,24 @@ import { mkdtemp, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, test } from "vitest"; -import { PROJECT_CONFIG_SCHEMA_URL } from "@supabase/config"; import { runSupabase } from "../../../../tests/helpers/cli.ts"; const INIT_TIMEOUT_MS = 5_000; describe("supabase init", () => { - test( - "creates a minimal config.json in the current directory", - { timeout: INIT_TIMEOUT_MS }, - async () => { - const tempDir = await mkdtemp(join(tmpdir(), "supabase-init-e2e-")); + test("creates config.toml in the current directory", { timeout: INIT_TIMEOUT_MS }, async () => { + const tempDir = await mkdtemp(join(tmpdir(), "supabase-init-e2e-")); - try { - const { stdout, exitCode } = await runSupabase(["init"], { cwd: tempDir }); + try { + const { stdout, exitCode } = await runSupabase(["init"], { cwd: tempDir }); - expect(exitCode).toBe(0); - expect(stdout).toContain("Initialized Supabase project."); + expect(exitCode).toBe(0); + expect(stdout).toContain("Initialized Supabase project."); - const content = await readFile(join(tempDir, "supabase", "config.json"), "utf8"); - expect(JSON.parse(content)).toEqual({ - $schema: PROJECT_CONFIG_SCHEMA_URL, - }); - } finally { - await rm(tempDir, { recursive: true, force: true }); - } - }, - ); + const content = await readFile(join(tempDir, "supabase", "config.toml"), "utf8"); + expect(content).toContain("major_version = 17"); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); }); diff --git a/apps/cli/src/next/commands/init/init.handler.ts b/apps/cli/src/next/commands/init/init.handler.ts index cf42a217e3..a7c9e523b9 100644 --- a/apps/cli/src/next/commands/init/init.handler.ts +++ b/apps/cli/src/next/commands/init/init.handler.ts @@ -1,49 +1,49 @@ -import { dirname } from "node:path"; -import { - PROJECT_CONFIG_SCHEMA_URL, - ProjectConfigSchema, - ProjectConfigStore, -} from "@supabase/config"; import { Effect } from "effect"; -import { Schema } from "effect"; -import { ensureProjectStateIgnored } from "../../config/project-gitignore.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts"; +import { initProject, type ProjectInitOptions } from "../../../shared/init/project-init.ts"; +import { InitExperimentalRequiredError } from "../../../shared/init/project-init.errors.ts"; -const emptyConfig = Schema.decodeUnknownSync(ProjectConfigSchema)({}); -const projectRootForConfigPath = (configPath: string): string => dirname(dirname(configPath)); - -export const init = Effect.fnUntraced(function* () { +export const init = Effect.fnUntraced(function* ( + flags: Omit & { + readonly experimental: boolean; + }, +) { const output = yield* Output; const runtimeInfo = yield* RuntimeInfo; - const projectConfigStore = yield* ProjectConfigStore; + + if (flags.useOrioledb && !flags.experimental) { + return yield* Effect.fail( + new InitExperimentalRequiredError({ + detail: "--use-orioledb is only available when experimental features are enabled.", + suggestion: "Rerun the command with `supabase init --experimental --use-orioledb`.", + }), + ); + } yield* output.intro("Initialize local Supabase project"); - const existingConfig = yield* projectConfigStore.load(runtimeInfo.cwd); - if (existingConfig !== null) { - yield* ensureProjectStateIgnored(projectRootForConfigPath(existingConfig.path)); + // The next shell does not expose the hidden IDE compat flags; editor settings + // are only generated when the user opts in through interactive mode. + const result = yield* initProject({ + cwd: runtimeInfo.cwd, + ...flags, + withVscodeSettings: false, + withIntellijSettings: false, + }); + + if (!result.created) { yield* output.success("Supabase project already initialized.", { - config_path: existingConfig.path, - schema_ref: existingConfig.schemaRef, + config_path: result.configPath, created: false, }); - yield* output.outro(`Using existing config at ${existingConfig.path}.`); + yield* output.outro(`Using existing config at ${result.configPath}.`); return; } - const saved = yield* projectConfigStore.save({ - cwd: runtimeInfo.cwd, - config: emptyConfig, - format: "json", - schemaRef: PROJECT_CONFIG_SCHEMA_URL, - }); - yield* ensureProjectStateIgnored(projectRootForConfigPath(saved.path)); - yield* output.success("Initialized Supabase project.", { - config_path: saved.path, - schema_ref: saved.schemaRef, + config_path: result.configPath, created: true, }); - yield* output.outro(`Created ${saved.path}.`); + yield* output.outro(`Created ${result.configPath}.`); }); diff --git a/apps/cli/src/next/commands/init/init.integration.test.ts b/apps/cli/src/next/commands/init/init.integration.test.ts index c2a06bda1e..17c513892b 100644 --- a/apps/cli/src/next/commands/init/init.integration.test.ts +++ b/apps/cli/src/next/commands/init/init.integration.test.ts @@ -1,38 +1,52 @@ import { describe, expect, it } from "@effect/vitest"; -import { projectConfigStoreLayer } from "@supabase/config"; import { BunServices } from "@effect/platform-bun"; -import { existsSync, mkdtempSync } from "node:fs"; +import { mkdtempSync } from "node:fs"; import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { Effect, Layer, Stdio } from "effect"; +import { basename, join } from "node:path"; +import { Cause, Effect, Exit, Layer, Option, Stdio } from "effect"; import { Command } from "effect/unstable/cli"; -import { PROJECT_CONFIG_SCHEMA_URL } from "@supabase/config"; -import { initCommand } from "./init.command.ts"; +import { INIT_GITIGNORE_TEMPLATE } from "../../../shared/init/project-init.templates.ts"; import { CurrentAnalyticsContext } from "../../../shared/telemetry/analytics-context.ts"; import { Analytics } from "../../../shared/telemetry/analytics.service.ts"; import { mockOutput, mockProcessControl, mockRuntimeInfo, + mockTty, } from "../../../../tests/helpers/mocks.ts"; +import { initCommand } from "./init.command.ts"; import { init } from "./init.handler.ts"; function makeTempDir(): string { return mkdtempSync(join(tmpdir(), "supabase-init-command-")); } -function buildLayer(cwd: string) { +function buildLayer( + cwd: string, + opts: { + interactive?: boolean; + stdinIsTty?: boolean; + promptConfirmResponses?: ReadonlyArray; + } = {}, +) { const runtimeInfoLayer = mockRuntimeInfo({ cwd }); - const out = mockOutput({ format: "text", interactive: false }); + const out = mockOutput({ + format: "text", + interactive: opts.interactive ?? false, + promptConfirmResponses: opts.promptConfirmResponses, + }); return { out, layer: Layer.mergeAll( out.layer, runtimeInfoLayer, + mockTty({ + stdinIsTty: opts.stdinIsTty ?? false, + stdoutIsTty: opts.interactive ?? false, + }), BunServices.layer, - projectConfigStoreLayer.pipe(Layer.provide(BunServices.layer)), ), }; } @@ -66,80 +80,413 @@ function mockContextualAnalytics() { return { layer, captured }; } +function expectFailureTag(exit: Exit.Exit, tag: string) { + expect(Exit.isFailure(exit)).toBe(true); + if (!Exit.isFailure(exit)) { + return; + } + + const failure = Cause.findErrorOption(exit.cause); + expect(Option.isSome(failure)).toBe(true); + if (Option.isSome(failure)) { + expect((failure.value as { _tag: string })._tag).toBe(tag); + } +} + describe("init handler", () => { - it.live("creates a minimal config.json with the hosted $schema", () => { + it.live("creates config.toml and supabase/.gitignore", () => { const tempDir = makeTempDir(); return Effect.gen(function* () { yield* Effect.tryPromise(() => mkdir(join(tempDir, ".git"), { recursive: true })); const { layer, out } = buildLayer(tempDir); - yield* init().pipe(Effect.provide(layer)); + yield* init({ + interactive: false, + experimental: false, + useOrioledb: false, + force: false, + }).pipe(Effect.provide(layer)); - const configPath = join(tempDir, "supabase", "config.json"); + const configPath = join(tempDir, "supabase", "config.toml"); const content = yield* Effect.tryPromise(() => readFile(configPath, "utf8")); - expect(JSON.parse(content)).toEqual({ - $schema: PROJECT_CONFIG_SCHEMA_URL, - }); + expect(content).toContain(`project_id = "${basename(tempDir)}"`); + expect(content).toContain("major_version = 17"); + expect(content).toContain('orioledb_version = ""'); expect( - yield* Effect.tryPromise(() => readFile(join(tempDir, ".gitignore"), "utf8")), - ).toContain(".supabase/"); + yield* Effect.tryPromise(() => readFile(join(tempDir, "supabase", ".gitignore"), "utf8")), + ).toBe(INIT_GITIGNORE_TEMPLATE); expect(out.messages).toContainEqual( - expect.objectContaining({ type: "success", message: "Initialized Supabase project." }), + expect.objectContaining({ + type: "success", + message: "Initialized Supabase project.", + data: expect.objectContaining({ config_path: configPath, created: true }), + }), ); }).pipe( Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), ); }); - it.live("does not overwrite an existing config", () => { + it.live("reports an already-initialized project without overwriting it", () => { const tempDir = makeTempDir(); - const configPath = join(tempDir, "supabase", "config.json"); - const initialConfig = JSON.stringify( - { - $schema: "./node_modules/@supabase/config/schema.json", - db: { major_version: 16 }, - }, - null, - 2, - ); + const configPath = join(tempDir, "supabase", "config.toml"); return Effect.gen(function* () { yield* Effect.tryPromise(() => mkdir(join(tempDir, "supabase"), { recursive: true })); - yield* Effect.tryPromise(() => mkdir(join(tempDir, ".git"), { recursive: true })); - yield* Effect.tryPromise(() => writeFile(configPath, `${initialConfig}\n`)); - + yield* Effect.tryPromise(() => writeFile(configPath, 'project_id = "existing"\n')); const { layer, out } = buildLayer(tempDir); - yield* init().pipe(Effect.provide(layer)); + yield* init({ + interactive: false, + experimental: false, + useOrioledb: false, + force: false, + }).pipe(Effect.provide(layer)); - const content = yield* Effect.tryPromise(() => readFile(configPath, "utf8")); - expect(content).toBe(`${initialConfig}\n`); - expect( - yield* Effect.tryPromise(() => readFile(join(tempDir, ".gitignore"), "utf8")), - ).toContain(".supabase/"); expect(out.messages).toContainEqual( expect.objectContaining({ type: "success", message: "Supabase project already initialized.", + data: expect.objectContaining({ config_path: configPath, created: false }), }), ); + expect(yield* Effect.tryPromise(() => readFile(configPath, "utf8"))).toBe( + 'project_id = "existing"\n', + ); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }); + + it.live("ignores a legacy config.json when creating config.toml", () => { + const tempDir = makeTempDir(); + const jsonPath = join(tempDir, "supabase", "config.json"); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => mkdir(join(tempDir, "supabase"), { recursive: true })); + yield* Effect.tryPromise(() => writeFile(jsonPath, '{ "$schema": "./schema.json" }\n')); + const { layer } = buildLayer(tempDir); + + yield* init({ + interactive: false, + experimental: false, + useOrioledb: false, + force: false, + }).pipe(Effect.provide(layer)); + + expect( + yield* Effect.tryPromise(() => readFile(join(tempDir, "supabase", "config.toml"), "utf8")), + ).toContain(`project_id = "${basename(tempDir)}"`); + expect(yield* Effect.tryPromise(() => readFile(jsonPath, "utf8"))).toBe( + '{ "$schema": "./schema.json" }\n', + ); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }); + + it.live("does not remove a legacy config.json when force is set", () => { + const tempDir = makeTempDir(); + const jsonPath = join(tempDir, "supabase", "config.json"); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => mkdir(join(tempDir, "supabase"), { recursive: true })); + yield* Effect.tryPromise(() => writeFile(jsonPath, '{ "$schema": "./schema.json" }\n')); + const { layer } = buildLayer(tempDir); + + yield* init({ + interactive: false, + experimental: false, + useOrioledb: false, + force: true, + }).pipe(Effect.provide(layer)); + + const content = yield* Effect.tryPromise(() => + readFile(join(tempDir, "supabase", "config.toml"), "utf8"), + ); + expect(content).toContain(`project_id = "${basename(tempDir)}"`); + expect(yield* Effect.tryPromise(() => readFile(jsonPath, "utf8"))).toBe( + '{ "$schema": "./schema.json" }\n', + ); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }); + + it.live("writes the OrioleDB version when requested", () => { + const tempDir = makeTempDir(); + + return Effect.gen(function* () { + const { layer } = buildLayer(tempDir); + + yield* init({ interactive: false, experimental: true, useOrioledb: true, force: false }).pipe( + Effect.provide(layer), + ); + + const content = yield* Effect.tryPromise(() => + readFile(join(tempDir, "supabase", "config.toml"), "utf8"), + ); + expect(content).toContain('orioledb_version = "15.1.0.150"'); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }); + + it.live("prompts for IDE settings in interactive mode", () => { + const tempDir = makeTempDir(); + + return Effect.gen(function* () { + const { layer, out } = buildLayer(tempDir, { + interactive: true, + stdinIsTty: true, + promptConfirmResponses: [true], + }); + + yield* init({ + interactive: true, + experimental: false, + useOrioledb: false, + force: false, + }).pipe(Effect.provide(layer)); + + expect( + yield* Effect.tryPromise(() => readFile(join(tempDir, ".vscode", "settings.json"), "utf8")), + ).toContain('"deno.enablePaths"'); + expect(out.stdoutText).toContain("Generated VS Code settings in .vscode/settings.json."); + expect(out.messages).toContainEqual( + expect.objectContaining({ type: "success", message: "Initialized Supabase project." }), + ); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }); + + it.live("overwrites nested VS Code formatter settings the same way as the old init flow", () => { + const tempDir = makeTempDir(); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => mkdir(join(tempDir, ".vscode"), { recursive: true })); + yield* Effect.tryPromise(() => + writeFile( + join(tempDir, ".vscode", "settings.json"), + JSON.stringify( + { + custom: true, + "[typescript]": { + "editor.tabSize": 4, + }, + }, + null, + 2, + ), + ), + ); + const { layer } = buildLayer(tempDir, { + interactive: true, + stdinIsTty: true, + promptConfirmResponses: [true], + }); + + yield* init({ + interactive: true, + experimental: false, + useOrioledb: false, + force: false, + }).pipe(Effect.provide(layer)); + + const settings = JSON.parse( + yield* Effect.tryPromise(() => readFile(join(tempDir, ".vscode", "settings.json"), "utf8")), + ) as Record; + + expect(settings.custom).toBe(true); + expect(settings["[typescript]"]).toEqual({ + "editor.defaultFormatter": "denoland.vscode-deno", + }); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }); + + it.live("merges into a JSONC settings file with comments and trailing commas", () => { + const tempDir = makeTempDir(); + const settingsPath = join(tempDir, ".vscode", "settings.json"); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => mkdir(join(tempDir, ".vscode"), { recursive: true })); + yield* Effect.tryPromise(() => + writeFile( + settingsPath, + [ + "{", + " // editor preferences", + ' "editor.tabSize": 4, // keep four spaces', + " /* a block comment */", + ' "files.eol": "\\n",', + "}", + ].join("\n"), + ), + ); + const { layer } = buildLayer(tempDir, { + interactive: true, + stdinIsTty: true, + promptConfirmResponses: [true], + }); + + yield* init({ + interactive: true, + experimental: false, + useOrioledb: false, + force: false, + }).pipe(Effect.provide(layer)); + + const settings = JSON.parse( + yield* Effect.tryPromise(() => readFile(settingsPath, "utf8")), + ) as Record; + + expect(settings["editor.tabSize"]).toBe(4); + expect(settings["files.eol"]).toBe("\n"); + expect(settings["deno.enablePaths"]).toBeDefined(); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }); + + it.live( + "fails with InitParseSettingsError on a malformed settings file without clobbering it", + () => { + const tempDir = makeTempDir(); + const settingsPath = join(tempDir, ".vscode", "settings.json"); + const malformed = '{ "editor.tabSize": '; + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => mkdir(join(tempDir, ".vscode"), { recursive: true })); + yield* Effect.tryPromise(() => writeFile(settingsPath, malformed)); + const { layer } = buildLayer(tempDir, { + interactive: true, + stdinIsTty: true, + promptConfirmResponses: [true], + }); + + const exit = yield* init({ + interactive: true, + experimental: false, + useOrioledb: false, + force: false, + }).pipe(Effect.provide(layer), Effect.exit); + + expectFailureTag(exit, "InitParseSettingsError"); + expect(yield* Effect.tryPromise(() => readFile(settingsPath, "utf8"))).toBe(malformed); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }, + ); + + it.live("does not prompt for IDE settings when stdin is not a TTY", () => { + const tempDir = makeTempDir(); + + return Effect.gen(function* () { + const { layer, out } = buildLayer(tempDir, { + interactive: true, + stdinIsTty: false, + promptConfirmResponses: [true], + }); + + yield* init({ + interactive: true, + experimental: false, + useOrioledb: false, + force: false, + }).pipe(Effect.provide(layer)); + + expect(out.messages).toContainEqual( + expect.objectContaining({ type: "success", message: "Initialized Supabase project." }), + ); + expect(out.stdoutText).not.toContain("Generated VS Code settings"); + expect( + yield* Effect.tryPromise(async () => { + try { + await readFile(join(tempDir, ".vscode", "settings.json"), "utf8"); + return true; + } catch { + return false; + } + }), + ).toBe(false); }).pipe( Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), ); }); - it.live("does not create local link metadata", () => { + it.live("only writes supabase/.gitignore inside a git repo", () => { const tempDir = makeTempDir(); + return Effect.gen(function* () { + const { layer } = buildLayer(tempDir); + + yield* init({ + interactive: false, + experimental: false, + useOrioledb: false, + force: false, + }).pipe(Effect.provide(layer)); + + expect( + yield* Effect.tryPromise(async () => { + try { + await readFile(join(tempDir, "supabase", ".gitignore"), "utf8"); + return true; + } catch { + return false; + } + }), + ).toBe(false); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }); + + it.live("appends to an existing supabase/.gitignore without clobbering it", () => { + const tempDir = makeTempDir(); + const gitignorePath = join(tempDir, "supabase", ".gitignore"); + return Effect.gen(function* () { yield* Effect.tryPromise(() => mkdir(join(tempDir, ".git"), { recursive: true })); + yield* Effect.tryPromise(() => mkdir(join(tempDir, "supabase"), { recursive: true })); + yield* Effect.tryPromise(() => writeFile(gitignorePath, "existing-entry\n")); + const { layer } = buildLayer(tempDir); + + yield* init({ + interactive: false, + experimental: false, + useOrioledb: false, + force: false, + }).pipe(Effect.provide(layer)); + + expect(yield* Effect.tryPromise(() => readFile(gitignorePath, "utf8"))).toBe( + `existing-entry\n\n${INIT_GITIGNORE_TEMPLATE}`, + ); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }); + + it.live("requires --experimental when --use-orioledb is set", () => { + const tempDir = makeTempDir(); + + return Effect.gen(function* () { const { layer } = buildLayer(tempDir); - yield* init().pipe(Effect.provide(layer)); + const exit = yield* init({ + interactive: false, + experimental: false, + useOrioledb: true, + force: false, + }).pipe(Effect.provide(layer), Effect.exit); - expect(existsSync(join(tempDir, ".supabase", "project.json"))).toBe(false); + expectFailureTag(exit, "InitExperimentalRequiredError"); }).pipe( Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), ); @@ -157,14 +504,13 @@ describe("init handler", () => { analytics.layer, runtimeInfoLayer, processControl.layer, + mockTty(), Stdio.layerTest({ args: Effect.succeed(["init"]), }), ); return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(tempDir, ".git"), { recursive: true })); - yield* Command.runWith(initCommand, { version: "0.1.0" })(["init"]).pipe( Effect.provide(layer), ); @@ -183,4 +529,35 @@ describe("init handler", () => { Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), ); }); + + it.live("wires command flags through the parser", () => { + const tempDir = makeTempDir(); + const runtimeInfoLayer = mockRuntimeInfo({ cwd: tempDir }); + const out = mockOutput({ format: "text", interactive: false }); + const analytics = mockContextualAnalytics(); + const processControl = mockProcessControl(); + const layer = Layer.mergeAll( + BunServices.layer, + out.layer, + analytics.layer, + runtimeInfoLayer, + mockTty(), + processControl.layer, + ); + + return Effect.gen(function* () { + yield* Command.runWith(initCommand, { version: "0.1.0" })([ + "init", + "--experimental", + "--use-orioledb", + ]).pipe(Effect.provide(layer)); + + const content = yield* Effect.tryPromise(() => + readFile(join(tempDir, "supabase", "config.toml"), "utf8"), + ); + expect(content).toContain('orioledb_version = "15.1.0.150"'); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }); }); diff --git a/apps/cli/src/shared/cli/hidden-flag.unit.test.ts b/apps/cli/src/shared/cli/hidden-flag.unit.test.ts index f0a1f60b56..9df5738d74 100644 --- a/apps/cli/src/shared/cli/hidden-flag.unit.test.ts +++ b/apps/cli/src/shared/cli/hidden-flag.unit.test.ts @@ -115,12 +115,6 @@ describe("native hidden flags", () => { "stop", "--backup=false", ]); - yield* Command.runWith(legacyTestRoot, { version: "0.0.0-test" })([ - "init", - "--with-vscode-workspace", - "--with-vscode-settings", - "--with-intellij-settings", - ]); yield* Command.runWith(legacyTestRoot, { version: "0.0.0-test" })([ "functions", "download", @@ -148,7 +142,6 @@ describe("native hidden flags", () => { expect(proxy.calls).toEqual([ ["start", "--preview"], ["stop", "--backup=false"], - ["init", "--with-vscode-workspace", "--with-vscode-settings", "--with-intellij-settings"], ["functions", "download", "hello", "--use-docker", "--legacy-bundle"], ["functions", "deploy", "hello", "--use-docker", "--legacy-bundle"], ["functions", "serve", "--all=false"], diff --git a/apps/cli/src/shared/init/project-init.errors.ts b/apps/cli/src/shared/init/project-init.errors.ts new file mode 100644 index 0000000000..edcd4e069c --- /dev/null +++ b/apps/cli/src/shared/init/project-init.errors.ts @@ -0,0 +1,30 @@ +import { Data } from "effect"; + +export class InitAlreadyExistsError extends Data.TaggedError("InitAlreadyExistsError")<{ + readonly detail: string; + readonly suggestion: string; +}> { + override get message() { + return "A Supabase project is already initialized in this directory."; + } +} + +export class InitExperimentalRequiredError extends Data.TaggedError( + "InitExperimentalRequiredError", +)<{ + readonly detail: string; + readonly suggestion: string; +}> { + override get message() { + return "The --use-orioledb flag requires --experimental."; + } +} + +export class InitParseSettingsError extends Data.TaggedError("InitParseSettingsError")<{ + readonly detail: string; + readonly suggestion: string; +}> { + override get message() { + return "Failed to parse existing IDE settings file."; + } +} diff --git a/apps/cli/src/shared/init/project-init.templates.ts b/apps/cli/src/shared/init/project-init.templates.ts new file mode 100644 index 0000000000..65213ce06c --- /dev/null +++ b/apps/cli/src/shared/init/project-init.templates.ts @@ -0,0 +1,472 @@ +const CONFIG_TEMPLATE_RAW = `# For detailed configuration reference documentation, visit: +# https://supabase.com/docs/guides/local-development/cli/config +# A string used to distinguish different Supabase projects on the same host. Defaults to the +# working directory name when running \`supabase init\`. +project_id = "__PROJECT_ID__" + +[api] +enabled = true +# Port to use for the API URL. +port = 54321 +# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API +# endpoints. \`public\` and \`graphql_public\` schemas are included by default. +schemas = ["public", "graphql_public"] +# Extra schemas to add to the search_path of every request. +extra_search_path = ["public", "extensions"] +# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size +# for accidental or malicious requests. +max_rows = 1000 +# Controls whether new tables, views, sequences and functions created in the \`public\` schema by +# \`postgres\` are reachable through the Data API roles (\`anon\`, \`authenticated\`, \`service_role\`) +# without explicit GRANTs. Leave unset today to preserve local behaviour. The implicit default +# flips to \`false\` on 2026-05-30 to match the new cloud default, and the field is removed in +# 2026-10-30 once the always-revoked behaviour is permanent. Set to \`false\` to opt in early. +# auto_expose_new_tables = false + +[api.tls] +# Enable HTTPS endpoints locally using a self-signed certificate. +enabled = false +# Paths to self-signed certificate pair. +# cert_path = "../certs/my-cert.pem" +# key_path = "../certs/my-key.pem" + +[db] +# Port to use for the local database URL. +port = 54322 +# Port used by db diff command to initialize the shadow database. +shadow_port = 54320 +# Maximum amount of time to wait for health check when starting the local database. +health_timeout = "2m" +# The database major version to use. This has to be the same as your remote database's. Run \`SHOW +# server_version;\` on the remote database to check. +major_version = 17 + +[db.pooler] +enabled = false +# Port to use for the local connection pooler. +port = 54329 +# Specifies when a server connection can be reused by other clients. +# Configure one of the supported pooler modes: \`transaction\`, \`session\`. +pool_mode = "transaction" +# How many server connections to allow per user/database pair. +default_pool_size = 20 +# Maximum number of client connections allowed. +max_client_conn = 100 + +# [db.vault] +# secret_key = "env(SECRET_VALUE)" + +[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" +schema_paths = [] + +[db.seed] +# If enabled, seeds the database after migrations during a db reset. +enabled = true +# Specifies an ordered list of seed files to load during db reset. +# Supports glob patterns relative to supabase directory: "./seeds/*.sql" +sql_paths = ["./seed.sql"] + +[db.network_restrictions] +# Enable management of network restrictions. +enabled = false +# List of IPv4 CIDR blocks allowed to connect to the database. +# Defaults to allow all IPv4 connections. Set empty array to block all IPs. +allowed_cidrs = ["0.0.0.0/0"] +# List of IPv6 CIDR blocks allowed to connect to the database. +# Defaults to allow all IPv6 connections. Set empty array to block all IPs. +allowed_cidrs_v6 = ["::/0"] + +# Uncomment to reject non-secure connections to the database. +# [db.ssl_enforcement] +# enabled = true + +[realtime] +enabled = true +# Bind realtime via either IPv4 or IPv6. (default: IPv4) +# ip_version = "IPv6" +# The maximum length in bytes of HTTP request headers. (default: 4096) +# max_header_length = 4096 + +[studio] +enabled = true +# Port to use for Supabase Studio. +port = 54323 +# External URL of the API server that frontend connects to. +api_url = "http://127.0.0.1" +# OpenAI API Key to use for Supabase AI in the Supabase Studio. +openai_api_key = "env(OPENAI_API_KEY)" + +# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they +# are monitored, and you can view the emails that would have been sent from the web interface. +[inbucket] +enabled = true +# Port to use for the email testing server web interface. +port = 54324 +# Uncomment to expose additional ports for testing user applications that send emails. +# smtp_port = 54325 +# pop3_port = 54326 +# admin_email = "admin@email.com" +# sender_name = "Admin" + +[storage] +enabled = true +# The maximum file size allowed (e.g. "5MB", "500KB"). +file_size_limit = "50MiB" + +# Uncomment to configure local storage buckets +# [storage.buckets.images] +# public = false +# file_size_limit = "50MiB" +# allowed_mime_types = ["image/png", "image/jpeg"] +# objects_path = "./images" + +# Allow connections via S3 compatible clients +[storage.s3_protocol] +enabled = true + +# Image transformation API is available to Supabase Pro plan. +# [storage.image_transformation] +# enabled = true + +# Store analytical data in S3 for running ETL jobs over Iceberg Catalog +# This feature is only available on the hosted platform. +[storage.analytics] +enabled = false +max_namespaces = 5 +max_tables = 10 +max_catalogs = 2 + +# Analytics Buckets is available to Supabase Pro plan. +# [storage.analytics.buckets.my-warehouse] + +# Store vector embeddings in S3 for large and durable datasets +[storage.vector] +enabled = true +max_buckets = 10 +max_indexes = 5 + +# Vector Buckets is available to Supabase Pro plan. +# [storage.vector.buckets.documents-openai] + +[auth] +enabled = true +# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used +# in emails. +site_url = "http://127.0.0.1:3000" +# The public URL that Auth serves on. Defaults to the API external URL with \`/auth/v1\` appended. +# external_url = "" +# A list of *exact* URLs that auth providers are permitted to redirect to post authentication. +additional_redirect_urls = ["https://127.0.0.1:3000"] +# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week). +jwt_expiry = 3600 +# JWT issuer URL. If not set, defaults to auth.external_url. +# jwt_issuer = "" +# Path to JWT signing key. DO NOT commit your signing keys file to git. +# signing_keys_path = "./signing_keys.json" +# If disabled, the refresh token will never expire. +enable_refresh_token_rotation = true +# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds. +# Requires enable_refresh_token_rotation = true. +refresh_token_reuse_interval = 10 +# Allow/disallow new user signups to your project. +enable_signup = true +# Allow/disallow anonymous sign-ins to your project. +enable_anonymous_sign_ins = false +# Allow/disallow testing manual linking of accounts +enable_manual_linking = false +# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more. +minimum_password_length = 6 +# Passwords that do not meet the following requirements will be rejected as weak. Supported values +# are: \`letters_digits\`, \`lower_upper_letters_digits\`, \`lower_upper_letters_digits_symbols\` +password_requirements = "" + +# Configure passkey sign-ins. +# [auth.passkey] +# enabled = false + +# Configure WebAuthn relying party settings (required when passkey is enabled). +# [auth.webauthn] +# rp_display_name = "Supabase" +# rp_id = "localhost" +# rp_origins = ["http://127.0.0.1:3000"] + +[auth.rate_limit] +# Number of emails that can be sent per hour. Requires auth.email.smtp to be enabled. +email_sent = 2 +# Number of SMS messages that can be sent per hour. Requires auth.sms to be enabled. +sms_sent = 30 +# Number of anonymous sign-ins that can be made per hour per IP address. Requires enable_anonymous_sign_ins = true. +anonymous_users = 30 +# Number of sessions that can be refreshed in a 5 minute interval per IP address. +token_refresh = 150 +# Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users). +sign_in_sign_ups = 30 +# Number of OTP / Magic link verifications that can be made in a 5 minute interval per IP address. +token_verifications = 30 +# Number of Web3 logins that can be made in a 5 minute interval per IP address. +web3 = 30 + +# Configure one of the supported captcha providers: \`hcaptcha\`, \`turnstile\`. +# [auth.captcha] +# enabled = true +# provider = "hcaptcha" +# secret = "" + +[auth.email] +# Allow/disallow new user signups via email to your project. +enable_signup = true +# If enabled, a user will be required to confirm any email change on both the old, and new email +# addresses. If disabled, only the new email is required to confirm. +double_confirm_changes = true +# If enabled, users need to confirm their email address before signing in. +enable_confirmations = false +# If enabled, users will need to reauthenticate or have logged in recently to change their password. +secure_password_change = false +# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email. +max_frequency = "1s" +# Number of characters used in the email OTP. +otp_length = 6 +# Number of seconds before the email OTP expires (defaults to 1 hour). +otp_expiry = 3600 + +# Use a production-ready SMTP server +# [auth.email.smtp] +# enabled = true +# host = "smtp.sendgrid.net" +# port = 587 +# user = "apikey" +# pass = "env(SENDGRID_API_KEY)" +# admin_email = "admin@email.com" +# sender_name = "Admin" + +# Uncomment to customize email template +# [auth.email.template.invite] +# subject = "You have been invited" +# content_path = "./supabase/templates/invite.html" + +# Uncomment to customize notification email template +# [auth.email.notification.password_changed] +# enabled = true +# subject = "Your password has been changed" +# content_path = "./templates/password_changed_notification.html" + +[auth.sms] +# Allow/disallow new user signups via SMS to your project. +enable_signup = false +# If enabled, users need to confirm their phone number before signing in. +enable_confirmations = false +# Template for sending OTP to users +template = "Your code is {{ \`{{ .Code }}\` }}" +# Controls the minimum amount of time that must pass before sending another sms otp. +max_frequency = "5s" + +# Use pre-defined map of phone number to OTP for testing. +# [auth.sms.test_otp] +# 4152127777 = "123456" + +# Configure logged in session timeouts. +# [auth.sessions] +# Force log out after the specified duration. +# timebox = "24h" +# Force log out if the user has been inactive longer than the specified duration. +# inactivity_timeout = "8h" + +# This hook runs before a new user is created and allows developers to reject the request based on the incoming user object. +# [auth.hook.before_user_created] +# enabled = true +# uri = "pg-functions://postgres/auth/before-user-created-hook" + +# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used. +# [auth.hook.custom_access_token] +# enabled = true +# uri = "pg-functions:////" + +# Configure one of the supported SMS providers: \`twilio\`, \`twilio_verify\`, \`messagebird\`, \`textlocal\`, \`vonage\`. +[auth.sms.twilio] +enabled = false +account_sid = "" +message_service_sid = "" +# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead: +auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)" + +# Multi-factor-authentication is available to Supabase Pro plan. +[auth.mfa] +# Control how many MFA factors can be enrolled at once per user. +max_enrolled_factors = 10 + +# Control MFA via App Authenticator (TOTP) +[auth.mfa.totp] +enroll_enabled = false +verify_enabled = false + +# Configure MFA via Phone Messaging +[auth.mfa.phone] +enroll_enabled = false +verify_enabled = false +otp_length = 6 +template = "Your code is {{ \`{{ .Code }}\` }}" +max_frequency = "5s" + +# Configure MFA via WebAuthn +# [auth.mfa.web_authn] +# enroll_enabled = true +# verify_enabled = true + +# Use an external OAuth provider. The full list of providers are: \`apple\`, \`azure\`, \`bitbucket\`, +# \`discord\`, \`facebook\`, \`github\`, \`gitlab\`, \`google\`, \`keycloak\`, \`linkedin_oidc\`, \`notion\`, \`twitch\`, +# \`twitter\`, \`x\`, \`slack\`, \`spotify\`, \`workos\`, \`zoom\`. +[auth.external.apple] +enabled = false +client_id = "" +# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead: +secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)" +# Overrides the default auth callback URL derived from auth.external_url. +redirect_uri = "" +# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure, +# or any other third-party OIDC providers. +url = "" +# If enabled, the nonce check will be skipped. Required for local sign in with Google auth. +skip_nonce_check = false +# If enabled, it will allow the user to successfully authenticate when the provider does not return an email address. +email_optional = false + +# Allow Solana wallet holders to sign in to your project via the Sign in with Solana (SIWS, EIP-4361) standard. +# You can configure "web3" rate limit in the [auth.rate_limit] section and set up [auth.captcha] if self-hosting. +[auth.web3.solana] +enabled = false + +# Use Firebase Auth as a third-party provider alongside Supabase Auth. +[auth.third_party.firebase] +enabled = false +# project_id = "my-firebase-project" + +# Use Auth0 as a third-party provider alongside Supabase Auth. +[auth.third_party.auth0] +enabled = false +# tenant = "my-auth0-tenant" +# tenant_region = "us" + +# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth. +[auth.third_party.aws_cognito] +enabled = false +# user_pool_id = "my-user-pool-id" +# user_pool_region = "us-east-1" + +# Use Clerk as a third-party provider alongside Supabase Auth. +[auth.third_party.clerk] +enabled = false +# Obtain from https://clerk.com/setup/supabase +# domain = "example.clerk.accounts.dev" + +# OAuth server configuration +[auth.oauth_server] +# Enable OAuth server functionality +enabled = false +# Path for OAuth consent flow UI +authorization_url_path = "/oauth/consent" +# Allow dynamic client registration +allow_dynamic_registration = false + +[edge_runtime] +enabled = true +# Supported request policies: \`oneshot\`, \`per_worker\`. +# \`per_worker\` (default) β€” enables hot reload during local development. +# \`oneshot\` β€” fallback mode if hot reload causes issues (e.g. in large repos or with symlinks). +policy = "per_worker" +# Port to attach the Chrome inspector for debugging edge functions. +inspector_port = 8083 +# The Deno major version to use. +deno_version = 2 + +# [edge_runtime.secrets] +# secret_key = "env(SECRET_VALUE)" + +[analytics] +enabled = true +port = 54327 +# Configure one of the supported backends: \`postgres\`, \`bigquery\`. +backend = "postgres" + +# Experimental features may be deprecated any time +[experimental] +# Configures Postgres storage engine to use OrioleDB (S3) +orioledb_version = "__ORIOLEDB_VERSION__" +# Configures S3 bucket URL, eg. .s3-.amazonaws.com +s3_host = "env(S3_HOST)" +# Configures S3 bucket region, eg. us-east-1 +s3_region = "env(S3_REGION)" +# Configures AWS_ACCESS_KEY_ID for S3 bucket +s3_access_key = "env(S3_ACCESS_KEY)" +# Configures AWS_SECRET_ACCESS_KEY for S3 bucket +s3_secret_key = "env(S3_SECRET_KEY)" + +# [experimental.pgdelta] +# When enabled, pg-delta becomes the active engine for supported schema flows. +# enabled = false +# Directory under \`supabase/\` where declarative files are written. +# declarative_schema_path = "./database" +# JSON string passed through to pg-delta SQL formatting. +# format_options = "{\\"keywordCase\\":\\"upper\\",\\"indent\\":2,\\"maxWidth\\":80,\\"commaStyle\\":\\"trailing\\"}" +`; + +export const INIT_GITIGNORE_TEMPLATE = `# Supabase +.branches +.temp + +# dotenvx +.env.keys +.env.local +.env.*.local +`; + +export const VSCODE_EXTENSIONS_TEMPLATE = `{ + "recommendations": ["denoland.vscode-deno"] +} +`; + +export const VSCODE_SETTINGS_TEMPLATE = `{ + "deno.enablePaths": [ + "supabase/functions" + ], + "deno.lint": true, + "deno.unstable": [ + "bare-node-builtins", + "byonm", + "sloppy-imports", + "unsafe-proto", + "webgpu", + "broadcast-channel", + "worker-options", + "cron", + "kv", + "ffi", + "fs", + "http", + "net" + ], + "[typescript]": { + "editor.defaultFormatter": "denoland.vscode-deno" + } +} +`; + +export const INTELLIJ_DENO_TEMPLATE = ` + + + + +`; + +const ORIOLE_DB_VERSION = "15.1.0.150"; + +export function renderProjectConfigTemplate(projectId: string, useOrioledb: boolean): string { + return CONFIG_TEMPLATE_RAW.replace("__PROJECT_ID__", projectId).replace( + "__ORIOLEDB_VERSION__", + useOrioledb ? ORIOLE_DB_VERSION : "", + ); +} diff --git a/apps/cli/src/shared/init/project-init.templates.unit.test.ts b/apps/cli/src/shared/init/project-init.templates.unit.test.ts new file mode 100644 index 0000000000..163acf3b5b --- /dev/null +++ b/apps/cli/src/shared/init/project-init.templates.unit.test.ts @@ -0,0 +1,56 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { + INIT_GITIGNORE_TEMPLATE, + INTELLIJ_DENO_TEMPLATE, + VSCODE_EXTENSIONS_TEMPLATE, + VSCODE_SETTINGS_TEMPLATE, + renderProjectConfigTemplate, +} from "./project-init.templates.ts"; + +const here = dirname(fileURLToPath(import.meta.url)); +const goCliRoot = join(here, "../../../../cli-go"); + +function normalizeNewlines(text: string): string { + return text.replace(/\r\n/g, "\n"); +} + +function readGoTemplate(...segments: ReadonlyArray): string { + return normalizeNewlines(readFileSync(join(goCliRoot, ...segments), "utf8")); +} + +describe("project init templates", () => { + it("renders config.toml with the same content as the Go scaffold", () => { + const expected = readGoTemplate("pkg", "config", "templates", "config.toml") + .replace("{{ .ProjectId }}", "demo-project") + .replace("{{ .Experimental.OrioleDBVersion }}", "15.1.0.150"); + + expect(normalizeNewlines(renderProjectConfigTemplate("demo-project", true))).toBe(expected); + }); + + it("matches the Go .gitignore scaffold", () => { + expect(INIT_GITIGNORE_TEMPLATE).toBe( + readGoTemplate("internal", "init", "templates", ".gitignore"), + ); + }); + + it("matches the Go VS Code extensions scaffold", () => { + expect(VSCODE_EXTENSIONS_TEMPLATE).toBe( + readGoTemplate("internal", "init", "templates", ".vscode", "extensions.json"), + ); + }); + + it("matches the Go VS Code settings scaffold", () => { + expect(VSCODE_SETTINGS_TEMPLATE).toBe( + readGoTemplate("internal", "init", "templates", ".vscode", "settings.json"), + ); + }); + + it("matches the Go IntelliJ scaffold", () => { + expect(INTELLIJ_DENO_TEMPLATE).toBe( + readGoTemplate("internal", "init", "templates", ".idea", "deno.xml"), + ); + }); +}); diff --git a/apps/cli/src/shared/init/project-init.ts b/apps/cli/src/shared/init/project-init.ts new file mode 100644 index 0000000000..4805a41f13 --- /dev/null +++ b/apps/cli/src/shared/init/project-init.ts @@ -0,0 +1,300 @@ +import { Effect, FileSystem, Path, Schema } from "effect"; +import { Output } from "../output/output.service.ts"; +import { Tty } from "../runtime/tty.service.ts"; +import { + INIT_GITIGNORE_TEMPLATE, + INTELLIJ_DENO_TEMPLATE, + VSCODE_EXTENSIONS_TEMPLATE, + VSCODE_SETTINGS_TEMPLATE, + renderProjectConfigTemplate, +} from "./project-init.templates.ts"; +import { InitParseSettingsError } from "./project-init.errors.ts"; + +const invalidProjectId = /[^a-zA-Z0-9_.-]+/g; +const maxProjectIdLength = 40; + +function truncateText(text: string, maxLength: number): string { + return text.length > maxLength ? text.slice(0, maxLength) : text; +} + +function sanitizeProjectId(src: string): string { + const sanitized = src.replaceAll(invalidProjectId, "_").replace(/^[_.-]+/, ""); + return truncateText(sanitized, maxProjectIdLength); +} + +// Mirrors Go's `jsonc.ToJSONInPlace` (github.com/tidwall/jsonc): strips line and +// block comments and trailing commas while preserving string contents, so an +// existing JSONC settings file parses exactly as it does in the Go CLI. +function stripJsonComments(contents: string): string { + const src = contents.replace(/^\uFEFF/, ""); + const out: Array = []; + let pendingCommaIndex = -1; + let i = 0; + while (i < src.length) { + const char = src.charAt(i); + + // String literal \u2014 copy verbatim, honoring escape sequences. + if (char === '"') { + pendingCommaIndex = -1; + out.push(char); + i++; + while (i < src.length) { + const stringChar = src.charAt(i); + out.push(stringChar); + i++; + if (stringChar === "\\") { + if (i < src.length) { + out.push(src.charAt(i)); + i++; + } + } else if (stringChar === '"') { + break; + } + } + continue; + } + + // Line comment. + if (char === "/" && src.charAt(i + 1) === "/") { + i += 2; + while (i < src.length && src.charAt(i) !== "\n") { + i++; + } + continue; + } + + // Block comment. + if (char === "/" && src.charAt(i + 1) === "*") { + i += 2; + while (i < src.length && !(src.charAt(i) === "*" && src.charAt(i + 1) === "/")) { + i++; + } + i += 2; + continue; + } + + // A comma is "trailing" if the next significant token is a closing brace or + // bracket; drop it in that case to match jsonc's trailing-comma handling. + if (char === ",") { + pendingCommaIndex = out.length; + out.push(char); + i++; + continue; + } + + if (char === "}" || char === "]") { + if (pendingCommaIndex >= 0) { + out[pendingCommaIndex] = ""; + pendingCommaIndex = -1; + } + out.push(char); + i++; + continue; + } + + if (char === " " || char === "\t" || char === "\n" || char === "\r") { + out.push(char); + i++; + continue; + } + + pendingCommaIndex = -1; + out.push(char); + i++; + } + return out.join(""); +} + +const decodeJsonObject = Schema.decodeUnknownEffect( + Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)), +); + +// Parses a settings file through a Schema boundary so malformed JSON surfaces as +// a typed `InitParseSettingsError` (recoverable, never a fiber defect) and a +// non-object document is rejected \u2014 matching Go's `json.Decoder` into a map. +function parseJsonObject(pathname: string, contents: string) { + return decodeJsonObject(stripJsonComments(contents)).pipe( + Effect.mapError( + (error) => + new InitParseSettingsError({ + detail: `Could not parse JSON in ${pathname}: ${error.message}`, + suggestion: `Fix or remove ${pathname}, then rerun \`supabase init\`.`, + }), + ), + ); +} + +export interface ProjectInitOptions { + readonly cwd: string; + readonly force: boolean; + readonly useOrioledb: boolean; + readonly interactive: boolean; + readonly withVscodeSettings: boolean; + readonly withIntellijSettings: boolean; +} + +function writeJsonFile(pathname: string, contents: Record) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.writeFileString(pathname, `${JSON.stringify(contents, null, 2)}\n`); + }); +} + +function updateJsonFile(pathname: string, template: string) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + + if (!(yield* fs.exists(pathname))) { + yield* fs.writeFileString(pathname, template); + return; + } + + const existing = yield* fs.readFileString(pathname); + if (existing.trim().length === 0) { + yield* fs.writeFileString(pathname, template); + return; + } + + const merged = { + ...(yield* parseJsonObject(pathname, existing)), + ...(yield* parseJsonObject(pathname, template)), + }; + yield* writeJsonFile(pathname, merged); + }); +} + +const writeVscodeConfig = Effect.fnUntraced(function* (cwd: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const output = yield* Output; + + const vscodeDir = path.join(cwd, ".vscode"); + const extensionsPath = path.join(vscodeDir, "extensions.json"); + const settingsPath = path.join(vscodeDir, "settings.json"); + + yield* fs.makeDirectory(vscodeDir, { recursive: true }); + yield* updateJsonFile(extensionsPath, VSCODE_EXTENSIONS_TEMPLATE); + yield* updateJsonFile(settingsPath, VSCODE_SETTINGS_TEMPLATE); + + yield* output.raw("Generated VS Code settings in .vscode/settings.json.\n"); + yield* output.raw( + "Please install the Deno extension for VS Code: https://marketplace.visualstudio.com/items?itemName=denoland.vscode-deno\n", + ); +}); + +const writeIntelliJConfig = Effect.fnUntraced(function* (cwd: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const output = yield* Output; + + const intellijDir = path.join(cwd, ".idea"); + const denoPath = path.join(intellijDir, "deno.xml"); + + yield* fs.makeDirectory(intellijDir, { recursive: true }); + yield* fs.writeFileString(denoPath, INTELLIJ_DENO_TEMPLATE); + + yield* output.raw("Generated IntelliJ settings in .idea/deno.xml.\n"); + yield* output.raw( + "Please install the Deno plugin for IntelliJ: https://plugins.jetbrains.com/plugin/14382-deno\n", + ); +}); + +const promptForIdeSettings = Effect.fnUntraced(function* (cwd: string) { + const output = yield* Output; + + if (yield* output.promptConfirm("Generate VS Code settings for Deno?", { defaultValue: true })) { + yield* writeVscodeConfig(cwd); + return; + } + + if ( + yield* output.promptConfirm("Generate IntelliJ IDEA settings for Deno?", { + defaultValue: false, + }) + ) { + yield* writeIntelliJConfig(cwd); + } +}); + +const isInGitRepo = Effect.fnUntraced(function* (cwd: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + for (let current = cwd; ; current = path.dirname(current)) { + if (yield* fs.exists(path.join(current, ".git"))) { + return true; + } + const parent = path.dirname(current); + if (parent === current) { + return false; + } + } +}); + +const ensureSupabaseGitignore = Effect.fnUntraced(function* (cwd: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + if (!(yield* isInGitRepo(cwd))) { + return; + } + + const gitignorePath = path.join(cwd, "supabase", ".gitignore"); + + if (yield* fs.exists(gitignorePath)) { + const existing = yield* fs.readFileString(gitignorePath); + if (existing.includes(INIT_GITIGNORE_TEMPLATE)) { + return; + } + const prefix = existing.length > 0 ? "\n" : ""; + yield* fs.writeFileString(gitignorePath, `${existing}${prefix}${INIT_GITIGNORE_TEMPLATE}`); + return; + } + + yield* fs.writeFileString(gitignorePath, INIT_GITIGNORE_TEMPLATE); +}); + +/** + * Scaffolds the local project files (config.toml, .gitignore, optional IDE + * settings). This owns the mechanical filesystem work only β€” it does not decide + * how an already-initialized project is reported. When `config.toml` already + * exists and `force` is not set it short-circuits with `created: false` and + * writes nothing, leaving each shell free to treat that as a hard error (legacy + * Go parity) or a graceful no-op (next). + */ +export const initProject = Effect.fnUntraced(function* (options: ProjectInitOptions) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tty = yield* Tty; + const output = yield* Output; + + const supabaseDir = path.join(options.cwd, "supabase"); + const configTomlPath = path.join(supabaseDir, "config.toml"); + const existingToml = yield* fs.exists(configTomlPath); + + if (existingToml && !options.force) { + return { created: false, configPath: configTomlPath }; + } + + const projectId = sanitizeProjectId(path.basename(options.cwd)) || "supabase"; + + yield* fs.makeDirectory(supabaseDir, { recursive: true }); + yield* fs.writeFileString( + configTomlPath, + renderProjectConfigTemplate(projectId, options.useOrioledb), + ); + yield* ensureSupabaseGitignore(options.cwd); + + const effectiveInteractive = options.interactive && tty.stdinIsTty && output.interactive; + if (effectiveInteractive) { + yield* promptForIdeSettings(options.cwd); + } + if (options.withVscodeSettings) { + yield* writeVscodeConfig(options.cwd); + } + if (options.withIntellijSettings) { + yield* writeIntelliJConfig(options.cwd); + } + + return { created: true, configPath: configTomlPath }; +}); diff --git a/apps/cli/src/shared/output/output.layer.ts b/apps/cli/src/shared/output/output.layer.ts index 1b16c86ed7..65c18177d3 100644 --- a/apps/cli/src/shared/output/output.layer.ts +++ b/apps/cli/src/shared/output/output.layer.ts @@ -307,9 +307,14 @@ export const textOutputLayer = Layer.effect( } return value.trim(); }), - promptConfirm: (message: string) => + promptConfirm: (message: string, opts?: { defaultValue?: boolean }) => Effect.gen(function* () { - const value = yield* Effect.promise(() => confirm({ message })); + const value = yield* Effect.promise(() => + confirm({ + message, + initialValue: opts?.defaultValue, + }), + ); if (isCancel(value)) { cancel("Operation cancelled."); return yield* Effect.interrupt; diff --git a/apps/cli/src/shared/output/output.service.ts b/apps/cli/src/shared/output/output.service.ts index 5f394b6188..36b911740f 100644 --- a/apps/cli/src/shared/output/output.service.ts +++ b/apps/cli/src/shared/output/output.service.ts @@ -47,7 +47,10 @@ interface OutputShape { opts?: { validate?: (v: string) => string | undefined; defaultValue?: string }, ) => Effect.Effect; readonly promptPassword: (message: string) => Effect.Effect; - readonly promptConfirm: (message: string) => Effect.Effect; + readonly promptConfirm: ( + message: string, + opts?: { defaultValue?: boolean }, + ) => Effect.Effect; readonly promptSelect: ( message: string, options: ReadonlyArray, diff --git a/apps/cli/src/shared/telemetry/consent.ts b/apps/cli/src/shared/telemetry/consent.ts index 1f1bd235f1..275676d562 100644 --- a/apps/cli/src/shared/telemetry/consent.ts +++ b/apps/cli/src/shared/telemetry/consent.ts @@ -1,17 +1,63 @@ import { Effect, FileSystem, Option, Path, Schema } from "effect"; import { CliConfig } from "../../next/config/cli-config.service.ts"; -import { TelemetryConfigSchema, type TelemetryConfig } from "./types.ts"; +import { type ConsentState, TelemetryConfigSchema, type TelemetryConfig } from "./types.ts"; export const getConfigDir = CliConfig.useSync((cliConfig) => cliConfig.supabaseHome); const TelemetryConfigFileSchema = Schema.fromJsonString(TelemetryConfigSchema); -const decodeTelemetryConfigFile = Schema.decodeUnknownEffect(TelemetryConfigFileSchema); +const LegacyTelemetryConfigSchema = Schema.Struct({ + enabled: Schema.Boolean, + device_id: Schema.String, + session_id: Schema.String, + session_last_active: Schema.String, + distinct_id: Schema.optionalKey(Schema.String), + schema_version: Schema.optionalKey(Schema.Number), +}); +type LegacyTelemetryConfig = Schema.Schema.Type; + +const decodeCurrentTelemetryConfigFile = Schema.decodeUnknownEffect(TelemetryConfigFileSchema); +const decodeLegacyTelemetryConfigFile = Schema.decodeUnknownEffect( + Schema.fromJsonString(LegacyTelemetryConfigSchema), +); const encodeTelemetryConfig = Schema.encodeUnknownSync(TelemetryConfigSchema); function encodePrettyJson(value: unknown): string { return `${JSON.stringify(value, null, 2)}\n`; } +function legacyConsent(enabled: boolean): ConsentState { + return enabled ? "granted" : "denied"; +} + +function legacyConfigToTelemetryConfig( + legacyConfig: LegacyTelemetryConfig, +): TelemetryConfig | undefined { + const sessionLastActive = Date.parse(legacyConfig.session_last_active); + if (!Number.isFinite(sessionLastActive)) return undefined; + return { + consent: legacyConsent(legacyConfig.enabled), + device_id: legacyConfig.device_id, + session_id: legacyConfig.session_id, + session_last_active: sessionLastActive, + ...(legacyConfig.distinct_id === undefined ? {} : { distinct_id: legacyConfig.distinct_id }), + }; +} + +const decodeTelemetryConfigFile = Effect.fnUntraced(function* (content: string) { + return yield* decodeCurrentTelemetryConfigFile(content).pipe( + Effect.catch(() => + Effect.gen(function* () { + const legacyConfig = yield* decodeLegacyTelemetryConfigFile(content); + const config = legacyConfigToTelemetryConfig(legacyConfig); + if (config === undefined) { + return yield* Effect.fail(new Error("invalid legacy telemetry state")); + } + return config; + }), + ), + ); +}); + export const readTelemetryConfig = Effect.fnUntraced( function* (configDir: string) { const fs = yield* FileSystem.FileSystem; diff --git a/apps/cli/src/shared/telemetry/consent.unit.test.ts b/apps/cli/src/shared/telemetry/consent.unit.test.ts index adb4d63d30..e2257ee95e 100644 --- a/apps/cli/src/shared/telemetry/consent.unit.test.ts +++ b/apps/cli/src/shared/telemetry/consent.unit.test.ts @@ -118,6 +118,66 @@ describe("readTelemetryConfig", () => { ); }); + it.live("decodes a legacy disabled telemetry state as denied consent", () => { + const dir = makeTempDir(); + writeTelemetryFile( + dir, + JSON.stringify({ + enabled: false, + device_id: "legacy-device", + session_id: "legacy-session", + session_last_active: "2026-04-01T12:00:00Z", + schema_version: 1, + }), + ); + + return Effect.gen(function* () { + const config = yield* readTelemetryConfig(dir); + expect(config).toEqual( + Option.some({ + consent: "denied", + device_id: "legacy-device", + session_id: "legacy-session", + session_last_active: Date.parse("2026-04-01T12:00:00Z"), + }), + ); + }).pipe( + Effect.provide(BunServices.layer), + Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), + ); + }); + + it.live("decodes a legacy enabled telemetry state as granted consent", () => { + const dir = makeTempDir(); + writeTelemetryFile( + dir, + JSON.stringify({ + enabled: true, + device_id: "legacy-device", + session_id: "legacy-session", + session_last_active: "2026-04-01T12:00:00Z", + distinct_id: "user-123", + schema_version: 1, + }), + ); + + return Effect.gen(function* () { + const config = yield* readTelemetryConfig(dir); + expect(config).toEqual( + Option.some({ + consent: "granted", + device_id: "legacy-device", + session_id: "legacy-session", + session_last_active: Date.parse("2026-04-01T12:00:00Z"), + distinct_id: "user-123", + }), + ); + }).pipe( + Effect.provide(BunServices.layer), + Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), + ); + }); + it.live("returns none for malformed JSON instead of throwing", () => { const dir = makeTempDir(); writeTelemetryFile(dir, ""); diff --git a/apps/cli/src/shared/telemetry/runtime.layer.unit.test.ts b/apps/cli/src/shared/telemetry/runtime.layer.unit.test.ts index 580cd9fb4b..fbb165b4fa 100644 --- a/apps/cli/src/shared/telemetry/runtime.layer.unit.test.ts +++ b/apps/cli/src/shared/telemetry/runtime.layer.unit.test.ts @@ -111,4 +111,31 @@ describe("telemetryRuntimeLayer", () => { Effect.ensuring(Effect.sync(() => rmSync(homeDir, { recursive: true, force: true }))), ); }); + + it.live("honors a legacy disabled telemetry state", () => { + const homeDir = makeTempDir(); + const configPath = path.join(homeDir, "telemetry.json"); + writeFileSync( + configPath, + JSON.stringify({ + enabled: false, + device_id: "legacy-device", + session_id: "legacy-session", + session_last_active: "2026-04-01T12:00:00Z", + schema_version: 1, + }), + ); + + return Effect.gen(function* () { + const runtime = yield* TelemetryRuntime; + expect(runtime.consent).toBe("denied"); + expect(runtime.deviceId).toBe("legacy-device"); + expect(runtime.sessionId).toBe("legacy-session"); + expect(runtime.isFirstRun).toBe(false); + expect(existsSync(configPath)).toBe(true); + }).pipe( + Effect.provide(buildLayer({ homeDir, stdoutIsTty: true })), + Effect.ensuring(Effect.sync(() => rmSync(homeDir, { recursive: true, force: true }))), + ); + }); }); diff --git a/apps/cli/tests/helpers/legacy-mocks.ts b/apps/cli/tests/helpers/legacy-mocks.ts index eebc95ef92..a2497bbd11 100644 --- a/apps/cli/tests/helpers/legacy-mocks.ts +++ b/apps/cli/tests/helpers/legacy-mocks.ts @@ -14,7 +14,23 @@ import * as UrlParams from "effect/unstable/http/UrlParams"; import { afterEach, beforeEach } from "vitest"; import { LegacyCredentials } from "../../src/legacy/auth/legacy-credentials.service.ts"; +import { + LegacyCredentialDeleteError, + LegacyDeleteTokenError, + LegacyInvalidAccessTokenError, + LegacyNotLoggedInError, +} from "../../src/legacy/auth/legacy-errors.ts"; import { LegacyPlatformApi } from "../../src/legacy/auth/legacy-platform-api.service.ts"; +import { + LegacyLoginApi, + type LegacyLoginSessionResponse, +} from "../../src/legacy/commands/login/login-api.service.ts"; +import { LegacyLoginCrypto } from "../../src/legacy/commands/login/login-crypto.service.ts"; +import { + LegacyLoginCryptoError, + LegacyLoginDecryptError, + LegacyLoginVerificationError, +} from "../../src/legacy/commands/login/login.errors.ts"; import { LegacyCliConfig } from "../../src/legacy/config/legacy-cli-config.service.ts"; import { legacyProjectRefLayer } from "../../src/legacy/config/legacy-project-ref.layer.ts"; import { LegacyLinkedProjectCache } from "../../src/legacy/telemetry/legacy-linked-project-cache.service.ts"; @@ -48,6 +64,8 @@ export const mockLegacyLinkedProjectCacheLayer = Layer.succeed(LegacyLinkedProje export const mockLegacyTelemetryStateLayer = Layer.succeed(LegacyTelemetryState, { flush: Effect.void, + stitchLogin: () => Effect.void, + clearDistinctId: Effect.void, }); // Default LegacyCredentials mock. `mockLegacyCliConfig` defaults to an env-set @@ -59,8 +77,182 @@ export const mockLegacyCredentialsLayer = Layer.succeed(LegacyCredentials, { getAccessToken: Effect.sync(() => Option.none()), saveAccessToken: () => Effect.die("unexpected legacy credentials write in test"), deleteAccessToken: Effect.die("unexpected legacy credentials delete in test"), + deleteAllProjectCredentials: Effect.die("unexpected legacy project-credential sweep in test"), + deleteProjectCredential: () => Effect.die("unexpected legacy project-credential delete in test"), }); +/** + * Tracked `LegacyCredentials` mock for `unlink` / `login` / `logout` tests. + * + * - `deleteProjectCredential` (unlink): records refs in `deletedRefs`; `deleteFails` + * makes it raise `LegacyCredentialDeleteError`. + * - `saveAccessToken` (login): records the saved token in `savedToken`; `saveFails` + * raises `LegacyInvalidAccessTokenError` (the token-path "cannot save" branch). + * - `deleteAccessToken` (logout): `deleteOutcome` selects success (`"ok"`), + * `LegacyNotLoggedInError` (`"notLoggedIn"`), or `LegacyDeleteTokenError` + * (`"deleteError"`). + * - `deleteAllProjectCredentials` (logout): flips `deletedAll`. + */ +export function mockLegacyCredentialsTracked( + opts: { + readonly deleteFails?: boolean; + readonly saveFails?: boolean; + readonly deleteOutcome?: "ok" | "notLoggedIn" | "deleteError"; + } = {}, +): { + readonly layer: Layer.Layer; + readonly deletedRefs: ReadonlyArray; + readonly savedToken: string | undefined; + readonly deletedAll: boolean; +} { + const deletedRefs: string[] = []; + let savedToken: string | undefined; + let deletedAll = false; + + const deleteAccessToken = + opts.deleteOutcome === "notLoggedIn" + ? Effect.fail( + new LegacyNotLoggedInError({ message: "You were not logged in, nothing to do." }), + ) + : opts.deleteOutcome === "deleteError" + ? Effect.fail( + new LegacyDeleteTokenError({ + message: "failed to remove access token file: permission denied", + }), + ) + : Effect.void; + + const layer = Layer.succeed(LegacyCredentials, { + getAccessToken: Effect.sync(() => Option.none()), + saveAccessToken: (token: string) => + opts.saveFails === true + ? Effect.fail( + new LegacyInvalidAccessTokenError({ + message: "Invalid access token format. Must be like `sbp_0102...1920`.", + }), + ) + : Effect.sync(() => { + savedToken = token; + }), + deleteAccessToken, + deleteAllProjectCredentials: Effect.sync(() => { + deletedAll = true; + }), + deleteProjectCredential: (projectRef: string) => + Effect.gen(function* () { + deletedRefs.push(projectRef); + if (opts.deleteFails === true) { + return yield* Effect.fail( + new LegacyCredentialDeleteError({ + message: "failed to delete project credential: permission denied", + }), + ); + } + return true; + }), + }); + return { + layer, + get deletedRefs() { + return deletedRefs; + }, + get savedToken() { + return savedToken; + }, + get deletedAll() { + return deletedAll; + }, + }; +} + +// --------------------------------------------------------------------------- +// Login crypto / API mocks. The crypto mock returns a dummy ECDH handle (the +// browser-flow integration tests never reach a real decrypt β€” the API mock +// supplies the ciphertext and the crypto mock returns the decrypted token). +// --------------------------------------------------------------------------- + +export function mockLegacyLoginCrypto( + opts: { + readonly publicKeyHex?: string; + readonly sessionId?: string; + readonly tokenName?: string; + readonly decryptedToken?: string; + readonly decryptFails?: boolean; + readonly keygenFails?: boolean; + } = {}, +): { readonly layer: Layer.Layer } { + const layer = Layer.succeed(LegacyLoginCrypto, { + generateKeyPair: opts.keygenFails + ? Effect.fail(new LegacyLoginCryptoError({ message: "cannot generate crypto keys: boom" })) + : Effect.succeed({ + ecdh: {} as import("node:crypto").ECDH, + publicKeyHex: opts.publicKeyHex ?? "04abcd", + }), + generateSessionId: Effect.sync(() => opts.sessionId ?? "test-session-id"), + defaultTokenName: Effect.sync(() => opts.tokenName ?? "cli_test@host_123"), + decryptToken: () => + opts.decryptFails + ? Effect.fail( + new LegacyLoginDecryptError({ + message: "cannot decrypt access token: cipher: message authentication failed", + }), + ) + : Effect.succeed(opts.decryptedToken ?? LEGACY_VALID_TOKEN), + }); + return { layer }; +} + +export function mockLegacyLoginApi( + opts: { + readonly sessionResponse?: Partial; + // Number of `fetchLoginSession` failures before it succeeds (drives the + // verification retry loop). + readonly failTimes?: number; + // `gotrue_id` returned by `fetchGotrueId` (Some); `profileFails` returns None. + readonly gotrueId?: string; + readonly profileFails?: boolean; + } = {}, +): { + readonly layer: Layer.Layer; + readonly loginCallCount: number; + readonly gotrueCallCount: number; +} { + let loginCallCount = 0; + let gotrueCallCount = 0; + const failTimes = opts.failTimes ?? 0; + const session: LegacyLoginSessionResponse = { + access_token: "656e6372797074656420746f6b656e", + public_key: "04abcd", + nonce: "0102030405060708090a0b0c", + ...opts.sessionResponse, + }; + const layer = Layer.succeed(LegacyLoginApi, { + fetchLoginSession: () => { + loginCallCount += 1; + if (loginCallCount <= failTimes) { + return Effect.fail( + new LegacyLoginVerificationError({ message: "Error status 404: not found" }), + ); + } + return Effect.succeed(session); + }, + fetchGotrueId: () => { + gotrueCallCount += 1; + if (opts.profileFails === true) return Effect.succeed(Option.none()); + return Effect.succeed(Option.some(opts.gotrueId ?? "gotrue-user-123")); + }, + }); + return { + layer, + get loginCallCount() { + return loginCallCount; + }, + get gotrueCallCount() { + return gotrueCallCount; + }, + }; +} + // --------------------------------------------------------------------------- // State-tracking factories β€” for PersistentPostRun-parity assertions // (telemetry must flush, linked-project cache fires after ref resolution). @@ -70,20 +262,39 @@ export const mockLegacyCredentialsLayer = Layer.succeed(LegacyCredentials, { export function mockLegacyTelemetryStateTracked(): { readonly layer: Layer.Layer; readonly flushed: boolean; + readonly stitchedDistinctId: string | undefined; + readonly clearedDistinctId: boolean; } { let flushed = false; + let stitchedDistinctId: string | undefined; + let clearedDistinctId = false; const layer = Layer.succeed(LegacyTelemetryState, { get flush() { return Effect.sync(() => { flushed = true; }); }, + stitchLogin: (distinctId: string) => + Effect.sync(() => { + stitchedDistinctId = distinctId; + }), + get clearDistinctId() { + return Effect.sync(() => { + clearedDistinctId = true; + }); + }, }); return { layer, get flushed() { return flushed; }, + get stitchedDistinctId() { + return stitchedDistinctId; + }, + get clearedDistinctId() { + return clearedDistinctId; + }, }; } diff --git a/apps/cli/tests/helpers/mocks.ts b/apps/cli/tests/helpers/mocks.ts index cbab509eef..2bec89d266 100644 --- a/apps/cli/tests/helpers/mocks.ts +++ b/apps/cli/tests/helpers/mocks.ts @@ -222,6 +222,7 @@ export function mockOutput( confirmRelogin?: boolean; confirmLogout?: boolean; promptTextFail?: boolean; + promptConfirmFail?: boolean; promptTextResponses?: ReadonlyArray; promptSelectResponses?: ReadonlyArray; promptPasswordResponses?: ReadonlyArray; @@ -374,10 +375,17 @@ export function mockOutput( }; })(), promptPassword: () => Effect.succeed(promptPasswordResponses.shift() ?? ""), - promptConfirm: () => - Effect.succeed( - promptConfirmResponses.shift() ?? opts.confirmLogout ?? opts.confirmRelogin ?? true, - ), + promptConfirm: (_message, _opts) => + opts.promptConfirmFail + ? Effect.fail( + new NonInteractiveError({ + detail: "Prompt cancelled", + suggestion: "Run in interactive mode", + }), + ) + : Effect.succeed( + promptConfirmResponses.shift() ?? opts.confirmLogout ?? opts.confirmRelogin ?? true, + ), promptSelect: (message, options, behavior) => Effect.sync(() => { promptSelectCalls.push({ message, options, behavior }); diff --git a/apps/cli/tests/smoke-test-windows.ts b/apps/cli/tests/smoke-test-windows.ts index 1e5c171628..8b4c26d63c 100644 --- a/apps/cli/tests/smoke-test-windows.ts +++ b/apps/cli/tests/smoke-test-windows.ts @@ -1,4 +1,6 @@ import { $ } from "bun"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; import path from "node:path"; import process from "node:process"; import { parseArgs } from "node:util"; @@ -19,6 +21,10 @@ if (tag !== "latest" && tag !== "alpha" && tag !== "beta") { } const root = path.resolve(import.meta.dir, "../../.."); +async function gitBashPath(filePath: string) { + return process.platform === "win32" ? (await $`cygpath -u ${filePath}`.text()).trim() : filePath; +} + interface TestResult { name: string; status: "pass" | "fail"; @@ -32,9 +38,11 @@ console.log(`\n${"=".repeat(60)}`); console.log("Native binary tests"); console.log("=".repeat(60)); +const arch = process.arch === "arm64" ? "arm64" : "x64"; + { - const name = "native-windows-x64"; - const binPath = path.join(root, "packages", "cli-windows-x64", "bin", "supabase.exe"); + const name = `native-windows-${arch}`; + const binPath = path.join(root, "packages", `cli-windows-${arch}`, "bin", "supabase.exe"); console.log(`[${name}] Running ${binPath} --version...`); try { @@ -51,6 +59,38 @@ console.log("=".repeat(60)); } } +// --- Release tarball --- + +console.log(`\n${"=".repeat(60)}`); +console.log("Release tarball test"); +console.log("=".repeat(60)); + +{ + const archiveArch = arch === "arm64" ? "arm64" : "amd64"; + const name = `windows-${archiveArch}-tarball`; + const archivePath = path.join(root, "dist", `supabase_${version}_windows_${archiveArch}.tar.gz`); + const extractDir = await mkdtemp(path.join(tmpdir(), "supabase-windows-tarball-")); + + console.log(`[${name}] Extracting ${archivePath}...`); + try { + await $`tar -xzf ${await gitBashPath(archivePath)} -C ${await gitBashPath(extractDir)}`; + const binPath = path.join(extractDir, "supabase.exe"); + const output = await $`${binPath} --version`.text(); + const trimmed = output.trim(); + const shellCheck = await verifyExpectedShell(binPath); + const passed = /^\d+\.\d+\.\d+/.test(trimmed) && shellCheck.passed; + + console.log(`[${name}] ${passed ? "PASS" : "FAIL"} β€” ${trimmed}`); + console.log(`[${name}] ${shellCheck.detail}`); + results.push({ name, status: passed ? "pass" : "fail" }); + } catch (e) { + console.error(`[${name}] Error: ${e}`); + results.push({ name, status: "fail" }); + } finally { + await rm(extractDir, { recursive: true, force: true }); + } +} + // --- Scoop --- console.log(`\n${"=".repeat(60)}`); diff --git a/docs/assets/supabase-cli-wordmark-dark.svg b/docs/assets/supabase-cli-wordmark-dark.svg new file mode 100644 index 0000000000..e96ab6721d --- /dev/null +++ b/docs/assets/supabase-cli-wordmark-dark.svg @@ -0,0 +1,24 @@ + + + + + + + + + +CLI + + + + + + + + + + + + + + diff --git a/docs/assets/supabase-cli-wordmark-light.svg b/docs/assets/supabase-cli-wordmark-light.svg new file mode 100644 index 0000000000..ca769b2913 --- /dev/null +++ b/docs/assets/supabase-cli-wordmark-light.svg @@ -0,0 +1,24 @@ + + + + + + + + + +CLI + + + + + + + + + + + + + + diff --git a/install b/install new file mode 100755 index 0000000000..98f8105032 --- /dev/null +++ b/install @@ -0,0 +1,332 @@ +#!/usr/bin/env bash +set -euo pipefail + +APP="supabase" +REPO="supabase/cli" +INSTALL_DIR="${SUPABASE_INSTALL_DIR:-"$HOME/.supabase/bin"}" +REQUESTED_VERSION="${VERSION:-}" +NO_MODIFY_PATH=false +BINARY_PATH="" + +RED="\033[0;31m" +MUTED="\033[0;2m" +GREEN="\033[0;32m" +NC="\033[0m" + +usage() { + cat < Install a specific version, for example 2.0.0. + -d, --install-dir Install into a custom directory. + -b, --binary Install from a local binary instead of downloading. + --no-modify-path Do not update shell config files. + -h, --help Show this help. + +Environment: + VERSION Install a specific version. + SUPABASE_INSTALL_DIR Install into a custom directory. +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + -h | --help) + usage + exit 0 + ;; + -v | --version) + if [[ -z "${2:-}" ]]; then + echo -e "${RED}Error: --version requires a version.${NC}" >&2 + exit 1 + fi + REQUESTED_VERSION="$2" + shift 2 + ;; + -d | --install-dir) + if [[ -z "${2:-}" ]]; then + echo -e "${RED}Error: --install-dir requires a path.${NC}" >&2 + exit 1 + fi + INSTALL_DIR="$2" + shift 2 + ;; + -b | --binary) + if [[ -z "${2:-}" ]]; then + echo -e "${RED}Error: --binary requires a path.${NC}" >&2 + exit 1 + fi + BINARY_PATH="$2" + shift 2 + ;; + --no-modify-path) + NO_MODIFY_PATH=true + shift + ;; + *) + echo -e "${RED}Error: unknown option '$1'.${NC}" >&2 + usage >&2 + exit 1 + ;; + esac +done + +say() { + echo -e "$1" +} + +need() { + if ! command -v "$1" >/dev/null 2>&1; then + echo -e "${RED}Error: '$1' is required but was not found.${NC}" >&2 + exit 1 + fi +} + +detect_target() { + local raw_os raw_arch os arch + raw_os="$(uname -s)" + raw_arch="$(uname -m)" + + case "$raw_os" in + Darwin*) os="darwin" ;; + Linux*) os="linux" ;; + MINGW* | MSYS* | CYGWIN*) os="windows" ;; + *) + echo -e "${RED}Error: unsupported OS '$raw_os'.${NC}" >&2 + exit 1 + ;; + esac + + case "$raw_arch" in + x86_64 | amd64) arch="amd64" ;; + arm64 | aarch64) arch="arm64" ;; + *) + echo -e "${RED}Error: unsupported architecture '$raw_arch'.${NC}" >&2 + exit 1 + ;; + esac + + if [[ "$os" == "darwin" && "$arch" == "amd64" ]]; then + local translated + translated="$(sysctl -n sysctl.proc_translated 2>/dev/null || echo 0)" + if [[ "$translated" == "1" ]]; then + arch="arm64" + fi + fi + + echo "${os}_${arch}" +} + +is_musl_linux() { + [[ -f /etc/alpine-release ]] && return 0 + command -v ldd >/dev/null 2>&1 && ldd --version 2>&1 | grep -qi musl +} + +latest_version() { + curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" | + sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p' | + head -n 1 +} + +check_installed_version() { + local version="$1" + local bin="${INSTALL_DIR}/${APP}" + local installed_version + + if [[ ! -x "$bin" ]]; then + return 0 + fi + + installed_version="$("$bin" --version 2>/dev/null || true)" + if [[ "$installed_version" == "$version" ]]; then + say "${MUTED}Version ${NC}${version}${MUTED} is already installed at ${NC}${bin}${MUTED}.${NC}" + exit 0 + fi +} + +checksum_for() { + local file="$1" + local checksums="$2" + awk -v file="$file" '$2 == file { print $1 }' "$checksums" +} + +verify_checksum() { + local file="$1" + local checksums="$2" + local expected actual + + expected="$(checksum_for "$(basename "$file")" "$checksums")" + if [[ -z "$expected" ]]; then + say "${MUTED}No checksum found for $(basename "$file"); skipping verification.${NC}" + return 0 + fi + + if command -v sha256sum >/dev/null 2>&1; then + actual="$(sha256sum "$file" | awk '{ print $1 }')" + elif command -v shasum >/dev/null 2>&1; then + actual="$(shasum -a 256 "$file" | awk '{ print $1 }')" + else + say "${MUTED}No sha256 tool found; skipping checksum verification.${NC}" + return 0 + fi + + if [[ "$actual" != "$expected" ]]; then + echo -e "${RED}Error: checksum verification failed for $(basename "$file").${NC}" >&2 + exit 1 + fi +} + +install_from_binary() { + local ext="$1" + + if [[ ! -f "$BINARY_PATH" ]]; then + echo -e "${RED}Error: binary not found at '$BINARY_PATH'.${NC}" >&2 + exit 1 + fi + + mkdir -p "$INSTALL_DIR" + cp "$BINARY_PATH" "${INSTALL_DIR}/${APP}${ext}" + chmod 755 "${INSTALL_DIR}/${APP}${ext}" +} + +download_and_install() { + local target="$1" + local ext="$2" + local version filename base_url tmp_dir archive checksums source_dir companion_ext + + need curl + need tar + need awk + need sed + need head + + version="${REQUESTED_VERSION#v}" + if [[ -z "$version" ]]; then + version="$(latest_version)" + fi + if [[ -z "$version" ]]; then + echo -e "${RED}Error: failed to resolve the latest Supabase CLI version.${NC}" >&2 + exit 1 + fi + check_installed_version "$version" + + filename="supabase_${version}_${target}.tar.gz" + if [[ "$target" == linux_* ]] && is_musl_linux; then + filename="supabase_${version}_${target}.apk" + fi + + base_url="https://github.com/${REPO}/releases/download/v${version}" + tmp_dir="$(mktemp -d "${TMPDIR:-/tmp}/supabase-install.XXXXXX")" + archive="${tmp_dir}/${filename}" + checksums="${tmp_dir}/checksums.txt" + source_dir="$tmp_dir" + + trap 'rm -rf "$tmp_dir"; trap - RETURN' RETURN + + say "${MUTED}Installing ${NC}${APP}${MUTED} ${version} for ${target}.${NC}" + curl -fL --progress-bar "${base_url}/${filename}" -o "$archive" + if curl -fsSL "${base_url}/checksums.txt" -o "$checksums"; then + verify_checksum "$archive" "$checksums" + else + say "${MUTED}Could not download checksums.txt; skipping verification.${NC}" + fi + + tar -xzf "$archive" -C "$tmp_dir" + if [[ "$filename" == *.apk ]]; then + source_dir="${tmp_dir}/usr/bin" + fi + + if [[ ! -f "${source_dir}/${APP}${ext}" ]]; then + echo -e "${RED}Error: archive did not contain ${APP}${ext}.${NC}" >&2 + exit 1 + fi + + mkdir -p "$INSTALL_DIR" + mv "${source_dir}/${APP}${ext}" "${INSTALL_DIR}/${APP}${ext}" + chmod 755 "${INSTALL_DIR}/${APP}${ext}" + + companion_ext="$ext" + if [[ -f "${source_dir}/${APP}-go${companion_ext}" ]]; then + mv "${source_dir}/${APP}-go${companion_ext}" "${INSTALL_DIR}/${APP}-go${companion_ext}" + chmod 755 "${INSTALL_DIR}/${APP}-go${companion_ext}" + fi +} + +path_command_for_shell() { + local shell_name="$1" + case "$shell_name" in + fish) echo "fish_add_path $INSTALL_DIR" ;; + *) echo "export PATH=\"$INSTALL_DIR:\$PATH\"" ;; + esac +} + +config_file_for_shell() { + local shell_name="$1" + case "$shell_name" in + fish) echo "${XDG_CONFIG_HOME:-"$HOME/.config"}/fish/config.fish" ;; + zsh) echo "${ZDOTDIR:-"$HOME"}/.zshrc" ;; + bash) echo "$HOME/.bashrc" ;; + *) echo "$HOME/.profile" ;; + esac +} + +add_to_path() { + local shell_name config_file command + + if [[ "$NO_MODIFY_PATH" == "true" ]]; then + return 0 + fi + + if [[ ":$PATH:" == *":$INSTALL_DIR:"* ]]; then + return 0 + fi + + shell_name="$(basename "${SHELL:-sh}")" + config_file="$(config_file_for_shell "$shell_name")" + command="$(path_command_for_shell "$shell_name")" + + mkdir -p "$(dirname "$config_file")" + touch "$config_file" + + if grep -Fxq "$command" "$config_file"; then + return 0 + fi + + { + echo "" + echo "# Supabase CLI" + echo "$command" + } >>"$config_file" + + say "${MUTED}Added ${NC}${APP}${MUTED} to PATH in ${NC}${config_file}${MUTED}.${NC}" +} + +target="$(detect_target)" +binary_ext="" +if [[ "$target" == windows_* ]]; then + binary_ext=".exe" +fi + +if [[ -n "$BINARY_PATH" ]]; then + install_from_binary "$binary_ext" +else + download_and_install "$target" "$binary_ext" +fi + +add_to_path + +if [[ "${GITHUB_ACTIONS:-}" == "true" && -n "${GITHUB_PATH:-}" ]]; then + echo "$INSTALL_DIR" >>"$GITHUB_PATH" +fi + +say "" +say "${GREEN}Supabase CLI installed to ${INSTALL_DIR}/${APP}${binary_ext}.${NC}" +say "Run '${APP} --version' to verify the installation." +if [[ ":$PATH:" != *":$INSTALL_DIR:"* ]]; then + say "${MUTED}Open a new terminal or run: export PATH=\"${INSTALL_DIR}:\$PATH\"${NC}" +fi diff --git a/package.json b/package.json index 7845c78455..9f1662441e 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@swc-node/register": "catalog:", "@swc/core": "catalog:", "nx": "catalog:", + "pkg-pr-new": "0.0.75", "verdaccio": "^6.7.2" } } diff --git a/packages/stack/src/StackBuilder.ts b/packages/stack/src/StackBuilder.ts index 504f8d57d6..9975ca4fb0 100644 --- a/packages/stack/src/StackBuilder.ts +++ b/packages/stack/src/StackBuilder.ts @@ -27,7 +27,11 @@ import { makePostgresService, makePostgresServiceDocker } from "./services/postg import { makePostgrestService, makePostgrestServiceDocker } from "./services/postgrest.ts"; import { makeRealtimeServiceDocker } from "./services/realtime.ts"; import { type ServiceDependency } from "./services/service-utils.ts"; -import { makeStorageServiceDocker } from "./services/storage.ts"; +import { + LOCAL_S3_PROTOCOL_ACCESS_KEY_ID, + LOCAL_S3_PROTOCOL_ACCESS_KEY_SECRET, + makeStorageServiceDocker, +} from "./services/storage.ts"; import { makeStudioServiceDocker } from "./services/studio.ts"; import { makeVectorServiceDocker } from "./services/vector.ts"; import type { PreparedStackArtifacts } from "./StackPreparation.ts"; @@ -879,6 +883,8 @@ export class StackBuilder extends Context.Service< pgmetaUrl: pgmetaConfig === false ? "" : `http://${serviceHost}:${pgmetaConfig.port}`, publishableKey: config.publishableKey, secretKey: config.secretKey, + s3ProtocolAccessKeyId: LOCAL_S3_PROTOCOL_ACCESS_KEY_ID, + s3ProtocolAccessKeySecret: LOCAL_S3_PROTOCOL_ACCESS_KEY_SECRET, jwtSecret: config.jwtSecret, analyticsEnabled: config.analytics !== false, analyticsBackend: config.analytics !== false ? config.analytics.backend : "postgres", diff --git a/packages/stack/src/services/services.unit.test.ts b/packages/stack/src/services/services.unit.test.ts index 6ddb1354c7..2db96bb9af 100644 --- a/packages/stack/src/services/services.unit.test.ts +++ b/packages/stack/src/services/services.unit.test.ts @@ -14,6 +14,8 @@ import { import { makePostgresService, makePostgresServiceDocker } from "./postgres.ts"; import { makePostgrestService } from "./postgrest.ts"; import { makePoolerServiceDocker, poolerContainerPorts } from "./pooler.ts"; +import { LOCAL_S3_PROTOCOL_ACCESS_KEY_ID, LOCAL_S3_PROTOCOL_ACCESS_KEY_SECRET } from "./storage.ts"; +import { makeStudioServiceDocker } from "./studio.ts"; import { makeVectorServiceDocker } from "./vector.ts"; import { DEFAULT_VERSIONS, dockerImageForService } from "../versions.ts"; @@ -81,6 +83,39 @@ describe("analyticsDockerRuntimeNetwork", () => { }); }); +describe("makeStudioServiceDocker", () => { + it("injects legacy keys, opaque keys, and S3 protocol credentials", () => { + const def = makeStudioServiceDocker({ + image: dockerImageForService("studio", DEFAULT_VERSIONS.studio), + apiPort: API_PORT, + port: 54323, + apiUrl: "http://host.docker.internal:54321", + publicApiUrl: "http://127.0.0.1:54321", + pgmetaUrl: "http://host.docker.internal:54322", + publishableKey: "sb_publishable_test", + secretKey: "sb_secret_test", + s3ProtocolAccessKeyId: LOCAL_S3_PROTOCOL_ACCESS_KEY_ID, + s3ProtocolAccessKeySecret: LOCAL_S3_PROTOCOL_ACCESS_KEY_SECRET, + jwtSecret: JWT_SECRET, + analyticsEnabled: true, + analyticsBackend: "postgres", + analyticsUrl: "http://host.docker.internal:54327", + analyticsApiKey: "test-api-key", + networkArgs: ["-p", "54323:54323"], + dependencies: [{ service: "pgmeta", condition: "healthy" }], + }); + + expect(def.args).toContain("SUPABASE_ANON_KEY=sb_publishable_test"); + expect(def.args).toContain("SUPABASE_SERVICE_KEY=sb_secret_test"); + expect(def.args).toContain("SUPABASE_PUBLISHABLE_KEY=sb_publishable_test"); + expect(def.args).toContain("SUPABASE_SECRET_KEY=sb_secret_test"); + expect(def.args).toContain(`S3_PROTOCOL_ACCESS_KEY_ID=${LOCAL_S3_PROTOCOL_ACCESS_KEY_ID}`); + expect(def.args).toContain( + `S3_PROTOCOL_ACCESS_KEY_SECRET=${LOCAL_S3_PROTOCOL_ACCESS_KEY_SECRET}`, + ); + }); +}); + describe("makePostgresService (dockerAccessible)", () => { it("creates per-run pg_hba.conf instead of mutating shared cache", () => { const tempDir = mkdtempSync(path.join(tmpdir(), "stack-postgres-service-")); diff --git a/packages/stack/src/services/storage.ts b/packages/stack/src/services/storage.ts index beb4f3b88b..056a27605e 100644 --- a/packages/stack/src/services/storage.ts +++ b/packages/stack/src/services/storage.ts @@ -24,6 +24,9 @@ interface DockerStorageOptions { const STORAGE_DATA_DIR = "/var/lib/storage"; +export const LOCAL_S3_PROTOCOL_ACCESS_KEY_ID = "local"; +export const LOCAL_S3_PROTOCOL_ACCESS_KEY_SECRET = "local-secret"; + const orphanCleanup = (opts: DockerStorageOptions) => opts.cleanupDataDirOnExit ? removePathOnOrphanCleanup(opts.dataDir, { recursive: true }) : []; @@ -66,8 +69,8 @@ export const makeStorageServiceDocker = (opts: DockerStorageOptions): ServiceDef IMGPROXY_URL: opts.imgproxyUrl, TUS_URL_PATH: "/storage/v1/upload/resumable", S3_PROTOCOL_ENABLED: String(opts.s3ProtocolEnabled), - S3_PROTOCOL_ACCESS_KEY_ID: "local", - S3_PROTOCOL_ACCESS_KEY_SECRET: "local-secret", + S3_PROTOCOL_ACCESS_KEY_ID: LOCAL_S3_PROTOCOL_ACCESS_KEY_ID, + S3_PROTOCOL_ACCESS_KEY_SECRET: LOCAL_S3_PROTOCOL_ACCESS_KEY_SECRET, S3_PROTOCOL_PREFIX: "/storage/v1", UPLOAD_FILE_SIZE_LIMIT: "52428800000", UPLOAD_FILE_SIZE_LIMIT_STANDARD: "5242880000", diff --git a/packages/stack/src/services/studio.ts b/packages/stack/src/services/studio.ts index 18bc9275d4..7521fce3c7 100644 --- a/packages/stack/src/services/studio.ts +++ b/packages/stack/src/services/studio.ts @@ -10,6 +10,8 @@ interface DockerStudioOptions { readonly pgmetaUrl: string; readonly publishableKey: string; readonly secretKey: string; + readonly s3ProtocolAccessKeyId: string; + readonly s3ProtocolAccessKeySecret: string; readonly jwtSecret: string; readonly analyticsEnabled: boolean; readonly analyticsBackend: "postgres" | "bigquery"; @@ -48,6 +50,10 @@ export const makeStudioServiceDocker = (opts: DockerStudioOptions): ServiceDef = AUTH_JWT_SECRET: opts.jwtSecret, SUPABASE_ANON_KEY: opts.publishableKey, SUPABASE_SERVICE_KEY: opts.secretKey, + SUPABASE_PUBLISHABLE_KEY: opts.publishableKey, + SUPABASE_SECRET_KEY: opts.secretKey, + S3_PROTOCOL_ACCESS_KEY_ID: opts.s3ProtocolAccessKeyId, + S3_PROTOCOL_ACCESS_KEY_SECRET: opts.s3ProtocolAccessKeySecret, LOGFLARE_PRIVATE_ACCESS_TOKEN: opts.analyticsApiKey, LOGFLARE_URL: opts.analyticsUrl, NEXT_PUBLIC_ENABLE_LOGS: String(opts.analyticsEnabled), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ef026fe268..7a114320de 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -74,12 +74,21 @@ importers: nx: specifier: 'catalog:' version: 22.7.5(@swc-node/register@1.11.1(@swc/core@1.15.40)(@swc/types@0.1.26)(typescript@6.0.3))(@swc/core@1.15.40) + pkg-pr-new: + specifier: 0.0.75 + version: 0.0.75 verdaccio: specifier: ^6.7.2 version: 6.7.2(typanion@3.14.0) apps/cli: devDependencies: + '@anthropic-ai/claude-agent-sdk': + specifier: ^0.3.146 + version: 0.3.146(@anthropic-ai/sdk@0.97.1(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3) + '@anthropic-ai/sdk': + specifier: ^0.97.1 + version: 0.97.1(zod@4.4.3) '@clack/prompts': specifier: ^1.4.0 version: 1.4.0 @@ -92,6 +101,9 @@ importers: '@effect/vitest': specifier: 'catalog:' version: 4.0.0-beta.74(effect@4.0.0-beta.74)(vitest@4.1.7) + '@modelcontextprotocol/sdk': + specifier: ^1.29.0 + version: 1.29.0(zod@4.4.3) '@napi-rs/keyring': specifier: ^1.3.0 version: 1.3.0 @@ -524,6 +536,67 @@ packages: resolution: {integrity: sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA==} engines: {node: '>=18'} + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.146': + resolution: {integrity: sha512-0IIvlEaenq2CRSVx5Bo5BaCtHQXS87GancM35WKEYveGVLn6DI+5G7ikYuTE4AKRPkMnogFtY4BJt6LulWGj+A==} + cpu: [arm64] + os: [darwin] + + '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.146': + resolution: {integrity: sha512-Dk5xJ03Ff1JXbMRP1t2wc/TyfY6xF/2Ysp31wMhFPjoNiKSPHMWaIg242+T3CHdxLWmJ8plWHL1HL5cyZ/LCkw==} + cpu: [x64] + os: [darwin] + + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.146': + resolution: {integrity: sha512-QlCid0ucdrmhUAOewfQjaofN2wlokWcfFTxSFePTSj1umk35JO7TDFP700F7jU49r1fPWIdvJpPwWGyB0DeFPA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.146': + resolution: {integrity: sha512-mzBXDDWWBAC/vDtAYpO1G/dq5QvJtYSPXsqcb+sNdcDhiuf4IYnYp7ytRncYlsUNDkLmX6Gk2jkWAHUUA2Lozg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.146': + resolution: {integrity: sha512-E3coK1ThQT08KIX80RLcsq7DWXFllCKOzoOe32it/bdtY56TBgPY9xemwXhIJ+cVBHTI9/MpBSIlKBcFCt+yQA==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.146': + resolution: {integrity: sha512-B2baXU1tCBT5CVlD7jJMKjpC4xdO45NUIWpqImmwuOfKvlM/PITjyTXyTY662mGZf1dBmdqBBsqirwFH/jhi8Q==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.146': + resolution: {integrity: sha512-CIwQxGX2r/yWpjCJ6ahB3smKXhghWgGTxL98+LGW52TUwqTiBnlNrH9DPqqgv1/+Hyquw6xfLrKU+StyfMgiLw==} + cpu: [arm64] + os: [win32] + + '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.146': + resolution: {integrity: sha512-qmxrsyaqA8s4HShqJls7ZCRjdoqN66Jo/hbjQNB3uHepD8tEO1iD19aPV4+osdLT7feMkhDBfLT07Q30R2NB5w==} + cpu: [x64] + os: [win32] + + '@anthropic-ai/claude-agent-sdk@0.3.146': + resolution: {integrity: sha512-hK9/Ng+hOyexUemTxdIUsSWJ9o2LFi2YNWzHwz8/YMCohUYOnFMZkBiENvUAb0WIc5hieOyBZrOIlg5OewuJMg==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@anthropic-ai/sdk': '>=0.93.0' + '@modelcontextprotocol/sdk': ^1.29.0 + zod: ^4.0.0 + + '@anthropic-ai/sdk@0.97.1': + resolution: {integrity: sha512-wOf7AUeJPitcVpvKO4UMu63mWH5SaVipkGd7OOQJt/G6VYGlV8D2Gp9dLxOrttDJh/9gqPqdaBwDGcBevumeAg==} + hasBin: true + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -579,6 +652,10 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/runtime@7.29.2': + resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} + engines: {node: '>=6.9.0'} + '@babel/template@7.29.7': resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} @@ -838,6 +915,12 @@ packages: tailwindcss: optional: true + '@hono/node-server@1.19.14': + resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + '@img/colour@1.1.0': resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} @@ -1021,6 +1104,16 @@ packages: '@mdx-js/mdx@3.1.1': resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==} + '@modelcontextprotocol/sdk@1.29.0': + resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': resolution: {integrity: sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==} cpu: [arm64] @@ -2475,6 +2568,9 @@ packages: resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} + '@stablelib/base64@1.0.1': + resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -2868,6 +2964,10 @@ packages: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -2890,6 +2990,14 @@ packages: resolution: {integrity: sha512-gOsf2YwSlleG6IjRYG2A7k0HmBMEo6qVNk9Bp/EaLgAJT5ngH6PXbqa4ItvnEwCm/velL5jAnQgsHsWnjhGmvw==} engines: {node: '>=18'} + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + ajv@8.18.0: resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} @@ -3036,6 +3144,10 @@ packages: resolution: {integrity: sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + body-parser@2.2.2: + resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} + engines: {node: '>=18'} + bottleneck@2.19.5: resolution: {integrity: sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==} @@ -3267,6 +3379,10 @@ packages: resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} engines: {node: '>= 0.6'} + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + content-type@1.0.5: resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} engines: {node: '>= 0.6'} @@ -3307,6 +3423,10 @@ packages: cookie-signature@1.0.7: resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==} + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + cookie@0.7.2: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} @@ -3610,6 +3730,14 @@ packages: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} + eventsource-parser@3.0.8: + resolution: {integrity: sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + execa@8.0.1: resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} engines: {node: '>=16.17'} @@ -3625,10 +3753,20 @@ packages: express-rate-limit@5.5.1: resolution: {integrity: sha512-MTjE2eIbHv5DyfuFz4zLYWxpqVhEhkTiwFGuB74Q9CSou2WHO52nlE5y3Zlg6SIsiYUIPj6ifFxnkPz6O3sIUg==} + express-rate-limit@8.5.2: + resolution: {integrity: sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + express@4.22.1: resolution: {integrity: sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==} engines: {node: '>= 0.10.0'} + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} @@ -3650,6 +3788,9 @@ packages: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} + fast-sha256@1.3.0: + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + fast-string-truncated-width@3.0.3: resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} @@ -3697,6 +3838,10 @@ packages: resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==} engines: {node: '>= 0.8'} + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + find-my-way-ts@0.1.6: resolution: {integrity: sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==} @@ -3762,6 +3907,10 @@ packages: resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} engines: {node: '>= 0.6'} + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} @@ -4028,6 +4177,10 @@ packages: highlight.js@10.7.3: resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} + hono@4.12.21: + resolution: {integrity: sha512-uV63apnb0kyPtAUwoWgaGh9HyIFcv8lgmzPZSiTBQAFOFGIzka5EZ1dZocmGnn0XdX0+XTqJ6Tqv7selMuGLRQ==} + engines: {node: '>=16.9.0'} + hook-std@4.0.0: resolution: {integrity: sha512-IHI4bEVOt3vRUDJ+bFA9VUJlo7SzvFARPNLw75pqSmAOP2HmTWfFJtPvLBrDrlgjEYXY9zs7SFdHPQaJShkSCQ==} engines: {node: '>=20'} @@ -4092,6 +4245,10 @@ packages: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -4159,6 +4316,10 @@ packages: resolution: {integrity: sha512-EZBErytyVovD8f6pDfG3Kb37N6Y3lmDA9NNj+4+IP13CzzHGeX+OyeRM2Um13khRzoBSzzL+5lVnCX8V2RLeMg==} engines: {node: '>=12.22.0'} + ip-address@10.2.0: + resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} + engines: {node: '>= 12'} + ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -4230,6 +4391,9 @@ packages: is-promise@2.2.2: resolution: {integrity: sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==} + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-stream@3.0.0: resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -4286,6 +4450,9 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + jose@6.2.3: + resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -4310,9 +4477,16 @@ packages: json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + json-schema-to-ts@3.1.1: + resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} + engines: {node: '>=16'} + json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json-schema@0.4.0: resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} @@ -4612,6 +4786,10 @@ packages: resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} engines: {node: '>= 0.6'} + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + meow@13.2.0: resolution: {integrity: sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==} engines: {node: '>=18'} @@ -4619,6 +4797,10 @@ packages: merge-descriptors@1.0.3: resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} @@ -4751,6 +4933,10 @@ packages: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + mime@1.6.0: resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} engines: {node: '>=4'} @@ -4850,6 +5036,10 @@ packages: resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==} engines: {node: '>= 0.6'} + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} @@ -5204,6 +5394,9 @@ packages: path-to-regexp@0.1.13: resolution: {integrity: sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==} + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} @@ -5249,10 +5442,18 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + pkg-conf@2.1.0: resolution: {integrity: sha512-C+VUP+8jis7EsQZIhDYmS5qlNtjv2yP4SNtjXK9AP1ZcTRlnSfuumaTnRfYZnYgUUYVIKqL0fRvmUGDV2fmp6g==} engines: {node: '>=4'} + pkg-pr-new@0.0.75: + resolution: {integrity: sha512-u9mdErTewKSMsr+ceCt8VcNuNP0ro5AXiPXhUVApuEyqr2Zlvt+DdCFBcm+yGWN8mhOdZJ27meIDbnoZgfzpOw==} + hasBin: true + postcss@8.4.31: resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} engines: {node: ^10 || ^12 || >=14} @@ -5339,6 +5540,10 @@ packages: resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==} engines: {node: '>= 0.8'} + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + rc@1.2.8: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true @@ -5531,6 +5736,10 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -5588,10 +5797,18 @@ packages: resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} engines: {node: '>= 0.8.0'} + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + serve-static@1.16.3: resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} engines: {node: '>= 0.8.0'} + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} @@ -5725,6 +5942,9 @@ packages: standard-as-callback@2.1.0: resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + standardwebhooks@1.0.0: + resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} @@ -5943,6 +6163,9 @@ packages: truncate-utf8-bytes@1.0.2: resolution: {integrity: sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==} + ts-algebra@2.0.0: + resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} + tsconfig-paths@4.2.0: resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==} engines: {node: '>=6'} @@ -5983,6 +6206,10 @@ packages: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} engines: {node: '>= 0.6'} + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + typescript@6.0.3: resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} engines: {node: '>=14.17'} @@ -6362,6 +6589,11 @@ packages: yoga-layout@3.2.1: resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==} + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} @@ -6391,6 +6623,52 @@ snapshots: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.146': + optional: true + + '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.146': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.146': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.146': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.146': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.146': + optional: true + + '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.146': + optional: true + + '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.146': + optional: true + + '@anthropic-ai/claude-agent-sdk@0.3.146(@anthropic-ai/sdk@0.97.1(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3)': + dependencies: + '@anthropic-ai/sdk': 0.97.1(zod@4.4.3) + '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) + zod: 4.4.3 + optionalDependencies: + '@anthropic-ai/claude-agent-sdk-darwin-arm64': 0.3.146 + '@anthropic-ai/claude-agent-sdk-darwin-x64': 0.3.146 + '@anthropic-ai/claude-agent-sdk-linux-arm64': 0.3.146 + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl': 0.3.146 + '@anthropic-ai/claude-agent-sdk-linux-x64': 0.3.146 + '@anthropic-ai/claude-agent-sdk-linux-x64-musl': 0.3.146 + '@anthropic-ai/claude-agent-sdk-win32-arm64': 0.3.146 + '@anthropic-ai/claude-agent-sdk-win32-x64': 0.3.146 + + '@anthropic-ai/sdk@0.97.1(zod@4.4.3)': + dependencies: + json-schema-to-ts: 3.1.1 + standardwebhooks: 1.0.0 + optionalDependencies: + zod: 4.4.3 + '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -6468,6 +6746,8 @@ snapshots: dependencies: '@babel/types': 7.29.7 + '@babel/runtime@7.29.2': {} + '@babel/template@7.29.7': dependencies: '@babel/code-frame': 7.29.7 @@ -6570,7 +6850,6 @@ snapshots: dependencies: '@emnapi/wasi-threads': 1.2.1 tslib: 2.8.1 - optional: true '@emnapi/core@1.4.5': dependencies: @@ -6580,7 +6859,6 @@ snapshots: '@emnapi/runtime@1.10.0': dependencies: tslib: 2.8.1 - optional: true '@emnapi/runtime@1.4.5': dependencies: @@ -6593,7 +6871,6 @@ snapshots: '@emnapi/wasi-threads@1.2.1': dependencies: tslib: 2.8.1 - optional: true '@esbuild/aix-ppc64@0.28.0': optional: true @@ -6692,6 +6969,10 @@ snapshots: '@fumadocs/tailwind@0.0.5': {} + '@hono/node-server@1.19.14(hono@4.12.21)': + dependencies: + hono: 4.12.21 + '@img/colour@1.1.0': optional: true @@ -6844,6 +7125,28 @@ snapshots: transitivePeerDependencies: - supports-color + '@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)': + dependencies: + '@hono/node-server': 1.19.14(hono@4.12.21) + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.8 + express: 5.2.1 + express-rate-limit: 8.5.2(express@5.2.1) + hono: 4.12.21 + jose: 6.2.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.4.3 + zod-to-json-schema: 3.25.2(zod@4.4.3) + transitivePeerDependencies: + - supports-color + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': optional: true @@ -6915,8 +7218,8 @@ snapshots: '@napi-rs/wasm-runtime@0.2.4': dependencies: - '@emnapi/core': 1.4.5 - '@emnapi/runtime': 1.4.5 + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 '@tybys/wasm-util': 0.9.0 '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': @@ -7935,6 +8238,8 @@ snapshots: '@sindresorhus/merge-streams@4.0.0': {} + '@stablelib/base64@1.0.1': {} + '@standard-schema/spec@1.1.0': {} '@supabase/auth-js@2.106.2': @@ -8406,6 +8711,11 @@ snapshots: mime-types: 2.1.35 negotiator: 0.6.3 + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + acorn-jsx@5.3.2(acorn@8.16.0): dependencies: acorn: 8.16.0 @@ -8425,6 +8735,10 @@ snapshots: clean-stack: 5.3.0 indent-string: 5.0.0 + ajv-formats@3.0.1(ajv@8.18.0): + optionalDependencies: + ajv: 8.18.0 + ajv@8.18.0: dependencies: fast-deep-equal: 3.1.3 @@ -8545,6 +8859,20 @@ snapshots: transitivePeerDependencies: - supports-color + body-parser@2.2.2: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + on-finished: 2.4.1 + qs: 6.14.2 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + bottleneck@2.19.5: {} brace-expansion@2.1.1: @@ -8784,6 +9112,8 @@ snapshots: dependencies: safe-buffer: 5.2.1 + content-disposition@1.1.0: {} + content-type@1.0.5: {} content-type@2.0.0: {} @@ -8815,6 +9145,8 @@ snapshots: cookie-signature@1.0.7: {} + cookie-signature@1.2.2: {} + cookie@0.7.2: {} core-util-is@1.0.2: {} @@ -9114,6 +9446,12 @@ snapshots: events@3.3.0: {} + eventsource-parser@3.0.8: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.0.8 + execa@8.0.1: dependencies: cross-spawn: 7.0.6 @@ -9145,6 +9483,11 @@ snapshots: express-rate-limit@5.5.1: {} + express-rate-limit@8.5.2(express@5.2.1): + dependencies: + express: 5.2.1 + ip-address: 10.2.0 + express@4.22.1: dependencies: accepts: 1.3.8 @@ -9181,6 +9524,39 @@ snapshots: transitivePeerDependencies: - supports-color + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.2.2 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.14.2 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + extend@3.0.2: {} extsprintf@1.3.0: {} @@ -9201,6 +9577,8 @@ snapshots: merge2: 1.4.1 micromatch: 4.0.8 + fast-sha256@1.3.0: {} + fast-string-truncated-width@3.0.3: {} fast-string-width@3.0.2: @@ -9253,6 +9631,17 @@ snapshots: transitivePeerDependencies: - supports-color + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + find-my-way-ts@0.1.6: {} find-up-simple@1.0.1: {} @@ -9299,6 +9688,8 @@ snapshots: fresh@0.5.2: {} + fresh@2.0.0: {} + fs-constants@1.0.0: {} fs-extra@11.3.5: @@ -9649,6 +10040,8 @@ snapshots: highlight.js@10.7.3: {} + hono@4.12.21: {} + hook-std@4.0.0: {} hosted-git-info@7.0.2: @@ -9717,6 +10110,10 @@ snapshots: dependencies: safer-buffer: 2.1.2 + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + ieee754@1.2.1: {} ignore@5.3.2: {} @@ -9802,6 +10199,8 @@ snapshots: transitivePeerDependencies: - supports-color + ip-address@10.2.0: {} + ipaddr.js@1.9.1: {} is-alphabetical@2.0.1: {} @@ -9847,6 +10246,8 @@ snapshots: is-promise@2.2.2: {} + is-promise@4.0.0: {} + is-stream@3.0.0: {} is-stream@4.0.1: {} @@ -9892,6 +10293,8 @@ snapshots: jiti@2.7.0: {} + jose@6.2.3: {} + js-tokens@4.0.0: {} js-yaml@4.1.1: @@ -9908,8 +10311,15 @@ snapshots: json-parse-even-better-errors@2.3.1: {} + json-schema-to-ts@3.1.1: + dependencies: + '@babel/runtime': 7.29.2 + ts-algebra: 2.0.0 + json-schema-traverse@1.0.0: {} + json-schema-typed@8.0.2: {} + json-schema@0.4.0: {} json-stringify-safe@5.0.1: {} @@ -9939,7 +10349,7 @@ snapshots: lodash.isstring: 4.0.1 lodash.once: 4.1.1 ms: 2.1.3 - semver: 7.8.0 + semver: 7.8.1 jsprim@2.0.2: dependencies: @@ -10310,10 +10720,14 @@ snapshots: media-typer@0.3.0: {} + media-typer@1.1.0: {} + meow@13.2.0: {} merge-descriptors@1.0.3: {} + merge-descriptors@2.0.0: {} + merge-stream@2.0.0: {} merge2@1.4.1: {} @@ -10597,6 +11011,10 @@ snapshots: dependencies: mime-db: 1.52.0 + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + mime@1.6.0: {} mime@3.0.0: {} @@ -10671,6 +11089,8 @@ snapshots: negotiator@0.6.4: {} + negotiator@1.0.0: {} + neo-async@2.6.2: {} nerf-dart@1.0.0: {} @@ -10928,7 +11348,7 @@ snapshots: bl: 4.1.0 chalk: 4.1.2 cli-cursor: 3.1.0 - cli-spinners: 2.6.1 + cli-spinners: 2.9.2 is-interactive: 1.0.0 log-symbols: 4.1.0 strip-ansi: 6.0.1 @@ -11125,6 +11545,8 @@ snapshots: path-to-regexp@0.1.13: {} + path-to-regexp@8.4.2: {} + path-type@4.0.0: {} pathe@2.0.3: {} @@ -11172,11 +11594,15 @@ snapshots: pirates@4.0.7: {} + pkce-challenge@5.0.1: {} + pkg-conf@2.1.0: dependencies: find-up: 2.1.0 load-json-file: 4.0.0 + pkg-pr-new@0.0.75: {} + postcss@8.4.31: dependencies: nanoid: 3.3.12 @@ -11257,6 +11683,13 @@ snapshots: iconv-lite: 0.4.24 unpipe: 1.0.0 + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + unpipe: 1.0.0 + rc@1.2.8: dependencies: deep-extend: 0.6.0 @@ -11531,6 +11964,16 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.0.2 '@rolldown/binding-win32-x64-msvc': 1.0.2 + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -11615,6 +12058,22 @@ snapshots: transitivePeerDependencies: - supports-color + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + serve-static@1.16.3: dependencies: encodeurl: 2.0.0 @@ -11624,6 +12083,15 @@ snapshots: transitivePeerDependencies: - supports-color + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + setprototypeof@1.2.0: {} sharp@0.34.5: @@ -11795,6 +12263,11 @@ snapshots: standard-as-callback@2.1.0: {} + standardwebhooks@1.0.0: + dependencies: + '@stablelib/base64': 1.0.1 + fast-sha256: 1.3.0 + statuses@2.0.2: {} std-env@4.1.0: {} @@ -12007,6 +12480,8 @@ snapshots: dependencies: utf8-byte-length: 1.0.5 + ts-algebra@2.0.0: {} + tsconfig-paths@4.2.0: dependencies: json5: 2.2.3 @@ -12040,6 +12515,12 @@ snapshots: media-typer: 0.3.0 mime-types: 2.1.35 + type-is@2.1.0: + dependencies: + content-type: 2.0.0 + media-typer: 1.1.0 + mime-types: 3.0.2 + typescript@6.0.3: {} uglify-js@3.19.3: @@ -12388,6 +12869,10 @@ snapshots: yoga-layout@3.2.1: {} + zod-to-json-schema@3.25.2(zod@4.4.3): + dependencies: + zod: 4.4.3 + zod@4.4.3: {} zwitch@2.0.4: {} diff --git a/tools/release/release-notes-prompt.md b/tools/release/release-notes-prompt.md new file mode 100644 index 0000000000..ba68328962 --- /dev/null +++ b/tools/release/release-notes-prompt.md @@ -0,0 +1,164 @@ +## Output + +Generate release notes for **supabase/cli** from the pasted semantic-release block below. +**Replace** the pasted block entirely β€” do not extend it. + +Output **only** the final markdown release notes: no reasoning, no investigation commentary, no +`Now I have everything…`, no ` ```markdown ` wrappers. + +--- + +## Inputs + +``` +REPO: supabase/cli +PRODUCT_NAME: Supabase CLI +AUDIENCE: developers using the Supabase CLI locally and in CI +TONE: clear, direct, lightly informal, no marketing fluff +``` + +**Semantic-release changelog block** (paste between the fences): + +``` +{{PASTE_SEMANTIC_RELEASE_BLOCK_HERE}} +``` + +Example header shape: `# [2.101.0](https://github.com/supabase/cli/compare/v2.100.1...v2.101.0) (2026-05-21)` with `### Bug Fixes` / `### Features` bullets. + +--- + +## Role + +Senior devrel writer for **Supabase CLI**. Translate merged PRs into workflow-focused notes β€” not +PR-title summaries. Answer: **Should I upgrade?** **What's new for me?** **Any gotchas?** + +--- + +## Repo scope (apply first) + +### Two shells β€” only `legacy/` counts + +| Path | Status | +|------|--------| +| `apps/cli/src/legacy/` | What users run as `supabase` today β€” **all user-facing behavior** | +| `apps/cli/src/next/` | v3 / alpha β€” **not user-facing** | + +- **Drop** PRs that only touch `next/` (commands, flags, tests, alpha plumbing): no bullet, **no tail count**, never mention `next/` or v3. +- PRs touching both `legacy/`/`shared/` and `next/`: write **only** the legacy/shared impact. + +### Go β†’ TypeScript port + +Ongoing port: `apps/cli-go/` β†’ `apps/cli/src/legacy/`. Parity PRs are **not** features/fixes. + +- If leaf commands were ported: **one line** under **TypeScript port progress** β€” list leaf commands only (`db diff`, not `db`); behavior matches Go CLI; cite PRs. Omit section if none. +- Port infra (services, tests, parity scripts) β†’ tail count only. +- Port PR that **also** fixes a real bug or adds a non-Go flag β†’ promote that part to Bug fixes / New features; still list the command under port progress. + +### Where user-visible changes usually live + +- `apps/cli/src/legacy/commands/**` β€” behavior, output, flags, errors (beyond pure porting) +- `apps/cli/src/shared/**` β€” telemetry, global flags, output inherited by legacy +- `apps/cli-go/**` β€” while still the production binary +- `packages/cli-*`, `apps/cli/scripts/` β€” install/packaging (homebrew, scoop, build) + +Everything else is usually internal. + +--- + +## Process + +Do not skip investigation β€” titles alone are insufficient. + +1. **Parse** β€” Extract version, compare URL, date, and each PR (title, prefix/scope, number, URL). Semantic-release sections (`### Bug Fixes`, etc.) are **hints only**, not final grouping. + +2. **Prefix triage** (fast pass) + +| Prefix | Action | +|--------|--------| +| `chore:`, `ci:`, `test:` | Tail (open only if title hints user impact) | +| `docs:` | Tail unless user-read docs / in-CLI help | +| `refactor:`, `style:` | Judge | +| `perf:` | Usually investigate | +| `fix:`, `feat:` (+ product scopes `cli`, `db`, `auth`, …) | Investigate | +| `feat!:`, `fix!:`, `BREAKING CHANGE` | Investigate + breaking section | + +Tail PRs count toward "Plus N internal…". **`next/`-only PRs do not.** + +3. **Investigate** each survivor β€” open the PR URL: body (not just title), linked issues (`Closes`/`Fixes`/`Refs`), files changed, labels, `!` / `BREAKING CHANGE`. Unclear after that β†’ `` β€” do not guess. + +4. **User-relevance gate** β€” Would a CLI user notice this in workflow, output, errors, or commands/flags? + - **Yes** β†’ entry + - **No** β†’ tail (e.g. build-time credential injection, CI smoke-test fixes, `next/`-only) + - **Borderline** (e.g. `--version` now correct) β†’ one-liner under Bug fixes, not Highlights + +5. **Classify** β€” Highlights (1–4 lead items), New features, Improvements, Bug fixes, Breaking changes (separate, always if any), TypeScript port progress, Internal (tail only). **Group** related PRs into one bullet with all PR numbers. + +6. **Write entries** β€” `**** β€” . (#1234)` + +Voice: second person, active; lead with benefit; name commands/flags/env vars; short examples when helpful; no marketing filler; never mention `next/`. + +- **Bug fixes:** symptom users saw, not root cause β€” βœ… `` `supabase start` no longer crashes when `[db.pooler]` is missing `` not "Fixed nil pointer in resolver" +- **Breaking:** what's breaking, who's affected, exact migration step + +7. **Intro** β€” 1–3 sentences on the headline. Honest if mostly fixes or grab-bag. Don't lead with port progress unless a command surface meaningfully changed. + +--- + +## Output format + +From the header line extract `VERSION`, `COMPARE_URL`, `DATE`. + +```markdown +## Supabase CLI v β€” + +<1–3 sentence intro> + +### ⚠️ Breaking changes + +- **** β€” . (#1234) + +### Highlights +- **** β€” . (#1234) + +### New features +- **** β€” . (#1234) + +### Improvements +- . (#1234) + +### Bug fixes +- . (#1234) + +### TypeScript port progress + +- **Now served by the TypeScript shell:** ``, ``. Behavior matches the Go CLI. (#1234) + +--- + +Plus N internal improvements and dependency updates. + +**Full changelog:** +``` + +Omit empty sections. + +--- + +## Quick examples + +| Case | ❌ | βœ… | +|------|----|----| +| Feature `feat(db): --linked on db diff` | Added `--linked` flag (#4567) | **`db diff` against your linked project, no Docker** β€” pass `--linked` to diff remote without a local stack; handy in CI (#4567) | +| Bug + issue | Fixed nil pointer in config parser (#5012) | `supabase start` no longer crashes when optional sections like `[db.pooler]` are missing (#5012) | +| 3 PRs, one feature | Three `db lint --json` bullets | **`db lint` machine-readable output** β€” `--json` for CI; empty array when clean (#4801, #4815, #4823) | +| Port only | New native `db diff` implementation | Under **TypeScript port progress** only β€” `db diff`; behavior unchanged (#5314) | +| Port + real bug | (same bullet as port) | **Bug fixes:** `orgs list` returns all orgs, not first 100 (#5318); **Port:** `orgs list` (#5318) | +| `fix(cli):` build inject credentials | (bullet) | Tail only β€” scope `cli` β‰  user impact | +| `feat(next):` only | Any mention | Silent drop | + +--- + +## Avoid + +PR titles verbatim; implementation-first wording; buried breaking changes; vague "various improvements"; +marketing tone; guessing when unclear; port PRs as features; any `next/` / v3 / alpha mention.