diff --git a/.github/workflows/cli-go-codeql.yml b/.github/workflows/cli-go-codeql.yml index b1e1adf123..cf4efe76f6 100644 --- a/.github/workflows/cli-go-codeql.yml +++ b/.github/workflows/cli-go-codeql.yml @@ -67,7 +67,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1 + uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -95,7 +95,7 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1 + uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 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 2844079487..87067ec032 100644 --- a/.github/workflows/cli-go-mirror-image.yml +++ b/.github/workflows/cli-go-mirror-image.yml @@ -34,7 +34,7 @@ jobs: run: | echo "image=${TAG##*/}" >> $GITHUB_OUTPUT - name: configure aws credentials - uses: aws-actions/configure-aws-credentials@517a711dbcd0e402f90c77e7e2f81e849156e31d # v6.2.2 + uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3 with: role-to-assume: ${{ secrets.PROD_AWS_ROLE }} aws-region: us-east-1 diff --git a/.vscode/settings.json b/.vscode/settings.json index 0ff9ffd7c9..0126f496d5 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,6 +2,5 @@ "editor.defaultFormatter": "oxc.oxc-vscode", "editor.formatOnSave": true, "editor.formatOnSaveMode": "file", - "typescript.experimental.useTsgo": true, - "oxc.useExecPath": true, + "typescript.experimental.useTsgo": true } diff --git a/apps/cli-e2e/src/tests/database-core.e2e.test.ts b/apps/cli-e2e/src/tests/database-core.e2e.test.ts index 4ba33c1f79..a0e5c21509 100644 --- a/apps/cli-e2e/src/tests/database-core.e2e.test.ts +++ b/apps/cli-e2e/src/tests/database-core.e2e.test.ts @@ -252,7 +252,17 @@ describe("db push", () => { expect(result.stderr).toContain("connect"); }); - testParity(["db", "push", "--local"]); + // Known parity divergence: Go never emits the Docker-start hint for --local (CLI-1995). + testParity(["db", "push", "--local"], { + normalize: { + stderr: { + stripPatterns: [ + /\nMake sure your local IP is allowed in Network Restrictions and Network Bans\.\n[^\n]*/g, // Expected from Go + /\nMake sure Docker is running, then run: supabase start/g, // Expected from TS + ], + }, + }, + }); }); describe("db push:linked", () => { diff --git a/apps/cli-e2e/src/tests/migrations.e2e.test.ts b/apps/cli-e2e/src/tests/migrations.e2e.test.ts index 45b02ef3bc..12069b0231 100644 --- a/apps/cli-e2e/src/tests/migrations.e2e.test.ts +++ b/apps/cli-e2e/src/tests/migrations.e2e.test.ts @@ -24,6 +24,8 @@ const CONNECT_REFUSED_STDERR_STRIP: readonly RegExp[] = [ /(?<=failed to connect to postgres:).*/g, // Go's SetConnectSuggestion: Network-Restrictions hint + dashboard URL line. /\nMake sure your local IP is allowed in Network Restrictions and Network Bans\.\n[^\n]*/g, + // TS's local-only Docker hint (CLI-1995); Go never emits this for --local. + /\nMake sure Docker is running, then run: supabase start/g, // TS's generic --debug suggestion. /\nTry rerunning the command with --debug to troubleshoot the error\./g, ]; diff --git a/apps/cli-go/go.mod b/apps/cli-go/go.mod index 778b1a731a..0abb427372 100644 --- a/apps/cli-go/go.mod +++ b/apps/cli-go/go.mod @@ -42,7 +42,7 @@ require ( github.com/multigres/multigres v0.0.0-20260126223308-f5a52171bbc4 github.com/oapi-codegen/nullable v1.2.0 github.com/olekukonko/tablewriter v1.1.4 - github.com/posthog/posthog-go v1.19.0 + github.com/posthog/posthog-go v1.21.0 github.com/spf13/afero v1.15.0 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 diff --git a/apps/cli-go/go.sum b/apps/cli-go/go.sum index 43e9aaee08..a1676e32f9 100644 --- a/apps/cli-go/go.sum +++ b/apps/cli-go/go.sum @@ -970,8 +970,8 @@ github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/posthog/posthog-go v1.19.0 h1:GsPilbAfQo8dbIo2yheTZ8pbmE5yYW3G289kXrxZxFA= -github.com/posthog/posthog-go v1.19.0/go.mod h1://M430hNH3e8CDv4i8SJesb26816Mpa6GIZaiP4pNQU= +github.com/posthog/posthog-go v1.21.0 h1:088VokgDXrx8BmDBfIg/XC/yYqc7Y/wDCEl4inmRQxM= +github.com/posthog/posthog-go v1.21.0/go.mod h1://M430hNH3e8CDv4i8SJesb26816Mpa6GIZaiP4pNQU= github.com/prometheus/client_golang v0.9.0-pre1.0.20180209125602-c332b6f63c06/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= diff --git a/apps/cli-go/internal/branches/create/create.go b/apps/cli-go/internal/branches/create/create.go index 8c0ac98c71..1d7ff9aff5 100644 --- a/apps/cli-go/internal/branches/create/create.go +++ b/apps/cli-go/internal/branches/create/create.go @@ -33,8 +33,8 @@ func Run(ctx context.Context, body api.CreateBranchBody, fsys afero.Fs) error { if err != nil { return errors.Errorf("failed to create preview branch: %w", err) } else if resp.JSON201 == nil { - if orgSlug, isGated := utils.SuggestUpgradeOnError(ctx, flags.ProjectRef, "branching_limit", resp.StatusCode()); isGated { - telemetry.TrackUpgradeSuggested(ctx, "branching_limit", orgSlug) + if feature, orgSlug, isGated := utils.SuggestUpgradeOnError(ctx, flags.ProjectRef, "branching_limit", resp.StatusCode(), resp.Body); isGated { + telemetry.TrackUpgradeSuggested(ctx, feature, orgSlug) } return errors.Errorf("unexpected create branch status %d: %s", resp.StatusCode(), string(resp.Body)) } diff --git a/apps/cli-go/internal/branches/create/create_test.go b/apps/cli-go/internal/branches/create/create_test.go index c08a10286c..736ddf2964 100644 --- a/apps/cli-go/internal/branches/create/create_test.go +++ b/apps/cli-go/internal/branches/create/create_test.go @@ -118,4 +118,24 @@ func TestCreateCommand(t *testing.T) { assert.ErrorContains(t, err, "unexpected create branch status 402") assert.Contains(t, utils.CmdSuggestion, "/org/test-org/billing") }) + + t.Run("suggests upgrade from envelope without entitlements round-trip", func(t *testing.T) { + t.Cleanup(apitest.MockPlatformAPI(t)) + t.Cleanup(func() { utils.CmdSuggestion = "" }) + gock.New(utils.DefaultApiHost). + Post("/v1/projects/" + flags.ProjectRef + "/branches"). + Reply(http.StatusPaymentRequired). + JSON(map[string]interface{}{ + "message": "Branching is supported only on the Pro plan or above", + "error": map[string]interface{}{ + "code": "entitlement_required", + "feature": "branching_limit", + "upgrade_url": "https://supabase.com/dashboard/org/acme/billing", + }, + }) + fsys := afero.NewMemMapFs() + err := Run(context.Background(), api.CreateBranchBody{Region: cast.Ptr("sin")}, fsys) + assert.ErrorContains(t, err, "unexpected create branch status 402") + assert.Contains(t, utils.CmdSuggestion, "org/acme/billing") + }) } diff --git a/apps/cli-go/internal/branches/update/update.go b/apps/cli-go/internal/branches/update/update.go index 7abbabb9af..7f926eab97 100644 --- a/apps/cli-go/internal/branches/update/update.go +++ b/apps/cli-go/internal/branches/update/update.go @@ -23,8 +23,8 @@ func Run(ctx context.Context, branchId string, body api.UpdateBranchBody, fsys a if err != nil { return errors.Errorf("failed to update preview branch: %w", err) } else if resp.JSON200 == nil { - if orgSlug, isGated := utils.SuggestUpgradeOnError(ctx, projectRef, "branching_persistent", resp.StatusCode()); isGated { - telemetry.TrackUpgradeSuggested(ctx, "branching_persistent", orgSlug) + if feature, orgSlug, isGated := utils.SuggestUpgradeOnError(ctx, projectRef, "branching_persistent", resp.StatusCode(), resp.Body); isGated { + telemetry.TrackUpgradeSuggested(ctx, feature, orgSlug) } return errors.Errorf("unexpected update branch status %d: %s", resp.StatusCode(), string(resp.Body)) } diff --git a/apps/cli-go/internal/debug/postgres.go b/apps/cli-go/internal/debug/postgres.go index 6e0929e238..3d774cb309 100644 --- a/apps/cli-go/internal/debug/postgres.go +++ b/apps/cli-go/internal/debug/postgres.go @@ -2,12 +2,15 @@ package debug import ( "context" + "crypto/tls" + "encoding/binary" "encoding/json" "errors" "io" "log" "net" "os" + "time" "github.com/jackc/pgproto3/v2" "github.com/jackc/pgx/v4" @@ -16,31 +19,84 @@ import ( type Proxy struct { dialContext func(ctx context.Context, network, addr string) (net.Conn, error) - errChan chan error + // tlsPlan is pgconn's TLS config per attempt, primary first. DialFunc walks it + // so sslmode=allow keeps its plaintext-then-TLS retry, and completes the + // handshake itself, leaving logged frames plaintext in memory but not on the wire. + tlsPlan []*tls.Config + attempts map[string]int + errChan chan error } func NewProxy() Proxy { dialer := net.Dialer{} return Proxy{ dialContext: dialer.DialContext, + attempts: map[string]int{}, errChan: make(chan error, 1), } } func SetupPGX(config *pgx.ConnConfig) { + tlsPlan := []*tls.Config{config.TLSConfig} + for _, fallback := range config.Fallbacks { + if fallback.Host != config.Host { + break + } + // ConnectByUrl drops plaintext fallbacks once the primary is TLS, and it + // runs after this, so apply the same rule here or --debug reinstates them. + if config.TLSConfig != nil && fallback.TLSConfig == nil { + continue + } + tlsPlan = append(tlsPlan, fallback.TLSConfig) + } proxy := Proxy{ dialContext: config.DialFunc, + tlsPlan: tlsPlan, + attempts: map[string]int{}, errChan: make(chan error, 1), } config.DialFunc = proxy.DialFunc + // pgx must not negotiate TLS again over the in-memory pipe; clearing these + // *without* DialFunc's handshake is what downgraded sslmode=require (#5872). config.TLSConfig = nil + // pgconn reads Fallbacks independently of TLSConfig, so a restored sslmode=allow + // would still retry TLS through this plaintext proxy. + for _, fallback := range config.Fallbacks { + fallback.TLSConfig = nil + } +} + +// nextTLSConfig returns the TLS config for this attempt against addr. pgconn dials +// each plan entry once per resolved address, in plan order, so a per-address +// counter replays the same TLS/plaintext sequence it would have used itself. +func (p *Proxy) nextTLSConfig(addr string) *tls.Config { + if p.attempts == nil { + p.attempts = map[string]int{} + } + if len(p.tlsPlan) == 0 { + return nil + } + i := p.attempts[addr] + p.attempts[addr]++ + // pgconn redials an addr for the target_session_attrs retry, so clamp rather + // than fall off the plan into plaintext. + if i >= len(p.tlsPlan) { + i = len(p.tlsPlan) - 1 + } + return p.tlsPlan[i] } func (p *Proxy) DialFunc(ctx context.Context, network, addr string) (net.Conn, error) { + tlsConfig := p.nextTLSConfig(addr) serverConn, err := p.dialContext(ctx, network, addr) if err != nil { return nil, err } + if tlsConfig != nil { + if serverConn, err = startTLS(ctx, serverConn, tlsConfig); err != nil { + return nil, err + } + } const bufSize = 1024 * 1024 ln := bufconn.Listen(bufSize) @@ -73,6 +129,43 @@ func (p *Proxy) DialFunc(ctx context.Context, network, addr string) (net.Conn, e return ln.DialContext(ctx) } +// libpq's SSLRequest message code, PG_PROTOCOL(1234,5679). +const sslRequestCode = 80877103 + +// startTLS mirrors pgconn's own startTLS (pgconn@v1.14.3/pgconn.go:406-419), but +// handshakes eagerly so a refusal or bad certificate fails the dial rather than +// surfacing as a parse error inside the forwarding goroutines. +func startTLS(ctx context.Context, conn net.Conn, tlsConfig *tls.Config) (net.Conn, error) { + // pgconn only watches ctx once DialFunc returns, so bound the negotiation here + // or an endpoint that accepts TCP and never replies hangs the read forever. + if deadline, ok := ctx.Deadline(); ok { + if err := conn.SetDeadline(deadline); err != nil { + conn.Close() + return nil, err + } + defer func() { _ = conn.SetDeadline(time.Time{}) }() + } + if err := binary.Write(conn, binary.BigEndian, []int32{8, sslRequestCode}); err != nil { + conn.Close() + return nil, err + } + response := make([]byte, 1) + if _, err := io.ReadFull(conn, response); err != nil { + conn.Close() + return nil, err + } + if response[0] != 'S' { + conn.Close() + return nil, errors.New("server refused TLS connection") + } + tlsConn := tls.Client(conn, tlsConfig) + if err := tlsConn.HandshakeContext(ctx); err != nil { + conn.Close() + return nil, err + } + return tlsConn, nil +} + type Backend struct { *pgproto3.Backend logger *log.Logger diff --git a/apps/cli-go/internal/debug/postgres_test.go b/apps/cli-go/internal/debug/postgres_test.go index 9f46fd6b84..87a29c3dbe 100644 --- a/apps/cli-go/internal/debug/postgres_test.go +++ b/apps/cli-go/internal/debug/postgres_test.go @@ -2,6 +2,9 @@ package debug import ( "context" + "encoding/binary" + "io" + "net" "testing" "github.com/jackc/pgx/v4" @@ -28,4 +31,35 @@ func TestPostgresProxy(t *testing.T) { assert.NoError(t, err) assert.NoError(t, proxy.Close(ctx)) }) + + t.Run("negotiates TLS itself instead of downgrading", func(t *testing.T) { + config, err := pgx.ParseConfig(postgresUrl + "?sslmode=require") + require.NoError(t, err) + require.NotNil(t, config.TLSConfig) + // Run test + SetupPGX(config) + assert.Nil(t, config.TLSConfig) + for _, fallback := range config.Fallbacks { + assert.Nil(t, fallback.TLSConfig) + } + // A server refusing TLS must fail the dial, not continue in plaintext. + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer ln.Close() + go func() { + conn, err := ln.Accept() + if err != nil { + return + } + defer conn.Close() + request := make([]byte, 8) + if _, err := io.ReadFull(conn, request); err != nil { + return + } + assert.Equal(t, uint32(sslRequestCode), binary.BigEndian.Uint32(request[4:])) + _, _ = conn.Write([]byte("N")) + }() + _, err = config.DialFunc(context.Background(), "tcp", ln.Addr().String()) + assert.ErrorContains(t, err, "server refused TLS connection") + }) } diff --git a/apps/cli-go/internal/functions/serve/templates/main.bundled.js b/apps/cli-go/internal/functions/serve/templates/main.bundled.js index 101a7920cd..79ff317891 100644 --- a/apps/cli-go/internal/functions/serve/templates/main.bundled.js +++ b/apps/cli-go/internal/functions/serve/templates/main.bundled.js @@ -1,6 +1,6 @@ -var h={OK:200,Unauthorized:401,NotFound:404,InternalServerError:500,ServiceUnavailable:503},Z={[h.OK]:"OK",[h.Unauthorized]:"Unauthorized",[h.NotFound]:"Not Found",[h.InternalServerError]:"Internal Server Error",[h.ServiceUnavailable]:"Service Unavailable"};function Ye(e){let t=e.startsWith("/"),r=[];for(let n of e.split("/"))if(!(n===""||n===".")){if(n===".."){r.length>0&&r[r.length-1]!==".."?r.pop():t||r.push("..");continue}r.push(n)}let o=r.join("/");return t?"/"+o:o===""?".":o}function Q(...e){let t=e.filter(r=>r.length>0).join("/");return t===""?".":Ye(t)}function ee(e){if(e.length===0)return".";let t=e.length;for(;t>1&&e[t-1]==="/";)t-=1;let r=e.slice(0,t),o=r.lastIndexOf("/");return o===-1?".":o===0?"/":r.slice(0,o)}function qe(e){return e.replace(/\s/g,t=>`%${t.charCodeAt(0).toString(16).padStart(2,"0").toUpperCase()}`)}function me(e){if(!e.startsWith("/"))throw new TypeError(`Path must be absolute: received "${e}"`);let t=new URL("file:///");return t.pathname=qe(e.replace(/%/g,"%25").replace(/\\/g,"%5C")),t}var L=new TextEncoder,g=new TextDecoder,Ut=2**32;function ye(...e){let t=e.reduce((n,{length:i})=>n+i,0),r=new Uint8Array(t),o=0;for(let n of e)r.set(n,o),o+=n.length;return r}function k(e){let t=new Uint8Array(e.length);for(let r=0;r127)throw new TypeError("non-ASCII string encountered in encode()");t[r]=o}return t}function Se(e){if(Uint8Array.fromBase64)return Uint8Array.fromBase64(e);let t=atob(e),r=new Uint8Array(t.length);for(let o=0;onew TypeError(`CryptoKey does not support this operation, its ${t} must be ${e}`),W=(e,t)=>e.name===t;function je(e){return parseInt(e.name.slice(4),10)}function te(e,t){if(je(e.hash)!==t)throw T(`SHA-${t}`,"algorithm.hash")}function Ze(e){switch(e){case"ES256":return"P-256";case"ES384":return"P-384";case"ES512":return"P-521";default:throw new Error("unreachable")}}function Qe(e,t){if(t&&!e.usages.includes(t))throw new TypeError(`CryptoKey does not support this operation, its usages must include ${t}.`)}function Ee(e,t,r){switch(t){case"HS256":case"HS384":case"HS512":{if(!W(e.algorithm,"HMAC"))throw T("HMAC");te(e.algorithm,parseInt(t.slice(2),10));break}case"RS256":case"RS384":case"RS512":{if(!W(e.algorithm,"RSASSA-PKCS1-v1_5"))throw T("RSASSA-PKCS1-v1_5");te(e.algorithm,parseInt(t.slice(2),10));break}case"PS256":case"PS384":case"PS512":{if(!W(e.algorithm,"RSA-PSS"))throw T("RSA-PSS");te(e.algorithm,parseInt(t.slice(2),10));break}case"Ed25519":case"EdDSA":{if(!W(e.algorithm,"Ed25519"))throw T("Ed25519");break}case"ML-DSA-44":case"ML-DSA-65":case"ML-DSA-87":{if(!W(e.algorithm,t))throw T(t);break}case"ES256":case"ES384":case"ES512":{if(!W(e.algorithm,"ECDSA"))throw T("ECDSA");let o=Ze(t);if(e.algorithm.namedCurve!==o)throw T(o,"algorithm.namedCurve");break}default:throw new TypeError("CryptoKey does not support this operation")}Qe(e,r)}function we(e,t,...r){if(r=r.filter(Boolean),r.length>2){let o=r.pop();e+=`one of type ${r.join(", ")}, or ${o}.`}else r.length===2?e+=`one of type ${r[0]} or ${r[1]}.`:e+=`of type ${r[0]}.`;return t==null?e+=` Received ${t}`:typeof t=="function"&&t.name?e+=` Received function ${t.name}`:typeof t=="object"&&t!=null&&t.constructor?.name&&(e+=` Received an instance of ${t.constructor.name}`),e}var ge=(e,...t)=>we("Key must be ",e,...t),re=(e,t,...r)=>we(`Key for the ${e} algorithm must be `,t,...r);var m=class extends Error{static code="ERR_JOSE_GENERIC";code="ERR_JOSE_GENERIC";constructor(t,r){super(t,r),this.name=this.constructor.name,Error.captureStackTrace?.(this,this.constructor)}},w=class extends m{static code="ERR_JWT_CLAIM_VALIDATION_FAILED";code="ERR_JWT_CLAIM_VALIDATION_FAILED";claim;reason;payload;constructor(t,r,o="unspecified",n="unspecified"){super(t,{cause:{claim:o,reason:n,payload:r}}),this.claim=o,this.reason=n,this.payload=r}},M=class extends m{static code="ERR_JWT_EXPIRED";code="ERR_JWT_EXPIRED";claim;reason;payload;constructor(t,r,o="unspecified",n="unspecified"){super(t,{cause:{claim:o,reason:n,payload:r}}),this.claim=o,this.reason=n,this.payload=r}},F=class extends m{static code="ERR_JOSE_ALG_NOT_ALLOWED";code="ERR_JOSE_ALG_NOT_ALLOWED"},l=class extends m{static code="ERR_JOSE_NOT_SUPPORTED";code="ERR_JOSE_NOT_SUPPORTED"};var u=class extends m{static code="ERR_JWS_INVALID";code="ERR_JWS_INVALID"},v=class extends m{static code="ERR_JWT_INVALID";code="ERR_JWT_INVALID"};var H=class extends m{static code="ERR_JWKS_INVALID";code="ERR_JWKS_INVALID"},I=class extends m{static code="ERR_JWKS_NO_MATCHING_KEY";code="ERR_JWKS_NO_MATCHING_KEY";constructor(t="no applicable key found in the JSON Web Key Set",r){super(t,r)}},$=class extends m{[Symbol.asyncIterator];static code="ERR_JWKS_MULTIPLE_MATCHING_KEYS";code="ERR_JWKS_MULTIPLE_MATCHING_KEYS";constructor(t="multiple matching keys found in the JSON Web Key Set",r){super(t,r)}},V=class extends m{static code="ERR_JWKS_TIMEOUT";code="ERR_JWKS_TIMEOUT";constructor(t="request timed out",r){super(t,r)}},G=class extends m{static code="ERR_JWS_SIGNATURE_VERIFICATION_FAILED";code="ERR_JWS_SIGNATURE_VERIFICATION_FAILED";constructor(t="signature verification failed",r){super(t,r)}};var oe=e=>{if(e?.[Symbol.toStringTag]==="CryptoKey")return!0;try{return e instanceof CryptoKey}catch{return!1}},ne=e=>e?.[Symbol.toStringTag]==="KeyObject",ie=e=>oe(e)||ne(e);function se(e,t,r){try{return A(e)}catch{throw new r(`Failed to base64url decode the ${t}`)}}var et=e=>typeof e=="object"&&e!==null;function y(e){if(!et(e)||Object.prototype.toString.call(e)!=="[object Object]")return!1;if(Object.getPrototypeOf(e)===null)return!0;let t=e;for(;Object.getPrototypeOf(t)!==null;)t=Object.getPrototypeOf(t);return Object.getPrototypeOf(e)===t}function Ae(...e){let t=e.filter(Boolean);if(t.length===0||t.length===1)return!0;let r;for(let o of t){let n=Object.keys(o);if(!r||r.size===0){r=new Set(n);continue}for(let i of n){if(r.has(i))return!1;r.add(i)}}return!0}var B=e=>y(e)&&typeof e.kty=="string",be=e=>e.kty!=="oct"&&(e.kty==="AKP"&&typeof e.priv=="string"||typeof e.d=="string"),Re=e=>e.kty!=="oct"&&e.d===void 0&&e.priv===void 0,Te=e=>e.kty==="oct"&&typeof e.k=="string";function rt(e,t){if(e.startsWith("RS")||e.startsWith("PS")){let{modulusLength:r}=t.algorithm;if(typeof r!="number"||r<2048)throw new TypeError(`${e} requires key modulusLength to be 2048 bits or larger`)}}function ot(e,t){let r=`SHA-${e.slice(-3)}`;switch(e){case"HS256":case"HS384":case"HS512":return{hash:r,name:"HMAC"};case"PS256":case"PS384":case"PS512":return{hash:r,name:"RSA-PSS",saltLength:parseInt(e.slice(-3),10)>>3};case"RS256":case"RS384":case"RS512":return{hash:r,name:"RSASSA-PKCS1-v1_5"};case"ES256":case"ES384":case"ES512":return{hash:r,name:"ECDSA",namedCurve:t.namedCurve};case"Ed25519":case"EdDSA":return{name:"Ed25519"};case"ML-DSA-44":case"ML-DSA-65":case"ML-DSA-87":return{name:e};default:throw new l(`alg ${e} is not supported either by JOSE or your javascript runtime`)}}async function nt(e,t,r){if(t instanceof Uint8Array){if(!e.startsWith("HS"))throw new TypeError(ge(t,"CryptoKey","KeyObject","JSON Web Key"));return crypto.subtle.importKey("raw",t,{hash:`SHA-${e.slice(-3)}`,name:"HMAC"},!1,[r])}return Ee(t,e,r),t}async function Ke(e,t,r,o){let n=await nt(e,t,"verify");rt(e,n);let i=ot(e,n.algorithm);try{return await crypto.subtle.verify(i,n,r,o)}catch{return!1}}var X='Invalid or unsupported JWK "alg" (Algorithm) Parameter value';function it(e){let t,r;switch(e.kty){case"AKP":{switch(e.alg){case"ML-DSA-44":case"ML-DSA-65":case"ML-DSA-87":t={name:e.alg},r=e.priv?["sign"]:["verify"];break;default:throw new l(X)}break}case"RSA":{switch(e.alg){case"PS256":case"PS384":case"PS512":t={name:"RSA-PSS",hash:`SHA-${e.alg.slice(-3)}`},r=e.d?["sign"]:["verify"];break;case"RS256":case"RS384":case"RS512":t={name:"RSASSA-PKCS1-v1_5",hash:`SHA-${e.alg.slice(-3)}`},r=e.d?["sign"]:["verify"];break;case"RSA-OAEP":case"RSA-OAEP-256":case"RSA-OAEP-384":case"RSA-OAEP-512":t={name:"RSA-OAEP",hash:`SHA-${parseInt(e.alg.slice(-3),10)||1}`},r=e.d?["decrypt","unwrapKey"]:["encrypt","wrapKey"];break;default:throw new l(X)}break}case"EC":{switch(e.alg){case"ES256":case"ES384":case"ES512":t={name:"ECDSA",namedCurve:{ES256:"P-256",ES384:"P-384",ES512:"P-521"}[e.alg]},r=e.d?["sign"]:["verify"];break;case"ECDH-ES":case"ECDH-ES+A128KW":case"ECDH-ES+A192KW":case"ECDH-ES+A256KW":t={name:"ECDH",namedCurve:e.crv},r=e.d?["deriveBits"]:[];break;default:throw new l(X)}break}case"OKP":{switch(e.alg){case"Ed25519":case"EdDSA":t={name:"Ed25519"},r=e.d?["sign"]:["verify"];break;case"ECDH-ES":case"ECDH-ES+A128KW":case"ECDH-ES+A192KW":case"ECDH-ES+A256KW":t={name:e.crv},r=e.d?["deriveBits"]:[];break;default:throw new l(X)}break}default:throw new l('Invalid or unsupported JWK "kty" (Key Type) Parameter value')}return{algorithm:t,keyUsages:r}}async function O(e){if(!e.alg)throw new TypeError('"alg" argument is required when "jwk.alg" is not present');let{algorithm:t,keyUsages:r}=it(e),o={...e};return o.kty!=="AKP"&&delete o.alg,delete o.use,crypto.subtle.importKey("jwk",o,t,e.ext??!(e.d||e.priv),e.key_ops??r)}var J="given KeyObject instance cannot be used for this algorithm",N,_e=async(e,t,r,o=!1)=>{N||=new WeakMap;let n=N.get(e);if(n?.[r])return n[r];let i=await O({...t,alg:r});return o&&Object.freeze(e),n?n[r]=i:N.set(e,{[r]:i}),i},st=(e,t)=>{N||=new WeakMap;let r=N.get(e);if(r?.[t])return r[t];let o=e.type==="public",n=!!o,i;if(e.asymmetricKeyType==="x25519"){switch(t){case"ECDH-ES":case"ECDH-ES+A128KW":case"ECDH-ES+A192KW":case"ECDH-ES+A256KW":break;default:throw new TypeError(J)}i=e.toCryptoKey(e.asymmetricKeyType,n,o?[]:["deriveBits"])}if(e.asymmetricKeyType==="ed25519"){if(t!=="EdDSA"&&t!=="Ed25519")throw new TypeError(J);i=e.toCryptoKey(e.asymmetricKeyType,n,[o?"verify":"sign"])}switch(e.asymmetricKeyType){case"ml-dsa-44":case"ml-dsa-65":case"ml-dsa-87":{if(t!==e.asymmetricKeyType.toUpperCase())throw new TypeError(J);i=e.toCryptoKey(e.asymmetricKeyType,n,[o?"verify":"sign"])}}if(e.asymmetricKeyType==="rsa"){let s;switch(t){case"RSA-OAEP":s="SHA-1";break;case"RS256":case"PS256":case"RSA-OAEP-256":s="SHA-256";break;case"RS384":case"PS384":case"RSA-OAEP-384":s="SHA-384";break;case"RS512":case"PS512":case"RSA-OAEP-512":s="SHA-512";break;default:throw new TypeError(J)}if(t.startsWith("RSA-OAEP"))return e.toCryptoKey({name:"RSA-OAEP",hash:s},n,o?["encrypt"]:["decrypt"]);i=e.toCryptoKey({name:t.startsWith("PS")?"RSA-PSS":"RSASSA-PKCS1-v1_5",hash:s},n,[o?"verify":"sign"])}if(e.asymmetricKeyType==="ec"){let f=new Map([["prime256v1","P-256"],["secp384r1","P-384"],["secp521r1","P-521"]]).get(e.asymmetricKeyDetails?.namedCurve);if(!f)throw new TypeError(J);let p={ES256:"P-256",ES384:"P-384",ES512:"P-521"};p[t]&&f===p[t]&&(i=e.toCryptoKey({name:"ECDSA",namedCurve:f},n,[o?"verify":"sign"])),t.startsWith("ECDH-ES")&&(i=e.toCryptoKey({name:"ECDH",namedCurve:f},n,o?[]:["deriveBits"]))}if(!i)throw new TypeError(J);return r?r[t]=i:N.set(e,{[t]:i}),i};async function Pe(e,t){if(e instanceof Uint8Array||oe(e))return e;if(ne(e)){if(e.type==="secret")return e.export();if("toCryptoKey"in e&&typeof e.toCryptoKey=="function")try{return st(e,t)}catch(o){if(o instanceof TypeError)throw o}let r=e.export({format:"jwk"});return _e(e,r,t)}if(B(e))return e.k?A(e.k):_e(e,e,t,!0);throw new Error("unreachable")}async function Ce(e,t,r){if(!y(e))throw new TypeError("JWK must be an object");let o;switch(t??=e.alg,o??=r?.extractable??e.ext,e.kty){case"oct":if(typeof e.k!="string"||!e.k)throw new TypeError('missing "k" (Key Value) Parameter value');return A(e.k);case"RSA":if("oth"in e&&e.oth!==void 0)throw new l('RSA JWK "oth" (Other Primes Info) Parameter value is not supported');return O({...e,alg:t,ext:o});case"AKP":{if(typeof e.alg!="string"||!e.alg)throw new TypeError('missing "alg" (Algorithm) Parameter value');if(t!==void 0&&t!==e.alg)throw new TypeError("JWK alg and alg option value mismatch");return O({...e,ext:o})}case"EC":case"OKP":return O({...e,alg:t,ext:o});default:throw new l('Unsupported "kty" (Key Type) Parameter value')}}function xe(e,t,r,o,n){if(n.crit!==void 0&&o?.crit===void 0)throw new e('"crit" (Critical) Header Parameter MUST be integrity protected');if(!o||o.crit===void 0)return new Set;if(!Array.isArray(o.crit)||o.crit.length===0||o.crit.some(s=>typeof s!="string"||s.length===0))throw new e('"crit" (Critical) Header Parameter MUST be an array of non-empty strings when present');let i;r!==void 0?i=new Map([...Object.entries(r),...t.entries()]):i=t;for(let s of o.crit){if(!i.has(s))throw new l(`Extension Header Parameter "${s}" is not recognized`);if(n[s]===void 0)throw new e(`Extension Header Parameter "${s}" is missing`);if(i.get(s)&&o[s]===void 0)throw new e(`Extension Header Parameter "${s}" MUST be integrity protected`)}return new Set(o.crit)}function We(e,t){if(t!==void 0&&(!Array.isArray(t)||t.some(r=>typeof r!="string")))throw new TypeError(`"${e}" option must be an array of strings`);if(t)return new Set(t)}var D=e=>e?.[Symbol.toStringTag],ae=(e,t,r)=>{if(t.use!==void 0){let o;switch(r){case"sign":case"verify":o="sig";break;case"encrypt":case"decrypt":o="enc";break}if(t.use!==o)throw new TypeError(`Invalid key for this operation, its "use" must be "${o}" when present`)}if(t.alg!==void 0&&t.alg!==e)throw new TypeError(`Invalid key for this operation, its "alg" must be "${e}" when present`);if(Array.isArray(t.key_ops)){let o;switch(!0){case(r==="sign"||r==="verify"):case e==="dir":case e.includes("CBC-HS"):o=r;break;case e.startsWith("PBES2"):o="deriveBits";break;case/^A\d{3}(?:GCM)?(?:KW)?$/.test(e):!e.includes("GCM")&&e.endsWith("KW")?o=r==="encrypt"?"wrapKey":"unwrapKey":o=r;break;case(r==="encrypt"&&e.startsWith("RSA")):o="wrapKey";break;case r==="decrypt":o=e.startsWith("RSA")?"unwrapKey":"deriveBits";break}if(o&&t.key_ops?.includes?.(o)===!1)throw new TypeError(`Invalid key for this operation, its "key_ops" must include "${o}" when present`)}return!0},at=(e,t,r)=>{if(!(t instanceof Uint8Array)){if(B(t)){if(Te(t)&&ae(e,t,r))return;throw new TypeError('JSON Web Key for symmetric algorithms must have JWK "kty" (Key Type) equal to "oct" and the JWK "k" (Key Value) present')}if(!ie(t))throw new TypeError(re(e,t,"CryptoKey","KeyObject","JSON Web Key","Uint8Array"));if(t.type!=="secret")throw new TypeError(`${D(t)} instances for symmetric algorithms must be of type "secret"`)}},ct=(e,t,r)=>{if(B(t))switch(r){case"decrypt":case"sign":if(be(t)&&ae(e,t,r))return;throw new TypeError("JSON Web Key for this operation must be a private JWK");case"encrypt":case"verify":if(Re(t)&&ae(e,t,r))return;throw new TypeError("JSON Web Key for this operation must be a public JWK")}if(!ie(t))throw new TypeError(re(e,t,"CryptoKey","KeyObject","JSON Web Key"));if(t.type==="secret")throw new TypeError(`${D(t)} instances for asymmetric algorithms must not be of type "secret"`);if(t.type==="public")switch(r){case"sign":throw new TypeError(`${D(t)} instances for asymmetric algorithm signing must be of type "private"`);case"decrypt":throw new TypeError(`${D(t)} instances for asymmetric algorithm decryption must be of type "private"`)}if(t.type==="private")switch(r){case"verify":throw new TypeError(`${D(t)} instances for asymmetric algorithm verifying must be of type "public"`);case"encrypt":throw new TypeError(`${D(t)} instances for asymmetric algorithm encryption must be of type "public"`)}};function ve(e,t,r){switch(e.substring(0,2)){case"A1":case"A2":case"di":case"HS":case"PB":at(e,t,r);break;default:ct(e,t,r)}}async function Ie(e,t,r){if(!y(e))throw new u("Flattened JWS must be an object");if(e.protected===void 0&&e.header===void 0)throw new u('Flattened JWS must have either of the "protected" or "header" members');if(e.protected!==void 0&&typeof e.protected!="string")throw new u("JWS Protected Header incorrect type");if(e.payload===void 0)throw new u("JWS Payload missing");if(typeof e.signature!="string")throw new u("JWS Signature missing or incorrect type");if(e.header!==void 0&&!y(e.header))throw new u("JWS Unprotected Header incorrect type");let o={};if(e.protected)try{let q=A(e.protected);o=JSON.parse(g.decode(q))}catch{throw new u("JWS Protected Header is invalid")}if(!Ae(o,e.header))throw new u("JWS Protected and JWS Unprotected Header Parameter names must be disjoint");let n={...o,...e.header},i=xe(u,new Map([["b64",!0]]),r?.crit,o,n),s=!0;if(i.has("b64")&&(s=o.b64,typeof s!="boolean"))throw new u('The "b64" (base64url-encode payload) Header Parameter must be a boolean');let{alg:f}=n;if(typeof f!="string"||!f)throw new u('JWS "alg" (Algorithm) Header Parameter missing or invalid');let p=r&&We("algorithms",r.algorithms);if(p&&!p.has(f))throw new F('"alg" (Algorithm) Header Parameter value not allowed');if(s){if(typeof e.payload!="string")throw new u("JWS Payload must be a string")}else if(typeof e.payload!="string"&&!(e.payload instanceof Uint8Array))throw new u("JWS Payload must be a string or an Uint8Array instance");let a=!1;typeof t=="function"&&(t=await t(o,e),a=!0),ve(f,t,"verify");let c=ye(e.protected!==void 0?k(e.protected):new Uint8Array,k("."),typeof e.payload=="string"?s?k(e.payload):L.encode(e.payload):e.payload),S=se(e.signature,"signature",u),C=await Pe(t,f);if(!await Ke(f,C,S,c))throw new G;let E;s?E=se(e.payload,"payload",u):typeof e.payload=="string"?E=L.encode(e.payload):E=e.payload;let R={payload:E};return e.protected!==void 0&&(R.protectedHeader=o),e.header!==void 0&&(R.unprotectedHeader=e.header),a?{...R,key:C}:R}async function Oe(e,t,r){if(e instanceof Uint8Array&&(e=g.decode(e)),typeof e!="string")throw new u("Compact JWS must be a string or Uint8Array");let{0:o,1:n,2:i,length:s}=e.split(".");if(s!==3)throw new u("Invalid Compact JWS");let f=await Ie({payload:n,protected:o,signature:i},t,r),p={payload:f.payload,protectedHeader:f.protectedHeader};return typeof t=="function"?{...p,key:f.key}:p}var ft=e=>Math.floor(e.getTime()/1e3),De=60,Ue=De*60,ce=Ue*24,ut=ce*7,pt=ce*365.25,dt=/^(\+|\-)? ?(\d+|\d+\.\d+) ?(seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d|weeks?|w|years?|yrs?|y)(?: (ago|from now))?$/i;function Je(e){let t=dt.exec(e);if(!t||t[4]&&t[1])throw new TypeError("Invalid time period format");let r=parseFloat(t[2]),o=t[3].toLowerCase(),n;switch(o){case"sec":case"secs":case"second":case"seconds":case"s":n=Math.round(r);break;case"minute":case"minutes":case"min":case"mins":case"m":n=Math.round(r*De);break;case"hour":case"hours":case"hr":case"hrs":case"h":n=Math.round(r*Ue);break;case"day":case"days":case"d":n=Math.round(r*ce);break;case"week":case"weeks":case"w":n=Math.round(r*ut);break;default:n=Math.round(r*pt);break}return t[1]==="-"||t[4]==="ago"?-n:n}var Ne=e=>e.includes("/")?e.toLowerCase():`application/${e.toLowerCase()}`,lt=(e,t)=>typeof e=="string"?t.includes(e):Array.isArray(e)?t.some(Set.prototype.has.bind(new Set(e))):!1;function Le(e,t,r={}){let o;try{o=JSON.parse(g.decode(t))}catch{}if(!y(o))throw new v("JWT Claims Set must be a top-level JSON object");let{typ:n}=r;if(n&&(typeof e.typ!="string"||Ne(e.typ)!==Ne(n)))throw new w('unexpected "typ" JWT header value',o,"typ","check_failed");let{requiredClaims:i=[],issuer:s,subject:f,audience:p,maxTokenAge:a}=r,c=[...i];a!==void 0&&c.push("iat"),p!==void 0&&c.push("aud"),f!==void 0&&c.push("sub"),s!==void 0&&c.push("iss");for(let E of new Set(c.reverse()))if(!(E in o))throw new w(`missing required "${E}" claim`,o,E,"missing");if(s&&!(Array.isArray(s)?s:[s]).includes(o.iss))throw new w('unexpected "iss" claim value',o,"iss","check_failed");if(f&&o.sub!==f)throw new w('unexpected "sub" claim value',o,"sub","check_failed");if(p&&!lt(o.aud,typeof p=="string"?[p]:p))throw new w('unexpected "aud" claim value',o,"aud","check_failed");let S;switch(typeof r.clockTolerance){case"string":S=Je(r.clockTolerance);break;case"number":S=r.clockTolerance;break;case"undefined":S=0;break;default:throw new TypeError("Invalid clockTolerance option type")}let{currentDate:C}=r,x=ft(C||new Date);if((o.iat!==void 0||a)&&typeof o.iat!="number")throw new w('"iat" claim must be a number',o,"iat","invalid");if(o.nbf!==void 0){if(typeof o.nbf!="number")throw new w('"nbf" claim must be a number',o,"nbf","invalid");if(o.nbf>x+S)throw new w('"nbf" claim timestamp check failed',o,"nbf","check_failed")}if(o.exp!==void 0){if(typeof o.exp!="number")throw new w('"exp" claim must be a number',o,"exp","invalid");if(o.exp<=x-S)throw new M('"exp" claim timestamp check failed',o,"exp","check_failed")}if(a){let E=x-o.iat,R=typeof a=="number"?a:Je(a);if(E-S>R)throw new M('"iat" claim timestamp check failed (too far in the past)',o,"iat","check_failed");if(E<0-S)throw new w('"iat" claim timestamp check failed (it should be in the past)',o,"iat","check_failed")}return o}async function z(e,t,r){let o=await Oe(e,t,r);if(o.protectedHeader.crit?.includes("b64")&&o.protectedHeader.b64===!1)throw new v("JWTs MUST NOT use unencoded payload");let i={payload:Le(o.protectedHeader,o.payload,r),protectedHeader:o.protectedHeader};return typeof t=="function"?{...i,key:o.key}:i}function ht(e){switch(typeof e=="string"&&e.slice(0,2)){case"RS":case"PS":return"RSA";case"ES":return"EC";case"Ed":return"OKP";case"ML":return"AKP";default:throw new l('Unsupported "alg" value for a JSON Web Key Set')}}function mt(e){return e&&typeof e=="object"&&Array.isArray(e.keys)&&e.keys.every(yt)}function yt(e){return y(e)}var fe=class{#r;#s=new WeakMap;constructor(t){if(!mt(t))throw new H("JSON Web Key Set malformed");this.#r=structuredClone(t)}jwks(){return this.#r}async getKey(t,r){let{alg:o,kid:n}={...t,...r?.header},i=ht(o),s=this.#r.keys.filter(a=>{let c=i===a.kty;if(c&&typeof n=="string"&&(c=n===a.kid),c&&(typeof a.alg=="string"||i==="AKP")&&(c=o===a.alg),c&&typeof a.use=="string"&&(c=a.use==="sig"),c&&Array.isArray(a.key_ops)&&(c=a.key_ops.includes("verify")),c)switch(o){case"ES256":c=a.crv==="P-256";break;case"ES384":c=a.crv==="P-384";break;case"ES512":c=a.crv==="P-521";break;case"Ed25519":case"EdDSA":c=a.crv==="Ed25519";break}return c}),{0:f,length:p}=s;if(p===0)throw new I;if(p!==1){let a=new $,c=this.#s;throw a[Symbol.asyncIterator]=async function*(){for(let S of s)try{yield await Me(c,S,o)}catch{}},a}return Me(this.#s,f,o)}};async function Me(e,t,r){let o=e.get(t)||e.set(t,{}).get(t);if(o[r]===void 0){let n=await Ce({...t,ext:!0},r);if(n instanceof Uint8Array||n.type!=="public")throw new H("JSON Web Key Set members must be public keys");o[r]=n}return o[r]}function U(e){let t=new fe(e),r=async(o,n)=>t.getKey(o,n);return Object.defineProperties(r,{jwks:{value:()=>structuredClone(t.jwks()),enumerable:!1,configurable:!1,writable:!1}}),r}function St(){return typeof WebSocketPair<"u"||typeof navigator<"u"&&navigator.userAgent==="Cloudflare-Workers"||typeof EdgeRuntime<"u"&&EdgeRuntime==="vercel"}var ue;(typeof navigator>"u"||!navigator.userAgent?.startsWith?.("Mozilla/5.0 "))&&(ue="jose/v6.2.3");var He=Symbol();async function Et(e,t,r,o=fetch){let n=await o(e,{method:"GET",signal:r,redirect:"manual",headers:t}).catch(i=>{throw i.name==="TimeoutError"?new V:i});if(n.status!==200)throw new m("Expected 200 OK from the JSON Web Key Set HTTP response");try{return await n.json()}catch{throw new m("Failed to parse the JSON Web Key Set HTTP response as JSON")}}var Y=Symbol();function wt(e,t){return!(typeof e!="object"||e===null||!("uat"in e)||typeof e.uat!="number"||Date.now()-e.uat>=t||!("jwks"in e)||!y(e.jwks)||!Array.isArray(e.jwks.keys)||!Array.prototype.every.call(e.jwks.keys,y))}var pe=class{#r;#s;#c;#a;#o;#e;#t;#f;#n;#i;constructor(t,r){if(!(t instanceof URL))throw new TypeError("url must be an instance of URL");this.#r=new URL(t.href),this.#s=typeof r?.timeoutDuration=="number"?r?.timeoutDuration:5e3,this.#c=typeof r?.cooldownDuration=="number"?r?.cooldownDuration:3e4,this.#a=typeof r?.cacheMaxAge=="number"?r?.cacheMaxAge:6e5,this.#t=new Headers(r?.headers),ue&&!this.#t.has("User-Agent")&&this.#t.set("User-Agent",ue),this.#t.has("accept")||(this.#t.set("accept","application/json"),this.#t.append("accept","application/jwk-set+json")),this.#f=r?.[He],r?.[Y]!==void 0&&(this.#i=r?.[Y],wt(r?.[Y],this.#a)&&(this.#o=this.#i.uat,this.#n=U(this.#i.jwks)))}pendingFetch(){return!!this.#e}coolingDown(){return typeof this.#o=="number"?Date.now(){this.#n=U(t),this.#i&&(this.#i.uat=Date.now(),this.#i.jwks=t),this.#o=Date.now(),this.#e=void 0}).catch(t=>{throw this.#e=void 0,t}),await this.#e}};function de(e,t){let r=new pe(e,t),o=async(n,i)=>r.getKey(n,i);return Object.defineProperties(o,{coolingDown:{get:()=>r.coolingDown(),enumerable:!0,configurable:!1},fresh:{get:()=>r.fresh(),enumerable:!0,configurable:!1},reload:{value:()=>r.reload(),enumerable:!0,configurable:!1,writable:!1},reloading:{get:()=>r.pendingFetch(),enumerable:!0,configurable:!1},jwks:{value:()=>r.jwks(),enumerable:!0,configurable:!1,writable:!1}}),o}function le(e){let t;if(typeof e=="string"){let r=e.split(".");(r.length===3||r.length===5)&&([t]=r)}else if(typeof e=="object"&&e)if("protected"in e)t=e.protected;else throw new TypeError("Token does not contain a Protected Header");try{if(typeof t!="string"||!t)throw new Error;let r=JSON.parse(g.decode(A(t)));if(!y(r))throw new Error;return r}catch{throw new TypeError("Invalid Token or Protected Header formatting")}}var b={BootError:h.ServiceUnavailable,InvalidWorkerResponse:h.InternalServerError,WorkerLimit:546},At={[b.BootError]:"BOOT_ERROR",[b.InvalidWorkerResponse]:"WORKER_ERROR",[b.WorkerLimit]:"WORKER_LIMIT"},bt={[b.BootError]:"Worker failed to boot (please check logs)",[b.InvalidWorkerResponse]:"Function exited due to an error (please check logs)",[b.WorkerLimit]:"Worker failed to respond due to a resource limit (please check logs)"},Rt=["HOME","HOSTNAME","PATH","PWD"],Ve=Deno.env.get("SUPABASE_INTERNAL_HOST_PORT"),Tt=Deno.env.get("SUPABASE_INTERNAL_JWT_SECRET"),Kt=new URL("/auth/v1/.well-known/jwks.json",Deno.env.get("SUPABASE_URL")),_t=Deno.env.get("SUPABASE_INTERNAL_DEBUG")==="true",Pt=Deno.env.get("SUPABASE_INTERNAL_FUNCTIONS_CONFIG"),Be=Deno.env.get("SUPABASE_INTERNAL_PUBLISHABLE_KEY"),ke=Deno.env.get("SUPABASE_INTERNAL_SECRET_KEY"),Fe=parseInt(Deno.env.get("SUPABASE_INTERNAL_WALLCLOCK_LIMIT_SEC")),Ct=new Map([[Deno.errors.InvalidWorkerCreation,b.BootError],[Deno.errors.InvalidWorkerResponse,b.InvalidWorkerResponse],[Deno.errors.WorkerRequestCancelled,b.WorkerLimit]]),$e=`Serving functions on http://127.0.0.1:${Ve}/functions/v1/`;function P(e,t,r={}){let o={...r},n=null;return e&&(typeof e=="object"?(o["Content-Type"]="application/json",n=JSON.stringify(e)):typeof e=="string"?(o["Content-Type"]="text/plain",n=e):n=null),new Response(n,{status:t,headers:o})}var K=(()=>{try{let e=JSON.parse(Pt);return _t&&console.log("Functions config:",JSON.stringify(e,null,2)),e}catch(e){throw new Error("Failed to parse functions config",{cause:e})}})();function xt(e){let t=e.split(" "),[r,o]=t;return r!=="Bearer"||t.length!==2?null:o}function Wt(e){let t=e.headers.get("authorization"),o=e.headers.get("sb-api-key")?.replace("Bearer","")?.trim();if(!t&&!o)throw new Error("Missing authorization header");let n=xt(t??""),i=!n||n.startsWith("sb_")?o:n;if(!i)throw new Error("Auth header is not 'Bearer {token}'");return i}async function vt(e,t){let o=new TextEncoder().encode(e);try{await z(t,o)}catch(n){return console.error("Symmetric Legacy JWT verification error",n),!1}return!0}var he=(()=>{try{return U(JSON.parse(Deno.env.get("SUPABASE_JWKS")))}catch{return null}})();async function It(e,t){try{he||(he=de(new URL(e))),await z(t,he)}catch(r){return console.error("Asymmetric JWT verification error",r),!1}return!0}async function Ot(e,t,r){let{alg:o}=le(r);return o==="HS256"?(console.log(`Legacy token type detected, attempting ${o} verification.`),await vt(e,r)):o==="ES256"||o==="RS256"?await It(t,r):!1}async function Jt({entrypointPath:e,importMapPath:t}){if(t)return!1;let r=Q(ee(e),"package.json");try{await Deno.lstat(r)}catch(o){if(o instanceof Deno.errors.NotFound)return!1}return!0}function Nt(e){let t=new URL(e.url),r=e.headers.get("x-forwarded-host");t.hostname=r??t.hostname;let o=new Request(t,e.clone());return o.headers.delete("sb-api-key"),EdgeRuntime.applySupabaseTag(e,o),o}Deno.serve({handler:async e=>{let t=new URL(e.url),{pathname:r}=t;if(r==="/_internal/health")return P({message:"ok"},h.OK);if(r==="/_internal/metric"){let d=await EdgeRuntime.getRuntimeMetrics();return Response.json(d)}let n=r.split("/")[1];if(!n||!(n in K))return P("Function not found",h.NotFound);if(e.method!=="OPTIONS"&&K[n].verifyJWT)try{let d=Wt(e);if(!await Ot(Tt,Kt,d))return P({msg:"Invalid JWT"},h.Unauthorized)}catch(d){return console.error(d),P({msg:d.toString()},h.Unauthorized)}let i=ee(K[n].entrypointPath);console.error(`serving the request with ${i}`);let s=256,f=isFinite(Fe)?Fe*1e3:400*1e3,p=!1,a={...Deno.env.toObject(),...Object.fromEntries(Object.entries(K[n].env??{}).filter(([d,_])=>!d.startsWith("SUPABASE_")))};Be&&(a.SUPABASE_PUBLISHABLE_KEYS=JSON.stringify({default:Be})),ke&&(a.SUPABASE_SECRET_KEYS=JSON.stringify({default:ke}));let c=Object.entries(a).filter(([d,_])=>!Rt.includes(d)&&!d.startsWith("SUPABASE_INTERNAL_")),S=!1,C="",x=1e3,E=2e3,R="tc39",q=Q(Deno.cwd(),K[n].entrypointPath),Ge=me(q).href,Xe=await Jt(K[n]),ze=K[n].staticFiles;try{let d=await EdgeRuntime.userWorkers.create({servicePath:i,memoryLimitMb:s,workerTimeoutMs:f,noModuleCache:p,noNpm:!Xe,importMapPath:K[n].importMapPath,envVars:c,forceCreate:S,customModuleRoot:C,cpuTimeSoftLimitMs:x,cpuTimeHardLimitMs:E,decoratorType:R,maybeEntrypoint:Ge,context:{useReadSyncFileAPI:!0},staticPatterns:ze}),_=Nt(e);return await d.fetch(_)}catch(d){console.error(d);for(let[_,j]of Ct.entries())if(_!==void 0&&d instanceof _)return P({code:At[j],message:bt[j]},j);return P({code:Z[h.InternalServerError],message:"Request failed due to an internal server error",trace:JSON.stringify(d.stack)},h.InternalServerError)}},onListen:()=>{try{let e=Deno.env.get("SUPABASE_INTERNAL_FUNCTIONS_CONFIG");if(e){let r=JSON.parse(e),o=Object.keys(r),i=o.slice(0,5).map(f=>` - http://127.0.0.1:${Ve}/functions/v1/${f}`),s=o.length>0?` +var m={OK:200,Unauthorized:401,NotFound:404,InternalServerError:500,ServiceUnavailable:503},j={[m.OK]:"OK",[m.Unauthorized]:"Unauthorized",[m.NotFound]:"Not Found",[m.InternalServerError]:"Internal Server Error",[m.ServiceUnavailable]:"Service Unavailable"};function ze(e){let t=e.startsWith("/"),r=[];for(let n of e.split("/"))if(!(n===""||n===".")){if(n===".."){r.length>0&&r[r.length-1]!==".."?r.pop():t||r.push("..");continue}r.push(n)}let o=r.join("/");return t?"/"+o:o===""?".":o}function Q(...e){let t=e.filter(r=>r.length>0).join("/");return t===""?".":ze(t)}function ee(e){if(e.length===0)return".";let t=e.length;for(;t>1&&e[t-1]==="/";)t-=1;let r=e.slice(0,t),o=r.lastIndexOf("/");return o===-1?".":o===0?"/":r.slice(0,o)}function Ze(e){return e.replace(/\s/g,t=>`%${t.charCodeAt(0).toString(16).padStart(2,"0").toUpperCase()}`)}function ye(e){if(!e.startsWith("/"))throw new TypeError(`Path must be absolute: received "${e}"`);let t=new URL("file:///");return t.pathname=Ze(e.replace(/%/g,"%25").replace(/\\/g,"%5C")),t}var L=new TextEncoder,A=new TextDecoder,Ht=2**32;function Se(...e){let t=e.reduce((n,{length:i})=>n+i,0),r=new Uint8Array(t),o=0;for(let n of e)r.set(n,o),o+=n.length;return r}function F(e){let t=new Uint8Array(e.length);for(let r=0;r127)throw new TypeError("non-ASCII string encountered in encode()");t[r]=o}return t}function Ee(e){if(Uint8Array.fromBase64)return Uint8Array.fromBase64(e);let t=atob(e),r=new Uint8Array(t.length);for(let o=0;onew TypeError(`CryptoKey does not support this operation, its ${t} must be ${e}`),I=(e,t)=>e.name===t;function je(e){return parseInt(e.name.slice(4),10)}function te(e,t){if(je(e.hash)!==t)throw K(`SHA-${t}`,"algorithm.hash")}function Qe(e){switch(e){case"ES256":return"P-256";case"ES384":return"P-384";case"ES512":return"P-521";default:throw new Error("unreachable")}}function et(e,t){if(t&&!e.usages.includes(t))throw new TypeError(`CryptoKey does not support this operation, its usages must include ${t}.`)}function ge(e,t,r){switch(t){case"HS256":case"HS384":case"HS512":{if(!I(e.algorithm,"HMAC"))throw K("HMAC");te(e.algorithm,parseInt(t.slice(2),10));break}case"RS256":case"RS384":case"RS512":{if(!I(e.algorithm,"RSASSA-PKCS1-v1_5"))throw K("RSASSA-PKCS1-v1_5");te(e.algorithm,parseInt(t.slice(2),10));break}case"PS256":case"PS384":case"PS512":{if(!I(e.algorithm,"RSA-PSS"))throw K("RSA-PSS");te(e.algorithm,parseInt(t.slice(2),10));break}case"Ed25519":case"EdDSA":{if(!I(e.algorithm,"Ed25519"))throw K("Ed25519");break}case"ML-DSA-44":case"ML-DSA-65":case"ML-DSA-87":{if(!I(e.algorithm,t))throw K(t);break}case"ES256":case"ES384":case"ES512":{if(!I(e.algorithm,"ECDSA"))throw K("ECDSA");let o=Qe(t);if(e.algorithm.namedCurve!==o)throw K(o,"algorithm.namedCurve");break}default:throw new TypeError("CryptoKey does not support this operation")}et(e,r)}function Ae(e,t,...r){if(r=r.filter(Boolean),r.length>2){let o=r.pop();e+=`one of type ${r.join(", ")}, or ${o}.`}else r.length===2?e+=`one of type ${r[0]} or ${r[1]}.`:e+=`of type ${r[0]}.`;return t==null?e+=` Received ${t}`:typeof t=="function"&&t.name?e+=` Received function ${t.name}`:typeof t=="object"&&t!=null&&t.constructor?.name&&(e+=` Received an instance of ${t.constructor.name}`),e}var we=(e,...t)=>Ae("Key must be ",e,...t),re=(e,t,...r)=>Ae(`Key for the ${e} algorithm must be `,t,...r);var h=class extends Error{static code="ERR_JOSE_GENERIC";code="ERR_JOSE_GENERIC";constructor(t,r){super(t,r),this.name=this.constructor.name,Error.captureStackTrace?.(this,this.constructor)}},g=class extends h{static code="ERR_JWT_CLAIM_VALIDATION_FAILED";code="ERR_JWT_CLAIM_VALIDATION_FAILED";claim;reason;payload;constructor(t,r,o="unspecified",n="unspecified"){super(t,{cause:{claim:o,reason:n,payload:r}}),this.claim=o,this.reason=n,this.payload=r}},H=class extends h{static code="ERR_JWT_EXPIRED";code="ERR_JWT_EXPIRED";claim;reason;payload;constructor(t,r,o="unspecified",n="unspecified"){super(t,{cause:{claim:o,reason:n,payload:r}}),this.claim=o,this.reason=n,this.payload=r}},k=class extends h{static code="ERR_JOSE_ALG_NOT_ALLOWED";code="ERR_JOSE_ALG_NOT_ALLOWED"},l=class extends h{static code="ERR_JOSE_NOT_SUPPORTED";code="ERR_JOSE_NOT_SUPPORTED"};var u=class extends h{static code="ERR_JWS_INVALID";code="ERR_JWS_INVALID"},x=class extends h{static code="ERR_JWT_INVALID";code="ERR_JWT_INVALID"};var M=class extends h{static code="ERR_JWKS_INVALID";code="ERR_JWKS_INVALID"},W=class extends h{static code="ERR_JWKS_NO_MATCHING_KEY";code="ERR_JWKS_NO_MATCHING_KEY";constructor(t="no applicable key found in the JSON Web Key Set",r){super(t,r)}},$=class extends h{[Symbol.asyncIterator];static code="ERR_JWKS_MULTIPLE_MATCHING_KEYS";code="ERR_JWKS_MULTIPLE_MATCHING_KEYS";constructor(t="multiple matching keys found in the JSON Web Key Set",r){super(t,r)}},V=class extends h{static code="ERR_JWKS_TIMEOUT";code="ERR_JWKS_TIMEOUT";constructor(t="request timed out",r){super(t,r)}},G=class extends h{static code="ERR_JWS_SIGNATURE_VERIFICATION_FAILED";code="ERR_JWS_SIGNATURE_VERIFICATION_FAILED";constructor(t="signature verification failed",r){super(t,r)}};var oe=e=>{if(e?.[Symbol.toStringTag]==="CryptoKey")return!0;try{return e instanceof CryptoKey}catch{return!1}},ne=e=>e?.[Symbol.toStringTag]==="KeyObject",ie=e=>oe(e)||ne(e);function se(e,t,r){try{return w(e)}catch{throw new r(`Failed to base64url decode the ${t}`)}}var tt=e=>typeof e=="object"&&e!==null;function y(e){if(!tt(e)||Object.prototype.toString.call(e)!=="[object Object]")return!1;if(Object.getPrototypeOf(e)===null)return!0;let t=e;for(;Object.getPrototypeOf(t)!==null;)t=Object.getPrototypeOf(t);return Object.getPrototypeOf(e)===t}function be(...e){let t=e.filter(Boolean);if(t.length===0||t.length===1)return!0;let r;for(let o of t){let n=Object.keys(o);if(!r||r.size===0){r=new Set(n);continue}for(let i of n){if(r.has(i))return!1;r.add(i)}}return!0}var B=e=>y(e)&&typeof e.kty=="string",Te=e=>e.kty!=="oct"&&(e.kty==="AKP"&&typeof e.priv=="string"||typeof e.d=="string"),Re=e=>e.kty!=="oct"&&e.d===void 0&&e.priv===void 0,Ke=e=>e.kty==="oct"&&typeof e.k=="string";function ot(e,t){if(e.startsWith("RS")||e.startsWith("PS")){let{modulusLength:r}=t.algorithm;if(typeof r!="number"||r<2048)throw new TypeError(`${e} requires key modulusLength to be 2048 bits or larger`)}}function nt(e,t){let r=`SHA-${e.slice(-3)}`;switch(e){case"HS256":case"HS384":case"HS512":return{hash:r,name:"HMAC"};case"PS256":case"PS384":case"PS512":return{hash:r,name:"RSA-PSS",saltLength:parseInt(e.slice(-3),10)>>3};case"RS256":case"RS384":case"RS512":return{hash:r,name:"RSASSA-PKCS1-v1_5"};case"ES256":case"ES384":case"ES512":return{hash:r,name:"ECDSA",namedCurve:t.namedCurve};case"Ed25519":case"EdDSA":return{name:"Ed25519"};case"ML-DSA-44":case"ML-DSA-65":case"ML-DSA-87":return{name:e};default:throw new l(`alg ${e} is not supported either by JOSE or your javascript runtime`)}}async function it(e,t,r){if(t instanceof Uint8Array){if(!e.startsWith("HS"))throw new TypeError(we(t,"CryptoKey","KeyObject","JSON Web Key"));return crypto.subtle.importKey("raw",t,{hash:`SHA-${e.slice(-3)}`,name:"HMAC"},!1,[r])}return ge(t,e,r),t}async function _e(e,t,r,o){let n=await it(e,t,"verify");ot(e,n);let i=nt(e,n.algorithm);try{return await crypto.subtle.verify(i,n,r,o)}catch{return!1}}var X='Invalid or unsupported JWK "alg" (Algorithm) Parameter value';function st(e){let t,r;switch(e.kty){case"AKP":{switch(e.alg){case"ML-DSA-44":case"ML-DSA-65":case"ML-DSA-87":t={name:e.alg},r=e.priv?["sign"]:["verify"];break;default:throw new l(X)}break}case"RSA":{switch(e.alg){case"PS256":case"PS384":case"PS512":t={name:"RSA-PSS",hash:`SHA-${e.alg.slice(-3)}`},r=e.d?["sign"]:["verify"];break;case"RS256":case"RS384":case"RS512":t={name:"RSASSA-PKCS1-v1_5",hash:`SHA-${e.alg.slice(-3)}`},r=e.d?["sign"]:["verify"];break;case"RSA-OAEP":case"RSA-OAEP-256":case"RSA-OAEP-384":case"RSA-OAEP-512":t={name:"RSA-OAEP",hash:`SHA-${parseInt(e.alg.slice(-3),10)||1}`},r=e.d?["decrypt","unwrapKey"]:["encrypt","wrapKey"];break;default:throw new l(X)}break}case"EC":{switch(e.alg){case"ES256":case"ES384":case"ES512":t={name:"ECDSA",namedCurve:{ES256:"P-256",ES384:"P-384",ES512:"P-521"}[e.alg]},r=e.d?["sign"]:["verify"];break;case"ECDH-ES":case"ECDH-ES+A128KW":case"ECDH-ES+A192KW":case"ECDH-ES+A256KW":t={name:"ECDH",namedCurve:e.crv},r=e.d?["deriveBits"]:[];break;default:throw new l(X)}break}case"OKP":{switch(e.alg){case"Ed25519":case"EdDSA":t={name:"Ed25519"},r=e.d?["sign"]:["verify"];break;case"ECDH-ES":case"ECDH-ES+A128KW":case"ECDH-ES+A192KW":case"ECDH-ES+A256KW":t={name:e.crv},r=e.d?["deriveBits"]:[];break;default:throw new l(X)}break}default:throw new l('Invalid or unsupported JWK "kty" (Key Type) Parameter value')}return{algorithm:t,keyUsages:r}}async function v(e){if(!e.alg)throw new TypeError('"alg" argument is required when "jwk.alg" is not present');let{algorithm:t,keyUsages:r}=st(e),o={...e};return o.kty!=="AKP"&&delete o.alg,delete o.use,crypto.subtle.importKey("jwk",o,t,e.ext??!(e.d||e.priv),e.key_ops??r)}var O="given KeyObject instance cannot be used for this algorithm",J,Pe=async(e,t,r,o=!1)=>{J||=new WeakMap;let n=J.get(e);if(n?.[r])return n[r];let i=await v({...t,alg:r});return o&&Object.freeze(e),n?n[r]=i:J.set(e,{[r]:i}),i},at=(e,t)=>{J||=new WeakMap;let r=J.get(e);if(r?.[t])return r[t];let o=e.type==="public",n=!!o,i;if(e.asymmetricKeyType==="x25519"){switch(t){case"ECDH-ES":case"ECDH-ES+A128KW":case"ECDH-ES+A192KW":case"ECDH-ES+A256KW":break;default:throw new TypeError(O)}i=e.toCryptoKey(e.asymmetricKeyType,n,o?[]:["deriveBits"])}if(e.asymmetricKeyType==="ed25519"){if(t!=="EdDSA"&&t!=="Ed25519")throw new TypeError(O);i=e.toCryptoKey(e.asymmetricKeyType,n,[o?"verify":"sign"])}switch(e.asymmetricKeyType){case"ml-dsa-44":case"ml-dsa-65":case"ml-dsa-87":{if(t!==e.asymmetricKeyType.toUpperCase())throw new TypeError(O);i=e.toCryptoKey(e.asymmetricKeyType,n,[o?"verify":"sign"])}}if(e.asymmetricKeyType==="rsa"){let s;switch(t){case"RSA-OAEP":s="SHA-1";break;case"RS256":case"PS256":case"RSA-OAEP-256":s="SHA-256";break;case"RS384":case"PS384":case"RSA-OAEP-384":s="SHA-384";break;case"RS512":case"PS512":case"RSA-OAEP-512":s="SHA-512";break;default:throw new TypeError(O)}if(t.startsWith("RSA-OAEP"))return e.toCryptoKey({name:"RSA-OAEP",hash:s},n,o?["encrypt"]:["decrypt"]);i=e.toCryptoKey({name:t.startsWith("PS")?"RSA-PSS":"RSASSA-PKCS1-v1_5",hash:s},n,[o?"verify":"sign"])}if(e.asymmetricKeyType==="ec"){let f=new Map([["prime256v1","P-256"],["secp384r1","P-384"],["secp521r1","P-521"]]).get(e.asymmetricKeyDetails?.namedCurve);if(!f)throw new TypeError(O);let d={ES256:"P-256",ES384:"P-384",ES512:"P-521"};d[t]&&f===d[t]&&(i=e.toCryptoKey({name:"ECDSA",namedCurve:f},n,[o?"verify":"sign"])),t.startsWith("ECDH-ES")&&(i=e.toCryptoKey({name:"ECDH",namedCurve:f},n,o?[]:["deriveBits"]))}if(!i)throw new TypeError(O);return r?r[t]=i:J.set(e,{[t]:i}),i};async function Ce(e,t){if(e instanceof Uint8Array||oe(e))return e;if(ne(e)){if(e.type==="secret")return e.export();if("toCryptoKey"in e&&typeof e.toCryptoKey=="function")try{return at(e,t)}catch(o){if(o instanceof TypeError)throw o}let r=e.export({format:"jwk"});return Pe(e,r,t)}if(B(e))return e.k?w(e.k):Pe(e,e,t,!0);throw new Error("unreachable")}async function Ie(e,t,r){if(!y(e))throw new TypeError("JWK must be an object");let o;switch(t??=e.alg,o??=r?.extractable??e.ext,e.kty){case"oct":if(typeof e.k!="string"||!e.k)throw new TypeError('missing "k" (Key Value) Parameter value');return w(e.k);case"RSA":if("oth"in e&&e.oth!==void 0)throw new l('RSA JWK "oth" (Other Primes Info) Parameter value is not supported');return v({...e,alg:t,ext:o});case"AKP":{if(typeof e.alg!="string"||!e.alg)throw new TypeError('missing "alg" (Algorithm) Parameter value');if(t!==void 0&&t!==e.alg)throw new TypeError("JWK alg and alg option value mismatch");return v({...e,ext:o})}case"EC":case"OKP":return v({...e,alg:t,ext:o});default:throw new l('Unsupported "kty" (Key Type) Parameter value')}}function xe(e,t,r,o,n){if(n.crit!==void 0&&o?.crit===void 0)throw new e('"crit" (Critical) Header Parameter MUST be integrity protected');if(!o||o.crit===void 0)return new Set;if(!Array.isArray(o.crit)||o.crit.length===0||o.crit.some(s=>typeof s!="string"||s.length===0))throw new e('"crit" (Critical) Header Parameter MUST be an array of non-empty strings when present');let i;r!==void 0?i=new Map([...Object.entries(r),...t.entries()]):i=t;for(let s of o.crit){if(!i.has(s))throw new l(`Extension Header Parameter "${s}" is not recognized`);if(n[s]===void 0)throw new e(`Extension Header Parameter "${s}" is missing`);if(i.get(s)&&o[s]===void 0)throw new e(`Extension Header Parameter "${s}" MUST be integrity protected`)}return new Set(o.crit)}function We(e,t){if(t!==void 0&&(!Array.isArray(t)||t.some(r=>typeof r!="string")))throw new TypeError(`"${e}" option must be an array of strings`);if(t)return new Set(t)}var N=e=>e?.[Symbol.toStringTag],ae=(e,t,r)=>{if(t.use!==void 0){let o;switch(r){case"sign":case"verify":o="sig";break;case"encrypt":case"decrypt":o="enc";break}if(t.use!==o)throw new TypeError(`Invalid key for this operation, its "use" must be "${o}" when present`)}if(t.alg!==void 0&&t.alg!==e)throw new TypeError(`Invalid key for this operation, its "alg" must be "${e}" when present`);if(Array.isArray(t.key_ops)){let o;switch(!0){case(r==="sign"||r==="verify"):case e==="dir":case e.includes("CBC-HS"):o=r;break;case e.startsWith("PBES2"):o="deriveBits";break;case/^A\d{3}(?:GCM)?(?:KW)?$/.test(e):!e.includes("GCM")&&e.endsWith("KW")?o=r==="encrypt"?"wrapKey":"unwrapKey":o=r;break;case(r==="encrypt"&&e.startsWith("RSA")):o="wrapKey";break;case r==="decrypt":o=e.startsWith("RSA")?"unwrapKey":"deriveBits";break}if(o&&t.key_ops?.includes?.(o)===!1)throw new TypeError(`Invalid key for this operation, its "key_ops" must include "${o}" when present`)}return!0},ct=(e,t,r)=>{if(!(t instanceof Uint8Array)){if(B(t)){if(Ke(t)&&ae(e,t,r))return;throw new TypeError('JSON Web Key for symmetric algorithms must have JWK "kty" (Key Type) equal to "oct" and the JWK "k" (Key Value) present')}if(!ie(t))throw new TypeError(re(e,t,"CryptoKey","KeyObject","JSON Web Key","Uint8Array"));if(t.type!=="secret")throw new TypeError(`${N(t)} instances for symmetric algorithms must be of type "secret"`)}},ft=(e,t,r)=>{if(B(t))switch(r){case"decrypt":case"sign":if(Te(t)&&ae(e,t,r))return;throw new TypeError("JSON Web Key for this operation must be a private JWK");case"encrypt":case"verify":if(Re(t)&&ae(e,t,r))return;throw new TypeError("JSON Web Key for this operation must be a public JWK")}if(!ie(t))throw new TypeError(re(e,t,"CryptoKey","KeyObject","JSON Web Key"));if(t.type==="secret")throw new TypeError(`${N(t)} instances for asymmetric algorithms must not be of type "secret"`);if(t.type==="public")switch(r){case"sign":throw new TypeError(`${N(t)} instances for asymmetric algorithm signing must be of type "private"`);case"decrypt":throw new TypeError(`${N(t)} instances for asymmetric algorithm decryption must be of type "private"`)}if(t.type==="private")switch(r){case"verify":throw new TypeError(`${N(t)} instances for asymmetric algorithm verifying must be of type "public"`);case"encrypt":throw new TypeError(`${N(t)} instances for asymmetric algorithm encryption must be of type "public"`)}};function ve(e,t,r){switch(e.substring(0,2)){case"A1":case"A2":case"di":case"HS":case"PB":ct(e,t,r);break;default:ft(e,t,r)}}async function Oe(e,t,r){if(!y(e))throw new u("Flattened JWS must be an object");if(e.protected===void 0&&e.header===void 0)throw new u('Flattened JWS must have either of the "protected" or "header" members');if(e.protected!==void 0&&typeof e.protected!="string")throw new u("JWS Protected Header incorrect type");if(e.payload===void 0)throw new u("JWS Payload missing");if(typeof e.signature!="string")throw new u("JWS Signature missing or incorrect type");if(e.header!==void 0&&!y(e.header))throw new u("JWS Unprotected Header incorrect type");let o={};if(e.protected)try{let z=w(e.protected);o=JSON.parse(A.decode(z))}catch{throw new u("JWS Protected Header is invalid")}if(!be(o,e.header))throw new u("JWS Protected and JWS Unprotected Header Parameter names must be disjoint");let n={...o,...e.header},i=xe(u,new Map([["b64",!0]]),r?.crit,o,n),s=!0;if(i.has("b64")&&(s=o.b64,typeof s!="boolean"))throw new u('The "b64" (base64url-encode payload) Header Parameter must be a boolean');let{alg:f}=n;if(typeof f!="string"||!f)throw new u('JWS "alg" (Algorithm) Header Parameter missing or invalid');let d=r&&We("algorithms",r.algorithms);if(d&&!d.has(f))throw new k('"alg" (Algorithm) Header Parameter value not allowed');if(s){if(typeof e.payload!="string")throw new u("JWS Payload must be a string")}else if(typeof e.payload!="string"&&!(e.payload instanceof Uint8Array))throw new u("JWS Payload must be a string or an Uint8Array instance");let a=!1;typeof t=="function"&&(t=await t(o,e),a=!0),ve(f,t,"verify");let c=Se(e.protected!==void 0?F(e.protected):new Uint8Array,F("."),typeof e.payload=="string"?s?F(e.payload):L.encode(e.payload):e.payload),S=se(e.signature,"signature",u),P=await Ce(t,f);if(!await _e(f,P,S,c))throw new G;let E;s?E=se(e.payload,"payload",u):typeof e.payload=="string"?E=L.encode(e.payload):E=e.payload;let R={payload:E};return e.protected!==void 0&&(R.protectedHeader=o),e.header!==void 0&&(R.unprotectedHeader=e.header),a?{...R,key:P}:R}async function Je(e,t,r){if(e instanceof Uint8Array&&(e=A.decode(e)),typeof e!="string")throw new u("Compact JWS must be a string or Uint8Array");let{0:o,1:n,2:i,length:s}=e.split(".");if(s!==3)throw new u("Invalid Compact JWS");let f=await Oe({payload:n,protected:o,signature:i},t,r),d={payload:f.payload,protectedHeader:f.protectedHeader};return typeof t=="function"?{...d,key:f.key}:d}var ut=e=>Math.floor(e.getTime()/1e3),Ue=60,Le=Ue*60,ce=Le*24,dt=ce*7,pt=ce*365.25,lt=/^(\+|\-)? ?(\d+|\d+\.\d+) ?(seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d|weeks?|w|years?|yrs?|y)(?: (ago|from now))?$/i;function Ne(e){let t=lt.exec(e);if(!t||t[4]&&t[1])throw new TypeError("Invalid time period format");let r=parseFloat(t[2]),o=t[3].toLowerCase(),n;switch(o){case"sec":case"secs":case"second":case"seconds":case"s":n=Math.round(r);break;case"minute":case"minutes":case"min":case"mins":case"m":n=Math.round(r*Ue);break;case"hour":case"hours":case"hr":case"hrs":case"h":n=Math.round(r*Le);break;case"day":case"days":case"d":n=Math.round(r*ce);break;case"week":case"weeks":case"w":n=Math.round(r*dt);break;default:n=Math.round(r*pt);break}return t[1]==="-"||t[4]==="ago"?-n:n}var De=e=>e.includes("/")?e.toLowerCase():`application/${e.toLowerCase()}`,ht=(e,t)=>typeof e=="string"?t.includes(e):Array.isArray(e)?t.some(Set.prototype.has.bind(new Set(e))):!1;function He(e,t,r={}){let o;try{o=JSON.parse(A.decode(t))}catch{}if(!y(o))throw new x("JWT Claims Set must be a top-level JSON object");let{typ:n}=r;if(n&&(typeof e.typ!="string"||De(e.typ)!==De(n)))throw new g('unexpected "typ" JWT header value',o,"typ","check_failed");let{requiredClaims:i=[],issuer:s,subject:f,audience:d,maxTokenAge:a}=r,c=[...i];a!==void 0&&c.push("iat"),d!==void 0&&c.push("aud"),f!==void 0&&c.push("sub"),s!==void 0&&c.push("iss");for(let E of new Set(c.reverse()))if(!(E in o))throw new g(`missing required "${E}" claim`,o,E,"missing");if(s&&!(Array.isArray(s)?s:[s]).includes(o.iss))throw new g('unexpected "iss" claim value',o,"iss","check_failed");if(f&&o.sub!==f)throw new g('unexpected "sub" claim value',o,"sub","check_failed");if(d&&!ht(o.aud,typeof d=="string"?[d]:d))throw new g('unexpected "aud" claim value',o,"aud","check_failed");let S;switch(typeof r.clockTolerance){case"string":S=Ne(r.clockTolerance);break;case"number":S=r.clockTolerance;break;case"undefined":S=0;break;default:throw new TypeError("Invalid clockTolerance option type")}let{currentDate:P}=r,C=ut(P||new Date);if((o.iat!==void 0||a)&&typeof o.iat!="number")throw new g('"iat" claim must be a number',o,"iat","invalid");if(o.nbf!==void 0){if(typeof o.nbf!="number")throw new g('"nbf" claim must be a number',o,"nbf","invalid");if(o.nbf>C+S)throw new g('"nbf" claim timestamp check failed',o,"nbf","check_failed")}if(o.exp!==void 0){if(typeof o.exp!="number")throw new g('"exp" claim must be a number',o,"exp","invalid");if(o.exp<=C-S)throw new H('"exp" claim timestamp check failed',o,"exp","check_failed")}if(a){let E=C-o.iat,R=typeof a=="number"?a:Ne(a);if(E-S>R)throw new H('"iat" claim timestamp check failed (too far in the past)',o,"iat","check_failed");if(E<0-S)throw new g('"iat" claim timestamp check failed (it should be in the past)',o,"iat","check_failed")}return o}async function Y(e,t,r){let o=await Je(e,t,r);if(o.protectedHeader.crit?.includes("b64")&&o.protectedHeader.b64===!1)throw new x("JWTs MUST NOT use unencoded payload");let i={payload:He(o.protectedHeader,o.payload,r),protectedHeader:o.protectedHeader};return typeof t=="function"?{...i,key:o.key}:i}function mt(e){switch(typeof e=="string"&&e.slice(0,2)){case"RS":case"PS":return"RSA";case"ES":return"EC";case"Ed":return"OKP";case"ML":return"AKP";default:throw new l('Unsupported "alg" value for a JSON Web Key Set')}}function yt(e){return e&&typeof e=="object"&&Array.isArray(e.keys)&&e.keys.every(St)}function St(e){return y(e)}var fe=class{#r;#s=new WeakMap;constructor(t){if(!yt(t))throw new M("JSON Web Key Set malformed");this.#r=structuredClone(t)}jwks(){return this.#r}async getKey(t,r){let{alg:o,kid:n}={...t,...r?.header},i=mt(o),s=this.#r.keys.filter(a=>{let c=i===a.kty;if(c&&typeof n=="string"&&(c=n===a.kid),c&&(typeof a.alg=="string"||i==="AKP")&&(c=o===a.alg),c&&typeof a.use=="string"&&(c=a.use==="sig"),c&&Array.isArray(a.key_ops)&&(c=a.key_ops.includes("verify")),c)switch(o){case"ES256":c=a.crv==="P-256";break;case"ES384":c=a.crv==="P-384";break;case"ES512":c=a.crv==="P-521";break;case"Ed25519":case"EdDSA":c=a.crv==="Ed25519";break}return c}),{0:f,length:d}=s;if(d===0)throw new W;if(d!==1){let a=new $,c=this.#s;throw a[Symbol.asyncIterator]=async function*(){for(let S of s)try{yield await Me(c,S,o)}catch{}},a}return Me(this.#s,f,o)}};async function Me(e,t,r){let o=e.get(t)||e.set(t,{}).get(t);if(o[r]===void 0){let n=await Ie({...t,ext:!0},r);if(n instanceof Uint8Array||n.type!=="public")throw new M("JSON Web Key Set members must be public keys");o[r]=n}return o[r]}function D(e){let t=new fe(e),r=async(o,n)=>t.getKey(o,n);return Object.defineProperties(r,{jwks:{value:()=>structuredClone(t.jwks()),enumerable:!1,configurable:!1,writable:!1}}),r}function Et(){return typeof WebSocketPair<"u"||typeof navigator<"u"&&navigator.userAgent==="Cloudflare-Workers"||typeof EdgeRuntime<"u"&&EdgeRuntime==="vercel"}var ue;(typeof navigator>"u"||!navigator.userAgent?.startsWith?.("Mozilla/5.0 "))&&(ue="jose/v6.2.3");var Be=Symbol();async function gt(e,t,r,o=fetch){let n=await o(e,{method:"GET",signal:r,redirect:"manual",headers:t}).catch(i=>{throw i.name==="TimeoutError"?new V:i});if(n.status!==200)throw new h("Expected 200 OK from the JSON Web Key Set HTTP response");try{return await n.json()}catch{throw new h("Failed to parse the JSON Web Key Set HTTP response as JSON")}}var q=Symbol();function At(e,t){return!(typeof e!="object"||e===null||!("uat"in e)||typeof e.uat!="number"||Date.now()-e.uat>=t||!("jwks"in e)||!y(e.jwks)||!Array.isArray(e.jwks.keys)||!Array.prototype.every.call(e.jwks.keys,y))}var de=class{#r;#s;#c;#a;#o;#e;#t;#f;#n;#i;constructor(t,r){if(!(t instanceof URL))throw new TypeError("url must be an instance of URL");this.#r=new URL(t.href),this.#s=typeof r?.timeoutDuration=="number"?r?.timeoutDuration:5e3,this.#c=typeof r?.cooldownDuration=="number"?r?.cooldownDuration:3e4,this.#a=typeof r?.cacheMaxAge=="number"?r?.cacheMaxAge:6e5,this.#t=new Headers(r?.headers),ue&&!this.#t.has("User-Agent")&&this.#t.set("User-Agent",ue),this.#t.has("accept")||(this.#t.set("accept","application/json"),this.#t.append("accept","application/jwk-set+json")),this.#f=r?.[Be],r?.[q]!==void 0&&(this.#i=r?.[q],At(r?.[q],this.#a)&&(this.#o=this.#i.uat,this.#n=D(this.#i.jwks)))}pendingFetch(){return!!this.#e}coolingDown(){return typeof this.#o=="number"?Date.now(){this.#n=D(t),this.#i&&(this.#i.uat=Date.now(),this.#i.jwks=t),this.#o=Date.now(),this.#e=void 0}).catch(t=>{throw this.#e=void 0,t}),await this.#e}};function pe(e,t){let r=new de(e,t),o=async(n,i)=>r.getKey(n,i);return Object.defineProperties(o,{coolingDown:{get:()=>r.coolingDown(),enumerable:!0,configurable:!1},fresh:{get:()=>r.fresh(),enumerable:!0,configurable:!1},reload:{value:()=>r.reload(),enumerable:!0,configurable:!1,writable:!1},reloading:{get:()=>r.pendingFetch(),enumerable:!0,configurable:!1},jwks:{value:()=>r.jwks(),enumerable:!0,configurable:!1,writable:!1}}),o}function le(e){let t;if(typeof e=="string"){let r=e.split(".");(r.length===3||r.length===5)&&([t]=r)}else if(typeof e=="object"&&e)if("protected"in e)t=e.protected;else throw new TypeError("Token does not contain a Protected Header");try{if(typeof t!="string"||!t)throw new Error;let r=JSON.parse(A.decode(w(t)));if(!y(r))throw new Error;return r}catch{throw new TypeError("Invalid Token or Protected Header formatting")}}var T={BootError:m.ServiceUnavailable,InvalidWorkerResponse:m.InternalServerError,WorkerLimit:546},bt={[T.BootError]:"BOOT_ERROR",[T.InvalidWorkerResponse]:"WORKER_ERROR",[T.WorkerLimit]:"WORKER_LIMIT"},Tt={[T.BootError]:"Worker failed to boot (please check logs)",[T.InvalidWorkerResponse]:"Function exited due to an error (please check logs)",[T.WorkerLimit]:"Worker failed to respond due to a resource limit (please check logs)"},Rt=["HOME","HOSTNAME","PATH","PWD"],Ge=Deno.env.get("SUPABASE_INTERNAL_HOST_PORT"),Kt=Deno.env.get("SUPABASE_INTERNAL_JWT_SECRET"),_t=new URL("/auth/v1/.well-known/jwks.json",Deno.env.get("SUPABASE_URL")),Pt=Deno.env.get("SUPABASE_INTERNAL_DEBUG")==="true",Ct=Deno.env.get("SUPABASE_INTERNAL_FUNCTIONS_CONFIG"),Fe=Deno.env.get("SUPABASE_INTERNAL_PUBLISHABLE_KEY"),ke=Deno.env.get("SUPABASE_INTERNAL_SECRET_KEY"),$e=parseInt(Deno.env.get("SUPABASE_INTERNAL_WALLCLOCK_LIMIT_SEC")),It=new Map([[Deno.errors.InvalidWorkerCreation,T.BootError],[Deno.errors.InvalidWorkerResponse,T.InvalidWorkerResponse],[Deno.errors.WorkerRequestCancelled,T.WorkerLimit]]),Ve=`Serving functions on http://127.0.0.1:${Ge}/functions/v1/`,xt=(i=>(i.MissingAuthHeader="UNAUTHORIZED_NO_AUTH_HEADER",i.InvalidLegacyJWT="UNAUTHORIZED_LEGACY_JWT",i.InvalidAsymmetricJWT="UNAUTHORIZED_ASYMMETRIC_JWT",i.InvalidTokenFormat="UNAUTHORIZED_INVALID_JWT_FORMAT",i.UnsupportedTokenAlgorithm="UNAUTHORIZED_UNSUPPORTED_TOKEN_ALGORITHM",i))(xt||{});function U(e,t,r={}){let o={...r},n=null;return e&&(typeof e=="object"?(o["Content-Type"]="application/json",n=JSON.stringify(e)):typeof e=="string"?(o["Content-Type"]="text/plain",n=e):n=null),new Response(n,{status:t,headers:o})}function he({code:e,message:t="Invalid JWT"}){return U({code:e,message:t,msg:t},m.Unauthorized,{"sb-error-code":e,"Access-Control-Expose-Headers":"sb-error-code"})}var _=(()=>{try{let e=JSON.parse(Ct);return Pt&&console.log("Functions config:",JSON.stringify(e,null,2)),e}catch(e){throw new Error("Failed to parse functions config",{cause:e})}})();function Wt(e){let t=e.split(" "),[r,o]=t;return r!=="Bearer"||t.length!==2?null:o}function vt(e){let t=e.headers.get("authorization"),o=e.headers.get("sb-api-key")?.replace("Bearer","")?.trim();if(!t&&!o)return{code:"UNAUTHORIZED_NO_AUTH_HEADER",message:"Missing authorization header"};let n=Wt(t??""),i=!n||n.startsWith("sb_")?o:n;return i||{code:"UNAUTHORIZED_INVALID_JWT_FORMAT",message:"Invalid JWT format"}}async function Ot(e,t){let o=new TextEncoder().encode(e);try{await Y(t,o)}catch(n){return console.error("Symmetric Legacy JWT verification error",n),{code:"UNAUTHORIZED_LEGACY_JWT"}}return null}var me=(()=>{try{return D(JSON.parse(Deno.env.get("SUPABASE_JWKS")))}catch{return null}})();async function Jt(e,t){try{me||(me=pe(new URL(e))),await Y(t,me)}catch(r){return console.error("Asymmetric JWT verification error",r),{code:"UNAUTHORIZED_ASYMMETRIC_JWT"}}return null}async function Nt(e,t,r){let o;try{o=le(r).alg}catch(n){return console.error("JWT format error",n),{code:"UNAUTHORIZED_INVALID_JWT_FORMAT",message:"Invalid JWT format"}}return o?o==="HS256"?(console.log(`Legacy token type detected, attempting ${o} verification.`),await Ot(e,r)):o==="ES256"||o==="RS256"?await Jt(t,r):{code:"UNAUTHORIZED_UNSUPPORTED_TOKEN_ALGORITHM",message:`Unsupported JWT algorithm ${o}`}:{code:"UNAUTHORIZED_INVALID_JWT_FORMAT",message:"Invalid JWT format"}}async function Dt({entrypointPath:e,importMapPath:t}){if(t)return!1;let r=Q(ee(e),"package.json");try{await Deno.lstat(r)}catch(o){if(o instanceof Deno.errors.NotFound)return!1}return!0}function Ut(e){let t=new URL(e.url),r=e.headers.get("x-forwarded-host");t.hostname=r??t.hostname;let o=new Request(t,e.clone());return o.headers.delete("sb-api-key"),EdgeRuntime.applySupabaseTag(e,o),o}Deno.serve({handler:async e=>{let t=new URL(e.url),{pathname:r}=t;if(r==="/_internal/health")return U({message:"ok"},m.OK);if(r==="/_internal/metric"){let p=await EdgeRuntime.getRuntimeMetrics();return Response.json(p)}let n=r.split("/")[1];if(!n||!(n in _))return U("Function not found",m.NotFound);if(e.method!=="OPTIONS"&&_[n].verifyJWT)try{let p=vt(e);if(typeof p!="string")return he(p);let b=await Nt(Kt,_t,p);if(b)return he(b)}catch(p){return console.error(p),he({code:"UNAUTHORIZED_INVALID_JWT_FORMAT",message:"Invalid JWT format"})}let i=ee(_[n].entrypointPath);console.error(`serving the request with ${i}`);let s=256,f=isFinite($e)?$e*1e3:400*1e3,d=!1,a={...Deno.env.toObject(),...Object.fromEntries(Object.entries(_[n].env??{}).filter(([p,b])=>!p.startsWith("SUPABASE_")))};Fe&&(a.SUPABASE_PUBLISHABLE_KEYS=JSON.stringify({default:Fe})),ke&&(a.SUPABASE_SECRET_KEYS=JSON.stringify({default:ke}));let c=Object.entries(a).filter(([p,b])=>!Rt.includes(p)&&!p.startsWith("SUPABASE_INTERNAL_")),S=!1,P="",C=1e3,E=2e3,R="tc39",z=Q(Deno.cwd(),_[n].entrypointPath),Xe=ye(z).href,Ye=await Dt(_[n]),qe=_[n].staticFiles;try{let p=await EdgeRuntime.userWorkers.create({servicePath:i,memoryLimitMb:s,workerTimeoutMs:f,noModuleCache:d,noNpm:!Ye,importMapPath:_[n].importMapPath,envVars:c,forceCreate:S,customModuleRoot:P,cpuTimeSoftLimitMs:C,cpuTimeHardLimitMs:E,decoratorType:R,maybeEntrypoint:Xe,context:{useReadSyncFileAPI:!0},staticPatterns:qe}),b=Ut(e);return await p.fetch(b)}catch(p){console.error(p);for(let[b,Z]of It.entries())if(b!==void 0&&p instanceof b)return U({code:bt[Z],message:Tt[Z]},Z);return U({code:j[m.InternalServerError],message:"Request failed due to an internal server error",trace:JSON.stringify(p.stack)},m.InternalServerError)}},onListen:()=>{try{let e=Deno.env.get("SUPABASE_INTERNAL_FUNCTIONS_CONFIG");if(e){let r=JSON.parse(e),o=Object.keys(r),i=o.slice(0,5).map(f=>` - http://127.0.0.1:${Ge}/functions/v1/${f}`),s=o.length>0?` ${i.join(` `)}${o.length>5?` -... and ${o.length-5} more functions`:""}`:"";console.log(`${$e}${s} -Using ${Deno.version.deno}`)}}catch{console.log(`${$e} -Using ${Deno.version.deno}`)}},onError:e=>P({code:Z[h.InternalServerError],message:"Request failed due to an internal server error",trace:JSON.stringify(e.stack)},h.InternalServerError)});export{xt as extractBearerToken,Nt as prepareUserRequest,Ot as verifyHybridJWT}; +... and ${o.length-5} more functions`:""}`:"";console.log(`${Ve}${s} +Using ${Deno.version.deno}`)}}catch{console.log(`${Ve} +Using ${Deno.version.deno}`)}},onError:e=>U({code:j[m.InternalServerError],message:"Request failed due to an internal server error",trace:JSON.stringify(e.stack)},m.InternalServerError)});export{xt as RequestErrors,Wt as extractBearerToken,Ut as prepareUserRequest,Nt as verifyHybridJWT}; diff --git a/apps/cli-go/internal/gen/types/types.go b/apps/cli-go/internal/gen/types/types.go index c12a7b58eb..688ab6302f 100644 --- a/apps/cli-go/internal/gen/types/types.go +++ b/apps/cli-go/internal/gen/types/types.go @@ -16,7 +16,6 @@ import ( "github.com/jackc/pgconn" "github.com/jackc/pgx/v4" "github.com/spf13/afero" - "github.com/spf13/viper" "github.com/supabase/cli/internal/utils" "github.com/supabase/cli/pkg/api" ) @@ -192,10 +191,8 @@ func isRequireSSL(ctx context.Context, dbUrl string, options ...func(*pgx.ConnCo } return false, err } - // SSL is not supported in debug mode - require := !viper.GetBool("DEBUG") - debugf("isRequireSSL result require_ssl=%t debug_mode=%t", require, viper.GetBool("DEBUG")) - return require, conn.Close(ctx) + debugf("isRequireSSL result require_ssl=true") + return true, conn.Close(ctx) } func IsSSLDebugEnabled() bool { diff --git a/apps/cli-go/internal/hostnames/activate/activate.go b/apps/cli-go/internal/hostnames/activate/activate.go index 11a5e41248..3cfb567650 100644 --- a/apps/cli-go/internal/hostnames/activate/activate.go +++ b/apps/cli-go/internal/hostnames/activate/activate.go @@ -7,6 +7,7 @@ import ( "github.com/go-errors/errors" "github.com/spf13/afero" "github.com/supabase/cli/internal/hostnames" + "github.com/supabase/cli/internal/telemetry" "github.com/supabase/cli/internal/utils" ) @@ -15,6 +16,9 @@ func Run(ctx context.Context, projectRef string, fsys afero.Fs) error { if err != nil { return errors.Errorf("failed to activate custom hostname: %w", err) } else if resp.JSON201 == nil { + if feature, orgSlug, isGated := utils.SuggestUpgradeOnError(ctx, projectRef, "", resp.StatusCode(), resp.Body); isGated { + telemetry.TrackUpgradeSuggested(ctx, feature, orgSlug) + } return errors.Errorf("unexpected activate hostname status %d: %s", resp.StatusCode(), string(resp.Body)) } hostnames.PrintStatus(resp.JSON201, os.Stderr) diff --git a/apps/cli-go/internal/hostnames/create/create.go b/apps/cli-go/internal/hostnames/create/create.go index ef6f01e603..15753fe81d 100644 --- a/apps/cli-go/internal/hostnames/create/create.go +++ b/apps/cli-go/internal/hostnames/create/create.go @@ -7,6 +7,7 @@ import ( "github.com/go-errors/errors" "github.com/spf13/afero" "github.com/supabase/cli/internal/hostnames" + "github.com/supabase/cli/internal/telemetry" "github.com/supabase/cli/internal/utils" "github.com/supabase/cli/pkg/api" ) @@ -24,6 +25,9 @@ func Run(ctx context.Context, projectRef string, customHostname string, fsys afe if err != nil { return errors.Errorf("failed to create custom hostname: %w", err) } else if resp.JSON201 == nil { + if feature, orgSlug, isGated := utils.SuggestUpgradeOnError(ctx, projectRef, "", resp.StatusCode(), resp.Body); isGated { + telemetry.TrackUpgradeSuggested(ctx, feature, orgSlug) + } return errors.Errorf("unexpected create hostname status %d: %s", resp.StatusCode(), string(resp.Body)) } hostnames.PrintStatus(resp.JSON201, os.Stderr) diff --git a/apps/cli-go/internal/hostnames/get/get.go b/apps/cli-go/internal/hostnames/get/get.go index ded7c8622f..88235275e4 100644 --- a/apps/cli-go/internal/hostnames/get/get.go +++ b/apps/cli-go/internal/hostnames/get/get.go @@ -7,6 +7,7 @@ import ( "github.com/go-errors/errors" "github.com/spf13/afero" "github.com/supabase/cli/internal/hostnames" + "github.com/supabase/cli/internal/telemetry" "github.com/supabase/cli/internal/utils" ) @@ -15,6 +16,9 @@ func Run(ctx context.Context, projectRef string, fsys afero.Fs) error { if err != nil { return errors.Errorf("failed to get custom hostname: %w", err) } else if resp.JSON200 == nil { + if feature, orgSlug, isGated := utils.SuggestUpgradeOnError(ctx, projectRef, "", resp.StatusCode(), resp.Body); isGated { + telemetry.TrackUpgradeSuggested(ctx, feature, orgSlug) + } return errors.Errorf("unexpected get hostname status %d: %s", resp.StatusCode(), string(resp.Body)) } hostnames.PrintStatus(resp.JSON200, os.Stderr) diff --git a/apps/cli-go/internal/hostnames/reverify/reverify.go b/apps/cli-go/internal/hostnames/reverify/reverify.go index f38f38fd49..6c6c610f98 100644 --- a/apps/cli-go/internal/hostnames/reverify/reverify.go +++ b/apps/cli-go/internal/hostnames/reverify/reverify.go @@ -7,6 +7,7 @@ import ( "github.com/go-errors/errors" "github.com/spf13/afero" "github.com/supabase/cli/internal/hostnames" + "github.com/supabase/cli/internal/telemetry" "github.com/supabase/cli/internal/utils" ) @@ -15,6 +16,9 @@ func Run(ctx context.Context, projectRef string, fsys afero.Fs) error { if err != nil { return errors.Errorf("failed to re-verify custom hostname: %w", err) } else if resp.JSON201 == nil { + if feature, orgSlug, isGated := utils.SuggestUpgradeOnError(ctx, projectRef, "", resp.StatusCode(), resp.Body); isGated { + telemetry.TrackUpgradeSuggested(ctx, feature, orgSlug) + } return errors.Errorf("unexpected re-verify hostname status %d: %s", resp.StatusCode(), string(resp.Body)) } hostnames.PrintStatus(resp.JSON201, os.Stderr) diff --git a/apps/cli-go/internal/sso/create/create.go b/apps/cli-go/internal/sso/create/create.go index 3716e5ee73..c471580ea8 100644 --- a/apps/cli-go/internal/sso/create/create.go +++ b/apps/cli-go/internal/sso/create/create.go @@ -71,8 +71,8 @@ func Run(ctx context.Context, params RunParams) error { } if resp.JSON201 == nil { - if orgSlug, isGated := utils.SuggestUpgradeOnError(ctx, params.ProjectRef, "auth.saml_2", resp.StatusCode()); isGated { - telemetry.TrackUpgradeSuggested(ctx, "auth.saml_2", orgSlug) + if feature, orgSlug, isGated := utils.SuggestUpgradeOnError(ctx, params.ProjectRef, "auth.saml_2", resp.StatusCode(), resp.Body); isGated { + telemetry.TrackUpgradeSuggested(ctx, feature, orgSlug) } if resp.StatusCode() == http.StatusNotFound { return errors.New("SAML 2.0 support is not enabled for this project. Please enable it through the dashboard") diff --git a/apps/cli-go/internal/sso/list/list.go b/apps/cli-go/internal/sso/list/list.go index 57ff92dd51..62de936929 100644 --- a/apps/cli-go/internal/sso/list/list.go +++ b/apps/cli-go/internal/sso/list/list.go @@ -18,8 +18,8 @@ func Run(ctx context.Context, ref, format string) error { } if resp.JSON200 == nil { - if orgSlug, isGated := utils.SuggestUpgradeOnError(ctx, ref, "auth.saml_2", resp.StatusCode()); isGated { - telemetry.TrackUpgradeSuggested(ctx, "auth.saml_2", orgSlug) + if feature, orgSlug, isGated := utils.SuggestUpgradeOnError(ctx, ref, "auth.saml_2", resp.StatusCode(), resp.Body); isGated { + telemetry.TrackUpgradeSuggested(ctx, feature, orgSlug) } if resp.StatusCode() == http.StatusNotFound { return errors.New("Looks like SAML 2.0 support is not enabled for this project. Please use the dashboard to enable it.") diff --git a/apps/cli-go/internal/sso/remove/remove.go b/apps/cli-go/internal/sso/remove/remove.go index a13d946c82..0145760167 100644 --- a/apps/cli-go/internal/sso/remove/remove.go +++ b/apps/cli-go/internal/sso/remove/remove.go @@ -24,8 +24,8 @@ func Run(ctx context.Context, ref, providerId, format string) error { } if resp.JSON200 == nil { - if orgSlug, isGated := utils.SuggestUpgradeOnError(ctx, ref, "auth.saml_2", resp.StatusCode()); isGated { - telemetry.TrackUpgradeSuggested(ctx, "auth.saml_2", orgSlug) + if feature, orgSlug, isGated := utils.SuggestUpgradeOnError(ctx, ref, "auth.saml_2", resp.StatusCode(), resp.Body); isGated { + telemetry.TrackUpgradeSuggested(ctx, feature, orgSlug) } if resp.StatusCode() == http.StatusNotFound { return errors.Errorf("An identity provider with ID %q could not be found.", providerId) diff --git a/apps/cli-go/internal/sso/update/update.go b/apps/cli-go/internal/sso/update/update.go index 6a4dc9b294..c2a206434f 100644 --- a/apps/cli-go/internal/sso/update/update.go +++ b/apps/cli-go/internal/sso/update/update.go @@ -45,8 +45,8 @@ func Run(ctx context.Context, params RunParams) error { } if getResp.JSON200 == nil { - if orgSlug, isGated := utils.SuggestUpgradeOnError(ctx, params.ProjectRef, "auth.saml_2", getResp.StatusCode()); isGated { - telemetry.TrackUpgradeSuggested(ctx, "auth.saml_2", orgSlug) + if feature, orgSlug, isGated := utils.SuggestUpgradeOnError(ctx, params.ProjectRef, "auth.saml_2", getResp.StatusCode(), getResp.Body); isGated { + telemetry.TrackUpgradeSuggested(ctx, feature, orgSlug) } if getResp.StatusCode() == http.StatusNotFound { return errors.Errorf("An identity provider with ID %q could not be found.", parsed) @@ -119,8 +119,8 @@ func Run(ctx context.Context, params RunParams) error { if putResp.JSON200 == nil { // GET branch above early-returns on failure, so this and the GET fire are mutually exclusive (max one event per invocation). - if orgSlug, isGated := utils.SuggestUpgradeOnError(ctx, params.ProjectRef, "auth.saml_2", putResp.StatusCode()); isGated { - telemetry.TrackUpgradeSuggested(ctx, "auth.saml_2", orgSlug) + if feature, orgSlug, isGated := utils.SuggestUpgradeOnError(ctx, params.ProjectRef, "auth.saml_2", putResp.StatusCode(), putResp.Body); isGated { + telemetry.TrackUpgradeSuggested(ctx, feature, orgSlug) } return errors.New("unexpected error fetching identity provider: " + string(putResp.Body)) } diff --git a/apps/cli-go/internal/utils/connect.go b/apps/cli-go/internal/utils/connect.go index e19aaaf523..406e515370 100644 --- a/apps/cli-go/internal/utils/connect.go +++ b/apps/cli-go/internal/utils/connect.go @@ -321,8 +321,6 @@ func SetConnectSuggestion(err error) { "Make sure your local IP is allowed in Network Restrictions and Network Bans.\n%s/project/_/database/settings", CurrentProfile.DashboardURL, ) - } else if strings.Contains(msg, "SSL connection is required") && viper.GetBool("DEBUG") { - CmdSuggestion = "SSL connection is not supported with --debug flag" } else if strings.Contains(msg, "SCRAM exchange: Wrong password") || strings.Contains(msg, "failed SASL auth") { // password authentication failed for user / invalid SCRAM server-final-message received from server CmdSuggestion = SuggestEnvVar @@ -346,7 +344,9 @@ func ConnectByConfigStream(ctx context.Context, config pgconn.Config, w io.Write return ConnectLocalPostgres(ctx, config, options...) } fmt.Fprintln(w, "Connecting to remote database...") - opts := append(options, func(cc *pgx.ConnConfig) { + // Ahead of the caller's overrides so they still win, e.g. pgtest clearing TLS. + opts := append([]func(*pgx.ConnConfig){preserveTLSConfig(config)}, options...) + opts = append(opts, func(cc *pgx.ConnConfig) { if DNSResolver.Value == DNS_OVER_HTTPS { cc.LookupFunc = FallbackLookupIP } @@ -361,6 +361,33 @@ func ConnectByConfigStream(ctx context.Context, config pgconn.Config, w io.Write return ConnectByUrl(ctx, ToPostgresURL(config), opts...) } +// preserveTLSConfig replays the TLS state pgconn resolved from sslmode, which +// ToPostgresURL cannot serialize (it lives in TLSConfig/Fallbacks, not +// RuntimeParams) so the rebuilt URL re-resolves it from PGSSLMODE and libpq's +// "prefer" default. Nil Fallbacks, which ParseConfig never produces, marks a +// config assembled in code with no preference to replay. +func preserveTLSConfig(config pgconn.Config) func(*pgx.ConnConfig) { + return func(cc *pgx.ConnConfig) { + if config.Fallbacks == nil { + return + } + cc.TLSConfig = config.TLSConfig + // Rebuild against the URL's endpoint: callers may override the port + // (GetPoolerConfig pins 5432), and extra HA hosts are dropped anyway. + cc.Fallbacks = nil + for _, fb := range config.Fallbacks { + if fb.Host != config.Host { + break + } + cc.Fallbacks = append(cc.Fallbacks, &pgconn.FallbackConfig{ + Host: cc.Host, + Port: cc.Port, + TLSConfig: fb.TLSConfig, + }) + } + } +} + func ConnectByConfig(ctx context.Context, config pgconn.Config, options ...func(*pgx.ConnConfig)) (*pgx.Conn, error) { return ConnectByConfigStream(ctx, config, os.Stderr, options...) } diff --git a/apps/cli-go/internal/utils/connect_test.go b/apps/cli-go/internal/utils/connect_test.go index a7b772d70f..80684df8d1 100644 --- a/apps/cli-go/internal/utils/connect_test.go +++ b/apps/cli-go/internal/utils/connect_test.go @@ -11,6 +11,7 @@ import ( "github.com/go-errors/errors" "github.com/h2non/gock" "github.com/jackc/pgconn" + "github.com/jackc/pgx/v4" "github.com/spf13/viper" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -208,10 +209,12 @@ func TestSetConnectSuggestion(t *testing.T) { suggestion: "", }, { + // The debug proxy negotiates TLS itself, so --debug is no longer the + // reason a server rejects an unencrypted connection (#5872). name: "ssl required with debug flag", err: errors.New("SSL connection is required"), debug: true, - suggestion: "SSL connection is not supported with --debug flag", + suggestion: "", }, { name: "wrong password via SCRAM", @@ -411,3 +414,44 @@ func TestPostgresURLWithoutPassword(t *testing.T) { assert.Equal(t, `postgresql://postgres@[2406:da18:4fd:9b0d:80ec:9812:3e65:450b]:5432/?connect_timeout=10&options=test`, url) assert.NotContains(t, url, "%21%40%23") } + +func TestPreserveTLSConfig(t *testing.T) { + const dsn = "postgresql://postgres:pw@example.com:5432/postgres" + + t.Run("replays the sslmode resolved from the connection string", func(t *testing.T) { + for _, sslmode := range []string{"disable", "require", "verify-full"} { + parsed, err := pgconn.ParseConfig(dsn + "?sslmode=" + sslmode) + require.NoError(t, err) + rebuilt, err := pgx.ParseConfig(ToPostgresURL(*parsed)) + require.NoError(t, err) + // Run test + preserveTLSConfig(*parsed)(rebuilt) + // Check TLS matches the DSN, not the re-parse default of "prefer" + assert.Equal(t, parsed.TLSConfig, rebuilt.TLSConfig, sslmode) + } + }) + + t.Run("mandates TLS even when PGSSLMODE would disable it", func(t *testing.T) { + t.Setenv("PGSSLMODE", "disable") + parsed, err := pgconn.ParseConfig(dsn + "?sslmode=require") + require.NoError(t, err) + require.NotNil(t, parsed.TLSConfig) + rebuilt, err := pgx.ParseConfig(ToPostgresURL(*parsed)) + require.NoError(t, err) + require.Nil(t, rebuilt.TLSConfig) + // Run test + preserveTLSConfig(*parsed)(rebuilt) + // Check the env var no longer overrides the connection string + assert.NotNil(t, rebuilt.TLSConfig) + }) + + t.Run("leaves configs assembled in code untouched", func(t *testing.T) { + rebuilt, err := pgx.ParseConfig(ToPostgresURL(dbConfig)) + require.NoError(t, err) + before := rebuilt.TLSConfig + // Run test + preserveTLSConfig(dbConfig)(rebuilt) + // Check libpq's default resolution is preserved + assert.Same(t, before, rebuilt.TLSConfig) + }) +} diff --git a/apps/cli-go/internal/utils/plan_gate.go b/apps/cli-go/internal/utils/plan_gate.go index 7267a2de39..c666725557 100644 --- a/apps/cli-go/internal/utils/plan_gate.go +++ b/apps/cli-go/internal/utils/plan_gate.go @@ -2,7 +2,11 @@ package utils import ( "context" + "encoding/json" "fmt" + "strings" + + "github.com/supabase/cli/pkg/api" ) func GetOrgSlugFromProjectRef(ctx context.Context, projectRef string) (string, error) { @@ -20,34 +24,74 @@ func GetOrgBillingURL(orgSlug string) string { return fmt.Sprintf("%s/org/%s/billing", GetSupabaseDashboardURL(), orgSlug) } -// SuggestUpgradeOnError checks if a failed API response is due to plan limitations -// by looking up the org's entitlements. Only sets CmdSuggestion when the entitlements -// API confirms the feature is gated (hasAccess == false). Returns the resolved org -// slug and true if a billing suggestion was shown (so callers can fire telemetry). -// Only checks on 4xx client errors; skips 2xx (success) and 5xx (server errors). -func SuggestUpgradeOnError(ctx context.Context, projectRef, featureKey string, statusCode int) (orgSlug string, isGated bool) { +func orgSlugFromBillingURL(billingURL string) string { + const marker = "/org/" + start := strings.Index(billingURL, marker) + if start < 0 { + return "" + } + rest := billingURL[start+len(marker):] + if end := strings.Index(rest, "/"); end >= 0 { + return rest[:end] + } + return rest +} + +func parsePlanGateError(body []byte) (feature, upgradeURL string, ok bool) { + var envelope api.PlanGateErrorBody + if err := json.Unmarshal(body, &envelope); err != nil || envelope.Error == nil { + return "", "", false + } + if envelope.Error.Code != api.EntitlementRequired || envelope.Error.Feature == "" { + return "", "", false + } + if envelope.Error.UpgradeUrl == nil || *envelope.Error.UpgradeUrl == "" { + return "", "", false + } + return envelope.Error.Feature, *envelope.Error.UpgradeUrl, true +} + +func setUpgradeSuggestion(billingURL string) { + CmdSuggestion = fmt.Sprintf("Your organization does not have access to this feature. Upgrade your plan: %s", Bold(billingURL)) +} + +// SuggestUpgradeOnError checks whether a failed API response is a plan gate and +// sets CmdSuggestion with the billing URL when it is. The entitlement_required +// envelope on the response body is authoritative and needs no network calls; +// when absent, a non-empty featureKey falls back to the entitlements lookup. +// An empty featureKey disables the fallback (envelope-only sites). Returns the +// effective feature and org slug for telemetry, and whether a suggestion was +// shown. Only fires on 4xx. +func SuggestUpgradeOnError(ctx context.Context, projectRef, featureKey string, statusCode int, body []byte) (gatedFeature, orgSlug string, isGated bool) { if statusCode < 400 || statusCode >= 500 { - return + return "", "", false + } + + if feature, upgradeURL, ok := parsePlanGateError(body); ok { + setUpgradeSuggestion(upgradeURL) + return feature, orgSlugFromBillingURL(upgradeURL), true + } + + if featureKey == "" { + return "", "", false } - orgSlug, err := GetOrgSlugFromProjectRef(ctx, projectRef) + slug, err := GetOrgSlugFromProjectRef(ctx, projectRef) if err != nil { - return + return "", "", false } - resp, err := GetSupabase().V1GetOrganizationEntitlementsWithResponse(ctx, orgSlug) + resp, err := GetSupabase().V1GetOrganizationEntitlementsWithResponse(ctx, slug) if err != nil || resp.JSON200 == nil { - return + return "", slug, false } for _, e := range resp.JSON200.Entitlements { if string(e.Feature.Key) == featureKey && !e.HasAccess { - billingURL := GetOrgBillingURL(orgSlug) - CmdSuggestion = fmt.Sprintf("Your organization does not have access to this feature. Upgrade your plan: %s", Bold(billingURL)) - isGated = true - return + setUpgradeSuggestion(GetOrgBillingURL(slug)) + return featureKey, slug, true } } - return + return "", slug, false } diff --git a/apps/cli-go/internal/utils/plan_gate_test.go b/apps/cli-go/internal/utils/plan_gate_test.go index c00f00a381..5f85d5076f 100644 --- a/apps/cli-go/internal/utils/plan_gate_test.go +++ b/apps/cli-go/internal/utils/plan_gate_test.go @@ -90,8 +90,9 @@ func TestSuggestUpgradeOnError(t *testing.T) { t.Cleanup(apitest.MockPlatformAPI(t)) t.Cleanup(func() { CmdSuggestion = "" }) mockEntitlementsCheck(ref, "branching_limit", false) - slug, got := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusPaymentRequired) + feature, slug, got := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusPaymentRequired, nil) assert.True(t, got) + assert.Equal(t, "branching_limit", feature) assert.Equal(t, "my-org", slug) assert.Contains(t, CmdSuggestion, "/org/my-org/billing") assert.Contains(t, CmdSuggestion, "does not have access") @@ -101,8 +102,9 @@ func TestSuggestUpgradeOnError(t *testing.T) { t.Cleanup(apitest.MockPlatformAPI(t)) t.Cleanup(func() { CmdSuggestion = "" }) mockEntitlementsCheck(ref, "vanity_subdomain", false) - slug, got := SuggestUpgradeOnError(context.Background(), ref, "vanity_subdomain", http.StatusBadRequest) + feature, slug, got := SuggestUpgradeOnError(context.Background(), ref, "vanity_subdomain", http.StatusBadRequest, nil) assert.True(t, got) + assert.Equal(t, "vanity_subdomain", feature) assert.Equal(t, "my-org", slug) assert.Contains(t, CmdSuggestion, "/org/my-org/billing") assert.Contains(t, CmdSuggestion, "does not have access") @@ -112,7 +114,7 @@ func TestSuggestUpgradeOnError(t *testing.T) { t.Cleanup(apitest.MockPlatformAPI(t)) t.Cleanup(func() { CmdSuggestion = "" }) mockEntitlementsCheck(ref, "auth.saml_2", false) - slug, got := SuggestUpgradeOnError(context.Background(), ref, "auth.saml_2", http.StatusNotFound) + _, slug, got := SuggestUpgradeOnError(context.Background(), ref, "auth.saml_2", http.StatusNotFound, nil) assert.True(t, got) assert.Equal(t, "my-org", slug) assert.Contains(t, CmdSuggestion, "/org/my-org/billing") @@ -128,7 +130,7 @@ func TestSuggestUpgradeOnError(t *testing.T) { gock.New(DefaultApiHost). Get("/v1/organizations/my-org/entitlements"). Reply(http.StatusInternalServerError) - slug, got := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusPaymentRequired) + _, slug, got := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusPaymentRequired, nil) assert.False(t, got) assert.Equal(t, "my-org", slug) assert.Empty(t, CmdSuggestion) @@ -140,7 +142,7 @@ func TestSuggestUpgradeOnError(t *testing.T) { gock.New(DefaultApiHost). Get("/v1/projects/" + ref). Reply(http.StatusNotFound) - slug, got := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusPaymentRequired) + _, slug, got := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusPaymentRequired, nil) assert.False(t, got) assert.Empty(t, slug) assert.Empty(t, CmdSuggestion) @@ -150,7 +152,7 @@ func TestSuggestUpgradeOnError(t *testing.T) { t.Cleanup(apitest.MockPlatformAPI(t)) t.Cleanup(func() { CmdSuggestion = "" }) mockEntitlementsCheck(ref, "branching_limit", true) - slug, got := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusPaymentRequired) + _, slug, got := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusPaymentRequired, nil) assert.False(t, got) assert.Equal(t, "my-org", slug) assert.Empty(t, CmdSuggestion) @@ -158,22 +160,92 @@ func TestSuggestUpgradeOnError(t *testing.T) { t.Run("skips on 503 server error", func(t *testing.T) { CmdSuggestion = "" - _, got := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusServiceUnavailable) + _, _, got := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusServiceUnavailable, nil) assert.False(t, got) assert.Empty(t, CmdSuggestion) }) t.Run("skips on 200", func(t *testing.T) { CmdSuggestion = "" - _, got := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusOK) + _, _, got := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusOK, nil) assert.False(t, got) assert.Empty(t, CmdSuggestion) }) t.Run("skips on 201", func(t *testing.T) { CmdSuggestion = "" - _, got := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusCreated) + _, _, got := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusCreated, nil) assert.False(t, got) assert.Empty(t, CmdSuggestion) }) } + +func TestSuggestUpgradeEnvelope(t *testing.T) { + envelope := `{"message":"x","error":{"code":"entitlement_required","feature":"custom_domain","upgrade_url":"https://supabase.com/dashboard/org/acme/billing"}}` + + t.Run("envelope sets suggestion with zero API calls", func(t *testing.T) { + t.Cleanup(func() { CmdSuggestion = "" }) + feature, slug, gated := SuggestUpgradeOnError(context.Background(), "ref", "", http.StatusBadRequest, []byte(envelope)) + assert.True(t, gated) + assert.Equal(t, "custom_domain", feature) + assert.Equal(t, "acme", slug) + assert.Contains(t, CmdSuggestion, "org/acme/billing") + assert.Contains(t, CmdSuggestion, "does not have access") + }) + + t.Run("envelope feature wins over call-site featureKey", func(t *testing.T) { + t.Cleanup(func() { CmdSuggestion = "" }) + body := `{"message":"x","error":{"code":"entitlement_required","feature":"branching_persistent","upgrade_url":"https://supabase.com/dashboard/org/acme/billing"}}` + feature, _, gated := SuggestUpgradeOnError(context.Background(), "ref", "branching_limit", http.StatusPaymentRequired, []byte(body)) + assert.True(t, gated) + assert.Equal(t, "branching_persistent", feature) + }) + + t.Run("envelope without upgrade_url falls back to entitlements", func(t *testing.T) { + ref := apitest.RandomProjectRef() + t.Cleanup(apitest.MockPlatformAPI(t)) + t.Cleanup(func() { CmdSuggestion = "" }) + mockEntitlementsCheck(ref, "branching_limit", false) + body := `{"message":"x","error":{"code":"entitlement_required","feature":"branching_limit"}}` + feature, slug, gated := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusPaymentRequired, []byte(body)) + assert.True(t, gated) + assert.Equal(t, "branching_limit", feature) + assert.Equal(t, "my-org", slug) + assert.Contains(t, CmdSuggestion, "/org/my-org/billing") + }) + + t.Run("malformed body falls back to entitlements", func(t *testing.T) { + ref := apitest.RandomProjectRef() + t.Cleanup(apitest.MockPlatformAPI(t)) + t.Cleanup(func() { CmdSuggestion = "" }) + mockEntitlementsCheck(ref, "branching_limit", false) + _, _, gated := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusPaymentRequired, []byte(`not json`)) + assert.True(t, gated) + }) + + t.Run("no envelope and empty featureKey is a no-op with zero API calls", func(t *testing.T) { + t.Cleanup(func() { CmdSuggestion = "" }) + _, _, gated := SuggestUpgradeOnError(context.Background(), "ref", "", http.StatusNotFound, []byte(`{"message":"not found"}`)) + assert.False(t, gated) + assert.Empty(t, CmdSuggestion) + }) + + t.Run("non-gate error code is ignored", func(t *testing.T) { + t.Cleanup(func() { CmdSuggestion = "" }) + body := `{"message":"x","error":{"code":"validation_failed","feature":"custom_domain","upgrade_url":"https://supabase.com/dashboard/org/acme/billing"}}` + _, _, gated := SuggestUpgradeOnError(context.Background(), "ref", "", http.StatusBadRequest, []byte(body)) + assert.False(t, gated) + assert.Empty(t, CmdSuggestion) + }) +} + +func TestOrgSlugFromBillingURL(t *testing.T) { + cases := map[string]string{ + "https://supabase.com/dashboard/org/acme/billing": "acme", + "https://supabase.com/dashboard/org/acme": "acme", + "https://example.com/nope": "", + } + for in, want := range cases { + assert.Equal(t, want, orgSlugFromBillingURL(in), in) + } +} diff --git a/apps/cli-go/internal/vanity_subdomains/activate/activate.go b/apps/cli-go/internal/vanity_subdomains/activate/activate.go index 18b953953b..379f75f8f0 100644 --- a/apps/cli-go/internal/vanity_subdomains/activate/activate.go +++ b/apps/cli-go/internal/vanity_subdomains/activate/activate.go @@ -19,8 +19,8 @@ func Run(ctx context.Context, projectRef string, desiredSubdomain string, fsys a if err != nil { return errors.Errorf("failed activate vanity subdomain: %w", err) } else if resp.JSON201 == nil { - if orgSlug, isGated := utils.SuggestUpgradeOnError(ctx, projectRef, "vanity_subdomain", resp.StatusCode()); isGated { - telemetry.TrackUpgradeSuggested(ctx, "vanity_subdomain", orgSlug) + if feature, orgSlug, isGated := utils.SuggestUpgradeOnError(ctx, projectRef, "vanity_subdomain", resp.StatusCode(), resp.Body); isGated { + telemetry.TrackUpgradeSuggested(ctx, feature, orgSlug) } return errors.Errorf("unexpected activate vanity subdomain status %d: %s", resp.StatusCode(), string(resp.Body)) } diff --git a/apps/cli-go/internal/vanity_subdomains/check/check.go b/apps/cli-go/internal/vanity_subdomains/check/check.go index fa64a1e463..a22a70b00b 100644 --- a/apps/cli-go/internal/vanity_subdomains/check/check.go +++ b/apps/cli-go/internal/vanity_subdomains/check/check.go @@ -18,7 +18,7 @@ func Run(ctx context.Context, projectRef string, desiredSubdomain string, fsys a if err != nil { return errors.Errorf("failed to check vanity subdomain: %w", err) } else if resp.JSON201 == nil { - utils.SuggestUpgradeOnError(ctx, projectRef, "vanity_subdomain", resp.StatusCode()) + utils.SuggestUpgradeOnError(ctx, projectRef, "vanity_subdomain", resp.StatusCode(), resp.Body) return errors.Errorf("unexpected check vanity subdomain status %d: %s", resp.StatusCode(), string(resp.Body)) } if utils.OutputFormat.Value != utils.OutputPretty { diff --git a/apps/cli-go/internal/vanity_subdomains/get/get.go b/apps/cli-go/internal/vanity_subdomains/get/get.go index 303e9edec1..3897f50bc3 100644 --- a/apps/cli-go/internal/vanity_subdomains/get/get.go +++ b/apps/cli-go/internal/vanity_subdomains/get/get.go @@ -7,6 +7,7 @@ import ( "github.com/go-errors/errors" "github.com/spf13/afero" + "github.com/supabase/cli/internal/telemetry" "github.com/supabase/cli/internal/utils" ) @@ -15,6 +16,9 @@ func Run(ctx context.Context, projectRef string, fsys afero.Fs) error { if err != nil { return errors.Errorf("failed to get vanity subdomain: %w", err) } else if resp.JSON200 == nil { + if feature, orgSlug, isGated := utils.SuggestUpgradeOnError(ctx, projectRef, "", resp.StatusCode(), resp.Body); isGated { + telemetry.TrackUpgradeSuggested(ctx, feature, orgSlug) + } return errors.Errorf("unexpected vanity subdomain status %d: %s", resp.StatusCode(), string(resp.Body)) } if utils.OutputFormat.Value != utils.OutputPretty { diff --git a/apps/cli-go/pkg/config/pgdelta_version.go b/apps/cli-go/pkg/config/pgdelta_version.go index a38641ae7c..a2c016473f 100644 --- a/apps/cli-go/pkg/config/pgdelta_version.go +++ b/apps/cli-go/pkg/config/pgdelta_version.go @@ -4,7 +4,7 @@ import "strings" // DefaultPgDeltaNpmVersion is the npm dist-tag/version used for @supabase/pg-delta // when supabase/.temp/pgdelta-version is absent or empty. -const DefaultPgDeltaNpmVersion = "1.0.0-alpha.32" +const DefaultPgDeltaNpmVersion = "1.0.0-alpha.33" const pgDeltaNpmVersionPlaceholder = "1.0.0-alpha.20" diff --git a/apps/cli-go/pkg/config/templates/Dockerfile b/apps/cli-go/pkg/config/templates/Dockerfile index 19f01f35b7..4c4738eebb 100644 --- a/apps/cli-go/pkg/config/templates/Dockerfile +++ b/apps/cli-go/pkg/config/templates/Dockerfile @@ -1,18 +1,18 @@ # Exposed for updates by .github/dependabot.yml -FROM supabase/postgres:17.6.1.143 AS pg +FROM supabase/postgres:17.6.1.156 AS pg # Append to ServiceImages when adding new dependencies below FROM library/kong:2.8.1 AS kong FROM axllent/mailpit:v1.30.2 AS mailpit FROM postgrest/postgrest:v14.15 AS postgrest FROM supabase/postgres-meta:v0.96.6 AS pgmeta -FROM supabase/studio:2026.07.13-sha-b5ada96 AS studio +FROM supabase/studio:2026.07.27-sha-cbb076d AS studio FROM darthsim/imgproxy:v3.8.0 AS imgproxy FROM supabase/edge-runtime:v1.74.2 AS edgeruntime FROM timberio/vector:0.53.0-alpine AS vector FROM supabase/supavisor:2.9.7 AS supavisor -FROM supabase/gotrue:v2.193.0 AS gotrue -FROM supabase/realtime:v2.113.4 AS realtime -FROM supabase/storage-api:v1.66.4 AS storage +FROM supabase/gotrue:v2.194.0 AS gotrue +FROM supabase/realtime:v2.120.3 AS realtime +FROM supabase/storage-api:v1.67.20 AS storage FROM supabase/logflare:1.47.1 AS logflare # Append to JobImages when adding new dependencies below FROM supabase/pgadmin-schema-diff:cli-0.0.5 AS differ diff --git a/apps/cli/AGENTS.md b/apps/cli/AGENTS.md index d4830ccc9e..6596169854 100644 --- a/apps/cli/AGENTS.md +++ b/apps/cli/AGENTS.md @@ -295,12 +295,12 @@ The legacy shell sends the same PostHog events to the same product analytics pip - **Proxy handlers (`LegacyGoProxy.exec`) must NOT wrap with any instrumentation.** The Go subprocess fires its own telemetry; a TS wrapper would double-count `cli_command_executed`. - **When promoting a command from proxy to native, reproduce every `phtelemetry.*` call in the Go counterpart.** Grep `apps/cli-go/internal//` for `service.Capture`, `service.Alias`, `service.Identify`, `service.GroupIdentify`, and `TrackUpgradeSuggested`. The current Go custom events that legacy ports must reproduce when natively ported: - | Command | Event | Identity / groups | Go source | - | ------------------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------ | --------------------------------------------- | - | `login` | `cli_login_completed` | `analytics.alias(gotrueId, deviceId)` after token persists | `internal/login/login.go:283-296` | - | `link` | `cli_project_linked` | `analytics.groupIdentify("organization", slug, …)` + `analytics.groupIdentify("project", ref, …)` after link write | `internal/link/link.go:60` | - | `start` | `cli_stack_started` | none — fired after stack health check passes | `internal/start/start.go:1245` | - | `sso/{list,create,update,remove}`, `branches/{create,update}` | `cli_upgrade_suggested` | none — payload is `{feature_key, org_slug}`, fired inside billing-gate error branch | 7 call-sites under `internal/{sso,branches}/` | + | Command | Event | Identity / groups | Go source | + | --------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | + | `login` | `cli_login_completed` | `analytics.alias(gotrueId, deviceId)` after token persists | `internal/login/login.go:283-296` | + | `link` | `cli_project_linked` | `analytics.groupIdentify("organization", slug, …)` + `analytics.groupIdentify("project", ref, …)` after link write | `internal/link/link.go:60` | + | `start` | `cli_stack_started` | none — fired after stack health check passes | `internal/start/start.go:1245` | + | `sso/{list,create,update,remove}`, `branches/{create,update}`, `hostnames/{create,activate,get,reverify}`, `vanity_subdomains/{activate,get}` | `cli_upgrade_suggested` | none — payload is `{feature_key, org_slug}`, fired inside billing-gate error branch (`SuggestUpgradeOnError` is envelope-first; hostnames + vanity get are envelope-only) | call-sites under `internal/{sso,branches,hostnames,vanity_subdomains}/` | Reference pattern for login: `next/commands/login/login.handler.ts:38-62`. diff --git a/apps/cli/package.json b/apps/cli/package.json index b04c7fab9d..7f80212f3d 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -43,8 +43,8 @@ "jose": "^6.2.3" }, "devDependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.3.207", - "@anthropic-ai/sdk": "^0.111.0", + "@anthropic-ai/claude-agent-sdk": "^0.3.216", + "@anthropic-ai/sdk": "^0.112.4", "@clack/prompts": "^1.7.0", "@effect/atom-react": "catalog:", "@effect/platform-bun": "catalog:", @@ -52,7 +52,7 @@ "@effect/vitest": "catalog:", "@modelcontextprotocol/sdk": "^1.29.0", "@napi-rs/keyring": "^1.3.0", - "@parcel/watcher": "^2.5.6", + "@parcel/watcher": "^2.6.0", "@supabase/api": "workspace:*", "@supabase/config": "workspace:*", "@supabase/process-compose": "workspace:*", @@ -68,7 +68,7 @@ "dotenv": "^17.4.2", "effect": "catalog:", "esbuild": "^0.28.1", - "ink": "^7.1.0", + "ink": "^7.1.1", "ink-spinner": "^5.0.0", "knip": "catalog:", "oxfmt": "catalog:", @@ -76,10 +76,10 @@ "oxlint-tsgolint": "catalog:", "pg": "^8.22.0", "pg-copy-streams": "^7.0.0", - "posthog-node": "^5.41.0", + "posthog-node": "^5.46.0", "react": "^19.2.7", "react-devtools-core": "^7.0.1", - "semantic-release": "^25.0.6", + "semantic-release": "^25.0.8", "smol-toml": "^1.7.0", "tldts": "catalog:", "vitest": "catalog:", diff --git a/apps/cli/src/legacy/cli/agent-output.e2e.test.ts b/apps/cli/src/legacy/cli/agent-output.e2e.test.ts index 15c124caa2..8cce87cbfe 100644 --- a/apps/cli/src/legacy/cli/agent-output.e2e.test.ts +++ b/apps/cli/src/legacy/cli/agent-output.e2e.test.ts @@ -1,26 +1,5 @@ import { describe, expect, test } from "vitest"; -import { runSupabase } from "../../../tests/helpers/cli.ts"; - -function stripAnsi(output: string): string { - let stripped = ""; - for (let i = 0; i < output.length; i++) { - const charCode = output.charCodeAt(i); - if (charCode !== 0x1b || output[i + 1] !== "[") { - stripped += output[i]; - continue; - } - - i += 2; - while (i < output.length) { - const code = output.charCodeAt(i); - if (code >= 0x40 && code <= 0x7e) { - break; - } - i++; - } - } - return stripped; -} +import { runSupabase, stripAnsi } from "../../../tests/helpers/cli.ts"; function parseJsonLines(output: string): Array { return stripAnsi(output) diff --git a/apps/cli/src/legacy/commands/bootstrap/bootstrap.handler.ts b/apps/cli/src/legacy/commands/bootstrap/bootstrap.handler.ts index 0ed9b20a8a..e000f10b58 100644 --- a/apps/cli/src/legacy/commands/bootstrap/bootstrap.handler.ts +++ b/apps/cli/src/legacy/commands/bootstrap/bootstrap.handler.ts @@ -6,6 +6,8 @@ import { LegacyCliConfig } from "../../config/legacy-cli-config.service.ts"; import { LegacyLinkedProjectCache } from "../../telemetry/legacy-linked-project-cache.service.ts"; import { LegacyTelemetryState } from "../../telemetry/legacy-telemetry-state.service.ts"; import { LegacyWorkdirFlag, legacyResolveYes } from "../../../shared/legacy/global-flags.ts"; +import { legacyPromptYesNo } from "../../../shared/legacy/legacy-prompt-yes-no.ts"; +import { CONTEXT_CANCELED_MESSAGE } from "../../../shared/output/errors.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { LegacyGoProxy } from "../../../shared/legacy/go-proxy.service.ts"; import { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts"; @@ -128,14 +130,20 @@ export const legacyBootstrap = Effect.fn("legacy.bootstrap")(function* ( ), ); if (entries.length > 0) { - const overwrite = yesFlag - ? true - : yield* output.promptConfirm( - `Do you want to overwrite existing files in ${legacyBold(workdir)} directory?`, - { defaultValue: true }, - ); + // Go's `PromptYesNo(title, true)` (`bootstrap.go:47-48`, `console.go:64-82`): + // `--yes`/`SUPABASE_YES` auto-confirms with the ` [Y/n] y` stderr echo + // instead of silently skipping the prompt, and a non-TTY stdin scans one + // piped line (100ms) before falling back to the Yes default (CLI-1974). + const overwrite = yield* legacyPromptYesNo( + output, + yesFlag, + `Do you want to overwrite existing files in ${legacyBold(workdir)} directory?`, + true, + ); if (!overwrite) { - return yield* new LegacyBootstrapOverwriteDeclinedError({ message: "context canceled" }); + return yield* new LegacyBootstrapOverwriteDeclinedError({ + message: CONTEXT_CANCELED_MESSAGE, + }); } } @@ -156,6 +164,7 @@ export const legacyBootstrap = Effect.fn("legacy.bootstrap")(function* ( cwd: workdir, force: true, interactive: false, + yes: yesFlag, useOrioledb: false, withVscodeSettings: false, withIntellijSettings: false, diff --git a/apps/cli/src/legacy/commands/bootstrap/bootstrap.integration.test.ts b/apps/cli/src/legacy/commands/bootstrap/bootstrap.integration.test.ts index 56c8fa1878..80ebe7903e 100644 --- a/apps/cli/src/legacy/commands/bootstrap/bootstrap.integration.test.ts +++ b/apps/cli/src/legacy/commands/bootstrap/bootstrap.integration.test.ts @@ -306,6 +306,10 @@ describe("legacy bootstrap integration", () => { writeFileSync(join(tempRoot.current, "existing.txt"), "keep me"); return Effect.gen(function* () { yield* legacyBootstrap(flags({ template: Option.some("scratch") }), FAST_BACKOFF); + // Go's PromptYesNo echoes the auto-accepted overwrite question to stderr + // under the global YES flag (`bootstrap.go:47-48`, `console.go:70-72`). + expect(s.out.stderrText).toContain("Do you want to overwrite existing files in "); + expect(s.out.stderrText).toContain(" directory? [Y/n] y\n"); expect(existsSync(join(s.workdir, "supabase", "config.toml"))).toBe(true); }).pipe(Effect.provide(s.layer)); }); diff --git a/apps/cli/src/legacy/commands/branches/create/create.command.ts b/apps/cli/src/legacy/commands/branches/create/create.command.ts index 1cc85ecbce..1c7db899f8 100644 --- a/apps/cli/src/legacy/commands/branches/create/create.command.ts +++ b/apps/cli/src/legacy/commands/branches/create/create.command.ts @@ -1,7 +1,9 @@ import { Argument, Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; +import { Layer } from "effect"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { stdinLayer } from "../../../../shared/runtime/stdin.layer.ts"; import { legacyManagementApiRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts"; import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; import { legacyBranchesCreate } from "./create.handler.ts"; @@ -99,5 +101,9 @@ export const legacyBranchesCreateCommand = Command.make("create", config).pipe( withJsonErrorHandling, ), ), - Command.provide(legacyManagementApiRuntimeLayer(["branches", "create"])), + // `stdinLayer`: the confirmation prompt reads piped stdin via `legacyPromptYesNo` + // (Go's `Console.ReadLine`, `console.go:38-61`) on a non-TTY stdin. + Command.provide( + Layer.mergeAll(legacyManagementApiRuntimeLayer(["branches", "create"]), stdinLayer), + ), ); diff --git a/apps/cli/src/legacy/commands/branches/create/create.handler.ts b/apps/cli/src/legacy/commands/branches/create/create.handler.ts index 9cf9352b1f..b952999506 100644 --- a/apps/cli/src/legacy/commands/branches/create/create.handler.ts +++ b/apps/cli/src/legacy/commands/branches/create/create.handler.ts @@ -1,14 +1,16 @@ import type { V1CreateABranchOutput } from "@supabase/api/effect"; import { Effect, Option } from "effect"; -import * as HttpClientError from "effect/unstable/http/HttpClientError"; import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; -import { LegacyOutputFlag } from "../../../../shared/legacy/global-flags.ts"; +import { LegacyOutputFlag, legacyResolveYes } from "../../../../shared/legacy/global-flags.ts"; +import { legacyPromptYesNo } from "../../../../shared/legacy/legacy-prompt-yes-no.ts"; +import { CONTEXT_CANCELED_MESSAGE } from "../../../../shared/output/errors.ts"; import { Output } from "../../../../shared/output/output.service.ts"; import { detectGitBranch } from "../../../../shared/git/git-branch.ts"; +import { legacyAqua } from "../../../shared/legacy-colors.ts"; import { encodeEnv, encodeGoJson, @@ -16,7 +18,7 @@ import { encodeYaml, } from "../../../shared/legacy-go-output.encoders.ts"; import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; -import { legacySuggestUpgrade } from "../../../shared/legacy-upgrade-suggest.ts"; +import { legacyGateMapError } from "../../../shared/legacy-upgrade-suggest.ts"; import { LegacyBranchesCreateCancelledError, LegacyBranchesCreateNetworkError, @@ -59,16 +61,21 @@ export const legacyBranchesCreate = Effect.fn("legacy.branches.create")(function if (branchName.length === 0) { const gitBranch = yield* detectGitBranch(); if (Option.isSome(gitBranch) && gitBranch.value.length > 0) { - // Go's `create.go:20-25` calls `utils.NewConsole().PromptYesNo(...)` - // unconditionally — on a TTY it blocks for input, off-TTY it reads stdin - // with a 100ms timeout and defaults to `true` on EOF. We always fire the - // confirm; the non-interactive `Output` layer auto-falls-through (via - // `Effect.orElseSucceed(true)`) which matches Go's EOF-default-true. - const confirmed = yield* output - .promptConfirm(`Do you want to create a branch named ${gitBranch.value}?`) - .pipe(Effect.orElseSucceed(() => true)); + // Go's `create.go:20-25` routes this through `PromptYesNo(title, true)` + // (`console.go:64-82`), so `--yes`/`SUPABASE_YES` auto-confirms with the + // `<title> [Y/n] y` stderr echo instead of blocking a TTY, and a non-TTY + // stdin prints the label and scans one piped line (100ms) before falling + // back to the Yes default — `echo n | supabase branches create` cancels + // (CLI-1974). Go wraps the branch name in `utils.Aqua` (`create.go:20`). + const yes = yield* legacyResolveYes; + const confirmed = yield* legacyPromptYesNo( + output, + yes, + `Do you want to create a branch named ${legacyAqua(gitBranch.value)}?`, + true, + ); if (!confirmed) { - return yield* new LegacyBranchesCreateCancelledError({ message: "context canceled" }); + return yield* new LegacyBranchesCreateCancelledError({ message: CONTEXT_CANCELED_MESSAGE }); } branchName = gitBranch.value; if (gitBranchForBody === undefined) { @@ -97,22 +104,10 @@ export const legacyBranchesCreate = Effect.fn("legacy.branches.create")(function }) .pipe( Effect.tapError(() => creating?.fail() ?? Effect.void), - Effect.catch((cause) => - // Mirror Go's `create.go:34-37`: on any non-201 status (including - // gated 4xx), run the entitlement check; `legacySuggestUpgrade` - // is a no-op for 2xx/5xx itself, so we can call it unconditionally. - Effect.gen(function* () { - const status = - HttpClientError.isHttpClientError(cause) && cause.response !== undefined - ? cause.response.status - : 0; - yield* legacySuggestUpgrade({ - projectRef: ref, - featureKey: "branching_limit", - statusCode: status, - }); - return yield* mapCreateErrorRaw(cause); - }), + // Mirror Go's `create.go:34-37`: on any non-201 status (including + // gated 4xx), run the plan-gate check before mapping the error. + Effect.catch( + legacyGateMapError({ projectRef: ref, featureKey: "branching_limit" }, mapCreateErrorRaw), ), ); yield* creating?.clear() ?? Effect.void; diff --git a/apps/cli/src/legacy/commands/branches/create/create.integration.test.ts b/apps/cli/src/legacy/commands/branches/create/create.integration.test.ts index 8fb2a9e5d5..5ea0d82e9f 100644 --- a/apps/cli/src/legacy/commands/branches/create/create.integration.test.ts +++ b/apps/cli/src/legacy/commands/branches/create/create.integration.test.ts @@ -1,10 +1,15 @@ import type { V1CreateABranchOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Option } from "effect"; +import { Cause, Effect, Exit, Layer, Option } from "effect"; import { Command } from "effect/unstable/cli"; -import { mockAnalytics, mockOutput } from "../../../../../tests/helpers/mocks.ts"; -import { LEGACY_GLOBAL_FLAGS } from "../../../../shared/legacy/global-flags.ts"; +import { + mockAnalytics, + mockOutput, + mockStdin, + mockTty, +} from "../../../../../tests/helpers/mocks.ts"; +import { LEGACY_GLOBAL_FLAGS, LegacyYesFlag } from "../../../../shared/legacy/global-flags.ts"; import { LEGACY_VALID_REF, buildLegacyTestRuntime, @@ -72,6 +77,12 @@ interface SetupOpts { readonly network?: "fail"; readonly gated?: boolean; readonly featureKey?: string; + /** Resolved `--yes`/`SUPABASE_YES` for the git-branch auto-name confirm. */ + readonly yes?: boolean; + readonly stdinIsTty?: boolean; + /** Piped stdin lines consumed by the non-TTY confirm read. */ + readonly stdinInput?: string; + readonly promptConfirmResponses?: ReadonlyArray<boolean>; } function buildApiLayer(opts: SetupOpts) { @@ -101,17 +112,25 @@ function buildApiLayer(opts: SetupOpts) { } function setup(opts: SetupOpts = {}) { - const out = mockOutput({ format: opts.format ?? "text" }); + const out = mockOutput({ + format: opts.format ?? "text", + promptConfirmResponses: opts.promptConfirmResponses, + }); const analytics = mockAnalytics(); const api = buildApiLayer(opts); const cliConfig = mockLegacyCliConfig({ workdir: tempRoot.current }); - const layer = buildLegacyTestRuntime({ - out, - api, - cliConfig, - analytics, - goOutput: opts.goOutput === undefined ? Option.none() : Option.some(opts.goOutput), - }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig, + analytics, + tty: mockTty({ stdinIsTty: opts.stdinIsTty ?? false, stdoutIsTty: false }), + stdin: mockStdin(opts.stdinIsTty ?? false, opts.stdinInput), + goOutput: opts.goOutput === undefined ? Option.none() : Option.some(opts.goOutput), + }), + Layer.succeed(LegacyYesFlag, opts.yes ?? false), + ); return { layer, out, api, analytics }; } @@ -122,14 +141,17 @@ function setupTracked(opts: SetupOpts = {}) { const cliConfig = mockLegacyCliConfig({ workdir: tempRoot.current }); const telemetry = mockLegacyTelemetryStateTracked(); const cache = mockLegacyLinkedProjectCacheTracked(); - const layer = buildLegacyTestRuntime({ - out, - api, - cliConfig, - analytics, - telemetry: telemetry.layer, - linkedProjectCache: cache.layer, - }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig, + analytics, + telemetry: telemetry.layer, + linkedProjectCache: cache.layer, + }), + Layer.succeed(LegacyYesFlag, opts.yes ?? false), + ); return { layer, out, api, telemetry, cache, analytics }; } @@ -197,6 +219,105 @@ describe("legacy branches create integration", () => { }).pipe(Effect.provide(layer)); }); + // --------------------------------------------------------------------------- + // Git-branch auto-name confirmation — Go `create.go:17-28` routes it through + // `PromptYesNo(title, true)` (`console.go:64-82`). `GITHUB_HEAD_REF` drives + // `detectGitBranch` deterministically (its highest-priority source). + // --------------------------------------------------------------------------- + + const withGitBranch = <A, E, R>(effect: Effect.Effect<A, E, R>, branch = "feat-y") => { + const prevHead = process.env["GITHUB_HEAD_REF"]; + process.env["GITHUB_HEAD_REF"] = branch; + return effect.pipe( + Effect.ensuring( + Effect.sync(() => { + if (prevHead === undefined) delete process.env["GITHUB_HEAD_REF"]; + else process.env["GITHUB_HEAD_REF"] = prevHead; + }), + ), + ); + }; + + it.live("--yes auto-confirms the git-branch name with the [Y/n] y echo", () => { + const { layer, out, api } = setup({ yes: true, stdinIsTty: true }); + return withGitBranch( + Effect.gen(function* () { + yield* legacyBranchesCreate(baseFlags); + // Go's `viper.GetBool("YES")` branch echoes `<title> [Y/n] y` to stderr + // (`console.go:70-72`) instead of blocking the TTY prompt. + expect(out.stderrText).toContain("Do you want to create a branch named "); + expect(out.stderrText).toContain("? [Y/n] y\n"); + expect(api.requests[0]?.body).toMatchObject({ + branch_name: "feat-y", + git_branch: "feat-y", + }); + }).pipe(Effect.provide(layer)), + ); + }); + + it.live("SUPABASE_YES=1 auto-confirms the git-branch name like --yes", () => { + const prev = process.env["SUPABASE_YES"]; + process.env["SUPABASE_YES"] = "1"; + const { layer, out, api } = setup({ stdinIsTty: true }); + return withGitBranch( + Effect.gen(function* () { + yield* legacyBranchesCreate(baseFlags); + expect(out.stderrText).toContain("? [Y/n] y\n"); + expect(api.requests[0]?.body).toMatchObject({ branch_name: "feat-y" }); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (prev === undefined) delete process.env["SUPABASE_YES"]; + else process.env["SUPABASE_YES"] = prev; + }), + ), + Effect.provide(layer), + ), + ); + }); + + it.live("non-TTY with piped `n` declines the git-branch name like Go", () => { + const { layer, out, api } = setup({ stdinIsTty: false, stdinInput: "n\n" }); + return withGitBranch( + Effect.gen(function* () { + const exit = yield* Effect.exit(legacyBranchesCreate(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyBranchesCreateCancelledError"); + } + // The piped answer is echoed to stderr like Go's non-TTY PromptText. + expect(out.stderrText).toContain("? [Y/n] n\n"); + expect(api.requests).toHaveLength(0); + }).pipe(Effect.provide(layer)), + ); + }); + + it.live("non-TTY with empty stdin takes the Yes default and creates the branch", () => { + const { layer, out, api } = setup({ stdinIsTty: false }); + return withGitBranch( + Effect.gen(function* () { + yield* legacyBranchesCreate(baseFlags); + // Label printed, empty scan echoed, true default wins (`console.go:64-102`). + expect(out.stderrText).toContain("? [Y/n] \n"); + expect(api.requests[0]?.body).toMatchObject({ branch_name: "feat-y" }); + }).pipe(Effect.provide(layer)), + ); + }); + + it.live("TTY decline of the git-branch name cancels without creating", () => { + const { layer, api } = setup({ stdinIsTty: true, promptConfirmResponses: [false] }); + return withGitBranch( + Effect.gen(function* () { + const exit = yield* Effect.exit(legacyBranchesCreate(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyBranchesCreateCancelledError"); + } + expect(api.requests).toHaveLength(0); + }).pipe(Effect.provide(layer)), + ); + }); + it.live("emits a success event for --output-format=json", () => { const { layer, out } = setup({ format: "json" }); return Effect.gen(function* () { @@ -259,6 +380,35 @@ describe("legacy branches create integration", () => { }).pipe(Effect.provide(layer)); }); + it.live("envelope on 402 suggests upgrade with no extra API calls", () => { + const { layer, out, analytics, api } = setup({ + status: 402, + response: { + message: "Branching is supported only on the Pro plan or above", + error: { + code: "entitlement_required", + feature: "branching_limit", + upgrade_url: "https://supabase.com/dashboard/org/env-org/billing", + }, + } as unknown as CreatedBranch, + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacyBranchesCreate({ ...baseFlags, name: Option.some("feat-x") }), + ); + expect(Exit.isFailure(exit)).toBe(true); + expect(api.requests).toHaveLength(1); + expect(api.requests[0]?.url).toContain("/branches"); + expect(out.stderrText).toContain("/org/env-org/billing"); + expect(analytics.captured).toEqual([ + { + event: "cli_upgrade_suggested", + properties: { feature_key: "branching_limit", org_slug: "env-org" }, + }, + ]); + }).pipe(Effect.provide(layer)); + }); + it.live("does NOT fire upgrade suggested on 500 (Go skips 5xx)", () => { const { layer, analytics } = setup({ status: 500 }); return Effect.gen(function* () { diff --git a/apps/cli/src/legacy/commands/branches/update/update.handler.ts b/apps/cli/src/legacy/commands/branches/update/update.handler.ts index 5dc3085c74..6e8ba5c59c 100644 --- a/apps/cli/src/legacy/commands/branches/update/update.handler.ts +++ b/apps/cli/src/legacy/commands/branches/update/update.handler.ts @@ -1,6 +1,5 @@ import type { V1UpdateABranchConfigInput, V1UpdateABranchConfigOutput } from "@supabase/api/effect"; import { Effect, Option } from "effect"; -import * as HttpClientError from "effect/unstable/http/HttpClientError"; import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; @@ -16,7 +15,7 @@ import { encodeYaml, } from "../../../shared/legacy-go-output.encoders.ts"; import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; -import { legacySuggestUpgrade } from "../../../shared/legacy-upgrade-suggest.ts"; +import { legacyGateMapError } from "../../../shared/legacy-upgrade-suggest.ts"; import { LegacyBranchesUpdateNetworkError, LegacyBranchesUpdateUnexpectedStatusError, @@ -70,21 +69,13 @@ export const legacyBranchesUpdate = Effect.fn("legacy.branches.update")(function }) .pipe( Effect.tapError(() => patching?.fail() ?? Effect.void), - Effect.catch((cause) => - Effect.gen(function* () { - const status = - HttpClientError.isHttpClientError(cause) && cause.response !== undefined - ? cause.response.status - : 0; - // Mirrors Go's `update.go:26` — pass the resolved branch's project - // ref so the entitlements check is scoped to the branch's org. - yield* legacySuggestUpgrade({ - projectRef: branchRef, - featureKey: "branching_persistent", - statusCode: status, - }); - return yield* mapUpdateError(cause); - }), + // Mirrors Go's `update.go:26` — pass the resolved branch's project + // ref so the entitlements check is scoped to the branch's org. + Effect.catch( + legacyGateMapError( + { projectRef: branchRef, featureKey: "branching_persistent" }, + mapUpdateError, + ), ), ); yield* patching?.clear() ?? Effect.void; diff --git a/apps/cli/src/legacy/commands/config/push/push.handler.ts b/apps/cli/src/legacy/commands/config/push/push.handler.ts index e1fc8971f3..62c868b9b3 100644 --- a/apps/cli/src/legacy/commands/config/push/push.handler.ts +++ b/apps/cli/src/legacy/commands/config/push/push.handler.ts @@ -13,7 +13,7 @@ import { legacyLoadProjectEnv, } from "../../../shared/legacy-db-config.toml-read.ts"; import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; -import { legacyPromptYesNo } from "../../../shared/legacy-prompt-yes-no.ts"; +import { legacyPromptYesNo } from "../../../../shared/legacy/legacy-prompt-yes-no.ts"; import { legacyCollectDotenvPrivateKeys } from "../../../shared/legacy-vault-decrypt.ts"; import { apiSubsetFromConfig, apiToUpdateBody, diffApiWithRemote } from "./config-sync/api.sync.ts"; import { diff --git a/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts b/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts index f4e6fb4ea6..f1a531c7a9 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts @@ -4,6 +4,7 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Effect, Exit, Layer, Option } from "effect"; +import { stripAnsi } from "../../../../../tests/helpers/ansi.ts"; import { legacyFailWriteStringOnNthCallFsLayer, mockLegacyCliConfig, @@ -230,10 +231,6 @@ const flags = (over: Partial<LegacyDbDiffFlags> = {}): LegacyDbDiffFlags => ({ schema: over.schema ?? [], }); -// Strip ANSI so assertions are colour-independent: `legacyAqua`/`legacyYellow` -// emit colour only when the test runner's stderr is a TTY. -// eslint-disable-next-line no-control-regex -const stripAnsi = (text: string) => text.replace(/\x1b\[[0-9;]*m/gu, ""); const stdout = (out: ReturnType<typeof mockOutput>) => stripAnsi( out.rawChunks diff --git a/apps/cli/src/legacy/commands/db/diff/diff.live.test.ts b/apps/cli/src/legacy/commands/db/diff/diff.live.test.ts new file mode 100644 index 0000000000..35adf5826b --- /dev/null +++ b/apps/cli/src/legacy/commands/db/diff/diff.live.test.ts @@ -0,0 +1,103 @@ +import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, expect, test } from "vitest"; + +import { describeLive, runSupabaseLive } from "../../../../../tests/helpers/live.ts"; + +const START_TIMEOUT_MS = 280_000; + +// CLI-1947 regression: pg-delta's `filterPublicBuiltInDefaults()` unconditionally +// treated PUBLIC's implicit built-in privilege as a no-op on both sides of a diff, +// so a declarative schema's `REVOKE ... FROM PUBLIC` on a function was silently +// dropped from the generated migration — exit code 0, no error, just a missing +// statement. Fixed upstream in @supabase/pg-delta@1.0.0-alpha.33 +// (supabase/pg-toolbelt#357). Verified directly against this repo's build: with +// the pre-fix pin (1.0.0-alpha.32) the migration below contains only the CREATE +// FUNCTION statement; the REVOKE is silently absent. `describeLive` is reused as +// the "real local Docker stack is available" signal, same as stop/status — this +// never calls the Management API. See AGENTS.md's "Live tests" section. +describeLive("supabase db diff (live, pg-delta declarative privileges)", () => { + let projectDir: string | undefined; + + afterEach(async () => { + if (projectDir === undefined) return; + // Best-effort cleanup even if an assertion above failed mid-lifecycle — a + // leaked local stack would otherwise pollute the CI runner for later jobs. + await runSupabaseLive(["stop", "--no-backup"], { cwd: projectDir }).catch(() => undefined); + await rm(projectDir, { recursive: true, force: true }).catch(() => undefined); + projectDir = undefined; + }); + + test( + "keeps REVOKE ... FROM PUBLIC on a function when diffing a declarative schema against local", + { timeout: START_TIMEOUT_MS }, + async () => { + projectDir = await mkdtemp(path.join(tmpdir(), "sb-db-diff-live-")); + + const init = await runSupabaseLive(["init"], { cwd: projectDir }); + expect(init.exitCode, `stdout:\n${init.stdout}\nstderr:\n${init.stderr}`).toBe(0); + + // `init`'s template already enables pg-delta by default (CLI-1877/#5511), but + // point `[db.migrations] schema_paths` at a declarative schema directory so + // `db diff --local` diffs against it instead of the (empty) local migration + // history. Go's `db.go:426` docs the exact syntax: paths relative to `supabase/`. + const configPath = path.join(projectDir, "supabase", "config.toml"); + const config = readFileSync(configPath, "utf8"); + expect(config).toContain("schema_paths = []"); + writeFileSync( + configPath, + config.replace("schema_paths = []", 'schema_paths = ["./schemas/*.sql"]'), + ); + + // Minimal, deterministic repro: a fresh function's implicit PUBLIC EXECUTE + // grant, explicitly revoked. Verified empirically against this build: pre-fix + // (pg-delta 1.0.0-alpha.32) the generated migration contains only the CREATE + // FUNCTION statement; the REVOKE is silently dropped. + const schemasDir = path.join(projectDir, "supabase", "schemas"); + mkdirSync(schemasDir, { recursive: true }); + writeFileSync( + path.join(schemasDir, "01_probe_fn.sql"), + `create function public.probe_fn() +returns void +language sql +as $$ select 1; $$; + +revoke execute on function public.probe_fn() from public; +`, + ); + + // Exclude the heaviest, least relevant services — `db diff` only needs the + // local Postgres container reachable, same rationale as stop/status. + const start = await runSupabaseLive( + ["start", "--exclude", "studio", "--exclude", "analytics", "--exclude", "vector"], + { cwd: projectDir, exitTimeoutMs: START_TIMEOUT_MS }, + ); + expect(start.exitCode, `stdout:\n${start.stdout}\nstderr:\n${start.stderr}`).toBe(0); + + const diff = await runSupabaseLive( + ["db", "diff", "--local", "--use-pg-delta", "-f", "revoke_public_execute"], + { cwd: projectDir, exitTimeoutMs: START_TIMEOUT_MS }, + ); + expect(diff.exitCode, `stdout:\n${diff.stdout}\nstderr:\n${diff.stderr}`).toBe(0); + + const migrationsDir = path.join(projectDir, "supabase", "migrations"); + const written = + existsSync(migrationsDir) && + readdirSync(migrationsDir).find((f) => f.endsWith("_revoke_public_execute.sql")); + expect(written, `no migration written; stderr:\n${diff.stderr}`).toBeTruthy(); + const sql = readFileSync(path.join(migrationsDir, written as string), "utf8"); + + // The negative-space regression: pre-fix, exit code 0 and this file would + // exist, but silently missing the REVOKE statement (only the CREATE FUNCTION + // survives). Anchor the match to the function's own REVOKE statement — up to + // its terminating `;` — so this cannot pass on an unrelated PUBLIC mention + // elsewhere in the file. + expect(sql).toContain("CREATE FUNCTION public.probe_fn()"); + expect(sql).toMatch( + /REVOKE\s+(?:ALL|EXECUTE)\s+ON\s+FUNCTION\s+public\.probe_fn\(\)\s+FROM\s+[^;]*PUBLIC[^;]*;/i, + ); + }, + ); +}); diff --git a/apps/cli/src/legacy/commands/db/pull/pull.debug.unit.test.ts b/apps/cli/src/legacy/commands/db/pull/pull.debug.unit.test.ts index dc83ad49db..5c4c5a36f5 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.debug.unit.test.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.debug.unit.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; +import { stripAnsi } from "../../../../../tests/helpers/ansi.ts"; import { legacyFormatByteSize, legacyFormatCatalogSummary, @@ -9,10 +10,6 @@ import { legacySummarizeCatalogJson, } from "./pull.debug.ts"; -// ANSI may wrap the bold debugDir; strip for assertions. -// eslint-disable-next-line no-control-regex -const stripAnsi = (text: string) => text.replace(/\x1b\[[0-9;]*m/gu, ""); - describe("legacyRedactPostgresURL", () => { it("replaces the password but keeps the username", () => { expect(legacyRedactPostgresURL("postgresql://postgres:secret@db.host:5432/postgres")).toBe( diff --git a/apps/cli/src/legacy/commands/db/pull/pull.handler.ts b/apps/cli/src/legacy/commands/db/pull/pull.handler.ts index f132f55dda..fdb2452543 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.handler.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.handler.ts @@ -10,7 +10,7 @@ import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; import { Output } from "../../../../shared/output/output.service.ts"; import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; import { legacyAqua, legacyBold } from "../../../shared/legacy-colors.ts"; -import { legacyPromptYesNo } from "../../../shared/legacy-prompt-yes-no.ts"; +import { legacyPromptYesNo } from "../../../../shared/legacy/legacy-prompt-yes-no.ts"; import { legacyIpv6Suggestion, legacyIsIPv6ConnectivityError, @@ -323,9 +323,34 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy yield* proxy.exec(rebuildDelegateArgs(flags), { env }); }); + // viper resolves `EXPERIMENTAL` from *either* the global `--experimental` + // pflag or `SUPABASE_EXPERIMENTAL` (`cmd/root.go:318-320,327,334`), so honor + // both forms; the legacy root only forwards `--experimental` to Go proxy + // argv, never into env. Resolved before connecting so the Connecting line + // below knows whether this run delegates to the Go child. Declarative mode + // never delegates (Go checks `usePgDelta` before `EXPERIMENTAL`, + // `pull.go:47-50`). + const delegatesExperimentalPull = + !useDeclarative && + (experimental || legacyParseBoolEnv(toml.envLookup("SUPABASE_EXPERIMENTAL"))); + // Connectivity check (Go's `ConnectByConfig` at the top of `pull.Run`). yield* Effect.scoped( Effect.gen(function* () { + // Go's `ConnectByConfigStream` prints this to stderr before dialing + // (`internal/utils/connect.go:344-348`), local vs remote keyed off + // `utils.IsLocalDatabase` (mirrored by the resolver's `isLocal`). The + // delegated `--experimental` branch skips it: the Go child's own + // `ConnectByConfig` already prints the line, so the parent printing too + // would double it. (The parent still dials below — mirroring Go's early + // connectivity check — so a parent-side connect failure on the delegate + // path surfaces without the line; pre-existing delegate behavior.) + if (!delegatesExperimentalPull) { + yield* output.raw( + `Connecting to ${resolved.isLocal ? "local" : "remote"} database...\n`, + "stderr", + ); + } const session = yield* connection.connect(resolved.conn, { isLocal: resolved.isLocal, dnsResolver, @@ -373,8 +398,12 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy Effect.mapError((cause) => new LegacyDbPullWriteError({ message: cause.message })), ); } + // Go prints `utils.GetDeclarativeDir()` verbatim (`pull.go:119`): the + // config's declarative_schema_path or the relative `supabase/database` + // default — never the resolved absolute directory. The json payload + // below keeps the absolute path for machine consumers. yield* output.raw( - `Declarative schema written to ${legacyBold(declarativeDir)}\n`, + `Declarative schema written to ${legacyBold(declarativeDirRel)}\n`, "stderr", ); if (output.format !== "text") { @@ -397,12 +426,8 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy // dumped statement with a PostgreSQL DDL AST parser (`multigres`, ~50 node // types) to route objects into structured files. No Postgres DDL parser // exists in TS yet, so porting it is tracked separately; until then the - // experimental path delegates the whole pull to Go. viper resolves - // `EXPERIMENTAL` from *either* the global `--experimental` pflag or - // `SUPABASE_EXPERIMENTAL` (`cmd/root.go:318-320,327,334`), so honor both - // forms here; the legacy root only forwards `--experimental` to Go proxy - // argv, never into env. - if (experimental || legacyParseBoolEnv(toml.envLookup("SUPABASE_EXPERIMENTAL"))) { + // experimental path delegates the whole pull to Go. + if (delegatesExperimentalPull) { // Go's structured-dump path returns before writing a migration or // touching schema_migrations (`pull.go:49-61`), so no history repair. yield* delegatePull(usePgDeltaDiff ? "pg-delta" : "migra", { @@ -742,7 +767,14 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy } for (const written of writtenMigrations) { - yield* output.raw(`Schema written to ${legacyBold(written.path)}\n`, "stderr"); + // Go prints the workdir-relative path (`pull.go:76`): `GetMigrationPath` + // joins the relative `utils.MigrationsDir` and Go chdirs into the + // workdir. Display-only — `writtenMigrations` keeps absolute paths for + // file I/O and the json payload. + yield* output.raw( + `Schema written to ${legacyBold(path.relative(cliConfig.workdir, written.path))}\n`, + "stderr", + ); } // Prompt to update the remote migration history table. Go calls diff --git a/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts b/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts index 62b8188e8e..aaac55ec20 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts @@ -4,6 +4,7 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Effect, Exit, Layer, Option } from "effect"; +import { stripAnsi } from "../../../../../tests/helpers/ansi.ts"; import { legacyFailWriteStringOnNthCallFsLayer, mockLegacyCliConfig, @@ -304,8 +305,6 @@ const flags = (over: Partial<LegacyDbPullFlags> = {}): LegacyDbPullFlags => ({ password: over.password ?? Option.none(), }); -// eslint-disable-next-line no-control-regex -const stripAnsi = (text: string) => text.replace(/\x1b\[[0-9;]*m/gu, ""); const streamText = (out: ReturnType<typeof mockOutput>, stream: "stdout" | "stderr") => stripAnsi( out.rawChunks @@ -345,7 +344,11 @@ describe("legacy db pull", () => { expect(readFileSync(join(dir, written[0] ?? ""), "utf8")).toContain( "create table remote ();", ); - expect(streamText(s.out, "stderr")).toContain("Schema written to"); + // Go prints the workdir-relative path (`pull.go:76`), never the absolute one. + expect(streamText(s.out, "stderr")).toContain( + `Schema written to ${join("supabase", "migrations", written[0] ?? "")}\n`, + ); + expect(streamText(s.out, "stderr")).not.toContain(tmp.current); expect(s.historyUpserts.length).toBe(1); expect(streamText(s.out, "stdout")).toContain("Finished supabase db pull."); }).pipe(Effect.provide(s.layer)); @@ -389,8 +392,13 @@ describe("legacy db pull", () => { expect(readFileSync(join(dir, written[2] ?? ""), "utf8")).toContain( "create index concurrently i on t (c);", ); - // One "Schema written to" line and one history upsert per unit. - expect(streamText(s.out, "stderr").match(/Schema written to/gu)).toHaveLength(3); + // One "Schema written to" line per unit, each printing the workdir-relative + // path (Go's `pull.go:76`), and one history upsert per unit. + const err = streamText(s.out, "stderr"); + expect(err.match(/Schema written to/gu)).toHaveLength(3); + for (const file of written) { + expect(err).toContain(`Schema written to ${join("supabase", "migrations", file)}\n`); + } expect(s.historyUpserts.length).toBe(3); // Go's UpdateMigrationTable prints all versions space-separated. expect(streamText(s.out, "stderr")).toContain( @@ -489,7 +497,17 @@ describe("legacy db pull", () => { return Effect.gen(function* () { yield* legacyDbPull(flags()); expect(s.provisionCalls[0]?.usePgDelta).toBe(false); - expect(streamText(s.out, "stderr")).toContain("Schema written to"); + const err = streamText(s.out, "stderr"); + // Go's `ConnectByConfig` prints the Connecting line to stderr before dialing + // (`internal/utils/connect.go:348`), ahead of any other pull output. + expect(err).toContain("Connecting to remote database...\n"); + expect(err.indexOf("Connecting to remote database...")).toBeLessThan( + err.indexOf("Creating shadow database..."), + ); + const dir = join(tmp.current, "supabase", "migrations"); + const file = readdirSync(dir).find((f) => f.endsWith("_remote_schema.sql")); + expect(err).toContain(`Schema written to ${join("supabase", "migrations", file ?? "")}\n`); + expect(err).not.toContain(tmp.current); }).pipe(Effect.provide(s.layer)); }); @@ -497,8 +515,17 @@ describe("legacy db pull", () => { const s = setup(tmp.current, { edgeStdout: EXPORT_JSON }); return Effect.gen(function* () { yield* legacyDbPull(flags({ declarative: Option.some(true) })); - expect(streamText(s.out, "stderr")).toContain("Preparing declarative schema export"); - expect(streamText(s.out, "stderr")).toContain("Declarative schema written to"); + const err = streamText(s.out, "stderr"); + // Go's order: `ConnectByConfig` prints Connecting (`pull.go:40`), then + // `pullDeclarativePgDelta` prints Preparing (`pull.go:93`). + expect(err).toContain("Connecting to remote database...\n"); + expect(err.indexOf("Connecting to remote database...")).toBeLessThan( + err.indexOf("Preparing declarative schema export"), + ); + // Go prints `utils.GetDeclarativeDir()` — the relative default, not the + // resolved absolute directory (`pull.go:119`). + expect(err).toContain(`Declarative schema written to ${join("supabase", "database")}\n`); + expect(err).not.toContain(tmp.current); expect( existsSync(join(tmp.current, "supabase", "database", "schemas", "public", "t.sql")), ).toBe(true); @@ -562,7 +589,9 @@ describe("legacy db pull", () => { return Effect.gen(function* () { yield* legacyDbPull(flags({ usePgDelta: Option.some(true) })); expect(streamText(s.out, "stderr")).toContain("Flag --use-pg-delta has been deprecated"); - expect(streamText(s.out, "stderr")).toContain("Declarative schema written to"); + expect(streamText(s.out, "stderr")).toContain( + `Declarative schema written to ${join("supabase", "database")}\n`, + ); }).pipe(Effect.provide(s.layer)); }, ); @@ -659,11 +688,16 @@ describe("legacy db pull", () => { expect(content).toContain("create table dumped ();"); expect(content).toContain("create table diffed ();"); expect(content.indexOf("dumped")).toBeLessThan(content.indexOf("diffed")); - // stderr order: dump → shadow → diff → written. + // stderr order: connect → dump → shadow → diff → written. The Connecting + // line comes first (Go's `ConnectByConfig` at the top of `pull.Run`). const err = streamText(s.out, "stderr"); + expect(err).toContain("Connecting to remote database...\n"); expect(err).toContain("Dumping schema from remote database..."); expect(err).toContain("Creating shadow database..."); - expect(err).toContain("Schema written to"); + expect(err).toContain(`Schema written to ${join("supabase", "migrations", file ?? "")}\n`); + expect(err.indexOf("Connecting to remote database")).toBeLessThan( + err.indexOf("Dumping schema"), + ); expect(err.indexOf("Dumping schema")).toBeLessThan(err.indexOf("Creating shadow")); expect(s.historyUpserts.length).toBe(1); }).pipe(Effect.provide(s.layer)); @@ -716,7 +750,9 @@ describe("legacy db pull", () => { const file = readdirSync(dir).find((f) => f.endsWith("_remote_schema.sql")); expect(file).toBeDefined(); expect(readFileSync(join(dir, file ?? ""), "utf8")).toContain("create table dumped ();"); - expect(streamText(s.out, "stderr")).toContain("Schema written to"); + expect(streamText(s.out, "stderr")).toContain( + `Schema written to ${join("supabase", "migrations", file ?? "")}\n`, + ); }).pipe(Effect.provide(s.layer)); }); @@ -955,6 +991,9 @@ describe("legacy db pull", () => { return Effect.gen(function* () { yield* legacyDbPull(flags()); expect(streamText(s.out, "stdout")).not.toContain("Finished supabase db pull."); + // Diagnostics still go to stderr in machine mode (Go writes the Connecting + // line to os.Stderr regardless of output format); stdout stays payload-only. + expect(streamText(s.out, "stderr")).toContain("Connecting to remote database...\n"); const success = s.out.messages.find((m) => m.type === "success"); expect(success?.data).toMatchObject({ declarative: false, remoteHistoryUpdated: true }); }).pipe(Effect.provide(s.layer)); @@ -1116,6 +1155,9 @@ describe("legacy db pull", () => { } expect(s.proxyCalls).toHaveLength(1); expect(s.proxyCalls[0]?.env).toEqual({ SUPABASE_TELEMETRY_DISABLED: "1" }); + // The Go child's own `ConnectByConfig` prints the Connecting line; the + // parent must not print it too (it would appear twice in the stream). + expect(streamText(s.out, "stderr")).not.toContain("Connecting to"); }).pipe(Effect.provide(s.layer)); }); @@ -1176,6 +1218,9 @@ describe("legacy db pull", () => { yield* legacyDbPull(flags()); expect(s.proxyCalls).toHaveLength(1); expect(s.proxyCalls[0]?.env).toEqual({ SUPABASE_TELEMETRY_DISABLED: "1" }); + // The Go child's own `ConnectByConfig` prints the Connecting line; the + // parent must not print it too (it would appear twice in the stream). + expect(streamText(s.out, "stderr")).not.toContain("Connecting to"); }).pipe(Effect.provide(s.layer)); }); @@ -1236,6 +1281,9 @@ describe("legacy db pull", () => { return Effect.gen(function* () { yield* legacyDbPull(flags({ local: Option.some(true) })); expect(s.provisionCalls[0]?.targetLocal).toBe(true); + // A local target prints the local wording (Go's `IsLocalDatabase` branch in + // `ConnectByConfigStream`, `internal/utils/connect.go:344-346`). + expect(streamText(s.out, "stderr")).toContain("Connecting to local database...\n"); }).pipe(Effect.provide(s.layer)); }); @@ -1369,7 +1417,9 @@ describe("legacy db pull", () => { expect(streamText(s.out, "stderr")).toContain("does not support IPv6"); expect(streamText(s.out, "stderr")).toContain("Retrying via the IPv4 connection pooler"); expect(s.edgeRunCount).toBe(2); - expect(streamText(s.out, "stderr")).toContain("Schema written to"); + expect(streamText(s.out, "stderr")).toMatch( + /Schema written to supabase[/\\]migrations[/\\]\d{14}_remote_schema\.sql\n/u, + ); }).pipe(Effect.provide(s.layer)); }); @@ -1385,7 +1435,9 @@ describe("legacy db pull", () => { yield* legacyDbPull(flags({ linked: Option.some(true), declarative: Option.some(true) })); expect(streamText(s.out, "stderr")).toContain("Retrying via the IPv4 connection pooler"); expect(s.edgeRunCount).toBe(2); - expect(streamText(s.out, "stderr")).toContain("Declarative schema written to"); + expect(streamText(s.out, "stderr")).toContain( + `Declarative schema written to ${join("supabase", "database")}\n`, + ); }).pipe(Effect.provide(s.layer)); }); diff --git a/apps/cli/src/legacy/commands/db/push/push.handler.ts b/apps/cli/src/legacy/commands/db/push/push.handler.ts index af75b4cfd3..e7349d02cf 100644 --- a/apps/cli/src/legacy/commands/db/push/push.handler.ts +++ b/apps/cli/src/legacy/commands/db/push/push.handler.ts @@ -3,6 +3,7 @@ import { Clock, Effect, FileSystem, Option, Path } from "effect"; import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; import { LegacyDnsResolverFlag } from "../../../../shared/legacy/global-flags.ts"; import { legacyResolveYesWithProjectEnv } from "../../../../shared/legacy/global-flags.ts"; +import { CONTEXT_CANCELED_MESSAGE } from "../../../../shared/output/errors.ts"; import { Output } from "../../../../shared/output/output.service.ts"; import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; @@ -18,7 +19,7 @@ import { legacyApplyMigrations, legacySeedGlobals, } from "../../../shared/legacy-migration-apply.ts"; -import { legacyPromptYesNo } from "../../../shared/legacy-prompt-yes-no.ts"; +import { legacyPromptYesNo } from "../../../../shared/legacy/legacy-prompt-yes-no.ts"; import { legacyToPostgresURL } from "../../../shared/legacy-postgres-url.ts"; import { resolveLegacyDbTargetFlags } from "../../../shared/legacy-db-target-flags.ts"; import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; @@ -270,7 +271,7 @@ export const legacyDbPush = Effect.fn("legacy.db.push")(function* (flags: Legacy ); if (!ok) { return yield* Effect.fail( - new LegacyDbPushCancelledError({ message: "context canceled" }), + new LegacyDbPushCancelledError({ message: CONTEXT_CANCELED_MESSAGE }), ); } yield* legacySeedGlobals( @@ -292,7 +293,7 @@ export const legacyDbPush = Effect.fn("legacy.db.push")(function* (flags: Legacy ); if (!ok) { return yield* Effect.fail( - new LegacyDbPushCancelledError({ message: "context canceled" }), + new LegacyDbPushCancelledError({ message: CONTEXT_CANCELED_MESSAGE }), ); } yield* legacyUpsertVaultSecrets(session, vaultSecrets); @@ -335,7 +336,7 @@ export const legacyDbPush = Effect.fn("legacy.db.push")(function* (flags: Legacy ); if (!ok) { return yield* Effect.fail( - new LegacyDbPushCancelledError({ message: "context canceled" }), + new LegacyDbPushCancelledError({ message: CONTEXT_CANCELED_MESSAGE }), ); } yield* legacySeedData(session, fs, workdir, path, seeds, applyError); diff --git a/apps/cli/src/legacy/commands/db/push/push.integration.test.ts b/apps/cli/src/legacy/commands/db/push/push.integration.test.ts index 6cbc8211a9..f0dc62ddac 100644 --- a/apps/cli/src/legacy/commands/db/push/push.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/push/push.integration.test.ts @@ -79,6 +79,7 @@ function mockConnection(opts: { vaultRows?: ReadonlyArray<{ id: string; name: string }>; noSeedTable?: boolean; failExec?: string; + failExecWith?: { message: string; code?: string; detail?: string; position?: number }; }) { const execs: Array<string> = []; const queries: Array<{ sql: string; params?: ReadonlyArray<unknown> }> = []; @@ -93,7 +94,9 @@ function mockConnection(opts: { execs.push(sql); if (opts.failExec !== undefined && sql === opts.failExec) { return Effect.fail( - new LegacyDbExecError({ message: "ERROR: boom (SQLSTATE 42601)" }), + new LegacyDbExecError( + opts.failExecWith ?? { message: "ERROR: boom (SQLSTATE 42601)" }, + ), ); } return Effect.void; @@ -159,6 +162,7 @@ function setup( vaultRows?: ReadonlyArray<{ id: string; name: string }>; noSeedTable?: boolean; failExec?: string; + failExecWith?: { message: string; code?: string; detail?: string; position?: number }; catalogStdout?: string; catalogExportFailWith?: string; noProjectId?: boolean; @@ -813,11 +817,45 @@ describe("legacy db push", () => { confirm: [true], }); return Effect.gen(function* () { - const exit = yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("At statement: 0"); - } + const error = yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.flip); + // A server error without position/detail keeps Go's plain ExecBatch layout. + expect(error._tag).toBe("LegacyDbPushApplyError"); + expect(error.message).toBe("ERROR: boom (SQLSTATE 42601)\nAt statement: 0\nBOOM"); + }); + }); + + it.live("renders Go's caret, Detail line, and 42704 extension hint on a failed migration", () => { + // Byte-match of Go's `(*MigrationFile).ExecBatch` failure rendering + // (`pkg/migration/file.go:88-113`): the `^` caret under the server-reported + // error position, the `Detail` line, and the undefined-object extension hint. + const stat = "CREATE TABLE test (path ltree NOT NULL)"; + const { layer } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: migrationFile("20240101000000", `${stat};`), + failExec: stat, + failExecWith: { + message: 'ERROR: type "ltree" does not exist (SQLSTATE 42704)', + code: "42704", + detail: "Detail from the server.", + position: 25, + }, + confirm: [true], + }); + return Effect.gen(function* () { + const error = yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.flip); + expect(error._tag).toBe("LegacyDbPushApplyError"); + expect(error.message).toBe( + 'ERROR: type "ltree" does not exist (SQLSTATE 42704)\n' + + "Detail from the server.\n" + + "\n" + + "Hint: This type may be defined in a schema that's not in your search_path.\n" + + " Use schema-qualified type references to avoid this error:\n" + + " CREATE TABLE example (col extensions.ltree);\n" + + " Learn more: supabase migration new --help\n" + + "At statement: 0\n" + + "CREATE TABLE test (path ltree NOT NULL)\n" + + " ^", + ); }); }); diff --git a/apps/cli/src/legacy/commands/db/reset/reset.e2e.test.ts b/apps/cli/src/legacy/commands/db/reset/reset.e2e.test.ts new file mode 100644 index 0000000000..7de2270883 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/reset/reset.e2e.test.ts @@ -0,0 +1,44 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; + +import { runSupabase, stripAnsi } from "../../../../../tests/helpers/cli.ts"; + +const E2E_TIMEOUT_MS = 30_000; + +describe("supabase db reset (legacy)", () => { + let workdir: string; + beforeEach(() => { + workdir = mkdtempSync(join(tmpdir(), "sb-db-reset-e2e-")); + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync(join(workdir, "supabase", "config.toml"), "[db]\nport = 54322\n"); + }); + afterEach(() => { + rmSync(workdir, { recursive: true, force: true }); + }); + + // Docker-free: the destructive remote-reset confirmation fires after the config + // load and BEFORE any connection is dialed, so a piped decline exits without a + // database. Declining must byte-match Go: a single `context canceled` line on + // stderr and exit 1, with NO `--debug` troubleshooting hint — `recoverAndExit` + // skips `SuggestDebugFlag` for `context.Canceled` (apps/cli-go/cmd/root.go:287-303). + // CLI-1973. + test( + "declining the remote reset prompt prints only context canceled, no --debug hint", + { timeout: E2E_TIMEOUT_MS }, + async () => { + const { exitCode, stderr } = await runSupabase( + ["db", "reset", "--db-url", "postgresql://postgres:postgres@127.0.0.1:9999/postgres"], + { entrypoint: "legacy", cwd: workdir, stdin: "n\n" }, + ); + expect(exitCode).toBe(1); + // The destructive confirmation (default No → `[y/N]`) actually rendered and + // was answered — the cancellation didn't come from some other failure path. + expect(stripAnsi(stderr)).toContain("[y/N]"); + const lines = stripAnsi(stderr).trimEnd().split("\n"); + expect(lines.at(-1)).toBe("context canceled"); + expect(stderr).not.toContain("Try rerunning the command with --debug"); + }, + ); +}); diff --git a/apps/cli/src/legacy/commands/db/reset/reset.errors.ts b/apps/cli/src/legacy/commands/db/reset/reset.errors.ts index bfb4c3e539..80d2cba332 100644 --- a/apps/cli/src/legacy/commands/db/reset/reset.errors.ts +++ b/apps/cli/src/legacy/commands/db/reset/reset.errors.ts @@ -21,8 +21,10 @@ export class LegacyDbResetVersionFlagsError extends Data.TaggedError( }> {} /** - * `--version` is not a valid integer. Byte-matches Go's - * `failed to parse <v>: invalid version number` (`repair.go:24-29`). + * `--version` is not a valid integer. Byte-matches Go's bare + * `repair.ErrInvalidVersion` = `invalid version number`, returned unwrapped by + * `reset.Run` (`reset.go:35-36`) — the `failed to parse <v>:` wrapper is the + * `migration repair` path only (`repair.go:29`). */ export class LegacyDbResetInvalidVersionError extends Data.TaggedError( "LegacyDbResetInvalidVersionError", diff --git a/apps/cli/src/legacy/commands/db/reset/reset.handler.ts b/apps/cli/src/legacy/commands/db/reset/reset.handler.ts index 4b5fc92782..aae30666de 100644 --- a/apps/cli/src/legacy/commands/db/reset/reset.handler.ts +++ b/apps/cli/src/legacy/commands/db/reset/reset.handler.ts @@ -8,6 +8,7 @@ import { legacyResolveYesWithProjectEnv, } from "../../../../shared/legacy/global-flags.ts"; import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { CONTEXT_CANCELED_MESSAGE } from "../../../../shared/output/errors.ts"; import { Output } from "../../../../shared/output/output.service.ts"; import { legacyAqua, legacyYellow } from "../../../shared/legacy-colors.ts"; import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; @@ -20,7 +21,8 @@ import { } from "../../../shared/legacy-db-config.toml-read.ts"; import { LegacyDbConnection } from "../../../shared/legacy-db-connection.service.ts"; import { legacyApplyMigrations } from "../../../shared/legacy-migration-apply.ts"; -import { legacyPromptYesNo } from "../../../shared/legacy-prompt-yes-no.ts"; +import { legacyParseMigrationVersion } from "../../../shared/legacy-migration-timestamp.format.ts"; +import { legacyPromptYesNo } from "../../../../shared/legacy/legacy-prompt-yes-no.ts"; import { type LegacyDbConnType, resolveLegacyDbTargetFlags, @@ -47,7 +49,6 @@ import { LegacyDbResetVersionFlagsError, } from "./reset.errors.ts"; -const INTEGER_PATTERN = /^[+-]?\d+$/u; const MIGRATE_FILE_PATTERN = /^([0-9]+)_(.*)\.sql$/u; const applyError = (message: string) => new LegacyDbResetApplyError({ message }); @@ -186,12 +187,19 @@ export const legacyDbReset = Effect.fn("legacy.db.reset")(function* (flags: Lega // Version / last resolution (Go's reset.Run lines 34-52), filesystem only. let resolvedVersion = ""; - if (Option.isSome(flags.version)) { + // Go's `len(version) > 0` guard (reset.go:34) skips validation entirely for an + // empty --version, falling through as if no version were given at all. + if (Option.isSome(flags.version) && flags.version.value.length > 0) { const v = flags.version.value; - if (!INTEGER_PATTERN.test(v)) { + // Go's `strconv.Atoi` (== `ParseInt(s, 10, 0)`) rejects non-numeric text AND + // values outside the int64 range; `legacyParseMigrationVersion` mirrors that + // exactly (`migration repair` uses the same helper for its own version parse). + if (legacyParseMigrationVersion(v) === undefined) { + // Go's reset.Run returns the bare repair.ErrInvalidVersion (reset.go:35-36); + // the `failed to parse <v>:` wrapper belongs to `migration repair` only. return yield* Effect.fail( new LegacyDbResetInvalidVersionError({ - message: `failed to parse ${v}: invalid version number`, + message: "invalid version number", }), ); } @@ -415,7 +423,9 @@ export const legacyDbReset = Effect.fn("legacy.db.reset")(function* (flags: Lega false, ); if (!shouldReset) { - return yield* Effect.fail(new LegacyDbResetCancelledError({ message: "context canceled" })); + return yield* Effect.fail( + new LegacyDbResetCancelledError({ message: CONTEXT_CANCELED_MESSAGE }), + ); } yield* output.raw(`Resetting remote database${toLogMessage(resolvedVersion)}\n`, "stderr"); diff --git a/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts b/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts index 48d25519c8..3f710a511a 100644 --- a/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts @@ -702,8 +702,15 @@ describe("legacy db reset", () => { version: Option.some("not-a-number"), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) - expect(JSON.stringify(exit.cause)).toContain("invalid version number"); + if (Exit.isFailure(exit)) { + const failure = Cause.findErrorOption(exit.cause); + expect(Option.isSome(failure) && failure.value._tag).toBe( + "LegacyDbResetInvalidVersionError", + ); + // Go's reset.Run returns the bare repair.ErrInvalidVersion (reset.go:35-36) — + // no `failed to parse <v>:` wrapper (that belongs to `migration repair`). + expect(Option.isSome(failure) && failure.value.message).toBe("invalid version number"); + } }); }); @@ -724,6 +731,47 @@ describe("legacy db reset", () => { }); }); + it.live("rejects an out-of-int64-range --version", () => { + // Go's `strconv.Atoi` == `ParseInt(s, 10, 0)`, which rejects magnitudes outside the + // int64 range even though the text is all digits. `INTEGER_PATTERN` alone would have + // accepted this and fallen through to the glob check instead. + const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n' }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset({ + ...DEFAULT_FLAGS, + linked: true, + version: Option.some("99999999999999999999"), + }).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const failure = Cause.findErrorOption(exit.cause); + expect(Option.isSome(failure) && failure.value._tag).toBe( + "LegacyDbResetInvalidVersionError", + ); + expect(Option.isSome(failure) && failure.value.message).toBe("invalid version number"); + } + }); + }); + + it.live("treats an empty --version like no version at all", () => { + // Go's `len(version) > 0` guard (reset.go:34) skips validation entirely for an empty + // --version, so it must fall through to a full reset rather than glob-checking "" or + // rejecting it as an invalid version. + const { layer, out, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n', + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbReset({ + ...DEFAULT_FLAGS, + linked: true, + version: Option.some(""), + }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Resetting remote database..."); + expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(true); + }); + }); + it.live("returns context canceled when the reset prompt is declined", () => { const { layer, conn } = setup(tmp.current, { toml: 'project_id = "test"\n', diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.gate.unit.test.ts b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.gate.unit.test.ts index f605be37fe..030c0890ea 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.gate.unit.test.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.gate.unit.test.ts @@ -1,6 +1,7 @@ import { Cause, Effect, Exit } from "effect"; import { describe, expect, it } from "vitest"; +import { stripAnsi } from "../../../../../../tests/helpers/ansi.ts"; import { LegacyDeclarativeNotEnabledError } from "./declarative.errors.ts"; import { legacyIsPgDeltaEnabled, @@ -8,12 +9,6 @@ import { legacyRequirePgDelta, } from "./declarative.gate.ts"; -// `legacyAqua`/`legacyBold` colour their tokens when stderr is a TTY (matching -// Go's lipgloss). Strip ANSI so the assertions validate text content exactly, -// independent of the runner's colour profile. -const stripAnsi = (text: string) => - text.replace(new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g"), ""); - const EXPECTED_SUGGESTION = "Either pass --experimental or add [experimental.pgdelta] with enabled = true to supabase/config.toml"; diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.smart-target.ts b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.smart-target.ts index 77fb087c26..2a01c96912 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.smart-target.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.smart-target.ts @@ -3,8 +3,9 @@ import { Effect, type FileSystem, Option, type Path } from "effect"; import { LegacyDnsResolverFlag, LegacyNetworkIdFlag, - LegacyYesFlag, + legacyResolveYesWithProjectEnv, } from "../../../../../shared/legacy/global-flags.ts"; +import { legacyPromptYesNo } from "../../../../../shared/legacy/legacy-prompt-yes-no.ts"; import { Output } from "../../../../../shared/output/output.service.ts"; import { PROJECT_REF_PATTERN } from "../../../../config/legacy-project-ref.service.ts"; import { LegacyDbConfigResolver } from "../../../../shared/legacy-db-config.service.ts"; @@ -96,7 +97,11 @@ export const legacyResolveSmartTargetUrl = Effect.fnUntraced(function* ( } const output = yield* Output; - const yes = yield* LegacyYesFlag; + // Go's prompts below read `viper.GetBool("YES")` after `loadNestedEnv` + // (`pkg/config/config.go:789`), so `SUPABASE_YES` — from the shell env or the + // project `.env` — must auto-confirm too, not just the flag (CLI-1974). + const projectEnv = yield* legacyLoadProjectEnv(fs, path, workdir); + const yes = yield* legacyResolveYesWithProjectEnv(projectEnv); const networkId = yield* LegacyNetworkIdFlag; // Insert "Linked project" between local and custom (Go's choice order) when the // workdir is linked with a valid ref. Go gates this on `LoadProjectRef`, which @@ -132,10 +137,9 @@ export const legacyResolveSmartTargetUrl = Effect.fnUntraced(function* ( } // Go parses the entry with pgconn.ParseConfig then feeds pg-delta a normalized // ToPostgresURL (`apps/cli-go/cmd/db_schema_declarative.go:283-287`). Layer the - // project env under the shell env like the --db-url path so libpq PG* fallbacks - // resolve, and reject malformed input with Go's "failed to parse connection - // string" error (password redacted, CWE-209). - const projectEnv = yield* legacyLoadProjectEnv(fs, path, workdir); + // project env (loaded once above) under the shell env like the --db-url path so + // libpq PG* fallbacks resolve, and reject malformed input with Go's "failed to + // parse connection string" error (password redacted, CWE-209). const conn = parseLegacyConnectionString( dbURL, (name) => process.env[name] ?? projectEnv[name], @@ -157,15 +161,16 @@ export const legacyResolveSmartTargetUrl = Effect.fnUntraced(function* ( let shouldReset = flags.reset; if (!shouldReset) { - // Go asks via Console.PromptYesNo (db_schema_declarative.go:257, default false), - // which auto-returns true under the global --yes flag (console.go:74-77), so - // `--yes` auto-resets here instead of prompting. - shouldReset = yes - ? true - : yield* output.promptConfirm( - "Reset local database to match migrations first? (local data will be lost)", - { defaultValue: false }, - ); + // Go asks via Console.PromptYesNo (db_schema_declarative.go:320-322, default + // false): --yes/SUPABASE_YES auto-resets WITH the `<label> [y/N] y` stderr + // echo (console.go:70-72) — routed through `legacyPromptYesNo` so the echo + // is not skipped (CLI-1974). + shouldReset = yield* legacyPromptYesNo( + output, + yes, + "Reset local database to match migrations first? (local data will be lost)", + false, + ); } if (shouldReset) { // Go runs reset in-process and returns the error (`cmd/db_schema_declarative.go:262-267`). diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.handler.ts b/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.handler.ts index 3cec545df7..def35f12a7 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.handler.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.handler.ts @@ -1,9 +1,10 @@ import { Effect, FileSystem, Option, Path } from "effect"; import { - LegacyYesFlag, legacyResolveExperimentalWithProjectEnv, + legacyResolveYesWithProjectEnv, } from "../../../../../../shared/legacy/global-flags.ts"; +import { legacyPromptYesNo } from "../../../../../../shared/legacy/legacy-prompt-yes-no.ts"; import { Output } from "../../../../../../shared/output/output.service.ts"; import { Tty } from "../../../../../../shared/runtime/tty.service.ts"; import { LegacyCliConfig } from "../../../../../config/legacy-cli-config.service.ts"; @@ -53,7 +54,10 @@ export const legacyDbSchemaDeclarativeGenerate = Effect.fn("legacy.db.schema.dec // `SUPABASE_EXPERIMENTAL` set only in `supabase/.env` opens the gate too. const projectEnv = yield* legacyLoadProjectEnv(fs, path, cliConfig.workdir); const experimental = yield* legacyResolveExperimentalWithProjectEnv(projectEnv); - const yes = yield* LegacyYesFlag; + // `--yes` OR `SUPABASE_YES` (shell env or project `.env`): Go's prompts here + // read `viper.GetBool("YES")` after `loadNestedEnv`, so the env var must + // auto-confirm too, not just the flag (CLI-1974). + const yes = yield* legacyResolveYesWithProjectEnv(projectEnv); // The resolved linked ref (explicit `--linked` only), hoisted so the post-run // linked-project cache finalizer can read it after the body resolves it. @@ -168,17 +172,18 @@ export const legacyDbSchemaDeclarativeGenerate = Effect.fn("legacy.db.schema.dec ); } if ((yield* hasDeclarativeFiles(fs, declarativeDir)) && !flags.overwrite) { - // Go asks via Console.PromptYesNo (db_schema_declarative.go:208, default - // false), which auto-returns true under the global --yes flag, so --yes - // regenerates without prompting instead of blocking in non-interactive mode. - const ok = yes - ? true - : yield* output.promptConfirm( - `Declarative schema already exists at ${legacyBold( - declarativeDir, - )}. Regenerate from database? This will overwrite existing files.`, - { defaultValue: false }, - ); + // Go asks via Console.PromptYesNo (db_schema_declarative.go:268-270, + // default false): --yes/SUPABASE_YES auto-confirms WITH the + // `<label> [y/N] y` stderr echo (console.go:70-72) — routed through + // `legacyPromptYesNo` so the echo is not skipped (CLI-1974). + const ok = yield* legacyPromptYesNo( + output, + yes, + `Declarative schema already exists at ${legacyBold( + declarativeDir, + )}. Regenerate from database? This will overwrite existing files.`, + false, + ); if (!ok) { yield* output.raw("Skipped generating declarative schema.\n", "stderr"); return; @@ -227,16 +232,17 @@ export const legacyDbSchemaDeclarativeGenerate = Effect.fn("legacy.db.schema.dec const result = yield* legacyGenerateDeclarativeOutput(run, targetUrl); if (!overwrite && (yield* confirmOverwriteHasFiles(fs, declarativeDir))) { - // Go's confirmOverwrite goes through Console.PromptYesNo, which returns true - // immediately when the global YES flag is set (`apps/cli-go/internal/utils/ - // console.go:70-73`). Honor --yes here too, or non-interactive/JSON runs - // would error on the prompt and a TTY would block despite --yes. - const ok = yes - ? true - : yield* output.promptConfirm( - "Overwrite declarative schema? Existing files may be deleted.", - { defaultValue: false }, - ); + // Go's confirmOverwrite goes through Console.PromptYesNo (`internal/db/ + // declarative/declarative.go:234`, default false): --yes/SUPABASE_YES + // auto-confirms WITH the `<label> [y/N] y` stderr echo (console.go:70-72) + // — routed through `legacyPromptYesNo` so the echo is not skipped + // (CLI-1974). + const ok = yield* legacyPromptYesNo( + output, + yes, + "Overwrite declarative schema? Existing files may be deleted.", + false, + ); if (!ok) { yield* output.raw("Skipped writing declarative schema.\n", "stderr"); return; diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.integration.test.ts b/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.integration.test.ts index 48ade9b8a6..b8c4e2733d 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.integration.test.ts @@ -5,7 +5,7 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, Layer, Option } from "effect"; -import { mockOutput, mockTty } from "../../../../../../../tests/helpers/mocks.ts"; +import { mockOutput, mockStdin, mockTty } from "../../../../../../../tests/helpers/mocks.ts"; import { mockLegacyCliConfig, mockLegacyLinkedProjectCacheTracked, @@ -148,6 +148,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { proxy, mockLegacyCliConfig({ workdir, projectId: opts.projectId ?? Option.some("test") }), mockTty({ stdinIsTty: opts.stdinIsTty ?? false, stdoutIsTty: false }), + mockStdin(opts.stdinIsTty ?? false), Layer.succeed(LegacyExperimentalFlag, opts.experimental ?? true), Layer.succeed(CliArgs, { args: opts.args ?? ["db", "schema", "declarative", "generate"] }), Layer.succeed(LegacyYesFlag, opts.yes ?? false), @@ -644,12 +645,43 @@ describe("legacy db schema declarative generate integration", () => { return Effect.gen(function* () { yield* legacyDbSchemaDeclarativeGenerate(flags()); expect(s.seamCalls).toEqual(["baseline", "declarative"]); + // Go's PromptYesNo echoes the auto-accepted question to stderr under the + // global YES flag (`console.go:70-72`) — the echo must not be skipped. + expect(s.out.stderrText).toContain( + ". Regenerate from database? This will overwrite existing files. [y/N] y\n", + ); expect( s.out.rawChunks.some((c) => c.text.includes("Skipped generating declarative schema")), ).toBe(false); }).pipe(Effect.provide(s.layer)); }); + it.effect("smart mode: SUPABASE_YES=1 regenerates over existing files like --yes", () => { + // Go reads `viper.GetBool("YES")`, which `AutomaticEnv` also binds to the + // SUPABASE_YES env var — the flag alone is not the whole surface (CLI-1974). + const declDir = join(tmp.current, "supabase", "database"); + mkdirSync(declDir, { recursive: true }); + writeFileSync(join(declDir, "existing.sql"), "-- existing"); + const prev = process.env["SUPABASE_YES"]; + process.env["SUPABASE_YES"] = "1"; + const s = setup(tmp.current, { experimental: true, stdinIsTty: false, yes: false }); + return Effect.gen(function* () { + yield* legacyDbSchemaDeclarativeGenerate(flags()); + expect(s.seamCalls).toEqual(["baseline", "declarative"]); + expect(s.out.stderrText).toContain( + ". Regenerate from database? This will overwrite existing files. [y/N] y\n", + ); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (prev === undefined) delete process.env["SUPABASE_YES"]; + else process.env["SUPABASE_YES"] = prev; + }), + ), + Effect.provide(s.layer), + ); + }); + it.effect("warms the declarative catalog cache after writing (skipped with --no-cache)", () => { const s = setup(tmp.current, { experimental: true }); return Effect.gen(function* () { diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.layers.ts b/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.layers.ts index f21b4ccae0..6f2429fe57 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.layers.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.layers.ts @@ -1,6 +1,7 @@ import { Layer } from "effect"; import { commandRuntimeLayer } from "../../../../../../shared/runtime/command-runtime.layer.ts"; +import { stdinLayer } from "../../../../../../shared/runtime/stdin.layer.ts"; import { legacyCliConfigLayer } from "../../../../../config/legacy-cli-config.layer.ts"; import { legacyDbConfigLayer } from "../../../../../shared/legacy-db-config.layer.ts"; import { legacyDbConnectionLayer } from "../../../../../shared/legacy-db-connection.layer.ts"; @@ -58,4 +59,7 @@ export const legacyDbSchemaDeclarativeGenerateRuntimeLayer = Layer.mergeAll( Layer.provide(legacyIdentityStitchLayer), ), commandRuntimeLayer(["db", "schema", "declarative", "generate"]), + // `stdinLayer`: the confirmation prompts route through `legacyPromptYesNo`, + // whose non-TTY branch reads piped stdin (Go's `Console.ReadLine`). + stdinLayer, ); diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/sync/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/schema/declarative/sync/SIDE_EFFECTS.md index e2182911be..ba4ee2562b 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/sync/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/schema/declarative/sync/SIDE_EFFECTS.md @@ -60,7 +60,10 @@ surfaces before an `--apply`/`--no-apply` conflict is ever checked. ## Output Text mode only. The generated SQL, the created-migration path, drop-statement -warnings, and apply status are written to stderr. +warnings, and apply status are written to stderr. The no-files bootstrap also +prints `Declarative schema written to <dir>` (the relative declarative dir, Go's +`GetDeclarativeDir()`) to stderr after generating, writing, and warming the +catalog cache — on both the interactive-accept and `--yes` paths. `--no-apply` writes the migration only (never prompts/applies); `--apply` applies without prompting; both override the global `--yes`. `--no-apply` and `--apply` are mutually exclusive. diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.handler.ts b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.handler.ts index 63f8d8a7e4..e7f946362b 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.handler.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.handler.ts @@ -3,9 +3,10 @@ import { Cause, Clock, Effect, Exit, FileSystem, Option, Path } from "effect"; import { LegacyDnsResolverFlag, LegacyNetworkIdFlag, - LegacyYesFlag, legacyResolveExperimentalWithProjectEnv, + legacyResolveYesWithProjectEnv, } from "../../../../../../shared/legacy/global-flags.ts"; +import { legacyPromptYesNo } from "../../../../../../shared/legacy/legacy-prompt-yes-no.ts"; import { Output } from "../../../../../../shared/output/output.service.ts"; import { Tty } from "../../../../../../shared/runtime/tty.service.ts"; import { LegacyCliConfig } from "../../../../../config/legacy-cli-config.service.ts"; @@ -52,7 +53,10 @@ import { legacyGenerateDeclarativeOutput, } from "../declarative.orchestrate.ts"; import { LegacyDeclarativeSeam } from "../../../shared/legacy-pgdelta.seam.service.ts"; -import { legacyWriteDeclarativeSchemas } from "../../../shared/legacy-pgdelta.write.ts"; +import { + legacyDeclarativeSchemaWrittenLine, + legacyWriteDeclarativeSchemas, +} from "../../../shared/legacy-pgdelta.write.ts"; import type { LegacyDbSchemaDeclarativeSyncFlags } from "./sync.command.ts"; const DEFAULT_SYNC_NAME = "declarative_sync"; @@ -81,7 +85,10 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara // `SUPABASE_EXPERIMENTAL` set only in `supabase/.env` opens the gate too. const projectEnv = yield* legacyLoadProjectEnv(fs, path, cliConfig.workdir); const experimental = yield* legacyResolveExperimentalWithProjectEnv(projectEnv); - const yes = yield* LegacyYesFlag; + // `--yes` OR `SUPABASE_YES` (shell env or project `.env`): Go's prompts here + // read `viper.GetBool("YES")` after `loadNestedEnv`, so the env var must + // auto-confirm too, not just the flag (CLI-1974). + const yes = yield* legacyResolveYesWithProjectEnv(projectEnv); const networkId = yield* LegacyNetworkIdFlag; const dnsResolver = yield* LegacyDnsResolverFlag; const seam = yield* LegacyDeclarativeSeam; @@ -123,13 +130,15 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara ); } + // Go's `utils.GetDeclarativeDir()` — the config value verbatim (already + // `supabase/`-prefixed when relative) or the relative `supabase/database` + // default. Printed verbatim in the bootstrap's written-to line below, exactly + // as Go prints it (Go chdirs into the workdir, so its paths stay relative). + const declarativeDirRel = legacyResolveDeclarativeDir(path, toml.pgDelta); // `path.resolve` (not `path.join`) so an absolute `declarative_schema_path` is // used as-is, matching Go's `config.resolve` (which only prefixes the workdir onto // a relative path). `path.join(workdir, abs)` would mangle the absolute path. - const declarativeDir = path.resolve( - cliConfig.workdir, - legacyResolveDeclarativeDir(path, toml.pgDelta), - ); + const declarativeDir = path.resolve(cliConfig.workdir, declarativeDirRel); const migrationsDir = path.join(cliConfig.workdir, "supabase", "migrations"); const tempDir = legacyPgDeltaTempPath(path, cliConfig.workdir); const run: LegacyDeclarativeRunContext = { @@ -168,14 +177,16 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara message: "no declarative schema found. Run supabase db schema declarative generate first", }); if (!tty.stdinIsTty && !yes) return yield* Effect.fail(noFiles); - // Go's Console.PromptYesNo auto-returns true when the global YES flag is set - // (`apps/cli-go/internal/utils/console.go:70-73`), so --yes must skip this - // prompt rather than block/fail. - const ok = yes - ? true - : yield* output.promptConfirm("No declarative schema found. Generate a new one ?", { - defaultValue: true, - }); + // Go asks via Console.PromptYesNo (db_schema_declarative.go:381, default + // true): --yes/SUPABASE_YES auto-confirms WITH the `<label> [Y/n] y` + // stderr echo (console.go:70-72) — routed through `legacyPromptYesNo` + // so the echo is not skipped (CLI-1974). + const ok = yield* legacyPromptYesNo( + output, + yes, + "No declarative schema found. Generate a new one ?", + true, + ); if (!ok) return yield* Effect.fail(noFiles); // Go delegates to the full smart-generate flow (`runDeclarativeGenerate`, // db_schema_declarative.go:321): with migrations present it offers the @@ -243,6 +254,15 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara if (!run.noCache) { yield* seam.exportCatalog({ mode: "declarative", noCache: run.noCache }); } + // Go's delegated `declarative.Generate` prints the written-to line to stderr + // after the write and the catalog warm (`declarative.go:133→138-155→156`), on + // both the interactive-accept and --yes/SUPABASE_YES bootstrap paths, and + // regardless of --no-cache (the warm is skipped, the line is not). It prints + // `utils.GetDeclarativeDir()` — the relative dir above, never a resolved + // absolute path, because Go chdirs into the workdir (CLI-1980). NOTE: + // `generate`'s port of this same Go line still prints the absolute dir + // today — a follow-up candidate for the same relative-dir treatment. + yield* output.raw(legacyDeclarativeSchemaWrittenLine(declarativeDirRel), "stderr"); } // Step 2: diff migrations state vs declarative; on error, save a debug bundle. diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.integration.test.ts b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.integration.test.ts index fa04d716dd..a5acb0655e 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.integration.test.ts @@ -4,7 +4,8 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, Layer, Option } from "effect"; -import { mockOutput, mockTty } from "../../../../../../../tests/helpers/mocks.ts"; +import { stripAnsi } from "../../../../../../../tests/helpers/ansi.ts"; +import { mockOutput, mockStdin, mockTty } from "../../../../../../../tests/helpers/mocks.ts"; import { mockLegacyCliConfig, mockLegacyLinkedProjectCacheTracked, @@ -70,8 +71,16 @@ function setup(workdir: string, opts: SetupOpts = {}) { const cache = mockLegacyLinkedProjectCacheTracked(); const execInheritCalls: ReadonlyArray<string>[] = []; const localPostgresImageChecks: Array<true> = []; + // Each catalog export records how many raw chunks had been emitted when it fired, + // so tests can assert output ordering relative to the exports (e.g. the bootstrap's + // written-to line lands after the declarative warm, before the diff's exports). + const exportCatalogCalls: Array<{ mode: string; rawChunksAt: number }> = []; const seam = Layer.succeed(LegacyDeclarativeSeam, { - exportCatalog: ({ mode }) => Effect.succeed(`supabase/.temp/pgdelta/${mode}.json`), + exportCatalog: ({ mode }) => + Effect.sync(() => { + exportCatalogCalls.push({ mode, rawChunksAt: out.rawChunks.length }); + return `supabase/.temp/pgdelta/${mode}.json`; + }), execInherit: (args) => Effect.sync(() => { execInheritCalls.push(args); @@ -170,6 +179,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { resolver, mockLegacyCliConfig({ workdir, projectId: opts.projectId ?? Option.some("test") }), mockTty({ stdinIsTty: opts.stdinIsTty ?? false, stdoutIsTty: false }), + mockStdin(opts.stdinIsTty ?? false), Layer.succeed(LegacyExperimentalFlag, opts.experimental ?? true), Layer.succeed(CliArgs, { args: opts.args ?? ["db", "schema", "declarative", "sync"] }), Layer.succeed(LegacyYesFlag, opts.yes ?? false), @@ -185,7 +195,15 @@ function setup(workdir: string, opts: SetupOpts = {}) { }), BunServices.layer, ); - return { layer, out, execInheritCalls, dbExec, cache, localPostgresImageChecks }; + return { + layer, + out, + execInheritCalls, + dbExec, + cache, + localPostgresImageChecks, + exportCatalogCalls, + }; } const flags = ( @@ -447,6 +465,92 @@ describe("legacy db schema declarative sync integration", () => { }).pipe(Effect.provide(s.layer)); }); + it.effect("bootstrap prints the declarative-schema-written line after the catalog warm", () => { + // Go's bootstrap delegates to `declarative.Generate`, which prints + // `Declarative schema written to <dir>` to stderr AFTER WriteDeclarativeSchemas + // and the catalog warm (`declarative.go:133→138-155→156`), before sync's own + // diff (step 2). It prints `utils.GetDeclarativeDir()` — the relative + // `supabase/database` default — never the absolute resolved dir (CLI-1980). + const s = setup(tmp.current, { + experimental: true, + stdinIsTty: true, + diffSql: "", + exportJson: EXPORT_JSON, + promptConfirmResponses: [true], // generate a new one? yes (no migrations → no reset prompt) + }); + return Effect.gen(function* () { + yield* legacyDbSchemaDeclarativeSync(flags({ noApply: Option.some(true) })); + const line = `Declarative schema written to ${join("supabase", "database")}\n`; + const written = s.out.rawChunks + .map((c, index) => ({ text: stripAnsi(c.text), stream: c.stream, index })) + .filter((c) => c.text === line); + expect(written).toHaveLength(1); + expect(written[0]?.stream).toBe("stderr"); + const lineAt = written[0]?.index ?? -1; + // The warm (first declarative-mode export) fires before the line is printed… + const warm = s.exportCatalogCalls.find((c) => c.mode === "declarative"); + expect(warm?.rawChunksAt).toBeLessThanOrEqual(lineAt); + // …and the diff's first export (migrations catalog) fires after it, so the + // line sits at the end of the bootstrap, matching Go's ordering. + const diffStart = s.exportCatalogCalls.find((c) => c.mode === "migrations"); + expect(diffStart?.rawChunksAt).toBeGreaterThan(lineAt); + // The generated files actually landed in the printed (resolved) dir. + expect( + existsSync( + join(tmp.current, "supabase", "database", "schemas", "public", "tables", "players.sql"), + ), + ).toBe(true); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("--yes bootstrap prints the declarative-schema-written line too", () => { + // Go reaches the same delegated `declarative.Generate` print on the + // auto-confirmed (--yes / SUPABASE_YES) bootstrap as on the interactive accept. + const s = setup(tmp.current, { + experimental: true, + stdinIsTty: false, + yes: true, + diffSql: "", + exportJson: EXPORT_JSON, + }); + return Effect.gen(function* () { + yield* legacyDbSchemaDeclarativeSync(flags({ noApply: Option.some(true) })); + expect( + s.out.rawChunks.map((c) => ({ text: stripAnsi(c.text), stream: c.stream })), + ).toContainEqual({ + text: `Declarative schema written to ${join("supabase", "database")}\n`, + stream: "stderr", + }); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("--no-cache bootstrap still prints the declarative-schema-written line", () => { + // Go's print sits OUTSIDE the `if !noCache` warm gate (`declarative.go:138-156`): + // skipping the catalog warm must not skip the line. + const s = setup(tmp.current, { + experimental: true, + stdinIsTty: false, + yes: true, + diffSql: "", + exportJson: EXPORT_JSON, + }); + return Effect.gen(function* () { + yield* legacyDbSchemaDeclarativeSync(flags({ noCache: true, noApply: Option.some(true) })); + const line = `Declarative schema written to ${join("supabase", "database")}\n`; + const written = s.out.rawChunks + .map((c, index) => ({ text: stripAnsi(c.text), stream: c.stream, index })) + .filter((c) => c.text === line); + expect(written).toHaveLength(1); + expect(written[0]?.stream).toBe("stderr"); + // The warm really was skipped: the only declarative-mode export is the diff's, + // which fires after the line — yet the line still printed. + const lineAt = written[0]?.index ?? -1; + const declarativeExports = s.exportCatalogCalls.filter((c) => c.mode === "declarative"); + expect(declarativeExports).toHaveLength(1); + expect(declarativeExports[0]?.rawChunksAt).toBeGreaterThan(lineAt); + }).pipe(Effect.provide(s.layer)); + }); + it.effect("bootstrap with migrations offers the smart target choice (not local-only)", () => { // Go delegates the no-files bootstrap to runDeclarativeGenerate; with migrations // present it offers local/linked/custom rather than silently generating from diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.layers.ts b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.layers.ts index 4d107a3ab7..0eb4fc8592 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.layers.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.layers.ts @@ -1,6 +1,7 @@ import { Layer } from "effect"; import { commandRuntimeLayer } from "../../../../../../shared/runtime/command-runtime.layer.ts"; +import { stdinLayer } from "../../../../../../shared/runtime/stdin.layer.ts"; import { legacyCliConfigLayer } from "../../../../../config/legacy-cli-config.layer.ts"; import { legacyDbConfigLayer } from "../../../../../shared/legacy-db-config.layer.ts"; import { legacyDbConnectionLayer } from "../../../../../shared/legacy-db-connection.layer.ts"; @@ -56,4 +57,7 @@ export const legacyDbSchemaDeclarativeSyncRuntimeLayer = Layer.mergeAll( Layer.provide(legacyIdentityStitchLayer), ), commandRuntimeLayer(["db", "schema", "declarative", "sync"]), + // `stdinLayer`: the confirmation prompts route through `legacyPromptYesNo`, + // whose non-TTY branch reads piped stdin (Go's `Console.ReadLine`). + stdinLayer, ); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.deno-templates.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.deno-templates.ts index 5a6fb7060b..2e069cdd60 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.deno-templates.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.deno-templates.ts @@ -35,7 +35,7 @@ export const legacyPgDeltaDeclarativeApplyScript = * config field) is absent or empty. Mirrors Go's `DefaultPgDeltaNpmVersion` * (`apps/cli-go/pkg/config/pgdelta_version.go:7`). */ -export const LEGACY_DEFAULT_PG_DELTA_NPM_VERSION = "1.0.0-alpha.32"; +export const LEGACY_DEFAULT_PG_DELTA_NPM_VERSION = "1.0.0-alpha.33"; /** * The literal version baked into the embedded templates above, replaced by diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.write.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.write.ts index ffce5573f9..53dfc3fd0b 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.write.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.write.ts @@ -1,8 +1,20 @@ import { Effect, type FileSystem, type Path } from "effect"; +import { legacyBold } from "../../../shared/legacy-colors.ts"; import { LegacyDeclarativeWriteError } from "./legacy-pgdelta.errors.ts"; import type { LegacyDeclarativeOutput } from "./legacy-pgdelta.ts"; +/** + * Go's `declarative.Generate` / `pull.go`'s written-to line, printed by all three + * declarative write paths (`generate`, `pull --declarative`, `sync`'s bootstrap). + * Each caller passes its own dir rendering (`pull` and `sync` already print the + * relative `GetDeclarativeDir()` value; `generate` still prints the resolved + * absolute dir — a follow-up candidate for the same treatment) — this only pins + * the shared message text in one place. + */ +export const legacyDeclarativeSchemaWrittenLine = (dir: string): string => + `Declarative schema written to ${legacyBold(dir)}\n`; + /** * Materializes pg-delta declarative export output under the declarative dir. * Mirrors Go's `WriteDeclarativeSchemas` (`declarative.go:239`): wipe the dir, diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.write.unit.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.write.unit.test.ts index be2a0e13de..ba2684ca1c 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.write.unit.test.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.write.unit.test.ts @@ -6,9 +6,13 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, FileSystem, Path } from "effect"; +import { legacyBold } from "../../../shared/legacy-colors.ts"; import { LegacyDeclarativeWriteError } from "./legacy-pgdelta.errors.ts"; import type { LegacyDeclarativeOutput } from "./legacy-pgdelta.ts"; -import { legacyWriteDeclarativeSchemas } from "./legacy-pgdelta.write.ts"; +import { + legacyDeclarativeSchemaWrittenLine, + legacyWriteDeclarativeSchemas, +} from "./legacy-pgdelta.write.ts"; const write = (declarativeDir: string, output: LegacyDeclarativeOutput) => Effect.gen(function* () { @@ -85,3 +89,11 @@ describe("legacyWriteDeclarativeSchemas", () => { ); }); }); + +describe("legacyDeclarativeSchemaWrittenLine", () => { + it("formats the shared written-to line for the given dir", () => { + expect(legacyDeclarativeSchemaWrittenLine("supabase/database")).toBe( + `Declarative schema written to ${legacyBold("supabase/database")}\n`, + ); + }); +}); diff --git a/apps/cli/src/legacy/commands/domains/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/domains/SIDE_EFFECTS.md index 5dada1097d..6d6b607d8c 100644 --- a/apps/cli/src/legacy/commands/domains/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/domains/SIDE_EFFECTS.md @@ -49,11 +49,10 @@ Cloudflare DNS-over-HTTPS CNAME pre-check. ## Telemetry Events Fired -| Event | When | Notable properties / groups | -| ---------------------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------- | -| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (all redacted — Go marks no `domains` flag telemetry-safe) | - -No custom events: the Go `internal/hostnames` package emits no `phtelemetry.*` calls. +| Event | When | Notable properties / groups | +| ----------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (all redacted — Go marks no `domains` flag telemetry-safe) | +| `cli_upgrade_suggested` | `create`/`get`/`activate`/`reverify` 4xx carrying the `entitlement_required` envelope | `feature_key` (from the envelope), `org_slug` (parsed from `upgrade_url`); envelope-only, no entitlements fallback | ## Output diff --git a/apps/cli/src/legacy/commands/domains/activate/activate.handler.ts b/apps/cli/src/legacy/commands/domains/activate/activate.handler.ts index 534050089c..de7496961a 100644 --- a/apps/cli/src/legacy/commands/domains/activate/activate.handler.ts +++ b/apps/cli/src/legacy/commands/domains/activate/activate.handler.ts @@ -7,6 +7,7 @@ import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-proje import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; import { emitLegacyHostnameResult } from "../domains.emit.ts"; import { mapLegacyDomainsHttpError } from "../domains.errors.ts"; +import { legacyGateMapError } from "../../../shared/legacy-upgrade-suggest.ts"; import type { LegacyDomainsActivateFlags } from "./activate.command.ts"; const mapActivateError = mapLegacyDomainsHttpError("activate"); @@ -27,7 +28,7 @@ export const legacyDomainsActivate = Effect.fn("legacy.domains.activate")(functi output.format === "text" ? yield* output.task("Activating custom hostname...") : undefined; const response = yield* api.v1.activateCustomHostname({ ref }).pipe( Effect.tapError(() => activating?.fail() ?? Effect.void), - Effect.catch(mapActivateError), + Effect.catch(legacyGateMapError({ projectRef: ref }, mapActivateError)), ); yield* activating?.clear() ?? Effect.void; diff --git a/apps/cli/src/legacy/commands/domains/activate/activate.integration.test.ts b/apps/cli/src/legacy/commands/domains/activate/activate.integration.test.ts index 950aeb2a95..d12f40f0d5 100644 --- a/apps/cli/src/legacy/commands/domains/activate/activate.integration.test.ts +++ b/apps/cli/src/legacy/commands/domains/activate/activate.integration.test.ts @@ -2,7 +2,7 @@ import { type V1GetHostnameConfigOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; import { Effect, Exit, Option } from "effect"; -import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { mockAnalytics, mockOutput } from "../../../../../tests/helpers/mocks.ts"; import { buildLegacyTestRuntime, mockLegacyCliConfig, @@ -38,14 +38,16 @@ interface SetupOpts { readonly goOutput?: GoOutput; readonly status?: number; readonly network?: "fail"; + readonly response?: unknown; } const tempRoot = useLegacyTempWorkdir("supabase-domains-activate-int-"); function setup(opts: SetupOpts = {}) { const out = mockOutput({ format: opts.format ?? "text" }); + const analytics = mockAnalytics(); const api = mockLegacyPlatformApi({ - response: { status: opts.status ?? 201, body: HOSTNAME_RESPONSE }, + response: { status: opts.status ?? 201, body: opts.response ?? HOSTNAME_RESPONSE }, network: opts.network, }); const cliConfig = mockLegacyCliConfig({ workdir: tempRoot.current }); @@ -55,16 +57,45 @@ function setup(opts: SetupOpts = {}) { out, api, cliConfig, + analytics, telemetry: telemetry.layer, linkedProjectCache: linkedProjectCache.layer, goOutput: opts.goOutput === undefined ? Option.none() : Option.some(opts.goOutput), }); - return { layer, out, api, telemetry, linkedProjectCache }; + return { layer, out, api, analytics, telemetry, linkedProjectCache }; } const baseFlags = { projectRef: Option.none<string>(), includeRawOutput: false }; describe("legacy domains activate integration", () => { + it.live("suggests upgrade from entitlement_required envelope on 400", () => { + const { layer, out, analytics, api } = setup({ + status: 400, + response: { + message: + "Custom domains require the Custom Domain add-on, available on the Pro plan and above.", + error: { + code: "entitlement_required", + feature: "custom_domain", + upgrade_url: "https://supabase.com/dashboard/org/env-org/billing", + }, + }, + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyDomainsActivate(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + expect(api.requests).toHaveLength(1); + expect(out.stderrText).toContain("Upgrade your plan:"); + expect(out.stderrText).toContain("/org/env-org/billing"); + expect(analytics.captured).toEqual([ + { + event: "cli_upgrade_suggested", + properties: { feature_key: "custom_domain", org_slug: "env-org" }, + }, + ]); + }).pipe(Effect.provide(layer)); + }); + it.live("prints the completion status to stderr in text mode", () => { const { layer, out, api, telemetry, linkedProjectCache } = setup(); return Effect.gen(function* () { diff --git a/apps/cli/src/legacy/commands/domains/create/create.handler.ts b/apps/cli/src/legacy/commands/domains/create/create.handler.ts index 8d3a10b585..35fdb7e6fb 100644 --- a/apps/cli/src/legacy/commands/domains/create/create.handler.ts +++ b/apps/cli/src/legacy/commands/domains/create/create.handler.ts @@ -10,6 +10,7 @@ import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state. import { verifyLegacyCname } from "../domains.cname.ts"; import { emitLegacyHostnameResult } from "../domains.emit.ts"; import { mapLegacyDomainsHttpError } from "../domains.errors.ts"; +import { legacyGateMapError } from "../../../shared/legacy-upgrade-suggest.ts"; import type { LegacyDomainsCreateFlags } from "./create.command.ts"; const mapCreateError = mapLegacyDomainsHttpError("create"); @@ -45,7 +46,7 @@ export const legacyDomainsCreate = Effect.fn("legacy.domains.create")(function* .updateHostnameConfig({ ref, custom_hostname: flags.customHostname }) .pipe( Effect.tapError(() => creating?.fail() ?? Effect.void), - Effect.catch(mapCreateError), + Effect.catch(legacyGateMapError({ projectRef: ref }, mapCreateError)), ); yield* creating?.clear() ?? Effect.void; diff --git a/apps/cli/src/legacy/commands/domains/create/create.integration.test.ts b/apps/cli/src/legacy/commands/domains/create/create.integration.test.ts index edb4843753..c541d03a8d 100644 --- a/apps/cli/src/legacy/commands/domains/create/create.integration.test.ts +++ b/apps/cli/src/legacy/commands/domains/create/create.integration.test.ts @@ -2,7 +2,7 @@ import { type V1GetHostnameConfigOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; import { Effect, Exit, Option } from "effect"; -import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { mockAnalytics, mockOutput } from "../../../../../tests/helpers/mocks.ts"; import { LEGACY_VALID_REF, buildLegacyTestRuntime, @@ -45,6 +45,7 @@ interface SetupOpts { readonly cname?: "ok" | "transport-fail" | "no-cname" | "mismatch" | "status-error"; readonly apiStatus?: number; readonly apiNetwork?: "fail"; + readonly apiResponse?: unknown; } const tempRoot = useLegacyTempWorkdir("supabase-domains-create-int-"); @@ -70,21 +71,25 @@ function setup(opts: SetupOpts = {}) { if (opts.apiNetwork === "fail") { return Effect.fail(legacyTransportFailure(request)); } - return Effect.succeed(legacyJsonResponse(request, opts.apiStatus ?? 201, HOSTNAME_RESPONSE)); + return Effect.succeed( + legacyJsonResponse(request, opts.apiStatus ?? 201, opts.apiResponse ?? HOSTNAME_RESPONSE), + ); }, }); const cliConfig = mockLegacyCliConfig({ workdir: tempRoot.current }); + const analytics = mockAnalytics(); const telemetry = mockLegacyTelemetryStateTracked(); const linkedProjectCache = mockLegacyLinkedProjectCacheTracked(); const layer = buildLegacyTestRuntime({ out, api, cliConfig, + analytics, telemetry: telemetry.layer, linkedProjectCache: linkedProjectCache.layer, goOutput: opts.goOutput === undefined ? Option.none() : Option.some(opts.goOutput), }); - return { layer, out, api, telemetry, linkedProjectCache }; + return { layer, out, api, analytics, telemetry, linkedProjectCache }; } function flags(over: Partial<{ includeRawOutput: boolean }> = {}) { @@ -112,6 +117,49 @@ describe("legacy domains create integration", () => { }).pipe(Effect.provide(layer)); }); + it.live("suggests upgrade from entitlement_required envelope on 400", () => { + const { layer, out, analytics, api } = setup({ + apiStatus: 400, + apiResponse: { + message: + "Custom domains require the Custom Domain add-on, available on the Pro plan and above.", + error: { + code: "entitlement_required", + feature: "custom_domain", + upgrade_url: "https://supabase.com/dashboard/org/env-org/billing", + }, + }, + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyDomainsCreate(flags())); + expect(Exit.isFailure(exit)).toBe(true); + expect(api.requests).toHaveLength(2); + expect(postedToInitialize(api)).toBe(true); + expect(out.stderrText).toContain("Upgrade your plan:"); + expect(out.stderrText).toContain("/org/env-org/billing"); + expect(analytics.captured).toEqual([ + { + event: "cli_upgrade_suggested", + properties: { feature_key: "custom_domain", org_slug: "env-org" }, + }, + ]); + }).pipe(Effect.provide(layer)); + }); + + it.live("plain 400 without envelope produces no upgrade hint", () => { + const { layer, out, analytics, api } = setup({ + apiStatus: 400, + apiResponse: { message: "invalid hostname" }, + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyDomainsCreate(flags())); + expect(Exit.isFailure(exit)).toBe(true); + expect(api.requests).toHaveLength(2); + expect(analytics.captured).toHaveLength(0); + expect(out.stderrText).not.toContain("Upgrade your plan:"); + }).pipe(Effect.provide(layer)); + }); + it.live("fails before any POST when the CNAME lookup transport fails", () => { const { layer, api } = setup({ cname: "transport-fail" }); return Effect.gen(function* () { diff --git a/apps/cli/src/legacy/commands/domains/get/get.handler.ts b/apps/cli/src/legacy/commands/domains/get/get.handler.ts index d60e6fd13f..70e76bc476 100644 --- a/apps/cli/src/legacy/commands/domains/get/get.handler.ts +++ b/apps/cli/src/legacy/commands/domains/get/get.handler.ts @@ -7,6 +7,7 @@ import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-proje import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; import { emitLegacyHostnameResult } from "../domains.emit.ts"; import { mapLegacyDomainsHttpError } from "../domains.errors.ts"; +import { legacyGateMapError } from "../../../shared/legacy-upgrade-suggest.ts"; import type { LegacyDomainsGetFlags } from "./get.command.ts"; const mapGetError = mapLegacyDomainsHttpError("get"); @@ -31,7 +32,7 @@ export const legacyDomainsGet = Effect.fn("legacy.domains.get")(function* ( : undefined; const response = yield* api.v1.getHostnameConfig({ ref }).pipe( Effect.tapError(() => fetching?.fail() ?? Effect.void), - Effect.catch(mapGetError), + Effect.catch(legacyGateMapError({ projectRef: ref }, mapGetError)), ); yield* fetching?.clear() ?? Effect.void; diff --git a/apps/cli/src/legacy/commands/domains/get/get.integration.test.ts b/apps/cli/src/legacy/commands/domains/get/get.integration.test.ts index 45cbd2ba3b..b7eb71d565 100644 --- a/apps/cli/src/legacy/commands/domains/get/get.integration.test.ts +++ b/apps/cli/src/legacy/commands/domains/get/get.integration.test.ts @@ -2,7 +2,7 @@ import { type V1GetHostnameConfigOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; import { Effect, Exit, Option } from "effect"; -import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { mockAnalytics, mockOutput } from "../../../../../tests/helpers/mocks.ts"; import { buildLegacyTestRuntime, mockLegacyCliConfig, @@ -38,13 +38,14 @@ interface SetupOpts { readonly goOutput?: GoOutput; readonly status?: number; readonly network?: "fail"; - readonly response?: typeof V1GetHostnameConfigOutput.Type; + readonly response?: unknown; } const tempRoot = useLegacyTempWorkdir("supabase-domains-get-int-"); function setup(opts: SetupOpts = {}) { const out = mockOutput({ format: opts.format ?? "text" }); + const analytics = mockAnalytics(); const api = mockLegacyPlatformApi({ response: { status: opts.status ?? 200, body: opts.response ?? HOSTNAME_RESPONSE }, network: opts.network, @@ -56,11 +57,12 @@ function setup(opts: SetupOpts = {}) { out, api, cliConfig, + analytics, telemetry: telemetry.layer, linkedProjectCache: linkedProjectCache.layer, goOutput: opts.goOutput === undefined ? Option.none() : Option.some(opts.goOutput), }); - return { layer, out, api, telemetry, linkedProjectCache }; + return { layer, out, api, analytics, telemetry, linkedProjectCache }; } const baseFlags = { projectRef: Option.none<string>(), includeRawOutput: false }; @@ -301,6 +303,48 @@ describe("legacy domains get integration", () => { }).pipe(Effect.provide(layer)); }); + it.live("suggests upgrade from entitlement_required envelope on 400", () => { + const { layer, out, analytics, api } = setup({ + status: 400, + response: { + message: + "Custom domains require the Custom Domain add-on, available on the Pro plan and above.", + error: { + code: "entitlement_required", + feature: "custom_domain", + upgrade_url: "https://supabase.com/dashboard/org/env-org/billing", + }, + }, + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyDomainsGet(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + expect(api.requests).toHaveLength(1); + expect(out.stderrText).toContain("Upgrade your plan:"); + expect(out.stderrText).toContain("/org/env-org/billing"); + expect(analytics.captured).toEqual([ + { + event: "cli_upgrade_suggested", + properties: { feature_key: "custom_domain", org_slug: "env-org" }, + }, + ]); + }).pipe(Effect.provide(layer)); + }); + + it.live("plain 404 without envelope produces no upgrade hint", () => { + const { layer, out, analytics, api } = setup({ + status: 404, + response: { message: "not found" }, + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyDomainsGet(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + expect(api.requests).toHaveLength(1); + expect(analytics.captured).toHaveLength(0); + expect(out.stderrText).not.toContain("Upgrade your plan:"); + }).pipe(Effect.provide(layer)); + }); + it.live("maps an HTTP error without a spinner in json mode", () => { const { layer, out } = setup({ format: "json", status: 503 }); return Effect.gen(function* () { diff --git a/apps/cli/src/legacy/commands/domains/reverify/reverify.handler.ts b/apps/cli/src/legacy/commands/domains/reverify/reverify.handler.ts index 83a527763a..9b338ecc2a 100644 --- a/apps/cli/src/legacy/commands/domains/reverify/reverify.handler.ts +++ b/apps/cli/src/legacy/commands/domains/reverify/reverify.handler.ts @@ -7,6 +7,7 @@ import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-proje import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; import { emitLegacyHostnameResult } from "../domains.emit.ts"; import { mapLegacyDomainsHttpError } from "../domains.errors.ts"; +import { legacyGateMapError } from "../../../shared/legacy-upgrade-suggest.ts"; import type { LegacyDomainsReverifyFlags } from "./reverify.command.ts"; const mapReverifyError = mapLegacyDomainsHttpError("re-verify"); @@ -27,7 +28,7 @@ export const legacyDomainsReverify = Effect.fn("legacy.domains.reverify")(functi output.format === "text" ? yield* output.task("Re-verifying custom hostname...") : undefined; const response = yield* api.v1.verifyDnsConfig({ ref }).pipe( Effect.tapError(() => reverifying?.fail() ?? Effect.void), - Effect.catch(mapReverifyError), + Effect.catch(legacyGateMapError({ projectRef: ref }, mapReverifyError)), ); yield* reverifying?.clear() ?? Effect.void; diff --git a/apps/cli/src/legacy/commands/domains/reverify/reverify.integration.test.ts b/apps/cli/src/legacy/commands/domains/reverify/reverify.integration.test.ts index f590ce361c..e6dfddd66e 100644 --- a/apps/cli/src/legacy/commands/domains/reverify/reverify.integration.test.ts +++ b/apps/cli/src/legacy/commands/domains/reverify/reverify.integration.test.ts @@ -2,7 +2,7 @@ import { type V1GetHostnameConfigOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; import { Effect, Exit, Option } from "effect"; -import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { mockAnalytics, mockOutput } from "../../../../../tests/helpers/mocks.ts"; import { buildLegacyTestRuntime, mockLegacyCliConfig, @@ -38,14 +38,16 @@ interface SetupOpts { readonly goOutput?: GoOutput; readonly status?: number; readonly network?: "fail"; + readonly response?: unknown; } const tempRoot = useLegacyTempWorkdir("supabase-domains-reverify-int-"); function setup(opts: SetupOpts = {}) { const out = mockOutput({ format: opts.format ?? "text" }); + const analytics = mockAnalytics(); const api = mockLegacyPlatformApi({ - response: { status: opts.status ?? 201, body: HOSTNAME_RESPONSE }, + response: { status: opts.status ?? 201, body: opts.response ?? HOSTNAME_RESPONSE }, network: opts.network, }); const cliConfig = mockLegacyCliConfig({ workdir: tempRoot.current }); @@ -55,16 +57,45 @@ function setup(opts: SetupOpts = {}) { out, api, cliConfig, + analytics, telemetry: telemetry.layer, linkedProjectCache: linkedProjectCache.layer, goOutput: opts.goOutput === undefined ? Option.none() : Option.some(opts.goOutput), }); - return { layer, out, api, telemetry, linkedProjectCache }; + return { layer, out, api, analytics, telemetry, linkedProjectCache }; } const baseFlags = { projectRef: Option.none<string>(), includeRawOutput: false }; describe("legacy domains reverify integration", () => { + it.live("suggests upgrade from entitlement_required envelope on 400", () => { + const { layer, out, analytics, api } = setup({ + status: 400, + response: { + message: + "Custom domains require the Custom Domain add-on, available on the Pro plan and above.", + error: { + code: "entitlement_required", + feature: "custom_domain", + upgrade_url: "https://supabase.com/dashboard/org/env-org/billing", + }, + }, + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyDomainsReverify(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + expect(api.requests).toHaveLength(1); + expect(out.stderrText).toContain("Upgrade your plan:"); + expect(out.stderrText).toContain("/org/env-org/billing"); + expect(analytics.captured).toEqual([ + { + event: "cli_upgrade_suggested", + properties: { feature_key: "custom_domain", org_slug: "env-org" }, + }, + ]); + }).pipe(Effect.provide(layer)); + }); + it.live("prints the initializing status to stderr in text mode", () => { const { layer, out, api, telemetry, linkedProjectCache } = setup(); return Effect.gen(function* () { diff --git a/apps/cli/src/legacy/commands/functions/deploy/deploy.command.ts b/apps/cli/src/legacy/commands/functions/deploy/deploy.command.ts index cb5577e056..45e233fc7e 100644 --- a/apps/cli/src/legacy/commands/functions/deploy/deploy.command.ts +++ b/apps/cli/src/legacy/commands/functions/deploy/deploy.command.ts @@ -1,7 +1,9 @@ +import { Layer } from "effect"; import { Argument, Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; import { FUNCTIONS_PROJECT_REF_SAFE_FLAGS } from "../../../../shared/functions/functions.shared.ts"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { stdinLayer } from "../../../../shared/runtime/stdin.layer.ts"; import { legacyManagementApiRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts"; import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; import { legacyFunctionsDeploy } from "./deploy.handler.ts"; @@ -69,5 +71,9 @@ export const legacyFunctionsDeployCommand = Command.make("deploy", config).pipe( withJsonErrorHandling, ), ), - Command.provide(legacyManagementApiRuntimeLayer(["functions", "deploy"])), + // `stdinLayer`: the `--prune` confirmation reads piped stdin via `legacyPromptYesNo` + // (Go's `Console.ReadLine`, `console.go:38-61`) on a non-TTY stdin. + Command.provide( + Layer.mergeAll(legacyManagementApiRuntimeLayer(["functions", "deploy"]), stdinLayer), + ), ); diff --git a/apps/cli/src/legacy/commands/functions/deploy/deploy.handler.ts b/apps/cli/src/legacy/commands/functions/deploy/deploy.handler.ts index f1f8e6c4c9..db3a446954 100644 --- a/apps/cli/src/legacy/commands/functions/deploy/deploy.handler.ts +++ b/apps/cli/src/legacy/commands/functions/deploy/deploy.handler.ts @@ -6,7 +6,7 @@ import { deployFunctions } from "../../../../shared/functions/deploy.ts"; 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 { LegacyYesFlag } from "../../../../shared/legacy/global-flags.ts"; +import { legacyResolveYes } from "../../../../shared/legacy/global-flags.ts"; import { legacyDashboardUrl } from "../../../shared/legacy-profile.ts"; import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; @@ -19,7 +19,10 @@ export const legacyFunctionsDeploy = Effect.fn("legacy.functions.deploy")(functi const api = yield* LegacyPlatformApi; const cliConfig = yield* LegacyCliConfig; const resolver = yield* LegacyProjectRefResolver; - const yes = yield* LegacyYesFlag; + // `--yes` OR `SUPABASE_YES` (Go's `viper.GetBool("YES")` inside the `--prune` + // confirm, `deploy.go:190` + root.go:318-320) — the env var must auto-confirm + // too, not just the flag (CLI-1974). + const yes = yield* legacyResolveYes; const linkedProjectCache = yield* LegacyLinkedProjectCache; const telemetryState = yield* LegacyTelemetryState; const runtimeInfo = yield* RuntimeInfo; diff --git a/apps/cli/src/legacy/commands/functions/deploy/deploy.integration.test.ts b/apps/cli/src/legacy/commands/functions/deploy/deploy.integration.test.ts index e572727916..9f735d97c5 100644 --- a/apps/cli/src/legacy/commands/functions/deploy/deploy.integration.test.ts +++ b/apps/cli/src/legacy/commands/functions/deploy/deploy.integration.test.ts @@ -49,6 +49,11 @@ async function writeLocalFunction( await writeFile(join(functionDir, "deno.json"), '{"imports":{}}\n'); } +// Strip ANSI SGR (color/bold) sequences — `legacyBold` styles the pruned slugs +// only when stderr supports color, so byte-assertions normalize first. +// eslint-disable-next-line no-control-regex +const stripSgr = (text: string) => text.replace(/\x1b\[[0-9;]*m/gu, ""); + describe("legacy functions deploy", () => { it.live("deploys a function natively through the Management API", () => { const out = mockOutput({ format: "text" }); @@ -476,6 +481,12 @@ describe("legacy functions deploy", () => { yield* legacyFunctionsDeploy({ ...baseFlags, prune: true }); expect(out.promptConfirmCalls).toHaveLength(0); + // Go's `PromptYesNo` echoes the accepted prompt to stderr under the global + // YES flag (`console.go:70-72`) — byte-match `confirmPruneAll` + choices + // (each slug is bolded like Go's `utils.Bold`, so strip SGR codes first). + expect(stripSgr(out.stderrText)).toContain( + "Do you want to delete the following Functions from your project?\n • remote-only\n\n [y/N] y\n", + ); expect(api.requests.some((request) => request.method === "DELETE")).toBe(true); }).pipe( Effect.provide(layer), diff --git a/apps/cli/src/legacy/commands/functions/new/new.command.ts b/apps/cli/src/legacy/commands/functions/new/new.command.ts index 5a9ef84b25..7147196c97 100644 --- a/apps/cli/src/legacy/commands/functions/new/new.command.ts +++ b/apps/cli/src/legacy/commands/functions/new/new.command.ts @@ -3,6 +3,7 @@ import { Argument, Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; +import { stdinLayer } from "../../../../shared/runtime/stdin.layer.ts"; import { legacyCliConfigLayer } from "../../../config/legacy-cli-config.layer.ts"; import { legacyDebugLoggerLayer } from "../../../shared/legacy-debug-logger.layer.ts"; import { legacyTelemetryStateLayer } from "../../../telemetry/legacy-telemetry-state.layer.ts"; @@ -29,6 +30,9 @@ const legacyFunctionsNewRuntimeLayer = Layer.mergeAll( cliConfig, legacyTelemetryStateLayer, commandRuntimeLayer(["functions", "new"]), + // `stdinLayer`: the first-function IDE prompts read piped stdin via + // `legacyPromptYesNo` (Go's `Console.ReadLine`, `console.go:38-61`). + stdinLayer, ); export const legacyFunctionsNewCommand = Command.make("new", config).pipe( diff --git a/apps/cli/src/legacy/commands/functions/new/new.handler.ts b/apps/cli/src/legacy/commands/functions/new/new.handler.ts index 5080e4416b..993459d4c5 100644 --- a/apps/cli/src/legacy/commands/functions/new/new.handler.ts +++ b/apps/cli/src/legacy/commands/functions/new/new.handler.ts @@ -7,7 +7,8 @@ import { validateFunctionSlugMessage, } from "../../../../shared/functions/functions.shared.ts"; import { writeIntelliJConfig, writeVscodeConfig } from "../../../../shared/init/project-init.ts"; -import { LegacyYesFlag } from "../../../../shared/legacy/global-flags.ts"; +import { legacyResolveYes } from "../../../../shared/legacy/global-flags.ts"; +import { legacyPromptYesNo } from "../../../../shared/legacy/legacy-prompt-yes-no.ts"; import { Output } from "../../../../shared/output/output.service.ts"; import { Tty } from "../../../../shared/runtime/tty.service.ts"; import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; @@ -105,42 +106,23 @@ const resolveTemplateInputs = Effect.fnUntraced(function* (workdir: string, slug // never scaffold IDE settings as an undisclosed side effect. const promptForIdeSettings = Effect.fnUntraced(function* (workdir: string) { const output = yield* Output; - const tty = yield* Tty; - const yes = yield* LegacyYesFlag; - - // `--yes`: echo the accepted prompt and write, matching Go's `viper.GetBool("YES")` branch - // (`fmt.Fprintln(os.Stderr, label+"y")`). - if (yes) { - yield* output.raw("Generate VS Code settings for Deno? [Y/n] y\n", "stderr"); - yield* writeVscodeConfig(workdir).pipe( - Effect.mapError(mapLegacyFunctionsNewWriteError(".vscode")), - ); - return; - } - - // Non-TTY: Go's `PromptYesNo` prints the label, reads nothing within the 100ms timeout, and - // falls back to the default (`true` for VS Code). The trailing space + newline matches the - // bytes Go writes — the `"... [Y/n] "` label followed by the echoed empty line. - if (!tty.stdinIsTty) { - yield* output.raw("Generate VS Code settings for Deno? [Y/n] \n", "stderr"); - yield* writeVscodeConfig(workdir).pipe( - Effect.mapError(mapLegacyFunctionsNewWriteError(".vscode")), - ); - return; - } + // `--yes` OR `SUPABASE_YES` (Go's `viper.GetBool("YES")`, root.go:318-320). + const yes = yield* legacyResolveYes; - if (yield* output.promptConfirm("Generate VS Code settings for Deno?", { defaultValue: true })) { + // Both questions route through `legacyPromptYesNo`, mirroring Go's + // `PromptForIDESettings` (`init.go:61-75`, `console.go:64-82`): `--yes`/ + // `SUPABASE_YES` auto-accepts VS Code with the `[Y/n] y` stderr echo; a + // non-TTY stdin prints the label and scans one piped line (100ms), so + // `echo n | supabase functions new` declines VS Code and falls through to + // the IntelliJ question instead of hardcoding the default (CLI-1974). + if (yield* legacyPromptYesNo(output, yes, "Generate VS Code settings for Deno?", true)) { yield* writeVscodeConfig(workdir).pipe( Effect.mapError(mapLegacyFunctionsNewWriteError(".vscode")), ); return; } - if ( - yield* output.promptConfirm("Generate IntelliJ IDEA settings for Deno?", { - defaultValue: false, - }) - ) { + if (yield* legacyPromptYesNo(output, yes, "Generate IntelliJ IDEA settings for Deno?", false)) { yield* writeIntelliJConfig(workdir).pipe( Effect.mapError(mapLegacyFunctionsNewWriteError(".idea/deno.xml")), ); diff --git a/apps/cli/src/legacy/commands/functions/new/new.integration.test.ts b/apps/cli/src/legacy/commands/functions/new/new.integration.test.ts index f5d38145a4..cd80dcb134 100644 --- a/apps/cli/src/legacy/commands/functions/new/new.integration.test.ts +++ b/apps/cli/src/legacy/commands/functions/new/new.integration.test.ts @@ -11,7 +11,8 @@ import { mockLegacyTelemetryStateTracked, useLegacyTempWorkdir, } from "../../../../../tests/helpers/legacy-mocks.ts"; -import { mockOutput, mockTty } from "../../../../../tests/helpers/mocks.ts"; +import { mockOutput, mockStdin, mockTty } from "../../../../../tests/helpers/mocks.ts"; +import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; import { LegacyYesFlag } from "../../../../shared/legacy/global-flags.ts"; import { legacyFunctionsNew } from "./new.handler.ts"; import { LEGACY_FUNCTIONS_NEW_DENO_JSON, LEGACY_FUNCTIONS_NEW_NPMRC } from "./new.templates.ts"; @@ -24,6 +25,8 @@ interface SetupOptions { readonly stdoutIsTty?: boolean; readonly yes?: boolean; readonly promptConfirmResponses?: ReadonlyArray<boolean>; + /** Piped stdin lines consumed by the non-TTY IDE-settings confirm reads. */ + readonly stdinInput?: string; } function setup(options: SetupOptions = {}) { @@ -42,7 +45,9 @@ function setup(options: SetupOptions = {}) { stdinIsTty: options.stdinIsTty ?? false, stdoutIsTty: options.stdoutIsTty ?? false, }), + mockStdin(options.stdinIsTty ?? false, options.stdinInput), Layer.succeed(LegacyYesFlag, options.yes ?? false), + Layer.succeed(CliArgs, { args: [] }), ); return { layer, out, telemetry, workdir: tempRoot.current }; } @@ -221,6 +226,41 @@ describe("legacy functions new integration", () => { }).pipe(Effect.provide(layer)); }); + it.live("SUPABASE_YES=1 in the environment echoes the VS Code prompt and writes settings", () => { + const prev = process.env["SUPABASE_YES"]; + process.env["SUPABASE_YES"] = "1"; + const { layer, out, workdir } = setup({ yes: false }); + return Effect.gen(function* () { + yield* legacyFunctionsNew({ functionName: "with-env-yes", auth: "apikey" }); + // Same bytes as Go's `viper.GetBool("YES")` branch (`console.go:70-72`), + // reached through the env var — not just the --yes flag (CLI-1974). + expect(out.stderrText).toContain("Generate VS Code settings for Deno? [Y/n] y"); + expect(existsSync(join(workdir, ".vscode", "settings.json"))).toBe(true); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (prev === undefined) delete process.env["SUPABASE_YES"]; + else process.env["SUPABASE_YES"] = prev; + }), + ), + Effect.provide(layer), + ); + }); + + it.live("piped `n` then `y` declines VS Code and writes IntelliJ settings (Go parity)", () => { + // Go's non-TTY `PromptYesNo` scans one piped line per question + // (`console.go:38-61`), so `printf 'n\ny\n'` answers VS Code=no, + // IntelliJ=yes instead of hardcoding the VS Code default. + const { layer, out, workdir } = setup({ stdinIsTty: false, stdinInput: "n\ny\n" }); + return Effect.gen(function* () { + yield* legacyFunctionsNew({ functionName: "piped-idea", auth: "apikey" }); + expect(out.stderrText).toContain("Generate VS Code settings for Deno? [Y/n] n"); + expect(out.stderrText).toContain("Generate IntelliJ IDEA settings for Deno? [y/N] y"); + expect(existsSync(join(workdir, ".vscode", "settings.json"))).toBe(false); + expect(existsSync(join(workdir, ".idea", "deno.xml"))).toBe(true); + }).pipe(Effect.provide(layer)); + }); + it.live("writes IntelliJ settings when VS Code is declined and IntelliJ is accepted", () => { const { layer, out, workdir } = setup({ stdinIsTty: true, diff --git a/apps/cli/src/legacy/commands/gen/signing-key/signing-key.e2e.test.ts b/apps/cli/src/legacy/commands/gen/signing-key/signing-key.e2e.test.ts index 3a2b377ee4..af5c6b3474 100644 --- a/apps/cli/src/legacy/commands/gen/signing-key/signing-key.e2e.test.ts +++ b/apps/cli/src/legacy/commands/gen/signing-key/signing-key.e2e.test.ts @@ -43,6 +43,8 @@ describe("supabase gen signing-key (legacy)", () => { }); expect(exitCode).toBe(1); expect(stderr).toContain("context canceled"); + // No SuggestDebugFlag fallback for context.Canceled (cmd/root.go:287-303, CLI-1973). + expect(stderr).not.toContain("Try rerunning the command with --debug"); expect(stderr).not.toContain("Service not found"); const saved = readFileSync(join(projectDir, "supabase", "signing_keys.json"), "utf8"); expect(JSON.parse(saved)).toEqual([]); diff --git a/apps/cli/src/legacy/commands/gen/signing-key/signing-key.handler.ts b/apps/cli/src/legacy/commands/gen/signing-key/signing-key.handler.ts index 87a734da73..695bd660dd 100644 --- a/apps/cli/src/legacy/commands/gen/signing-key/signing-key.handler.ts +++ b/apps/cli/src/legacy/commands/gen/signing-key/signing-key.handler.ts @@ -8,9 +8,10 @@ import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; import { findGitRootPath } from "../../../../shared/git/git-root.ts"; import { legacyLoadProjectEnv } from "../../../shared/legacy-db-config.toml-read.ts"; import { LegacyDebugLogger } from "../../../shared/legacy-debug-logger.service.ts"; -import { legacyPromptYesNo } from "../../../shared/legacy-prompt-yes-no.ts"; +import { legacyPromptYesNo } from "../../../../shared/legacy/legacy-prompt-yes-no.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; import { legacyResolveYesWithProjectEnv } from "../../../../shared/legacy/global-flags.ts"; +import { CONTEXT_CANCELED_MESSAGE } from "../../../../shared/output/errors.ts"; import { Output } from "../../../../shared/output/output.service.ts"; import { Tty } from "../../../../shared/runtime/tty.service.ts"; import type { LegacyGenSigningKeyFlags } from "./signing-key.command.ts"; @@ -316,7 +317,7 @@ export const legacyGenSigningKey = Effect.fn("legacy.gen.signing-key")(function* ); if (!confirmed) { return yield* Effect.fail( - new LegacyGenSigningKeyCancelledError({ message: "context canceled" }), + new LegacyGenSigningKeyCancelledError({ message: CONTEXT_CANCELED_MESSAGE }), ); } return [key]; diff --git a/apps/cli/src/legacy/commands/gen/types/types.handler.ts b/apps/cli/src/legacy/commands/gen/types/types.handler.ts index 3f090e6090..5f4caa1dbf 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.handler.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.handler.ts @@ -2,7 +2,6 @@ import { loadProjectConfig } from "@supabase/config"; import { ChildProcessSpawner } from "effect/unstable/process"; import { Effect, FileSystem, Option, Path, Stdio, Stream } from "effect"; import { - LegacyDebugFlag, LegacyDnsResolverFlag, LegacyNetworkIdFlag, } from "../../../../shared/legacy/global-flags.ts"; @@ -192,7 +191,6 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le const stdio = yield* Stdio.Stdio; const networkId = yield* LegacyNetworkIdFlag; const dnsResolver = yield* LegacyDnsResolverFlag; - const debug = yield* LegacyDebugFlag; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const rawArgs = yield* stdio.args; const platformApi = yield* LegacyPlatformApiFactory; @@ -426,7 +424,7 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le } const useTls = yield* sslProbe.requireSslForHost(target.probeHost, target.probePort); - if (useTls && !debug) { + if (useTls) { env.push(`PG_META_DB_SSL_ROOT_CERT=${legacyRootCaBundle()}`); } diff --git a/apps/cli/src/legacy/commands/gen/types/types.integration.test.ts b/apps/cli/src/legacy/commands/gen/types/types.integration.test.ts index 5d75f2caa9..4488a603c6 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.integration.test.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.integration.test.ts @@ -2883,7 +2883,10 @@ describe("legacy gen types", () => { }), ); - it.live("omits the CA bundle env var in --debug mode even when TLS is supported", () => + // Go's `GetRootCA` no longer special-cases `--debug`: `isRequireSSL` returns true + // on any successful probe (`types.go:194-195`), so the bundle is passed to pgmeta + // regardless of the flag. + it.live("passes the CA bundle env var in --debug mode when TLS is supported", () => Effect.tryPromise({ try: () => withSslProbeServer(async (port) => { @@ -2903,7 +2906,7 @@ describe("legacy gen types", () => { ).pipe(Effect.provide(layer)), ); - expect(docker.env.startsWith("PG_META_DB_SSL_ROOT_CERT=")).toBe(false); + expect(docker.env.startsWith("PG_META_DB_SSL_ROOT_CERT=")).toBe(true); }, "S"), catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), }), diff --git a/apps/cli/src/legacy/commands/init/init.command.ts b/apps/cli/src/legacy/commands/init/init.command.ts index 32e8661d4c..d66bb38a1d 100644 --- a/apps/cli/src/legacy/commands/init/init.command.ts +++ b/apps/cli/src/legacy/commands/init/init.command.ts @@ -1,7 +1,9 @@ +import { Layer } from "effect"; import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; +import { stdinLayer } from "../../../shared/runtime/stdin.layer.ts"; import { withLegacyCommandInstrumentation } from "../../telemetry/legacy-command-instrumentation.ts"; import { legacyInit } from "./init.handler.ts"; @@ -38,5 +40,9 @@ export const legacyInitCommand = Command.make("init", config).pipe( Command.withHandler((flags) => legacyInit(flags).pipe(withLegacyCommandInstrumentation({ flags }), withJsonErrorHandling), ), - Command.provide(commandRuntimeLayer(["init"])), + // `stdinLayer` satisfies `legacyPromptYesNo`'s `Stdin` requirement (via the + // shared `initProject` IDE prompts). The prompts are gated on a TTY stdin, so + // init never actually reads a piped line at runtime — the layer is here for + // the effect's type requirements only. + Command.provide(Layer.mergeAll(commandRuntimeLayer(["init"]), stdinLayer)), ); diff --git a/apps/cli/src/legacy/commands/init/init.handler.ts b/apps/cli/src/legacy/commands/init/init.handler.ts index aaa984d397..e3b76db46a 100644 --- a/apps/cli/src/legacy/commands/init/init.handler.ts +++ b/apps/cli/src/legacy/commands/init/init.handler.ts @@ -7,7 +7,11 @@ import { 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 { + LegacyExperimentalFlag, + LegacyWorkdirFlag, + legacyResolveYes, +} from "../../../shared/legacy/global-flags.ts"; import type { LegacyInitFlags } from "./init.command.ts"; export const legacyInit = Effect.fn("legacy.init")(function* (flags: LegacyInitFlags) { @@ -30,6 +34,10 @@ export const legacyInit = Effect.fn("legacy.init")(function* (flags: LegacyInitF force: flags.force, useOrioledb: flags.useOrioledb, interactive: flags.interactive, + // `--yes` OR `SUPABASE_YES` (Go's `viper.GetBool("YES")`, root.go:318-320): + // auto-accepts the `-i` IDE prompts with Go's stderr echo instead of + // prompting anyway (CLI-1974). + yes: yield* legacyResolveYes, withVscodeSettings: flags.withVscodeWorkspace || flags.withVscodeSettings, withIntellijSettings: flags.withIntellijSettings, }); diff --git a/apps/cli/src/legacy/commands/init/init.integration.test.ts b/apps/cli/src/legacy/commands/init/init.integration.test.ts index 45b6911baf..f9703f76c4 100644 --- a/apps/cli/src/legacy/commands/init/init.integration.test.ts +++ b/apps/cli/src/legacy/commands/init/init.integration.test.ts @@ -5,8 +5,18 @@ 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 { CliArgs } from "../../../shared/cli/cli-args.service.ts"; +import { + LegacyExperimentalFlag, + LegacyWorkdirFlag, + LegacyYesFlag, +} from "../../../shared/legacy/global-flags.ts"; +import { + mockOutput, + mockRuntimeInfo, + mockStdin, + mockTty, +} from "../../../../tests/helpers/mocks.ts"; import { legacyInit } from "./init.handler.ts"; function makeTempDir(): string { @@ -20,6 +30,9 @@ function setup( workdir?: Option.Option<string>; interactive?: boolean; stdinIsTty?: boolean; + yes?: boolean; + /** Piped stdin lines consumed by the non-TTY IDE-settings confirm reads. */ + stdinInput?: string; } = {}, ) { const out = mockOutput({ format: "text", interactive: opts.interactive ?? false }); @@ -33,8 +46,11 @@ function setup( stdinIsTty: opts.stdinIsTty ?? false, stdoutIsTty: opts.interactive ?? false, }), + mockStdin(opts.stdinIsTty ?? false, opts.stdinInput), Layer.succeed(LegacyExperimentalFlag, opts.experimental ?? false), Layer.succeed(LegacyWorkdirFlag, opts.workdir ?? Option.none()), + Layer.succeed(LegacyYesFlag, opts.yes ?? false), + Layer.succeed(CliArgs, { args: [] }), ), }; } @@ -156,4 +172,84 @@ describe("legacy init", () => { Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), ); }); + + // --------------------------------------------------------------------------- + // `-i` + `--yes`/`SUPABASE_YES` — Go's `PromptForIDESettings` goes through + // `PromptYesNo`, so the global YES auto-accepts the VS Code question with the + // `[Y/n] y` stderr echo instead of prompting anyway (CLI-1974). + // --------------------------------------------------------------------------- + + const BASE_INIT_FLAGS = { + useOrioledb: false, + force: false, + withVscodeWorkspace: false, + withVscodeSettings: false, + withIntellijSettings: false, + } as const; + + it.live("init -i --yes writes VS Code settings with the Go echo instead of prompting", () => { + const tempDir = makeTempDir(); + + return Effect.gen(function* () { + const { layer, out } = setup(tempDir, { interactive: true, stdinIsTty: true, yes: true }); + + yield* legacyInit({ ...BASE_INIT_FLAGS, interactive: true }).pipe(Effect.provide(layer)); + + // No clack prompt fired; the auto-accepted question was echoed to stderr. + expect(out.promptConfirmCalls).toHaveLength(0); + expect(out.stderrText).toContain("Generate VS Code settings for Deno? [Y/n] y\n"); + // Go returns after writing VS Code settings — IntelliJ is never asked. + expect(out.stderrText).not.toContain("IntelliJ"); + expect( + yield* Effect.tryPromise(() => readFile(join(tempDir, ".vscode", "settings.json"), "utf8")), + ).toContain('"deno.enablePaths"'); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }); + + it.live("init -i with SUPABASE_YES=1 auto-accepts the VS Code prompt like --yes", () => { + const tempDir = makeTempDir(); + const prev = process.env["SUPABASE_YES"]; + process.env["SUPABASE_YES"] = "1"; + + return Effect.gen(function* () { + const { layer, out } = setup(tempDir, { interactive: true, stdinIsTty: true }); + + yield* legacyInit({ ...BASE_INIT_FLAGS, interactive: true }).pipe(Effect.provide(layer)); + + expect(out.promptConfirmCalls).toHaveLength(0); + expect(out.stderrText).toContain("Generate VS Code settings for Deno? [Y/n] y\n"); + expect( + yield* Effect.tryPromise(() => readFile(join(tempDir, ".vscode", "settings.json"), "utf8")), + ).toContain('"deno.enablePaths"'); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (prev === undefined) delete process.env["SUPABASE_YES"]; + else process.env["SUPABASE_YES"] = prev; + }), + ), + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }); + + it.live("init -i --yes writes VS Code settings even when stdout is piped (Go parity)", () => { + // Go gates the IDE prompts on `-i` + a TTY stdin only (`cmd/init.go:40`); with + // YES set no clack UI is rendered, so a piped stdout must not skip the write. + const tempDir = makeTempDir(); + + return Effect.gen(function* () { + const { layer, out } = setup(tempDir, { interactive: false, stdinIsTty: true, yes: true }); + + yield* legacyInit({ ...BASE_INIT_FLAGS, interactive: true }).pipe(Effect.provide(layer)); + + expect(out.stderrText).toContain("Generate VS Code settings for Deno? [Y/n] y\n"); + expect( + yield* Effect.tryPromise(() => readFile(join(tempDir, ".vscode", "settings.json"), "utf8")), + ).toContain('"deno.enablePaths"'); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }); }); diff --git a/apps/cli/src/legacy/commands/logout/logout.e2e.test.ts b/apps/cli/src/legacy/commands/logout/logout.e2e.test.ts index 5fb3d31ab2..d039a3d25b 100644 --- a/apps/cli/src/legacy/commands/logout/logout.e2e.test.ts +++ b/apps/cli/src/legacy/commands/logout/logout.e2e.test.ts @@ -3,7 +3,7 @@ import { join } from "node:path"; import { describe, expect, test } from "vitest"; -import { makeTempHome, runSupabase } from "../../../../tests/helpers/cli.ts"; +import { makeTempHome, runSupabase, stripAnsi } from "../../../../tests/helpers/cli.ts"; const E2E_TIMEOUT_MS = 30_000; const VALID_TOKEN = "sbp_" + "a".repeat(40); @@ -37,6 +37,29 @@ describe("supabase logout (legacy)", () => { }, ); + // Declining the confirmation must byte-match Go: a single `context canceled` + // line on stderr and exit 1, with NO `--debug` troubleshooting hint — + // `recoverAndExit` skips `SuggestDebugFlag` for `context.Canceled` + // (apps/cli-go/cmd/root.go:287-303). CLI-1973. + test( + "declining the logout prompt prints only context canceled, no --debug hint", + { timeout: E2E_TIMEOUT_MS }, + async () => { + using home = makeTempHome(); + seedTokenFile(home.dir); + const { exitCode, stderr } = await runSupabase(["logout"], { + entrypoint: "legacy", + home: home.dir, + env: { HOME: home.dir }, + stdin: "n\n", + }); + expect(exitCode).toBe(1); + const lines = stripAnsi(stderr).trimEnd().split("\n"); + expect(lines.at(-1)).toBe("context canceled"); + expect(stderr).not.toContain("Try rerunning the command with --debug"); + }, + ); + // No token at all: same not-logged-in message, exit 0. test( "logout --yes with no token reports not-logged-in and exits 0", diff --git a/apps/cli/src/legacy/commands/logout/logout.errors.ts b/apps/cli/src/legacy/commands/logout/logout.errors.ts index 1f3dd768f6..a7ced0612c 100644 --- a/apps/cli/src/legacy/commands/logout/logout.errors.ts +++ b/apps/cli/src/legacy/commands/logout/logout.errors.ts @@ -2,13 +2,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`), + * `errors.New(context.Canceled)` (`apps/cli-go/internal/logout/logout.go:19`), * 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`). + * exit code 1 and no `--debug` suggestion (`cmd/root.go:287-303` skips + * `SuggestDebugFlag` for `context.Canceled`). The TS renderer mirrors that: + * constructing this error with `CONTEXT_CANCELED_MESSAGE` + * (`shared/output/errors.ts`) is what makes the text `Output.fail` withhold + * the debug hint (CLI-1973). */ 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 8b4a606249..9889d4f076 100644 --- a/apps/cli/src/legacy/commands/logout/logout.handler.ts +++ b/apps/cli/src/legacy/commands/logout/logout.handler.ts @@ -3,9 +3,10 @@ import { Effect } from "effect"; import { LegacyCredentials } from "../../auth/legacy-credentials.service.ts"; import { LegacyTelemetryState } from "../../telemetry/legacy-telemetry-state.service.ts"; import { legacyResolveYes } from "../../../shared/legacy/global-flags.ts"; +import { CONTEXT_CANCELED_MESSAGE } from "../../../shared/output/errors.ts"; import { Output } from "../../../shared/output/output.service.ts"; -import { legacyPromptYesNo } from "../../shared/legacy-prompt-yes-no.ts"; -import { LegacyLogoutCancelledError, LEGACY_LOGOUT_CANCELLED_MESSAGE } from "./logout.errors.ts"; +import { legacyPromptYesNo } from "../../../shared/legacy/legacy-prompt-yes-no.ts"; +import { LegacyLogoutCancelledError } from "./logout.errors.ts"; const LOGGED_OUT_MSG = "Access token deleted successfully. You are now logged out."; @@ -23,21 +24,23 @@ export const legacyLogout = Effect.fn("legacy.logout")(function* () { const body = Effect.gen(function* () { // Confirm prompt, honoring the global `--yes`/`SUPABASE_YES` (`logout.go:15-16`). const confirmed = yield* Effect.gen(function* () { - if (yes) return true; - // Machine (json/stream-json) mode has no Go equivalent — fail loudly on a - // non-interactive prompt rather than silently defaulting, preserving the - // existing contract. - if (output.format !== "text") { + // Machine (json/stream-json) mode without `--yes` has no Go equivalent — + // fail loudly on a non-interactive prompt rather than silently defaulting, + // preserving the existing contract. + if (!yes && output.format !== "text") { return yield* output.promptConfirm(confirmLabel, { defaultValue: false }); } - // Text mode mirrors Go's `PromptYesNo(..., false)` (`logout.go:16`): it scans - // piped stdin before falling back to the default (`console.go:64-82`), so - // `printf 'y\n' | supabase logout` deletes the token. + // Mirrors Go's `PromptYesNo(..., false)` (`logout.go:16`): `--yes`/ + // `SUPABASE_YES` auto-confirms WITH the `<label> [y/N] y` stderr echo + // (`console.go:70-72`) — do not short-circuit before the helper, or the + // echo line Go always prints goes missing (CLI-1974). Without `--yes` it + // scans piped stdin before falling back to the default (`console.go:64-82`), + // so `printf 'y\n' | supabase logout` deletes the token. return yield* legacyPromptYesNo(output, yes, confirmLabel, false); }); if (!confirmed) { return yield* Effect.fail( - new LegacyLogoutCancelledError({ message: LEGACY_LOGOUT_CANCELLED_MESSAGE }), + new LegacyLogoutCancelledError({ message: CONTEXT_CANCELED_MESSAGE }), ); } diff --git a/apps/cli/src/legacy/commands/logout/logout.integration.test.ts b/apps/cli/src/legacy/commands/logout/logout.integration.test.ts index a1dbbb0b84..502075500d 100644 --- a/apps/cli/src/legacy/commands/logout/logout.integration.test.ts +++ b/apps/cli/src/legacy/commands/logout/logout.integration.test.ts @@ -62,6 +62,11 @@ describe("legacy logout integration", () => { return Effect.gen(function* () { yield* legacyLogout(); expect(credentials.deletedAll).toBe(true); + // Go's `viper.GetBool("YES")` branch still echoes the accepted prompt to + // stderr (`console.go:70-72`) — `--yes` runs must not be silent (CLI-1974). + expect(out.stderrText).toContain( + "Do you want to log out? This will remove the access token from your system. [y/N] y\n", + ); expect(out.stdoutText).toContain( "Access token deleted successfully. You are now logged out.", ); diff --git a/apps/cli/src/legacy/commands/migration/down/down.handler.ts b/apps/cli/src/legacy/commands/migration/down/down.handler.ts index d646db1782..69c7fc46a0 100644 --- a/apps/cli/src/legacy/commands/migration/down/down.handler.ts +++ b/apps/cli/src/legacy/commands/migration/down/down.handler.ts @@ -5,6 +5,7 @@ import { legacyResolveYesWithProjectEnv, } from "../../../../shared/legacy/global-flags.ts"; import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; +import { CONTEXT_CANCELED_MESSAGE } from "../../../../shared/output/errors.ts"; import { Output } from "../../../../shared/output/output.service.ts"; import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; @@ -138,7 +139,7 @@ const runDown = Effect.fnUntraced(function* ( ); if (!confirmed) { return yield* Effect.fail( - new LegacyOperationCanceledError({ message: "context canceled" }), + new LegacyOperationCanceledError({ message: CONTEXT_CANCELED_MESSAGE }), ); } diff --git a/apps/cli/src/legacy/commands/migration/down/down.integration.test.ts b/apps/cli/src/legacy/commands/migration/down/down.integration.test.ts index 57c15c999e..b39174673c 100644 --- a/apps/cli/src/legacy/commands/migration/down/down.integration.test.ts +++ b/apps/cli/src/legacy/commands/migration/down/down.integration.test.ts @@ -5,6 +5,7 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, Layer, Option } from "effect"; +import { stripAnsi } from "../../../../../tests/helpers/ansi.ts"; import { LEGACY_VALID_REF, mockLegacyCliConfig, @@ -151,8 +152,6 @@ const flags = (over: Partial<LegacyMigrationDownFlags> = {}): LegacyMigrationDow local: over.local ?? true, }); -// eslint-disable-next-line no-control-regex -const stripAnsi = (text: string) => text.replace(/\x1b\[[0-9;]*m/gu, ""); const seed = (workdir: string, name: string, body = "create table a;\n") => { const dir = join(workdir, "supabase", "migrations"); mkdirSync(dir, { recursive: true }); diff --git a/apps/cli/src/legacy/commands/migration/fetch/fetch.e2e.test.ts b/apps/cli/src/legacy/commands/migration/fetch/fetch.e2e.test.ts index ec8c28b274..48ac38e76b 100644 --- a/apps/cli/src/legacy/commands/migration/fetch/fetch.e2e.test.ts +++ b/apps/cli/src/legacy/commands/migration/fetch/fetch.e2e.test.ts @@ -3,13 +3,10 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; -import { runSupabase } from "../../../../../tests/helpers/cli.ts"; +import { runSupabase, stripAnsi } from "../../../../../tests/helpers/cli.ts"; const E2E_TIMEOUT_MS = 30_000; -// eslint-disable-next-line no-control-regex -const stripAnsi = (text: string) => text.replace(/\x1b\[[0-9;]*m/gu, ""); - describe("supabase migration fetch (legacy)", () => { let workdir: string; beforeEach(() => { @@ -40,9 +37,15 @@ describe("supabase migration fetch (legacy)", () => { stdin: "n\n", }); - // Declined → cancelled (non-zero), and the Go-style prompt label reached stderr. - expect(exitCode).not.toBe(0); + // Declined → cancelled (exit 1), and the Go-style prompt label reached stderr. + expect(exitCode).toBe(1); expect(stripAnsi(stderr)).toContain("[Y/n]"); + // Byte-parity with Go's `recoverAndExit` (apps/cli-go/cmd/root.go:287-303): + // a declined prompt renders a lone `context canceled` line, with NO + // `SuggestDebugFlag` troubleshooting hint appended. CLI-1973. + const lines = stripAnsi(stderr).trimEnd().split("\n"); + expect(lines.at(-1)).toBe("context canceled"); + expect(stderr).not.toContain("Try rerunning the command with --debug"); // The existing file was NOT overwritten — the piped answer was honored. expect(readdirSync(join(workdir, "supabase", "migrations"))).toEqual([ "20240101000000_existing.sql", diff --git a/apps/cli/src/legacy/commands/migration/fetch/fetch.handler.ts b/apps/cli/src/legacy/commands/migration/fetch/fetch.handler.ts index 6fadfea2b4..e5bc6f29c2 100644 --- a/apps/cli/src/legacy/commands/migration/fetch/fetch.handler.ts +++ b/apps/cli/src/legacy/commands/migration/fetch/fetch.handler.ts @@ -5,6 +5,7 @@ import { legacyResolveYesWithProjectEnv, } from "../../../../shared/legacy/global-flags.ts"; import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; +import { CONTEXT_CANCELED_MESSAGE } from "../../../../shared/output/errors.ts"; import { Output } from "../../../../shared/output/output.service.ts"; import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; @@ -114,7 +115,7 @@ const runFetch = Effect.fnUntraced(function* ( const overwrite = yield* legacyMigrationConfirm(title, { defaultValue: true, yes }); if (!overwrite) { return yield* Effect.fail( - new LegacyOperationCanceledError({ message: "context canceled" }), + new LegacyOperationCanceledError({ message: CONTEXT_CANCELED_MESSAGE }), ); } } diff --git a/apps/cli/src/legacy/commands/migration/list/list.integration.test.ts b/apps/cli/src/legacy/commands/migration/list/list.integration.test.ts index 549dfad5e4..fb9a9c3907 100644 --- a/apps/cli/src/legacy/commands/migration/list/list.integration.test.ts +++ b/apps/cli/src/legacy/commands/migration/list/list.integration.test.ts @@ -4,6 +4,7 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, Layer, Option } from "effect"; +import { stripAnsi } from "../../../../../tests/helpers/ansi.ts"; import { LEGACY_VALID_REF, mockLegacyCliConfig, @@ -108,9 +109,6 @@ function setup(workdir: string, opts: SetupOpts = {}) { }; } -// eslint-disable-next-line no-control-regex -const stripAnsi = (text: string) => text.replace(/\x1b\[[0-9;]*m/gu, ""); - const flags = (over: Partial<LegacyMigrationListFlags> = {}): LegacyMigrationListFlags => ({ dbUrl: over.dbUrl ?? Option.none(), linked: over.linked ?? true, diff --git a/apps/cli/src/legacy/commands/migration/migration.prompt.ts b/apps/cli/src/legacy/commands/migration/migration.prompt.ts index 3bef8af5fc..3b8d7ff87e 100644 --- a/apps/cli/src/legacy/commands/migration/migration.prompt.ts +++ b/apps/cli/src/legacy/commands/migration/migration.prompt.ts @@ -1,5 +1,6 @@ import { Effect, Option } from "effect"; +import { legacyParseYesNo } from "../../../shared/legacy/legacy-prompt-yes-no.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { Stdin } from "../../../shared/runtime/stdin.service.ts"; @@ -7,25 +8,24 @@ import { Stdin } from "../../../shared/runtime/stdin.service.ts"; const TTY_TIMEOUT_MILLIS = 10 * 60 * 1000; const NON_TTY_TIMEOUT_MILLIS = 100; -/** Go's `parseYesNo` (`internal/utils/console.go:84-93`): case-insensitive y/yes/n/no. */ -const parseYesNo = (value: string): boolean | undefined => { - const lower = value.toLowerCase(); - if (lower === "y" || lower === "yes") return true; - if (lower === "n" || lower === "no") return false; - return undefined; -}; - /** * Port of Go's `utils.NewConsole().PromptYesNo(ctx, label, def)` * (`internal/utils/console.go:64-107`), shared by the prompting migration subcommands * (fetch / repair / down / squash). * - * Go writes the label to STDERR and reads one line of STDIN regardless of the `--output` - * format (which only shapes stdout); `IsTTY` changes only the read timeout (10 min vs - * 100 ms) and whether the input is echoed. So this prompts independently of - * `output.format` and does NOT route through `output.promptConfirm` — the json/stream-json - * Output layers make that non-interactive, which would silently auto-default a real TTY run - * under `--output-format json` (fetch auto-overwriting, down/repair-all auto-cancelling). + * This deliberately diverges from the general `legacyPromptYesNo` + * (`shared/legacy/legacy-prompt-yes-no.ts`) on two axes — do NOT "unify" them + * without deciding both behaviors (CLI-1974 review): + * - json/stream-json: Go writes the label to STDERR and reads one line of STDIN + * regardless of the `--output` format (which only shapes stdout), so this prompts + * independently of `output.format` and does NOT route through `output.promptConfirm` + * — the json/stream-json Output layers make that non-interactive, which would + * silently auto-default a real TTY run under `--output-format json` (fetch + * auto-overwriting, down/repair-all auto-cancelling). `legacyPromptYesNo` instead + * silently takes the default in machine modes. + * - real TTY: this reads a raw stdin line with Go's 10-minute timeout (exactly what + * Go does); `legacyPromptYesNo` renders a clack confirm UI. + * * `--yes` short-circuits to `true`, echoing `<label> y` like Go's `viper.GetBool("YES")`. */ export const legacyMigrationConfirm = ( @@ -49,5 +49,5 @@ export const legacyMigrationConfirm = ( const line = yield* stdin.readLine(stdin.isTTY ? TTY_TIMEOUT_MILLIS : NON_TTY_TIMEOUT_MILLIS); const input = Option.getOrElse(line, () => ""); if (!stdin.isTTY) yield* output.raw(`${input}\n`, "stderr"); - return parseYesNo(input) ?? options.defaultValue; + return legacyParseYesNo(input) ?? options.defaultValue; }); diff --git a/apps/cli/src/legacy/commands/migration/new/new.e2e.test.ts b/apps/cli/src/legacy/commands/migration/new/new.e2e.test.ts index 1c2f012fbd..08270e5b37 100644 --- a/apps/cli/src/legacy/commands/migration/new/new.e2e.test.ts +++ b/apps/cli/src/legacy/commands/migration/new/new.e2e.test.ts @@ -3,17 +3,10 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; -import { runSupabase } from "../../../../../tests/helpers/cli.ts"; +import { runSupabase, stripAnsi } from "../../../../../tests/helpers/cli.ts"; const E2E_TIMEOUT_MS = 30_000; -// Strip ANSI so the assertion is colour-independent: the handler prints the path -// via `legacyBold`, which emits bold escapes under CI's `FORCE_COLOR` even on a -// piped stdout. The text content is the parity contract, not the colour. Mirrors -// `new.integration.test.ts`. -// eslint-disable-next-line no-control-regex -const stripAnsi = (text: string) => text.replace(/\x1b\[[0-9;]*m/gu, ""); - describe("supabase migration new (legacy)", () => { let workdir: string; beforeEach(() => { diff --git a/apps/cli/src/legacy/commands/migration/new/new.integration.test.ts b/apps/cli/src/legacy/commands/migration/new/new.integration.test.ts index caa3c051d4..2d8c4b02cb 100644 --- a/apps/cli/src/legacy/commands/migration/new/new.integration.test.ts +++ b/apps/cli/src/legacy/commands/migration/new/new.integration.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, Layer, Option, Stream } from "effect"; import { badArgument } from "effect/PlatformError"; +import { stripAnsi } from "../../../../../tests/helpers/ansi.ts"; import { mockLegacyCliConfig, mockLegacyTelemetryStateTracked, @@ -35,10 +36,6 @@ function setup(workdir: string, opts: SetupOpts = {}) { return { layer, out, telemetry }; } -// Strip ANSI so assertions are colour-independent (`legacyBold` emits colour on a TTY). -// eslint-disable-next-line no-control-regex -const stripAnsi = (text: string) => text.replace(/\x1b\[[0-9;]*m/gu, ""); - const tmp = useLegacyTempWorkdir(); const migrationsDir = (workdir: string) => join(workdir, "supabase", "migrations"); diff --git a/apps/cli/src/legacy/commands/migration/repair/repair.handler.ts b/apps/cli/src/legacy/commands/migration/repair/repair.handler.ts index ea6727d68d..3f6d5578a5 100644 --- a/apps/cli/src/legacy/commands/migration/repair/repair.handler.ts +++ b/apps/cli/src/legacy/commands/migration/repair/repair.handler.ts @@ -5,6 +5,7 @@ import { legacyResolveYesWithProjectEnv, } from "../../../../shared/legacy/global-flags.ts"; import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; +import { CONTEXT_CANCELED_MESSAGE } from "../../../../shared/output/errors.ts"; import { Output } from "../../../../shared/output/output.service.ts"; import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; @@ -200,7 +201,7 @@ const runRepair = Effect.fnUntraced(function* ( ); if (!confirmed) { return yield* Effect.fail( - new LegacyOperationCanceledError({ message: "context canceled" }), + new LegacyOperationCanceledError({ message: CONTEXT_CANCELED_MESSAGE }), ); } versions = yield* legacyLoadLocalVersions(fs, path, migrationsDir); diff --git a/apps/cli/src/legacy/commands/migration/repair/repair.integration.test.ts b/apps/cli/src/legacy/commands/migration/repair/repair.integration.test.ts index b6737c053b..d00cb96677 100644 --- a/apps/cli/src/legacy/commands/migration/repair/repair.integration.test.ts +++ b/apps/cli/src/legacy/commands/migration/repair/repair.integration.test.ts @@ -4,6 +4,7 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, Layer, Option } from "effect"; +import { stripAnsi } from "../../../../../tests/helpers/ansi.ts"; import { LEGACY_VALID_REF, mockLegacyCliConfig, @@ -133,8 +134,6 @@ const input = (over: Partial<LegacyMigrationRepairInput> = {}): LegacyMigrationR password: over.password ?? Option.none(), }); -// eslint-disable-next-line no-control-regex -const stripAnsi = (text: string) => text.replace(/\x1b\[[0-9;]*m/gu, ""); const seedMigration = (workdir: string, name: string, body: string) => { const dir = join(workdir, "supabase", "migrations"); mkdirSync(dir, { recursive: true }); @@ -218,6 +217,11 @@ describe("legacy migration repair", () => { expect(Option.isSome(failure) && failure.value._tag).toBe( "LegacyMigrationInvalidVersionError", ); + // Guard: unlike `db reset` (bare `invalid version number`, reset.go:35-36), + // `migration repair` keeps Go's `failed to parse <v>:` wrapper (repair.go:29). + expect(Option.isSome(failure) && failure.value.message).toBe( + "failed to parse not-a-number: invalid version number", + ); } }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/legacy/commands/migration/up/up.integration.test.ts b/apps/cli/src/legacy/commands/migration/up/up.integration.test.ts index 8d7be64dcc..abab7670fd 100644 --- a/apps/cli/src/legacy/commands/migration/up/up.integration.test.ts +++ b/apps/cli/src/legacy/commands/migration/up/up.integration.test.ts @@ -4,6 +4,7 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, Layer, Option } from "effect"; +import { stripAnsi } from "../../../../../tests/helpers/ansi.ts"; import { LEGACY_VALID_REF, mockLegacyCliConfig, @@ -126,8 +127,6 @@ const flags = (over: Partial<LegacyMigrationUpFlags> = {}): LegacyMigrationUpFla local: over.local ?? true, }); -// eslint-disable-next-line no-control-regex -const stripAnsi = (text: string) => text.replace(/\x1b\[[0-9;]*m/gu, ""); const seed = (workdir: string, name: string, body = "create table a;\n") => { const dir = join(workdir, "supabase", "migrations"); mkdirSync(dir, { recursive: true }); diff --git a/apps/cli/src/legacy/commands/network-restrictions/get/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/network-restrictions/get/SIDE_EFFECTS.md index f39c04d7c9..0298b977ae 100644 --- a/apps/cli/src/legacy/commands/network-restrictions/get/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/network-restrictions/get/SIDE_EFFECTS.md @@ -9,10 +9,10 @@ ## Files Written -| Path | Format | When | -| ------------------------------------------------ | ------ | ----------------------------------------------------------------------------- | -| `~/.supabase/<workdir-hash>/linked-project.json` | JSON | always (after ref resolution), via `Effect.ensuring` — on success and failure | -| `~/.supabase/telemetry.json` | JSON | always, via `Effect.ensuring` — on success and failure | +| Path | Format | When | +| ------------------------------------------------ | ------ | -------------------------------------------------------------------------------------------------------------------------- | +| `~/.supabase/<workdir-hash>/linked-project.json` | JSON | once the `--experimental` gate is open, after ref resolution, via `Effect.ensuring` — on success and failure | +| `~/.supabase/telemetry.json` | JSON | once the `--experimental` gate is open, via `Effect.ensuring` — on success and failure. Not written if the gate is closed. | ## API Routes @@ -22,26 +22,28 @@ ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | -------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` → prompt) | +| Variable | Purpose | Required? | +| ----------------------- | -------------------------------------------------------- | -------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` → prompt) | +| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | ## Exit Codes -| Code | Condition | -| ---- | --------------------------------------------------------------------------------------- | -| `0` | success — network-restrictions status printed to stdout | -| `1` | project ref unresolved (`LegacyProjectNotLinkedError` / `LegacyInvalidProjectRefError`) | -| `1` | API non-200 (`LegacyNetworkRestrictionsGetUnexpectedStatusError`) | -| `1` | transport failure (`LegacyNetworkRestrictionsGetNetworkError`) | +| Code | Condition | +| ---- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success — network-restrictions status printed to stdout | +| `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`LegacyExperimentalRequiredError`) — checked before ref resolution/API/telemetry | +| `1` | project ref unresolved (`LegacyProjectNotLinkedError` / `LegacyInvalidProjectRefError`) | +| `1` | API non-200 (`LegacyNetworkRestrictionsGetUnexpectedStatusError`) | +| `1` | transport failure (`LegacyNetworkRestrictionsGetNetworkError`) | ## Telemetry Events Fired -| Event | When | Notable properties / groups | -| ---------------------- | ------------------------------------------ | -------------------------------------------------------------------- | -| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--project-ref` → `<redacted>`) | +| Event | When | Notable properties / groups | +| ---------------------- | ---------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper); not fired when the `--experimental` gate is closed | `exit_code`, `duration_ms`, `flags` (`--project-ref` → `<redacted>`) | Matches `apps/cli-go/internal/restrictions/get/`. Go does not fire any custom telemetry event for this command. @@ -90,6 +92,8 @@ One `result` event whose `data` is the full response object. - The Go `--output` flag wins over the TS `--output-format` flag when both are provided. - `linked-project.json` is written **after** the project ref is resolved, regardless of whether the subsequent API call succeeds (mirrors Go's `PersistentPostRun`). -- `telemetry.json` is written on every invocation, including failures. +- `telemetry.json` is written on every invocation past the `--experimental` gate, including + failures. A closed gate writes nothing (Go's `PersistentPreRunE` fails before + `PersistentPostRun` runs). - Go's `restrictions/get` itself does not honor `--output`. The legacy TS port honors both `--output` and `--output-format` per the legacy CLAUDE.md output-parity rules. diff --git a/apps/cli/src/legacy/commands/network-restrictions/get/get.command.ts b/apps/cli/src/legacy/commands/network-restrictions/get/get.command.ts index b9105e5d74..3ec90f170c 100644 --- a/apps/cli/src/legacy/commands/network-restrictions/get/get.command.ts +++ b/apps/cli/src/legacy/commands/network-restrictions/get/get.command.ts @@ -1,9 +1,15 @@ +import { Effect } from "effect"; import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { legacyRequireExperimental } from "../../../shared/legacy-experimental-gate.ts"; +import { LEGACY_RESOURCE_OUTPUT_FORMATS } from "../../../shared/legacy-go-output-flag.ts"; import { legacyManagementApiRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts"; -import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; +import { + legacyValidateOutputFormat, + withLegacyCommandInstrumentation, +} from "../../../telemetry/legacy-command-instrumentation.ts"; import { legacyNetworkRestrictionsGet } from "./get.handler.ts"; const config = { @@ -19,10 +25,23 @@ export const legacyNetworkRestrictionsGetCommand = Command.make("get", config).p Command.withDescription("Get the current network restrictions."), Command.withShortDescription("Get the current network restrictions"), Command.withHandler((flags) => - legacyNetworkRestrictionsGet(flags).pipe( - withLegacyCommandInstrumentation({ flags }), - withJsonErrorHandling, - ), + Effect.gen(function* () { + // Cobra parses flags — rejecting an out-of-enum `-o` (`internal/utils/enum.go:21-27`) + // — before `PersistentPreRunE` ever runs (`cobra@v1.10.2/command.go:919,985`), so an + // invalid `-o` value must win over a missing `--experimental` flag. + yield* legacyValidateOutputFormat(LEGACY_RESOURCE_OUTPUT_FORMATS); + // Go gates `restrictionsCmd` (network-restrictions) behind `--experimental` in + // PersistentPreRunE (root.go:91-96) BEFORE the `IsManagementAPI` login check + // (root.go:105-109). `legacyManagementApiRuntimeLayer` eagerly resolves an + // access token as part of building its `LegacyPlatformApi` layer, so it must + // be provided AFTER the gate (inline here) rather than via `Command.provide` + // on the whole command — `Command.provide` would build the layer, and fail on + // a missing token, before this generator's first `yield*` ever runs. + yield* legacyRequireExperimental; + return yield* legacyNetworkRestrictionsGet(flags).pipe( + withLegacyCommandInstrumentation({ flags }), + Effect.provide(legacyManagementApiRuntimeLayer(["network-restrictions", "get"])), + ); + }).pipe(withJsonErrorHandling), ), - Command.provide(legacyManagementApiRuntimeLayer(["network-restrictions", "get"])), ); diff --git a/apps/cli/src/legacy/commands/network-restrictions/network-restrictions.experimental-gate.integration.test.ts b/apps/cli/src/legacy/commands/network-restrictions/network-restrictions.experimental-gate.integration.test.ts new file mode 100644 index 0000000000..57acaad0b9 --- /dev/null +++ b/apps/cli/src/legacy/commands/network-restrictions/network-restrictions.experimental-gate.integration.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, Layer } from "effect"; +import { CliOutput, Command } from "effect/unstable/cli"; + +import { textCliOutputFormatter } from "../../../shared/output/text-formatter.ts"; +import { LEGACY_GLOBAL_FLAGS } from "../../../shared/legacy/global-flags.ts"; +import { TelemetryRuntime } from "../../../shared/telemetry/runtime.service.ts"; +import { makeTelemetryIdentity } from "../../../shared/telemetry/identity.ts"; +import { mockOutput, mockRuntimeInfo, processEnvLayer } from "../../../../tests/helpers/mocks.ts"; +import { + buildLegacyTestRuntime, + mockLegacyCliConfig, + mockLegacyPlatformApi, + useLegacyTempWorkdir, +} from "../../../../tests/helpers/legacy-mocks.ts"; +import { legacyNetworkRestrictionsCommand } from "./network-restrictions.command.ts"; + +// See postgres-config.experimental-gate.integration.test.ts for the full +// rationale: this proves `--experimental` is wired into the actual +// `.command.ts` handler pipeline AND runs before +// `legacyManagementApiRuntimeLayer`'s eager access-token resolution +// (Go's `IsExperimental` check precedes `IsManagementAPI` in +// `apps/cli-go/cmd/root.go:91-109`). + +const tempRoot = useLegacyTempWorkdir("supabase-network-restrictions-experimental-int-"); + +const testRoot = Command.make("supabase").pipe( + Command.withGlobalFlags(LEGACY_GLOBAL_FLAGS), + Command.withSubcommands([legacyNetworkRestrictionsCommand]), +); + +function setup() { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ + response: { status: 200, body: { config: { dbAllowedCidrs: [] }, status: "applied" } }, + }); + const runtime = buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + // `RuntimeInfo` is ambient (not provided by `legacyManagementApiRuntimeLayer` + // itself), so the real `legacyCredentialsLayer` built inline inside the + // command for the "gate open" case resolves ITS `RuntimeInfo` from this + // layer. Point homeDir at this test's isolated tempRoot so the layer's + // file-based token fallback (`<homeDir>/.supabase/access-token`) can't pick + // up a stray token left at the shared default `/tmp/supabase-cli-test-home`. + runtimeInfo: mockRuntimeInfo({ homeDir: tempRoot.current }), + }); + const layer = Layer.mergeAll( + runtime, + CliOutput.layer(textCliOutputFormatter()), + // The "gate open" case reaches the real `legacyManagementApiRuntimeLayer` + // (provided inline inside the command, not by this test's mocked runtime), + // which reads credentials/env directly — an ambient SUPABASE_ACCESS_TOKEN, + // SUPABASE_EXPERIMENTAL, or OS keyring entry on the machine running the + // test would make these assertions non-deterministic. Wipe process.env + // down to just this and disable the keyring fallback. + processEnvLayer({ SUPABASE_NO_KEYRING: "1" }), + Layer.succeed( + TelemetryRuntime, + TelemetryRuntime.of({ + configDir: `${tempRoot.current}/.supabase`, + tracesDir: `${tempRoot.current}/.supabase/traces`, + consent: "granted", + showDebug: false, + deviceId: "test-device-id", + sessionId: "test-session-id", + identity: makeTelemetryIdentity(undefined), + isFirstRun: false, + isTty: false, + isCi: false, + os: "linux", + arch: "x64", + cliVersion: "0.1.0", + }), + ), + ); + return { layer, api }; +} + +describe("legacy network-restrictions experimental gate (Go PersistentPreRunE parity)", () => { + const leaves: ReadonlyArray<{ readonly name: string; readonly args: ReadonlyArray<string> }> = [ + { name: "get", args: ["network-restrictions", "get"] }, + { name: "update", args: ["network-restrictions", "update"] }, + ]; + + for (const { name, args } of leaves) { + it.live( + `${name} fails with LegacyExperimentalRequiredError when --experimental is unset`, + () => { + const { layer, api } = setup(); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + Command.runWith(testRoot, { version: "0.0.0-test" })(args), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyExperimentalRequiredError"); + } + expect(api.requests).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live(`${name} does not fail with the gate error once --experimental is set`, () => { + const { layer, api } = setup(); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + Command.runWith(testRoot, { version: "0.0.0-test" })([...args, "--experimental"]), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const causeText = JSON.stringify(exit.cause); + expect(causeText).not.toContain("LegacyExperimentalRequiredError"); + expect(causeText).toContain("LegacyPlatformAuthRequiredError"); + } + expect(api.requests).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }); + } +}); diff --git a/apps/cli/src/legacy/commands/network-restrictions/update/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/network-restrictions/update/SIDE_EFFECTS.md index f30b5ed87e..3eb0cd2630 100644 --- a/apps/cli/src/legacy/commands/network-restrictions/update/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/network-restrictions/update/SIDE_EFFECTS.md @@ -9,10 +9,10 @@ ## Files Written -| Path | Format | When | -| ------------------------------------------------ | ------ | ------------------------------------------------------------------------------- | -| `~/.supabase/<workdir-hash>/linked-project.json` | JSON | always (after ref resolution), via `Effect.ensuring` — success and HTTP failure | -| `~/.supabase/telemetry.json` | JSON | always, via outermost `Effect.ensuring` — including CIDR validation failures | +| Path | Format | When | +| ------------------------------------------------ | ------ | ------------------------------------------------------------------------------------------------------------------------------------ | +| `~/.supabase/<workdir-hash>/linked-project.json` | JSON | once the `--experimental` gate is open, after ref resolution, via `Effect.ensuring` — success and HTTP failure | +| `~/.supabase/telemetry.json` | JSON | once the `--experimental` gate is open, via outermost `Effect.ensuring` — including CIDR validation failures. Not written if closed. | ## API Routes @@ -28,28 +28,30 @@ when no `--db-allow-cidr` was supplied), matching Go's `&[]string{}` initializat ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | -------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` → prompt) | +| Variable | Purpose | Required? | +| ----------------------- | -------------------------------------------------------- | -------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` → prompt) | +| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | ## Exit Codes -| Code | Condition | -| ---- | ------------------------------------------------------------------------------------------------- | -| `0` | success — network restrictions updated and status printed to stdout | -| `1` | CIDR parse failure — `LegacyNetworkRestrictionsInvalidCidrError` (`failed to parse IP: <input>`) | -| `1` | private-IP rejection — `LegacyNetworkRestrictionsPrivateIpError` (`private IP provided: <input>`) | -| `1` | project ref unresolved (`LegacyProjectNotLinkedError` / `LegacyInvalidProjectRefError`) | -| `1` | API non-201 (POST) / non-200 (PATCH) — `LegacyNetworkRestrictionsUpdateUnexpectedStatusError` | -| `1` | transport failure — `LegacyNetworkRestrictionsUpdateNetworkError` | +| Code | Condition | +| ---- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| `0` | success — network restrictions updated and status printed to stdout | +| `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`LegacyExperimentalRequiredError`) — checked before CIDR validation/ref/API | +| `1` | CIDR parse failure — `LegacyNetworkRestrictionsInvalidCidrError` (`failed to parse IP: <input>`) | +| `1` | private-IP rejection — `LegacyNetworkRestrictionsPrivateIpError` (`private IP provided: <input>`) | +| `1` | project ref unresolved (`LegacyProjectNotLinkedError` / `LegacyInvalidProjectRefError`) | +| `1` | API non-201 (POST) / non-200 (PATCH) — `LegacyNetworkRestrictionsUpdateUnexpectedStatusError` | +| `1` | transport failure — `LegacyNetworkRestrictionsUpdateNetworkError` | ## Telemetry Events Fired -| Event | When | Notable properties / groups | -| ---------------------- | ------------------------------------------ | -------------------------------------------------------------------- | -| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--project-ref` → `<redacted>`) | +| Event | When | Notable properties / groups | +| ---------------------- | ---------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper); not fired when the `--experimental` gate is closed | `exit_code`, `duration_ms`, `flags` (`--project-ref` → `<redacted>`) | Matches `apps/cli-go/internal/restrictions/update/`. Go does not fire any custom telemetry event for this command. @@ -109,7 +111,8 @@ One `result` event whose `data` is the full response object. envelope (`{ dbAllowedCidrs, dbAllowedCidrsV6 }` → `{ add: { dbAllowedCidrs, dbAllowedCidrsV6 } }`). - `linked-project.json` writes after a successful project-ref resolution, regardless of whether the subsequent API call succeeds. -- `telemetry.json` writes on every invocation, including CIDR validation failures, ref - resolution failures, and API failures. +- `telemetry.json` writes on every invocation past the `--experimental` gate, including + CIDR validation failures, ref resolution failures, and API failures. A closed gate + writes nothing (Go's `PersistentPreRunE` fails before `PersistentPostRun` runs). - Go's `restrictions/update` itself does not honor `--output`. The legacy TS port honors both `--output` and `--output-format` per the legacy CLAUDE.md output-parity rules. diff --git a/apps/cli/src/legacy/commands/network-restrictions/update/update.command.ts b/apps/cli/src/legacy/commands/network-restrictions/update/update.command.ts index 047d17eff5..a2e3cc0667 100644 --- a/apps/cli/src/legacy/commands/network-restrictions/update/update.command.ts +++ b/apps/cli/src/legacy/commands/network-restrictions/update/update.command.ts @@ -1,9 +1,15 @@ +import { Effect } from "effect"; import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { legacyRequireExperimental } from "../../../shared/legacy-experimental-gate.ts"; +import { LEGACY_RESOURCE_OUTPUT_FORMATS } from "../../../shared/legacy-go-output-flag.ts"; import { legacyManagementApiRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts"; -import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; +import { + legacyValidateOutputFormat, + withLegacyCommandInstrumentation, +} from "../../../telemetry/legacy-command-instrumentation.ts"; import { legacyNetworkRestrictionsUpdate } from "./update.handler.ts"; const config = { @@ -29,10 +35,24 @@ export const legacyNetworkRestrictionsUpdateCommand = Command.make("update", con Command.withDescription("Update network restrictions."), Command.withShortDescription("Update network restrictions"), Command.withHandler((flags) => - legacyNetworkRestrictionsUpdate(flags).pipe( - withLegacyCommandInstrumentation({ flags }), - withJsonErrorHandling, - ), + Effect.gen(function* () { + // Cobra parses flags — rejecting an out-of-enum `-o` (`internal/utils/enum.go:21-27`) + // — before `PersistentPreRunE` ever runs (`cobra@v1.10.2/command.go:919,985`), so an + // invalid `-o` value must win over a missing `--experimental` flag. + yield* legacyValidateOutputFormat(LEGACY_RESOURCE_OUTPUT_FORMATS); + // Go gates `restrictionsCmd` (network-restrictions) behind `--experimental` in + // PersistentPreRunE (root.go:91-96) BEFORE the `IsManagementAPI` login check + // (root.go:105-109) — and before RunE, so the gate also precedes this command's + // local CIDR validation. `legacyManagementApiRuntimeLayer` eagerly resolves an + // access token as part of building its `LegacyPlatformApi` layer, so it must + // be provided AFTER the gate (inline here) rather than via `Command.provide` + // on the whole command — `Command.provide` would build the layer, and fail on + // a missing token, before this generator's first `yield*` ever runs. + yield* legacyRequireExperimental; + return yield* legacyNetworkRestrictionsUpdate(flags).pipe( + withLegacyCommandInstrumentation({ flags }), + Effect.provide(legacyManagementApiRuntimeLayer(["network-restrictions", "update"])), + ); + }).pipe(withJsonErrorHandling), ), - Command.provide(legacyManagementApiRuntimeLayer(["network-restrictions", "update"])), ); diff --git a/apps/cli/src/legacy/commands/projects/delete/delete.command.ts b/apps/cli/src/legacy/commands/projects/delete/delete.command.ts index 6c97b2d0f9..a217fafa04 100644 --- a/apps/cli/src/legacy/commands/projects/delete/delete.command.ts +++ b/apps/cli/src/legacy/commands/projects/delete/delete.command.ts @@ -1,6 +1,8 @@ +import { Layer } from "effect"; import { Argument, Command } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { stdinLayer } from "../../../../shared/runtime/stdin.layer.ts"; import { legacyManagementApiRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts"; import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; import { legacyProjectsDelete } from "./delete.handler.ts"; @@ -28,5 +30,9 @@ export const legacyProjectsDeleteCommand = Command.make("delete", config).pipe( withJsonErrorHandling, ), ), - Command.provide(legacyManagementApiRuntimeLayer(["projects", "delete"])), + // `stdinLayer`: the delete confirmation reads piped stdin via `legacyPromptYesNo` + // (Go's `Console.ReadLine`, `console.go:38-61`) on a non-TTY stdin. + Command.provide( + Layer.mergeAll(legacyManagementApiRuntimeLayer(["projects", "delete"]), stdinLayer), + ), ); diff --git a/apps/cli/src/legacy/commands/projects/delete/delete.handler.ts b/apps/cli/src/legacy/commands/projects/delete/delete.handler.ts index a3b7855b72..25b21d60f7 100644 --- a/apps/cli/src/legacy/commands/projects/delete/delete.handler.ts +++ b/apps/cli/src/legacy/commands/projects/delete/delete.handler.ts @@ -12,9 +12,12 @@ import { } 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 { LegacyYesFlag } from "../../../../shared/legacy/global-flags.ts"; +import { legacyResolveYes } from "../../../../shared/legacy/global-flags.ts"; +import { legacyPromptYesNo } from "../../../../shared/legacy/legacy-prompt-yes-no.ts"; +import { CONTEXT_CANCELED_MESSAGE } from "../../../../shared/output/errors.ts"; import { Output } from "../../../../shared/output/output.service.ts"; import { Tty } from "../../../../shared/runtime/tty.service.ts"; +import { legacyAqua } from "../../../shared/legacy-colors.ts"; import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; import { LegacyProjectsDeleteCancelledError, @@ -36,7 +39,9 @@ export const legacyProjectsDelete = Effect.fn("legacy.projects.delete")(function const cliConfig = yield* LegacyCliConfig; const linkedProjectCache = yield* LegacyLinkedProjectCache; const telemetryState = yield* LegacyTelemetryState; - const yes = yield* LegacyYesFlag; + // `--yes` OR `SUPABASE_YES` (Go's `viper.GetBool("YES")`, root.go:318-320) — + // the env var must auto-confirm too, not just the flag (CLI-1974). + const yes = yield* legacyResolveYes; const tty = yield* Tty; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -67,21 +72,17 @@ export const legacyProjectsDelete = Effect.fn("legacy.projects.delete")(function return yield* new LegacyInvalidProjectRefError({ ref, message: INVALID_PROJECT_REF_MESSAGE }); } - const title = `Do you want to delete project ${ref}? This action is irreversible.`; - let confirmed: boolean; - if (yes) { - // Mirror Go's `PromptYesNo` confirm-by-flag UX (`console.go:64-78`): the - // default is No, so the choices render `[y/N]` and the auto-answer is `y`. - yield* output.raw(`${title} [y/N] y\n`, "stderr"); - confirmed = true; - } else if (!tty.stdinIsTty) { - // Non-TTY with no `--yes`: `PromptYesNo` returns the `false` default. - confirmed = false; - } else { - confirmed = yield* output.promptConfirm(title).pipe(Effect.orElseSucceed(() => false)); - } + // Go passes `utils.Aqua(ref)` inside the title (`delete.go:21`); `legacyAqua` + // mirrors lipgloss's profile detection (plain when stderr is not a TTY). + const title = `Do you want to delete project ${legacyAqua(ref)}? This action is irreversible.`; + // Go's `PromptYesNo(title, false)` (`delete.go:22`, `console.go:64-82`): + // `--yes`/`SUPABASE_YES` auto-confirms with the `<title> [y/N] y` stderr echo; + // a non-TTY stdin still prints the label and scans one piped line (100ms), so + // `echo y | supabase projects delete <ref>` confirms; empty/unparseable input + // falls back to the No default (CLI-1974). + const confirmed = yield* legacyPromptYesNo(output, yes, title, false); if (!confirmed) { - return yield* new LegacyProjectsDeleteCancelledError({ message: "context canceled" }); + return yield* new LegacyProjectsDeleteCancelledError({ message: CONTEXT_CANCELED_MESSAGE }); } const mapDeleteError = mapLegacyHttpError({ diff --git a/apps/cli/src/legacy/commands/projects/delete/delete.integration.test.ts b/apps/cli/src/legacy/commands/projects/delete/delete.integration.test.ts index 58438255c3..109f2db053 100644 --- a/apps/cli/src/legacy/commands/projects/delete/delete.integration.test.ts +++ b/apps/cli/src/legacy/commands/projects/delete/delete.integration.test.ts @@ -5,7 +5,7 @@ import type { V1ListAllProjectsOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; import { Effect, Exit, Layer, Option } from "effect"; -import { mockOutput, mockTty } from "../../../../../tests/helpers/mocks.ts"; +import { mockOutput, mockStdin, mockTty } from "../../../../../tests/helpers/mocks.ts"; import { type LegacyApiResponse, type LegacyHttpMethod, @@ -47,6 +47,8 @@ interface SetupOpts { readonly format?: "text" | "json" | "stream-json"; readonly stdinIsTty?: boolean; readonly yes?: boolean; + /** Piped stdin lines consumed by the non-TTY confirm read. */ + readonly stdinInput?: string; readonly byMethod?: Partial<Record<LegacyHttpMethod, LegacyApiResponse>>; readonly network?: "fail"; readonly promptConfirmResponses?: ReadonlyArray<boolean>; @@ -76,6 +78,7 @@ function setup(opts: SetupOpts = {}) { api, cliConfig, tty, + stdin: mockStdin(opts.stdinIsTty ?? false, opts.stdinInput), telemetry: telemetry.layer, linkedProjectCache: cache.layer, }), @@ -159,13 +162,58 @@ describe("legacy projects delete integration", () => { }); it.live("cancels on a non-TTY when a ref is provided but --yes is unset", () => { - const { layer, api } = setup({ stdinIsTty: false, yes: false }); + const { layer, out, api } = setup({ stdinIsTty: false, yes: false }); return Effect.gen(function* () { const exit = yield* Effect.exit(legacyProjectsDelete({ ref: Option.some(LEGACY_VALID_REF) })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(JSON.stringify(exit.cause)).toContain("LegacyProjectsDeleteCancelledError"); } + // Go's non-TTY PromptText still prints the label and echoes the (empty) + // scanned line before the No default cancels (`console.go:64-102`). + expect(out.stderrText).toContain("Do you want to delete project "); + expect(out.stderrText).toContain("? This action is irreversible. [y/N] \n"); + expect(hasMethod(api, "DELETE")).toBe(false); + }).pipe(Effect.provide(layer)); + }); + + it.live("SUPABASE_YES=1 in the environment auto-confirms with the [y/N] y echo", () => { + const prev = process.env["SUPABASE_YES"]; + process.env["SUPABASE_YES"] = "1"; + const { layer, out, api } = setup({ yes: false }); + return Effect.gen(function* () { + yield* legacyProjectsDelete({ ref: Option.some(LEGACY_VALID_REF) }); + // Same bytes as Go's `viper.GetBool("YES")` branch (`console.go:70-72`). + expect(out.stderrText).toContain("Do you want to delete project "); + expect(out.stderrText).toContain("? This action is irreversible. [y/N] y\n"); + expect(hasMethod(api, "DELETE")).toBe(true); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (prev === undefined) delete process.env["SUPABASE_YES"]; + else process.env["SUPABASE_YES"] = prev; + }), + ), + Effect.provide(layer), + ); + }); + + it.live("non-TTY with piped `y` confirms like Go", () => { + const { layer, out, api } = setup({ stdinIsTty: false, stdinInput: "y\n" }); + return Effect.gen(function* () { + yield* legacyProjectsDelete({ ref: Option.some(LEGACY_VALID_REF) }); + // The piped answer is echoed to stderr like Go's non-TTY PromptText. + expect(out.stderrText).toContain("[y/N] y\n"); + expect(hasMethod(api, "DELETE")).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("non-TTY with piped `n` declines like Go", () => { + const { layer, out, api } = setup({ stdinIsTty: false, stdinInput: "n\n" }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyProjectsDelete({ ref: Option.some(LEGACY_VALID_REF) })); + expect(Exit.isFailure(exit)).toBe(true); + expect(out.stderrText).toContain("[y/N] n\n"); expect(hasMethod(api, "DELETE")).toBe(false); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/legacy/commands/secrets/unset/unset.command.ts b/apps/cli/src/legacy/commands/secrets/unset/unset.command.ts index 432e5ef8f1..a4996c79da 100644 --- a/apps/cli/src/legacy/commands/secrets/unset/unset.command.ts +++ b/apps/cli/src/legacy/commands/secrets/unset/unset.command.ts @@ -1,7 +1,9 @@ import type * as CliCommand from "effect/unstable/cli/Command"; import { Argument, Command, Flag } from "effect/unstable/cli"; +import { Layer } from "effect"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { stdinLayer } from "../../../../shared/runtime/stdin.layer.ts"; import { legacyManagementApiRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts"; import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; import { legacySecretsUnset } from "./unset.handler.ts"; @@ -38,5 +40,9 @@ export const legacySecretsUnsetCommand = Command.make("unset", config).pipe( withJsonErrorHandling, ), ), - Command.provide(legacyManagementApiRuntimeLayer(["secrets", "unset"])), + // `stdinLayer`: the confirmation prompt reads piped stdin via `legacyPromptYesNo` + // (Go's `Console.ReadLine`, `console.go:38-61`) on a non-TTY stdin. + Command.provide( + Layer.mergeAll(legacyManagementApiRuntimeLayer(["secrets", "unset"]), stdinLayer), + ), ); diff --git a/apps/cli/src/legacy/commands/secrets/unset/unset.handler.ts b/apps/cli/src/legacy/commands/secrets/unset/unset.handler.ts index 726c8e7b88..e641753511 100644 --- a/apps/cli/src/legacy/commands/secrets/unset/unset.handler.ts +++ b/apps/cli/src/legacy/commands/secrets/unset/unset.handler.ts @@ -6,8 +6,9 @@ import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.ser import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; import { legacyResolveYes } from "../../../../shared/legacy/global-flags.ts"; +import { legacyPromptYesNo } from "../../../../shared/legacy/legacy-prompt-yes-no.ts"; +import { CONTEXT_CANCELED_MESSAGE } from "../../../../shared/output/errors.ts"; import { Output } from "../../../../shared/output/output.service.ts"; -import { Tty } from "../../../../shared/runtime/tty.service.ts"; import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; import { LegacySecretsListNetworkError, @@ -46,7 +47,6 @@ export const legacySecretsUnset = Effect.fn("legacy.secrets.unset")(function* ( const telemetryState = yield* LegacyTelemetryState; // `--yes` OR `SUPABASE_YES` (Go's viper AutomaticEnv, root.go:318-320). const yes = yield* legacyResolveYes; - const tty = yield* Tty; const ref = yield* resolver.resolve(flags.projectRef); @@ -69,25 +69,16 @@ export const legacySecretsUnset = Effect.fn("legacy.secrets.unset")(function* ( const label = `Do you want to unset these function secrets?\n • ${names.join("\n • ")}\n\n`; - let confirmed: boolean; - if (yes) { - // Match Go's confirm-by-flag UX byte-for-byte: `PromptYesNo` formats the - // line as `fmt.Sprintf("%s [%s] ", label, choices)` then `Fprintln` adds - // the `y` and trailing newline. The single space between `${label}` and - // `[Y/n]` is intentional — `console.go:69`. - yield* output.raw(`${label} [Y/n] y\n`, "stderr"); - confirmed = true; - } else if (!tty.stdinIsTty) { - // Go's `PromptYesNo` defaults to true after a 100ms non-TTY read timeout - // (no stderr echo). Mirror that. - confirmed = true; - } else { - confirmed = yield* output.promptConfirm(label).pipe(Effect.orElseSucceed(() => false)); - } + // Go's `PromptYesNo(msg, true)` (`unset.go:34`, `console.go:64-82`): + // `--yes`/`SUPABASE_YES` auto-confirms with the `<label> [Y/n] y` stderr + // echo; a non-TTY stdin still prints the label and scans one piped line + // (100ms), so `echo n | supabase secrets unset` declines like Go instead of + // hardcoding the Yes default (CLI-1974). + const confirmed = yield* legacyPromptYesNo(output, yes, label, true); if (!confirmed) { return yield* Effect.fail( - new LegacySecretsUnsetCancelledError({ message: "context canceled" }), + new LegacySecretsUnsetCancelledError({ message: CONTEXT_CANCELED_MESSAGE }), ); } diff --git a/apps/cli/src/legacy/commands/secrets/unset/unset.integration.test.ts b/apps/cli/src/legacy/commands/secrets/unset/unset.integration.test.ts index e30b74b5fe..103703261b 100644 --- a/apps/cli/src/legacy/commands/secrets/unset/unset.integration.test.ts +++ b/apps/cli/src/legacy/commands/secrets/unset/unset.integration.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, Exit, Layer, Option } from "effect"; import { LegacyYesFlag } from "../../../../shared/legacy/global-flags.ts"; -import { mockOutput, mockTty } from "../../../../../tests/helpers/mocks.ts"; +import { mockOutput, mockStdin, mockTty } from "../../../../../tests/helpers/mocks.ts"; import { LEGACY_VALID_REF, buildLegacyTestRuntime, @@ -26,6 +26,8 @@ interface SetupOpts { goOutput?: "pretty" | "json" | "yaml" | "toml" | "env"; yes?: boolean; stdinIsTty?: boolean; + /** Piped stdin lines consumed by the non-TTY confirm read. */ + stdinInput?: string; confirm?: boolean; list?: SecretsList; listStatus?: number; @@ -64,6 +66,7 @@ function setup(opts: SetupOpts = {}) { api, cliConfig, tty: mockTty({ stdinIsTty: opts.stdinIsTty ?? false, stdoutIsTty: false }), + stdin: mockStdin(opts.stdinIsTty ?? false, opts.stdinInput), goOutput: opts.goOutput === undefined ? Option.none() : Option.some(opts.goOutput), }), Layer.succeed(LegacyYesFlag, opts.yes ?? false), @@ -154,16 +157,67 @@ describe("legacy secrets unset integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("non-TTY without --yes auto-confirms silently (Go parity)", () => { + it.live("non-TTY with empty stdin prints the label and takes the Yes default (Go parity)", () => { const { layer, out, api } = setup({ yes: false, stdinIsTty: false }); return Effect.gen(function* () { yield* legacySecretsUnset({ projectRef: Option.none(), names: ["FOO"] }); - // Go's PromptYesNo defaults to true after 100ms non-TTY read timeout — no stderr echo. - expect(out.stderrText).not.toContain("[Y/n]"); + // Go's PromptText prints the label to stderr, the 100ms non-TTY read scans + // nothing, and the empty input is echoed back before the true default wins + // (`console.go:64-102`). + expect(out.stderrText).toContain( + "Do you want to unset these function secrets?\n • FOO\n\n [Y/n] \n", + ); expect(api.requests.filter((r) => r.method === "DELETE")).toHaveLength(1); }).pipe(Effect.provide(layer)); }); + it.live("non-TTY with piped `n` declines like Go (echoed answer, no DELETE)", () => { + const { layer, out, api } = setup({ yes: false, stdinIsTty: false, stdinInput: "n\n" }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacySecretsUnset({ projectRef: Option.none(), names: ["FOO"] }), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacySecretsUnsetCancelledError"); + } + // The piped answer is echoed to stderr like Go's non-TTY PromptText. + expect(out.stderrText).toContain("[Y/n] n\n"); + expect(api.requests.filter((r) => r.method === "DELETE")).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }); + + it.live("non-TTY with piped `y` confirms like Go", () => { + const { layer, out, api } = setup({ yes: false, stdinIsTty: false, stdinInput: "y\n" }); + return Effect.gen(function* () { + yield* legacySecretsUnset({ projectRef: Option.none(), names: ["FOO"] }); + expect(out.stderrText).toContain("[Y/n] y\n"); + expect(api.requests.filter((r) => r.method === "DELETE")).toHaveLength(1); + }).pipe(Effect.provide(layer)); + }); + + it.live("SUPABASE_YES=1 in the environment auto-confirms with the [Y/n] y echo", () => { + const prev = process.env["SUPABASE_YES"]; + process.env["SUPABASE_YES"] = "1"; + const { layer, out, api } = setup(); + return Effect.gen(function* () { + yield* legacySecretsUnset({ projectRef: Option.none(), names: ["FOO"] }); + // Same bytes as Go's `viper.GetBool("YES")` branch (`console.go:70-72`). + expect(out.stderrText).toContain( + "Do you want to unset these function secrets?\n • FOO\n\n [Y/n] y\n", + ); + expect(api.requests.filter((r) => r.method === "DELETE")).toHaveLength(1); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (prev === undefined) delete process.env["SUPABASE_YES"]; + else process.env["SUPABASE_YES"] = prev; + }), + ), + Effect.provide(layer), + ); + }); + it.live("TTY without --yes prompts via output.promptConfirm and proceeds on accept", () => { const { layer, api } = setup({ yes: false, stdinIsTty: true, confirm: true }); return Effect.gen(function* () { @@ -251,6 +305,21 @@ describe("legacy secrets unset integration", () => { }).pipe(Effect.provide(layer)); }); + it.live("--output-format=json without --yes takes the Yes default silently", () => { + // TS-only machine mode has no Go equivalent; `legacyPromptYesNo` documents + // that json/stream-json never prompts and takes the call site's default — + // for unset that is Yes (Go's `unset.go:34`), mirroring Go's non-TTY + // default-through behavior for scripts. Deliberate (CLI-1974 review); + // pass --yes explicitly in automation for clarity. + const { layer, out, api } = setup({ yes: false, format: "json" }); + return Effect.gen(function* () { + yield* legacySecretsUnset({ projectRef: Option.none(), names: ["FOO"] }); + const success = out.messages.find((m) => m.type === "success"); + expect(success).toBeDefined(); + expect(api.requests.filter((r) => r.method === "DELETE")).toHaveLength(1); + }).pipe(Effect.provide(layer)); + }); + it.live("emits a success event for --output-format=stream-json", () => { const { layer, out } = setup({ yes: true, format: "stream-json" }); return Effect.gen(function* () { diff --git a/apps/cli/src/legacy/commands/sso/add/add.handler.ts b/apps/cli/src/legacy/commands/sso/add/add.handler.ts index e1951e7b6c..29b3592ba9 100644 --- a/apps/cli/src/legacy/commands/sso/add/add.handler.ts +++ b/apps/cli/src/legacy/commands/sso/add/add.handler.ts @@ -141,6 +141,7 @@ export const legacySsoAdd = Effect.fn("legacy.sso.add")(function* (flags: Legacy projectRef: ref, featureKey: "auth.saml_2", statusCode: response.status, + response, }); yield* creating?.fail() ?? Effect.void; if (response.status === 404) { diff --git a/apps/cli/src/legacy/commands/sso/list/list.handler.ts b/apps/cli/src/legacy/commands/sso/list/list.handler.ts index 408e43d23b..9f3a49d8f2 100644 --- a/apps/cli/src/legacy/commands/sso/list/list.handler.ts +++ b/apps/cli/src/legacy/commands/sso/list/list.handler.ts @@ -14,7 +14,10 @@ import { import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; -import { legacySuggestUpgrade } from "../../../shared/legacy-upgrade-suggest.ts"; +import { + legacyGateResponse, + legacySuggestUpgrade, +} from "../../../shared/legacy-upgrade-suggest.ts"; import { LegacySsoListNetworkError, LegacySsoListSamlDisabledError, @@ -41,6 +44,7 @@ const handleListError = (ref: string, cause: SupabaseApiError) => projectRef: ref, featureKey: "auth.saml_2", statusCode: mapped.status, + response: legacyGateResponse(cause), }); if (mapped.status === 404) { return yield* Effect.fail( diff --git a/apps/cli/src/legacy/commands/sso/remove/remove.handler.ts b/apps/cli/src/legacy/commands/sso/remove/remove.handler.ts index c3a35f0ef3..c7cc0bf8e6 100644 --- a/apps/cli/src/legacy/commands/sso/remove/remove.handler.ts +++ b/apps/cli/src/legacy/commands/sso/remove/remove.handler.ts @@ -9,7 +9,10 @@ import { encodeGoJson, encodeToml, encodeYaml } from "../../../shared/legacy-go- import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; -import { legacySuggestUpgrade } from "../../../shared/legacy-upgrade-suggest.ts"; +import { + legacyGateResponse, + legacySuggestUpgrade, +} from "../../../shared/legacy-upgrade-suggest.ts"; import { LegacySsoRemoveNetworkError, LegacySsoRemoveNotFoundError, @@ -33,6 +36,7 @@ const handleRemoveError = (ref: string, providerId: string, cause: SupabaseApiEr projectRef: ref, featureKey: "auth.saml_2", statusCode: mapped.status, + response: legacyGateResponse(cause), }); if (mapped.status === 404) { return yield* Effect.fail( diff --git a/apps/cli/src/legacy/commands/sso/update/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/sso/update/SIDE_EFFECTS.md index 961ea33239..4fa58fb871 100644 --- a/apps/cli/src/legacy/commands/sso/update/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/sso/update/SIDE_EFFECTS.md @@ -20,13 +20,13 @@ ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ------------------------------------------------------------ | ------------ | ------------------------------------------------------------------------------- | ------------------------------------------------------------------ | -| `GET` | `/v1/projects/{ref}/config/auth/sso/providers/{provider_id}` | Bearer token | none | `{id, saml?, domains?, created_at?, updated_at?}` | -| `PUT` | `/v1/projects/{ref}/config/auth/sso/providers/{provider_id}` | Bearer token | `{metadata_xml?, metadata_url?, domains?, attribute_mapping?, name_id_format?}` | `{id, saml?, domains?, ...}` (parsed loosely) | -| `GET` | `<metadata-url>` | none | `Accept: application/xml`, 10s timeout | XML body (UTF-8) — validation when `--skip-url-validation` not set | -| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{organization_slug}` — upgrade-gate side-call on 4xx | -| `GET` | `/v1/organizations/{slug}/entitlements` | Bearer token | none | `{entitlements[].feature.key, .hasAccess}` — upgrade-gate | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ------------------------------------------------------------ | ------------ | ------------------------------------------------------------------------------ | ------------------------------------------------------------------ | +| `GET` | `/v1/projects/{ref}/config/auth/sso/providers/{provider_id}` | Bearer token | none | `{id, saml?, domains?, created_at?, updated_at?}` | +| `PUT` | `/v1/projects/{ref}/config/auth/sso/providers/{provider_id}` | Bearer token | `{metadata_xml?, metadata_url?, domains, attribute_mapping?, name_id_format?}` | `{id, saml?, domains?, ...}` (parsed loosely) | +| `GET` | `<metadata-url>` | none | `Accept: application/xml`, 10s timeout | XML body (UTF-8) — validation when `--skip-url-validation` not set | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{organization_slug}` — upgrade-gate side-call on 4xx | +| `GET` | `/v1/organizations/{slug}/entitlements` | Bearer token | none | `{entitlements[].feature.key, .hasAccess}` — upgrade-gate | Bypasses the typed Management API client for the PUT so user-supplied keys inside `attribute_mapping.keys.<x>` (e.g. `default`) are preserved verbatim. The initial @@ -83,6 +83,7 @@ Single `success` event with the parsed response as data. - `--metadata-file` and `--metadata-url` are mutually exclusive. - Always performs the GET pre-check (matches Go's `update.go:42`), regardless of whether `--add-domains` / `--remove-domains` are used. - Domain merge: removals are applied first, then additions. Go uses a `map[string]bool` so the resulting order is **unordered**; consumers must sort if comparing. +- **`domains` is always present in the PUT body** (CLI-1981): Go's `--add-domains`/`--remove-domains` default to a non-nil `[]string{}` (`cmd/sso.go:171-172`), so `update.go:84`'s `!= nil` merge gate is always true from the CLI. With no domain flags (or an explicit empty `--domains=`) the body carries the recomputed existing set; when the provider has no domains it is the literal `"domains":[]` (non-nil `*[]string` under `omitempty` — verified by live capture against the Go binary). - Metadata URL validation error message: `only HTTPS Metadata URLs are supported Use --skip-url-validation to suppress this error.` (single trailing period — matches Go's `update.go:69`, which wraps with `%w Use --skip-url-validation to suppress this error.`; differs from `sso add`'s variant which omits the trailing period). - The `## Attribute Mapping` / `## SAML 2.0 Metadata XML` sections are emitted as plain markdown (heading + fence). Visual styling of the headings does not match Go's Glamour-rendered output; the table portion and the XML body inside the fence are byte-parity (see `formatSsoMetadataXml`). - **PUT failure message reuses the GET error string**: a non-2xx PUT response produces `unexpected error fetching identity provider: <body>` — note "fetching" not "updating". This matches Go's `update.go:133` verbatim. diff --git a/apps/cli/src/legacy/commands/sso/update/update.handler.ts b/apps/cli/src/legacy/commands/sso/update/update.handler.ts index aba1d6833f..77e86d906f 100644 --- a/apps/cli/src/legacy/commands/sso/update/update.handler.ts +++ b/apps/cli/src/legacy/commands/sso/update/update.handler.ts @@ -22,7 +22,10 @@ import { mapLegacyHttpError, sanitizeLegacyErrorBody } from "../../../shared/leg import { resolveLegacyAccessToken } from "../../../shared/legacy-resolve-token.ts"; import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; -import { legacySuggestUpgrade } from "../../../shared/legacy-upgrade-suggest.ts"; +import { + legacyGateResponse, + legacySuggestUpgrade, +} from "../../../shared/legacy-upgrade-suggest.ts"; import { LegacySsoMutexFlagError, LegacySsoUpdateAttributeMappingFileError, @@ -94,6 +97,7 @@ const handleGetError = (ref: string, providerId: string, cause: SupabaseApiError projectRef: ref, featureKey: "auth.saml_2", statusCode: mapped.status, + response: legacyGateResponse(cause), }); if (mapped.status === 404) { return yield* Effect.fail( @@ -115,13 +119,15 @@ function mergeDomains( add: ReadonlyArray<string>, remove: ReadonlyArray<string>, ): ReadonlyArray<string> { - // Mirrors Go's `update.go:93-117` — seed from current domains, apply + // Mirrors Go's `update.go:84-109` — seed from current domains, apply // removals, then add new entries. Go uses a `map[string]bool`, so iteration - // order is unspecified; integration tests sort before asserting. + // order is unspecified; integration tests sort before asserting. Go's seed + // check is nil-ness only (`domain.Domain != nil`), so an empty-string domain + // from the GET response is kept, not filtered. const set = new Set<string>(); if (existing !== undefined) { for (const item of existing) { - if (typeof item.domain === "string" && item.domain.length > 0) { + if (typeof item.domain === "string") { set.add(item.domain); } } @@ -228,7 +234,16 @@ export const legacySsoUpdate = Effect.fn("legacy.sso.update")(function* ( if (flags.domains.length > 0) { body["domains"] = [...flags.domains]; - } else if (flags.addDomains.length > 0 || flags.removeDomains.length > 0) { + } else { + // Go's `update.go:84` reads as gating the merge on + // `params.AddDomains != nil || params.RemoveDomains != nil`, but + // `cmd/sso.go:171-172` declares both flags with a non-nil `[]string{}` + // default and passes them unconditionally — so from the CLI that + // condition is always true and every `sso update` PUT recomputes and + // sends `domains`, even when no domain flag was passed. `body.Domains` + // is a non-nil `*[]string` under `json:"domains,omitempty"`, so an + // empty merged set serializes as `"domains":[]`, never omitted + // (CLI-1981; live-captured against the Go binary). body["domains"] = mergeDomains(existing.domains, flags.addDomains, flags.removeDomains); } @@ -267,6 +282,7 @@ export const legacySsoUpdate = Effect.fn("legacy.sso.update")(function* ( projectRef: ref, featureKey: "auth.saml_2", statusCode: response.status, + response, }); yield* fetching?.fail() ?? Effect.void; return yield* Effect.fail( diff --git a/apps/cli/src/legacy/commands/sso/update/update.integration.test.ts b/apps/cli/src/legacy/commands/sso/update/update.integration.test.ts index c76206a29b..757e7b46de 100644 --- a/apps/cli/src/legacy/commands/sso/update/update.integration.test.ts +++ b/apps/cli/src/legacy/commands/sso/update/update.integration.test.ts @@ -476,12 +476,84 @@ describe("legacy sso update integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("no domain flag set → body.domains omitted", () => { + it.live("no domain flag set → PUT still sends the recomputed existing domain set", () => { + // Go's `--add-domains`/`--remove-domains` default to a non-nil `[]string{}` + // (`cmd/sso.go:171-172`), so `update.go:84`'s `!= nil` gate is always true + // from the CLI — every `sso update` enters the merge and sends `domains`, + // even when no domain flag was passed (CLI-1981). Live-captured Go PUT: + // `{"domains":["old1.com","old2.com"]}`. const { layer, api } = setup(); return Effect.gen(function* () { yield* legacySsoUpdate(defaultFlags); const putReq = api.requests.find((r) => r.method === "PUT"); - expect((putReq?.body as { domains?: string[] })?.domains).toBeUndefined(); + const domains = (putReq?.body as { domains: string[] })?.domains; + // Go map iteration is unordered — sort before asserting. + expect([...domains].sort()).toEqual(["old1.com", "old2.com"]); + }).pipe(Effect.provide(layer)); + }); + + it.live( + "no domain flags + provider with no domains → PUT sends domains: [] (not omitted)", + () => { + // Go sets `body.Domains` to a non-nil pointer to `make([]string, 0)`, and + // `json:"domains,omitempty"` never omits a non-nil pointer — live-captured + // Go PUT body is exactly `{"domains":[]}`. + const { layer, api } = setup({ getBody: { ...EXISTING_PROVIDER, domains: [] } }); + return Effect.gen(function* () { + yield* legacySsoUpdate(defaultFlags); + const putReq = api.requests.find((r) => r.method === "PUT"); + const body = putReq?.body as Record<string, unknown>; + expect(Object.keys(body)).toContain("domains"); + expect(body["domains"]).toEqual([]); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("no domain flags + GET response missing domains entirely → PUT sends domains: []", () => { + // Go's seed loop is skipped when `getResp.JSON200.Domains == nil`, leaving + // the merged set empty — same `{"domains":[]}` bytes as the empty-list case. + const { domains: _omitted, ...providerWithoutDomains } = EXISTING_PROVIDER; + const { layer, api } = setup({ getBody: providerWithoutDomains }); + return Effect.gen(function* () { + yield* legacySsoUpdate(defaultFlags); + const putReq = api.requests.find((r) => r.method === "PUT"); + const body = putReq?.body as Record<string, unknown>; + expect(Object.keys(body)).toContain("domains"); + expect(body["domains"]).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + + it.live("explicit empty --domains= falls into the merge and resends the existing set", () => { + // `--domains=` parses to an empty slice, so Go's `len(params.Domains) != 0` + // replace gate is false and the merge branch runs with no add/remove — + // live-captured Go PUT resends the existing domains, it does NOT replace + // them with an empty list. + const { layer, api } = setup({ + cliArgs: ["sso", "update", VALID_PROVIDER_ID, "--domains="], + }); + return Effect.gen(function* () { + yield* legacySsoUpdate({ ...defaultFlags, domains: [] }); + const putReq = api.requests.find((r) => r.method === "PUT"); + const domains = (putReq?.body as { domains: string[] })?.domains; + expect([...domains].sort()).toEqual(["old1.com", "old2.com"]); + }).pipe(Effect.provide(layer)); + }); + + it.live("merge keeps empty-string domains and skips entries without a domain field", () => { + // Go's seed check is nil-ness only (`domain.Domain != nil`, + // `update.go:89`): an empty-string domain from the GET response stays in + // the merged set, while an entry missing the field entirely is skipped. + const { layer, api } = setup({ + getBody: { + ...EXISTING_PROVIDER, + domains: [{ id: "d1", domain: "" }, { id: "d2", domain: "old1.com" }, { id: "d3" }], + }, + }); + return Effect.gen(function* () { + yield* legacySsoUpdate(defaultFlags); + const putReq = api.requests.find((r) => r.method === "PUT"); + const domains = (putReq?.body as { domains: string[] })?.domains; + expect([...domains].sort()).toEqual(["", "old1.com"]); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md index c8982c2b75..b43a5bede7 100644 --- a/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md @@ -226,6 +226,23 @@ output modes. volume) → Postgres create+start+health-wait → `Starting containers...` → (image pre-pull) → per-container create+start → `Waiting for health checks...` → `Started supabase local development setup.` +- stderr (conditional, health-check timeout): per unhealthy container, a + `<container> container logs:` header and that container's `docker logs` output, then one + `<container>: <reason>` line each. Containers are named `supabase_<service>_<project id>` + throughout, matching Go's `started = append(started, utils.InbucketId)` rather than the + id `docker create` returns. +- stderr (conditional, `exec format error` in those logs) — **TS-port-only, beyond Go + parity**: a recovery `suggestion` printed after the reasons, naming each affected + container **with** its image (they can be named after different things — + `supabase_inbucket_*` runs `mailpit`), then a `supabase stop` / `<runtime> image rm -f` / + `supabase start` sequence, then a closing line for the case re-pulling cannot fix. The + runtime named is whichever of `docker`/`podman` actually answered. `supabase stop` leads + because `--ignore-health-check` leaves the stack up, so a bare restart would hit the + already-running short-circuit and never recreate the broken container. The sequence tells + the reader to run it from the project directory or with the same `--workdir`, rather than + embedding the resolved path, which would need shell quoting that differs per platform. Being a + `suggestion` also replaces the usual "rerun with --debug" line, which cannot help here. + Nothing is ever removed automatically, and Go prints no such guidance. - stdout: the `status` pretty table (rounded box, same renderer `supabase status` uses). - stderr: the local-dev security notice block (bind-to-`0.0.0.0` / shared-default-keys / no-auth-on-Studio-pgMeta-analytics warning). @@ -234,7 +251,10 @@ supabase local development setup.` A single JSON object (or a `result` NDJSON event) carrying the same value shape `supabase status --output json` produces. All the progress/warning/banner/security-notice -text above is suppressed in these modes — stdout stays payload-only. +text above is suppressed in these modes — stdout stays payload-only. On a health-check +timeout the error payload carries the advice above as a discrete `error.suggestion` string +alongside `error.message`, so a caller can surface it without re-parsing the message. It is +prose, not structured data. ## Notes diff --git a/apps/cli/src/legacy/commands/start/lib/health-check.ts b/apps/cli/src/legacy/commands/start/lib/health-check.ts index f21e6b9693..20b0a7524e 100644 --- a/apps/cli/src/legacy/commands/start/lib/health-check.ts +++ b/apps/cli/src/legacy/commands/start/lib/health-check.ts @@ -15,7 +15,10 @@ import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; -import { spawnContainerCli } from "../../../shared/legacy-container-cli.ts"; +import { + legacySpawnContainerCliWithRuntime, + type LegacyContainerRuntime, +} from "../../../shared/legacy-container-cli.ts"; import { legacyInspectContainerState } from "../../../shared/legacy-docker-lifecycle.ts"; import { legacyKongAuthHeaders } from "../../../shared/legacy-kong-auth.ts"; @@ -50,6 +53,11 @@ const LEGACY_EDGE_RUNTIME_READY_PATH = "/functions/v1/_internal/health"; /** Identifies a single container's readiness failure this round. */ export interface LegacyHealthCheckFailure { + /** + * The container NAME (`supabase_<service>_<project id>`), not the opaque id + * `docker create` returns — both work with `docker container inspect`/`docker + * logs`, but only the name tells a user which service failed. + */ readonly containerId: string; readonly reason: string; } @@ -69,6 +77,12 @@ export class LegacyHealthCheckTimeoutError extends Data.TaggedError( )<{ readonly message: string; readonly unhealthy: ReadonlyArray<LegacyHealthCheckFailure>; + /** + * Kept out of {@link message} so `Output.fail` renders it unstyled and drops + * the generic "rerun with --debug" line: once an exact command is named, + * pointing at an HTTP request logger is a non-sequitur (as in CLI-1973). + */ + readonly suggestion?: string; }> {} /** @@ -110,6 +124,77 @@ export interface LegacyWaitForHealthyServicesOptions { readonly postgrest?: LegacyHealthCheckPostgrestGateway; /** See {@link LEGACY_EDGE_RUNTIME_READY_PATH}'s doc comment for why this reuses the same gateway shape as {@link postgrest}. */ readonly edgeRuntime?: LegacyHealthCheckPostgrestGateway; + /** Each watched container's already-resolved image, keyed by container name. */ + readonly images?: ReadonlyMap<string, string>; +} + +/** The container runtime's message when an image cannot be executed by the host kernel. */ +const LEGACY_EXEC_FORMAT_ERROR = "exec format error"; + +/** + * Scans a byte stream for {@link LEGACY_EXEC_FORMAT_ERROR} in constant memory: + * retaining the trailing marker-length window is exactly enough to match a + * marker split across a chunk boundary. One scanner per stream, so stdout and + * stderr bytes cannot splice into a marker neither stream contains. + */ +function legacyMakeExecFormatScanner() { + const decoder = new TextDecoder(); + let tail = ""; + let found = false; + return { + scan(chunk: Uint8Array): void { + const text = tail + decoder.decode(chunk, { stream: true }); + found ||= text.includes(LEGACY_EXEC_FORMAT_ERROR); + tail = text.slice(-LEGACY_EXEC_FORMAT_ERROR.length); + }, + get found(): boolean { + return found; + }, + }; +} + +/** + * Recovery advice for a timeout whose logs showed {@link LEGACY_EXEC_FORMAT_ERROR}, + * or `undefined` when no affected image can be named. + * + * `supabase stop` leads the sequence because a bare restart is a no-op on the + * `--ignore-health-check` path: that path leaves the stack up by design, so the + * next `supabase start` takes the already-running short-circuit and never + * recreates the broken container. `stop` without `--no-backup` preserves the + * database volume, and is harmless after the hard-fail path's own rollback. + * + * Both `supabase` steps resolve their own project the way this run did, so the + * sequence names that requirement rather than embedding the resolved workdir: + * a path rendered into a copy-pasteable command needs shell quoting, and the + * correct quoting differs between POSIX shells and `cmd.exe`. + * + * `-f` because the container may still reference the image, and `runtime` + * rather than a hardcoded `docker` because `spawnContainerCli` falls back to + * Podman on hosts without Docker. The closing line covers the case re-pulling + * cannot fix: a pinned version with no build for this host (supabase/cli#3718, + * #4674), where the same image comes straight back. + */ +function legacyExecFormatRecoveryHint( + containerIds: ReadonlyArray<string>, + images: ReadonlyMap<string, string> | undefined, + runtime: LegacyContainerRuntime, +): string | undefined { + // Both names, always: a container and its image can be named after different + // things (`supabase_inbucket_*` runs `mailpit`), so naming only one leaves + // the reader guessing whether this is the same failure as the reason above. + const affected = containerIds.flatMap((containerId) => { + const image = images?.get(containerId); + return image === undefined ? [] : [{ containerId, image }]; + }); + if (affected.length === 0) return undefined; + const uniqueImages = [...new Set(affected.map((entry) => entry.image))]; + return [ + `${affected.map((entry) => `${entry.containerId}'s image ${entry.image}`).join(", ")} could not be executed ("${LEGACY_EXEC_FORMAT_ERROR}").`, + "Either the cached copy is corrupt, or it was built for a different architecture.", + "Remove the cached copy and start again, from this project directory or with the same --workdir:", + `\n supabase stop\n ${runtime} image rm -f ${uniqueImages.join(" ")}\n supabase start\n`, + "If it fails the same way, that image has no build for this machine's architecture.", + ].join("\n"); } /** @@ -165,25 +250,42 @@ function legacyCheckHttpReady( * via `docker logs <id>`, teed to this process's stderr — best-effort: a * failure to stream logs must never mask the timeout error it was printed * alongside, so every failure here is swallowed. + * + * Resolves to whether the logs contained {@link LEGACY_EXEC_FORMAT_ERROR}, + * scanned off the bytes already being teed — no extra Docker call, no buffer — + * and to the runtime that answered, so recovery advice can name the binary the + * user actually has. */ -function legacyStreamContainerLogsOnce(spawner: Spawner, containerId: string): Effect.Effect<void> { +function legacyStreamContainerLogsOnce( + spawner: Spawner, + containerId: string, +): Effect.Effect<LegacyExecFormatScanResult> { + // Outside the effect that can fail, so a stream breaking mid-dump cannot + // discard a marker the scanner has already seen. + const stdoutScanner = legacyMakeExecFormatScanner(); + const stderrScanner = legacyMakeExecFormatScanner(); + let runtime: LegacyContainerRuntime | undefined; return Effect.scoped( Effect.gen(function* () { - const handle = yield* spawnContainerCli(spawner, ["logs", containerId], { + const spawned = yield* legacySpawnContainerCliWithRuntime(spawner, ["logs", containerId], { stdin: "ignore", stdout: "pipe", stderr: "pipe", }); + runtime = spawned.runtime; + const handle = spawned.handle; yield* Effect.all( [ Stream.runForEach(handle.stdout, (chunk) => Effect.sync(() => { globalThis.process.stderr.write(chunk); + stdoutScanner.scan(chunk); }), ), Stream.runForEach(handle.stderr, (chunk) => Effect.sync(() => { globalThis.process.stderr.write(chunk); + stderrScanner.scan(chunk); }), ), ], @@ -191,16 +293,29 @@ function legacyStreamContainerLogsOnce(spawner: Spawner, containerId: string): E ); yield* handle.exitCode; }), - ).pipe(Effect.orElseSucceed(() => undefined)); + ).pipe( + Effect.orElseSucceed(() => undefined), + Effect.map(() => ({ found: stdoutScanner.found || stderrScanner.found, runtime })), + ); +} + +/** What one container's log dump learned, beyond the bytes it teed to stderr. */ +interface LegacyExecFormatScanResult { + readonly found: boolean; + /** `undefined` only when neither runtime could be spawned at all. */ + readonly runtime: LegacyContainerRuntime | undefined; } /** Go's `fmt.Fprintln(os.Stderr, containerId, "container logs:")` (`start.go:218`) + the log dump itself. */ -function legacyDumpContainerLogs(spawner: Spawner, containerId: string): Effect.Effect<void> { +function legacyDumpContainerLogs( + spawner: Spawner, + containerId: string, +): Effect.Effect<LegacyExecFormatScanResult> { return Effect.gen(function* () { yield* Effect.sync(() => { globalThis.process.stderr.write(`${containerId} container logs:\n`); }); - yield* legacyStreamContainerLogsOnce(spawner, containerId); + return yield* legacyStreamContainerLogsOnce(spawner, containerId); }); } @@ -278,8 +393,18 @@ export function legacyWaitForHealthyServices( // `!errors.Is(err, context.Canceled)`) — an interrupted fiber never // reaches this `Effect.catch` handler at all, so no separate check // is needed here. - yield* Effect.forEach(probeError.failures, (failure) => - legacyDumpContainerLogs(spawner, failure.containerId), + const scans = yield* Effect.forEach(probeError.failures, (failure) => + legacyDumpContainerLogs(spawner, failure.containerId).pipe( + Effect.map((scan) => ({ ...scan, containerId: failure.containerId })), + ), + ); + const execFormatErrors = scans + .filter((scan) => scan.found) + .map((scan) => scan.containerId); + const suggestion = legacyExecFormatRecoveryHint( + execFormatErrors, + opts.images, + scans.find((scan) => scan.runtime !== undefined)?.runtime ?? "docker", ); return yield* Effect.fail( new LegacyHealthCheckTimeoutError({ @@ -287,6 +412,7 @@ export function legacyWaitForHealthyServices( .map((failure) => `${failure.containerId}: ${failure.reason}`) .join("\n"), unhealthy: probeError.failures, + ...(suggestion === undefined ? {} : { suggestion }), }), ); }), diff --git a/apps/cli/src/legacy/commands/start/lib/health-check.unit.test.ts b/apps/cli/src/legacy/commands/start/lib/health-check.unit.test.ts index ed0d287421..8737d09001 100644 --- a/apps/cli/src/legacy/commands/start/lib/health-check.unit.test.ts +++ b/apps/cli/src/legacy/commands/start/lib/health-check.unit.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; import { Deferred, Effect, Exit, Fiber, Layer, Sink, Stream } from "effect"; +import * as PlatformError from "effect/PlatformError"; import { ChildProcessSpawner } from "effect/unstable/process"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; @@ -11,37 +12,101 @@ import { type LegacyHealthCheckPostgrestGateway, } from "./health-check.ts"; +/** + * Ends a scripted `logs` script, failing the stream after the chunks before it + * have already been emitted — the real "daemon dropped the pipe mid-dump" case. + */ +const LOG_STREAM_FAILS = Symbol("log stream fails"); + /** * A spawner that answers `docker container inspect`/`docker logs` calls. * `inspectResponse` is called once per `(containerId, callIndex)` pair — the * `callIndex` (0-based, per container) lets a test script a container's * health across successive polling rounds. `docker logs` calls (the - * timeout-path debug dump) always succeed with empty output. + * timeout-path debug dump) succeed with whatever `logs` scripts for that + * container — an array so a test can script chunk boundaries, optionally + * terminated by {@link LOG_STREAM_FAILS} — and with empty output otherwise. */ -function mockHealthSpawner(inspectResponse: (containerId: string, callIndex: number) => string) { +type LogChunks = ReadonlyArray<string | typeof LOG_STREAM_FAILS>; + +/** A container's scripted `docker logs` output; a bare array targets stdout. */ +type LogScript = LogChunks | { readonly stdout?: LogChunks; readonly stderr?: LogChunks }; + +const isLogChunks = (script: LogScript): script is LogChunks => Array.isArray(script); + +function mockHealthSpawner( + inspectResponse: (containerId: string, callIndex: number) => string, + logs: Readonly<Record<string, LogScript>> = {}, + opts: { readonly runtime?: "docker" | "podman" } = {}, +) { const counts = new Map<string, number>(); const encoder = new TextEncoder(); const spawned: Array<ReadonlyArray<string>> = []; + const toStream = (chunks: LogChunks) => { + const emitted = Stream.fromIterable( + chunks + .filter((chunk): chunk is string => typeof chunk === "string" && chunk.length > 0) + .map((chunk) => encoder.encode(chunk)), + ); + return chunks.includes(LOG_STREAM_FAILS) + ? Stream.concat( + emitted, + Stream.fail( + PlatformError.systemError({ + _tag: "BadResource", + module: "ChildProcess", + method: "stdout", + description: "broken pipe", + }), + ), + ) + : emitted; + }; + const spawner = ChildProcessSpawner.make((command) => Effect.gen(function* () { const args = command._tag === "StandardCommand" ? command.args : []; + const binary = command._tag === "StandardCommand" ? command.command : ""; + // Mirrors a host where only one runtime is installed: `spawnContainerCli` + // tries `docker` first and falls back to `podman` when the spawn fails. + if (opts.runtime !== undefined && binary !== opts.runtime) { + return yield* Effect.fail( + PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: `${binary}: command not found`, + }), + ); + } spawned.push(args); - let stdout = ""; + let stdoutChunks: LogChunks = []; + let stderrChunks: LogChunks = []; if (args[0] === "container" && args[1] === "inspect") { const containerId = args[2] ?? ""; const callIndex = counts.get(containerId) ?? 0; counts.set(containerId, callIndex + 1); - stdout = inspectResponse(containerId, callIndex); + stdoutChunks = [inspectResponse(containerId, callIndex)]; + } else if (args[0] === "logs") { + const script = logs[args[1] ?? ""]; + if (script !== undefined) { + if (isLogChunks(script)) { + stdoutChunks = script; + } else { + stdoutChunks = script.stdout ?? []; + stderrChunks = script.stderr ?? []; + } + } } const exitDeferred = yield* Deferred.make<ChildProcessSpawner.ExitCode>(); yield* Deferred.succeed(exitDeferred, ChildProcessSpawner.ExitCode(0)); return ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(1), - stdout: Stream.fromIterable(stdout.length > 0 ? [encoder.encode(stdout)] : []), - stderr: Stream.empty, + stdout: toStream(stdoutChunks), + stderr: toStream(stderrChunks), all: Stream.empty, exitCode: Deferred.await(exitDeferred), isRunning: Effect.succeed(false), @@ -205,6 +270,255 @@ describe("legacyWaitForHealthyServices", () => { }), ); + describe("exec format error recovery advice", () => { + /** + * The dumped logs are teed to the real `process.stderr`; swallow them so + * a scenario that scripts a failing container does not spray the reporter. + */ + function timeoutError( + mock: ReturnType<typeof mockHealthSpawner>, + containerIds: ReadonlyArray<string>, + images?: ReadonlyMap<string, string>, + ) { + return Effect.gen(function* () { + const originalWrite = globalThis.process.stderr.write.bind(globalThis.process.stderr); + globalThis.process.stderr.write = (() => true) as typeof globalThis.process.stderr.write; + const fiber = yield* legacyWaitForHealthyServices(mock.spawner, containerIds, { + timeoutSeconds: 1, + images, + }).pipe( + Effect.provide(unusedHttpClientLayer), + Effect.forkChild({ startImmediately: true }), + ); + yield* TestClock.adjust("1 seconds"); + return yield* Fiber.join(fiber).pipe( + Effect.flip, + Effect.ensuring( + Effect.sync(() => { + globalThis.process.stderr.write = originalWrite; + }), + ), + ); + }); + } + + it.effect("names the exact image to remove when a container cannot execute its image", () => + Effect.gen(function* () { + const mock = mockHealthSpawner(() => notRunning, { + supabase_inbucket_proj: ["exec /mailpit: exec format error\n"], + }); + + const error = yield* timeoutError( + mock, + ["supabase_inbucket_proj"], + new Map([["supabase_inbucket_proj", "public.ecr.aws/supabase/mailpit:v1.30.2"]]), + ); + + // Reasons unchanged; the advice rides on `suggestion`, which is what + // suppresses the unhelpful "--debug" hint downstream. + expect(error.message).toBe("supabase_inbucket_proj: container is not running: exited"); + expect(error.unhealthy).toEqual([ + { containerId: "supabase_inbucket_proj", reason: "container is not running: exited" }, + ]); + // Container and image named together: they can be named after different + // things, so either alone leaves the reader guessing. + expect(error.suggestion).toContain( + "supabase_inbucket_proj's image public.ecr.aws/supabase/mailpit:v1.30.2", + ); + // Names the cause without promising a remedy: re-pulling fixes a corrupt + // or wrong-architecture copy, but not a version with no build at all. + expect(error.suggestion).toContain("built for a different architecture"); + expect(error.suggestion).toContain( + "docker image rm -f public.ecr.aws/supabase/mailpit:v1.30.2", + ); + }), + ); + + it.effect("matches the marker when Docker splits it across log chunks", () => + Effect.gen(function* () { + const mock = mockHealthSpawner(() => notRunning, { + supabase_studio_proj: ["exec /docker-entrypoint.sh: exec for", "mat error\n"], + }); + + const error = yield* timeoutError( + mock, + ["supabase_studio_proj"], + new Map([["supabase_studio_proj", "public.ecr.aws/supabase/studio:2026.07.13"]]), + ); + + expect(error.suggestion).toContain( + "docker image rm -f public.ecr.aws/supabase/studio:2026.07.13", + ); + }), + ); + + it.effect("keeps a marker already seen when the log stream breaks mid-dump", () => + Effect.gen(function* () { + const mock = mockHealthSpawner(() => notRunning, { + supabase_inbucket_proj: ["exec /mailpit: exec format error\n", LOG_STREAM_FAILS], + }); + + const error = yield* timeoutError( + mock, + ["supabase_inbucket_proj"], + new Map([["supabase_inbucket_proj", "public.ecr.aws/supabase/mailpit:v1.30.2"]]), + ); + + // A dropped pipe part-way through the dump must not discard a marker the + // scanner already matched — that is exactly when this bug class shows up. + expect(error.suggestion).toContain( + "docker image rm -f public.ecr.aws/supabase/mailpit:v1.30.2", + ); + }), + ); + + it.effect("stays silent for an ordinary unhealthy container", () => + Effect.gen(function* () { + const mock = mockHealthSpawner(() => notRunning, { + supabase_storage_proj: ['{"msg":"Server not started with error"}\n'], + }); + + const error = yield* timeoutError(mock, ["supabase_storage_proj"]); + + expect(error.message).toBe("supabase_storage_proj: container is not running: exited"); + expect(error.suggestion).toBeUndefined(); + }), + ); + + it.effect("detects the marker when it arrives on the container's stderr stream", () => + Effect.gen(function* () { + // Where the container runtime actually writes it: `docker logs` demuxes + // the container's stderr onto its own stderr pipe, so this is the real + // path, not the stdout one every other scenario here scripts. + const mock = mockHealthSpawner(() => notRunning, { + supabase_inbucket_proj: { stderr: ["exec /mailpit: exec format error\n"] }, + }); + + const error = yield* timeoutError( + mock, + ["supabase_inbucket_proj"], + new Map([["supabase_inbucket_proj", "public.ecr.aws/supabase/mailpit:v1.30.2"]]), + ); + + expect(error.suggestion).toContain( + "docker image rm -f public.ecr.aws/supabase/mailpit:v1.30.2", + ); + }), + ); + + it.effect("names podman in the recovery command on a Podman-only host", () => + Effect.gen(function* () { + const mock = mockHealthSpawner( + () => notRunning, + { supabase_inbucket_proj: { stderr: ["exec format error\n"] } }, + { runtime: "podman" }, + ); + + const error = yield* timeoutError( + mock, + ["supabase_inbucket_proj"], + new Map([["supabase_inbucket_proj", "public.ecr.aws/supabase/mailpit:v1.30.2"]]), + ); + + // A `docker ...` line would be uncopyable on a host without Docker. + expect(error.suggestion).toContain( + "podman image rm -f public.ecr.aws/supabase/mailpit:v1.30.2", + ); + expect(error.suggestion).not.toContain("docker image rm"); + }), + ); + + it.effect("leads the recovery sequence with supabase stop", () => + Effect.gen(function* () { + const mock = mockHealthSpawner(() => notRunning, { + supabase_inbucket_proj: ["exec format error\n"], + }); + + const error = yield* timeoutError( + mock, + ["supabase_inbucket_proj"], + new Map([["supabase_inbucket_proj", "public.ecr.aws/supabase/mailpit:v1.30.2"]]), + ); + + // Without `supabase stop`, the `--ignore-health-check` path leaves the + // stack up and the next `supabase start` short-circuits on + // already-running, never recreating the broken container. + const suggestion = error.suggestion ?? ""; + expect(suggestion).toContain("supabase stop"); + expect(suggestion.indexOf("supabase stop")).toBeLessThan(suggestion.indexOf("image rm -f")); + expect(suggestion.indexOf("image rm -f")).toBeLessThan( + suggestion.indexOf("supabase start"), + ); + // Both `supabase` steps resolve their own project, so a run made with + // `--workdir` has to repeat it rather than rely on the current directory. + expect(suggestion).toContain("--workdir"); + // And a next step for the cause re-pulling cannot fix. + expect(suggestion).toContain("no build for this machine's architecture"); + }), + ); + + it.effect("lists every distinct broken image", () => + Effect.gen(function* () { + const mock = mockHealthSpawner(() => notRunning, { + supabase_inbucket_proj: ["exec format error\n"], + supabase_studio_proj: ["exec format error\n"], + }); + + const error = yield* timeoutError( + mock, + ["supabase_inbucket_proj", "supabase_studio_proj"], + new Map([ + ["supabase_inbucket_proj", "public.ecr.aws/supabase/mailpit:v1.30.2"], + ["supabase_studio_proj", "public.ecr.aws/supabase/studio:2026.07.13"], + ]), + ); + + expect(error.suggestion).toContain( + "supabase_inbucket_proj's image public.ecr.aws/supabase/mailpit:v1.30.2", + ); + expect(error.suggestion).toContain( + "supabase_studio_proj's image public.ecr.aws/supabase/studio:2026.07.13", + ); + expect(error.suggestion).toContain( + "docker image rm -f public.ecr.aws/supabase/mailpit:v1.30.2 public.ecr.aws/supabase/studio:2026.07.13", + ); + }), + ); + + it.effect("dedupes an image shared by two broken containers", () => + Effect.gen(function* () { + const mock = mockHealthSpawner(() => notRunning, { + supabase_rest_proj: ["exec format error\n"], + supabase_realtime_proj: ["exec format error\n"], + supabase_storage_proj: ["listening on :5000\n"], + }); + + const error = yield* timeoutError( + mock, + ["supabase_rest_proj", "supabase_realtime_proj", "supabase_storage_proj"], + new Map([ + ["supabase_rest_proj", "public.ecr.aws/supabase/postgrest:v14.15"], + ["supabase_realtime_proj", "public.ecr.aws/supabase/postgrest:v14.15"], + ["supabase_storage_proj", "public.ecr.aws/supabase/storage-api:v1.66.4"], + ]), + ); + + // Both containers are named against the shared image... + expect(error.suggestion).toContain("supabase_rest_proj's image"); + expect(error.suggestion).toContain("supabase_realtime_proj's image"); + // ...which the removal command lists once, not twice. + expect(error.suggestion).toContain( + "docker image rm -f public.ecr.aws/supabase/postgrest:v14.15", + ); + expect(error.suggestion).not.toContain( + "postgrest:v14.15 public.ecr.aws/supabase/postgrest:v14.15", + ); + // The healthy-logged container's own image is never suggested. + expect(error.suggestion).not.toContain("storage-api"); + }), + ); + }); + describe("PostgREST HTTP-HEAD readiness", () => { function postgrestGateway(secretKey: string): LegacyHealthCheckPostgrestGateway { return { diff --git a/apps/cli/src/legacy/commands/start/start.e2e.test.ts b/apps/cli/src/legacy/commands/start/start.e2e.test.ts index 6b9932f1e7..9f6bb9eb44 100644 --- a/apps/cli/src/legacy/commands/start/start.e2e.test.ts +++ b/apps/cli/src/legacy/commands/start/start.e2e.test.ts @@ -3,16 +3,10 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; -import { runSupabase } from "../../../../tests/helpers/cli.ts"; +import { runSupabase, stripAnsi } from "../../../../tests/helpers/cli.ts"; const E2E_TIMEOUT_MS = 30_000; -// Strip ANSI so the assertion is colour-independent — `legacyPartitionStartExcludeFlags` -// styles the warning via `legacy-colors.ts`, matching `start.exclude.unit.test.ts`'s -// own convention for the same text. -// eslint-disable-next-line no-control-regex -const stripAnsi = (text: string) => text.replace(/\x1b\[[0-9;]*m/gu, ""); - describe("supabase start (legacy)", () => { let projectDir: string; diff --git a/apps/cli/src/legacy/commands/start/start.exclude.unit.test.ts b/apps/cli/src/legacy/commands/start/start.exclude.unit.test.ts index 6964f40140..93f65230d8 100644 --- a/apps/cli/src/legacy/commands/start/start.exclude.unit.test.ts +++ b/apps/cli/src/legacy/commands/start/start.exclude.unit.test.ts @@ -1,14 +1,8 @@ import { describe, expect, it } from "vitest"; +import { stripAnsi } from "../../../../tests/helpers/ansi.ts"; import { LEGACY_START_EXCLUDABLE_KEYS, legacyPartitionStartExcludeFlags } from "./start.exclude.ts"; -// The warning applies Go-parity ANSI styling via `legacy-colors.ts`, which -// no-ops on a real non-TTY stream but the vitest process presents its stderr -// as color-capable. Strip escapes so these assertions target the plain text -// content — matching `status.pretty.unit.test.ts`'s convention. -// eslint-disable-next-line no-control-regex -const stripAnsi = (text: string) => text.replace(/\x1b\[[0-9;]*m/gu, ""); - // Go's `ExcludableContainers()` order (`apps/cli-go/internal/start/start.go: // 1297-1303` walking `config.Images.Services()`, // `apps/cli-go/pkg/config/constants.go:60-76`), expressed as the exact diff --git a/apps/cli/src/legacy/commands/start/start.format.unit.test.ts b/apps/cli/src/legacy/commands/start/start.format.unit.test.ts index 3f104d7a11..e2fd2310d6 100644 --- a/apps/cli/src/legacy/commands/start/start.format.unit.test.ts +++ b/apps/cli/src/legacy/commands/start/start.format.unit.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; +import { stripAnsi } from "../../../../tests/helpers/ansi.ts"; import { LEGACY_START_STARTING_CONTAINERS_MESSAGE, LEGACY_START_STARTING_DATABASE_FROM_BACKUP_MESSAGE, @@ -10,13 +11,6 @@ import { legacyStartSecurityNotice, } from "./start.format.ts"; -// The formatters apply Go-parity ANSI styling via `legacy-colors.ts`, which -// no-ops on a real non-TTY stream but the vitest process presents its stderr -// as color-capable. Strip escapes so these assertions target the plain text -// content — matching `status.pretty.unit.test.ts`'s convention. -// eslint-disable-next-line no-control-regex -const stripAnsi = (text: string) => text.replace(/\x1b\[[0-9;]*m/gu, ""); - describe("legacyStartAlreadyRunningMessage", () => { it("matches Go's exact stderr line, with a single trailing newline", () => { expect(stripAnsi(legacyStartAlreadyRunningMessage())).toBe( diff --git a/apps/cli/src/legacy/commands/start/start.handler.ts b/apps/cli/src/legacy/commands/start/start.handler.ts index ad4f81c7f5..0e087161cf 100644 --- a/apps/cli/src/legacy/commands/start/start.handler.ts +++ b/apps/cli/src/legacy/commands/start/start.handler.ts @@ -153,6 +153,7 @@ import { legacyEnsureImagesCached } from "./lib/image-prepull.ts"; import { legacyWaitForHealthyServices, type LegacyHealthCheckPostgrestGateway, + type LegacyHealthCheckTimeoutError, } from "./lib/health-check.ts"; import { legacyStartInternalDbPassword, @@ -687,6 +688,17 @@ function buildKongEmailTemplateMounts( ]; } +/** + * What `--ignore-health-check` prints when it downgrades a health-check timeout + * to a warning. That decision belongs to this caller, not `lib/health-check.ts` + * (which only implements the polling contract), and it writes straight to + * stderr — bypassing the `Output.fail` renderer that would otherwise append the + * error's `suggestion` for it. + */ +function legacyHealthWarningText(error: LegacyHealthCheckTimeoutError): string { + return error.suggestion === undefined ? error.message : `${error.message}\n${error.suggestion}`; +} + export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacyStartFlags) { const output = yield* Output; const cliConfig = yield* LegacyCliConfig; @@ -2007,13 +2019,17 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta configImage: postgresImage, rootKey: values.rootKey, }); - const postgresContainerId = yield* legacyStartContainer(spawner, postgresSpec, startOpts); + yield* legacyStartContainer(spawner, postgresSpec, startOpts); // `dbHealthTimeoutSeconds` is resolved eagerly, before any Docker work — see its // definition above, alongside the other eagerly-validated config-override fields. + // Watched by container name, matching Go's `utils.DbId`. const postgresHealthResult = yield* legacyWaitForHealthyServices( spawner, - [postgresContainerId], - { timeoutSeconds: dbHealthTimeoutSeconds }, + [postgresSpec.containerName], + { + timeoutSeconds: dbHealthTimeoutSeconds, + images: new Map([[postgresSpec.containerName, postgresSpec.image]]), + }, ).pipe(Effect.result); if (Result.isFailure(postgresHealthResult)) { const error = postgresHealthResult.failure; @@ -2030,7 +2046,7 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta // falls through to the SAME unconditional tail every other path // reaches (`start.go:84-87`), not an early return from the whole // command. - yield* output.raw(`${error.message}\n`, "stderr"); + yield* output.raw(`${legacyHealthWarningText(error)}\n`, "stderr"); return { kind: "postgresUnhealthyIgnored" as const }; } return yield* Effect.fail(error); @@ -2162,7 +2178,13 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta yield* output.raw(LEGACY_START_STARTING_CONTAINERS_MESSAGE, "stderr"); } - const started: Array<string> = []; + // Go's `started` slice, as an insertion-ordered container NAME -> resolved + // image map. Go watches container names (`started = append(started, + // utils.XxxId)`, `start.go:393-1267`), not the ids `docker create` + // returns, so an unhealthy container reports as `supabase_auth_demo` + // rather than 64 hex characters. Keying the images by that same name + // keeps the watch list and its images from drifting apart. + const started = new Map<string, string>(); let postgrestGateway: LegacyHealthCheckPostgrestGateway | undefined; let edgeRuntimeGateway: LegacyHealthCheckPostgrestGateway | undefined; let storageContainerId: string | undefined; @@ -2259,7 +2281,7 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta // EdgeRuntimeContainer` already runs `cleanup` on a failed or // interrupted bring-up internally, so only the success path must // leave it alone. - started.push(runtime.containerId); + started.set(runtime.containerId, edgeRuntimeInput.image); edgeRuntimeGateway = { containerId: runtime.containerId, apiExternalUrl: values.apiUrl, @@ -2295,19 +2317,19 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta ), ), ); - const containerId = yield* legacyStartContainer(spawner, spec, startOpts); + yield* legacyStartContainer(spawner, spec, startOpts); if (excludeFromHealthWatch !== true) { - started.push(containerId); + started.set(spec.containerName, spec.image); } if (entry.service === "postgrest") { postgrestGateway = { - containerId, + containerId: spec.containerName, apiExternalUrl: values.apiUrl, secretKey: values.secretKey, }; } if (entry.service === "storage") { - storageContainerId = containerId; + storageContainerId = spec.containerName; } } @@ -2473,9 +2495,10 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta projectRef: "", config: effectiveLocalStorageConfig, }); - const healthResult = yield* legacyWaitForHealthyServices(spawner, started, { + const healthResult = yield* legacyWaitForHealthyServices(spawner, [...started.keys()], { postgrest: postgrestGateway, edgeRuntime: edgeRuntimeGateway, + images: started, }).pipe( Effect.result, localKongCa !== undefined @@ -2499,9 +2522,14 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta // the same downgrade-to-warning as every other ignored-unhealthy // failure. if (isFreshVolume && storageContainerId !== undefined) { - const storageHealthResult = yield* legacyWaitForHealthyServices(spawner, [ - storageContainerId, - ]).pipe(Effect.result); + // `images` is intentionally the whole run's registry, not scoped to + // this one-container watch list — the hint can only ever key off + // containers that actually appear in this call's own failures. + const storageHealthResult = yield* legacyWaitForHealthyServices( + spawner, + [storageContainerId], + { images: started }, + ).pipe(Effect.result); if (Result.isSuccess(storageHealthResult)) { const seedResult = yield* legacySeedBucketsRun({ projectRef: "", @@ -2521,7 +2549,7 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta } } // Downgrade to a warning and fall through to the success path, no rollback. - yield* output.raw(`${error.message}\n`, "stderr"); + yield* output.raw(`${legacyHealthWarningText(error)}\n`, "stderr"); } else { // No manual `legacyRollbackStart` here — the outer `Effect.onError` // below rolls back on this failure too. diff --git a/apps/cli/src/legacy/commands/start/start.integration.test.ts b/apps/cli/src/legacy/commands/start/start.integration.test.ts index a47cfa9e80..dfdf0f4866 100644 --- a/apps/cli/src/legacy/commands/start/start.integration.test.ts +++ b/apps/cli/src/legacy/commands/start/start.integration.test.ts @@ -181,6 +181,19 @@ function containerNameFromCreateArgs(args: ReadonlyArray<string>): string { return nameIndex !== -1 ? (args[nameIndex + 1] ?? "unknown") : "unknown"; } +/** + * Real `docker create` prints a 64-hex id, never the `--name`. The mock does + * too, so a caller that carries that opaque id into the health watch fails the + * assertions here instead of shipping unreadable output to users. + */ +function fakeContainerId(name: string): string { + return [...name] + .map((char) => (char.codePointAt(0) ?? 0).toString(16).padStart(2, "0")) + .join("") + .padEnd(64, "0") + .slice(0, 64); +} + function createdContainerNames(spawned: ReadonlyArray<SpawnRecord>): ReadonlyArray<string> { return spawned .filter((s) => s.args[0] === "create") @@ -205,7 +218,7 @@ function defaultRoute(opts: { readonly neverHealthy?: ReadonlySet<string> } = {} if (args[0] === "create") { const name = containerNameFromCreateArgs(args); created.add(name); - return { stdout: [name] }; + return { stdout: [fakeContainerId(name)] }; } if (args[0] === "start") return { exitCode: 0 }; if (args[0] === "container" && args[1] === "inspect") { @@ -3102,6 +3115,12 @@ content_path = "./templates/custom_notice.html" const name = containerNameFromCreateArgs(args); if (name.includes("_db_")) neverHealthy.add(name); } + // Postgres's own health wait builds its `images` map separately from the + // bulk one, so this scripts the marker here too rather than assuming the + // two call sites are wired the same way. + if (args[0] === "logs" && (args[1] ?? "").includes("_db_")) { + return { stdout: ["exec /usr/local/bin/docker-entrypoint.sh: exec format error\n"] }; + } return base(args); }; const { layer, out, child, analytics } = setup({ @@ -3113,6 +3132,14 @@ content_path = "./templates/custom_notice.html" expect(out.stderrText).toContain("is not ready"); expect(out.stderrText).toContain("Started"); expect(rollbackWasAttempted(child.spawned)).toBe(false); + // Reported by container name, with the recovery advice naming the image + // Postgres's own health wait resolved for it. + expect(out.stderrText).toContain("supabase_db_demo: container is not ready"); + expect(out.stderrText).toContain("supabase_db_demo's image"); + expect(out.stderrText).toContain("image rm -f public.ecr.aws/supabase/postgres:"); + // `--ignore-health-check` leaves the stack up, so a bare restart would be a + // no-op — the sequence must stop first. + expect(out.stderrText).toContain("supabase stop"); // No other service's container is ever created — Go's `StartDatabase` // returns before `run()`'s "Starting containers..." message or any other // service's bring-up even begins. @@ -3190,6 +3217,12 @@ content_path = "./templates/custom_notice.html" const name = containerNameFromCreateArgs(args); if (name.includes("_auth_")) neverHealthy.add(name); } + // The timeout path dumps this container's logs; scripting the + // `exec format error` signature into them exercises the whole + // recovery-advice wiring (name -> resolved image -> rendered hint). + if (args[0] === "logs" && (args[1] ?? "").includes("_auth_")) { + return { stdout: ["exec /usr/local/bin/auth: exec format error\n"] }; + } return route(args); }, // Sidesteps the PostgREST/Edge Runtime HTTP-HEAD readiness probes @@ -3205,6 +3238,10 @@ content_path = "./templates/custom_notice.html" expect(out.stderrText).toContain("is not ready"); expect(out.stderrText).toContain("Started"); expect(rollbackWasAttempted(child.spawned)).toBe(false); + // Reported by container name, not `docker create`'s opaque id, and the + // advice names the image actually resolved for that container. + expect(out.stderrText).toContain("supabase_auth_demo: container is not ready"); + expect(out.stderrText).toContain("docker image rm -f public.ecr.aws/supabase/gotrue:"); // Go never fires `cli_stack_started` on the ignored-unhealthy // fallthrough (`start.go:1287` sits after the `if err != nil` block) — // only a genuine bulk health-check SUCCESS reaches that capture. diff --git a/apps/cli/src/legacy/commands/start/start.live.test.ts b/apps/cli/src/legacy/commands/start/start.live.test.ts index c54aef9e62..7c7b7090bf 100644 --- a/apps/cli/src/legacy/commands/start/start.live.test.ts +++ b/apps/cli/src/legacy/commands/start/start.live.test.ts @@ -1,5 +1,5 @@ import { execFile } from "node:child_process"; -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { promisify } from "node:util"; @@ -11,7 +11,9 @@ import { legacyServiceContainerName, localDbContainerId, } from "../../shared/legacy-docker-ids.ts"; +import { legacyGetRegistryImageUrl } from "../../shared/legacy-docker-registry.ts"; import { LEGACY_SERVICE_CATALOG } from "../../shared/legacy-service-catalog.ts"; +import { dockerfileServiceImage } from "../../../shared/services/dockerfile-images.ts"; const execFileAsync = promisify(execFile); @@ -202,4 +204,63 @@ describeLive("supabase start (live)", () => { expect(status.exitCode, `stdout:\n${status.stdout}\nstderr:\n${status.stderr}`).toBe(0); }, ); + + // The health watch inspects and dumps logs by container NAME against a real + // daemon, and derives recovery advice from a real container's real log bytes. + // Neither is observable through the in-process mocks, so this reproduces + // supabase/cli#5952 for real: a locally cached image that cannot be executed. + test( + "names the container and its image when a cached image cannot be executed", + { timeout: START_TIMEOUT_MS + LIFECYCLE_OVERHEAD_MS }, + async () => { + projectDir = await mkdtemp(path.join(tmpdir(), "sb-start-live-exec-")); + const projectId = legacySanitizeProjectId(path.basename(projectDir)); + const mailpitContainer = legacyServiceContainerName("inbucket", projectId); + // The exact tag `start` resolves for Mailpit, so its already-cached check + // finds this deliberately broken build and never reaches a registry. + const mailpitImage = legacyGetRegistryImageUrl(dockerfileServiceImage("mailpit")); + + const init = await runSupabaseLive(["init"], { + cwd: projectDir, + exitTimeoutMs: SHORT_LIVE_TIMEOUT_MS, + }); + expect(init.exitCode, `stdout:\n${init.stdout}\nstderr:\n${init.stderr}`).toBe(0); + + // A `scratch` image whose entrypoint is not an executable binary — the + // kernel refuses it with exactly the "exec format error" this diagnoses. + const buildDir = path.join(projectDir, "broken-image"); + await mkdir(buildDir, { recursive: true }); + await writeFile(path.join(buildDir, "mailpit"), "not an executable\n", { mode: 0o755 }); + await writeFile( + path.join(buildDir, "Dockerfile"), + 'FROM scratch\nCOPY mailpit /mailpit\nENTRYPOINT ["/mailpit"]\n', + ); + await execFileAsync("docker", ["build", "-q", "-t", mailpitImage, buildDir]); + + try { + // Everything except Postgres and Mailpit is excluded: this scenario only + // needs one container that cannot start. + const excludeArgs = LEGACY_SERVICE_CATALOG.flatMap((entry) => + entry.excludeKey === undefined || entry.excludeKey === "mailpit" + ? [] + : ["--exclude", entry.excludeKey], + ); + const start = await runSupabaseLive(["start", ...excludeArgs], { + cwd: projectDir, + exitTimeoutMs: START_TIMEOUT_MS, + }); + + expect(start.exitCode, `stdout:\n${start.stdout}\nstderr:\n${start.stderr}`).not.toBe(0); + // Go reports `utils.InbucketId`, not the id `docker create` returns. + expect(start.stderr).toContain(`${mailpitContainer} container logs:`); + expect(start.stderr).toContain(`${mailpitContainer}: container is not ready`); + // ...and the advice names that container's actual resolved image. + expect(start.stderr).toContain(`${mailpitContainer}'s image ${mailpitImage}`); + expect(start.stderr).toContain(`image rm -f ${mailpitImage}`); + } finally { + // Never leave a poisoned tag behind for later jobs on this runner. + await execFileAsync("docker", ["image", "rm", "-f", mailpitImage]).catch(() => undefined); + } + }, + ); }); diff --git a/apps/cli/src/legacy/commands/storage/rm/rm.handler.ts b/apps/cli/src/legacy/commands/storage/rm/rm.handler.ts index aa361c58e9..5b11ee22d2 100644 --- a/apps/cli/src/legacy/commands/storage/rm/rm.handler.ts +++ b/apps/cli/src/legacy/commands/storage/rm/rm.handler.ts @@ -8,7 +8,7 @@ import { legacyResolveYesWithProjectEnv } from "../../../../shared/legacy/global import { Output } from "../../../../shared/output/output.service.ts"; import { legacyBold } from "../../../shared/legacy-colors.ts"; import { legacyLoadProjectEnv } from "../../../shared/legacy-db-config.toml-read.ts"; -import { legacyPromptYesNo } from "../../../shared/legacy-prompt-yes-no.ts"; +import { legacyPromptYesNo } from "../../../../shared/legacy/legacy-prompt-yes-no.ts"; import { LEGACY_DELETE_OBJECTS_LIMIT, type LegacyStorageGateway, diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/activate/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/vanity-subdomains/activate/SIDE_EFFECTS.md index aace0d1a0b..9ad8fa4b34 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/activate/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/vanity-subdomains/activate/SIDE_EFFECTS.md @@ -9,10 +9,10 @@ ## Files Written -| Path | Format | When | -| ------------------------------------------------ | ------ | ----------------------------------------------------- | -| `~/.supabase/<workdir-hash>/linked-project.json` | JSON | after ref resolution, on success and failure | -| `~/.supabase/telemetry.json` | JSON | always, via `Effect.ensuring`, on success and failure | +| Path | Format | When | +| ------------------------------------------------ | ------ | ---------------------------------------------------------------------------------------------------------------------- | +| `~/.supabase/<workdir-hash>/linked-project.json` | JSON | once the `--experimental` gate is open, after ref resolution, via `Effect.ensuring` — on success and failure | +| `~/.supabase/telemetry.json` | JSON | once the `--experimental` gate is open, via `Effect.ensuring` — on success and failure. Not written if gate is closed. | ## API Routes @@ -22,27 +22,30 @@ ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | ---------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring then `~/.supabase/access-token`) | -| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`) | +| Variable | Purpose | Required? | +| ----------------------- | -------------------------------------------------------- | -------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring then `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`) | +| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | ## Exit Codes -| Code | Condition | -| ---- | --------------------------------------------------------------------------------------- | -| `0` | success | -| `1` | project ref unresolved (`LegacyProjectNotLinkedError` / `LegacyInvalidProjectRefError`) | -| `1` | API non-2xx (`LegacyVanitySubdomainsActivateUnexpectedStatusError`) | -| `1` | transport failure (`LegacyVanitySubdomainsActivateNetworkError`) | +| Code | Condition | +| ---- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success | +| `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`LegacyExperimentalRequiredError`) — checked before ref resolution/API/telemetry | +| `1` | project ref unresolved (`LegacyProjectNotLinkedError` / `LegacyInvalidProjectRefError`) | +| `1` | `--desired-subdomain` omitted (`LegacyDesiredSubdomainRequiredError`) — checked after gate/login/ref resolution; telemetry still fires | +| `1` | API non-2xx (`LegacyVanitySubdomainsActivateUnexpectedStatusError`) | +| `1` | transport failure (`LegacyVanitySubdomainsActivateNetworkError`) | ## Telemetry Events Fired -| Event | When | Notable properties / groups | -| ----------------------- | ------------------------------------------ | ------------------------------------------ | -| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` | -| `cli_upgrade_suggested` | gated 4xx responses only | `feature_key=vanity_subdomain`, `org_slug` | +| Event | When | Notable properties / groups | +| ----------------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------ | +| `cli_command_executed` | post-run, success or failure (via wrapper); not fired when the `--experimental` gate is closed | `exit_code`, `duration_ms`, `flags` | +| `cli_upgrade_suggested` | gated 4xx responses only | `feature_key=vanity_subdomain`, `org_slug` | ## Output @@ -69,5 +72,15 @@ One `result` event with the full response object. ## Notes - The legacy `--output` flag wins over TS `--output-format` when both are provided. -- `linked-project.json` is written after ref resolution, even when the API call fails. +- `linked-project.json` is written after ref resolution (once the `--experimental` gate is open), + even when the API call fails. A closed gate writes nothing (Go's `PersistentPreRunE` fails + before `PersistentPostRun` runs). - On gated 4xx responses this command prints an upgrade suggestion and fires `cli_upgrade_suggested`. +- `--desired-subdomain` is required in Go (`cmd/vanitySubdomains.go:67`) but cobra validates + required flags only after `PersistentPreRunE` (gate → login → ref resolution), so the TS flag + is optional at parse time and enforced in the handler with cobra's exact wording + (`required flag(s) "desired-subdomain" not set`). The gate/login/ref errors win over the + missing flag, matching Go's ordering, and — as in Go, where `PersistentPostRun` still runs — + telemetry and `linked-project.json` are written on this failure. An explicit empty value + (`--desired-subdomain ""`) passes the check and reaches the API, as cobra only requires the + flag to be set. diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/activate/activate.command.ts b/apps/cli/src/legacy/commands/vanity-subdomains/activate/activate.command.ts index c17e24138c..2824618a25 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/activate/activate.command.ts +++ b/apps/cli/src/legacy/commands/vanity-subdomains/activate/activate.command.ts @@ -1,9 +1,15 @@ +import { Effect } from "effect"; import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { legacyRequireExperimental } from "../../../shared/legacy-experimental-gate.ts"; +import { LEGACY_RESOURCE_OUTPUT_FORMATS } from "../../../shared/legacy-go-output-flag.ts"; import { legacyManagementApiRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts"; -import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; +import { + legacyValidateOutputFormat, + withLegacyCommandInstrumentation, +} from "../../../telemetry/legacy-command-instrumentation.ts"; import { legacyVanitySubdomainsActivate } from "./activate.handler.ts"; const config = { @@ -11,8 +17,15 @@ const config = { Flag.withDescription("Project ref of the Supabase project."), Flag.optional, ), + // Go marks this flag required (`cmd/vanitySubdomains.go:67`), but cobra + // validates required flags only AFTER `PersistentPreRunE` + // (`cobra@v1.10.2/command.go:985,1005`) — so the `--experimental` gate, + // login check, and project-ref resolution must all win over a missing + // `--desired-subdomain`. Optional at parse time; presence is enforced in + // the handler (after ref resolution) instead. desiredSubdomain: Flag.string("desired-subdomain").pipe( Flag.withDescription("The desired vanity subdomain to use for your Supabase project."), + Flag.optional, ), } as const; @@ -24,10 +37,23 @@ export const legacyVanitySubdomainsActivateCommand = Command.make("activate", co ), Command.withShortDescription("Activate a vanity subdomain"), Command.withHandler((flags) => - legacyVanitySubdomainsActivate(flags).pipe( - withLegacyCommandInstrumentation({ flags }), - withJsonErrorHandling, - ), + Effect.gen(function* () { + // Cobra parses flags — rejecting an out-of-enum `-o` (`internal/utils/enum.go:21-27`) + // — before `PersistentPreRunE` ever runs (`cobra@v1.10.2/command.go:919,985`), so an + // invalid `-o` value must win over a missing `--experimental` flag. + yield* legacyValidateOutputFormat(LEGACY_RESOURCE_OUTPUT_FORMATS); + // Go gates `vanityCmd` (vanity-subdomains) behind `--experimental` in PersistentPreRunE + // (root.go:91-96) BEFORE the `IsManagementAPI` login check (root.go:105-109). + // `legacyManagementApiRuntimeLayer` eagerly resolves an access token as part + // of building its `LegacyPlatformApi` layer, so it must be provided AFTER + // the gate (inline here) rather than via `Command.provide` on the whole + // command — `Command.provide` would build the layer, and fail on a missing + // token, before this generator's first `yield*` ever runs. + yield* legacyRequireExperimental; + return yield* legacyVanitySubdomainsActivate(flags).pipe( + withLegacyCommandInstrumentation({ flags }), + Effect.provide(legacyManagementApiRuntimeLayer(["vanity-subdomains", "activate"])), + ); + }).pipe(withJsonErrorHandling), ), - Command.provide(legacyManagementApiRuntimeLayer(["vanity-subdomains", "activate"])), ); diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/activate/activate.handler.ts b/apps/cli/src/legacy/commands/vanity-subdomains/activate/activate.handler.ts index 27877ce2c5..2d8a04f6b6 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/activate/activate.handler.ts +++ b/apps/cli/src/legacy/commands/vanity-subdomains/activate/activate.handler.ts @@ -2,7 +2,10 @@ import { Effect, Option } from "effect"; import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; -import { legacySuggestUpgrade } from "../../../shared/legacy-upgrade-suggest.ts"; +import { + legacyGateResponse, + legacySuggestUpgrade, +} from "../../../shared/legacy-upgrade-suggest.ts"; import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; import { LegacyOutputFlag } from "../../../../shared/legacy/global-flags.ts"; @@ -15,6 +18,7 @@ import { } from "../../../shared/legacy-go-output.encoders.ts"; import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; import { + LegacyDesiredSubdomainRequiredError, LegacyVanitySubdomainsActivateNetworkError, LegacyVanitySubdomainsActivateUnexpectedStatusError, } from "../vanity-subdomains.errors.ts"; @@ -40,6 +44,21 @@ export const legacyVanitySubdomainsActivate = Effect.fn("legacy.vanity-subdomain const ref = yield* resolver.resolve(flags.projectRef); yield* Effect.gen(function* () { + // Go validates the required `--desired-subdomain` only after + // `PersistentPreRunE` completes (gate → login → ref resolution, + // `cmd/root.go:93-117`; `cobra@v1.10.2/command.go:985,1005`), and + // `PersistentPostRun` still fires telemetry + the linked-project cache + // on that failure — hence this check sits inside both `Effect.ensuring` + // wrappers, after ref resolution. Cobra checks the flag was *changed*, + // not non-empty, so `--desired-subdomain ""` passes and reaches the API. + if (Option.isNone(flags.desiredSubdomain)) { + return yield* Effect.fail( + new LegacyDesiredSubdomainRequiredError({ + message: `required flag(s) "desired-subdomain" not set`, + }), + ); + } + const desiredSubdomain = flags.desiredSubdomain.value; const activating = output.format === "text" ? yield* output.task("Activating vanity subdomain...") @@ -47,7 +66,7 @@ export const legacyVanitySubdomainsActivate = Effect.fn("legacy.vanity-subdomain const response = yield* api.v1 .activateVanitySubdomainConfig({ ref, - vanity_subdomain: flags.desiredSubdomain, + vanity_subdomain: desiredSubdomain, }) .pipe( Effect.tapError(() => activating?.fail() ?? Effect.void), @@ -61,6 +80,7 @@ export const legacyVanitySubdomainsActivate = Effect.fn("legacy.vanity-subdomain projectRef: ref, featureKey: "vanity_subdomain", statusCode: mapped.status, + response: legacyGateResponse(cause), }); } return yield* Effect.fail(mapped); diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/SIDE_EFFECTS.md index 9fc14225dc..180330a9c5 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/SIDE_EFFECTS.md @@ -9,10 +9,10 @@ ## Files Written -| Path | Format | When | -| ------------------------------------------------ | ------ | ----------------------------------------------------- | -| `~/.supabase/<workdir-hash>/linked-project.json` | JSON | after ref resolution, on success and failure | -| `~/.supabase/telemetry.json` | JSON | always, via `Effect.ensuring`, on success and failure | +| Path | Format | When | +| ------------------------------------------------ | ------ | ---------------------------------------------------------------------------------------------------------------------- | +| `~/.supabase/<workdir-hash>/linked-project.json` | JSON | once the `--experimental` gate is open, after ref resolution, via `Effect.ensuring` — on success and failure | +| `~/.supabase/telemetry.json` | JSON | once the `--experimental` gate is open, via `Effect.ensuring` — on success and failure. Not written if gate is closed. | ## API Routes @@ -22,26 +22,29 @@ ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | ---------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring then `~/.supabase/access-token`) | -| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`) | +| Variable | Purpose | Required? | +| ----------------------- | -------------------------------------------------------- | -------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring then `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`) | +| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | ## Exit Codes -| Code | Condition | -| ---- | --------------------------------------------------------------------------------------- | -| `0` | success | -| `1` | project ref unresolved (`LegacyProjectNotLinkedError` / `LegacyInvalidProjectRefError`) | -| `1` | API non-2xx (`LegacyVanitySubdomainsCheckUnexpectedStatusError`) | -| `1` | transport failure (`LegacyVanitySubdomainsCheckNetworkError`) | +| Code | Condition | +| ---- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success | +| `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`LegacyExperimentalRequiredError`) — checked before ref resolution/API/telemetry | +| `1` | project ref unresolved (`LegacyProjectNotLinkedError` / `LegacyInvalidProjectRefError`) | +| `1` | `--desired-subdomain` omitted (`LegacyDesiredSubdomainRequiredError`) — checked after gate/login/ref resolution; telemetry still fires | +| `1` | API non-2xx (`LegacyVanitySubdomainsCheckUnexpectedStatusError`) | +| `1` | transport failure (`LegacyVanitySubdomainsCheckNetworkError`) | ## Telemetry Events Fired -| Event | When | Notable properties / groups | -| ---------------------- | ------------------------------------------ | ----------------------------------- | -| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` | +| Event | When | Notable properties / groups | +| ---------------------- | ---------------------------------------------------------------------------------------------- | ----------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper); not fired when the `--experimental` gate is closed | `exit_code`, `duration_ms`, `flags` | This command may print an upgrade suggestion for gated 4xx responses, but it does not fire `cli_upgrade_suggested`. @@ -71,4 +74,14 @@ One `result` event with the full response object. ## Notes - The legacy `--output` flag wins over TS `--output-format` when both are provided. -- `linked-project.json` is written after ref resolution, even when the API call fails. +- `linked-project.json` is written after ref resolution (once the `--experimental` gate is open), + even when the API call fails. A closed gate writes nothing (Go's `PersistentPreRunE` fails + before `PersistentPostRun` runs). +- `--desired-subdomain` is required in Go (`cmd/vanitySubdomains.go:69`) but cobra validates + required flags only after `PersistentPreRunE` (gate → login → ref resolution), so the TS flag + is optional at parse time and enforced in the handler with cobra's exact wording + (`required flag(s) "desired-subdomain" not set`). The gate/login/ref errors win over the + missing flag, matching Go's ordering, and — as in Go, where `PersistentPostRun` still runs — + telemetry and `linked-project.json` are written on this failure. An explicit empty value + (`--desired-subdomain ""`) passes the check and reaches the API, as cobra only requires the + flag to be set. diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/check-availability.command.ts b/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/check-availability.command.ts index 934d6f92e3..a015585fab 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/check-availability.command.ts +++ b/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/check-availability.command.ts @@ -1,9 +1,15 @@ +import { Effect } from "effect"; import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { legacyRequireExperimental } from "../../../shared/legacy-experimental-gate.ts"; +import { LEGACY_RESOURCE_OUTPUT_FORMATS } from "../../../shared/legacy-go-output-flag.ts"; import { legacyManagementApiRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts"; -import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; +import { + legacyValidateOutputFormat, + withLegacyCommandInstrumentation, +} from "../../../telemetry/legacy-command-instrumentation.ts"; import { legacyVanitySubdomainsCheckAvailability } from "./check-availability.handler.ts"; const config = { @@ -11,8 +17,15 @@ const config = { Flag.withDescription("Project ref of the Supabase project."), Flag.optional, ), + // Go marks this flag required (`cmd/vanitySubdomains.go:69`), but cobra + // validates required flags only AFTER `PersistentPreRunE` + // (`cobra@v1.10.2/command.go:985,1005`) — so the `--experimental` gate, + // login check, and project-ref resolution must all win over a missing + // `--desired-subdomain`. Optional at parse time; presence is enforced in + // the handler (after ref resolution) instead. desiredSubdomain: Flag.string("desired-subdomain").pipe( Flag.withDescription("The desired vanity subdomain to use for your Supabase project."), + Flag.optional, ), } as const; @@ -27,10 +40,25 @@ export const legacyVanitySubdomainsCheckAvailabilityCommand = Command.make( Command.withDescription("Checks if a desired subdomain is available for use."), Command.withShortDescription("Check subdomain availability"), Command.withHandler((flags) => - legacyVanitySubdomainsCheckAvailability(flags).pipe( - withLegacyCommandInstrumentation({ flags }), - withJsonErrorHandling, - ), + Effect.gen(function* () { + // Cobra parses flags — rejecting an out-of-enum `-o` (`internal/utils/enum.go:21-27`) + // — before `PersistentPreRunE` ever runs (`cobra@v1.10.2/command.go:919,985`), so an + // invalid `-o` value must win over a missing `--experimental` flag. + yield* legacyValidateOutputFormat(LEGACY_RESOURCE_OUTPUT_FORMATS); + // Go gates `vanityCmd` (vanity-subdomains) behind `--experimental` in PersistentPreRunE + // (root.go:91-96) BEFORE the `IsManagementAPI` login check (root.go:105-109). + // `legacyManagementApiRuntimeLayer` eagerly resolves an access token as part + // of building its `LegacyPlatformApi` layer, so it must be provided AFTER + // the gate (inline here) rather than via `Command.provide` on the whole + // command — `Command.provide` would build the layer, and fail on a missing + // token, before this generator's first `yield*` ever runs. + yield* legacyRequireExperimental; + return yield* legacyVanitySubdomainsCheckAvailability(flags).pipe( + withLegacyCommandInstrumentation({ flags }), + Effect.provide( + legacyManagementApiRuntimeLayer(["vanity-subdomains", "check-availability"]), + ), + ); + }).pipe(withJsonErrorHandling), ), - Command.provide(legacyManagementApiRuntimeLayer(["vanity-subdomains", "check-availability"])), ); diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/check-availability.handler.ts b/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/check-availability.handler.ts index d17836e693..01271d408b 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/check-availability.handler.ts +++ b/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/check-availability.handler.ts @@ -2,7 +2,10 @@ import { Effect, Option } from "effect"; import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; -import { legacySuggestUpgrade } from "../../../shared/legacy-upgrade-suggest.ts"; +import { + legacyGateResponse, + legacySuggestUpgrade, +} from "../../../shared/legacy-upgrade-suggest.ts"; import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; import { LegacyOutputFlag } from "../../../../shared/legacy/global-flags.ts"; @@ -15,6 +18,7 @@ import { } from "../../../shared/legacy-go-output.encoders.ts"; import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; import { + LegacyDesiredSubdomainRequiredError, LegacyVanitySubdomainsCheckNetworkError, LegacyVanitySubdomainsCheckUnexpectedStatusError, } from "../vanity-subdomains.errors.ts"; @@ -41,6 +45,21 @@ export const legacyVanitySubdomainsCheckAvailability = Effect.fn( const ref = yield* resolver.resolve(flags.projectRef); yield* Effect.gen(function* () { + // Go validates the required `--desired-subdomain` only after + // `PersistentPreRunE` completes (gate → login → ref resolution, + // `cmd/root.go:93-117`; `cobra@v1.10.2/command.go:985,1005`), and + // `PersistentPostRun` still fires telemetry + the linked-project cache + // on that failure — hence this check sits inside both `Effect.ensuring` + // wrappers, after ref resolution. Cobra checks the flag was *changed*, + // not non-empty, so `--desired-subdomain ""` passes and reaches the API. + if (Option.isNone(flags.desiredSubdomain)) { + return yield* Effect.fail( + new LegacyDesiredSubdomainRequiredError({ + message: `required flag(s) "desired-subdomain" not set`, + }), + ); + } + const desiredSubdomain = flags.desiredSubdomain.value; const checking = output.format === "text" ? yield* output.task("Checking vanity subdomain availability...") @@ -48,7 +67,7 @@ export const legacyVanitySubdomainsCheckAvailability = Effect.fn( const response = yield* api.v1 .checkVanitySubdomainAvailability({ ref, - vanity_subdomain: flags.desiredSubdomain, + vanity_subdomain: desiredSubdomain, }) .pipe( Effect.tapError(() => checking?.fail() ?? Effect.void), @@ -64,6 +83,7 @@ export const legacyVanitySubdomainsCheckAvailability = Effect.fn( projectRef: ref, featureKey: "vanity_subdomain", statusCode: mapped.status, + response: legacyGateResponse(cause), trackAnalytics: false, }); } @@ -97,7 +117,7 @@ export const legacyVanitySubdomainsCheckAvailability = Effect.fn( return; } - yield* output.raw(`Subdomain ${flags.desiredSubdomain} available: ${response.available}\n`); + yield* output.raw(`Subdomain ${desiredSubdomain} available: ${response.available}\n`); }).pipe(Effect.ensuring(linkedProjectCache.cache(ref))); }).pipe(Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/delete/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/vanity-subdomains/delete/SIDE_EFFECTS.md index 5e67410233..0e9d2f1708 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/vanity-subdomains/delete/SIDE_EFFECTS.md @@ -9,10 +9,10 @@ ## Files Written -| Path | Format | When | -| ------------------------------------------------ | ------ | ----------------------------------------------------- | -| `~/.supabase/<workdir-hash>/linked-project.json` | JSON | after ref resolution, on success and failure | -| `~/.supabase/telemetry.json` | JSON | always, via `Effect.ensuring`, on success and failure | +| Path | Format | When | +| ------------------------------------------------ | ------ | ---------------------------------------------------------------------------------------------------------------------- | +| `~/.supabase/<workdir-hash>/linked-project.json` | JSON | once the `--experimental` gate is open, after ref resolution, via `Effect.ensuring` — on success and failure | +| `~/.supabase/telemetry.json` | JSON | once the `--experimental` gate is open, via `Effect.ensuring` — on success and failure. Not written if gate is closed. | ## API Routes @@ -22,26 +22,28 @@ ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | ---------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring then `~/.supabase/access-token`) | -| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`) | +| Variable | Purpose | Required? | +| ----------------------- | -------------------------------------------------------- | -------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring then `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`) | +| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | ## Exit Codes -| Code | Condition | -| ---- | --------------------------------------------------------------------------------------- | -| `0` | success | -| `1` | project ref unresolved (`LegacyProjectNotLinkedError` / `LegacyInvalidProjectRefError`) | -| `1` | API non-2xx (`LegacyVanitySubdomainsDeleteUnexpectedStatusError`) | -| `1` | transport failure (`LegacyVanitySubdomainsDeleteNetworkError`) | +| Code | Condition | +| ---- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success | +| `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`LegacyExperimentalRequiredError`) — checked before ref resolution/API/telemetry | +| `1` | project ref unresolved (`LegacyProjectNotLinkedError` / `LegacyInvalidProjectRefError`) | +| `1` | API non-2xx (`LegacyVanitySubdomainsDeleteUnexpectedStatusError`) | +| `1` | transport failure (`LegacyVanitySubdomainsDeleteNetworkError`) | ## Telemetry Events Fired -| Event | When | Notable properties / groups | -| ---------------------- | ------------------------------------------ | ----------------------------------- | -| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` | +| Event | When | Notable properties / groups | +| ---------------------- | ---------------------------------------------------------------------------------------------- | ----------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper); not fired when the `--experimental` gate is closed | `exit_code`, `duration_ms`, `flags` | ## Output @@ -68,4 +70,6 @@ One `result` event when the legacy `--output` flag is unset. ## Notes - The legacy `--output` flag wins over TS `--output-format` when both are provided. -- `linked-project.json` is written after ref resolution, even when the API call fails. +- `linked-project.json` is written after ref resolution (once the `--experimental` gate is open), + even when the API call fails. A closed gate writes nothing (Go's `PersistentPreRunE` fails + before `PersistentPostRun` runs). diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/delete/delete.command.ts b/apps/cli/src/legacy/commands/vanity-subdomains/delete/delete.command.ts index 2a6200c66e..10a798500f 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/delete/delete.command.ts +++ b/apps/cli/src/legacy/commands/vanity-subdomains/delete/delete.command.ts @@ -1,9 +1,15 @@ +import { Effect } from "effect"; import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { legacyRequireExperimental } from "../../../shared/legacy-experimental-gate.ts"; +import { LEGACY_RESOURCE_OUTPUT_FORMATS } from "../../../shared/legacy-go-output-flag.ts"; import { legacyManagementApiRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts"; -import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; +import { + legacyValidateOutputFormat, + withLegacyCommandInstrumentation, +} from "../../../telemetry/legacy-command-instrumentation.ts"; import { legacyVanitySubdomainsDelete } from "./delete.handler.ts"; const config = { @@ -21,10 +27,23 @@ export const legacyVanitySubdomainsDeleteCommand = Command.make("delete", config ), Command.withShortDescription("Delete the vanity subdomain"), Command.withHandler((flags) => - legacyVanitySubdomainsDelete(flags).pipe( - withLegacyCommandInstrumentation({ flags }), - withJsonErrorHandling, - ), + Effect.gen(function* () { + // Cobra parses flags — rejecting an out-of-enum `-o` (`internal/utils/enum.go:21-27`) + // — before `PersistentPreRunE` ever runs (`cobra@v1.10.2/command.go:919,985`), so an + // invalid `-o` value must win over a missing `--experimental` flag. + yield* legacyValidateOutputFormat(LEGACY_RESOURCE_OUTPUT_FORMATS); + // Go gates `vanityCmd` (vanity-subdomains) behind `--experimental` in PersistentPreRunE + // (root.go:91-96) BEFORE the `IsManagementAPI` login check (root.go:105-109). + // `legacyManagementApiRuntimeLayer` eagerly resolves an access token as part + // of building its `LegacyPlatformApi` layer, so it must be provided AFTER + // the gate (inline here) rather than via `Command.provide` on the whole + // command — `Command.provide` would build the layer, and fail on a missing + // token, before this generator's first `yield*` ever runs. + yield* legacyRequireExperimental; + return yield* legacyVanitySubdomainsDelete(flags).pipe( + withLegacyCommandInstrumentation({ flags }), + Effect.provide(legacyManagementApiRuntimeLayer(["vanity-subdomains", "delete"])), + ); + }).pipe(withJsonErrorHandling), ), - Command.provide(legacyManagementApiRuntimeLayer(["vanity-subdomains", "delete"])), ); diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/get/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/vanity-subdomains/get/SIDE_EFFECTS.md index 73b367ec63..fd58ea0e63 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/get/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/vanity-subdomains/get/SIDE_EFFECTS.md @@ -9,10 +9,10 @@ ## Files Written -| Path | Format | When | -| ------------------------------------------------ | ------ | ----------------------------------------------------- | -| `~/.supabase/<workdir-hash>/linked-project.json` | JSON | after ref resolution, on success and failure | -| `~/.supabase/telemetry.json` | JSON | always, via `Effect.ensuring`, on success and failure | +| Path | Format | When | +| ------------------------------------------------ | ------ | ---------------------------------------------------------------------------------------------------------------------- | +| `~/.supabase/<workdir-hash>/linked-project.json` | JSON | once the `--experimental` gate is open, after ref resolution, via `Effect.ensuring` — on success and failure | +| `~/.supabase/telemetry.json` | JSON | once the `--experimental` gate is open, via `Effect.ensuring` — on success and failure. Not written if gate is closed. | ## API Routes @@ -22,26 +22,29 @@ ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | ---------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring then `~/.supabase/access-token`) | -| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`) | +| Variable | Purpose | Required? | +| ----------------------- | -------------------------------------------------------- | -------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring then `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`) | +| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | ## Exit Codes -| Code | Condition | -| ---- | --------------------------------------------------------------------------------------- | -| `0` | success | -| `1` | project ref unresolved (`LegacyProjectNotLinkedError` / `LegacyInvalidProjectRefError`) | -| `1` | API non-2xx (`LegacyVanitySubdomainsGetUnexpectedStatusError`) | -| `1` | transport failure (`LegacyVanitySubdomainsGetNetworkError`) | +| Code | Condition | +| ---- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success | +| `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`LegacyExperimentalRequiredError`) — checked before ref resolution/API/telemetry | +| `1` | project ref unresolved (`LegacyProjectNotLinkedError` / `LegacyInvalidProjectRefError`) | +| `1` | API non-2xx (`LegacyVanitySubdomainsGetUnexpectedStatusError`) | +| `1` | transport failure (`LegacyVanitySubdomainsGetNetworkError`) | ## Telemetry Events Fired -| Event | When | Notable properties / groups | -| ---------------------- | ------------------------------------------ | ----------------------------------- | -| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` | +| Event | When | Notable properties / groups | +| ----------------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| `cli_command_executed` | post-run, success or failure (via wrapper); not fired when the `--experimental` gate is closed | `exit_code`, `duration_ms`, `flags` | +| `cli_upgrade_suggested` | 4xx carrying the `entitlement_required` envelope (gate open, so the request was made) | `feature_key` (from the envelope), `org_slug` (parsed from `upgrade_url`); envelope-only, no entitlements fallback | ## Output @@ -71,4 +74,6 @@ One `result` event with the full response object. ## Notes - The legacy `--output` flag wins over TS `--output-format` when both are provided. -- `linked-project.json` is written after ref resolution, even when the API call fails. +- `linked-project.json` is written after ref resolution (once the `--experimental` gate is open), + even when the API call fails. A closed gate writes nothing (Go's `PersistentPreRunE` fails + before `PersistentPostRun` runs). diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/get/get.command.ts b/apps/cli/src/legacy/commands/vanity-subdomains/get/get.command.ts index 76c8e87f50..9dd58b19f3 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/get/get.command.ts +++ b/apps/cli/src/legacy/commands/vanity-subdomains/get/get.command.ts @@ -1,9 +1,15 @@ +import { Effect } from "effect"; import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { legacyRequireExperimental } from "../../../shared/legacy-experimental-gate.ts"; +import { LEGACY_RESOURCE_OUTPUT_FORMATS } from "../../../shared/legacy-go-output-flag.ts"; import { legacyManagementApiRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts"; -import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; +import { + legacyValidateOutputFormat, + withLegacyCommandInstrumentation, +} from "../../../telemetry/legacy-command-instrumentation.ts"; import { legacyVanitySubdomainsGet } from "./get.handler.ts"; const config = { @@ -19,10 +25,23 @@ export const legacyVanitySubdomainsGetCommand = Command.make("get", config).pipe Command.withDescription("Get the current vanity subdomain."), Command.withShortDescription("Get the current vanity subdomain"), Command.withHandler((flags) => - legacyVanitySubdomainsGet(flags).pipe( - withLegacyCommandInstrumentation({ flags }), - withJsonErrorHandling, - ), + Effect.gen(function* () { + // Cobra parses flags — rejecting an out-of-enum `-o` (`internal/utils/enum.go:21-27`) + // — before `PersistentPreRunE` ever runs (`cobra@v1.10.2/command.go:919,985`), so an + // invalid `-o` value must win over a missing `--experimental` flag. + yield* legacyValidateOutputFormat(LEGACY_RESOURCE_OUTPUT_FORMATS); + // Go gates `vanityCmd` (vanity-subdomains) behind `--experimental` in PersistentPreRunE + // (root.go:91-96) BEFORE the `IsManagementAPI` login check (root.go:105-109). + // `legacyManagementApiRuntimeLayer` eagerly resolves an access token as part + // of building its `LegacyPlatformApi` layer, so it must be provided AFTER + // the gate (inline here) rather than via `Command.provide` on the whole + // command — `Command.provide` would build the layer, and fail on a missing + // token, before this generator's first `yield*` ever runs. + yield* legacyRequireExperimental; + return yield* legacyVanitySubdomainsGet(flags).pipe( + withLegacyCommandInstrumentation({ flags }), + Effect.provide(legacyManagementApiRuntimeLayer(["vanity-subdomains", "get"])), + ); + }).pipe(withJsonErrorHandling), ), - Command.provide(legacyManagementApiRuntimeLayer(["vanity-subdomains", "get"])), ); diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/get/get.handler.ts b/apps/cli/src/legacy/commands/vanity-subdomains/get/get.handler.ts index 47cfc2908e..192725f606 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/get/get.handler.ts +++ b/apps/cli/src/legacy/commands/vanity-subdomains/get/get.handler.ts @@ -13,6 +13,7 @@ import { encodeYaml, } from "../../../shared/legacy-go-output.encoders.ts"; import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; +import { legacyGateMapError } from "../../../shared/legacy-upgrade-suggest.ts"; import { LegacyVanitySubdomainsGetNetworkError, LegacyVanitySubdomainsGetUnexpectedStatusError, @@ -44,7 +45,7 @@ export const legacyVanitySubdomainsGet = Effect.fn("legacy.vanity-subdomains.get output.format === "text" ? yield* output.task("Getting vanity subdomain...") : undefined; const response = yield* api.v1.getVanitySubdomainConfig({ ref }).pipe( Effect.tapError(() => fetching?.fail() ?? Effect.void), - Effect.catch(mapGetError), + Effect.catch(legacyGateMapError({ projectRef: ref }, mapGetError)), ); yield* fetching?.clear() ?? Effect.void; diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/vanity-subdomains.errors.ts b/apps/cli/src/legacy/commands/vanity-subdomains/vanity-subdomains.errors.ts index aa849f89ef..7a06a00976 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/vanity-subdomains.errors.ts +++ b/apps/cli/src/legacy/commands/vanity-subdomains/vanity-subdomains.errors.ts @@ -1,5 +1,21 @@ import { Data } from "effect"; +/** + * Raised by the `activate` and `check-availability` handlers when + * `--desired-subdomain` is omitted. Go marks the flag required + * (`cmd/vanitySubdomains.go:67,69`) but cobra validates required flags only + * AFTER `PersistentPreRunE` (`cobra@v1.10.2/command.go:985,1005`) — i.e. after + * the `--experimental` gate, login check, and project-ref resolution + * (`cmd/root.go:93-117`) — so the flag is optional at parse time and enforced + * in the handler instead. Byte-matches cobra's required-flag wording + * (`command.go:1198`), same pattern as `LegacyProjectRefRequiredError`. + */ +export class LegacyDesiredSubdomainRequiredError extends Data.TaggedError( + "LegacyDesiredSubdomainRequiredError", +)<{ + readonly message: string; +}> {} + export class LegacyVanitySubdomainsGetNetworkError extends Data.TaggedError( "LegacyVanitySubdomainsGetNetworkError", )<{ diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/vanity-subdomains.experimental-gate.integration.test.ts b/apps/cli/src/legacy/commands/vanity-subdomains/vanity-subdomains.experimental-gate.integration.test.ts new file mode 100644 index 0000000000..f87a5c8ebc --- /dev/null +++ b/apps/cli/src/legacy/commands/vanity-subdomains/vanity-subdomains.experimental-gate.integration.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, Layer } from "effect"; +import { CliOutput, Command } from "effect/unstable/cli"; + +import { textCliOutputFormatter } from "../../../shared/output/text-formatter.ts"; +import { LEGACY_GLOBAL_FLAGS } from "../../../shared/legacy/global-flags.ts"; +import { TelemetryRuntime } from "../../../shared/telemetry/runtime.service.ts"; +import { makeTelemetryIdentity } from "../../../shared/telemetry/identity.ts"; +import { mockOutput, mockRuntimeInfo, processEnvLayer } from "../../../../tests/helpers/mocks.ts"; +import { + buildLegacyTestRuntime, + mockLegacyCliConfig, + mockLegacyPlatformApi, + useLegacyTempWorkdir, +} from "../../../../tests/helpers/legacy-mocks.ts"; +import { legacyVanitySubdomainsCommand } from "./vanity-subdomains.command.ts"; + +// See postgres-config.experimental-gate.integration.test.ts for the full +// rationale: this proves `--experimental` is wired into the actual +// `.command.ts` handler pipeline AND runs before +// `legacyManagementApiRuntimeLayer`'s eager access-token resolution +// (Go's `IsExperimental` check precedes `IsManagementAPI` in +// `apps/cli-go/cmd/root.go:91-109`). + +const tempRoot = useLegacyTempWorkdir("supabase-vanity-subdomains-experimental-int-"); + +const testRoot = Command.make("supabase").pipe( + Command.withGlobalFlags(LEGACY_GLOBAL_FLAGS), + Command.withSubcommands([legacyVanitySubdomainsCommand]), +); + +function setup() { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ + response: { status: 200, body: { status: "not-used" } }, + }); + const runtime = buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + // `RuntimeInfo` is ambient (not provided by `legacyManagementApiRuntimeLayer` + // itself), so the real `legacyCredentialsLayer` built inline inside the + // command for the "gate open" case resolves ITS `RuntimeInfo` from this + // layer. Point homeDir at this test's isolated tempRoot so the layer's + // file-based token fallback (`<homeDir>/.supabase/access-token`) can't pick + // up a stray token left at the shared default `/tmp/supabase-cli-test-home`. + runtimeInfo: mockRuntimeInfo({ homeDir: tempRoot.current }), + }); + const layer = Layer.mergeAll( + runtime, + CliOutput.layer(textCliOutputFormatter()), + // The "gate open" case reaches the real `legacyManagementApiRuntimeLayer` + // (provided inline inside the command, not by this test's mocked runtime), + // which reads credentials/env directly — an ambient SUPABASE_ACCESS_TOKEN, + // SUPABASE_EXPERIMENTAL, or OS keyring entry on the machine running the + // test would make these assertions non-deterministic. Wipe process.env + // down to just this and disable the keyring fallback. + processEnvLayer({ SUPABASE_NO_KEYRING: "1" }), + Layer.succeed( + TelemetryRuntime, + TelemetryRuntime.of({ + configDir: `${tempRoot.current}/.supabase`, + tracesDir: `${tempRoot.current}/.supabase/traces`, + consent: "granted", + showDebug: false, + deviceId: "test-device-id", + sessionId: "test-session-id", + identity: makeTelemetryIdentity(undefined), + isFirstRun: false, + isTty: false, + isCi: false, + os: "linux", + arch: "x64", + cliVersion: "0.1.0", + }), + ), + ); + return { layer, api }; +} + +describe("legacy vanity-subdomains experimental gate (Go PersistentPreRunE parity)", () => { + // `check-availability` and `activate` deliberately OMIT `--desired-subdomain`: + // Go marks it required (`cmd/vanitySubdomains.go:67,69`) but cobra validates + // required flags only after `PersistentPreRunE` (`cobra@v1.10.2 + // command.go:985,1005`), so the gate error must win when both flags are + // missing. The TS flag is optional at parse time (enforced in the handler) + // precisely so this ordering holds — these cases assert it end to end. + const leaves: ReadonlyArray<{ readonly name: string; readonly args: ReadonlyArray<string> }> = [ + { name: "get", args: ["vanity-subdomains", "get"] }, + { + name: "check-availability", + args: ["vanity-subdomains", "check-availability"], + }, + { + name: "activate", + args: ["vanity-subdomains", "activate"], + }, + { name: "delete", args: ["vanity-subdomains", "delete"] }, + ]; + + for (const { name, args } of leaves) { + it.live( + `${name} fails with LegacyExperimentalRequiredError when --experimental is unset`, + () => { + const { layer, api } = setup(); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + Command.runWith(testRoot, { version: "0.0.0-test" })(args), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyExperimentalRequiredError"); + } + expect(api.requests).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live(`${name} does not fail with the gate error once --experimental is set`, () => { + const { layer, api } = setup(); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + Command.runWith(testRoot, { version: "0.0.0-test" })([...args, "--experimental"]), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const causeText = JSON.stringify(exit.cause); + expect(causeText).not.toContain("LegacyExperimentalRequiredError"); + expect(causeText).toContain("LegacyPlatformAuthRequiredError"); + } + expect(api.requests).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }); + } +}); diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/vanity-subdomains.integration.test.ts b/apps/cli/src/legacy/commands/vanity-subdomains/vanity-subdomains.integration.test.ts index 40c484acfe..54e636a3bc 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/vanity-subdomains.integration.test.ts +++ b/apps/cli/src/legacy/commands/vanity-subdomains/vanity-subdomains.integration.test.ts @@ -111,6 +111,56 @@ describe("legacy vanity-subdomains get", () => { }).pipe(Effect.provide(layer)); }); + it.live("suggests upgrade from entitlement_required envelope on 400", () => { + const out = mockOutput({ format: "text" }); + const analytics = mockAnalytics(); + const api = mockLegacyPlatformApi({ + response: { + status: 400, + body: { + message: "This feature requires the Pro, Team, or Enterprise organization plan.", + error: { + code: "entitlement_required", + feature: "vanity_subdomain", + upgrade_url: "https://supabase.com/dashboard/org/env-org/billing", + }, + }, + }, + }); + const layer = runtimeWith({ out, api, analytics }); + + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyVanitySubdomainsGet({ projectRef: Option.none() })); + expect(Exit.isFailure(exit)).toBe(true); + expect(api.requests).toHaveLength(1); + expect(out.stderrText).toContain("Upgrade your plan:"); + expect(out.stderrText).toContain("/org/env-org/billing"); + expect(analytics.captured).toEqual([ + { + event: "cli_upgrade_suggested", + properties: { feature_key: "vanity_subdomain", org_slug: "env-org" }, + }, + ]); + }).pipe(Effect.provide(layer)); + }); + + it.live("plain 404 without envelope produces no upgrade hint", () => { + const out = mockOutput({ format: "text" }); + const analytics = mockAnalytics(); + const api = mockLegacyPlatformApi({ + response: { status: 404, body: { message: "not found" } }, + }); + const layer = runtimeWith({ out, api, analytics }); + + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyVanitySubdomainsGet({ projectRef: Option.none() })); + expect(Exit.isFailure(exit)).toBe(true); + expect(api.requests).toHaveLength(1); + expect(analytics.captured).toHaveLength(0); + expect(out.stderrText).not.toContain("Upgrade your plan:"); + }).pipe(Effect.provide(layer)); + }); + it.live("omits the subdomain line in text mode when none is configured", () => { const out = mockOutput({ format: "text" }); const api = mockLegacyPlatformApi({ response: { status: 200, body: SAMPLE_GET_NO_DOMAIN } }); @@ -249,7 +299,7 @@ describe("legacy vanity-subdomains check-availability", () => { return Effect.gen(function* () { yield* legacyVanitySubdomainsCheckAvailability({ projectRef: Option.none(), - desiredSubdomain: "example.com", + desiredSubdomain: Option.some("example.com"), }); expect(out.stdoutText).toBe("Subdomain example.com available: true\n"); expect(api.requests[0]?.method).toBe("POST"); @@ -268,7 +318,7 @@ describe("legacy vanity-subdomains check-availability", () => { return Effect.gen(function* () { yield* legacyVanitySubdomainsCheckAvailability({ projectRef: Option.none(), - desiredSubdomain: "example.com", + desiredSubdomain: Option.some("example.com"), }); expect(out.stdoutText).toContain('"available": true'); }).pipe(Effect.provide(layer)); @@ -282,7 +332,7 @@ describe("legacy vanity-subdomains check-availability", () => { return Effect.gen(function* () { yield* legacyVanitySubdomainsCheckAvailability({ projectRef: Option.none(), - desiredSubdomain: "example.com", + desiredSubdomain: Option.some("example.com"), }); expect(out.stdoutText).toContain("available: true"); }).pipe(Effect.provide(layer)); @@ -296,7 +346,7 @@ describe("legacy vanity-subdomains check-availability", () => { return Effect.gen(function* () { yield* legacyVanitySubdomainsCheckAvailability({ projectRef: Option.none(), - desiredSubdomain: "example.com", + desiredSubdomain: Option.some("example.com"), }); expect(out.stdoutText).toBe("Available = true\n\n"); }).pipe(Effect.provide(layer)); @@ -310,7 +360,7 @@ describe("legacy vanity-subdomains check-availability", () => { return Effect.gen(function* () { yield* legacyVanitySubdomainsCheckAvailability({ projectRef: Option.none(), - desiredSubdomain: "example.com", + desiredSubdomain: Option.some("example.com"), }); expect(out.stdoutText).toContain('AVAILABLE="true"'); }).pipe(Effect.provide(layer)); @@ -324,7 +374,7 @@ describe("legacy vanity-subdomains check-availability", () => { return Effect.gen(function* () { yield* legacyVanitySubdomainsCheckAvailability({ projectRef: Option.none(), - desiredSubdomain: "example.com", + desiredSubdomain: Option.some("example.com"), }); const success = out.messages.find((m) => m.type === "success"); expect(success?.data).toMatchObject({ available: true }); @@ -341,7 +391,7 @@ describe("legacy vanity-subdomains check-availability", () => { const exit = yield* Effect.exit( legacyVanitySubdomainsCheckAvailability({ projectRef: Option.none(), - desiredSubdomain: "example.com", + desiredSubdomain: Option.some("example.com"), }), ); expect(Exit.isFailure(exit)).toBe(true); @@ -360,7 +410,7 @@ describe("legacy vanity-subdomains check-availability", () => { const exit = yield* Effect.exit( legacyVanitySubdomainsCheckAvailability({ projectRef: Option.none(), - desiredSubdomain: "example.com", + desiredSubdomain: Option.some("example.com"), }), ); expect(Exit.isFailure(exit)).toBe(true); @@ -371,6 +421,44 @@ describe("legacy vanity-subdomains check-availability", () => { } }).pipe(Effect.provide(layer)); }); + + // Go marks --desired-subdomain required but cobra validates it only after + // PersistentPreRunE (gate → login → ref resolution), so the handler enforces + // it with cobra's exact wording after the ref resolves. + it.live("fails with cobra's required-flag error when --desired-subdomain is omitted", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ response: { status: 201, body: SAMPLE_CHECK } }); + const layer = runtimeWith({ out, api }); + + return Effect.gen(function* () { + const error = yield* Effect.flip( + legacyVanitySubdomainsCheckAvailability({ + projectRef: Option.none(), + desiredSubdomain: Option.none(), + }), + ); + expect(error._tag).toBe("LegacyDesiredSubdomainRequiredError"); + expect(error.message).toBe('required flag(s) "desired-subdomain" not set'); + expect(api.requests).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }); + + // Cobra's MarkFlagRequired checks the flag was *changed*, not non-empty — + // an explicit empty value must pass the check and reach the API. + it.live("passes an explicit empty --desired-subdomain through to the API", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ response: { status: 201, body: SAMPLE_CHECK } }); + const layer = runtimeWith({ out, api }); + + return Effect.gen(function* () { + yield* legacyVanitySubdomainsCheckAvailability({ + projectRef: Option.none(), + desiredSubdomain: Option.some(""), + }); + expect(api.requests[0]?.body).toEqual({ vanity_subdomain: "" }); + expect(out.stdoutText).toBe("Subdomain available: true\n"); + }).pipe(Effect.provide(layer)); + }); }); describe("legacy vanity-subdomains activate", () => { @@ -382,7 +470,7 @@ describe("legacy vanity-subdomains activate", () => { return Effect.gen(function* () { yield* legacyVanitySubdomainsActivate({ projectRef: Option.none(), - desiredSubdomain: "example.com", + desiredSubdomain: Option.some("example.com"), }); expect(out.stdoutText).toBe("Activated vanity subdomain at example.com\n"); expect(api.requests[0]?.method).toBe("POST"); @@ -401,7 +489,7 @@ describe("legacy vanity-subdomains activate", () => { return Effect.gen(function* () { yield* legacyVanitySubdomainsActivate({ projectRef: Option.none(), - desiredSubdomain: "example.com", + desiredSubdomain: Option.some("example.com"), }); expect(out.stdoutText).toContain('"custom_domain": "example.com"'); }).pipe(Effect.provide(layer)); @@ -415,7 +503,7 @@ describe("legacy vanity-subdomains activate", () => { return Effect.gen(function* () { yield* legacyVanitySubdomainsActivate({ projectRef: Option.none(), - desiredSubdomain: "example.com", + desiredSubdomain: Option.some("example.com"), }); expect(out.stdoutText).toContain("custom_domain: example.com"); }).pipe(Effect.provide(layer)); @@ -429,7 +517,7 @@ describe("legacy vanity-subdomains activate", () => { return Effect.gen(function* () { yield* legacyVanitySubdomainsActivate({ projectRef: Option.none(), - desiredSubdomain: "example.com", + desiredSubdomain: Option.some("example.com"), }); expect(out.stdoutText).toBe('CustomDomain = "example.com"\n\n'); }).pipe(Effect.provide(layer)); @@ -443,7 +531,7 @@ describe("legacy vanity-subdomains activate", () => { return Effect.gen(function* () { yield* legacyVanitySubdomainsActivate({ projectRef: Option.none(), - desiredSubdomain: "example.com", + desiredSubdomain: Option.some("example.com"), }); expect(out.stdoutText).toContain('CUSTOM_DOMAIN="example.com"'); }).pipe(Effect.provide(layer)); @@ -457,7 +545,7 @@ describe("legacy vanity-subdomains activate", () => { return Effect.gen(function* () { yield* legacyVanitySubdomainsActivate({ projectRef: Option.none(), - desiredSubdomain: "example.com", + desiredSubdomain: Option.some("example.com"), }); const success = out.messages.find((m) => m.type === "success"); expect(success?.data).toMatchObject({ custom_domain: "example.com" }); @@ -474,7 +562,7 @@ describe("legacy vanity-subdomains activate", () => { const exit = yield* Effect.exit( legacyVanitySubdomainsActivate({ projectRef: Option.none(), - desiredSubdomain: "example.com", + desiredSubdomain: Option.some("example.com"), }), ); expect(Exit.isFailure(exit)).toBe(true); @@ -499,7 +587,7 @@ describe("legacy vanity-subdomains activate", () => { const exit = yield* Effect.exit( legacyVanitySubdomainsActivate({ projectRef: Option.none(), - desiredSubdomain: "example.com", + desiredSubdomain: Option.some("example.com"), }), ); expect(Exit.isFailure(exit)).toBe(true); @@ -512,6 +600,43 @@ describe("legacy vanity-subdomains activate", () => { expect(analytics.captured).toHaveLength(0); }).pipe(Effect.provide(layer)); }); + + // Go marks --desired-subdomain required but cobra validates it only after + // PersistentPreRunE (gate → login → ref resolution), so the handler enforces + // it with cobra's exact wording after the ref resolves. + it.live("fails with cobra's required-flag error when --desired-subdomain is omitted", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ response: { status: 201, body: SAMPLE_ACTIVATE } }); + const layer = runtimeWith({ out, api }); + + return Effect.gen(function* () { + const error = yield* Effect.flip( + legacyVanitySubdomainsActivate({ + projectRef: Option.none(), + desiredSubdomain: Option.none(), + }), + ); + expect(error._tag).toBe("LegacyDesiredSubdomainRequiredError"); + expect(error.message).toBe('required flag(s) "desired-subdomain" not set'); + expect(api.requests).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }); + + // Cobra's MarkFlagRequired checks the flag was *changed*, not non-empty — + // an explicit empty value must pass the check and reach the API. + it.live("passes an explicit empty --desired-subdomain through to the API", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ response: { status: 201, body: SAMPLE_ACTIVATE } }); + const layer = runtimeWith({ out, api }); + + return Effect.gen(function* () { + yield* legacyVanitySubdomainsActivate({ + projectRef: Option.none(), + desiredSubdomain: Option.some(""), + }); + expect(api.requests[0]?.body).toEqual({ vanity_subdomain: "" }); + }).pipe(Effect.provide(layer)); + }); }); describe("legacy vanity-subdomains delete", () => { @@ -625,4 +750,36 @@ describe("legacy vanity-subdomains PersistentPostRun parity", () => { expect(cache.cached).toBe(true); }).pipe(Effect.provide(layer)); }); + + // In Go the missing-required-flag failure happens AFTER PersistentPreRunE + // completes, so PersistentPostRun still fires telemetry and writes the + // linked-project cache (`cmd/root.go:171-181,212-233`). The handler-level + // check sits inside both `Effect.ensuring` wrappers to match. + it.live( + "flushes telemetry and writes linked-project cache on a missing --desired-subdomain", + () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ response: { status: 201, body: SAMPLE_ACTIVATE } }); + const telemetry = mockLegacyTelemetryStateTracked(); + const cache = mockLegacyLinkedProjectCacheTracked(); + const layer = runtimeWith({ + out, + api, + telemetry: telemetry.layer, + linkedProjectCache: cache.layer, + }); + + return Effect.gen(function* () { + const error = yield* Effect.flip( + legacyVanitySubdomainsActivate({ + projectRef: Option.none(), + desiredSubdomain: Option.none(), + }), + ); + expect(error._tag).toBe("LegacyDesiredSubdomainRequiredError"); + expect(telemetry.flushed).toBe(true); + expect(cache.cached).toBe(true); + }).pipe(Effect.provide(layer)); + }, + ); }); diff --git a/apps/cli/src/legacy/shared/legacy-connect-errors.ts b/apps/cli/src/legacy/shared/legacy-connect-errors.ts index 5a269abf87..d888f8993c 100644 --- a/apps/cli/src/legacy/shared/legacy-connect-errors.ts +++ b/apps/cli/src/legacy/shared/legacy-connect-errors.ts @@ -1,8 +1,10 @@ /** - * Connection-error classification ported from Go's `internal/utils/connect.go`. + * Connection-error classification and rendering ported from Go's + * `internal/utils/connect.go` and pgconn's `connectError` (`errors.go:60-77`). * Used by the container-level pooler fallback (`db dump --linked`) to decide * whether a failed pg_dump/pg container was an IPv6 connectivity failure that - * warrants retrying through the IPv4 transaction pooler. + * warrants retrying through the IPv4 transaction pooler, and by the connection + * layer to render connect failures with Go's `host=… user=… database=…` detail. */ import { isIPv6 } from "node:net"; @@ -29,7 +31,10 @@ export function legacyIpv6Suggestion(): string { // Go's `ipv6LiteralPattern` (`connect.go:181`): an IPv6 address in brackets // (Go dial form) or parens (libpq form). Run against the original-case message. const IPV6_LITERAL_PATTERN = /(?:\[[0-9a-fA-F:]+\]|\([0-9a-fA-F:]+\))/; -const NODE_ENETUNREACH_PATTERN = /\benetunreach\s+([0-9a-fA-F:]+):\d+(?:\s|$)/i; +// Node's dial-failure shape (`connect ENETUNREACH 2600:…:5432`). The port may be +// followed by whitespace, end-of-string, or a closing paren — the connect-failure +// message renders the driver cause parenthesized (pgconn `dial error (…)` form). +const NODE_ENETUNREACH_PATTERN = /\benetunreach\s+([0-9a-fA-F:]+):\d+(?:[\s)]|$)/i; /** * Port of Go's `isIPv6ConnectivityError` (`connect.go:189-208`). Lower-cases the @@ -59,24 +64,49 @@ export function legacyIsIPv6ConnectivityError(message: string): boolean { export const LEGACY_SUGGEST_ENV_VAR = "Connect to your database by setting the env var correctly: SUPABASE_DB_PASSWORD"; +/** + * TS-only addition — Go's `SetConnectSuggestion` has no local/remote distinction, + * so a refused `--local` connection (Docker/Postgres not running) got the same + * remote-only "Network Restrictions" dashboard hint as an actual network-restricted + * connection, which is a dead end locally. Shown instead of that hint when + * `ctx.isLocal` is true — see `legacyConnectSuggestion`. + */ +export const LEGACY_SUGGEST_LOCAL_STACK = "Make sure Docker is running, then run: supabase start"; + +/** + * Go's `SetConnectSuggestion` remote-only "Network Restrictions" hint + * (`internal/utils/connect.go:319-321`), shown for a connection refused/blocked + * by IP allow-listing. Shared by both the always-remote `Address not in tenant + * allow_list` branch and the non-local `ECONNREFUSED`/`connection refused` + * branch in `legacyConnectSuggestion`. + */ +function legacySuggestNetworkRestrictions(dashboardUrl: string): string { + return `Make sure your local IP is allowed in Network Restrictions and Network Bans.\n${dashboardUrl}/project/_/database/settings`; +} + /** Context the connect-suggestion needs but cannot derive from the error alone. */ export interface LegacyConnectSuggestionContext { /** Active profile's dashboard URL (Go's `CurrentProfile.DashboardURL`). */ readonly dashboardUrl: string; /** Active profile name (Go's `CurrentProfile.Name`). */ readonly profileName: string; - /** Whether `--debug` is set (Go's `viper.GetBool("DEBUG")`). */ - readonly debug: boolean; } /** - * Flatten an error's `cause` chain and any `AggregateError.errors` into a single - * searchable string of every nested `message` and `code`. The `@effect/sql` - * `SqlError` wraps the node-postgres / node `net` driver error on its `cause`; a - * multi-address dial wraps an `AggregateError` whose `errors[]` carry the per-IP - * `ECONNREFUSED` / `ENETUNREACH` system errors. Including the `code` strings lets - * the matcher key off node's `ECONNREFUSED` the way Go keys off pgconn's - * `connect: connection refused`. + * Flatten an error's `cause` chain into a single searchable string of every + * nested `message` and `code`. The `@effect/sql` `SqlError` wraps the + * node-postgres / node `net` driver error on its `cause`; a multi-address dial + * wraps an `AggregateError` whose `errors[]` carry the per-IP `ECONNREFUSED` / + * `ENETUNREACH` system errors — an aggregate node contributes NOTHING itself + * and only its LAST child is visited, because that is the attempt pgconn + * surfaces: its fallback loop overwrites the error on every attempt so only the + * last one survives (`pgconn.go:171-203`), and Go's `SetConnectSuggestion` + * classifies exactly that `err.Error()` string (`connect.go:317`). The parent's + * own fields must be skipped: node's `aggregateErrors` copies `errors[0].code` + * onto the aggregate itself (`lib/internal/errors.js`, Bun matches), so reading + * them would blame an abandoned first attempt. Including the `code` strings of + * the visited nodes lets the matcher key off node's `ECONNREFUSED` the way Go + * keys off pgconn's `connect: connection refused`. */ function legacyCollectConnectErrorText(error: unknown): string { const parts: string[] = []; @@ -84,18 +114,244 @@ function legacyCollectConnectErrorText(error: unknown): string { const visit = (node: unknown, depth: number): void => { if (depth > 8 || typeof node !== "object" || node === null || seen.has(node)) return; seen.add(node); + const errors = Reflect.get(node, "errors"); + if (Array.isArray(errors) && errors.length > 0) { + visit(errors[errors.length - 1], depth + 1); + return; + } const message = Reflect.get(node, "message"); if (typeof message === "string") parts.push(message); const code = Reflect.get(node, "code"); if (typeof code === "string") parts.push(code); visit(Reflect.get(node, "cause"), depth + 1); - const errors = Reflect.get(node, "errors"); - if (Array.isArray(errors)) for (const child of errors) visit(child, depth + 1); }; visit(error, 0); return parts.join("\n"); } +/** + * The connection identity pgconn embeds in its `connectError` text + * (`errors.go:66-68`): the config-level (primary) host, user, and database — + * never the password. + */ +export interface LegacyConnectFailureTarget { + readonly host: string; + readonly user: string; + readonly database: string; +} + +/** + * Walk to the deepest underlying driver error: unwrap `cause` chains (the + * `@effect/sql` `SqlError` exposes its `ConnectionError` reason as `cause`, and + * the reason exposes the node-postgres error the same way) and descend into the + * LAST entry of an `AggregateError`'s `errors[]` — pgconn's multi-address + * fallback loop likewise surfaces the last attempt's error + * (`pgconn.go:159-192`). + */ +function legacyDeepestConnectCause(error: unknown): unknown { + let current: unknown = error; + const seen = new Set<unknown>(); + for (let depth = 0; depth < 8; depth++) { + if (typeof current !== "object" || current === null || seen.has(current)) break; + seen.add(current); + const errors = Reflect.get(current, "errors"); + if (Array.isArray(errors) && errors.length > 0) { + current = errors[errors.length - 1]; + continue; + } + const cause = Reflect.get(current, "cause"); + if (typeof cause !== "object" || cause === null) break; + current = cause; + } + return current; +} + +// Node/Bun errno codes raised while dialing the server — pgconn wraps the +// equivalent net.Dial failures as `dial error (…)` (`pgconn.go:276`). +const LEGACY_DIAL_ERROR_CODES = new Set([ + "ECONNREFUSED", + "ETIMEDOUT", + "ENETUNREACH", + "EHOSTUNREACH", + "EADDRNOTAVAIL", +]); +// The complete documented Node/OpenSSL X509 certificate-verification code +// family (Node tls docs "X509 certificate error codes", OpenSSL's +// `X509_verify_cert_error` set), complemented by node's ERR_TLS_*/ERR_SSL_* +// prefixes at the use site. pgconn stages by connection PHASE — ANY `startTLS` +// failure becomes `tls error (…)` (`pgconn.go:283-289`) — but node exposes no +// phase marker, so the full code family is the proxy. These strings are unique +// to TLS-layer verification: server SQLSTATEs and dial/DNS `E…` errnos are +// classified by earlier branches. +const LEGACY_TLS_ERROR_CODES = new Set([ + "UNABLE_TO_GET_ISSUER_CERT", + "UNABLE_TO_GET_CRL", + "UNABLE_TO_DECRYPT_CERT_SIGNATURE", + "UNABLE_TO_DECRYPT_CRL_SIGNATURE", + "UNABLE_TO_DECODE_ISSUER_PUBLIC_KEY", + "CERT_SIGNATURE_FAILURE", + "CRL_SIGNATURE_FAILURE", + "CERT_NOT_YET_VALID", + "CERT_HAS_EXPIRED", + "CRL_NOT_YET_VALID", + "CRL_HAS_EXPIRED", + "ERROR_IN_CERT_NOT_BEFORE_FIELD", + "ERROR_IN_CERT_NOT_AFTER_FIELD", + "ERROR_IN_CRL_LAST_UPDATE_FIELD", + "ERROR_IN_CRL_NEXT_UPDATE_FIELD", + "OUT_OF_MEM", + "DEPTH_ZERO_SELF_SIGNED_CERT", + "SELF_SIGNED_CERT_IN_CHAIN", + "UNABLE_TO_GET_ISSUER_CERT_LOCALLY", + "UNABLE_TO_VERIFY_LEAF_SIGNATURE", + "CERT_CHAIN_TOO_LONG", + "CERT_REVOKED", + "INVALID_CA", + "PATH_LENGTH_EXCEEDED", + "INVALID_PURPOSE", + "CERT_UNTRUSTED", + "CERT_REJECTED", + "HOSTNAME_MISMATCH", +]); +// node-postgres' own message when the server answers `N` to SSLRequest +// (`pg/lib/connection.js`); pgconn: `tls error (server refused TLS connection)`. +const LEGACY_SERVER_REFUSED_SSL = "The server does not support SSL connections"; +// Node/Bun's TLS-socket message when the server accepts SSLRequest but closes +// the socket before the handshake completes (node `lib/_tls_wrap.js` +// `onConnectEnd`; Bun emits the same text for both FIN and RST). Phase-specific +// by construction — only ever raised pre-secure-connection — so it maps to +// pgconn's startTLS stage (`tls error (…)`, `pgconn.go:283-289`). Its code is +// ECONNRESET, deliberately absent from LEGACY_DIAL_ERROR_CODES: a raw +// post-handshake `read ECONNRESET` is not phase-specific and stays verbatim. +const LEGACY_TLS_DISCONNECT_MESSAGE = + "Client network socket disconnected before secure TLS connection was established"; +// A Postgres SQLSTATE is exactly five uppercase alphanumerics. Combined with the +// `severity` field this identifies a node-postgres `DatabaseError` (a server +// ErrorResponse), never a node system error (whose codes are longer `E…` names). +const SQLSTATE_PATTERN = /^[0-9A-Z]{5}$/; + +/** + * Whether a driver error `code` is a Postgres SQLSTATE (a server ErrorResponse) + * rather than a node system errno (`ECONNRESET`, …). Shared by the connect-cause + * renderer below and the driver layer's exec-error mapping, which both need to + * distinguish server errors from socket/driver failures. + */ +export const legacyIsSqlState = (code: string): boolean => SQLSTATE_PATTERN.test(code); + +/** + * Render the underlying driver failure the way pgconn stages its + * `connectError.msg` (`server error` / `hostname resolving error` / + * `dial error` / `tls error`, each with the cause parenthesized — + * `errors.go:66-72`). A server ErrorResponse reproduces pgconn's `PgError` + * rendering byte-for-byte (`Severity: Message (SQLSTATE Code)`, `errors.go:51`); + * for the other stages the parenthesized text is the node driver's own message, + * which cannot byte-match libpq/pgconn wording (e.g. node's + * `connect ECONNREFUSED 1.2.3.4:5432` vs Go's + * `dial tcp 1.2.3.4:5432: connect: connection refused`). An unrecognized cause + * (e.g. node-postgres' `Connection terminated unexpectedly`, where pgconn would + * say `failed to receive message (unexpected EOF)`) is rendered verbatim rather + * than guessing a stage. + * + * Known stage-label caveat: pgconn labels by auth PHASE, which node-postgres + * does not expose — a wrong password over SCRAM arrives mid-SASL, so Go renders + * `failed SASL auth (FATAL: password authentication failed … (SQLSTATE 28P01))` + * (`pgconn.go:359`) where this renders `server error (…)` with the identical + * inner `PgError` bytes. The suggestion classifier keys off the inner text, so + * the `SUPABASE_DB_PASSWORD` hint fires identically either way. + */ +function legacyConnectCauseDetail(cause: unknown): string { + if (typeof cause !== "object" || cause === null) return String(cause); + const message = Reflect.get(cause, "message"); + const code = Reflect.get(cause, "code"); + const severity = Reflect.get(cause, "severity"); + const syscall = Reflect.get(cause, "syscall"); + const text = + typeof message === "string" && message.length > 0 + ? message + : typeof code === "string" + ? code + : String(cause); + if (typeof severity === "string" && typeof code === "string" && legacyIsSqlState(code)) { + return `server error (${severity}: ${text} (SQLSTATE ${code}))`; + } + if (syscall === "getaddrinfo" || code === "ENOTFOUND" || code === "EAI_AGAIN") { + return `hostname resolving error (${text})`; + } + if (syscall === "connect" || (typeof code === "string" && LEGACY_DIAL_ERROR_CODES.has(code))) { + return `dial error (${text})`; + } + if ( + text === LEGACY_SERVER_REFUSED_SSL || + text === LEGACY_TLS_DISCONNECT_MESSAGE || + (typeof code === "string" && + (LEGACY_TLS_ERROR_CODES.has(code) || + code.startsWith("ERR_TLS") || + code.startsWith("ERR_SSL"))) + ) { + return `tls error (${text})`; + } + return text; +} + +/** + * Port of pgconn's `connectError.Error()` (`errors.go:66-72`), the inner text of + * Go's `failed to connect to postgres: %w` wrap (`pkg/pgxv5/connect.go:33`): + * `` failed to connect to `host=… user=… database=…`: <staged driver cause> ``. + * Callers pass the connection config (pgconn embeds the config-level identity, + * `errors.go:66-68`) and the raw failure — either the `@effect/sql` `SqlError` + * from the pooled connect probe or the bare node-postgres error from the raw + * client, both unwrapped by {@link legacyDeepestConnectCause}. + */ +export function legacyConnectFailureMessage( + target: LegacyConnectFailureTarget, + error: unknown, +): string { + const detail = legacyConnectCauseDetail(legacyDeepestConnectCause(error)); + return `failed to connect to \`host=${target.host} user=${target.user} database=${target.database}\`: ${detail}`; +} + +// Dial errno codes that mean the target address itself is unreachable. Combined +// with an IPv6 `address` they are node's equivalents of Go's IPv6-connectivity +// texts: ENETUNREACH → `network is unreachable`, EHOSTUNREACH → `no route to +// host`, EADDRNOTAVAIL → `cannot assign requested address` (`connect.go:204-223`). +const LEGACY_IPV6_DIAL_CODES = new Set(["ENETUNREACH", "EHOSTUNREACH", "EADDRNOTAVAIL"]); + +/** + * Whether the error chain carries a node dial failure whose errno + `address` + * fields identify an unreachable IPv6 target. This is the structured complement + * to {@link legacyIsIPv6ConnectivityError}: node system errors carry the dialed + * address as a field (`connect EHOSTUNREACH 2600:…:5432` has `code` and + * `address`), whereas Go's classifier reads the same facts out of pgconn's + * message text. Narrower than `legacyIsIPv6ConnectivityErrorCause` — it never + * treats `ENOTFOUND` (a plain DNS miss, e.g. a typo'd host) as IPv6, matching + * Go, where a failed lookup renders `hostname resolving error` and sets no + * suggestion. Use THIS one for the connect suggestion; the container-level + * pooler fallback keeps the broader `legacyIsIPv6ConnectivityErrorCause`. + * Like {@link legacyDeepestConnectCause}, an `AggregateError` descends into its + * LAST child only — the attempt pgconn surfaces (`pgconn.go:171-203`) and the + * one Go's classifier reads (`connect.go:317`). Depth-bounded recursion (no + * `seen` set): a pathological cause cycle re-walks at most 8 levels, which is + * cheap and cannot loop. + */ +function legacyHasIPv6DialCause(error: unknown, depth = 0): boolean { + if (depth > 8 || typeof error !== "object" || error === null) return false; + const code = Reflect.get(error, "code"); + const address = Reflect.get(error, "address"); + if ( + typeof code === "string" && + LEGACY_IPV6_DIAL_CODES.has(code) && + typeof address === "string" && + isIPv6(address) + ) { + return true; + } + const errors = Reflect.get(error, "errors"); + if (Array.isArray(errors) && errors.length > 0) { + return legacyHasIPv6DialCause(errors[errors.length - 1], depth + 1); + } + return legacyHasIPv6DialCause(Reflect.get(error, "cause"), depth + 1); +} + /** * Port of Go's `SetConnectSuggestion` (`internal/utils/connect.go:313-335`): map a * Postgres connect failure to an actionable hint that replaces the generic @@ -105,23 +361,32 @@ function legacyCollectConnectErrorText(error: unknown): string { * the `SqlError` cause/aggregate chain. The branch order mirrors Go's `if/else if`. * Returns `undefined` when no specific suggestion applies (the caller then falls * back to the generic suggestion, like Go leaving `CmdSuggestion` empty). + * + * Sourcing note: the rendered message ({@link legacyConnectFailureMessage}) and + * this classifier inspect the SAME surfaced attempt, as Go guarantees by + * construction — pgconn's fallback loop overwrites its error on every attempt so + * only the last one survives (`pgconn.go:171-203`), and `SetConnectSuggestion` + * classifies exactly that rendered `err.Error()` string (`connect.go:317`). The + * collectors above therefore descend into only the LAST aggregate child, the + * same attempt {@link legacyDeepestConnectCause} surfaces for rendering, so the + * displayed cause and the suggestion can never disagree. */ export function legacyConnectSuggestion( error: unknown, - ctx: LegacyConnectSuggestionContext, + ctx: LegacyConnectSuggestionContext & { readonly isLocal: boolean }, ): string | undefined { const text = legacyCollectConnectErrorText(error); - // connect: connection refused / Address not in tenant allow_list → network restrictions. - if ( - text.includes("ECONNREFUSED") || - text.includes("connection refused") || - text.includes("Address not in tenant allow_list") - ) { - return `Make sure your local IP is allowed in Network Restrictions and Network Bans.\n${ctx.dashboardUrl}/project/_/database/settings`; + // connect: connection refused - "Address not in tenant allow_list" only ever comes from the remote pooler + // rejecting the caller's IP, so it always means network restrictions. + if (text.includes("Address not in tenant allow_list")) { + return legacySuggestNetworkRestrictions(ctx.dashboardUrl); } - // SSL connection is required (only under --debug, which disables TLS). - if (text.includes("SSL connection is required") && ctx.debug) { - return "SSL connection is not supported with --debug flag"; + // connect: connection refused — don't send the user to the + // dashboard's Network Restrictions page for a --local connection. + if (text.includes("ECONNREFUSED") || text.includes("connection refused")) { + return ctx.isLocal + ? LEGACY_SUGGEST_LOCAL_STACK + : legacySuggestNetworkRestrictions(ctx.dashboardUrl); } // Wrong password (Go: "SCRAM exchange: Wrong password" / "failed SASL auth"; // node-postgres surfaces the server's `28P01` "password authentication failed"). @@ -132,11 +397,21 @@ export function legacyConnectSuggestion( ) { return LEGACY_SUGGEST_ENV_VAR; } - if (legacyIsIPv6ConnectivityError(text)) { + // Go: `isIPv6ConnectivityError` on the pgconn text. node system errors carry + // the dialed address as a structured field instead of libpq's parenthesized + // literal, so also consult the errno + `address` classifier. + if (legacyIsIPv6ConnectivityError(text) || legacyHasIPv6DialCause(error)) { return legacyIpv6Suggestion(); } - // no route to host / Tenant or user not found → wrong profile. - if (text.includes("no route to host") || text.includes("Tenant or user not found")) { + // connect: no route to host / Tenant or user not found → wrong profile. + // node's "no route to host" is `connect EHOSTUNREACH <ip>:<port>`; an IPv6 + // EHOSTUNREACH was already captured by the IPv6 branch above, mirroring Go's + // branch order ("Assumes IPv6 check has been performed before this"). + if ( + text.includes("no route to host") || + text.includes("EHOSTUNREACH") || + text.includes("Tenant or user not found") + ) { return `Make sure your project exists on profile: ${ctx.profileName}`; } return undefined; @@ -155,6 +430,9 @@ function hasStringCode(error: unknown): error is { * Classifies Node socket/getaddrinfo causes that carry errno-style `code` fields. * `ENOTFOUND` is intentionally broader than Go's text classifier (it can include * typo'd hosts); callers must combine this with a direct `db.<ref>` host gate. + * Used by the container-level pooler fallback (`gen types` / `db dump`); the + * connect-suggestion path uses the narrower `legacyHasIPv6DialCause` instead, + * which must not treat a DNS miss as IPv6. */ export function legacyIsIPv6ConnectivityErrorCause(error: unknown): boolean { if (error instanceof AggregateError) { diff --git a/apps/cli/src/legacy/shared/legacy-connect-errors.unit.test.ts b/apps/cli/src/legacy/shared/legacy-connect-errors.unit.test.ts index 844cda45e0..627c52575c 100644 --- a/apps/cli/src/legacy/shared/legacy-connect-errors.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-connect-errors.unit.test.ts @@ -1,13 +1,48 @@ +import { ConnectionError, SqlError } from "effect/unstable/sql/SqlError"; +import * as Pg from "pg"; import { describe, expect, it } from "vitest"; import { LEGACY_SUGGEST_ENV_VAR, + LEGACY_SUGGEST_LOCAL_STACK, + legacyConnectFailureMessage, legacyConnectSuggestion, legacyIpv6Suggestion, legacyIsIPv6ConnectivityError, legacyIsIPv6ConnectivityErrorCause, } from "./legacy-connect-errors.ts"; +// The real `@effect/sql` wrapper produced by the connect probe +// (`legacyAcquireProbedPool`): a `SqlError` whose `ConnectionError` reason carries +// the node-postgres driver error as its `cause`. +const realSqlConnectError = (cause: unknown) => + new SqlError({ + reason: new ConnectionError({ + cause, + message: "PgClient: Failed to connect", + operation: "connect", + }), + }); + +// A node/Bun system error exactly as the `net` stack raises it while dialing: +// `connect ECONNREFUSED 127.0.0.1:5432` with errno-style fields attached. +const dialError = (code: string, address: string, port: number) => + Object.assign(new Error(`connect ${code} ${address}:${port}`), { + code, + errno: -61, + syscall: "connect", + address, + port, + }); + +// A real node-postgres server ErrorResponse (`DatabaseError` from pg-protocol), +// with the fields a live Postgres attaches for a failed password authentication. +const authFailedError = () => + Object.assign( + new Pg.DatabaseError('password authentication failed for user "postgres"', 104, "error"), + { severity: "FATAL", code: "28P01", file: "auth.c", line: "326", routine: "auth_failed" }, + ); + describe("legacyIsIPv6ConnectivityError", () => { it("classifies the getaddrinfo IPv6-only failures (case-insensitive)", () => { expect( @@ -41,17 +76,208 @@ describe("legacyIsIPv6ConnectivityError", () => { expect(legacyIsIPv6ConnectivityError("connect ENETUNREACH 10.0.0.1:5432")).toBe(false); }); + it("classifies Node ENETUNREACH inside the parenthesized connect-failure rendering", () => { + expect( + legacyIsIPv6ConnectivityError( + "failed to connect to `host=db.x.supabase.co user=postgres database=postgres`: dial error (connect ENETUNREACH 2600:1f18::1:5432)", + ), + ).toBe(true); + }); + it("does not classify unrelated errors", () => { expect(legacyIsIPv6ConnectivityError("permission denied for schema public")).toBe(false); expect(legacyIsIPv6ConnectivityError("")).toBe(false); }); }); +describe("legacyConnectFailureMessage", () => { + const target = { host: "db.abcdefghij.supabase.co", user: "postgres", database: "postgres" }; + const prefix = + "failed to connect to `host=db.abcdefghij.supabase.co user=postgres database=postgres`:"; + + it("renders host/user/database and the staged dial cause through the real SqlError chain", () => { + const error = realSqlConnectError(dialError("ECONNREFUSED", "127.0.0.1", 5432)); + expect(legacyConnectFailureMessage(target, error)).toBe( + `${prefix} dial error (connect ECONNREFUSED 127.0.0.1:5432)`, + ); + }); + + it("surfaces the last dial attempt of a dual-stack AggregateError (pgconn last-fallback parity)", () => { + // node dials ::1 then 127.0.0.1 for `localhost` and aggregates both failures + // into an AggregateError with an EMPTY message; pgconn's fallback loop + // likewise surfaces the last attempt's error. + const aggregate = Object.assign(new AggregateError([], ""), { + code: "ECONNREFUSED", + errors: [ + dialError("ECONNREFUSED", "::1", 5432), + dialError("ECONNREFUSED", "127.0.0.1", 5432), + ], + }); + expect(legacyConnectFailureMessage(target, realSqlConnectError(aggregate))).toBe( + `${prefix} dial error (connect ECONNREFUSED 127.0.0.1:5432)`, + ); + }); + + it("reproduces pgconn's server-error rendering byte-for-byte for a server ErrorResponse", () => { + expect(legacyConnectFailureMessage(target, realSqlConnectError(authFailedError()))).toBe( + `${prefix} server error (FATAL: password authentication failed for user "postgres" (SQLSTATE 28P01))`, + ); + }); + + it("stages a DNS failure as hostname resolving error (Bun getaddrinfo shape)", () => { + // Bun's getaddrinfo failure omits the hostname from the message; the host is + // already carried by the `host=…` identity. + const dns = Object.assign(new Error("getaddrinfo ENOTFOUND"), { + code: "ENOTFOUND", + syscall: "getaddrinfo", + }); + expect(legacyConnectFailureMessage(target, realSqlConnectError(dns))).toBe( + `${prefix} hostname resolving error (getaddrinfo ENOTFOUND)`, + ); + // A transient resolver failure classifies by code alone (no syscall field). + const eaiAgain = Object.assign(new Error("getaddrinfo EAI_AGAIN db.x.supabase.co"), { + code: "EAI_AGAIN", + }); + expect(legacyConnectFailureMessage(target, realSqlConnectError(eaiAgain))).toBe( + `${prefix} hostname resolving error (getaddrinfo EAI_AGAIN db.x.supabase.co)`, + ); + }); + + it("stages a dial errno by code alone when the syscall field is absent", () => { + const timedOut = Object.assign(new Error("connect ETIMEDOUT 10.0.0.9:5432"), { + code: "ETIMEDOUT", + }); + expect(legacyConnectFailureMessage(target, realSqlConnectError(timedOut))).toBe( + `${prefix} dial error (connect ETIMEDOUT 10.0.0.9:5432)`, + ); + }); + + it("stages TLS failures as tls error", () => { + // node-postgres' own refusal message carries no code. + expect( + legacyConnectFailureMessage( + target, + realSqlConnectError(new Error("The server does not support SSL connections")), + ), + ).toBe(`${prefix} tls error (The server does not support SSL connections)`); + const selfSigned = Object.assign(new Error("self-signed certificate in certificate chain"), { + code: "SELF_SIGNED_CERT_IN_CHAIN", + }); + expect(legacyConnectFailureMessage(target, realSqlConnectError(selfSigned))).toBe( + `${prefix} tls error (self-signed certificate in certificate chain)`, + ); + // node's ERR_TLS_* family (e.g. a hostname mismatch under verify-full). + const altname = Object.assign(new Error("Hostname/IP does not match certificate's altnames"), { + code: "ERR_TLS_CERT_ALTNAME_INVALID", + }); + expect(legacyConnectFailureMessage(target, realSqlConnectError(altname))).toBe( + `${prefix} tls error (Hostname/IP does not match certificate's altnames)`, + ); + }); + + it("stages every documented X509 certificate-verification code as tls error", () => { + // pgconn stages by connection phase — ANY startTLS failure is `tls error (…)` + // (`pgconn.go:283-289`) — so the complete Node/OpenSSL verification family + // (Node tls docs "X509 certificate error codes") must keep the staged + // rendering under sslmode=verify-ca / verify-full. Pinned code-by-code so a + // future trim of the allowlist regresses loudly. + const x509Codes = [ + "UNABLE_TO_GET_ISSUER_CERT", + "UNABLE_TO_GET_CRL", + "UNABLE_TO_DECRYPT_CERT_SIGNATURE", + "UNABLE_TO_DECRYPT_CRL_SIGNATURE", + "UNABLE_TO_DECODE_ISSUER_PUBLIC_KEY", + "CERT_SIGNATURE_FAILURE", + "CRL_SIGNATURE_FAILURE", + "CERT_NOT_YET_VALID", + "CERT_HAS_EXPIRED", + "CRL_NOT_YET_VALID", + "CRL_HAS_EXPIRED", + "ERROR_IN_CERT_NOT_BEFORE_FIELD", + "ERROR_IN_CERT_NOT_AFTER_FIELD", + "ERROR_IN_CRL_LAST_UPDATE_FIELD", + "ERROR_IN_CRL_NEXT_UPDATE_FIELD", + "OUT_OF_MEM", + "DEPTH_ZERO_SELF_SIGNED_CERT", + "SELF_SIGNED_CERT_IN_CHAIN", + "UNABLE_TO_GET_ISSUER_CERT_LOCALLY", + "UNABLE_TO_VERIFY_LEAF_SIGNATURE", + "CERT_CHAIN_TOO_LONG", + "CERT_REVOKED", + "INVALID_CA", + "PATH_LENGTH_EXCEEDED", + "INVALID_PURPOSE", + "CERT_UNTRUSTED", + "CERT_REJECTED", + "HOSTNAME_MISMATCH", + ] as const; + for (const code of x509Codes) { + const failure = Object.assign(new Error(`certificate verification failed: ${code}`), { + code, + }); + expect(legacyConnectFailureMessage(target, realSqlConnectError(failure))).toBe( + `${prefix} tls error (certificate verification failed: ${code})`, + ); + } + }); + + it("stages a mid-handshake TLS disconnect as tls error despite its ECONNRESET code", () => { + // Node/Bun's `_tls_wrap.js` onConnectEnd shape: the server accepted + // SSLRequest but closed the socket before the handshake completed. The + // message is phase-specific (only ever raised pre-secure-connection), so it + // stages like pgconn's startTLS wrap (`tls error (…)`, pgconn.go:283-289). + const midHandshake = Object.assign( + new Error("Client network socket disconnected before secure TLS connection was established"), + { code: "ECONNRESET" }, + ); + expect(legacyConnectFailureMessage(target, realSqlConnectError(midHandshake))).toBe( + `${prefix} tls error (Client network socket disconnected before secure TLS connection was established)`, + ); + }); + + it("renders a raw socket reset verbatim — not phase-specific, so no stage is guessed", () => { + // Node's hard-RST shape (`read ECONNRESET`, syscall "read") is identical + // before and after the handshake, so unlike the message above it must NOT + // be staged as tls error. + const rawReset = Object.assign(new Error("read ECONNRESET"), { + code: "ECONNRESET", + syscall: "read", + }); + expect(legacyConnectFailureMessage(target, realSqlConnectError(rawReset))).toBe( + `${prefix} read ECONNRESET`, + ); + }); + + it("renders an unrecognized cause verbatim (CLI-1942 session-pooler EOF shape)", () => { + // node-postgres raises `Connection terminated unexpectedly` where pgconn + // says `failed to receive message (unexpected EOF)` — no stage is guessed. + const eof = new Error("Connection terminated unexpectedly"); + expect(legacyConnectFailureMessage(target, realSqlConnectError(eof))).toBe( + `${prefix} Connection terminated unexpectedly`, + ); + }); + + it("handles a bare driver error (raw-client path) and non-object failures", () => { + // `acquireRawClient` maps the node-postgres rejection without a SqlError wrapper. + expect(legacyConnectFailureMessage(target, dialError("ECONNREFUSED", "127.0.0.1", 6543))).toBe( + `${prefix} dial error (connect ECONNREFUSED 127.0.0.1:6543)`, + ); + expect(legacyConnectFailureMessage(target, "boom")).toBe(`${prefix} boom`); + }); + + it("falls back to the code when the cause carries an empty message", () => { + const bare = Object.assign(new Error(), { code: "ECONNREFUSED" }); + expect(legacyConnectFailureMessage(target, realSqlConnectError(bare))).toBe( + `${prefix} dial error (ECONNREFUSED)`, + ); + }); +}); + describe("legacyConnectSuggestion", () => { const ctx = { dashboardUrl: "https://supabase.com/dashboard", profileName: "supabase", - debug: false, + isLocal: false, } as const; // The @effect/sql SqlError wraps the node driver error on `.cause`; a multi-address @@ -68,6 +294,13 @@ describe("legacyConnectSuggestion", () => { ); }); + it("maps a refused local connection to the local stack hint", () => { + const err = sqlError(systemError("connect ECONNREFUSED 127.0.0.1:54322", "ECONNREFUSED")); + expect(legacyConnectSuggestion(err, { ...ctx, isLocal: true })).toBe( + LEGACY_SUGGEST_LOCAL_STACK, + ); + }); + it("maps an AggregateError of refused dials to the network-restrictions hint", () => { const err = sqlError( Object.assign(new AggregateError([], "all attempts failed"), { @@ -93,12 +326,10 @@ describe("legacyConnectSuggestion", () => { expect(legacyConnectSuggestion(err, ctx)).toBe(LEGACY_SUGGEST_ENV_VAR); }); - it("suggests the --debug SSL note only under --debug", () => { + // `ssl` comes from the DSN alone, so a server demanding it blames the DSN, not the flag. + it("does not blame --debug when the server demands SSL", () => { const err = sqlError(new Error("SSL connection is required")); expect(legacyConnectSuggestion(err, ctx)).toBeUndefined(); - expect(legacyConnectSuggestion(err, { ...ctx, debug: true })).toBe( - "SSL connection is not supported with --debug flag", - ); }); it("maps an IPv6-only connectivity failure to the IPv6 pooler suggestion", () => { @@ -113,6 +344,139 @@ describe("legacyConnectSuggestion", () => { ); }); + it("maps node's no-route-to-host (EHOSTUNREACH over IPv4) to the wrong-profile hint", () => { + // Go matches pgconn's `connect: no route to host`; node renders the same + // failure as `connect EHOSTUNREACH <ip>:<port>` with errno fields. + const err = realSqlConnectError(dialError("EHOSTUNREACH", "10.1.2.3", 5432)); + expect(legacyConnectSuggestion(err, ctx)).toBe( + "Make sure your project exists on profile: supabase", + ); + }); + + it("maps an IPv6 no-route-to-host (EHOSTUNREACH) to the IPv6 pooler suggestion", () => { + // Go's `no route to host` counts as IPv6 when the message carries an IPv6 + // literal; node carries the dialed address as a structured field instead. + const err = realSqlConnectError(dialError("EHOSTUNREACH", "2600:1f18::1", 5432)); + expect(legacyConnectSuggestion(err, ctx)).toBe(legacyIpv6Suggestion()); + }); + + it("maps an IPv6 cannot-assign-address (EADDRNOTAVAIL) to the IPv6 pooler suggestion", () => { + const err = realSqlConnectError(dialError("EADDRNOTAVAIL", "2a05:d014::1", 5432)); + expect(legacyConnectSuggestion(err, ctx)).toBe(legacyIpv6Suggestion()); + }); + + it("maps an aggregate of IPv6 dial failures to the IPv6 pooler suggestion", () => { + const aggregate = Object.assign(new AggregateError([], ""), { + errors: [dialError("EHOSTUNREACH", "2600:1f18::1", 5432)], + }); + expect(legacyConnectSuggestion(realSqlConnectError(aggregate), ctx)).toBe( + legacyIpv6Suggestion(), + ); + }); + + it("classifies only the LAST attempt of a mixed-family aggregate (pgconn last-fallback parity)", () => { + // Go can never blame an abandoned attempt: pgconn's fallback loop keeps only + // the last error (`pgconn.go:171-203`) and `SetConnectSuggestion` classifies + // that same rendered string (`connect.go:317`). An earlier IPv6 EHOSTUNREACH + // followed by a final unclassified IPv4 timeout must NOT fire the IPv6 hint. + // The parent carries `code` copied from errors[0] (node's `aggregateErrors`), + // which must not leak into the wrong-profile branch either. + const aggregate = Object.assign(new AggregateError([], ""), { + code: "EHOSTUNREACH", + errors: [ + dialError("EHOSTUNREACH", "2600:1f18::1", 5432), + dialError("ETIMEDOUT", "10.0.0.9", 5432), + ], + }); + expect(legacyConnectSuggestion(realSqlConnectError(aggregate), ctx)).toBeUndefined(); + }); + + it("fires the IPv6 pooler suggestion when the LAST aggregate attempt is the IPv6 dial failure", () => { + // The surfaced (last) attempt drives both the rendered cause and the + // suggestion — an earlier refused IPv4 attempt is ignored, like Go, even + // though node copies its `code` onto the aggregate parent. + const aggregate = Object.assign(new AggregateError([], ""), { + code: "ECONNREFUSED", + errors: [ + dialError("ECONNREFUSED", "10.0.0.9", 5432), + dialError("EHOSTUNREACH", "2600:1f18::1", 5432), + ], + }); + expect(legacyConnectSuggestion(realSqlConnectError(aggregate), ctx)).toBe( + legacyIpv6Suggestion(), + ); + }); + + it("ignores the parent aggregate's copied first-attempt code (node aggregateErrors shape)", () => { + // Node's `aggregateErrors` (`lib/internal/errors.js`, Bun matches) copies + // `errors[0].code` onto the AggregateError itself. A refused first attempt + // followed by a final unreachable-IPv6 attempt must classify the LAST + // attempt (IPv6 pooler hint), not the parent's copied ECONNREFUSED — + // otherwise the suggestion disagrees with the rendered cause, which Go + // makes impossible (`pgconn.go:171-203`, `connect.go:317`). + const aggregate = Object.assign(new AggregateError([], ""), { + code: "ECONNREFUSED", + errors: [ + dialError("ECONNREFUSED", "10.0.0.9", 5432), + dialError("ENETUNREACH", "2600:1f18::1", 5432), + ], + }); + expect(legacyConnectSuggestion(realSqlConnectError(aggregate), ctx)).toBe( + legacyIpv6Suggestion(), + ); + }); + + it("classifies a refused LAST attempt as network restrictions despite an IPv6 first attempt", () => { + // Reverse direction: the parent's copied ENETUNREACH (from the abandoned + // IPv6 first attempt) must not fabricate the IPv6 hint when the surfaced + // last attempt is a plain refusal. + const aggregate = Object.assign(new AggregateError([], ""), { + code: "ENETUNREACH", + errors: [ + dialError("ENETUNREACH", "2600:1f18::1", 5432), + dialError("ECONNREFUSED", "10.0.0.9", 5432), + ], + }); + expect(legacyConnectSuggestion(realSqlConnectError(aggregate), ctx)).toContain( + "Network Restrictions and Network Bans", + ); + }); + + it("sets no suggestion for a mid-handshake TLS disconnect, like Go", () => { + // Go's `SetConnectSuggestion` (`connect.go:313-335`) has no branch matching + // resets or TLS failures — the staged `tls error (…)` rendering must not + // change that. + const midHandshake = Object.assign( + new Error("Client network socket disconnected before secure TLS connection was established"), + { code: "ECONNRESET" }, + ); + expect(legacyConnectSuggestion(realSqlConnectError(midHandshake), ctx)).toBeUndefined(); + }); + + it("keeps an IPv4 EADDRNOTAVAIL unclassified, like Go without an IPv6 literal", () => { + const err = realSqlConnectError(dialError("EADDRNOTAVAIL", "10.1.2.3", 5432)); + expect(legacyConnectSuggestion(err, ctx)).toBeUndefined(); + }); + + it("fires the refused hint through the real SqlError chain, not just the test double", () => { + const err = realSqlConnectError(dialError("ECONNREFUSED", "127.0.0.1", 54322)); + expect(legacyConnectSuggestion(err, ctx)).toContain("Network Restrictions and Network Bans"); + }); + + it("fires the env-var hint for a real node-postgres 28P01 DatabaseError", () => { + expect(legacyConnectSuggestion(realSqlConnectError(authFailedError()), ctx)).toBe( + LEGACY_SUGGEST_ENV_VAR, + ); + }); + + it("keeps the CLI-1942 session-pooler EOF unclassified so the generic --debug suggestion applies", () => { + // Go's SetConnectSuggestion has no branch for pgconn's `unexpected EOF` + // (the session-pooler drop in CLI-1942); node-postgres' equivalent + // `Connection terminated unexpectedly` must stay unclassified too. + const err = realSqlConnectError(new Error("Connection terminated unexpectedly")); + expect(legacyConnectSuggestion(err, ctx)).toBeUndefined(); + }); + it("returns undefined for an unrecognized connect error", () => { expect(legacyConnectSuggestion(sqlError(new Error("some other failure")), ctx)).toBeUndefined(); }); diff --git a/apps/cli/src/legacy/shared/legacy-container-cli.ts b/apps/cli/src/legacy/shared/legacy-container-cli.ts index 53bfb6e947..4708903b38 100644 --- a/apps/cli/src/legacy/shared/legacy-container-cli.ts +++ b/apps/cli/src/legacy/shared/legacy-container-cli.ts @@ -44,30 +44,45 @@ export function legacyDescribeContainerCliFailure(cause: unknown): string { return String(cause); } +/** Which of the two supported container CLIs actually answered a spawn. */ +export type LegacyContainerRuntime = "docker" | "podman"; + /** - * Spawn a container-CLI command and return the process handle. Use when the - * caller needs to read stdout/stderr or await the exit code itself. + * {@link spawnContainerCli}, but also reporting which runtime answered — for + * callers that print a command for the user to run themselves, where naming + * `docker` on a Podman-only host would be uncopyable advice. */ -export const spawnContainerCli = ( +export const legacySpawnContainerCliWithRuntime = ( spawner: Spawner, args: ReadonlyArray<string>, options?: ChildProcess.CommandOptions, ) => - spawner - .spawn(ChildProcess.make("docker", args, options)) - .pipe( - Effect.catch(() => - spawner - .spawn(ChildProcess.make("podman", args, options)) - .pipe( - Effect.catch(() => - Effect.fail( - new LegacyContainerRuntimeNotFoundError({ message: RUNTIME_NOT_FOUND_MESSAGE }), - ), - ), + spawner.spawn(ChildProcess.make("docker", args, options)).pipe( + Effect.map((handle) => ({ handle, runtime: dockerRuntime })), + Effect.catch(() => + spawner.spawn(ChildProcess.make("podman", args, options)).pipe( + Effect.map((handle) => ({ handle, runtime: podmanRuntime })), + Effect.catch(() => + Effect.fail( + new LegacyContainerRuntimeNotFoundError({ message: RUNTIME_NOT_FOUND_MESSAGE }), ), + ), ), - ); + ), + ); + +const dockerRuntime: LegacyContainerRuntime = "docker"; +const podmanRuntime: LegacyContainerRuntime = "podman"; + +/** + * Spawn a container-CLI command and return the process handle. Use when the + * caller needs to read stdout/stderr or await the exit code itself. + */ +export const spawnContainerCli = ( + spawner: Spawner, + args: ReadonlyArray<string>, + options?: ChildProcess.CommandOptions, +) => legacySpawnContainerCliWithRuntime(spawner, args, options).pipe(Effect.map((it) => it.handle)); /** * Run a container-CLI command and resolve to its exit code, mirroring the diff --git a/apps/cli/src/legacy/shared/legacy-db-config.integration.test.ts b/apps/cli/src/legacy/shared/legacy-db-config.integration.test.ts index c277d2930e..83c414b3b6 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.integration.test.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.integration.test.ts @@ -163,7 +163,6 @@ describe("legacyDbConfigResolver (local + db-url)", () => { suggestionContext: { dashboardUrl: "https://supabase.com/dashboard", profileName: "supabase", - debug: false, }, }); expect(r.isLocal).toBe(true); @@ -216,7 +215,6 @@ describe("legacyDbConfigResolver (local + db-url)", () => { suggestionContext: { dashboardUrl: "https://supabase.com/dashboard", profileName: "supabase", - debug: false, }, }); expect(r.isLocal).toBe(false); @@ -510,7 +508,6 @@ describe("legacyDbConfigResolver (linked config ordering)", () => { suggestionContext: { dashboardUrl: "https://supabase.com/dashboard", profileName: "supabase", - debug: false, }, }); expect(r.ref).toEqual(Option.some(adHocRef)); @@ -668,7 +665,6 @@ describe("legacyDbConfigResolver (linked config ordering)", () => { suggestionContext: { dashboardUrl: "https://supabase.com/dashboard", profileName: "supabase", - debug: false, }, }); } @@ -810,7 +806,6 @@ describe("legacyDbConfigResolver (linked config ordering)", () => { suggestionContext: { dashboardUrl: "https://supabase.com/dashboard", profileName: "supabase", - debug: false, }, }); } diff --git a/apps/cli/src/legacy/shared/legacy-db-config.layer.ts b/apps/cli/src/legacy/shared/legacy-db-config.layer.ts index 45714feb97..b4de96d26b 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.layer.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.layer.ts @@ -108,13 +108,12 @@ export const legacyDbConfigLayer = Layer.effect( const path = yield* Path.Path; // Profile context for the connect-failure suggestion (Go's `SetConnectSuggestion` - // reads the ambient `CurrentProfile` + `viper.GetBool("DEBUG")`). Snapshot it once + // reads the ambient `CurrentProfile`). Snapshot it once // and attach it to every resolved connection so the driver layer can render Go's // hint on a refused/auth/IPv6 connect error. const suggestionContext: LegacyConnectSuggestionContext = { dashboardUrl: cliConfig.dashboardUrl, profileName: cliConfig.profile, - debug: yield* LegacyDebugFlag, }; // Capture the ambient services the Management API stack needs, so the diff --git a/apps/cli/src/legacy/shared/legacy-db-connection.errors.ts b/apps/cli/src/legacy/shared/legacy-db-connection.errors.ts index 49b68bbbce..94b66c6938 100644 --- a/apps/cli/src/legacy/shared/legacy-db-connection.errors.ts +++ b/apps/cli/src/legacy/shared/legacy-db-connection.errors.ts @@ -24,6 +24,20 @@ export class LegacyDbExecError extends Data.TaggedError("LegacyDbExecError")<{ * missing migration-history table, not an undefined column. */ readonly code?: string; + /** + * Postgres `Detail` field of a server ErrorResponse (Go's `pgErr.Detail`). + * Only set for server errors that carry a non-empty detail; the migration-apply + * error context renders it on its own line, matching Go's `ExecBatch` + * (`pkg/migration/file.go:99-101`). + */ + readonly detail?: string; + /** + * Postgres error cursor of a server ErrorResponse (Go's `pgErr.Position`): a + * 1-based index into the failing statement. Only set when the server reported a + * position > 0. The migration-apply error context uses it to render Go's `^` + * caret under the error position (`pkg/migration/file.go:98`, `markError`). + */ + readonly position?: number; }> {} /** diff --git a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.integration.test.ts b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.integration.test.ts new file mode 100644 index 0000000000..0af60b46c9 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.integration.test.ts @@ -0,0 +1,337 @@ +/** + * Connection-failure behavior of the real `@effect/sql-pg` driver layer against + * real sockets: the `LegacyDbConnectError` message must carry Go's + * `failed to connect to postgres: failed to connect to + * `host=… user=… database=…`: <cause>` structure (pgconn `errors.go:66-72`, + * `pkg/pgxv5/connect.go:33`), and the connect suggestion must classify real + * node-postgres error shapes, not libpq wording. + */ +import * as net from "node:net"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; + +import { LEGACY_SUGGEST_ENV_VAR, LEGACY_SUGGEST_LOCAL_STACK } from "./legacy-connect-errors.ts"; +import type { LegacyDbConnectError, LegacyDbExecError } from "./legacy-db-connection.errors.ts"; +import { type LegacyPgConnInput, LegacyDbConnection } from "./legacy-db-connection.service.ts"; +import { legacyDbConnectionSqlPgLayer } from "./legacy-db-connection.sql-pg.layer.ts"; + +const SUGGESTION_CONTEXT = { + dashboardUrl: "https://supabase.com/dashboard", + profileName: "supabase", + debug: false, +} as const; + +// A distinctive sentinel so a regression that leaks the password into the +// rendered message or suggestion fails the assertions below (pgconn embeds only +// host/user/database — never the password). +const SENTINEL_PASSWORD = "s3cr3t-pw-do-not-leak"; + +/** + * Connect through the real layer and flip the expected failure into the value. + * `isLocal` defaults to `true`, pass `false` to drive the remote/`--linked` suggestion branch instead. + */ +const connectFailure = ( + cfg: Partial<LegacyPgConnInput> & { readonly port: number }, + isLocal = true, +): Effect.Effect<LegacyDbConnectError> => + Effect.gen(function* () { + const conn = yield* LegacyDbConnection; + return yield* conn + .connect( + { + host: "127.0.0.1", + user: "postgres", + password: SENTINEL_PASSWORD, + database: "postgres", + suggestionContext: SUGGESTION_CONTEXT, + ...cfg, + }, + { isLocal, dnsResolver: "native" }, + ) + .pipe( + Effect.scoped, + Effect.flip, + Effect.mapError(() => new Error("expected the connection to fail")), + Effect.orDie, + ); + }).pipe(Effect.provide(legacyDbConnectionSqlPgLayer)); + +/** A TCP port that is guaranteed closed: bind an ephemeral port, then release it. */ +const acquireClosedPort = (): Promise<number> => + new Promise((resolve, reject) => { + const server = net.createServer(); + server.listen(0, "127.0.0.1", () => { + const address = server.address() as net.AddressInfo; + server.close((error) => (error === undefined ? resolve(address.port) : reject(error))); + }); + }); + +/** Encode a Postgres wire-protocol ErrorResponse ('E') message. */ +const errorResponse = (fields: Record<string, string>): Buffer => { + const parts: Array<Buffer> = []; + for (const [key, value] of Object.entries(fields)) { + parts.push(Buffer.from(key, "ascii"), Buffer.from(value, "utf8"), Buffer.from([0])); + } + parts.push(Buffer.from([0])); + const body = Buffer.concat(parts); + const head = Buffer.alloc(5); + head.write("E", 0, "ascii"); + head.writeInt32BE(body.length + 4, 1); + return Buffer.concat([head, body]); +}; + +/** + * A minimal fake Postgres server: answers `N` to an SSLRequest and hands the + * first startup message to `onStartup`, so tests can drive the real driver + * through real server-side failure shapes. + */ +const fakePostgresServer = ( + onStartup: (socket: net.Socket) => void, +): Promise<{ readonly port: number; readonly close: () => void }> => + new Promise((resolve) => { + const server = net.createServer((socket) => { + let sawStartup = false; + socket.on("data", (data: Buffer) => { + // SSLRequest: 8-byte message with request code 80877103. + if (!sawStartup && data.length >= 8 && data.readInt32BE(4) === 80877103) { + socket.write("N"); + return; + } + if (!sawStartup) { + sawStartup = true; + onStartup(socket); + } + }); + socket.on("error", () => {}); + }); + server.listen(0, "127.0.0.1", () => { + const address = server.address() as net.AddressInfo; + resolve({ port: address.port, close: () => server.close() }); + }); + }); + +/** Encode a Postgres wire-protocol message with a 1-byte type + int32 length + body. */ +const wireMessage = (type: string, body: Buffer): Buffer => { + const head = Buffer.alloc(5); + head.write(type, 0, "ascii"); + head.writeInt32BE(body.length + 4, 1); + return Buffer.concat([head, body]); +}; + +// AuthenticationOk ('R', code 0) + ReadyForQuery ('Z', idle). +const AUTHENTICATION_OK = wireMessage("R", Buffer.from([0, 0, 0, 0])); +const READY_FOR_QUERY = wireMessage("Z", Buffer.from("I", "ascii")); +const commandComplete = (tag: string): Buffer => + wireMessage("C", Buffer.concat([Buffer.from(tag, "utf8"), Buffer.from([0])])); + +/** + * A fake Postgres server that completes the startup handshake (no auth) and + * answers every simple-protocol query ('Q') via `onQuery`, so tests can drive + * the REAL driver stack — node-postgres wire parsing → `DatabaseError` → + * `@effect/sql-pg`'s `SqlError` wrapping → `legacyToExecError` — through real + * server-side statement failures. + */ +const fakeQueryServer = ( + onQuery: (sql: string) => Buffer, +): Promise<{ readonly port: number; readonly close: () => void }> => + new Promise((resolve) => { + const server = net.createServer((socket) => { + let sawStartup = false; + let pending = Buffer.alloc(0); + socket.on("data", (data: Buffer) => { + pending = Buffer.concat([pending, data]); + for (;;) { + if (!sawStartup) { + if (pending.length < 8) return; + const length = pending.readInt32BE(0); + if (pending.length < length) return; + // SSLRequest: request code 80877103 → answer `N` (no TLS). + if (pending.readInt32BE(4) === 80877103) { + socket.write("N"); + } else { + sawStartup = true; + socket.write(Buffer.concat([AUTHENTICATION_OK, READY_FOR_QUERY])); + } + pending = pending.subarray(length); + continue; + } + // Regular frames: [type:1][length:4][body:length-4]. + if (pending.length < 5) return; + const length = pending.readInt32BE(1); + if (pending.length < length + 1) return; + const type = String.fromCharCode(pending[0] ?? 0); + const body = pending.subarray(5, length + 1); + pending = pending.subarray(length + 1); + if (type === "Q") { + // Query body is a NUL-terminated SQL string. + const sql = body.toString("utf8", 0, body.length - 1); + socket.write(onQuery(sql)); + } + // Ignore Terminate ('X') and anything else. + } + }); + socket.on("error", () => {}); + }); + server.listen(0, "127.0.0.1", () => { + const address = server.address() as net.AddressInfo; + resolve({ port: address.port, close: () => server.close() }); + }); + }); + +describe("legacyDbConnectionSqlPgLayer connect failures", () => { + it.live( + "surfaces host, user, database, and the driver cause when a remote (--linked) connection is refused", + () => + Effect.gen(function* () { + const port = yield* Effect.promise(acquireClosedPort); + const error = yield* connectFailure({ port }, false); + expect(error._tag).toBe("LegacyDbConnectError"); + expect(error.message).toBe( + "failed to connect to postgres: failed to connect to `host=127.0.0.1 user=postgres database=postgres`: " + + `dial error (connect ECONNREFUSED 127.0.0.1:${port})`, + ); + expect(error.suggestion).toBe( + "Make sure your local IP is allowed in Network Restrictions and Network Bans.\n" + + "https://supabase.com/dashboard/project/_/database/settings", + ); + expect(error.message).not.toContain(SENTINEL_PASSWORD); + }), + ); + + it.live("surfaces the local-stack hint when a local connection is refused", () => + Effect.gen(function* () { + const port = yield* Effect.promise(acquireClosedPort); + const error = yield* connectFailure({ port }); + expect(error.suggestion).toBe(LEGACY_SUGGEST_LOCAL_STACK); + }), + ); + + it.live( + "reproduces pgconn's server-error rendering for an auth failure and suggests SUPABASE_DB_PASSWORD", + () => + Effect.gen(function* () { + const server = yield* Effect.promise(() => + fakePostgresServer((socket) => { + socket.write( + errorResponse({ + S: "FATAL", + V: "FATAL", + C: "28P01", + M: 'password authentication failed for user "postgres"', + F: "auth.c", + L: "326", + R: "auth_failed", + }), + ); + socket.end(); + }), + ); + const error = yield* connectFailure({ port: server.port }).pipe( + Effect.ensuring(Effect.sync(server.close)), + ); + expect(error.message).toBe( + "failed to connect to postgres: failed to connect to `host=127.0.0.1 user=postgres database=postgres`: " + + 'server error (FATAL: password authentication failed for user "postgres" (SQLSTATE 28P01))', + ); + expect(error.suggestion).toBe(LEGACY_SUGGEST_ENV_VAR); + expect(error.message).not.toContain(SENTINEL_PASSWORD); + }), + ); + + it.live( + "keeps the CLI-1942 session-pooler EOF shape unclassified while surfacing the cause", + () => + Effect.gen(function* () { + // The session pooler dropping the connection (CLI-1942) surfaces as + // node-postgres' `Connection terminated unexpectedly`. Go has no + // suggestion branch for the equivalent `unexpected EOF`, so no + // suggestion may fire — the generic --debug fallback applies. + const server = yield* Effect.promise(() => + fakePostgresServer((socket) => socket.destroy()), + ); + const error = yield* connectFailure({ + port: server.port, + user: "postgres.abcdefghijklmnopqrst", + }).pipe(Effect.ensuring(Effect.sync(server.close))); + expect(error.message).toBe( + "failed to connect to postgres: failed to connect to " + + "`host=127.0.0.1 user=postgres.abcdefghijklmnopqrst database=postgres`: " + + "Connection terminated unexpectedly", + ); + expect(error.suggestion).toBeUndefined(); + }), + ); +}); + +describe("legacyDbConnectionSqlPgLayer exec failures", () => { + it.live( + "maps a real wire ErrorResponse to pgconn's PgError rendering with detail and position", + () => + // Tripwire for the server-error extraction: drives the REAL driver stack + // (node-postgres wire parsing → `DatabaseError` → `@effect/sql-pg`'s + // `SqlError` cause chain → `legacyToExecError`), so a dependency bump that + // changes the error wrapping fails here instead of silently degrading + // migration-apply failures back to the opaque driver text. + Effect.gen(function* () { + const failing = "CREATE TABLE test (path ltree NOT NULL)"; + const server = yield* Effect.promise(() => + fakeQueryServer((sql) => + sql === failing + ? Buffer.concat([ + // `S` (localized) and `V` (unlocalized) are deliberately distinct: + // Go renders pgconn's `PgError.Severity`, populated from the wire + // `S` field (pgproto3 `error_response.go` maps 'S'→Severity, + // 'V'→SeverityUnlocalized), so a localized server prints e.g. + // `FEHLER: …`. pg-protocol likewise assigns `severity = fields.S` + // (`parser.js` parseErrorMessage); asserting `FEHLER` below fails + // the tripwire if a dependency bump ever renders `V` instead. + errorResponse({ + S: "FEHLER", + V: "ERROR", + C: "42704", + M: 'type "ltree" does not exist', + D: "Detail from the server.", + P: "25", + F: "parse_type.c", + L: "270", + R: "typenameType", + }), + READY_FOR_QUERY, + ]) + : Buffer.concat([commandComplete("SELECT 1"), READY_FOR_QUERY]), + ), + ); + const error: LegacyDbConnectError | LegacyDbExecError = yield* Effect.gen(function* () { + const conn = yield* LegacyDbConnection; + return yield* conn + .connect( + { + host: "127.0.0.1", + port: server.port, + user: "postgres", + password: "postgres", + database: "postgres", + }, + { isLocal: true, dnsResolver: "native" }, + ) + .pipe( + Effect.flatMap((session) => session.exec(failing)), + Effect.scoped, + Effect.flip, + Effect.mapError(() => new Error("expected the statement to fail")), + Effect.orDie, + ); + }).pipe( + Effect.provide(legacyDbConnectionSqlPgLayer), + Effect.ensuring(Effect.sync(server.close)), + ); + expect(error._tag).toBe("LegacyDbExecError"); + expect(error.message).toBe('FEHLER: type "ltree" does not exist (SQLSTATE 42704)'); + if (error._tag === "LegacyDbExecError") { + expect(error.code).toBe("42704"); + expect(error.detail).toBe("Detail from the server."); + expect(error.position).toBe(25); + } + }), + ); +}); diff --git a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts index 7553b1a3e0..a05da9fd73 100644 --- a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts +++ b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts @@ -11,7 +11,11 @@ import { ConnectionError, SqlError } from "effect/unstable/sql/SqlError"; // resolves, so the COPY path and the pooled path use the same driver. import * as Pg from "pg"; import { to as pgCopyTo } from "pg-copy-streams"; -import { legacyConnectSuggestion } from "./legacy-connect-errors.ts"; +import { + legacyConnectFailureMessage, + legacyConnectSuggestion, + legacyIsSqlState, +} from "./legacy-connect-errors.ts"; import { LegacyDbConnectError, LegacyDbCopyError, @@ -124,6 +128,70 @@ function legacyExtractSqlState(error: unknown): string | undefined { return undefined; } +/** Structured fields of a Postgres server ErrorResponse (pgconn's `PgError` subset). */ +interface LegacyPgServerError { + readonly severity: string; + readonly message: string; + readonly code: string; + readonly detail?: string; + readonly position?: number; +} + +/** + * Extracts the server ErrorResponse from a driver error's `cause` chain. The + * `@effect/sql` `SqlError` wraps its reason on `cause`, and the reason wraps the + * node-postgres `DatabaseError` the same way; a `DatabaseError` is identified by + * its string `severity` plus a SQLSTATE-shaped `code` (never a node system error). + * `detail` and `position` mirror Go's `pgErr.Detail`/`pgErr.Position` + * (node-postgres carries `position` as a decimal string): `detail` only when + * non-empty, `position` only when > 0, matching Go's gates in `ExecBatch` + * (`pkg/migration/file.go:98-101` — `markError` no-ops on 0 anyway). + */ +function legacyExtractPgServerError(error: unknown): LegacyPgServerError | undefined { + let current: unknown = error; + for (let depth = 0; depth < 6 && typeof current === "object" && current !== null; depth++) { + const severity = Reflect.get(current, "severity"); + const code = Reflect.get(current, "code"); + if (typeof severity === "string" && typeof code === "string" && legacyIsSqlState(code)) { + const message = Reflect.get(current, "message"); + const detail = Reflect.get(current, "detail"); + const rawPosition = Reflect.get(current, "position"); + const position = typeof rawPosition === "string" ? Number.parseInt(rawPosition, 10) : NaN; + return { + severity, + message: typeof message === "string" ? message : "", + code, + ...(typeof detail === "string" && detail.length > 0 ? { detail } : {}), + ...(Number.isInteger(position) && position > 0 ? { position } : {}), + }; + } + current = Reflect.get(current, "cause"); + } + return undefined; +} + +/** + * Maps a failed statement to `LegacyDbExecError`. A server ErrorResponse renders + * pgconn's `PgError.Error()` byte-for-byte — `<Severity>: <Message> (SQLSTATE + * <Code>)` (pgconn `errors.go:51`) — which is the head line Go prints when a + * migration statement fails (`pkg/migration/file.go:112`, `%w`), and carries the + * structured `detail`/`position` fields the migration-apply error context renders + * (Go `file.go:96-110`). Non-server failures (socket drops, driver errors) keep + * the driver's own text. + */ +export function legacyToExecError(error: unknown): LegacyDbExecError { + const server = legacyExtractPgServerError(error); + if (server !== undefined) { + return new LegacyDbExecError({ + message: `${server.severity}: ${server.message} (SQLSTATE ${server.code})`, + code: server.code, + detail: server.detail, + position: server.position, + }); + } + return new LegacyDbExecError({ message: String(error), code: legacyExtractSqlState(error) }); +} + /** * Whether a dial host is a libpq unix-socket path. pgconn skips TLS/DNS entirely for * a unix `NetworkAddress` regardless of `sslmode` (jackc/pgconn `configTLS`), so a @@ -584,13 +652,18 @@ const connect = ( // (`connect.go:187`), mapping the driver error to an actionable hint that replaces // the generic "--debug" suggestion. The resolver attaches the profile context to // `cfg.suggestionContext`; map it here so the suggestion travels on the error. + // The message mirrors Go's `pgxv5.Connect` wrap of pgconn's `connectError` + // (`pkg/pgxv5/connect.go:33`, pgconn `errors.go:66-72`): the `failed to connect + // to postgres:` prefix plus the `host=… user=… database=…` identity and the + // underlying driver cause — not the bare `SqlError` toString, which drops all + // of that detail. const toConnectError = (error: unknown) => { const suggestion = cfg.suggestionContext === undefined ? undefined - : legacyConnectSuggestion(error, cfg.suggestionContext); + : legacyConnectSuggestion(error, { ...cfg.suggestionContext, isLocal }); return new LegacyDbConnectError({ - message: `failed to connect to postgres: ${error}`, + message: `failed to connect to postgres: ${legacyConnectFailureMessage(cfg, error)}`, ...(suggestion === undefined ? {} : { suggestion }), }); }; @@ -767,31 +840,15 @@ const connect = ( }); const session: LegacyDbSession = { - exec: (sql) => - client.unsafe(sql).pipe( - Effect.asVoid, - Effect.mapError( - (error) => - new LegacyDbExecError({ message: String(error), code: legacyExtractSqlState(error) }), - ), - ), + exec: (sql) => client.unsafe(sql).pipe(Effect.asVoid, Effect.mapError(legacyToExecError)), query: (sql, params) => - client.unsafe<Record<string, unknown>>(sql, params).pipe( - Effect.mapError( - (error) => - new LegacyDbExecError({ - message: String(error), - code: legacyExtractSqlState(error), - }), - ), - ), + client + .unsafe<Record<string, unknown>>(sql, params) + .pipe(Effect.mapError(legacyToExecError)), extensionExists: (name) => client`select 1 from pg_extension where extname = ${name}`.pipe( Effect.map((rows) => rows.length > 0), - Effect.mapError( - (error) => - new LegacyDbExecError({ message: String(error), code: legacyExtractSqlState(error) }), - ), + Effect.mapError(legacyToExecError), ), queryRaw: (sql) => Effect.gen(function* () { diff --git a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.unit.test.ts b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.unit.test.ts index 334ecda513..4d24a158db 100644 --- a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.unit.test.ts @@ -1,5 +1,6 @@ import { EventEmitter } from "node:events"; import { Effect, Exit } from "effect"; +import { SqlError, SqlSyntaxError, UnknownError } from "effect/unstable/sql/SqlError"; import { describe, expect, it } from "vitest"; import { @@ -14,6 +15,7 @@ import { legacyMergedConnectionOptions, legacySslConfigsFor, legacySslOptionFor, + legacyToExecError, } from "./legacy-db-connection.sql-pg.layer.ts"; describe("legacyBuildConnectionUrl", () => { @@ -493,3 +495,69 @@ describe("legacyIsTerminalConnectError (pgconn fallback termination)", () => { expect(legacyIsTerminalConnectError(undefined, false)).toBe(false); }); }); + +describe("legacyToExecError (pg server-error extraction)", () => { + /** + * The real failure chain for a statement error: `@effect/sql`'s `SqlError` + * exposes its reason as `cause`, and the reason exposes the node-postgres + * `DatabaseError` (an `Error` carrying `severity`/`code`/`detail`/`position` + * string fields) as `cause` again. + */ + const sqlErrorChain = ( + server: Partial<Record<"severity" | "code" | "detail" | "position", string>> & { + message: string; + }, + ) => + new SqlError({ + reason: new SqlSyntaxError({ + cause: Object.assign(new Error(server.message), server), + message: "Failed to execute statement", + operation: "execute", + }), + }); + + it("renders pgconn's PgError message and carries code, detail, and position", () => { + const error = legacyToExecError( + sqlErrorChain({ + message: 'type "ltree" does not exist', + severity: "ERROR", + code: "42704", + detail: "Some detail line.", + position: "25", + }), + ); + expect(error.message).toBe('ERROR: type "ltree" does not exist (SQLSTATE 42704)'); + expect(error.code).toBe("42704"); + expect(error.detail).toBe("Some detail line."); + expect(error.position).toBe(25); + }); + + it("omits detail and position when the server sent none (Go's non-empty gates)", () => { + const error = legacyToExecError( + sqlErrorChain({ + message: 'syntax error at or near "BOOM"', + severity: "ERROR", + code: "42601", + detail: "", + position: "0", + }), + ); + expect(error.message).toBe('ERROR: syntax error at or near "BOOM" (SQLSTATE 42601)'); + expect(error.code).toBe("42601"); + expect(error.detail).toBeUndefined(); + expect(error.position).toBeUndefined(); + }); + + it("keeps the driver text verbatim for non-server failures", () => { + const cause = Object.assign(new Error("read ECONNRESET"), { code: "ECONNRESET" }); + const error = legacyToExecError( + new SqlError({ + reason: new UnknownError({ cause, message: "Failed to execute statement" }), + }), + ); + expect(error.message).toBe("effect/sql/SqlError: Failed to execute statement"); + expect(error.code).toBe("ECONNRESET"); + expect(error.detail).toBeUndefined(); + expect(error.position).toBeUndefined(); + }); +}); diff --git a/apps/cli/src/legacy/shared/legacy-edge-runtime-script.layer.integration.test.ts b/apps/cli/src/legacy/shared/legacy-edge-runtime-script.layer.integration.test.ts index dc7a3fb052..2d7834b39a 100644 --- a/apps/cli/src/legacy/shared/legacy-edge-runtime-script.layer.integration.test.ts +++ b/apps/cli/src/legacy/shared/legacy-edge-runtime-script.layer.integration.test.ts @@ -5,7 +5,7 @@ import { Effect, Exit, Layer, Option } from "effect"; import { LegacyDebugFlag, LegacyNetworkIdFlag } from "../../shared/legacy/global-flags.ts"; import { RuntimeInfo } from "../../shared/runtime/runtime-info.service.ts"; import { LegacyCliConfig } from "../config/legacy-cli-config.service.ts"; -import { LegacyDockerRun } from "./legacy-docker-run.service.ts"; +import { LegacyDockerRun, type LegacyDockerRunOpts } from "./legacy-docker-run.service.ts"; import { legacyEdgeRuntimeScriptLayer } from "./legacy-edge-runtime-script.layer.ts"; import { LegacyEdgeRuntimeScript } from "./legacy-edge-runtime-script.service.ts"; @@ -14,16 +14,25 @@ import { LegacyEdgeRuntimeScript } from "./legacy-edge-runtime-script.service.ts // "main worker has been destroyed" in stderr — the crash path only differs by // the sentinel line the templates' catch blocks add. function fakeDocker(result: { exitCode: number; stdout?: string; stderr?: string }) { - return Layer.succeed(LegacyDockerRun, { - runCapture: () => - Effect.succeed({ - exitCode: result.exitCode, - stdout: new TextEncoder().encode(result.stdout ?? ""), - stderr: result.stderr ?? "", - }), - run: () => Effect.die("unused"), - runStream: () => Effect.die("unused"), - }); + let lastOpts: LegacyDockerRunOpts | undefined; + return { + layer: Layer.succeed(LegacyDockerRun, { + runCapture: (opts) => + Effect.sync(() => { + lastOpts = opts; + return { + exitCode: result.exitCode, + stdout: new TextEncoder().encode(result.stdout ?? ""), + stderr: result.stderr ?? "", + }; + }), + run: () => Effect.die("unused"), + runStream: () => Effect.die("unused"), + }), + get lastOpts() { + return lastOpts; + }, + }; } // `workdir` points at a directory without `supabase/.temp/edge-runtime-version`, @@ -41,10 +50,11 @@ const cliConfig = Layer.succeed(LegacyCliConfig, { }); function setup(result: { exitCode: number; stdout?: string; stderr?: string }) { - return legacyEdgeRuntimeScriptLayer.pipe( + const docker = fakeDocker(result); + const layer = legacyEdgeRuntimeScriptLayer.pipe( Layer.provideMerge( Layer.mergeAll( - fakeDocker(result), + docker.layer, cliConfig, Layer.succeed(RuntimeInfo, { cwd: "/nonexistent-workdir", @@ -60,6 +70,7 @@ function setup(result: { exitCode: number; stdout?: string; stderr?: string }) { ), ), ); + return { layer, docker }; } const runScript = Effect.fnUntraced(function* () { @@ -97,7 +108,7 @@ describe("legacyEdgeRuntimeScriptLayer sentinel handling", () => { expect(message).toContain("permission denied for table pg_user_mapping"); }), ), - Effect.provide(setup({ exitCode: 1, stdout: "", stderr })), + Effect.provide(setup({ exitCode: 1, stdout: "", stderr }).layer), ); }, ); @@ -116,8 +127,29 @@ describe("legacyEdgeRuntimeScriptLayer sentinel handling", () => { exitCode: 1, stdout: "ALTER TABLE x;\n", stderr: "main worker has been destroyed\n", - }), + }).layer, ), ); }); + + it.effect( + "disables SELinux label separation so the container can read CLI-written workspace files", + () => { + // Without this the pg-delta CA bundle written under `supabase/.temp/pgdelta/` + // is unreadable through the `/workspace` bind on SELinux hosts (supabase/cli#5989). + const { layer, docker } = setup({ + exitCode: 1, + stdout: "{}", + stderr: "main worker has been destroyed\n", + }); + return runScript().pipe( + Effect.tap(() => + Effect.sync(() => { + expect(docker.lastOpts?.securityOpt).toEqual(["label:disable"]); + }), + ), + Effect.provide(layer), + ); + }, + ); }); diff --git a/apps/cli/src/legacy/shared/legacy-edge-runtime-script.layer.ts b/apps/cli/src/legacy/shared/legacy-edge-runtime-script.layer.ts index 3fa93dad83..ed0e076ada 100644 --- a/apps/cli/src/legacy/shared/legacy-edge-runtime-script.layer.ts +++ b/apps/cli/src/legacy/shared/legacy-edge-runtime-script.layer.ts @@ -107,7 +107,14 @@ export const legacyEdgeRuntimeScriptLayer = Layer.effect( env, binds: opts.binds, workingDir: Option.none(), - securityOpt: [], + // SELinux-enforcing hosts (e.g. Fedora + rootless Podman) block the + // container from reading CLI-generated files under the `/workspace` + // bind, like the pg-delta CA bundle (supabase/cli#5989). Disable label + // separation for this helper container instead of relabeling the + // user's project files — same as `db test`'s pg_prove run + // (`apps/cli-go/internal/db/test/test.go:81`); Bitbucket CI clears it + // via `legacyApplyBitbucketDockerFilter`. + securityOpt: ["label:disable"], extraHosts, network, }) diff --git a/apps/cli/src/legacy/shared/legacy-migration-apply.ts b/apps/cli/src/legacy/shared/legacy-migration-apply.ts index 9f98eb8788..046efa4b0e 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-apply.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-apply.ts @@ -1,6 +1,7 @@ import { Data, Effect, type FileSystem, type Path } from "effect"; import { Output } from "../../shared/output/output.service.ts"; +import type { LegacyDbExecError } from "./legacy-db-connection.errors.ts"; import type { LegacyDbSession } from "./legacy-db-connection.service.ts"; import { INSERT_MIGRATION_VERSION, @@ -85,6 +86,41 @@ const errMessage = (e: unknown): string => ? e.message : String(e); +const utf8ByteLength = (value: string): number => new TextEncoder().encode(value).length; + +/** + * Port of Go's `markError` (`pkg/migration/file.go:117-132`): renders a `^` caret + * line under the error position of the failing statement. `pos` is the server's + * 1-based error cursor (`pgErr.Position`); Go consumes it against **byte** + * lengths (`len(line)` on a Go string counts UTF-8 bytes) and pads the caret with + * `pos-1` space bytes, so multibyte statements shift the caret exactly as Go does + * (verified empirically against the Go implementation). The caret line REPLACES + * every line after the error line (Go's `append(lines[:j+1], caret)` truncates + * the tail). Position 0 (absent), a position past the end of the statement, or + * one landing exactly on a line break leave the statement untouched. + */ +export const legacyMarkError = (stat: string, pos: number): string => { + const lines = stat.split("\n"); + for (const [j, line] of lines.entries()) { + const c = utf8ByteLength(line); + if (pos > c) { + pos -= c + 1; + continue; + } + // Show a caret below the error position + if (pos > 0) { + return [...lines.slice(0, j + 1), `${" ".repeat(pos - 1)}^`].join("\n"); + } + break; + } + return stat; +}; + +// Go's `typeNamePattern` (`pkg/migration/file.go:31`): extracts the type name from +// PostgreSQL error messages like `type "ltree" does not exist`. Unanchored, so it +// matches identically inside the rendered `ERROR: … (SQLSTATE …)` head line. +const TYPE_NAME_PATTERN = /type "([^"]+)" does not exist/; + /** * Runs a single migration/seed file's statements (plus the optional history insert). * Mirrors Go's `(*MigrationFile).ExecBatch` (`pkg/migration/file.go`): statements run @@ -117,10 +153,30 @@ const execMigrationBatch = <E>( const version = forceNoVersion ? "" : (matches?.[1] ?? ""); const name = matches?.[2] ?? ""; - // Mirror Go's `MigrationFile.ExecBatch` error context (`pkg/migration/file.go`): - // on a failed statement, append `At statement: <index>` and the statement text. - const atStatement = (e: unknown, index: number, stat: string) => - new Error(`${errMessage(e)}\nAt statement: ${index}\n${stat}`); + // Mirror Go's `MigrationFile.ExecBatch` error context (`pkg/migration/file.go:88-113`): + // on a failed statement, render the `^` caret under the server-reported error + // position, the `Detail` line when present, the SQLSTATE-42704 extension hint, + // then `At statement: <index>` and the (caret-marked) statement text. The + // structured `detail`/`position` fields are only set by the driver for server + // ErrorResponses, mirroring Go's `errors.As(err, &pgErr)` gate. + const atStatement = (e: LegacyDbExecError, index: number, stat: string) => { + const marked = legacyMarkError(stat, e.position ?? 0); + const msg: Array<string> = []; + if (e.detail !== undefined && e.detail.length > 0) { + msg.push(e.detail); + } + // Provide helpful hint for extension type errors (SQLSTATE 42704: undefined_object) + const typeName = TYPE_NAME_PATTERN.exec(e.message)?.[1]; + if (typeName !== undefined && e.code === "42704" && !typeName.includes(".")) { + msg.push(""); + msg.push("Hint: This type may be defined in a schema that's not in your search_path."); + msg.push(" Use schema-qualified type references to avoid this error:"); + msg.push(` CREATE TABLE example (col extensions.${typeName});`); + msg.push(" Learn more: supabase migration new --help"); + } + msg.push(`At statement: ${index}`, marked); + return new Error(`${errMessage(e)}\n${msg.join("\n")}`); + }; // `executed` is the global statement index of the next statement to run, so the // error context stays accurate across flushed batches and standalone statements diff --git a/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts b/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts index a5039843de..c78a91e686 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts @@ -10,20 +10,31 @@ import type { LegacyDbSession } from "./legacy-db-connection.service.ts"; import { legacyApplyMigrationFile, legacyIsPipelineIncompatible, + legacyMarkError, legacySeedGlobals, } from "./legacy-migration-apply.ts"; class TestError extends Data.TaggedError("TestError")<{ readonly message: string }> {} -class FakeExecError extends Data.TaggedError("LegacyDbExecError")<{ readonly message: string }> {} +class FakeExecError extends Data.TaggedError("LegacyDbExecError")<{ + readonly message: string; + readonly code?: string; + readonly detail?: string; + readonly position?: number; +}> {} -function fakeSession(opts: { failOn?: string } = {}) { +function fakeSession( + opts: { + failOn?: string; + failWith?: { message: string; code?: string; detail?: string; position?: number }; + } = {}, +) { const calls: Array<{ kind: "exec" | "query"; sql: string; params?: ReadonlyArray<unknown> }> = []; const session: LegacyDbSession = { exec: (sql) => { calls.push({ kind: "exec", sql }); return opts.failOn !== undefined && sql.includes(opts.failOn) - ? Effect.fail(new FakeExecError({ message: "exec failed" })) + ? Effect.fail(new FakeExecError(opts.failWith ?? { message: "exec failed" })) : Effect.void; }, query: (sql, params) => { @@ -184,6 +195,163 @@ describe("legacyApplyMigrationFile", () => { }); }); +describe("migration failure rendering (Go ExecBatch parity)", () => { + // Byte-exact ports of Go's `(*MigrationFile).ExecBatch` error layout + // (`pkg/migration/file.go:88-113`): `<pg error>\n[Detail]\n[42704 hint]\n` + // `At statement: <i>\n<caret-marked statement>`. + const failing = ( + sql: string, + failWith: { message: string; code?: string; detail?: string; position?: number }, + ) => { + const dir = mkdtempSync(join(tmpdir(), "legacy-apply-")); + const file = join(dir, "20240101120000_fail.sql"); + writeFileSync(file, `${sql};`); + const { session } = fakeSession({ failOn: sql, failWith }); + return run(session, file).pipe( + Effect.flip, + Effect.map((error) => error.message), + Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), + ); + }; + + it.effect("marks the error position with a caret under the failing statement", () => { + const stat = "CREATE TABLE test (path ltree NOT NULL)"; + return failing(stat, { + message: 'ERROR: type "ltree" does not exist (SQLSTATE 42704)', + code: "42704", + position: 25, + }).pipe( + Effect.tap((message) => + Effect.sync(() => { + expect(message).toBe( + 'ERROR: type "ltree" does not exist (SQLSTATE 42704)\n' + + "\n" + + "Hint: This type may be defined in a schema that's not in your search_path.\n" + + " Use schema-qualified type references to avoid this error:\n" + + " CREATE TABLE example (col extensions.ltree);\n" + + " Learn more: supabase migration new --help\n" + + "At statement: 0\n" + + "CREATE TABLE test (path ltree NOT NULL)\n" + + " ^", + ); + }), + ), + ); + }); + + it.effect("renders the server Detail line before the statement context", () => { + const stat = "INSERT INTO child VALUES (1)"; + return failing(stat, { + message: + 'ERROR: insert or update on table "child" violates foreign key constraint "child_parent_id_fkey" (SQLSTATE 23503)', + code: "23503", + detail: 'Key (parent_id)=(1) is not present in table "parent".', + }).pipe( + Effect.tap((message) => + Effect.sync(() => { + expect(message).toBe( + 'ERROR: insert or update on table "child" violates foreign key constraint "child_parent_id_fkey" (SQLSTATE 23503)\n' + + 'Key (parent_id)=(1) is not present in table "parent".\n' + + "At statement: 0\n" + + "INSERT INTO child VALUES (1)", + ); + }), + ), + ); + }); + + it.effect("skips the hint when the SQLSTATE is not 42704", () => { + // Go gates the hint on `pgErr.Code == "42704"` in addition to the message + // pattern — a matching message under a different code must stay hint-free. + const stat = "CREATE TABLE test (path ltree NOT NULL)"; + return failing(stat, { + message: 'ERROR: type "ltree" does not exist (SQLSTATE 42P01)', + code: "42P01", + }).pipe( + Effect.tap((message) => + Effect.sync(() => { + expect(message).toBe( + 'ERROR: type "ltree" does not exist (SQLSTATE 42P01)\n' + + "At statement: 0\n" + + "CREATE TABLE test (path ltree NOT NULL)", + ); + }), + ), + ); + }); + + it.effect("skips the 42704 hint when the type is already schema-qualified", () => { + const stat = "CREATE TABLE test (path extensions.ltree NOT NULL)"; + return failing(stat, { + message: 'ERROR: type "extensions.ltree" does not exist (SQLSTATE 42704)', + code: "42704", + }).pipe( + Effect.tap((message) => + Effect.sync(() => { + expect(message).toBe( + 'ERROR: type "extensions.ltree" does not exist (SQLSTATE 42704)\n' + + "At statement: 0\n" + + "CREATE TABLE test (path extensions.ltree NOT NULL)", + ); + }), + ), + ); + }); + + it.effect("keeps the plain layout for errors without position or detail", () => { + const stat = "ALTER TABLE a ADD COLUMN b int"; + return failing(stat, { message: "exec failed" }).pipe( + Effect.tap((message) => + Effect.sync(() => { + expect(message).toBe("exec failed\nAt statement: 0\nALTER TABLE a ADD COLUMN b int"); + }), + ), + ); + }); +}); + +describe("legacyMarkError", () => { + // Every expectation below is pinned against Go's `markError` output + // (`pkg/migration/file.go:117-132`), captured by running the Go function + // directly on the same inputs. + it("places the caret under a mid-statement position", () => { + expect(legacyMarkError("create table t (col ltree)", 21)).toBe( + "create table t (col ltree)\n ^", + ); + }); + + it("returns the statement unchanged for position 0 (absent)", () => { + expect(legacyMarkError("create table t (col ltree)", 0)).toBe("create table t (col ltree)"); + }); + + it("returns the statement unchanged when the position is past the end", () => { + expect(legacyMarkError("abc", 99)).toBe("abc"); + }); + + it("returns the statement unchanged when the position lands on a line break", () => { + expect(legacyMarkError("ab\ncd", 3)).toBe("ab\ncd"); + }); + + it("marks a position on a later line and drops the lines after it", () => { + expect(legacyMarkError("create table t (\n col ltree\n)", 20)).toBe( + "create table t (\n col ltree\n ^", + ); + expect(legacyMarkError("l1\nl2\nl3\nl4", 4)).toBe("l1\nl2\n^"); + }); + + it("places the caret under the last character when the position equals the line length", () => { + expect(legacyMarkError("abcde", 5)).toBe("abcde\n ^"); + }); + + it("consumes the position in UTF-8 bytes like Go, not characters", () => { + // "héllo" is 5 characters but 6 bytes; Go decrements the position by the + // BYTE length + 1 per line, so position 8 lands on column 1 of "world" + // (character counting would land on column 2). Verified against Go. + expect(legacyMarkError("héllo\nworld", 8)).toBe("héllo\nworld\n^"); + expect(legacyMarkError("sélect 1", 3)).toBe("sélect 1\n ^"); + }); +}); + describe("legacyIsPipelineIncompatible", () => { // Mirrors Go's `TestIsPipelineIncompatible` (`pkg/migration/file_test.go`, supabase/cli#5156). const cases: ReadonlyArray<readonly [string, string, boolean]> = [ diff --git a/apps/cli/src/legacy/shared/legacy-migration-history.unit.test.ts b/apps/cli/src/legacy/shared/legacy-migration-history.unit.test.ts index a7d2d9e132..446fe29b15 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-history.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-history.unit.test.ts @@ -1,6 +1,7 @@ import { Effect, Exit } from "effect"; import { describe, expect, it } from "vitest"; +import { stripAnsi } from "../../../tests/helpers/ansi.ts"; import { LegacyDbExecError } from "./legacy-db-connection.errors.ts"; import type { LegacyDbSession } from "./legacy-db-connection.service.ts"; import { @@ -22,10 +23,6 @@ const failingSession = (error: LegacyDbExecError): LegacyDbSession => ({ queryRaw: () => Effect.die("unused"), }); -// Strip ANSI so the bold repair suggestions compare regardless of TTY colour. -// eslint-disable-next-line no-control-regex -const stripAnsi = (text: string) => text.replace(/\x1b\[[0-9;]*m/gu, ""); - describe("legacyReconcileMigrations", () => { it("reports in-sync when remote and local match", () => { expect(legacyReconcileMigrations(["20240101000000"], ["20240101000000"])).toEqual({ diff --git a/apps/cli/src/legacy/shared/legacy-pgdelta-ssl-probe.layer.ts b/apps/cli/src/legacy/shared/legacy-pgdelta-ssl-probe.layer.ts index 4d2e0f02b5..965d83e21c 100644 --- a/apps/cli/src/legacy/shared/legacy-pgdelta-ssl-probe.layer.ts +++ b/apps/cli/src/legacy/shared/legacy-pgdelta-ssl-probe.layer.ts @@ -1,7 +1,6 @@ import { Effect, Layer } from "effect"; import * as net from "node:net"; -import { LegacyDebugFlag } from "../../shared/legacy/global-flags.ts"; import { LegacyPgDeltaSslProbe, LegacyPgDeltaSslProbeError, @@ -76,10 +75,7 @@ export function legacyInterpretSslProbeByte(byte: number | undefined): "tls" | " */ export const legacyPgDeltaSslProbeLayer = Layer.effect( LegacyPgDeltaSslProbe, - Effect.gen(function* () { - // Go disables SSL in debug mode (`require := !viper.GetBool("DEBUG")`), so a - // server that speaks TLS still reports "not required" under `--debug`. - const debug = yield* LegacyDebugFlag; + Effect.sync(() => { const probeTarget = (target: LegacySslProbeTarget) => Effect.gen(function* () { const outcome = yield* Effect.callback<"tls" | "refused", LegacyPgDeltaSslProbeError>( @@ -136,7 +132,7 @@ export const legacyPgDeltaSslProbeLayer = Layer.effect( }, ); if (outcome === "refused") return false; - return !debug; + return true; }); return LegacyPgDeltaSslProbe.of({ requireSsl: (dbUrl) => diff --git a/apps/cli/src/legacy/shared/legacy-seed-buckets.ts b/apps/cli/src/legacy/shared/legacy-seed-buckets.ts index e4c06c8227..d263c9633c 100644 --- a/apps/cli/src/legacy/shared/legacy-seed-buckets.ts +++ b/apps/cli/src/legacy/shared/legacy-seed-buckets.ts @@ -13,7 +13,7 @@ import { legacyResolveYesWithProjectEnv } from "../../shared/legacy/global-flags import { LegacyCliConfig } from "../config/legacy-cli-config.service.ts"; import { legacyBold, legacyYellow } from "./legacy-colors.ts"; import { legacyLoadProjectEnv } from "./legacy-db-config.toml-read.ts"; -import { legacyPromptYesNo } from "./legacy-prompt-yes-no.ts"; +import { legacyPromptYesNo } from "../../shared/legacy/legacy-prompt-yes-no.ts"; import { legacyResolveStorageCredentials, legacyStorageGatewayFetch, diff --git a/apps/cli/src/legacy/shared/legacy-status-pretty.unit.test.ts b/apps/cli/src/legacy/shared/legacy-status-pretty.unit.test.ts index 51337df7e5..9c970819ed 100644 --- a/apps/cli/src/legacy/shared/legacy-status-pretty.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-status-pretty.unit.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; +import { stripAnsi } from "../../../tests/helpers/ansi.ts"; import { legacyRenderStatusPretty, legacyStatusColumnLayout, @@ -8,14 +9,6 @@ import { } from "./legacy-status-pretty.ts"; import type { LegacyStatusOutputNames } from "./legacy-status-values.ts"; -// The renderer applies Go-parity ANSI styling via `legacy-colors.ts`, which -// no-ops on a real non-TTY stream but the vitest process presents its stderr -// as color-capable. Strip escapes so these assertions target the plain -// structural output — the golden contract per the port plan — not whichever -// TTY heuristic the test runner happens to report. -// eslint-disable-next-line no-control-regex -const stripAnsi = (text: string) => text.replace(/\x1b\[[0-9;]*m/gu, ""); - // Default (un-overridden) output names, matching `legacy-status-values.ts`'s // `resolveOutputNames` with an empty override map — the KEYs the pretty // renderer looks values up by. diff --git a/apps/cli/src/legacy/shared/legacy-upgrade-suggest.ts b/apps/cli/src/legacy/shared/legacy-upgrade-suggest.ts index 0466e726ec..0ff7eb7f2c 100644 --- a/apps/cli/src/legacy/shared/legacy-upgrade-suggest.ts +++ b/apps/cli/src/legacy/shared/legacy-upgrade-suggest.ts @@ -1,8 +1,11 @@ import { styleText } from "node:util"; +import type { SupabaseApiError } from "@supabase/api/effect"; import { Effect, Option } from "effect"; import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientError from "effect/unstable/http/HttpClientError"; import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import type * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import { LegacyCliConfig } from "../config/legacy-cli-config.service.ts"; import { resolveLegacyAccessToken } from "./legacy-resolve-token.ts"; @@ -23,37 +26,73 @@ function readString(obj: unknown, key: string): string { return ""; } +interface LegacyPlanGateEnvelope { + readonly feature: string; + readonly upgradeUrl: string; +} + +// Hand-validated: the packages/api codegen emits no non-2xx schemas. +function parseLegacyPlanGateEnvelope(body: unknown): LegacyPlanGateEnvelope | undefined { + if (typeof body !== "object" || body === null) return undefined; + const error = (body as { readonly error?: unknown }).error; + if (typeof error !== "object" || error === null) return undefined; + const code = readString(error, "code"); + const feature = readString(error, "feature"); + const upgradeUrl = readString(error, "upgrade_url"); + if (code !== "entitlement_required" || feature === "" || upgradeUrl === "") { + return undefined; + } + return { feature, upgradeUrl }; +} + +function legacyOrgSlugFromUpgradeUrl(upgradeUrl: string): string { + const match = /\/org\/([^/]+)/.exec(upgradeUrl); + return match?.[1] ?? ""; +} + +export function legacyGateResponse( + cause: unknown, +): HttpClientResponse.HttpClientResponse | undefined { + return HttpClientError.isHttpClientError(cause) ? cause.response : undefined; +} + +export const legacyGateMapError = + <E, R2>( + opts: { readonly projectRef: string; readonly featureKey?: string }, + mapError: (cause: SupabaseApiError) => Effect.Effect<never, E, R2>, + ) => + (cause: SupabaseApiError) => + Effect.gen(function* () { + const response = legacyGateResponse(cause); + yield* legacySuggestUpgrade({ + projectRef: opts.projectRef, + featureKey: opts.featureKey, + statusCode: response?.status ?? 0, + response, + }); + return yield* mapError(cause); + }); + /** - * Reproduces `apps/cli-go/internal/utils/plan_gate.go:SuggestUpgradeOnError`: - * - * - Skip non-4xx statuses (2xx / 5xx). - * - GET `/v1/projects/{ref}` → `organization_slug`. - * - GET `/v1/organizations/{slug}/entitlements` → look for the requested feature - * key with `hasAccess: false`. - * - On gated: write the billing-link suggestion to stderr (text mode only, - * matches Go's `CmdSuggestion` print) **and** fire the `cli_upgrade_suggested` - * telemetry event with `{feature_key, org_slug}` properties. - * - * Never fails the caller; lookup errors swallow into a no-op suggestion. + * Ports Go's `plan_gate.go:SuggestUpgradeOnError`. Never fails the caller. * - * Bypasses the typed Management API client to GET `/v1/projects/{ref}` and - * `/v1/organizations/{slug}/entitlements` directly via `HttpClient`. The - * generated `V1GetProjectOutput` schema enforces `ref: isMinLength(20)`, which - * the cli-e2e replay fixtures cannot satisfy (they embed the literal 15-char - * `__PROJECT_REF__` placeholder in response bodies). Strict decode would fail - * silently inside `Effect.option`, the entitlements GET would be skipped, and - * parity with Go's request log would break. Same workaround used by - * `legacy-linked-project-cache.layer.ts`. + * The fallback bypasses the typed API client: its strict response schemas + * reject the cli-e2e replay fixtures' placeholder refs (same workaround as + * `legacy-linked-project-cache.layer.ts`). */ export const legacySuggestUpgrade = Effect.fnUntraced(function* (opts: { readonly projectRef: string; - readonly featureKey: string; + /** + * Entitlements-fallback key. Omit for envelope-only sites: the domains + * add-on gate has no plan-level entitlement key, so the fallback would + * false-positive on unrelated 4xxs. + */ + readonly featureKey?: string; readonly statusCode: number; + readonly response?: HttpClientResponse.HttpClientResponse; /** - * Whether to fire the `cli_upgrade_suggested` analytics event when a gate is - * detected. Defaults to `true`. Pass `false` for Go call-sites that invoke - * `SuggestUpgradeOnError` without a following `TrackUpgradeSuggested` - * (e.g. `vanity-subdomains check-availability`), so telemetry stays 1:1 with Go. + * Set false where the Go twin fires no `TrackUpgradeSuggested` (vanity + * check-availability), keeping telemetry 1:1. */ readonly trackAnalytics?: boolean; }) { @@ -66,59 +105,86 @@ export const legacySuggestUpgrade = Effect.fnUntraced(function* (opts: { const cliConfig = yield* LegacyCliConfig; const httpClient = yield* HttpClient.HttpClient; - const tokenOpt = yield* resolveLegacyAccessToken; - const authHeader: ( - req: HttpClientRequest.HttpClientRequest, - ) => HttpClientRequest.HttpClientRequest = Option.isSome(tokenOpt) - ? HttpClientRequest.bearerToken(tokenOpt.value) - : (req) => req; - - const projectReq = HttpClientRequest.get( - `${cliConfig.apiUrl}/v1/projects/${opts.projectRef}`, - ).pipe(authHeader, HttpClientRequest.setHeader("User-Agent", cliConfig.userAgent)); - const projectResp = yield* httpClient.execute(projectReq).pipe(Effect.option); - if (projectResp._tag === "None" || projectResp.value.status !== 200) { - return; - } - const projectBody = yield* projectResp.value.json.pipe(Effect.option); - if (projectBody._tag === "None") { - return; - } - const orgSlug = readString(projectBody.value, "organization_slug"); - if (orgSlug.length === 0) { - return; - } + let gate: + | { readonly billingUrl: string; readonly feature: string; readonly orgSlug: string } + | undefined; - const entReq = HttpClientRequest.get( - `${cliConfig.apiUrl}/v1/organizations/${orgSlug}/entitlements`, - ).pipe(authHeader, HttpClientRequest.setHeader("User-Agent", cliConfig.userAgent)); - const entResp = yield* httpClient.execute(entReq).pipe(Effect.option); - if (entResp._tag === "None" || entResp.value.status !== 200) { - return; - } - const entBody = yield* entResp.value.json.pipe(Effect.option); - if (entBody._tag === "None") { - return; - } - const entitlements = (entBody.value as { entitlements?: unknown }).entitlements; - if (!Array.isArray(entitlements)) { - return; + if (opts.response !== undefined) { + const body = yield* opts.response.json.pipe(Effect.option); + const envelope = Option.isSome(body) ? parseLegacyPlanGateEnvelope(body.value) : undefined; + if (envelope !== undefined) { + gate = { + billingUrl: envelope.upgradeUrl, + feature: envelope.feature, + orgSlug: legacyOrgSlugFromUpgradeUrl(envelope.upgradeUrl), + }; + } } - const gated = entitlements.some((entry: unknown) => { - if (typeof entry !== "object" || entry === null) return false; - const feature = (entry as { feature?: unknown }).feature; - if (typeof feature !== "object" || feature === null) return false; - const key = (feature as { key?: unknown }).key; - const hasAccess = (entry as { hasAccess?: unknown }).hasAccess; - return key === opts.featureKey && hasAccess === false; - }); - if (!gated) { - return; + if (gate === undefined) { + if (opts.featureKey === undefined || opts.featureKey === "") { + return; + } + + const tokenOpt = yield* resolveLegacyAccessToken; + const authHeader: ( + req: HttpClientRequest.HttpClientRequest, + ) => HttpClientRequest.HttpClientRequest = Option.isSome(tokenOpt) + ? HttpClientRequest.bearerToken(tokenOpt.value) + : (req) => req; + + const projectReq = HttpClientRequest.get( + `${cliConfig.apiUrl}/v1/projects/${opts.projectRef}`, + ).pipe(authHeader, HttpClientRequest.setHeader("User-Agent", cliConfig.userAgent)); + const projectResp = yield* httpClient.execute(projectReq).pipe(Effect.option); + if (projectResp._tag === "None" || projectResp.value.status !== 200) { + return; + } + const projectBody = yield* projectResp.value.json.pipe(Effect.option); + if (projectBody._tag === "None") { + return; + } + const orgSlug = readString(projectBody.value, "organization_slug"); + if (orgSlug.length === 0) { + return; + } + + const entReq = HttpClientRequest.get( + `${cliConfig.apiUrl}/v1/organizations/${orgSlug}/entitlements`, + ).pipe(authHeader, HttpClientRequest.setHeader("User-Agent", cliConfig.userAgent)); + const entResp = yield* httpClient.execute(entReq).pipe(Effect.option); + if (entResp._tag === "None" || entResp.value.status !== 200) { + return; + } + const entBody = yield* entResp.value.json.pipe(Effect.option); + if (entBody._tag === "None") { + return; + } + const entitlements = (entBody.value as { entitlements?: unknown }).entitlements; + if (!Array.isArray(entitlements)) { + return; + } + + const gated = entitlements.some((entry: unknown) => { + if (typeof entry !== "object" || entry === null) return false; + const feature = (entry as { feature?: unknown }).feature; + if (typeof feature !== "object" || feature === null) return false; + const key = (feature as { key?: unknown }).key; + const hasAccess = (entry as { hasAccess?: unknown }).hasAccess; + return key === opts.featureKey && hasAccess === false; + }); + if (!gated) { + return; + } + + gate = { + billingUrl: legacyBillingUrl(cliConfig.profile, orgSlug), + feature: opts.featureKey, + orgSlug, + }; } - const url = legacyBillingUrl(cliConfig.profile, orgSlug); - const suggestion = `Your organization does not have access to this feature. Upgrade your plan: ${styleText("bold", url)}`; + const suggestion = `Your organization does not have access to this feature. Upgrade your plan: ${styleText("bold", gate.billingUrl)}`; if (output.format === "text") { yield* output.raw(suggestion + "\n", "stderr"); @@ -126,8 +192,8 @@ export const legacySuggestUpgrade = Effect.fnUntraced(function* (opts: { if (opts.trackAnalytics !== false) { yield* analytics.capture(EventUpgradeSuggested, { - [PropFeatureKey]: opts.featureKey, - [PropOrgSlug]: orgSlug, + [PropFeatureKey]: gate.feature, + [PropOrgSlug]: gate.orgSlug, }); } }); diff --git a/apps/cli/src/legacy/shared/legacy-upgrade-suggest.unit.test.ts b/apps/cli/src/legacy/shared/legacy-upgrade-suggest.unit.test.ts index 103ab98348..8b4246bd7c 100644 --- a/apps/cli/src/legacy/shared/legacy-upgrade-suggest.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-upgrade-suggest.unit.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect } from "effect"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import { mockAnalytics, mockOutput } from "../../../tests/helpers/mocks.ts"; import { @@ -224,4 +226,145 @@ describe("legacySuggestUpgrade", () => { expect(out.stderrText).toContain("Upgrade your plan:"); }).pipe(Effect.provide(layer)); }); + + const ENVELOPE_BODY = { + message: "Branching is supported only on the Pro plan or above", + error: { + code: "entitlement_required", + feature: "branching_persistent", + upgrade_url: "https://supabase.com/dashboard/org/env-org/billing", + }, + }; + + function gateResponse(status: number, body: unknown) { + const request = HttpClientRequest.get("https://api.supabase.com/v1/projects/x/branches"); + return legacyJsonResponse(request, status, body); + } + + it.live("envelope path suggests with zero API calls and envelope-derived telemetry", () => { + const { layer, out, analytics, api } = setup(); + return Effect.gen(function* () { + yield* legacySuggestUpgrade({ + projectRef: LEGACY_VALID_REF, + featureKey: "branching_limit", + statusCode: 402, + response: gateResponse(402, ENVELOPE_BODY), + }); + expect(api.requests).toHaveLength(0); + expect(out.stderrText).toContain("Upgrade your plan:"); + expect(out.stderrText).toContain("/org/env-org/billing"); + expect(analytics.captured).toEqual([ + { + event: "cli_upgrade_suggested", + properties: { feature_key: "branching_persistent", org_slug: "env-org" }, + }, + ]); + }).pipe(Effect.provide(layer)); + }); + + it.live("envelope path works without a featureKey (envelope-only sites)", () => { + const { layer, out, analytics, api } = setup(); + return Effect.gen(function* () { + yield* legacySuggestUpgrade({ + projectRef: LEGACY_VALID_REF, + statusCode: 400, + response: gateResponse(400, { + message: "add-on required", + error: { + code: "entitlement_required", + feature: "custom_domain", + upgrade_url: "https://supabase.com/dashboard/org/env-org/billing", + }, + }), + }); + expect(api.requests).toHaveLength(0); + expect(out.stderrText).toContain("Upgrade your plan:"); + expect(analytics.captured).toEqual([ + { + event: "cli_upgrade_suggested", + properties: { feature_key: "custom_domain", org_slug: "env-org" }, + }, + ]); + }).pipe(Effect.provide(layer)); + }); + + it.live("no envelope and no featureKey is a no-op with zero API calls", () => { + const { layer, out, analytics, api } = setup(); + return Effect.gen(function* () { + yield* legacySuggestUpgrade({ + projectRef: LEGACY_VALID_REF, + statusCode: 404, + response: gateResponse(404, { message: "not found" }), + }); + expect(api.requests).toHaveLength(0); + expect(analytics.captured).toHaveLength(0); + expect(out.stderrText).toBe(""); + }).pipe(Effect.provide(layer)); + }); + + it.live("envelope without upgrade_url falls back to the entitlements round-trip", () => { + const { layer, out, api } = setup(); + return Effect.gen(function* () { + yield* legacySuggestUpgrade({ + projectRef: LEGACY_VALID_REF, + featureKey: "branching_limit", + statusCode: 402, + response: gateResponse(402, { + message: "x", + error: { code: "entitlement_required", feature: "branching_limit" }, + }), + }); + expect(api.requests).toHaveLength(2); + expect(out.stderrText).toContain("/org/test-org/billing"); + }).pipe(Effect.provide(layer)); + }); + + it.live("unparseable body falls back to the entitlements round-trip", () => { + const { layer, out, api } = setup(); + return Effect.gen(function* () { + const request = HttpClientRequest.get("https://api.supabase.com/v1/projects/x/branches"); + const malformed = HttpClientResponse.fromWeb( + request, + new Response("not json", { + status: 402, + headers: { "content-type": "application/json" }, + }), + ); + yield* legacySuggestUpgrade({ + projectRef: LEGACY_VALID_REF, + featureKey: "branching_limit", + statusCode: 402, + response: malformed, + }); + expect(api.requests).toHaveLength(2); + expect(out.stderrText).toContain("/org/test-org/billing"); + }).pipe(Effect.provide(layer)); + }); + + it.live("envelope path respects json output mode (no stderr, telemetry fires)", () => { + const { layer, out, analytics } = setup({ format: "json" }); + return Effect.gen(function* () { + yield* legacySuggestUpgrade({ + projectRef: LEGACY_VALID_REF, + statusCode: 402, + response: gateResponse(402, ENVELOPE_BODY), + }); + expect(out.stderrText).toBe(""); + expect(analytics.captured).toHaveLength(1); + }).pipe(Effect.provide(layer)); + }); + + it.live("envelope path respects trackAnalytics=false", () => { + const { layer, out, analytics } = setup(); + return Effect.gen(function* () { + yield* legacySuggestUpgrade({ + projectRef: LEGACY_VALID_REF, + statusCode: 402, + response: gateResponse(402, ENVELOPE_BODY), + trackAnalytics: false, + }); + expect(analytics.captured).toHaveLength(0); + expect(out.stderrText).toContain("Upgrade your plan:"); + }).pipe(Effect.provide(layer)); + }); }); diff --git a/apps/cli/src/next/commands/functions/deploy/deploy.command.ts b/apps/cli/src/next/commands/functions/deploy/deploy.command.ts index 5d499eb70d..df4061ca3b 100644 --- a/apps/cli/src/next/commands/functions/deploy/deploy.command.ts +++ b/apps/cli/src/next/commands/functions/deploy/deploy.command.ts @@ -7,6 +7,7 @@ import { platformApiLayer } from "../../../auth/platform-api.layer.ts"; import { projectLinkStateLayer } from "../../../config/project-link-state.layer.ts"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; +import { stdinLayer } from "../../../../shared/runtime/stdin.layer.ts"; import { withCommandInstrumentation } from "../../../../shared/telemetry/command-instrumentation.ts"; import { functionsDeploy } from "./deploy.handler.ts"; @@ -65,6 +66,8 @@ const functionsDeployRuntimeLayer = Layer.mergeAll( functionsDeployPlatformApiLayer, projectLinkStateLayer, functionsDeployCommandRuntimeLayer, + // `stdinLayer`: the `--prune` confirmation reads piped stdin on a non-TTY stdin. + stdinLayer, ); export const functionsDeployCommand = Command.make("deploy", config).pipe( diff --git a/apps/cli/src/next/commands/functions/deploy/deploy.integration.test.ts b/apps/cli/src/next/commands/functions/deploy/deploy.integration.test.ts index 60f5bfe80d..5709a21d5a 100644 --- a/apps/cli/src/next/commands/functions/deploy/deploy.integration.test.ts +++ b/apps/cli/src/next/commands/functions/deploy/deploy.integration.test.ts @@ -7,7 +7,7 @@ import { mkdir, realpath, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join, sep } from "node:path"; import { brotliCompressSync, constants as zlibConstants } from "node:zlib"; -import { Effect, Layer, Option, Sink, Stdio, Stream } from "effect"; +import { Effect, Exit, Layer, Option, Sink, Stdio, Stream } from "effect"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import type * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; @@ -25,6 +25,8 @@ import { mockOutput, mockProjectLinkState, mockRuntimeInfo, + mockStdin, + mockTty, } from "../../../../../tests/helpers/mocks.ts"; import { functionsDeploy } from "./deploy.handler.ts"; import type { FunctionsDeployFlags } from "./deploy.command.ts"; @@ -315,6 +317,13 @@ function mockDeployApi( return jsonResponse(request, 200, makeFunction()); } + if ( + request.method === "DELETE" && + path.startsWith(`/v1/projects/${PROJECT_REF}/functions/`) + ) { + return jsonResponse(request, 200, {}); + } + return jsonResponse(request, 404, { error: "not found" }); }), ), @@ -424,6 +433,8 @@ function setup( readonly format?: "text" | "json" | "stream-json"; readonly childLayer?: Layer.Layer<ChildProcessSpawner.ChildProcessSpawner, never, never>; readonly api?: Parameters<typeof mockDeployApi>[0]; + /** Piped stdin lines consumed by the non-TTY `--prune` confirm read. */ + readonly stdinInput?: string; } = {}, ) { const out = mockOutput({ format: opts.format ?? "text", interactive: false }); @@ -439,6 +450,8 @@ function setup( Stdio.layerTest({ args: Effect.succeed(opts.rawArgs ?? ["functions", "deploy"]), }), + mockStdin(false, opts.stdinInput), + mockTty({ stdinIsTty: false, stdoutIsTty: false }), opts.childLayer ?? mockChildProcessSpawner({ exitCode: 0 }).layer, ); @@ -2287,4 +2300,120 @@ describe("functions deploy", () => { }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); }); }); + + describe("--prune confirmation (Go parity: deploy.go:180-195, console.go:64-102)", () => { + // Strip ANSI SGR (bold slugs via `legacyBold`) so byte-assertions are stable + // whether or not the test stderr supports color. + // eslint-disable-next-line no-control-regex + const stripSgr = (text: string) => text.replace(/\x1b\[[0-9;]*m/gu, ""); + const PRUNE_PROMPT = + "Do you want to delete the following Functions from your project?\n \u2022 remote-only\n\n [y/N] "; + + const pruneSetup = (tempDir: string, stdinInput?: string) => + setup(tempDir, { + rawArgs: ["functions", "deploy", "--use-api", "--prune"], + stdinInput, + api: { + listFunctions: [ + makeFunction(), + makeFunction({ slug: "remote-only", name: "remote-only" }), + ], + }, + }); + + it.live("--yes auto-confirms with the Go prompt echo and deletes the orphan", () => { + const tempDir = makeTempDir(); + return Effect.gen(function* () { + yield* Effect.promise(() => writeProjectConfig(tempDir)); + yield* Effect.promise(() => writeLocalFunction(tempDir, "hello-world")); + const { out, api, layer } = pruneSetup(tempDir); + + yield* functionsDeploy({ + ...BASE_FLAGS, + functionNames: ["hello-world"], + useApi: true, + prune: true, + yes: true, + }).pipe(Effect.provide(layer)); + + expect(stripSgr(out.stderrText)).toContain(`${PRUNE_PROMPT}y\n`); + expect(out.stderrText).toContain("Deleting Function: remote-only\n"); + expect( + api.requests.some( + (request) => + request.method === "DELETE" && + request.path === `/v1/projects/${PROJECT_REF}/functions/remote-only`, + ), + ).toBe(true); + }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); + }); + + it.live("piped `n` declines the prune and cancels like Go", () => { + const tempDir = makeTempDir(); + return Effect.gen(function* () { + yield* Effect.promise(() => writeProjectConfig(tempDir)); + yield* Effect.promise(() => writeLocalFunction(tempDir, "hello-world")); + const { out, api, layer } = pruneSetup(tempDir, "n\n"); + + const exit = yield* Effect.exit( + functionsDeploy({ + ...BASE_FLAGS, + functionNames: ["hello-world"], + useApi: true, + prune: true, + }).pipe(Effect.provide(layer)), + ); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("FunctionDeployCancelledError"); + } + // The piped answer is echoed after the label like Go's non-TTY PromptText. + expect(stripSgr(out.stderrText)).toContain(`${PRUNE_PROMPT}n\n`); + expect(api.requests.some((request) => request.method === "DELETE")).toBe(false); + }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); + }); + + it.live("piped `y` confirms the prune like Go", () => { + const tempDir = makeTempDir(); + return Effect.gen(function* () { + yield* Effect.promise(() => writeProjectConfig(tempDir)); + yield* Effect.promise(() => writeLocalFunction(tempDir, "hello-world")); + const { out, api, layer } = pruneSetup(tempDir, "y\n"); + + yield* functionsDeploy({ + ...BASE_FLAGS, + functionNames: ["hello-world"], + useApi: true, + prune: true, + }).pipe(Effect.provide(layer)); + + expect(stripSgr(out.stderrText)).toContain(`${PRUNE_PROMPT}y\n`); + expect(api.requests.some((request) => request.method === "DELETE")).toBe(true); + }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); + }); + + it.live("empty stdin takes the No default and cancels", () => { + const tempDir = makeTempDir(); + return Effect.gen(function* () { + yield* Effect.promise(() => writeProjectConfig(tempDir)); + yield* Effect.promise(() => writeLocalFunction(tempDir, "hello-world")); + const { out, api, layer } = pruneSetup(tempDir); + + const exit = yield* Effect.exit( + functionsDeploy({ + ...BASE_FLAGS, + functionNames: ["hello-world"], + useApi: true, + prune: true, + }).pipe(Effect.provide(layer)), + ); + + expect(Exit.isFailure(exit)).toBe(true); + // Empty scan echoed, false default wins (`console.go:74-82`). + expect(stripSgr(out.stderrText)).toContain(`${PRUNE_PROMPT}\n`); + expect(api.requests.some((request) => request.method === "DELETE")).toBe(false); + }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); + }); + }); }); diff --git a/apps/cli/src/next/commands/init/init.command.ts b/apps/cli/src/next/commands/init/init.command.ts index 33e881ef89..0d719d7d7e 100644 --- a/apps/cli/src/next/commands/init/init.command.ts +++ b/apps/cli/src/next/commands/init/init.command.ts @@ -2,6 +2,7 @@ import { BunServices } from "@effect/platform-bun"; 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 { stdinLayer } from "../../../shared/runtime/stdin.layer.ts"; import { withCommandInstrumentation } from "../../../shared/telemetry/command-instrumentation.ts"; import { init } from "./init.handler.ts"; @@ -38,5 +39,8 @@ export const initCommand = Command.make("init", config).pipe( init(flags).pipe(withCommandInstrumentation({ flags }), withJsonErrorHandling), ), Command.provide(commandRuntimeLayer(["init"])), + // `stdinLayer` satisfies the shared IDE prompts' `Stdin` requirement; they are + // gated on a TTY stdin, so init never reads a piped line at runtime. + Command.provide(stdinLayer), Command.provide(BunServices.layer), ); diff --git a/apps/cli/src/next/commands/init/init.handler.ts b/apps/cli/src/next/commands/init/init.handler.ts index a7c9e523b9..6cd3ca6d32 100644 --- a/apps/cli/src/next/commands/init/init.handler.ts +++ b/apps/cli/src/next/commands/init/init.handler.ts @@ -5,7 +5,7 @@ import { initProject, type ProjectInitOptions } from "../../../shared/init/proje import { InitExperimentalRequiredError } from "../../../shared/init/project-init.errors.ts"; export const init = Effect.fnUntraced(function* ( - flags: Omit<ProjectInitOptions, "cwd" | "withVscodeSettings" | "withIntellijSettings"> & { + flags: Omit<ProjectInitOptions, "cwd" | "yes" | "withVscodeSettings" | "withIntellijSettings"> & { readonly experimental: boolean; }, ) { @@ -23,11 +23,13 @@ export const init = Effect.fnUntraced(function* ( yield* output.intro("Initialize local Supabase project"); - // The next shell does not expose the hidden IDE compat flags; editor settings - // are only generated when the user opts in through interactive mode. + // The next shell does not expose the hidden IDE compat flags (nor a `--yes` + // on init); editor settings are only generated when the user opts in through + // interactive mode. const result = yield* initProject({ cwd: runtimeInfo.cwd, ...flags, + yes: false, withVscodeSettings: false, withIntellijSettings: false, }); 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 17c513892b..e14986ffcf 100644 --- a/apps/cli/src/next/commands/init/init.integration.test.ts +++ b/apps/cli/src/next/commands/init/init.integration.test.ts @@ -13,6 +13,7 @@ import { mockOutput, mockProcessControl, mockRuntimeInfo, + mockStdin, mockTty, } from "../../../../tests/helpers/mocks.ts"; import { initCommand } from "./init.command.ts"; @@ -46,6 +47,7 @@ function buildLayer( stdinIsTty: opts.stdinIsTty ?? false, stdoutIsTty: opts.interactive ?? false, }), + mockStdin(opts.stdinIsTty ?? false), BunServices.layer, ), }; diff --git a/apps/cli/src/shared/functions/deploy.ts b/apps/cli/src/shared/functions/deploy.ts index e707cb76f2..a58ef2c432 100644 --- a/apps/cli/src/shared/functions/deploy.ts +++ b/apps/cli/src/shared/functions/deploy.ts @@ -11,8 +11,11 @@ import { import { Duration, Effect, Option, Schema, Stream } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import * as HttpClientError from "effect/unstable/http/HttpClientError"; +import { legacyPromptYesNo } from "../legacy/legacy-prompt-yes-no.ts"; +import { CONTEXT_CANCELED_MESSAGE } from "../output/errors.ts"; import { Output } from "../output/output.service.ts"; import { spawnContainerCli } from "../../legacy/shared/legacy-container-cli.ts"; +import { legacyBold } from "../../legacy/shared/legacy-colors.ts"; import { legacyGetRegistryImageUrl } from "../../legacy/shared/legacy-docker-registry.ts"; import { findGitRootPath } from "../git/git-root.ts"; import { @@ -2078,13 +2081,20 @@ const pruneFunctions = Effect.fnUntraced(function* ( return; } - const prompt = [ + // Go's `confirmPruneAll` + `fmt.Sprintln` (`deploy.go:189,206-212`): header, one + // ` • <bold slug>` line per function, and a trailing blank line before the + // `[y/N]` choices. Routed through `legacyPromptYesNo` (Go `PromptYesNo(msg, + // false)`, `console.go:64-82`) so `--yes`/`SUPABASE_YES` auto-confirms with the + // stderr echo and a non-TTY stdin honors a piped `y`/`n` answer (CLI-1974). + const prompt = `${[ "Do you want to delete the following Functions from your project?", - ...toDelete.map((slug) => ` - ${slug}`), - ].join("\n"); - const confirmed = yes || (yield* output.promptConfirm(`${prompt}\n`, { defaultValue: false })); + ...toDelete.map((slug) => ` • ${legacyBold(slug)}`), + ].join("\n")}\n\n`; + const confirmed = yield* legacyPromptYesNo(output, yes, prompt, false); if (!confirmed) { - return yield* Effect.fail(new FunctionDeployCancelledError({ message: "context canceled" })); + return yield* Effect.fail( + new FunctionDeployCancelledError({ message: CONTEXT_CANCELED_MESSAGE }), + ); } for (const slug of toDelete) { diff --git a/apps/cli/src/shared/functions/serve-main-offline.e2e.test.ts b/apps/cli/src/shared/functions/serve-main-offline.e2e.test.ts index dad054aaf1..bccd99d091 100644 --- a/apps/cli/src/shared/functions/serve-main-offline.e2e.test.ts +++ b/apps/cli/src/shared/functions/serve-main-offline.e2e.test.ts @@ -1,11 +1,12 @@ import { execSync, spawnSync } from "node:child_process"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, test } from "vitest"; import { LEGACY_EDGE_RUNTIME_IMAGE } from "../../legacy/shared/legacy-edge-runtime-image.ts"; +import { dockerfileServiceImage } from "../services/dockerfile-images.ts"; import { bundleServeMainTemplate } from "./serve-main-bundler.ts"; /** @@ -33,12 +34,98 @@ function hasDocker(): boolean { const dockerAvailable = hasDocker(); const SERVE_OFFLINE_STARTUP_TIMEOUT_MS = 60_000; const SERVE_OFFLINE_TEST_TIMEOUT_MS = 120_000; +const LEGACY_KONG_IMAGE = `public.ecr.aws/supabase/${dockerfileServiceImage("kong").replace(/^.*\//, "")}`; +const AUTH_FUNCTIONS_CONFIG = JSON.stringify({ + test: { + entrypointPath: "/tmp/test/index.ts", + importMapPath: "", + staticFiles: [], + verifyJWT: true, + }, +}); +const KONG_FUNCTIONS_CONFIG = JSON.stringify({ + test: { + entrypointPath: "/app/functions/custom/index.ts", + importMapPath: "", + staticFiles: [], + verifyJWT: true, + }, + custom: { + entrypointPath: "/app/functions/custom/index.ts", + importMapPath: "", + staticFiles: [], + verifyJWT: false, + }, +}); +const CUSTOM_FUNCTION = `Deno.serve(() => new Response("ok", { + headers: { + "X-Custom-Id": "abc123", + "Access-Control-Expose-Headers": "X-Custom-Id", + }, +}));`; + +function jwtWithInvalidSignature(algorithm?: string): string { + const header = Buffer.from(JSON.stringify({ alg: algorithm, typ: "JWT" })).toString("base64url"); + const payload = Buffer.from(JSON.stringify({ sub: "test-user" })).toString("base64url"); + return `${header}.${payload}.invalid`; +} + +const authFailureCases = [ + { + name: "missing authorization", + code: "UNAUTHORIZED_NO_AUTH_HEADER", + message: "Missing authorization header", + }, + { + name: "invalid JWT format", + authorization: "Bearer not-a-jwt", + code: "UNAUTHORIZED_INVALID_JWT_FORMAT", + message: "Invalid JWT format", + }, + { + name: "missing JWT algorithm", + authorization: `Bearer ${jwtWithInvalidSignature()}`, + code: "UNAUTHORIZED_INVALID_JWT_FORMAT", + message: "Invalid JWT format", + }, + { + name: "invalid legacy JWT", + authorization: `Bearer ${jwtWithInvalidSignature("HS256")}`, + code: "UNAUTHORIZED_LEGACY_JWT", + message: "Invalid JWT", + }, + { + name: "invalid asymmetric JWT", + authorization: `Bearer ${jwtWithInvalidSignature("ES256")}`, + code: "UNAUTHORIZED_ASYMMETRIC_JWT", + message: "Invalid JWT", + }, + { + name: "unsupported JWT algorithm", + authorization: `Bearer ${jwtWithInvalidSignature("none")}`, + code: "UNAUTHORIZED_UNSUPPORTED_TOKEN_ALGORITHM", + message: "Unsupported JWT algorithm none", + }, +]; function containerLogs(container: string): string { const result = spawnSync("docker", ["logs", container], { encoding: "utf8" }); return `${result.stdout ?? ""}\n${result.stderr ?? ""}`; } +async function writeKongConfig(dir: string, edgeRuntimeContainer: string) { + const template = await readFile( + new URL("../../../../cli-go/internal/start/templates/kong.yml", import.meta.url), + "utf8", + ); + const config = template + .replaceAll("{{ .EdgeRuntimeId }}", edgeRuntimeContainer) + .replaceAll("{{ .BearerToken }}", "$((headers.authorization or headers.apikey))") + .replaceAll("{{ .QueryToken }}", "$((query_params.apikey))") + .replace(/{{ \.[A-Za-z]+ }}/g, "unused"); + await writeFile(join(dir, "kong.yml"), config); +} + describe("functions serve runtime template (offline)", () => { test.skipIf(!dockerAvailable)( "boots under edge-runtime with networking disabled and fetches nothing remote", @@ -102,4 +189,222 @@ describe("functions serve runtime template (offline)", () => { } }, ); + + test.skipIf(!dockerAvailable)( + "returns canonical JWT auth failures", + { timeout: SERVE_OFFLINE_TEST_TIMEOUT_MS }, + async () => { + const dir = await mkdtemp(join(tmpdir(), "supabase-serve-auth-e2e-")); + const container = `supabase-serve-auth-e2e-${process.pid.toString()}`; + try { + await writeFile(join(dir, "index.ts"), await bundleServeMainTemplate()); + + const run = spawnSync( + "docker", + [ + "run", + "-d", + "--name", + container, + "-p", + "127.0.0.1::8081", + "-e", + "SUPABASE_INTERNAL_HOST_PORT=8081", + "-e", + "SUPABASE_INTERNAL_JWT_SECRET=auth-e2e", + "-e", + "SUPABASE_URL=http://127.0.0.1:54321", + "-e", + `SUPABASE_INTERNAL_FUNCTIONS_CONFIG=${AUTH_FUNCTIONS_CONFIG}`, + "-e", + "SUPABASE_INTERNAL_WALLCLOCK_LIMIT_SEC=400", + "-e", + 'SUPABASE_JWKS={"keys":[]}', + "-v", + `${dir}:/app:ro`, + "--entrypoint", + "edge-runtime", + LEGACY_EDGE_RUNTIME_IMAGE, + "start", + "--main-service=/app", + "--port=8081", + ], + { encoding: "utf8" }, + ); + expect(run.status, run.stderr).toBe(0); + + const portResult = spawnSync("docker", ["port", container, "8081/tcp"], { + encoding: "utf8", + }); + expect(portResult.status, portResult.stderr).toBe(0); + const port = Number(portResult.stdout.trim().split(":").at(-1)); + expect(port).toBeGreaterThan(0); + const url = `http://127.0.0.1:${port}/test`; + + const deadline = Date.now() + SERVE_OFFLINE_STARTUP_TIMEOUT_MS; + let ready = false; + while (Date.now() < deadline) { + try { + const response = await fetch(url); + if (response.status === 401) { + ready = true; + break; + } + } catch {} + await new Promise((resolve) => setTimeout(resolve, 250)); + } + expect(ready, containerLogs(container)).toBe(true); + + for (const { name, authorization, code, message } of authFailureCases) { + const response = await fetch(url, { + headers: authorization === undefined ? undefined : { authorization }, + }); + expect(response.status, name).toBe(401); + expect(response.headers.get("content-type"), name).toContain("application/json"); + expect(response.headers.get("sb-error-code"), name).toBe(code); + expect(await response.json(), name).toEqual({ code, message, msg: message }); + } + } finally { + spawnSync("docker", ["rm", "-f", container], { stdio: "ignore" }); + await rm(dir, { recursive: true, force: true }); + } + }, + ); + + test.skipIf(!dockerAvailable)( + "preserves function CORS headers and exposes JWT errors through Kong", + { timeout: SERVE_OFFLINE_TEST_TIMEOUT_MS }, + async () => { + const dir = await mkdtemp(join(tmpdir(), "supabase-serve-kong-e2e-")); + const network = `supabase-serve-kong-e2e-${process.pid.toString()}`; + const runtimeContainer = `${network}-runtime`; + const kongContainer = `${network}-kong`; + try { + await writeFile(join(dir, "index.ts"), await bundleServeMainTemplate()); + await mkdir(join(dir, "functions", "custom"), { recursive: true }); + await writeFile(join(dir, "functions", "custom", "index.ts"), CUSTOM_FUNCTION); + await writeKongConfig(dir, runtimeContainer); + + const createNetwork = spawnSync("docker", ["network", "create", network], { + encoding: "utf8", + }); + expect(createNetwork.status, createNetwork.stderr).toBe(0); + + const runRuntime = spawnSync( + "docker", + [ + "run", + "-d", + "--name", + runtimeContainer, + "--network", + network, + "-e", + "SUPABASE_INTERNAL_HOST_PORT=8081", + "-e", + "SUPABASE_INTERNAL_JWT_SECRET=auth-e2e", + "-e", + `SUPABASE_URL=http://${kongContainer}:8000`, + "-e", + `SUPABASE_INTERNAL_FUNCTIONS_CONFIG=${KONG_FUNCTIONS_CONFIG}`, + "-e", + "SUPABASE_INTERNAL_WALLCLOCK_LIMIT_SEC=400", + "-e", + 'SUPABASE_JWKS={"keys":[]}', + "-v", + `${dir}:/app:ro`, + "--entrypoint", + "edge-runtime", + LEGACY_EDGE_RUNTIME_IMAGE, + "start", + "--main-service=/app", + "--port=8081", + ], + { encoding: "utf8" }, + ); + expect(runRuntime.status, runRuntime.stderr).toBe(0); + + const runKong = spawnSync( + "docker", + [ + "run", + "-d", + "--name", + kongContainer, + "--network", + network, + "-p", + "127.0.0.1::8000", + "-e", + "KONG_DATABASE=off", + "-e", + "KONG_DECLARATIVE_CONFIG=/home/kong/kong.yml", + "-e", + "KONG_PLUGINS=request-transformer,cors", + "-e", + "KONG_NGINX_WORKER_PROCESSES=1", + "-v", + `${join(dir, "kong.yml")}:/home/kong/kong.yml:ro`, + LEGACY_KONG_IMAGE, + "kong", + "docker-start", + ], + { encoding: "utf8" }, + ); + expect(runKong.status, runKong.stderr).toBe(0); + + const portResult = spawnSync("docker", ["port", kongContainer, "8000/tcp"], { + encoding: "utf8", + }); + expect(portResult.status, portResult.stderr).toBe(0); + const port = Number(portResult.stdout.trim().split(":").at(-1)); + expect(port).toBeGreaterThan(0); + const functionsUrl = `http://127.0.0.1:${port}/functions/v1`; + const authUrl = `${functionsUrl}/test`; + + const deadline = Date.now() + SERVE_OFFLINE_STARTUP_TIMEOUT_MS; + let ready = false; + while (Date.now() < deadline) { + try { + const response = await fetch(authUrl); + if (response.status === 401) { + ready = true; + break; + } + } catch {} + await new Promise((resolve) => setTimeout(resolve, 250)); + } + expect(ready, `${containerLogs(kongContainer)}\n${containerLogs(runtimeContainer)}`).toBe( + true, + ); + + const customResponse = await fetch(`${functionsUrl}/custom`, { + headers: { Origin: "http://localhost:3000" }, + }); + expect(customResponse.status).toBe(200); + expect(customResponse.headers.get("x-custom-id")).toBe("abc123"); + expect(customResponse.headers.get("access-control-expose-headers")?.toLowerCase()).toBe( + "x-custom-id", + ); + + const authResponse = await fetch(authUrl, { + headers: { Origin: "http://localhost:3000" }, + }); + expect(authResponse.status).toBe(401); + expect(authResponse.headers.get("sb-error-code")).toBe("UNAUTHORIZED_NO_AUTH_HEADER"); + expect(authResponse.headers.get("access-control-expose-headers")).toBe("sb-error-code"); + expect(await authResponse.json()).toEqual({ + code: "UNAUTHORIZED_NO_AUTH_HEADER", + message: "Missing authorization header", + msg: "Missing authorization header", + }); + } finally { + spawnSync("docker", ["rm", "-f", kongContainer, runtimeContainer], { + stdio: "ignore", + }); + spawnSync("docker", ["network", "rm", network], { stdio: "ignore" }); + await rm(dir, { recursive: true, force: true }); + } + }, + ); }); diff --git a/apps/cli/src/shared/functions/serve.main.ts b/apps/cli/src/shared/functions/serve.main.ts index e86b82ebda..3366498eac 100644 --- a/apps/cli/src/shared/functions/serve.main.ts +++ b/apps/cli/src/shared/functions/serve.main.ts @@ -46,6 +46,18 @@ const DENO_SB_ERROR_MAP = new Map([ [Deno.errors.WorkerRequestCancelled, SB_SPECIFIC_ERROR_CODE.WorkerLimit], ]); const GENERIC_FUNCTION_SERVE_MESSAGE = `Serving functions on http://127.0.0.1:${HOST_PORT}/functions/v1/<function-name>`; +export enum RequestErrors { + MissingAuthHeader = "UNAUTHORIZED_NO_AUTH_HEADER", + InvalidLegacyJWT = "UNAUTHORIZED_LEGACY_JWT", + InvalidAsymmetricJWT = "UNAUTHORIZED_ASYMMETRIC_JWT", + InvalidTokenFormat = "UNAUTHORIZED_INVALID_JWT_FORMAT", + UnsupportedTokenAlgorithm = "UNAUTHORIZED_UNSUPPORTED_TOKEN_ALGORITHM", +} + +interface AuthFailure { + code: RequestErrors; + message?: string; +} interface FunctionConfig { entrypointPath: string; @@ -74,6 +86,22 @@ function getResponse(payload: any, status: number, customHeaders = {}) { return new Response(body, { status, headers }); } +function getAuthErrorResponse({ code, message = "Invalid JWT" }: AuthFailure) { + return getResponse( + { + code, + message, + // DEPRECATED: Retained for backward compatibility. + msg: message, + }, + STATUS_CODE.Unauthorized, + { + "sb-error-code": code, + "Access-Control-Expose-Headers": "sb-error-code", + }, + ); +} + const functionsConfig: Record<string, FunctionConfig> = (() => { try { const functionsConfig = JSON.parse(FUNCTIONS_CONFIG_STRING); @@ -99,7 +127,7 @@ export function extractBearerToken(rawToken: string) { return token; } -function getAuthToken(req: Request) { +function getAuthToken(req: Request): string | AuthFailure { const authHeader = req.headers.get("authorization"); const sbApiKeyCompatibilityToken = req.headers.get("sb-api-key"); @@ -107,7 +135,10 @@ function getAuthToken(req: Request) { const cleanSbApiKeyCompatibilityToken = sbApiKeyCompatibilityToken?.replace("Bearer", "")?.trim(); if (!authHeader && !cleanSbApiKeyCompatibilityToken) { - throw new Error("Missing authorization header"); + return { + code: RequestErrors.MissingAuthHeader, + message: "Missing authorization header", + }; } // NOTE:(kallebysantos) Compatibility mode is triggered when all conditions match: @@ -118,22 +149,25 @@ function getAuthToken(req: Request) { !bearerToken || bearerToken.startsWith("sb_") ? cleanSbApiKeyCompatibilityToken : bearerToken; if (!token) { - throw new Error(`Auth header is not 'Bearer {token}'`); + return { + code: RequestErrors.InvalidTokenFormat, + message: "Invalid JWT format", + }; } return token; } -async function isValidLegacyJWT(jwtSecret: string, jwt: string): Promise<boolean> { +async function isValidLegacyJWT(jwtSecret: string, jwt: string): Promise<AuthFailure | null> { const encoder = new TextEncoder(); const secretKey = encoder.encode(jwtSecret); try { await jose.jwtVerify(jwt, secretKey); } catch (e) { console.error("Symmetric Legacy JWT verification error", e); - return false; + return { code: RequestErrors.InvalidLegacyJWT }; } - return true; + return null; } // Lazy-loading JWKs @@ -146,7 +180,7 @@ let jwks = (() => { } })(); -async function isValidJWT(jwksUrl: URL, jwt: string): Promise<boolean> { +async function isValidJWT(jwksUrl: URL, jwt: string): Promise<AuthFailure | null> { try { if (!jwks) { // Loading from remote-url on fly @@ -155,9 +189,9 @@ async function isValidJWT(jwksUrl: URL, jwt: string): Promise<boolean> { await jose.jwtVerify(jwt, jwks); } catch (e) { console.error("Asymmetric JWT verification error", e); - return false; + return { code: RequestErrors.InvalidAsymmetricJWT }; } - return true; + return null; } /** @@ -168,8 +202,24 @@ export async function verifyHybridJWT( jwtSecret: string, jwksUrl: URL, jwt: string, -): Promise<boolean> { - const { alg: jwtAlgorithm } = jose.decodeProtectedHeader(jwt); +): Promise<AuthFailure | null> { + let jwtAlgorithm: string | undefined; + try { + jwtAlgorithm = jose.decodeProtectedHeader(jwt).alg; + } catch (e) { + console.error("JWT format error", e); + return { + code: RequestErrors.InvalidTokenFormat, + message: "Invalid JWT format", + }; + } + + if (!jwtAlgorithm) { + return { + code: RequestErrors.InvalidTokenFormat, + message: "Invalid JWT format", + }; + } if (jwtAlgorithm === "HS256") { console.log(`Legacy token type detected, attempting ${jwtAlgorithm} verification.`); @@ -181,7 +231,10 @@ export async function verifyHybridJWT( return await isValidJWT(jwksUrl, jwt); } - return false; + return { + code: RequestErrors.UnsupportedTokenAlgorithm, + message: `Unsupported JWT algorithm ${jwtAlgorithm}`, + }; } // Ref: https://docs.deno.com/examples/checking_file_existence/ @@ -242,14 +295,19 @@ Deno.serve({ if (req.method !== "OPTIONS" && functionsConfig[functionName].verifyJWT) { try { const token = getAuthToken(req); - const isValidJWT = await verifyHybridJWT(JWT_SECRET, JWKS_ENDPOINT, token); - - if (!isValidJWT) { - return getResponse({ msg: "Invalid JWT" }, STATUS_CODE.Unauthorized); + if (typeof token !== "string") { + return getAuthErrorResponse(token); + } + const authFailure = await verifyHybridJWT(JWT_SECRET, JWKS_ENDPOINT, token); + if (authFailure) { + return getAuthErrorResponse(authFailure); } } catch (e) { console.error(e); - return getResponse({ msg: e.toString() }, STATUS_CODE.Unauthorized); + return getAuthErrorResponse({ + code: RequestErrors.InvalidTokenFormat, + message: "Invalid JWT format", + }); } } diff --git a/apps/cli/src/shared/init/project-init.templates.ts b/apps/cli/src/shared/init/project-init.templates.ts index f3a41b6db8..2c6a823579 100644 --- a/apps/cli/src/shared/init/project-init.templates.ts +++ b/apps/cli/src/shared/init/project-init.templates.ts @@ -260,7 +260,7 @@ 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 }}\` }}" +template = "Your code is {{ .Code }}" # Controls the minimum amount of time that must pass before sending another sms otp. max_frequency = "5s" @@ -308,7 +308,7 @@ verify_enabled = false enroll_enabled = false verify_enabled = false otp_length = 6 -template = "Your code is {{ \`{{ .Code }}\` }}" +template = "Your code is {{ .Code }}" max_frequency = "5s" # Configure MFA via WebAuthn 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 index 7b58bf1aea..650ef7ec88 100644 --- a/apps/cli/src/shared/init/project-init.templates.unit.test.ts +++ b/apps/cli/src/shared/init/project-init.templates.unit.test.ts @@ -21,16 +21,51 @@ function readGoTemplate(...segments: ReadonlyArray<string>): 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") +// Go renders its config.toml scaffold through text/template (config.Eject), so an action +// containing a backtick raw string — {{ (backtick){{ .Code }}(backtick) }} in the template +// source — is rendered to the literal string it quotes: {{ .Code }} in the ejected file. +// This is the only text/template construct emulated here beyond the substituted fields; +// the "models every template action" test below fails loudly if the Go scaffold ever +// gains a construct this suite does not resolve. +function resolveGoTemplateEscapes(template: string): string { + return template.replace(/\{\{\s*`([^`]*)`\s*\}\}/g, "$1"); +} + +// Emulates what Go's config.Eject writes to disk for a fresh `supabase init` project. +function renderExpectedGoEject(): string { + return ( + resolveGoTemplateEscapes(readGoTemplate("pkg", "config", "templates", "config.toml")) .replace("{{ .ProjectId }}", "demo-project") .replace("{{ .Experimental.OrioleDBVersion }}", "15.1.0.150") // supabase init always opts new projects into pg-delta; the Go template renders // this from a flag set only on the init path (false when deriving defaults). - .replace("{{ .Experimental.PgDeltaInitEnabled }}", "true"); + .replace("{{ .Experimental.PgDeltaInitEnabled }}", "true") + ); +} - expect(normalizeNewlines(renderProjectConfigTemplate("demo-project", true))).toBe(expected); +describe("project init templates", () => { + it("renders config.toml with the same content as the Go CLI ejects", () => { + expect(normalizeNewlines(renderProjectConfigTemplate("demo-project", true))).toBe( + renderExpectedGoEject(), + ); + }); + + it("models every template action in the Go scaffold, so parity cannot silently drift", () => { + // After escape resolution and field substitution, the only {{ ... }} occurrences left + // must be the GoTrue OTP placeholders quoted by the backtick escapes. Anything else + // means the Go template gained a construct this suite does not emulate yet — update + // resolveGoTemplateEscapes/renderExpectedGoEject to match config.Eject before shipping. + const unresolvedActions = renderExpectedGoEject().match(/\{\{[^}]*\}\}/g) ?? []; + expect(new Set(unresolvedActions)).toEqual(new Set(["{{ .Code }}"])); + }); + + it("renders the SMS and MFA phone OTP templates as GoTrue templates, not raw Go escapes", () => { + const rendered = renderProjectConfigTemplate("demo-project", false); + const otpTemplateLines = rendered.split("\n").filter((line) => line.startsWith("template = ")); + expect(otpTemplateLines).toEqual([ + 'template = "Your code is {{ .Code }}"', + 'template = "Your code is {{ .Code }}"', + ]); }); it("enables pg-delta by default in the generated config", () => { diff --git a/apps/cli/src/shared/init/project-init.ts b/apps/cli/src/shared/init/project-init.ts index f2cafdc852..8efcc21336 100644 --- a/apps/cli/src/shared/init/project-init.ts +++ b/apps/cli/src/shared/init/project-init.ts @@ -1,4 +1,5 @@ import { Effect, FileSystem, Path, Schema } from "effect"; +import { legacyPromptYesNo } from "../legacy/legacy-prompt-yes-no.ts"; import { Output } from "../output/output.service.ts"; import { Tty } from "../runtime/tty.service.ts"; import { @@ -129,6 +130,14 @@ export interface ProjectInitOptions { readonly force: boolean; readonly useOrioledb: boolean; readonly interactive: boolean; + /** + * Auto-confirms the interactive IDE-settings prompts, mirroring Go's + * `viper.GetBool("YES")` branch inside `PromptYesNo` (`console.go:70-72`): + * with `--yes`/`SUPABASE_YES`, `init -i` echoes the accepted VS Code prompt + * to stderr and writes the settings instead of blocking on a TTY (CLI-1974). + * The next shell exposes no `--yes` on init and always passes `false`. + */ + readonly yes: boolean; readonly withVscodeSettings: boolean; readonly withIntellijSettings: boolean; } @@ -209,19 +218,19 @@ export const writeIntelliJConfig = Effect.fnUntraced(function* ( } }); -const promptForIdeSettings = Effect.fnUntraced(function* (cwd: string) { +// Mirrors Go's `PromptForIDESettings` (`apps/cli-go/internal/init/init.go:61-75`): +// both questions go through `PromptYesNo`, so `--yes`/`SUPABASE_YES` auto-accepts +// the VS Code prompt with the `[Y/n] y` stderr echo and never reaches the +// IntelliJ one — Go returns after writing the VS Code settings (CLI-1974). +const promptForIdeSettings = Effect.fnUntraced(function* (cwd: string, yes: boolean) { const output = yield* Output; - if (yield* output.promptConfirm("Generate VS Code settings for Deno?", { defaultValue: true })) { + if (yield* legacyPromptYesNo(output, yes, "Generate VS Code settings for Deno?", true)) { yield* writeVscodeConfig(cwd); return; } - if ( - yield* output.promptConfirm("Generate IntelliJ IDEA settings for Deno?", { - defaultValue: false, - }) - ) { + if (yield* legacyPromptYesNo(output, yes, "Generate IntelliJ IDEA settings for Deno?", false)) { yield* writeIntelliJConfig(cwd); } }); @@ -295,9 +304,21 @@ export const initProject = Effect.fnUntraced(function* (options: ProjectInitOpti ); yield* ensureSupabaseGitignore(options.cwd); - const effectiveInteractive = options.interactive && tty.stdinIsTty && output.interactive; + // Go gates the IDE prompts on `-i` plus a TTY stdin only (`cmd/init.go:40`). + // TS additionally requires text mode (json/stream-json runs stay payload-only + // and never scaffold IDE settings as an undisclosed side effect) and — because + // clack renders its prompt UI on stdout, unlike Go's stderr prompts — an + // interactive stdout. `yes` lifts the stdout requirement: no clack prompt is + // rendered when the answer is auto-confirmed (the `[Y/n] y` echo goes to + // stderr), so `init -i --yes` with a piped stdout writes the VS Code settings + // exactly like Go (CLI-1974). + const effectiveInteractive = + options.interactive && + tty.stdinIsTty && + output.format === "text" && + (output.interactive || options.yes); if (effectiveInteractive) { - yield* promptForIdeSettings(options.cwd); + yield* promptForIdeSettings(options.cwd, options.yes); } if (options.withVscodeSettings) { yield* writeVscodeConfig(options.cwd); diff --git a/apps/cli/src/shared/legacy/global-flags.ts b/apps/cli/src/shared/legacy/global-flags.ts index 59796bb6aa..a52a2ab24c 100644 --- a/apps/cli/src/shared/legacy/global-flags.ts +++ b/apps/cli/src/shared/legacy/global-flags.ts @@ -2,7 +2,7 @@ import { Effect, Option } from "effect"; import { Flag, GlobalFlag } from "effect/unstable/cli"; import { CliArgs } from "../cli/cli-args.service.ts"; -import { legacyViperBool, legacyViperEnvBool } from "./legacy-viper-env.ts"; +import { legacyViperEnvBool, legacyViperEnvBoolWithProjectFallback } from "./legacy-viper-env.ts"; // The Effect CLI hoists global flags out of the token stream before the leaf // parse and builds ONE tree-wide registry, so a command cannot redeclare an @@ -174,11 +174,13 @@ export const legacyResolveYes = Effect.gen(function* () { /** * `--yes` resolved with the project `.env` consulted too, for commands that load the nested * project env before prompting (`migration down`, `migration repair --all`). Go runs - * `loadNestedEnv` — which `os.Setenv`s each project-.env key — inside `ParseDatabaseConfig` - * before `PromptYesNo` reads `viper.GetBool("YES")` (`pkg/config/config.go:701`, - * `internal/utils/console.go:71`), so a `SUPABASE_YES` set only in `supabase/.env` - * auto-confirms. The shell env still wins over the file value. An explicit `--yes` - * (including `--yes=false`) wins over both. `projectEnv` is the loaded map from + * `loadNestedEnv` — `godotenv.Load`, which only sets keys absent from the shell env — + * inside `ParseDatabaseConfig` before `PromptYesNo` reads `viper.GetBool("YES")` + * (`pkg/config/config.go:701`, `internal/utils/console.go:71`), so a `SUPABASE_YES` set + * only in `supabase/.env` auto-confirms. Shell *presence* — any value, including `false`, + * empty, or garbage — suppresses the file value entirely (see + * {@link legacyViperEnvBoolWithProjectFallback}). An explicit `--yes` (including + * `--yes=false`) wins over both. `projectEnv` is the loaded map from * `legacyLoadProjectEnv`. */ export const legacyResolveYesWithProjectEnv = (projectEnv: Record<string, string>) => @@ -188,9 +190,7 @@ export const legacyResolveYesWithProjectEnv = (projectEnv: Record<string, string if (legacyYesFlagExplicitlyFalse(cliArgs.args)) { return false; } - return ( - flag || legacyViperEnvBool("SUPABASE_YES") || legacyViperBool(projectEnv["SUPABASE_YES"]) - ); + return flag || legacyViperEnvBoolWithProjectFallback("SUPABASE_YES", projectEnv); }); /** @@ -231,12 +231,13 @@ export const legacyResolveExperimental = Effect.gen(function* () { * `--experimental` resolved with the project `.env` consulted too, for commands that load the * nested project env before branching on the experimental gate (`db reset`, * `db schema declarative generate`/`sync`). Go's `ParseDatabaseConfig` / - * `dbDeclarativeCmd.PersistentPreRunE` run `loadNestedEnv` — which `os.Setenv`s each - * project-.env key — before reading `viper.GetBool("EXPERIMENTAL")`, so a - * `SUPABASE_EXPERIMENTAL` set only in `supabase/.env` enables the experimental path. The - * shell env still wins over the file value; an explicit `--experimental` — including - * `--experimental=false` — wins over both, matching viper's bound-pflag precedence. - * `projectEnv` is the loaded map from `legacyLoadProjectEnv`. + * `dbDeclarativeCmd.PersistentPreRunE` run `loadNestedEnv` — `godotenv.Load`, which only + * sets keys absent from the shell env — before reading `viper.GetBool("EXPERIMENTAL")`, so + * a `SUPABASE_EXPERIMENTAL` set only in `supabase/.env` enables the experimental path. + * Shell *presence* — any value, including `false`, empty, or garbage — suppresses the file + * value entirely (see {@link legacyViperEnvBoolWithProjectFallback}); an explicit + * `--experimental` — including `--experimental=false` — wins over both, matching viper's + * bound-pflag precedence. `projectEnv` is the loaded map from `legacyLoadProjectEnv`. */ export const legacyResolveExperimentalWithProjectEnv = (projectEnv: Record<string, string>) => Effect.gen(function* () { @@ -245,9 +246,5 @@ export const legacyResolveExperimentalWithProjectEnv = (projectEnv: Record<strin if (legacyExperimentalFlagExplicitlyFalse(cliArgs.args)) { return false; } - return ( - flag || - legacyViperEnvBool("SUPABASE_EXPERIMENTAL") || - legacyViperBool(projectEnv["SUPABASE_EXPERIMENTAL"]) - ); + return flag || legacyViperEnvBoolWithProjectFallback("SUPABASE_EXPERIMENTAL", projectEnv); }); diff --git a/apps/cli/src/legacy/shared/legacy-prompt-yes-no.ts b/apps/cli/src/shared/legacy/legacy-prompt-yes-no.ts similarity index 80% rename from apps/cli/src/legacy/shared/legacy-prompt-yes-no.ts rename to apps/cli/src/shared/legacy/legacy-prompt-yes-no.ts index 81499e2b72..bf637b29ae 100644 --- a/apps/cli/src/legacy/shared/legacy-prompt-yes-no.ts +++ b/apps/cli/src/shared/legacy/legacy-prompt-yes-no.ts @@ -1,8 +1,8 @@ import { Effect, Option } from "effect"; -import { Output } from "../../shared/output/output.service.ts"; -import { Stdin } from "../../shared/runtime/stdin.service.ts"; -import { Tty } from "../../shared/runtime/tty.service.ts"; +import { Output } from "../output/output.service.ts"; +import { Stdin } from "../runtime/stdin.service.ts"; +import { Tty } from "../runtime/tty.service.ts"; /** Go's non-TTY `Console.ReadLine` timeout (`internal/utils/console.go:36`). */ const LEGACY_NON_TTY_TIMEOUT_MILLIS = 100; @@ -25,8 +25,11 @@ export const legacyParseYesNo = (input: string): boolean | undefined => { /** * Confirm-or-default prompt mirroring Go's `console.PromptYesNo` - * (`apps/cli-go/internal/utils/console.go:64-82`), shared by `config push`, - * `db pull`, `seed buckets`, and `storage rm`: + * (`apps/cli-go/internal/utils/console.go:64-82`) — the single Go-faithful + * confirmation helper for every legacy-parity prompt (CLI-1974). It lives in + * `shared/legacy/` (not `legacy/shared/`) because Go-parity confirmations also + * fire from shell-agnostic shared code (`shared/functions/deploy.ts` prune, + * `shared/init/project-init.ts` IDE settings): * - when `yes` is set, echoes `<label> [Y/n|y/N] y` and returns true even on a * TTY (Go auto-confirms with the affirmative echo, `console.go:70-72`); * - when `interactive` is false (Go callers that force `console.IsTTY = false`, @@ -41,6 +44,12 @@ export const legacyParseYesNo = (input: string): boolean | undefined => { * * Callers resolve `yes` via `legacyResolveYes` so it honors both `--yes` and * `SUPABASE_YES`, matching Go's `viper.GetBool("YES")` (root.go:318-320,334). + * + * NOTE: the migration family's `legacyMigrationConfirm` + * (`legacy/commands/migration/migration.prompt.ts`) intentionally diverges on + * the TS-only machine modes (it prompts regardless of `output.format`) and on a + * real TTY (raw stdin read instead of clack) — see its doc comment before + * consolidating the two (CLI-1974 review). */ export const legacyPromptYesNo = Effect.fnUntraced(function* ( output: typeof Output.Service, diff --git a/apps/cli/src/legacy/shared/legacy-prompt-yes-no.unit.test.ts b/apps/cli/src/shared/legacy/legacy-prompt-yes-no.unit.test.ts similarity index 100% rename from apps/cli/src/legacy/shared/legacy-prompt-yes-no.unit.test.ts rename to apps/cli/src/shared/legacy/legacy-prompt-yes-no.unit.test.ts diff --git a/apps/cli/src/shared/legacy/legacy-viper-env.ts b/apps/cli/src/shared/legacy/legacy-viper-env.ts index ec5db179f6..f787ab97d8 100644 --- a/apps/cli/src/shared/legacy/legacy-viper-env.ts +++ b/apps/cli/src/shared/legacy/legacy-viper-env.ts @@ -26,7 +26,7 @@ const LEGACY_VIPER_TRUE = new Set(["1", "t", "T", "TRUE", "true", "True"]); /** `viper.GetBool` truthiness for an already-resolved env value (see module doc). */ -export function legacyViperBool(raw: string | undefined): boolean { +function legacyViperBool(raw: string | undefined): boolean { return raw !== undefined && LEGACY_VIPER_TRUE.has(raw); } @@ -34,3 +34,24 @@ export function legacyViperBool(raw: string | undefined): boolean { export function legacyViperEnvBool(name: string): boolean { return legacyViperBool(process.env[name]); } + +/** + * `viper.GetBool` for a `SUPABASE_*` key where a project `supabase/.env` value may also + * apply. Go loads the project env via `godotenv.Load`, which builds its presence map from + * `os.Environ()` and never overwrites a key that already exists in the shell env — even one + * set to the empty string (`godotenv@v1.5.1/godotenv.go:184-200`, called by `loadNestedEnv` + * at `apps/cli-go/pkg/config/config.go:1220-1261`). `viper.GetBool` then reads the merged + * env, and since the CLI never enables `AllowEmptyEnv`, an empty shell value resolves to the + * `false` default (`viper@v1.21.0/viper.go:442-450`). + * + * Net effect: shell *presence* — any value, including `false`, `""`, or garbage (all of + * which cast to `false`) — suppresses the project value entirely; the file value is + * consulted only when the variable is absent from the shell env. `??` (not `||`) encodes + * exactly that presence check. + */ +export function legacyViperEnvBoolWithProjectFallback( + name: string, + projectEnv: Record<string, string>, +): boolean { + return legacyViperBool(process.env[name] ?? projectEnv[name]); +} diff --git a/apps/cli/src/shared/legacy/legacy-viper-env.unit.test.ts b/apps/cli/src/shared/legacy/legacy-viper-env.unit.test.ts index bc84912cd3..7dc63d33d9 100644 --- a/apps/cli/src/shared/legacy/legacy-viper-env.unit.test.ts +++ b/apps/cli/src/shared/legacy/legacy-viper-env.unit.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it } from "vitest"; -import { legacyViperEnvBool } from "./legacy-viper-env.ts"; +import { legacyViperEnvBool, legacyViperEnvBoolWithProjectFallback } from "./legacy-viper-env.ts"; const KEY = "SUPABASE_TEST_VIPER_BOOL"; @@ -30,3 +30,43 @@ describe("legacyViperEnvBool", () => { expect(legacyViperEnvBool(KEY)).toBe(false); }); }); + +describe("legacyViperEnvBoolWithProjectFallback", () => { + afterEach(() => { + delete process.env[KEY]; + }); + + // Go truth table: godotenv.Load only sets project-.env keys ABSENT from the + // shell env (presence is key-existence, so even an empty shell value blocks + // the file value), then viper.GetBool reads the merged env + // (godotenv@v1.5.1/godotenv.go:184-200, apps/cli-go/pkg/config/config.go). + + it("falls back to the project value only when the shell var is absent", () => { + delete process.env[KEY]; + expect(legacyViperEnvBoolWithProjectFallback(KEY, { [KEY]: "true" })).toBe(true); + expect(legacyViperEnvBoolWithProjectFallback(KEY, { [KEY]: "false" })).toBe(false); + expect(legacyViperEnvBoolWithProjectFallback(KEY, {})).toBe(false); + }); + + it("keeps a false shell override even when the project .env says true", () => { + process.env[KEY] = "false"; + expect(legacyViperEnvBoolWithProjectFallback(KEY, { [KEY]: "true" })).toBe(false); + }); + + it("treats an empty shell value as present (blocks the project value) and false", () => { + // godotenv's presence check is key-existence in os.Environ(), and viper + // without AllowEmptyEnv resolves "" to the false default. + process.env[KEY] = ""; + expect(legacyViperEnvBoolWithProjectFallback(KEY, { [KEY]: "true" })).toBe(false); + }); + + it("treats an unparsable shell value as present and false (cast.ToBool swallows the error)", () => { + process.env[KEY] = "banana"; + expect(legacyViperEnvBoolWithProjectFallback(KEY, { [KEY]: "true" })).toBe(false); + }); + + it("keeps a true shell value over a false project value", () => { + process.env[KEY] = "true"; + expect(legacyViperEnvBoolWithProjectFallback(KEY, { [KEY]: "false" })).toBe(true); + }); +}); diff --git a/apps/cli/src/shared/output/errors.ts b/apps/cli/src/shared/output/errors.ts index 02e93c8143..39bc34be27 100644 --- a/apps/cli/src/shared/output/errors.ts +++ b/apps/cli/src/shared/output/errors.ts @@ -1,5 +1,31 @@ import { Data } from "effect"; +/** + * Byte-for-byte render of Go's `context.Canceled` sentinel. + * + * Every declined confirmation prompt in the Go CLI surfaces as a bare + * `context.Canceled` (e.g. `errors.New(context.Canceled)` in + * `apps/cli-go/internal/logout/logout.go:19`), and `recoverAndExit` + * (`apps/cli-go/cmd/root.go:287-303`) deliberately skips the + * `SuggestDebugFlag` hint for it — declining a prompt is a user decision, + * not an error worth troubleshooting. Handlers that port those decline + * paths construct their cancellation errors with this exact message, and + * the text `Output.fail` renderer keys on it to suppress the `--debug` + * hint, mirroring Go's `!errors.Is(err, context.Canceled)` guard. + * + * Two invariants of that renderer check: + * - The value must stay trim-invariant: it round-trips through + * `normalizeCliError`'s trimming `readString` before reaching the + * renderer's equality check (`shared/output/normalize-error.ts`). + * - The check is exact-match, narrower than Go's chain-walking + * `errors.Is`: a future producer surfacing a WRAPPED cancellation + * (`"...: context canceled"`) through `Output.fail` would keep the hint + * where Go suppresses it — no such producer exists today (mid-flight + * Ctrl-C takes the interrupt/exit-130 path and never reaches + * `Output.fail`), but widen the check if one ever appears. + */ +export const CONTEXT_CANCELED_MESSAGE = "context canceled"; + export class NonInteractiveError extends Data.TaggedError("NonInteractiveError")<{ readonly detail: string; readonly suggestion: string; diff --git a/apps/cli/src/shared/output/output.layer.ts b/apps/cli/src/shared/output/output.layer.ts index 352e8190b5..fe8ff28710 100644 --- a/apps/cli/src/shared/output/output.layer.ts +++ b/apps/cli/src/shared/output/output.layer.ts @@ -17,7 +17,7 @@ import { styleText } from "node:util"; import { Effect, Layer, Stdio, Stream } from "effect"; import { Tty } from "../runtime/tty.service.ts"; -import { NonInteractiveError } from "./errors.ts"; +import { CONTEXT_CANCELED_MESSAGE, NonInteractiveError } from "./errors.ts"; import { Output } from "./output.service.ts"; import type { OutputFormat, StreamEvent } from "./types.ts"; @@ -346,8 +346,15 @@ export const textOutputLayer = Layer.effect( } if (err.suggestion !== undefined) { process.stderr.write(err.suggestion + "\n"); - } else if (!process.argv.includes("--debug")) { - // Go's `utils.SuggestDebugFlag` (apps/cli-go/internal/utils/misc.go:41). + } else if ( + err.message !== CONTEXT_CANCELED_MESSAGE && + !process.argv.includes("--debug") + ) { + // Go's `utils.SuggestDebugFlag` (apps/cli-go/internal/utils/misc.go:41), + // withheld for the canceled sentinel exactly like `recoverAndExit`'s + // `!errors.Is(err, context.Canceled)` guard (apps/cli-go/cmd/root.go:287-292): + // a declined confirmation prompt is a user decision, so Go prints only + // the red `context canceled` line with no troubleshooting hint (CLI-1973). process.stderr.write( "Try rerunning the command with --debug to troubleshoot the error.\n", ); diff --git a/apps/cli/src/shared/output/output.layer.unit.test.ts b/apps/cli/src/shared/output/output.layer.unit.test.ts index 0a6d7f0497..e3f8346f97 100644 --- a/apps/cli/src/shared/output/output.layer.unit.test.ts +++ b/apps/cli/src/shared/output/output.layer.unit.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; import { afterEach, beforeEach, vi } from "vitest"; import { Cause, Effect, Exit, Layer, Sink, Stdio, Stream } from "effect"; -import { NonInteractiveError } from "./errors.ts"; +import { CONTEXT_CANCELED_MESSAGE, NonInteractiveError } from "./errors.ts"; import { mockTty } from "../../../tests/helpers/mocks.ts"; import { Output } from "./output.service.ts"; import { @@ -250,6 +250,62 @@ describe("Output", () => { ); }); + it.effect("fail withholds the --debug fallback for a declined-prompt cancellation", () => { + const writes: string[] = []; + const originalWrite = process.stderr.write.bind(process.stderr); + const originalArgv = process.argv; + process.stderr.write = ((chunk: string | Uint8Array) => { + writes.push(typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk)); + return true; + }) as typeof process.stderr.write; + // Strip --debug from argv so only the canceled-sentinel check can suppress the hint. + process.argv = originalArgv.filter((arg) => arg !== "--debug"); + return Effect.gen(function* () { + const out = yield* Output; + // Same shape `normalizeCause` produces for any declined confirmation prompt + // (logout, migration fetch/repair/down, db push/reset, functions deploy + // --prune, ...): Go's `recoverAndExit` prints only the red `context canceled` + // line for `context.Canceled` (apps/cli-go/cmd/root.go:287-303) — CLI-1973. + yield* out.fail({ code: "LegacyLogoutCancelledError", message: CONTEXT_CANCELED_MESSAGE }); + expect(writes).toEqual(["\x1B[31mcontext canceled\x1B[39m\n"]); + }).pipe( + Effect.provide(layer), + Effect.ensuring( + Effect.sync(() => { + process.stderr.write = originalWrite; + process.argv = originalArgv; + }), + ), + ); + }); + + it.effect("fail still prints an explicit caller suggestion for a cancellation", () => { + const writes: string[] = []; + const originalWrite = process.stderr.write.bind(process.stderr); + process.stderr.write = ((chunk: string | Uint8Array) => { + writes.push(typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk)); + return true; + }) as typeof process.stderr.write; + return Effect.gen(function* () { + const out = yield* Output; + // Go prints a pre-set `utils.CmdSuggestion` even for `context.Canceled` — + // only the `SuggestDebugFlag` fallback is withheld (cmd/root.go:287-292). + yield* out.fail({ + code: "E_TEST", + message: CONTEXT_CANCELED_MESSAGE, + suggestion: "custom hint", + }); + expect(writes).toEqual(["\x1B[31mcontext canceled\x1B[39m\n", "custom hint\n"]); + }).pipe( + Effect.provide(layer), + Effect.ensuring( + Effect.sync(() => { + process.stderr.write = originalWrite; + }), + ), + ); + }); + it.effect("promptText passes validate callback to clack", () => { mockClack.text.mockImplementation( (opts: { validate?: (v: string | undefined) => string | undefined }) => { diff --git a/apps/cli/tests/helpers/ansi.ts b/apps/cli/tests/helpers/ansi.ts new file mode 100644 index 0000000000..689a67887f --- /dev/null +++ b/apps/cli/tests/helpers/ansi.ts @@ -0,0 +1,21 @@ +/** Strip ANSI CSI escape sequences (colors, styles, cursor codes) from captured CLI output. */ +export function stripAnsi(output: string): string { + let stripped = ""; + for (let i = 0; i < output.length; i++) { + const charCode = output.charCodeAt(i); + if (charCode !== 0x1b || output[i + 1] !== "[") { + stripped += output[i]; + continue; + } + + i += 2; + while (i < output.length) { + const code = output.charCodeAt(i); + if (code >= 0x40 && code <= 0x7e) { + break; + } + i++; + } + } + return stripped; +} diff --git a/apps/cli/tests/helpers/cli.ts b/apps/cli/tests/helpers/cli.ts index c232c776a9..9d0bec1214 100644 --- a/apps/cli/tests/helpers/cli.ts +++ b/apps/cli/tests/helpers/cli.ts @@ -13,6 +13,8 @@ import { registerTempStackProject, } from "./stack-e2e-cleanup.ts"; +export { stripAnsi } from "./ansi.ts"; + const BINARY_EXT = process.platform === "win32" ? ".exe" : ""; const SHIM_PATH = fileURLToPath(new URL("../../dist/supabase.js", import.meta.url)); const LEGACY_BINARY_PATH = fileURLToPath( diff --git a/apps/cli/tests/helpers/legacy-mocks.ts b/apps/cli/tests/helpers/legacy-mocks.ts index e02e241fbb..c3bbbfa1e3 100644 --- a/apps/cli/tests/helpers/legacy-mocks.ts +++ b/apps/cli/tests/helpers/legacy-mocks.ts @@ -38,13 +38,14 @@ import { legacyProjectRefLayer } from "../../src/legacy/config/legacy-project-re import { LegacyLinkedProjectCache } from "../../src/legacy/telemetry/legacy-linked-project-cache.service.ts"; import { LegacyTelemetryState } from "../../src/legacy/telemetry/legacy-telemetry-state.service.ts"; import { CliArgs } from "../../src/shared/cli/cli-args.service.ts"; +import type { Stdin } from "../../src/shared/runtime/stdin.service.ts"; import { LegacyOutputFlag } from "../../src/shared/legacy/global-flags.ts"; import type { Output } from "../../src/shared/output/output.service.ts"; import type { ProcessControl } from "../../src/shared/runtime/process-control.service.ts"; import type { RuntimeInfo } from "../../src/shared/runtime/runtime-info.service.ts"; import type { Tty } from "../../src/shared/runtime/tty.service.ts"; import { Analytics } from "../../src/shared/telemetry/analytics.service.ts"; -import { mockAnalytics, mockProcessControl, mockRuntimeInfo, mockTty } from "./mocks.ts"; +import { mockAnalytics, mockProcessControl, mockRuntimeInfo, mockStdin, mockTty } from "./mocks.ts"; // --------------------------------------------------------------------------- // Constants — Go-parity test fixtures used across every native-port integration @@ -710,6 +711,12 @@ export interface BuildLegacyTestRuntimeOpts { }; readonly cliConfig: Layer.Layer<LegacyCliConfig>; readonly tty?: Layer.Layer<Tty>; + /** + * `Stdin` for prompts routed through `legacyPromptYesNo` (piped-answer reads on + * a non-TTY, Go's `Console.ReadLine`). Defaults to a non-TTY stdin with no + * piped input, i.e. every bounded read times out like Go's empty 100ms scan. + */ + readonly stdin?: Layer.Layer<Stdin>; readonly processControl?: { readonly layer: Layer.Layer<ProcessControl> }; readonly runtimeInfo?: Layer.Layer<RuntimeInfo>; readonly telemetry?: Layer.Layer<LegacyTelemetryState>; @@ -722,6 +729,7 @@ export interface BuildLegacyTestRuntimeOpts { export function buildLegacyTestRuntime(opts: BuildLegacyTestRuntimeOpts) { const tty = opts.tty ?? mockTty({ stdinIsTty: false, stdoutIsTty: false }); + const stdin = opts.stdin ?? mockStdin(false); const processControl = (opts.processControl ?? mockProcessControl()).layer; const runtimeInfo = opts.runtimeInfo ?? mockRuntimeInfo(); const telemetry = opts.telemetry ?? mockLegacyTelemetryStateLayer; @@ -753,6 +761,7 @@ export function buildLegacyTestRuntime(opts: BuildLegacyTestRuntimeOpts) { topLevelFactory, opts.cliConfig, tty, + stdin, processControl, runtimeInfo, legacyProjectRefLayer.pipe( diff --git a/apps/docs/package.json b/apps/docs/package.json index 6783494b58..4c6f856a8b 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -8,9 +8,9 @@ "build": "bun run generate && next build" }, "dependencies": { - "fumadocs-core": "^16.11.3", - "fumadocs-mdx": "^15.1.0", - "fumadocs-ui": "^16.11.3", + "fumadocs-core": "^16.11.5", + "fumadocs-mdx": "^15.2.0", + "fumadocs-ui": "^16.11.5", "next": "16.3.0-preview.6", "react": "^19.2.7", "react-dom": "^19.2.7" diff --git a/package.json b/package.json index 4ac9174815..3d460833b5 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,9 @@ "@swc/core": "catalog:", "@typescript/native": "npm:typescript@^7.0.2", "nx": "catalog:", + "oxfmt": "catalog:", + "oxlint": "catalog:", + "oxlint-tsgolint": "catalog:", "pkg-pr-new": "0.0.75", "typescript": "npm:@typescript/typescript6@^6.0.2", "verdaccio": "^6.7.4" diff --git a/packages/api/package.json b/packages/api/package.json index 8e60e45359..23312c44f1 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -23,7 +23,7 @@ "@effect/platform-bun": "catalog:", "@effect/platform-node": "catalog:", "effect": "catalog:", - "undici": "^8.7.0" + "undici": "^8.8.0" }, "devDependencies": { "@tsconfig/bun": "catalog:", diff --git a/packages/stack/package.json b/packages/stack/package.json index 632976a7e2..3582370c56 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -28,7 +28,7 @@ }, "devDependencies": { "@effect/vitest": "catalog:", - "@supabase/supabase-js": "^2.110.2", + "@supabase/supabase-js": "^2.110.7", "@tsconfig/bun": "catalog:", "@types/bun": "catalog:", "@typescript/native-preview": "catalog:", diff --git a/packages/stack/src/ApiProxy.ts b/packages/stack/src/ApiProxy.ts index 116de838f2..2f24a075bb 100644 --- a/packages/stack/src/ApiProxy.ts +++ b/packages/stack/src/ApiProxy.ts @@ -92,7 +92,7 @@ const CORS_HEADERS: ReadonlyArray<readonly [string, string]> = [ ["access-control-allow-origin", "*"], ["access-control-allow-methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS"], ["access-control-allow-headers", "Authorization, Content-Type, apikey, X-Client-Info"], - ["access-control-expose-headers", "Content-Range, Range"], + ["access-control-expose-headers", "Content-Range, Range, sb-error-code"], ["access-control-max-age", "86400"], ]; diff --git a/packages/stack/src/ApiProxy.unit.test.ts b/packages/stack/src/ApiProxy.unit.test.ts index d2b3a00bbc..506c6d31e4 100644 --- a/packages/stack/src/ApiProxy.unit.test.ts +++ b/packages/stack/src/ApiProxy.unit.test.ts @@ -197,6 +197,7 @@ describe("ApiProxy", () => { expect(res.headers.get("access-control-allow-methods")).toContain("GET"); expect(res.headers.get("access-control-allow-headers")).toContain("apikey"); expect(res.headers.get("access-control-expose-headers")).toContain("Content-Range"); + expect(res.headers.get("access-control-expose-headers")).toContain("sb-error-code"); expect(res.headers.get("access-control-max-age")).toBe("86400"); }); diff --git a/packages/stack/src/functions.unit.test.ts b/packages/stack/src/functions.unit.test.ts index a744b173ba..56ead117fb 100644 --- a/packages/stack/src/functions.unit.test.ts +++ b/packages/stack/src/functions.unit.test.ts @@ -6,11 +6,13 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { Effect } from "effect"; import { resolveConfig } from "./createStack.ts"; +import { defaultJwtSecret, generateJwt } from "./JwtGenerator.ts"; import { configureFunctionsRuntime, functionsRuntimeConfigPath, resolveFunctionsRuntimeConfig, } from "./functions.ts"; +import { verifyRequest } from "./services/edge-runtime-main.ts"; function makeTempProject(): string { return mkdtempSync(join(tmpdir(), "supabase-stack-functions-")); @@ -54,6 +56,50 @@ async function writeProject(cwd: string) { ); } +function jwtWithInvalidSignature(algorithm?: string): string { + const header = Buffer.from(JSON.stringify({ alg: algorithm, typ: "JWT" })).toString("base64url"); + const payload = Buffer.from(JSON.stringify({ sub: "test-user" })).toString("base64url"); + return `${header}.${payload}.invalid`; +} + +const authFailureCases = [ + { + name: "returns the missing authorization error", + code: "UNAUTHORIZED_NO_AUTH_HEADER", + message: "Missing authorization header", + }, + { + name: "returns the invalid JWT format error", + authorization: "Bearer not-a-jwt", + code: "UNAUTHORIZED_INVALID_JWT_FORMAT", + message: "Invalid JWT format", + }, + { + name: "returns the invalid JWT format error when the algorithm is missing", + authorization: `Bearer ${jwtWithInvalidSignature()}`, + code: "UNAUTHORIZED_INVALID_JWT_FORMAT", + message: "Invalid JWT format", + }, + { + name: "returns the legacy JWT error", + authorization: `Bearer ${jwtWithInvalidSignature("HS256")}`, + code: "UNAUTHORIZED_LEGACY_JWT", + message: "Invalid JWT", + }, + { + name: "returns the asymmetric JWT error", + authorization: `Bearer ${jwtWithInvalidSignature("ES256")}`, + code: "UNAUTHORIZED_ASYMMETRIC_JWT", + message: "Invalid JWT", + }, + { + name: "returns the unsupported algorithm error", + authorization: `Bearer ${jwtWithInvalidSignature("none")}`, + code: "UNAUTHORIZED_UNSUPPORTED_TOKEN_ALGORITHM", + message: "Unsupported JWT algorithm none", + }, +]; + describe("stack Functions runtime config", () => { it.live("auto-detects enabled functions from projectDir", () => { const cwd = makeTempProject(); @@ -150,3 +196,52 @@ describe("stack Functions runtime config", () => { ); }); }); + +describe("stack Functions runtime auth", () => { + for (const { name, authorization, code, message } of authFailureCases) { + it(name, async () => { + const response = await verifyRequest( + new Request("http://127.0.0.1/functions/v1/test", { + headers: authorization === undefined ? undefined : { authorization }, + }), + { jwtSecret: defaultJwtSecret }, + { verifyJWT: true }, + ); + + expect(response).not.toBeNull(); + if (response === null) return; + + expect(response.status).toBe(401); + expect(response.headers.get("content-type")).toContain("application/json"); + expect(response.headers.get("sb-error-code")).toBe(code); + expect(response.headers.get("access-control-expose-headers")).toBe("sb-error-code"); + expect(await response.json()).toEqual({ code, message, msg: message }); + }); + } + + it("accepts a valid legacy JWT", async () => { + const token = generateJwt(defaultJwtSecret, "anon"); + const response = await verifyRequest( + new Request("http://127.0.0.1/functions/v1/test", { + headers: { authorization: `Bearer ${token}` }, + }), + { jwtSecret: defaultJwtSecret }, + { verifyJWT: true }, + ); + + expect(response).toBeNull(); + }); + + it("accepts a lowercase bearer scheme", async () => { + const token = generateJwt(defaultJwtSecret, "anon"); + const response = await verifyRequest( + new Request("http://127.0.0.1/functions/v1/test", { + headers: { authorization: `bearer ${token}` }, + }), + { jwtSecret: defaultJwtSecret }, + { verifyJWT: true }, + ); + + expect(response).toBeNull(); + }); +}); diff --git a/packages/stack/src/services/edge-runtime-main.ts b/packages/stack/src/services/edge-runtime-main.ts index 1db10e1243..cda66721c5 100644 --- a/packages/stack/src/services/edge-runtime-main.ts +++ b/packages/stack/src/services/edge-runtime-main.ts @@ -6,6 +6,19 @@ const placeholder = { message: "Edge Functions are not configured for this local stack yet.", }; +export enum RequestErrors { + MissingAuthHeader = "UNAUTHORIZED_NO_AUTH_HEADER", + InvalidLegacyJWT = "UNAUTHORIZED_LEGACY_JWT", + InvalidAsymmetricJWT = "UNAUTHORIZED_ASYMMETRIC_JWT", + InvalidTokenFormat = "UNAUTHORIZED_INVALID_JWT_FORMAT", + UnsupportedTokenAlgorithm = "UNAUTHORIZED_UNSUPPORTED_TOKEN_ALGORITHM", +} + +interface AuthFailure { + code: RequestErrors; + message?: string; +} + const configPath = typeof Deno === "undefined" ? new URL("./functions-runtime-config.json", import.meta.url) @@ -36,6 +49,33 @@ function bytesEqual(left: Uint8Array, right: Uint8Array) { return result === 0; } +function getAuthErrorResponse({ code, message = "Invalid JWT" }: AuthFailure) { + return Response.json( + { + code, + message, + // DEPRECATED: Retained for backward compatibility. + msg: message, + }, + { + status: 401, + headers: { + "sb-error-code": code, + "Access-Control-Expose-Headers": "sb-error-code", + }, + }, + ); +} + +function decodeJwtAlgorithm(jwt: string): string | undefined { + const parts = jwt.split("."); + if (parts.length !== 3) { + throw new Error("Invalid JWT format"); + } + const decodedHeader = JSON.parse(new TextDecoder().decode(base64UrlToBytes(parts[0]!))); + return typeof decodedHeader.alg === "string" ? decodedHeader.alg : undefined; +} + async function isValidLocalJwt(secret: string, jwt: string) { const parts = jwt.split("."); if (parts.length !== 3) return false; @@ -60,13 +100,16 @@ async function isValidLocalJwt(secret: string, jwt: string) { return bytesEqual(new Uint8Array(signed), base64UrlToBytes(signature!)); } -async function verifyRequest(req: Request, config: any, functionConfig: any) { +export async function verifyRequest(req: Request, config: any, functionConfig: any) { if (!functionConfig.verifyJWT || req.method === "OPTIONS") return null; const bearerToken = req.headers.get("authorization")?.slice("Bearer ".length); const sbApiKeyCompatibilityToken = req.headers.get("sb-api-key")?.replace("Bearer", "")?.trim(); if (!bearerToken && !sbApiKeyCompatibilityToken) { - return Response.json({ msg: "Missing authorization header" }, { status: 401 }); + return getAuthErrorResponse({ + code: RequestErrors.MissingAuthHeader, + message: "Missing authorization header", + }); } // NOTE:(kallebysantos) Compatibility mode is triggered when all conditions match: @@ -76,15 +119,47 @@ async function verifyRequest(req: Request, config: any, functionConfig: any) { !bearerToken || bearerToken.startsWith("sb_") ? sbApiKeyCompatibilityToken : bearerToken; if (!token) { - return Response.json({ msg: "Auth header is not 'Bearer {token}'" }, { status: 401 }); + return getAuthErrorResponse({ + code: RequestErrors.InvalidTokenFormat, + message: "Invalid JWT format", + }); } + let algorithm: string | undefined; try { - if (await isValidLocalJwt(config.jwtSecret, token)) return null; + algorithm = decodeJwtAlgorithm(token); } catch (error) { - console.error("JWT verification failed", error); + console.error("JWT format error", error); + return getAuthErrorResponse({ + code: RequestErrors.InvalidTokenFormat, + message: "Invalid JWT format", + }); + } + + if (!algorithm) { + return getAuthErrorResponse({ + code: RequestErrors.InvalidTokenFormat, + message: "Invalid JWT format", + }); } - return Response.json({ msg: "Invalid JWT" }, { status: 401 }); + + if (algorithm === "HS256") { + try { + if (await isValidLocalJwt(config.jwtSecret, token)) return null; + } catch (error) { + console.error("JWT verification failed", error); + } + return getAuthErrorResponse({ code: RequestErrors.InvalidLegacyJWT }); + } + + if (algorithm === "ES256" || algorithm === "RS256") { + return getAuthErrorResponse({ code: RequestErrors.InvalidAsymmetricJWT }); + } + + return getAuthErrorResponse({ + code: RequestErrors.UnsupportedTokenAlgorithm, + message: `Unsupported JWT algorithm ${algorithm}`, + }); } function dirname(path: string) { diff --git a/packages/stack/src/versions.ts b/packages/stack/src/versions.ts index 989d9357b0..613a9a032d 100644 --- a/packages/stack/src/versions.ts +++ b/packages/stack/src/versions.ts @@ -46,16 +46,16 @@ export interface VersionManifest { } export const DEFAULT_VERSIONS: VersionManifest = { - postgres: "17.6.1.143", + postgres: "17.6.1.156", postgrest: "14.15", - auth: "2.193.0", + auth: "2.194.0", "edge-runtime": "1.74.2", - realtime: "2.113.4", - storage: "1.66.4", + realtime: "2.120.3", + storage: "1.67.20", imgproxy: "v3.8.0", mailpit: "v1.30.2", pgmeta: "0.96.6", - studio: "2026.07.13-sha-b5ada96", + studio: "2026.07.27-sha-cbb076d", analytics: "1.47.1", vector: "0.53.0-alpine", pooler: "2.9.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c2db0ba728..b6e5837da1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,11 +25,11 @@ catalogs: specifier: ^23.0.2 version: 23.1.0 '@swc-node/register': - specifier: ^1.10.9 - version: 1.11.1 + specifier: ^1.12.1 + version: 1.12.1 '@swc/core': - specifier: ^1.15.43 - version: 1.15.43 + specifier: ^1.15.46 + version: 1.15.46 '@tsconfig/bun': specifier: ^1.0.10 version: 1.0.10 @@ -46,23 +46,23 @@ catalogs: specifier: 4.0.0-beta.97 version: 4.0.0-beta.97 knip: - specifier: ^6.26.0 - version: 6.26.0 + specifier: ^6.27.0 + version: 6.27.0 nx: specifier: ^23.0.2 version: 23.1.0 oxfmt: - specifier: ^0.58.0 - version: 0.58.0 + specifier: ^0.59.0 + version: 0.59.0 oxlint: specifier: ^1.72.0 version: 1.74.0 oxlint-tsgolint: - specifier: ^0.24.0 - version: 0.24.0 + specifier: ^0.25.0 + version: 0.25.0 tldts: - specifier: ^7.4.8 - version: 7.4.8 + specifier: ^7.4.9 + version: 7.4.9 vitest: specifier: ^4.1.10 version: 4.1.10 @@ -76,16 +76,25 @@ importers: devDependencies: '@swc-node/register': specifier: 'catalog:' - version: 1.11.1(@swc/core@1.15.43)(@swc/types@0.1.27)(@typescript/typescript6@6.0.2) + version: 1.12.1(@swc/core@1.15.46)(@swc/types@0.1.27)(@typescript/typescript6@6.0.2) '@swc/core': specifier: 'catalog:' - version: 1.15.43 + version: 1.15.46 '@typescript/native': specifier: npm:typescript@^7.0.2 version: typescript@7.0.2 nx: specifier: 'catalog:' - version: 23.1.0(@swc-node/register@1.11.1(@swc/core@1.15.43)(@swc/types@0.1.27)(@typescript/typescript6@6.0.2))(@swc/core@1.15.43) + version: 23.1.0(@swc-node/register@1.12.1(@swc/core@1.15.46)(@swc/types@0.1.27)(@typescript/typescript6@6.0.2))(@swc/core@1.15.46) + oxfmt: + specifier: 'catalog:' + version: 0.59.0 + oxlint: + specifier: 'catalog:' + version: 1.74.0(oxlint-tsgolint@0.25.0) + oxlint-tsgolint: + specifier: 'catalog:' + version: 0.25.0 pkg-pr-new: specifier: 0.0.75 version: 0.0.75 @@ -106,11 +115,11 @@ importers: version: 6.2.3 devDependencies: '@anthropic-ai/claude-agent-sdk': - specifier: ^0.3.207 - version: 0.3.209(@anthropic-ai/sdk@0.111.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3) + specifier: ^0.3.216 + version: 0.3.216(@anthropic-ai/sdk@0.112.4(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3) '@anthropic-ai/sdk': - specifier: ^0.111.0 - version: 0.111.0(zod@4.4.3) + specifier: ^0.112.4 + version: 0.112.4(zod@4.4.3) '@clack/prompts': specifier: ^1.7.0 version: 1.7.0 @@ -133,8 +142,8 @@ importers: specifier: ^1.3.0 version: 1.3.0 '@parcel/watcher': - specifier: ^2.5.6 - version: 2.5.6 + specifier: ^2.6.0 + version: 2.6.0 '@supabase/api': specifier: workspace:* version: link:../../packages/api @@ -181,23 +190,23 @@ importers: specifier: ^0.28.1 version: 0.28.1 ink: - specifier: ^7.1.0 - version: 7.1.0(@types/react@19.2.17)(react-devtools-core@7.0.1)(react@19.2.7) + specifier: ^7.1.1 + version: 7.1.1(@types/react@19.2.17)(react-devtools-core@7.0.1)(react@19.2.7) ink-spinner: specifier: ^5.0.0 - version: 5.0.0(ink@7.1.0(@types/react@19.2.17)(react-devtools-core@7.0.1)(react@19.2.7))(react@19.2.7) + version: 5.0.0(ink@7.1.1(@types/react@19.2.17)(react-devtools-core@7.0.1)(react@19.2.7))(react@19.2.7) knip: specifier: 'catalog:' - version: 6.26.0 + version: 6.27.0 oxfmt: specifier: 'catalog:' - version: 0.58.0 + version: 0.59.0 oxlint: specifier: 'catalog:' - version: 1.74.0(oxlint-tsgolint@0.24.0) + version: 1.74.0(oxlint-tsgolint@0.25.0) oxlint-tsgolint: specifier: 'catalog:' - version: 0.24.0 + version: 0.25.0 pg: specifier: ^8.22.0 version: 8.22.0 @@ -205,8 +214,8 @@ importers: specifier: ^7.0.0 version: 7.0.0 posthog-node: - specifier: ^5.41.0 - version: 5.41.0 + specifier: ^5.46.0 + version: 5.46.0 react: specifier: ^19.2.7 version: 19.2.7 @@ -214,14 +223,14 @@ importers: specifier: ^7.0.1 version: 7.0.1 semantic-release: - specifier: ^25.0.6 - version: 25.0.7(typescript@7.0.2) + specifier: ^25.0.8 + version: 25.0.8(typescript@7.0.2) smol-toml: specifier: ^1.7.0 version: 1.7.0 tldts: specifier: 'catalog:' - version: 7.4.8 + version: 7.4.9 vitest: specifier: 'catalog:' version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-istanbul@4.1.10)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) @@ -274,16 +283,16 @@ importers: version: 4.1.10(vitest@4.1.10) knip: specifier: 'catalog:' - version: 6.26.0 + version: 6.27.0 oxfmt: specifier: 'catalog:' - version: 0.58.0 + version: 0.59.0 oxlint: specifier: 'catalog:' - version: 1.74.0(oxlint-tsgolint@0.24.0) + version: 1.74.0(oxlint-tsgolint@0.25.0) oxlint-tsgolint: specifier: 'catalog:' - version: 0.24.0 + version: 0.25.0 vitest: specifier: 'catalog:' version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-istanbul@4.1.10)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) @@ -291,14 +300,14 @@ importers: apps/docs: dependencies: fumadocs-core: - specifier: ^16.11.3 - version: 16.11.4(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.24.0(react@19.2.7))(next@16.3.0-preview.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3) + specifier: ^16.11.5 + version: 16.11.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.7))(next@16.3.0-preview.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3) fumadocs-mdx: - specifier: ^15.1.0 - version: 15.1.1(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.17)(fumadocs-core@16.11.4(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.24.0(react@19.2.7))(next@16.3.0-preview.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3))(next@16.3.0-preview.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)(rolldown@1.1.5)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) + specifier: ^15.2.0 + version: 15.2.0(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.17)(fumadocs-core@16.11.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.7))(next@16.3.0-preview.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3))(next@16.3.0-preview.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)(rolldown@1.1.5)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) fumadocs-ui: - specifier: ^16.11.3 - version: 16.11.4(@types/mdx@2.0.14)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(fumadocs-core@16.11.4(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.24.0(react@19.2.7))(next@16.3.0-preview.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3))(next@16.3.0-preview.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + specifier: ^16.11.5 + version: 16.11.5(@types/mdx@2.0.14)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(fumadocs-core@16.11.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.7))(next@16.3.0-preview.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3))(next@16.3.0-preview.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7) next: specifier: 16.3.0-preview.6 version: 16.3.0-preview.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -337,8 +346,8 @@ importers: specifier: 'catalog:' version: 4.0.0-beta.97 undici: - specifier: ^8.7.0 - version: 8.7.0 + specifier: ^8.8.0 + version: 8.8.0 devDependencies: '@tsconfig/bun': specifier: 'catalog:' @@ -354,16 +363,16 @@ importers: version: 4.1.10(vitest@4.1.10) knip: specifier: 'catalog:' - version: 6.26.0 + version: 6.27.0 oxfmt: specifier: 'catalog:' - version: 0.58.0 + version: 0.59.0 oxlint: specifier: 'catalog:' - version: 1.74.0(oxlint-tsgolint@0.24.0) + version: 1.74.0(oxlint-tsgolint@0.25.0) oxlint-tsgolint: specifier: 'catalog:' - version: 0.24.0 + version: 0.25.0 vitest: specifier: 'catalog:' version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-istanbul@4.1.10)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) @@ -396,16 +405,16 @@ importers: version: 4.1.10(vitest@4.1.10) knip: specifier: 'catalog:' - version: 6.26.0 + version: 6.27.0 oxfmt: specifier: 'catalog:' - version: 0.58.0 + version: 0.59.0 oxlint: specifier: 'catalog:' - version: 1.74.0(oxlint-tsgolint@0.24.0) + version: 1.74.0(oxlint-tsgolint@0.25.0) oxlint-tsgolint: specifier: 'catalog:' - version: 0.24.0 + version: 0.25.0 vitest: specifier: 'catalog:' version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-istanbul@4.1.10)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) @@ -446,16 +455,16 @@ importers: version: 4.1.10(vitest@4.1.10) knip: specifier: 'catalog:' - version: 6.26.0 + version: 6.27.0 oxfmt: specifier: 'catalog:' - version: 0.58.0 + version: 0.59.0 oxlint: specifier: 'catalog:' - version: 1.74.0(oxlint-tsgolint@0.24.0) + version: 1.74.0(oxlint-tsgolint@0.25.0) oxlint-tsgolint: specifier: 'catalog:' - version: 0.24.0 + version: 0.25.0 vitest: specifier: 'catalog:' version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-istanbul@4.1.10)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) @@ -486,16 +495,16 @@ importers: version: 4.1.10(vitest@4.1.10) knip: specifier: 'catalog:' - version: 6.26.0 + version: 6.27.0 oxfmt: specifier: 'catalog:' - version: 0.58.0 + version: 0.59.0 oxlint: specifier: 'catalog:' - version: 1.74.0(oxlint-tsgolint@0.24.0) + version: 1.74.0(oxlint-tsgolint@0.25.0) oxlint-tsgolint: specifier: 'catalog:' - version: 0.24.0 + version: 0.25.0 vitest: specifier: 'catalog:' version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-istanbul@4.1.10)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) @@ -522,8 +531,8 @@ importers: specifier: 'catalog:' version: 4.0.0-beta.97(effect@4.0.0-beta.97)(vitest@4.1.10) '@supabase/supabase-js': - specifier: ^2.110.2 - version: 2.110.5 + specifier: ^2.110.7 + version: 2.110.7 '@tsconfig/bun': specifier: 'catalog:' version: 1.0.10 @@ -538,16 +547,16 @@ importers: version: 4.1.10(vitest@4.1.10) knip: specifier: 'catalog:' - version: 6.26.0 + version: 6.27.0 oxfmt: specifier: 'catalog:' - version: 0.58.0 + version: 0.59.0 oxlint: specifier: 'catalog:' - version: 1.74.0(oxlint-tsgolint@0.24.0) + version: 1.74.0(oxlint-tsgolint@0.25.0) oxlint-tsgolint: specifier: 'catalog:' - version: 0.24.0 + version: 0.25.0 vitest: specifier: 'catalog:' version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-istanbul@4.1.10)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) @@ -556,7 +565,7 @@ importers: dependencies: '@nx/devkit': specifier: 'catalog:' - version: 23.1.0(nx@23.1.0(@swc-node/register@1.11.1(@swc/core@1.15.43)(@swc/types@0.1.27)(@typescript/typescript6@6.0.2))(@swc/core@1.15.43)) + version: 23.1.0(nx@23.1.0(@swc-node/register@1.12.1(@swc/core@1.15.46)(@swc/types@0.1.27)(@typescript/typescript6@6.0.2))(@swc/core@1.15.46)) typescript: specifier: npm:@typescript/typescript6@^6.0.2 version: '@typescript/typescript6@6.0.2' @@ -582,60 +591,60 @@ packages: resolution: {integrity: sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA==} engines: {node: '>=18'} - '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.209': - resolution: {integrity: sha512-08qu5ZImKxAsfyxQdXw2fHpIvTS39kAN/T6iWdRPY/1yicnqAxIl8R9PQ63TJaDnWTo7wWu6O26GKbEX9d7xDw==} + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.216': + resolution: {integrity: sha512-TWtTUabXj+AMOBMknSe3EVQb/7qucw5pHYi0HWoKoixQt1JE6J/CiLDBIY69OD3eQn9k1XG6LaurT239kFxLUw==} cpu: [arm64] os: [darwin] - '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.209': - resolution: {integrity: sha512-p3egSQ75amhjOO0P21pNfs94SueEAbOc0s7IFrGy8CZKXcFMIrOzUXKoi2iTWtR8P9v4ayibf38AJRa2Rz7KPA==} + '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.216': + resolution: {integrity: sha512-tULuvqhJUwGTj71Uln4uPumm0la4EO1XydFbISTbdc1BsuvGx3FOtFcVs0Qg3dhNBFAoU/iYVTO/B1rImeJZig==} cpu: [x64] os: [darwin] - '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.209': - resolution: {integrity: sha512-jtoia0l10xgrsBWjA12n6Q0WxOyr/8qf1eUMOGSO7zdNau/7wABYBXS+VGF8utBEsF4NJjfCEEj2yhEB8uHjLA==} + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.216': + resolution: {integrity: sha512-sisWj2nhQolKu6aGWnu+I7d8EU6/m5J3G6bGfR7YvZ1wkQTQZAX3aOq6DVLIfCaZSH22wtqyuXhE/gn5eU1syw==} cpu: [arm64] os: [linux] libc: [musl] - '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.209': - resolution: {integrity: sha512-eTgga/TuganOj9VAWUrjtt6BSqapeJau4ioCrQRw0RbDN/d/oLih/hdKzPeCn3yBw4Wdwwlip2z1cHKPNEggtQ==} + '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.216': + resolution: {integrity: sha512-kX6x0otwOuA1zIeEiuAKQqOk2TUAOkDUzm4MmLoQHOM2xWvr/pc+xY4DZxaX4ef9dUFQuHA4r5Vtbk7tPTL7Bw==} cpu: [arm64] os: [linux] libc: [glibc] - '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.209': - resolution: {integrity: sha512-GjxSlqk6yYCxb9BApVZvJ/Aq3OoI5mDRhpBpsZM23idNHg5/019sBBx4IGmkBjmfxTX5zzvu+m0fU18vl3W2Mg==} + '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.216': + resolution: {integrity: sha512-3Ts2Ab6oEGG2r+epmWc6lOR1ahECjvH4y+lPb32qLKMdYxT2PU6TAc4Z/USLLuqDpgw3nSb8xuiYEH77rv88fw==} cpu: [x64] os: [linux] libc: [musl] - '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.209': - resolution: {integrity: sha512-XGhc23qmqk1yUwG633iZUxPM91iJjrX/a+DVy+Pz5X5J64aXFAsR9R+kNkJ1X5FDcm+MSYywUtQm9AmAZaiG2Q==} + '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.216': + resolution: {integrity: sha512-WbJTLQafcYw9wl+gh/YpepdbWsOoJS7JZQA0obpzBBWzpx9PqPbjUDXDfCDrtzDI2fmr3Nz0//vC12bvlMZd8g==} cpu: [x64] os: [linux] libc: [glibc] - '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.209': - resolution: {integrity: sha512-LIMVnToVRmcTeN/dDAdFP/U60/aM0/qbKOWza+0BabhrGwF/3nPIIoGrMrkLsKPzOsfNWrl+kepexP1zWb1x5w==} + '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.216': + resolution: {integrity: sha512-xGB8rKatsXl9enqmPXixYNvdjGh1AL2jFmMRC9lR9M1prf2FGl7dkpdHz8YyItKAciRwpPIfts/9dSGhxlgT3A==} cpu: [arm64] os: [win32] - '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.209': - resolution: {integrity: sha512-tY5/+lmhqm7JQhvxLVTkhe2/UAw46gu8RnFrTwhoM0gEXDTmQhNlTM+m4F9LGeSfgKb2TElHpcQ237XNG1Ok6Q==} + '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.216': + resolution: {integrity: sha512-cGbBgoKNzB6wRL6bMOwM2iJEG/wx0B6FCblmKF+ZAYKcj5oNi3jM6oQsnPJbKfrzVKrip3YEpXxopQ9O30LrSg==} cpu: [x64] os: [win32] - '@anthropic-ai/claude-agent-sdk@0.3.209': - resolution: {integrity: sha512-HdxLmpnIOd4CSYxdlD/4ShKFvHAuWefP1MTf/B+Mj0Oq8Tb9qzmhLuHCZMd5Pf21Knrt7jke/t4coYAtokFcuw==} + '@anthropic-ai/claude-agent-sdk@0.3.216': + resolution: {integrity: sha512-Fl/d9yGW7Pm0aGwUNkJMvruOVobleYcoprGJqufdP4J9W2XL/2xKXqanY8KqUdfySRMI2N46rY4/lryq5SfgFg==} 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.111.0': - resolution: {integrity: sha512-1hUqKi+uJQoS5X90+InwHbFAXMvgq0DnsC5hVLEeSRaODiU5WvmqDAcVCmGS2wC0pN9Z8jtWCbWw7JLzeDdm/Q==} + '@anthropic-ai/sdk@0.112.4': + resolution: {integrity: sha512-7eXJJnrmBI5GMC6drrCiSkycVsT7crRZX3qv5HusLSm+qiILjmtqP7gf+UiT7ASu/7Gdj+Zfl4f2haV8wATKUg==} hasBin: true peerDependencies: zod: ^3.25.0 || ^4.0.0 @@ -1301,6 +1310,9 @@ packages: resolution: {integrity: sha512-WrOw/bcXm0f9qHkumlT1QlArXSTWqaY9sunsDpOk+yCCorCKMxvWT/a3xko4EYHVdeZoh00yI2TydXn6eyICDA==} engines: {node: '>= 10'} + '@napi-rs/wasm-runtime@0.2.12': + resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} + '@napi-rs/wasm-runtime@0.2.4': resolution: {integrity: sha512-9zESzOO5aDByvhIAsOy9TbpZ0Ur2AJbUI7UT73kcUTS2mxAMHOBaa1st/jAymNoCtvrit99kkzT1FZuXVcgfIQ==} @@ -1377,6 +1389,97 @@ packages: resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} engines: {node: ^14.21.3 || >=16} + '@node-rs/xxhash-android-arm-eabi@1.7.6': + resolution: {integrity: sha512-ptmfpFZ8SgTef58Us+0HsZ9BKhyX/gZYbhLkuzPt7qUoMqMSJK85NC7LEgzDgjUiG+S5GahEEQ9/tfh9BVvKhw==} + engines: {node: '>= 12'} + cpu: [arm] + os: [android] + + '@node-rs/xxhash-android-arm64@1.7.6': + resolution: {integrity: sha512-n4MyZvqifuoARfBvrZ2IBqmsGzwlVI3kb2mB0gVvoHtMsPbl/q94zoDBZ7WgeP3t4Wtli+QS3zgeTCOWUbqqUQ==} + engines: {node: '>= 12'} + cpu: [arm64] + os: [android] + + '@node-rs/xxhash-darwin-arm64@1.7.6': + resolution: {integrity: sha512-6xGuE07CiCIry/KT3IiwQd/kykTOmjKzO/ZnHlE5ibGMx64NFE0qDuwJbxQ4rGyUzgJ0KuN9ZdOhUDJmepnpcw==} + engines: {node: '>= 12'} + cpu: [arm64] + os: [darwin] + + '@node-rs/xxhash-darwin-x64@1.7.6': + resolution: {integrity: sha512-Z4oNnhyznDvHhxv+s0ka+5KG8mdfLVucZMZMejj9BL+CPmamClygPiHIRiifRcPAoX9uPZykaCsULngIfLeF3Q==} + engines: {node: '>= 12'} + cpu: [x64] + os: [darwin] + + '@node-rs/xxhash-freebsd-x64@1.7.6': + resolution: {integrity: sha512-arCDOf3xZ5NfBL5fk5J52sNPjXL2cVWN6nXNB3nrtRFFdPBLsr6YXtshAc6wMVxnIW4VGaEv/5K6IpTA8AFyWw==} + engines: {node: '>= 12'} + cpu: [x64] + os: [freebsd] + + '@node-rs/xxhash-linux-arm-gnueabihf@1.7.6': + resolution: {integrity: sha512-ndLLEW+MwLH3lFS0ahlHCcmkf2ykOv/pbP8OBBeAOlz/Xc3jKztg5IJ9HpkjKOkHk470yYxgHVaw1QMoMzU00A==} + engines: {node: '>= 12'} + cpu: [arm] + os: [linux] + + '@node-rs/xxhash-linux-arm64-gnu@1.7.6': + resolution: {integrity: sha512-VX7VkTG87mAdrF2vw4aroiRpFIIN8Lj6NgtGHF+IUVbzQxPudl4kG+FPEjsNH8y04yQxRbPE7naQNgHcTKMrNw==} + engines: {node: '>= 12'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@node-rs/xxhash-linux-arm64-musl@1.7.6': + resolution: {integrity: sha512-AB5m6crGYSllM9F/xZNOQSPImotR5lOa9e4arW99Bv82S+gcpphI8fGMDOVTTCXY/RLRhvvhwzLDxmLB2O8VDg==} + engines: {node: '>= 12'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@node-rs/xxhash-linux-x64-gnu@1.7.6': + resolution: {integrity: sha512-a2A6M+5tc0PVlJlE/nl0XsLEzMpKkwg7Y1lR5urFUbW9uVQnKjJYQDrUojhlXk0Uv3VnYQPa6ThmwlacZA5mvQ==} + engines: {node: '>= 12'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@node-rs/xxhash-linux-x64-musl@1.7.6': + resolution: {integrity: sha512-WioGJSC1GoxQpmdQrG5l/uddSBAS4XCWczHNwXe895J5xadGQzyvmr0r17BNfihvbBUDH1H9jwouNYzDDeA6+A==} + engines: {node: '>= 12'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@node-rs/xxhash-wasm32-wasi@1.7.6': + resolution: {integrity: sha512-WDXXKMMFMrez+esm2DzMPHFNPFYf+wQUtaXrXwtxXeQMFEzleOLwEaqV0+bbXGJTwhPouL3zY1Qo2xmIH4kkTg==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@node-rs/xxhash-win32-arm64-msvc@1.7.6': + resolution: {integrity: sha512-qjDFUZJT/Zq0yFS+0TApkD86p0NBdPXlOoHur9yNeO9YX2/9/b1sC2P7N27PgOu13h61TUOvTUC00e/82jAZRQ==} + engines: {node: '>= 12'} + cpu: [arm64] + os: [win32] + + '@node-rs/xxhash-win32-ia32-msvc@1.7.6': + resolution: {integrity: sha512-s7a+mQWOTnU4NiiypRq/vbNGot/il0HheXuy9oxJ0SW2q/e4BJ8j0pnP6UBlAjsk+005A76vOwsEj01qbQw8+A==} + engines: {node: '>= 12'} + cpu: [ia32] + os: [win32] + + '@node-rs/xxhash-win32-x64-msvc@1.7.6': + resolution: {integrity: sha512-zHOHm2UaIahRhgRPJll+4Xy4Z18aAT/7KNeQW+QJupGvFz+GzOFXMGs3R/3B1Ktob/F5ui3i1MrW9GEob3CWTg==} + engines: {node: '>= 12'} + cpu: [x64] + os: [win32] + + '@node-rs/xxhash@1.7.6': + resolution: {integrity: sha512-XMisO+aQHsVpxRp/85EszTtOQTOlhPbd149P/Xa9F55wafA6UM3h2UhOgOs7aAzItnHU/Aw1WQ1FVTEg7WB43Q==} + engines: {node: '>= 12'} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -1839,155 +1942,155 @@ packages: cpu: [x64] os: [win32] - '@oxfmt/binding-android-arm-eabi@0.58.0': - resolution: {integrity: sha512-Uz62sHduGGPftXtILGyxdSW4PX82rUg+rfdNqhsgxe881g4rIoXlIqmZQ6HVKcF4f+F8qMhdD03Bx5u7gmeTdg==} + '@oxfmt/binding-android-arm-eabi@0.59.0': + resolution: {integrity: sha512-bNTnfbuG7sAwb2PakMNaDukx5kXeW9duXOBeWtTOiLz3fXz3q2DlWguufPZ+c2IHEVrRXHD+M4aUgEWm841LDA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxfmt/binding-android-arm64@0.58.0': - resolution: {integrity: sha512-rD0lRaJp1b+9vw6X4A2dJWKukd6X8yxiicN4JxXcXayolmUypRZxk+lKR+fVOu5q/iYc0fh5fR4bgmfOfVlbaA==} + '@oxfmt/binding-android-arm64@0.59.0': + resolution: {integrity: sha512-R/Sn7z52QtdAKNqQLLY0EK7hVMjXiz3XUlvoCFCm/60jgIzAnQtiqLKBCFaBkimCQL5rs2ezPMcicpjCsrl54Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxfmt/binding-darwin-arm64@0.58.0': - resolution: {integrity: sha512-uzbPPk7O6M+w2K65vcQ1woga3wgP8zghjL1KOG5b6qJ8dvYHZJ1VShaslg2KOK6yQIwCQtcMCXqLBM6sqXUNTg==} + '@oxfmt/binding-darwin-arm64@0.59.0': + resolution: {integrity: sha512-vm/ynUqE4HjC0ZIEjmXv1UJu1/GngccQ+T+TJudTMxUxm6r+GQTg1TO3E5jJfI71pBaXxSzs1+vWHIwuilGHhw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxfmt/binding-darwin-x64@0.58.0': - resolution: {integrity: sha512-L0nKYDxU32oxeQqJj21W9SlIMnf81VZEhyah6iDvFhf5q0oynq498Fopth7blErUJVBpVtxQ98RMCfMPqpJX6w==} + '@oxfmt/binding-darwin-x64@0.59.0': + resolution: {integrity: sha512-uTtYDpLN/obfKVWGpgEc8BqYlLZBQTPz2uYEvLRy3HPZxjZ34wiFzukUBU2bf64JuCYZI//GTV1EOMmWlPjf/w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxfmt/binding-freebsd-x64@0.58.0': - resolution: {integrity: sha512-woNwfD58dC5PGS9LSLSD5JYfo/EFK5iG9vhDWkcCg3q78ag7KC8bpDqgvPHrMoXpx83OLXxoSOhu6z8FsVTHlg==} + '@oxfmt/binding-freebsd-x64@0.59.0': + resolution: {integrity: sha512-e2UnxL/ifStSPy8ffBCDbdy595SYsGy+U1pur4G65TuMmWxAMBzYGG7atZo/3mp515p8rZdsflxVD/E1FAdPLQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxfmt/binding-linux-arm-gnueabihf@0.58.0': - resolution: {integrity: sha512-Sqs8nMLxuQpY21NKJ1u4stPDmO5hskBCNNh2E3AdCfI1QqWtf4m+Qn4mGEIUO4KGmuq3SWc/SZ80uy5IiwTCDw==} + '@oxfmt/binding-linux-arm-gnueabihf@0.59.0': + resolution: {integrity: sha512-LtdeZ1l0urxte3VNi3g8cocZwv1xGM1NKHSgF/fJEEVhyQmlgGh7WFWKFd/pNuO7djfvPNtNO1+MS+FEWkgVSA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxfmt/binding-linux-arm-musleabihf@0.58.0': - resolution: {integrity: sha512-Vd4exzBI5B5hB9m22JiTQzIL23WvHo/Pe+sNXPNeBLXSP9swCBPKCEBRwKpmpQzYhlgYaCgfPcGXPKAJBRIiZQ==} + '@oxfmt/binding-linux-arm-musleabihf@0.59.0': + resolution: {integrity: sha512-dBTciSsj9GTMl7p+h2gMSI0hoPn2ijfc/dUsbnWsP0RbwgPl2r0C/5zkMb3Pb+gGj17LH7f1o4qLo9aes/pAvA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxfmt/binding-linux-arm64-gnu@0.58.0': - resolution: {integrity: sha512-bUWi5mHV+4Vi56RLHE1h6q/HHfwAIT3XoB9vJAVeRzfu5NriXM8y6eeJu0vlKa0C9kq2rq1sOWRClhdLHPocrg==} + '@oxfmt/binding-linux-arm64-gnu@0.59.0': + resolution: {integrity: sha512-tXVdJ/JINsNWdponPHN0OuKHtC+HdpyoS9sd6IDPNiiEYsRki8b7tefRZ1iMnRkdbyT4SEbguWsr6o+5awvbPQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-arm64-musl@0.58.0': - resolution: {integrity: sha512-2ZHxemzgHcjtktAuVUwSoyXmGo/t+aF5tS1ciPpPei4rhSyrz3JOqDosXXrmhN/yLUSzJjtuW7ToTWqfQpCj2w==} + '@oxfmt/binding-linux-arm64-musl@0.59.0': + resolution: {integrity: sha512-RRTq38i2zT5fnw6XGHjvT6w2mh6x/G3m6AZcAZ56OTDTT/lsOeYnG3SVjwmH40z5kPqF+lf+o35e6m6PpKy9Dw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@oxfmt/binding-linux-ppc64-gnu@0.58.0': - resolution: {integrity: sha512-AwKkVwjVmFQ3bcO7j0McGYAqCKH2a326fswfofng/E8VewCT/raeeGQr4huVhY704deK8AWASSTlxzMj0eZc6Q==} + '@oxfmt/binding-linux-ppc64-gnu@0.59.0': + resolution: {integrity: sha512-lD3k7glAJSaXW0D6xzu8VOZbYbosvy+0ktOVkfLEoQF5HJlMSxTQ2KNW0JO+08ccP/1ElOKktVEMI0fqRbVB4w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-riscv64-gnu@0.58.0': - resolution: {integrity: sha512-xsRpTxfUnJF8D3AUKko/qyWdjw4GZVHlCVFuGlzSCTeewLmykKINW8em1+wx+axsDVtJJcMtvsiaXggXxrlHgw==} + '@oxfmt/binding-linux-riscv64-gnu@0.59.0': + resolution: {integrity: sha512-WH5ZP1RbuHKBO/yfPRQKpNO/ijHcEDNbnmC4VPf/Bcd3+mbMAZpRiJWRa1PL5bREdIZZHo343mk3sqlc9x7Usw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-riscv64-musl@0.58.0': - resolution: {integrity: sha512-Z4AYOTcy7nYEIiXwD62PlerimyYRcfJOgUbQAEBjXz098kxKuERBlRntofGy69HHhe9E0TLVNMl1yspVNu+efw==} + '@oxfmt/binding-linux-riscv64-musl@0.59.0': + resolution: {integrity: sha512-743wOiaI9RZY4QVGkWkfGRavD5ZJUJ6gscFjVrVu1dP8AZh9jM+a6v3NhlR+OIzHdS6DhLM96w+gcVskskz7rw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [musl] - '@oxfmt/binding-linux-s390x-gnu@0.58.0': - resolution: {integrity: sha512-A3nhhtZPC/TKVWOPj9q/H3p2znJDCcHWYlJBhWL8hGq/bFmBaNBHC8Np6E581yVq1w9Mi3rMDNzDalWvtUfJtQ==} + '@oxfmt/binding-linux-s390x-gnu@0.59.0': + resolution: {integrity: sha512-xjRXQsRnrRZCcCkIEnbd2lmsQNobtwwkJxdy2bWXhZ1lIN0ouZwsBXRsoovW3yATuziAYwr9HMiQuR/Cc75NIw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-x64-gnu@0.58.0': - resolution: {integrity: sha512-2g+tVkgwqphw8R4hgo+kF4oz8+P5RwVOtr9+irsC7uwEp0e9j7Crw8kDGKL20uYlLPD7g02DqA61mC/UNYx98A==} + '@oxfmt/binding-linux-x64-gnu@0.59.0': + resolution: {integrity: sha512-4hNjqq/Rbr9B+StY9zMMAfm72+mtM4v80xYL5Qkb59Qd72g2vJMI0iFlPj3kf6miMsie/yJ7rt4urJT292HBgA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-x64-musl@0.58.0': - resolution: {integrity: sha512-rc15P6AbyyB7426aN8AakLd02Trb3a6ML/mmfAQeVHJEfVofWLcWIrBdy6zDEY+DIaL/s8E4GGPboVw+oP3+EA==} + '@oxfmt/binding-linux-x64-musl@0.59.0': + resolution: {integrity: sha512-NH579iN8EVQYsWowUB8B5vFchcylJtwPVJ7NmUAqEQHNLfhPbDT3K56KrECNAkUN4QpF4qiMgN2vsfZwVvjm7g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@oxfmt/binding-openharmony-arm64@0.58.0': - resolution: {integrity: sha512-ZWoTM27/HYPOh9iq86DAbhPu9nXb8qKvvGU/h8OfliyVUFAMMNTLDkGsWDKKnDqIkqvZ9+dXlgUOsH1LYO3O7g==} + '@oxfmt/binding-openharmony-arm64@0.59.0': + resolution: {integrity: sha512-mzZy3Z5Aj1D75Aq9FVlmoRQH5ei8Ga4o/NZmlXkKyeZ5EmPrUXRR7c6BMBteV1ZuZ/356UYDuLRLjAMxTDTiBA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxfmt/binding-win32-arm64-msvc@0.58.0': - resolution: {integrity: sha512-LHZnqFXe2dEfkRI4XdZS/57nEOT/I4UCRX5IyM9v4GYW9XwQCjGe1IUK59SuKw3POwvcgWQ4pme2cYXmNqTNPg==} + '@oxfmt/binding-win32-arm64-msvc@0.59.0': + resolution: {integrity: sha512-0CpDJ1gE3jN1Gk6xms1Ie6LPfPcOtY4FAtoOmVLHQoAf8DvO2wd0DW2dIX2f7YTp5dxrr0ND8JeUEjm3DP3k5g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxfmt/binding-win32-ia32-msvc@0.58.0': - resolution: {integrity: sha512-mZKpg20TpheCJym1rarcZCUJeW1sSruw8zAAaCYWvuVfwIUDN1CXdrPU/JgCWReXTCTrEfCB8Wyo3hh9jSZ2EA==} + '@oxfmt/binding-win32-ia32-msvc@0.59.0': + resolution: {integrity: sha512-zwdKBu3pt87uW0bRcywZb0oGMS7C6n87qogwRYFUgmk44T90ZzYlPjtlFYXs/DnBFrgNCvlHwCuWKfVWLeE7kw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxfmt/binding-win32-x64-msvc@0.58.0': - resolution: {integrity: sha512-N/wUU4N5PZ2orBtI+Ko7MnMfYLfE7K91UrGMY/c/pYyHR3lA9kwst1XugkZx+92YcRh/Eo+iv2eTESSWXfiZPA==} + '@oxfmt/binding-win32-x64-msvc@0.59.0': + resolution: {integrity: sha512-dUUbZkKgWrmAeI/puzv4bxN8lzcYaFnQVwFTFtwO2Gp8M7lZGSE2qJjC58g518+1bltJ8mizjYwD0BGHym0l/w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] - '@oxlint-tsgolint/darwin-arm64@0.24.0': - resolution: {integrity: sha512-C2uMmwK5Bc4ri4ysZ6sA8Rcu+A5zBQTp6ml2u0CLLbRZp4kMFPV3yWk8B5DK9Aw7y9bbjogIm75tUwGLFzlsYQ==} + '@oxlint-tsgolint/darwin-arm64@0.25.0': + resolution: {integrity: sha512-87opKlwFP8qS9WHAeETV+kA0fC9Oyj4sg7OxWdI4xQY0WC7zlN6BgG66uE5mvtN5mahkt/gL0i/AVEnX6POq2Q==} cpu: [arm64] os: [darwin] - '@oxlint-tsgolint/darwin-x64@0.24.0': - resolution: {integrity: sha512-Wgvt/1lRbDxmoNqWQKKcL+UIiqLmdJ+EWLpQa1qzoNVAfNB0PJpa82/8dH1twT/3rSs4zrP5TXPWl4juB71WuQ==} + '@oxlint-tsgolint/darwin-x64@0.25.0': + resolution: {integrity: sha512-HJmuZexsrhqp4WmETn+Soq7Ogt5F0jirv+cYRSniIPe+d/x5beQzLX69xOLhQRE+8FLGETe7FahWMVP8x0dW4g==} cpu: [x64] os: [darwin] - '@oxlint-tsgolint/linux-arm64@0.24.0': - resolution: {integrity: sha512-PB1rxII7KV83+ASY4sSkXtqvpij6ME66+QCRL49uksi/ofs2Rf/UVboYr095n0Rkbl2wgvlsHGl6DHC361jQUQ==} + '@oxlint-tsgolint/linux-arm64@0.25.0': + resolution: {integrity: sha512-aNyYsPREvCJi3qjfBA0sQB7DhT3y/W5Ac2JI2D8IJynoTOAhVZj401Si6901oDajlBWyqJqqojudn0VgHB6+7A==} cpu: [arm64] os: [linux] - '@oxlint-tsgolint/linux-x64@0.24.0': - resolution: {integrity: sha512-xcz3CxKmjTQLREtE/UShh+ruWmm9nAb7UM9zKcD65BStiuYgOakAKkPHl4YS5DztpVcDrE0+HqbOolTlRKYWmw==} + '@oxlint-tsgolint/linux-x64@0.25.0': + resolution: {integrity: sha512-+60+VjK9Mch3uA5WlTdNHuAm5+WA7wPPjuWdPWlU0F6JJpYpGZXUpO1RPKuFEWsBpNbLcLeJ0LbCJ1doWu58NA==} cpu: [x64] os: [linux] - '@oxlint-tsgolint/win32-arm64@0.24.0': - resolution: {integrity: sha512-A2i6ZGBec3i20S7RaxkgHc6r3HYtD5Mn7j/mb22NkTz14u0JuudvTu6JggAnbGMcv8+dBKQI//EasxSPJLD8pw==} + '@oxlint-tsgolint/win32-arm64@0.25.0': + resolution: {integrity: sha512-r53TO+eHp/t53nnUkQJfrYYXODPAxmtf3RUFQG5XsE2hD21IunliOaAdZXP2UwzCx+r/fbNaEelqTaAHcDr57w==} cpu: [arm64] os: [win32] - '@oxlint-tsgolint/win32-x64@0.24.0': - resolution: {integrity: sha512-0ZbGd9qRB6zs82moekaKdEvncRANq49EAwfNX62JpTS46feXUhKAuoyVDvZMj6Rywejylrmmu79Wo6faYCo4Ew==} + '@oxlint-tsgolint/win32-x64@0.25.0': + resolution: {integrity: sha512-vqe66B+gL9HarhyHemdlfC2VWT7eoA+o/ufZ7zT6AGHv64boyDZIJS3U+rpZo+ey4O7wZtiS/vYR2fWqDoFeBw==} cpu: [x64] os: [win32] @@ -2113,92 +2216,86 @@ packages: cpu: [x64] os: [win32] - '@parcel/watcher-android-arm64@2.5.6': - resolution: {integrity: sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==} + '@parcel/watcher-android-arm64@2.6.0': + resolution: {integrity: sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [android] - '@parcel/watcher-darwin-arm64@2.5.6': - resolution: {integrity: sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==} + '@parcel/watcher-darwin-arm64@2.6.0': + resolution: {integrity: sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [darwin] - '@parcel/watcher-darwin-x64@2.5.6': - resolution: {integrity: sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==} + '@parcel/watcher-darwin-x64@2.6.0': + resolution: {integrity: sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [darwin] - '@parcel/watcher-freebsd-x64@2.5.6': - resolution: {integrity: sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==} + '@parcel/watcher-freebsd-x64@2.6.0': + resolution: {integrity: sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [freebsd] - '@parcel/watcher-linux-arm-glibc@2.5.6': - resolution: {integrity: sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==} + '@parcel/watcher-linux-arm-glibc@2.6.0': + resolution: {integrity: sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==} engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] libc: [glibc] - '@parcel/watcher-linux-arm-musl@2.5.6': - resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==} + '@parcel/watcher-linux-arm-musl@2.6.0': + resolution: {integrity: sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==} engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] libc: [musl] - '@parcel/watcher-linux-arm64-glibc@2.5.6': - resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==} + '@parcel/watcher-linux-arm64-glibc@2.6.0': + resolution: {integrity: sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] libc: [glibc] - '@parcel/watcher-linux-arm64-musl@2.5.6': - resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==} + '@parcel/watcher-linux-arm64-musl@2.6.0': + resolution: {integrity: sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] libc: [musl] - '@parcel/watcher-linux-x64-glibc@2.5.6': - resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==} + '@parcel/watcher-linux-x64-glibc@2.6.0': + resolution: {integrity: sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] libc: [glibc] - '@parcel/watcher-linux-x64-musl@2.5.6': - resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==} + '@parcel/watcher-linux-x64-musl@2.6.0': + resolution: {integrity: sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] libc: [musl] - '@parcel/watcher-win32-arm64@2.5.6': - resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==} + '@parcel/watcher-win32-arm64@2.6.0': + resolution: {integrity: sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [win32] - '@parcel/watcher-win32-ia32@2.5.6': - resolution: {integrity: sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==} - engines: {node: '>= 10.0.0'} - cpu: [ia32] - os: [win32] - - '@parcel/watcher-win32-x64@2.5.6': - resolution: {integrity: sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==} + '@parcel/watcher-win32-x64@2.6.0': + resolution: {integrity: sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [win32] - '@parcel/watcher@2.5.6': - resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==} + '@parcel/watcher@2.6.0': + resolution: {integrity: sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==} engines: {node: '>= 10.0.0'} '@pinojs/redact@0.4.0': @@ -2216,20 +2313,20 @@ packages: resolution: {integrity: sha512-//0sR/cow/s4ICQaYoAobOl4aU8cjU6x/V24V7XkKotb9+O+3zySIYp146vpaobYHnxa4pZX8NkV54Z5AwbDKA==} engines: {node: '>=12'} - '@posthog/core@1.40.2': - resolution: {integrity: sha512-H12j7O9iHGvpK9t2ko8W4pvfbV1pBDxrsWC1LA6yp2RhzwvC4T3sWhu+AekDQJSRSrJEWlB0t/Ueq9QhPSq7FQ==} + '@posthog/core@1.44.0': + resolution: {integrity: sha512-uE+mdKvetxNQC6gWQf4MHIH8bGt+JN6z7ho0A4G3t9G1VGQTx9wHYHNwkKf3gzi+1oAXS9ej2VVY4pjWUU2PWg==} - '@posthog/types@1.393.0': - resolution: {integrity: sha512-vzWeEJZ7ERQhFRoQYaP5jzN1JvIu46UJyHXsuv+dTGW2r3sMgREOhNxXLZjmFHwZ8/FOHQoyqqQmXTCXZSfMSg==} + '@posthog/types@1.397.1': + resolution: {integrity: sha512-W/LpWbKVaaUnfZKuFuHa+Dg03D+fC87cM+PQbG+59JcSPW8F0JcBtSoXmpfrqbpuxUToMo+gktutrUkAb/KQBw==} '@radix-ui/number@1.1.2': resolution: {integrity: sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig==} - '@radix-ui/primitive@1.1.5': - resolution: {integrity: sha512-d86WIWFYNtGA0H/d8exstrTRTp7eWJYlYJbtNofxr/3ljupZYn6EFDG/Qgu/0Kc8v7yMUxySagqJsL1+PdYjWg==} + '@radix-ui/primitive@1.1.6': + resolution: {integrity: sha512-w9hl+724uYEgCGR3bhuRepjBtrNB/6gkhCnAf58Ke+SLbHPPQqVZZB59z60roB+5H+nh3nWTcdJhQdFMEydWmw==} - '@radix-ui/react-accordion@1.2.16': - resolution: {integrity: sha512-BpZJNmetujnGgUI6OX0jEhEmlA46WPqgub8Rv09Kyquwd0cc1ndMKpiPYCjmBU6KSSRPAMtgLpEoZSG/tdNIWQ==} + '@radix-ui/react-accordion@1.2.17': + resolution: {integrity: sha512-l3Dmp+qPPc3SqT8+SPnxIgoWBEU2MMBxcQ7BsoRgak2UT75xY83SFvFcrUkUAWukOV3LFF+BQ9aBIFtZsIG8yQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2241,8 +2338,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-arrow@1.1.11': - resolution: {integrity: sha512-Kdil9BB1rIFC/khmf4hC35bn8701AJcizTU7G7cUbEbk5XqqbjDuHW60uUfKqO5WojjZcbAW51Q7P0hRmMLw8A==} + '@radix-ui/react-arrow@1.1.12': + resolution: {integrity: sha512-ltXCE0glRomMZ9+u10d9o1Go+edqa1aLxufH59JRNNM3Yz1uvaeNWSaS1HeVh1X64agtdBG5JA1W1I6ySqWiwA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2254,8 +2351,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-collapsible@1.1.16': - resolution: {integrity: sha512-opfXRe6nnzyGmCDPx+l1Aqo/RbqWtQal2FnsBqF9hhePp6j0LsRoBaRxcMOlTv+uYTJVtWYZKg9t9wTe+BA/ZA==} + '@radix-ui/react-collapsible@1.1.17': + resolution: {integrity: sha512-DJgqGsNXa0df3ifz9PFNgvgj/bzIu5QTVWCt5nQWaUkM6y0EarUv4QG4s6mCoeQdOIyVOT/Q1osFuEGub2TDXQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2298,8 +2395,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-dialog@1.1.19': - resolution: {integrity: sha512-+HhbN2+YtkRgVirjZ2afMeutQRuGOrdkWR5+EFC58SJojGmtyNQwYzgi6tHBpOxvFHefMtPeHdgtjz0BOGxFQg==} + '@radix-ui/react-dialog@1.1.20': + resolution: {integrity: sha512-cngVJcvK0yMvR7wICJpv+1uW3Qw4T7QM5sdbb+oE/lxOdTdvF00oaRpWUjVgmjyXe3J+xh7eZyXZlVF3g2g59g==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2320,8 +2417,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-dismissable-layer@1.1.15': - resolution: {integrity: sha512-b0XaRlzn2QKuo10XyNgi2DAJDf5XC9d1nD3FJcuvCjbR7+4Ad28zmZsLsqx+hvDEzMnRuZaZxZm9gYObV6RmRA==} + '@radix-ui/react-dismissable-layer@1.1.16': + resolution: {integrity: sha512-t45h68IjFx0ccBnPJqk0X6ecv69LkCFWd6DNCFQX56mUnVEXZbNOLCH/u9fHlAjFZ1RrFdl8/m4zev7B7NyhXQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2342,8 +2439,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-focus-scope@1.1.12': - resolution: {integrity: sha512-jjk/lqTeNL0azUx5ZYzVrl4NgaDIrdzTNE4mABV9yBFI7FQqN7pIgzV1bTleUezP2QiTGA1BFTqY8MegDgWX9A==} + '@radix-ui/react-focus-scope@1.1.13': + resolution: {integrity: sha512-dE04aPEuP9rvKKT0d0KjSOtTEYNg6bmCYFsoSJpfC+y91Hic28ZfDCGgv6aJ+2Kw/LBXYipMZpyqVj/OD3Z8Gg==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2364,8 +2461,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-navigation-menu@1.2.18': - resolution: {integrity: sha512-K9HiuxZ6xCwSaHcIuUpxyhy4w5gpwzWjh9dHTSbMN3Ix4qAyVObS9RlU3zMycb0PO3v9Tpk0BXMwWvXOUbVXew==} + '@radix-ui/react-navigation-menu@1.2.19': + resolution: {integrity: sha512-58OVQUrpWx/zGVV3lxGUyAtjX4n0305Z8xIdUAq2QlFO2m2hd1eBS4x1yIVtV8bzCQJja0TJttWcwiPI6y6tmw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2377,8 +2474,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-popover@1.1.19': - resolution: {integrity: sha512-jkrTdQVxnIB8fpn0NyyxW9CTB5aCXZZelVz5z+Xmii6g5WxMqS3fInNslZ63puP39+Puu4jYohUK31y3dT87gQ==} + '@radix-ui/react-popover@1.1.20': + resolution: {integrity: sha512-/PYqbsyuDkNj+IxMcRx71qNt6GelnuNulMwdCV7AtFEhUyK6XkbwreEN6CCLydMeTiDozBV4uv5aF5d12dDH7w==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2390,8 +2487,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-popper@1.3.3': - resolution: {integrity: sha512-mS7dGpyjv6b+gsDjLF7e0ia1W4Im1B1hSCy2yuXlHuvnZxHKagfDaobt/KAKt27EpZMit2pss8eJBVyVjEWM+g==} + '@radix-ui/react-popper@1.3.4': + resolution: {integrity: sha512-PXnCa3XgTQk0FegMctxgqJXtFLZe4IFJdbUkB7jKSCKEpb6utEO4S9Vog/pkyCfEPdzM331gvE4xpztmBAfMng==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2403,8 +2500,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-portal@1.1.13': - resolution: {integrity: sha512-z3oXfmaHLJTF1wktbjgD6cn9jiEbq3WSondB10LIuIt2m2Ym4iJlrW04/euMwENDdWDdE7z+OuY7Qyp1YpRSwA==} + '@radix-ui/react-portal@1.1.14': + resolution: {integrity: sha512-REwjAGPMa3J9oyDE4cuWkZbwnCbbyky66NurquQklXMSDn67cl6oGFx2gO7KZhPtFNbNw9xTWNrti3VIhgluYw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2416,8 +2513,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-presence@1.1.7': - resolution: {integrity: sha512-zBZ4QM5XG3JRanDmqXYf3MD6th4AFXFmgU6KNMFzUaV6F3uw9I5/zjMUvFriSEn5ewo1nxuibvyxJdmLlDcslA==} + '@radix-ui/react-presence@1.1.8': + resolution: {integrity: sha512-0hhyrQdXMaATgq4ammLG9+iPqsXxzZkgTSIxdrJHdfLnXO4Uo5L7BoO3/Xf0AEaettadGZWGGJMw6ujzQvIpGA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2442,8 +2539,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-roving-focus@1.1.15': - resolution: {integrity: sha512-40svmmugfM3mUN7VUDGVE1tQGOhyi8enlGD0CNJEcMM36C1f71PKM21DFgNHUfem0XnA+d8H8oN3Z9ZpJjSslg==} + '@radix-ui/react-roving-focus@1.1.16': + resolution: {integrity: sha512-w7lLsTSd3940vFYEshKkHw+NGf7H0QDJPHYsy8NRjDCVbO6ZdKW1X/xoJSYHZtttnrdZiYqbN2O/2uHGB0zasw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2455,8 +2552,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-scroll-area@1.2.14': - resolution: {integrity: sha512-bBODCWZK7JTbQLHs0uIP4f73wIWatakK4OS33UzkR1x897wu0PuO658a3f+6P2GEGyDzGYMuHRatMVoAk9WZTw==} + '@radix-ui/react-scroll-area@1.2.15': + resolution: {integrity: sha512-JVBHNfTBbGd9hhq/xZZOgmVnBCXhLs8PJJ8vMzgwI0pLZNsKckW9pkoqHyxokUCt1hoxbwDNvF9DItEeZsG68g==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2477,8 +2574,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-tabs@1.1.17': - resolution: {integrity: sha512-nRyXnrAVCwjeXcHbvEbLS6ndbTeKHG1RqCP4A8Gw5L4cemDzPXdD8rAmr6wet0v57R69wGvuIIsFjHSVkZiMzQ==} + '@radix-ui/react-tabs@1.1.18': + resolution: {integrity: sha512-1zq2XkQkK/KfbZn84edytYpOLquhNalra5LXc3NAMKhNRSGtyXqjMv6OyC9jlSuNKpqvQtsb57WKoICNk1v/sQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2499,8 +2596,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-use-controllable-state@1.2.3': - resolution: {integrity: sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==} + '@radix-ui/react-use-controllable-state@1.2.4': + resolution: {integrity: sha512-cx2DixxmSfjCcEoRvDvy1NLd6SWK94XFcEEOZUcharUlXbmahFQGKCfwdKZL2ub34iIwOPOEFVF80xb+yfLYiA==} peerDependencies: '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc @@ -2562,8 +2659,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-visually-hidden@1.2.7': - resolution: {integrity: sha512-1wNZBggTDK3GRuuQ6nP4k2yi7a6l7I5qbMPbZcRsrGsGVead/f/d5FhEzUvqFs0bcrDLx7n1zKQ3JvLR6whaaw==} + '@radix-ui/react-visually-hidden@1.2.8': + resolution: {integrity: sha512-FjsQEpkNBJJYiPSat6jh2LGKLPX2jAoDVS3AZSBNX3cOUoEGhw/f+z2FCY8Cf1NkoYIbytJ1f4mlWPQpR+MjVg==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2756,129 +2853,129 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@supabase/auth-js@2.110.5': - resolution: {integrity: sha512-QSlI5CNeEefHP95/GbeMhNgr8aHEHiXFh8c1IWthYyI9ZzBwEigYzJFCw4Ff+GkL8OK9tArBmGGv6rpg5zLXSw==} + '@supabase/auth-js@2.110.7': + resolution: {integrity: sha512-M5Bpl4hCv6kHcOO/xM06Dyfg1mYLHljMkp1plhzG9IRZPc3czvyMsSN1XpL5+GKisOKM3lSN59zhpcm6sMVXfA==} engines: {node: '>=22.0.0'} - '@supabase/functions-js@2.110.5': - resolution: {integrity: sha512-nuQuoIoEGT8ukrwr6THlY5v50bSMJ2rqti1FFsGyDaC98POLmcFQVFFh23PVPhRsKWUxrFc0MpmEoHKa7CY4mg==} + '@supabase/functions-js@2.110.7': + resolution: {integrity: sha512-megYmexlYEoR/0qlsr4Snh9wtzAodO7MAri3NMevZrXzNvQRKlvmTcSBoKGLQEPDakgDZMqbMdf9DwoZz6qfoA==} engines: {node: '>=22.0.0'} - '@supabase/phoenix@0.4.4': - resolution: {integrity: sha512-Gt0pqoXuIqX/8dvG0OKp/wMCobXNH3klNbUPBNyOfN0YA1IswrM3HyWFMOPk1Jy+BRaIyDPcFx4jLBwHNmlyfQ==} + '@supabase/phoenix@0.4.5': + resolution: {integrity: sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw==} - '@supabase/postgrest-js@2.110.5': - resolution: {integrity: sha512-eObfgBjxLPzFakwMpUmsv8nuNPOhoDhzzN8cwvEvGkxkmuRIZcOCYH9+DRbpKnxh7tgsBZW+KWtaZI2j98b2/g==} + '@supabase/postgrest-js@2.110.7': + resolution: {integrity: sha512-ban6YV0djhVaqVYezlOARKLIuOBSvLLhyQVZjA2nxPrtswhxHCl1+gI4giFgI9ATQAaMNbUZb4JXiuL5lEA/5g==} engines: {node: '>=22.0.0'} - '@supabase/realtime-js@2.110.5': - resolution: {integrity: sha512-VtOZxw5jfrc37KgIfFdp9SOFJjtvJ0qU+gT8CPQjL7cqy4DJlaTXacw5aBBogLZksqAI0YN67qhlDaMKFwfOuw==} + '@supabase/realtime-js@2.110.7': + resolution: {integrity: sha512-AMtZjyFA2gsmjuxopPNS/sRznLQHG0Ht5x+ytTPTOh3vAcOTUlVRLx7gW4/CONNnbb3PKOkE+HmM35HOSbmomQ==} engines: {node: '>=22.0.0'} - '@supabase/storage-js@2.110.5': - resolution: {integrity: sha512-7GkOZlrYknVGVPyijoDbu5OXGvQ2oQ6dkU0juAkIMPZ9QgtmJ8IPrxe76zGSbmzbaC1GdKw7pqeXEi2HT67sbA==} + '@supabase/storage-js@2.110.7': + resolution: {integrity: sha512-2tcDE8cjEDy1uKxKavBpKQod1JdMV1jDXQag48TCa+kycmJOltc0yVabC0BUlhOwAl6WykXU2aOsH3ELMtZrmQ==} engines: {node: '>=22.0.0'} - '@supabase/supabase-js@2.110.5': - resolution: {integrity: sha512-cAO1Nm+CCogRNVXN93bBkh0vjOdLM5e6J9gB/cHV9Lqni/gEmN2HJFrnn4NI33GYIfmlh4Wbm6siH+XXRgpexA==} + '@supabase/supabase-js@2.110.7': + resolution: {integrity: sha512-AnfO3A230Shy6RMO7cya3Wl1OcXnABJrzH8vP+fY7/RFjhzcchB7DjKkkTIAntlwekD+GkSFzEvt2tC+D4Fp8w==} engines: {node: '>=22.0.0'} - '@swc-node/core@1.14.1': - resolution: {integrity: sha512-jrt5GUaZUU6cmMS+WTJEvGvaB6j1YNKPHPzC2PUi2BjaFbtxURHj6641Az6xN7b665hNniAIdvjxWcRml5yCnw==} + '@swc-node/core@1.15.0': + resolution: {integrity: sha512-WAerrrl087WgenB92XG4Th2t0NQFfMNLYSe0sW2cEMMqM/LQmP4rozsDJl0vuzTrbewjpKQryxFXI+aYig/dBg==} engines: {node: '>= 10'} peerDependencies: '@swc/core': '>= 1.13.3' '@swc/types': '>= 0.1' - '@swc-node/register@1.11.1': - resolution: {integrity: sha512-VQ0hJ5jX31TVv/fhZx4xJRzd8pwn6VvzYd2tGOHHr2TfXGCBixZoqdPDXTiEoJLCTS2MmvBf6zyQZZ0M8aGQCQ==} + '@swc-node/register@1.12.1': + resolution: {integrity: sha512-t6t+0bDos+bj0+jcSqKl7+ys/i1an5cEViC0LuIskJFSHONX871nXN8j+gxdKVOgCCpjs0buXUPNnp/0DaV4EQ==} peerDependencies: '@swc/core': '>= 1.4.13' - typescript: '>= 4.3' + typescript: '>= 4.3 < 7' '@swc-node/sourcemap-support@0.6.1': resolution: {integrity: sha512-ovltDVH5QpdHXZkW138vG4+dgcNsxfwxHVoV6BtmTbz2KKl1A8ZSlbdtxzzfNjCjbpayda8Us9eMtcHobm38dA==} - '@swc/core-darwin-arm64@1.15.43': - resolution: {integrity: sha512-v1aVuvXdo/BHxJzco9V2xpHrvwWmhfS8t6gziY5wJxd+Z2h8AeJRnAwPD8itCDaGXVBwJ/CaKfxEzTkG0Va0OA==} + '@swc/core-darwin-arm64@1.15.46': + resolution: {integrity: sha512-IsISIT22EfktVJrlvIpnAxG2u/A9aob9l99HMlx80x72WlFmFPk1V3UhkEzx86eJP8hw049KTFv/RISho2cq2Q==} engines: {node: '>=10'} cpu: [arm64] os: [darwin] - '@swc/core-darwin-x64@1.15.43': - resolution: {integrity: sha512-lp3d4Lamc8dt5huYdGLSR+9hLxmfr1jb0l+4XXG2zPqZwYWRN9R0U2qYoTrggiU2RWW0oV9VbWM3kBnqIc2kdQ==} + '@swc/core-darwin-x64@1.15.46': + resolution: {integrity: sha512-4Tj4ppVIPCmUMpmGFiGtyEriwLyJ+yi/US4WfBrP/ok8COGddDZXLEzQETnKyK46mjvr1v0jevrS23zjoff7vA==} engines: {node: '>=10'} cpu: [x64] os: [darwin] - '@swc/core-linux-arm-gnueabihf@1.15.43': - resolution: {integrity: sha512-JWTQQELtsG5GgphDrr/XqqmM2pDN3cZqbMS0Mrg+iTiXL3F74sn/S2IyYE/5u4h2KLkTf9qQ7dXyxsbx7YzkeA==} + '@swc/core-linux-arm-gnueabihf@1.15.46': + resolution: {integrity: sha512-i8tUGnNjyOgMmfmgFSg4aeJLQoFyfpIHK5FjpQAwpRyQIqEUB2w1e8zIDQzY1WhOxx8NoS1S5iUL813Un4Sf5A==} engines: {node: '>=10'} cpu: [arm] os: [linux] - '@swc/core-linux-arm64-gnu@1.15.43': - resolution: {integrity: sha512-B4otJRdPWIsmiSBf0uG7Z/+vMWmkufjz5MmYxubwKuZazDW14Zd3symga1N62QR4RT+kEFeHEgsXfZGyn/w0hw==} + '@swc/core-linux-arm64-gnu@1.15.46': + resolution: {integrity: sha512-c0OnhqzdhfOvv6qhNCcByepB+sNYOGZyhtr2Qa6ZCHvAWTYhSRw4j/u92Stue9PbZ/6q74b9nHzi76+kVzqQHQ==} engines: {node: '>=10'} cpu: [arm64] os: [linux] libc: [glibc] - '@swc/core-linux-arm64-musl@1.15.43': - resolution: {integrity: sha512-6zB6OnpViBxYy4tgY3v2i6AZY9fwkcHZ032UOwtwUuW1d19sdT07qF0kZe6/3UR1tUaK6jjg2rmVcUIBCEYVjQ==} + '@swc/core-linux-arm64-musl@1.15.46': + resolution: {integrity: sha512-imyRpNEcUzFQFV2LE4jL68ErvmKEuZCbvZru77iQREunJ+bR4i658cupTgtG1mLYM3F1Tzy3Sb9xYb02KghWTg==} engines: {node: '>=10'} cpu: [arm64] os: [linux] libc: [musl] - '@swc/core-linux-ppc64-gnu@1.15.43': - resolution: {integrity: sha512-coxE1ZWdB3uSDVNoEtYNrRi/1epvckZx9cTJ8ICUxTMTxGk+yvQ/Twacp3ruZSaMPGCriUjP86C37VhaT6nyRg==} + '@swc/core-linux-ppc64-gnu@1.15.46': + resolution: {integrity: sha512-ctEfcl/HcUeomK33cbySiHZm98GEDIxTm1EkpBsYCiHxElYBzvTXVeuQT2YwbUXn9XCrjiw4ipyUNk33k26qRg==} engines: {node: '>=10'} cpu: [ppc64] os: [linux] libc: [glibc] - '@swc/core-linux-s390x-gnu@1.15.43': - resolution: {integrity: sha512-lXfLhs+LpBsD5inuYx+YDH5WsPPBQ95KPUiy8P5wq9ob9xKDZFqwNfU2QW6bGO8NqRO/H9JQomTSt5Yyh+FGfA==} + '@swc/core-linux-s390x-gnu@1.15.46': + resolution: {integrity: sha512-DxlMdnt84TtRVTv7WL/thWyz9+QU8QZNNoAP9rrk0P68LziuhfePp8MjQ44zIprpTHTsEwyziIuGUUN5iSC1bQ==} engines: {node: '>=10'} cpu: [s390x] os: [linux] libc: [glibc] - '@swc/core-linux-x64-gnu@1.15.43': - resolution: {integrity: sha512-07XnKwTmKy8TGOZG3D9fRnLWGynxPjwQnZLVmBFbo6F+7vHYzBIOuwXEhemrChBWb6yDNZsVCcMWCPX6FDD2xg==} + '@swc/core-linux-x64-gnu@1.15.46': + resolution: {integrity: sha512-SKxI7J6t90XPl8hRUqtJi9NfGdunN/E/vZMc7Bc0figeRdOPDBT+Tm8g7cx9xM0T0mewh2l+8dewa3Am27/P+A==} engines: {node: '>=10'} cpu: [x64] os: [linux] libc: [glibc] - '@swc/core-linux-x64-musl@1.15.43': - resolution: {integrity: sha512-TJc+bsSIaBh+hZvZ5GRtW/K1bw66TJ9vsUwvVIsZdiWxU5ObLwZvfcnZ3UpgVfMnFibRes9uriJrQNBHEEogRQ==} + '@swc/core-linux-x64-musl@1.15.46': + resolution: {integrity: sha512-qj9T6B7bosI0VEsrWOVXZN1OXxS8Tp63ywyrLxNdOycnUtLdkgYcoBsN5y8ImnDDsnwrEWZOy1e+J4xSe7mA3Q==} engines: {node: '>=10'} cpu: [x64] os: [linux] libc: [musl] - '@swc/core-win32-arm64-msvc@1.15.43': - resolution: {integrity: sha512-jfd7s2/bUQYkOHLs+LWQNKZdmDa8+sufKLllhpWAhVQ2GDCwsHe3vR/j+OSiItZNtkzFuaawa3+SAKz9y5gYfw==} + '@swc/core-win32-arm64-msvc@1.15.46': + resolution: {integrity: sha512-8p7l4c3LU+eA5g9Et1JPhNeMC1oQwXTGU+uah8DPIBX7YXzqswvaBtyKVmXefVGi/DJU1x3YJsc3mbAp9aWzSQ==} engines: {node: '>=10'} cpu: [arm64] os: [win32] - '@swc/core-win32-ia32-msvc@1.15.43': - resolution: {integrity: sha512-rLAE8JvucqEW1ZGohxPQrQWPBQeJG4+ypKbWfdlU/qmKScvCkxf9/Jxnzki1dkUQCQ7P5Enp13RlvqOlvx/32g==} + '@swc/core-win32-ia32-msvc@1.15.46': + resolution: {integrity: sha512-tUEnfr3Bn9u6FOjUb3PN9p+09qZC2j+wNDLKHzXXZn22rqGcUqR/ohCRSS+nG9B9+X+U+3FewNEHJkTmdIvMjQ==} engines: {node: '>=10'} cpu: [ia32] os: [win32] - '@swc/core-win32-x64-msvc@1.15.43': - resolution: {integrity: sha512-h8MLDHZcfIukwQWj03rIJZx1I0E81AYj2X7J/nGErG4nz+QAv6G1Z+peotvinL3lqpbo32tLYSMFo32/ySzxKg==} + '@swc/core-win32-x64-msvc@1.15.46': + resolution: {integrity: sha512-Vux7UDzBJYQggSuPfcl2w9iu+IJpgpRCxHzgCaVkELnAXAE4XZMOTX9HNcaNiwfeIDqdu2rkr69RuDm6wY8neA==} engines: {node: '>=10'} cpu: [x64] os: [win32] - '@swc/core@1.15.43': - resolution: {integrity: sha512-1CuKjFkPxIgGdeHVuNbkxmBxkcbdc08u0aiI43pFq6yY1tTVKmXT9hFEooyyKs/sJ3xf1GPHyEwTtk9Xl8dvQw==} + '@swc/core@1.15.46': + resolution: {integrity: sha512-Ri3em2mBpq3h2zSPliCYl63otDGqek8PPEfv2nWgRQEbZ/VBCNyypVTVQ6cEbTCXBhy+WE2T3fQb08moIyuYaw==} engines: {node: '>=10'} peerDependencies: '@swc/helpers': '>=0.5.17' @@ -3260,6 +3357,73 @@ packages: '@yarnpkg/lockfile@1.1.0': resolution: {integrity: sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==} + '@yuku-analyzer/binding-darwin-arm64@0.6.12': + resolution: {integrity: sha512-9rpIP7IeybjyvWUf6WnU24h1qo+JdxIHr1o3yb06HoE8tM3S/Jh5RrUw9aw5P9BKSIvSPbLyVlItX7PcD3o5bQ==} + cpu: [arm64] + os: [darwin] + + '@yuku-analyzer/binding-darwin-x64@0.6.12': + resolution: {integrity: sha512-ELLhNT4FGnqY8yh0W3cSs9rGMSeUyhib1aYD84RupjlfsrDTrQRoDhWu01Dv6xCfYgASYaj1Abntk91A7njNag==} + cpu: [x64] + os: [darwin] + + '@yuku-analyzer/binding-freebsd-x64@0.6.12': + resolution: {integrity: sha512-s76XocUMlK9liTyipALFb2K64ku35u/wg238A0NW8U5CUDsuIe/8tu5TzdLjJAGxnd0IV+gBneDt9cJJzLeFRQ==} + cpu: [x64] + os: [freebsd] + + '@yuku-analyzer/binding-linux-arm-gnu@0.6.12': + resolution: {integrity: sha512-hm8Tq0umop3RGu6dOMF61q69tYn1bDp1CeYD5ZjuGFQJclp0moVtjzY4z0bzusicKeZ9+k5LRroR0p5HWC2hDw==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@yuku-analyzer/binding-linux-arm-musl@0.6.12': + resolution: {integrity: sha512-CxtPKLddogHAB3ZHVWaUl+U8jx0pdriTSbQ1K/orlDqU0GDhg8LuIRyUscP7r2/62fGGMzkc119fE71I4Nl1Fg==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@yuku-analyzer/binding-linux-arm64-gnu@0.6.12': + resolution: {integrity: sha512-EOyLcpAmF5qAVDKmKvV7xt8oBGeWQ92CqFI4s7h7TRlrF6TfGRrh8PwawGn92gFploNLAYj/1Z9Q1gVvwGgG9g==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@yuku-analyzer/binding-linux-arm64-musl@0.6.12': + resolution: {integrity: sha512-T3eCYy6bMnVRMQEYAbDcpj08/XM93dBTtnn/DDocJN21RARe+KCzWKeL26J3yd3bOW3WVjVLq09BfdpAGB0buQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@yuku-analyzer/binding-linux-x64-gnu@0.6.12': + resolution: {integrity: sha512-1Y+noIuvnDugIVsoIr5NduZqX7KuFTzICSkvG8RW3OKK9URVeTOicKK217i44ABZSSZJ7A0E7vzifapx0c9VDw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@yuku-analyzer/binding-linux-x64-musl@0.6.12': + resolution: {integrity: sha512-woN/GuG95Fd6bp+ZQfmiFrZnoA2hdu3vfVSc89A8LElnYpzFaJM81sOZp8f3tVOVUJxbt7KAUiCLwSy34MJKqA==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@yuku-analyzer/binding-win32-arm64@0.6.12': + resolution: {integrity: sha512-8OVFnKbK+lgsL6MqILPLpzlsa00K4KiKsdbHH94hpGcrqaz1jv+k0Y7ujSaoYTWw5Bb7Lr9GJ3L1n1hT2sXoYA==} + cpu: [arm64] + os: [win32] + + '@yuku-analyzer/binding-win32-x64@0.6.12': + resolution: {integrity: sha512-3w8w1Xc5njwgbGTcn3JfDxWuQnFvtSll1D8gBlk4U8CI5v7ibKOMIdABucCXH8WtsRREG0ME5Vn0i422eX3zLQ==} + cpu: [x64] + os: [win32] + + '@yuku-toolchain/types@0.6.11': + resolution: {integrity: sha512-i1JYFNJaKNCgyJ/nVoR8GK7wvlXF+ShYzFHBauWcvg8IoiXInK7pVziHcgNz/MWLPNr/Mb/CtmXccrJMkKqSHQ==} + + '@yuku-toolchain/types@0.6.8': + resolution: {integrity: sha512-AbUd1775RVkOxJkh8hkldIWoU6kRMTCsZFSZq8Ny53q7GkbaVe5UCfleNZ3RWCoz/ZKE8qwfeB7Cj0xqhLWsKA==} + '@zkochan/js-yaml@0.0.7': resolution: {integrity: sha512-nrUSn7hzt7J6JWgWGz78ZYI8wj+gdIJdk0Ynjpp8l+trkn58Uqsf6RYrYkEK+3X18EX+TNdtJI0WxAtc+L84SQ==} hasBin: true @@ -4115,6 +4279,9 @@ packages: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + fast-sha256@1.3.0: resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} @@ -4250,8 +4417,8 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] - fumadocs-core@16.11.4: - resolution: {integrity: sha512-PckGsjeXoQ7UkOn6XuJSNMoZYgYHAtEV5izxCHbjry1A9CM1m/u2dLM6Fefi9PS3SDVGQ8BKiYxtx1i4FF7T7g==} + fumadocs-core@16.11.5: + resolution: {integrity: sha512-YrHjS09+QYYKOSTGyiZbxF/VDs7ciMcjurYBGfmYqtzdj14k7Ho0HX9c6VuvG54YsYHQs5mGWemT1TXm7vDBaA==} peerDependencies: '@mdx-js/mdx': '*' '@mixedbread/sdk': 0.x.x @@ -4309,8 +4476,8 @@ packages: zod: optional: true - fumadocs-mdx@15.1.1: - resolution: {integrity: sha512-qJ5WstRONnCkidmxdKcu9WgYfmM8TbAZRsprWXWnSZk+RIa+QQCnn0FXa1c9Pm8qesYSFVm6HmgKGSI2uPpQ6w==} + fumadocs-mdx@15.2.0: + resolution: {integrity: sha512-+yBP8QYw5wA9LF5eVdMhwbP7KT1OF4B/YfC6PZoD2jz0amZi1B+6QHTI6XoRRSTmhWrI4cL5LU1DspW0itk+NA==} hasBin: true peerDependencies: '@fumadocs/satteri': 0.x.x @@ -4346,12 +4513,12 @@ packages: vite: optional: true - fumadocs-ui@16.11.4: - resolution: {integrity: sha512-ZLvyfPQjqa3m0+R6P9vkYtFW0HDC34b9lWiA6UPIJil7BwC8OUE9tpebKfQD69/DZF1KAC9lxz6929vLTraylQ==} + fumadocs-ui@16.11.5: + resolution: {integrity: sha512-Eda7x2Hk7E1iIjZ4uES0xxGr25Z72efRM5kP8sbgLSLhWg8TDCyWddvKAkzXIq8bupPOuJkdZa/YVvXbCktIEA==} peerDependencies: '@types/mdx': '*' '@types/react': '*' - fumadocs-core: 16.11.4 + fumadocs-core: 16.11.5 next: 16.x.x react: ^19.2.0 react-dom: ^19.2.0 @@ -4629,8 +4796,8 @@ packages: ink: '>=4.0.0' react: '>=18.0.0' - ink@7.1.0: - resolution: {integrity: sha512-VWE6/yeLtFCJBNLflyI2OSylyXK1Rc24LuXup8Qt+icwkmmycFNdbn8IkSp6Frc0h1iA0NOvvi1ajW44U/w3Qg==} + ink@7.1.1: + resolution: {integrity: sha512-Y43xxa1ZSPvpmfLHcN5o+OdP8Rf8ykkNJEuKYOUNZKT8wXVNLFTtEm1nSDMQkfBH+YANF4Xuu0hhZ4ejqAtN2w==} engines: {node: '>=22'} peerDependencies: '@types/react': '>=19.2.0' @@ -4865,86 +5032,86 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} - knip@6.26.0: - resolution: {integrity: sha512-e9eELEEpBpGTd4H4HB7818/DYj9dMzMyUqAddfYwUN/EbSkgIjOuWEF96W/xHsmV0SDrsdXjIM+oZ2xpPzPsBA==} + knip@6.27.0: + resolution: {integrity: sha512-CngYEYrD0n20N06FXA8n3u/0Wnnugoa+B9k14OP+iKIgkCHuzvIdsP3nfwjhByoc1WfogpxfiriMboAXFETDUw==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true kubernetes-types@1.30.0: resolution: {integrity: sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==} - lightningcss-android-arm64@1.32.0: - resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [android] - lightningcss-darwin-arm64@1.32.0: - resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [darwin] - lightningcss-darwin-x64@1.32.0: - resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [darwin] - lightningcss-freebsd-x64@1.32.0: - resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [freebsd] - lightningcss-linux-arm-gnueabihf@1.32.0: - resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} engines: {node: '>= 12.0.0'} cpu: [arm] os: [linux] - lightningcss-linux-arm64-gnu@1.32.0: - resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] libc: [glibc] - lightningcss-linux-arm64-musl@1.32.0: - resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] libc: [musl] - lightningcss-linux-x64-gnu@1.32.0: - resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] libc: [glibc] - lightningcss-linux-x64-musl@1.32.0: - resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] libc: [musl] - lightningcss-win32-arm64-msvc@1.32.0: - resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [win32] - lightningcss-win32-x64-msvc@1.32.0: - resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [win32] - lightningcss@1.32.0: - resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} engines: {node: '>= 12.0.0'} lines-and-columns@1.2.4: @@ -5030,8 +5197,8 @@ packages: resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} engines: {node: '>=12'} - lucide-react@1.24.0: - resolution: {integrity: sha512-YT6mBD8lGKkg4nM39enlm94/sfJIiW0YKUT60fBy4YK8tai31ylg1VhGNWxkpSKHo9UagfnZqwIff3HTDQwXeA==} + lucide-react@1.25.0: + resolution: {integrity: sha512-/mdJTRbiwcLOQ1NZZK1amZF9rIZyvO18D6r9TngE6TG1NmqHgFuT4eE7Xrkm9UsXMbBJD1NlfwHVltCDWHrOTw==} peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -5607,8 +5774,8 @@ packages: oxc-resolver@11.24.2: resolution: {integrity: sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw==} - oxfmt@0.58.0: - resolution: {integrity: sha512-8feG/7NVEHDVwc1OUpP6Pks+TnaDFUw2jLLFIMi5bcmmwxAX2wBQvjSzj62RRTYBf2Op1Wt8xbkmagmPTR5ETg==} + oxfmt@0.59.0: + resolution: {integrity: sha512-Xqk6cPZS1yMvVa7OAuenaDZUsgMDutvvbZ9/L5gSvAfW64+WN4HVhgipLj5rVERbYQt8fLs9TopyZ1rU1XEG/w==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -5620,8 +5787,8 @@ packages: vite-plus: optional: true - oxlint-tsgolint@0.24.0: - resolution: {integrity: sha512-giCk5sEvG02d5tzPmFMX3hem8ndzEEu1xvGYS5OwNfO2WGl6ZVxt5LjE0yiMDoz94INI7XkXwgFAQiydPvVHDw==} + oxlint-tsgolint@0.25.0: + resolution: {integrity: sha512-7DBpqyLZCfyoXiivyfzt9Xmju/K1RcN+Y1W7buEwrgRCWWF11v9alypPqWGZBmh2erDkKL/kVyhKUH2Px+t13A==} hasBin: true oxlint@1.74.0: @@ -5661,8 +5828,8 @@ packages: resolution: {integrity: sha512-nQja7m7gSKuewoVRen45CtVfODR3crN3goVQ0DDZ9N3yHxgpkuBhZqsaiotSQRrADUrne346peY7kT3TSACykg==} engines: {node: '>=4'} - p-map@7.0.5: - resolution: {integrity: sha512-e8vJF4XdVkzqqSHguEMz41mQO1wKwxKm5ENrUJQUu9kLDCtn83cxbyHZcszr4QC5zEA7WffRRC4gsTecC7J9oA==} + p-map@7.0.6: + resolution: {integrity: sha512-I4Prw6ivkd6p8PiYR1tXASOAOBzIJwu0TB7fqaX0c/8c3QAehNYmX57EijyGGGBt3c/BIowGwV03RVBtXvHEVg==} engines: {node: '>=18'} p-reduce@3.0.0: @@ -5852,8 +6019,8 @@ packages: resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==} engines: {node: ^10 || ^12 || >=14} - postcss@8.5.19: - resolution: {integrity: sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==} + postcss@8.5.20: + resolution: {integrity: sha512-lW616l85ucIQL+FocMmL7pQFPqBmwejrCMg+iPxyImlrANNJG9NHq/RkyCZopDhd8C3LA03PHRJDjkbGu8vvug==} engines: {node: ^10 || ^12 || >=14} postgres-array@2.0.0: @@ -5891,8 +6058,8 @@ packages: postgres-range@1.1.4: resolution: {integrity: sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==} - posthog-node@5.41.0: - resolution: {integrity: sha512-jOkX6THOr5WD+FGUEaTxekas8c7NOC3TqJ2Byfe2KMimQdL/F/osz17uSbkNzR4V9WFZoe8YaGP3Xp0EUpPKGg==} + posthog-node@5.46.0: + resolution: {integrity: sha512-Uzkth327Qxho9X55UygGUjVKCF9oaox90HQpa0o9YNjwLjbQmXttgHChzAtjAcsMw/ZKr3NnHC3xcAaS5dXwEQ==} engines: {node: ^20.20.0 || >=22.22.0} peerDependencies: rxjs: ^7.0.0 @@ -6207,8 +6374,8 @@ packages: scroll-into-view-if-needed@3.1.0: resolution: {integrity: sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==} - semantic-release@25.0.7: - resolution: {integrity: sha512-fFiUD7LNFI0TOGkc49WDHUX4GYKrOMDKct5ReSB1EJCZiPsPdbIPIJGys1xb2ILpK1v9JMsRkjJQgTRpbr7DgQ==} + semantic-release@25.0.8: + resolution: {integrity: sha512-w/iZ0bur36rKffXZYmIUmy068eoBY3Ij1DCCddx2JwWEM5Tg+eU9ld/E9qSInVvPASyyR2Ln/XGfQ9OZrMlhtw==} engines: {node: ^22.14.0 || >= 24.10.0} hasBin: true @@ -6562,15 +6729,15 @@ packages: tldts-core@6.1.86: resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} - tldts-core@7.4.8: - resolution: {integrity: sha512-c1P7u0EhACHj7lPy4MJm8iTFEU8+nB0LCtddH0fhP7noaVoXAqafMtOOeX+ulpuPBqnrRgRhw494RICT3mbhnw==} + tldts-core@7.4.9: + resolution: {integrity: sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==} tldts@6.1.86: resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} hasBin: true - tldts@7.4.8: - resolution: {integrity: sha512-htwgN/8KRB3z3vnC0BOETVh2m499g5GmyTK9Wq5JBLX3FNz6tSBveAd+fQhzy9hkjif8vy2jwDMR1sGhLtZl2A==} + tldts@7.4.9: + resolution: {integrity: sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==} hasBin: true tmp@0.2.7: @@ -6686,8 +6853,8 @@ packages: resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} engines: {node: '>=20.18.1'} - undici@8.7.0: - resolution: {integrity: sha512-N7iQtfyLhIMOFgQubvmLV26svHpO0bqKnAiWotTQCVKCmWrcGbBotPuW1x+xwYZ2VHdSTVUfPQQnlEt1/LouTQ==} + undici@8.8.0: + resolution: {integrity: sha512-ubshXMXwF3MQIMF1y/WxZdNBnjEKeSg2wF5mcGUtU55YTw34tnVVpKRlLf7ruDXZ5344KokPVX4RBx1wJm64Bw==} engines: {node: '>=22.19.0'} unicode-emoji-modifier-base@1.0.0: @@ -6986,8 +7153,8 @@ packages: utf-8-validate: optional: true - ws@8.21.0: - resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + ws@8.21.1: + resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -7045,6 +7212,12 @@ packages: yoga-layout@3.2.1: resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==} + yuku-analyzer@0.6.12: + resolution: {integrity: sha512-0zu/gwv6nKA3wm2GMjM1iczw9rbt77ijEyR5tXpPQ8AZcXIpXlll66BXOtMHgYudLn91bJx0ybhpARoJWm5/dw==} + + yuku-ast@0.6.11: + resolution: {integrity: sha512-ZfXkFYVsDewS45+kv3WiA/qNB73CRfxFDEQwfnRMUAR4AD5zRI7PRqxmI2U3Jz/oG41GneTVW6mxDOQal0lgeA==} + zod-to-json-schema@3.25.2: resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} peerDependencies: @@ -7079,46 +7252,46 @@ snapshots: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 - '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.209': + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.216': optional: true - '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.209': + '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.216': optional: true - '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.209': + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.216': optional: true - '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.209': + '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.216': optional: true - '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.209': + '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.216': optional: true - '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.209': + '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.216': optional: true - '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.209': + '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.216': optional: true - '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.209': + '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.216': optional: true - '@anthropic-ai/claude-agent-sdk@0.3.209(@anthropic-ai/sdk@0.111.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3)': + '@anthropic-ai/claude-agent-sdk@0.3.216(@anthropic-ai/sdk@0.112.4(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3)': dependencies: - '@anthropic-ai/sdk': 0.111.0(zod@4.4.3) + '@anthropic-ai/sdk': 0.112.4(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.209 - '@anthropic-ai/claude-agent-sdk-darwin-x64': 0.3.209 - '@anthropic-ai/claude-agent-sdk-linux-arm64': 0.3.209 - '@anthropic-ai/claude-agent-sdk-linux-arm64-musl': 0.3.209 - '@anthropic-ai/claude-agent-sdk-linux-x64': 0.3.209 - '@anthropic-ai/claude-agent-sdk-linux-x64-musl': 0.3.209 - '@anthropic-ai/claude-agent-sdk-win32-arm64': 0.3.209 - '@anthropic-ai/claude-agent-sdk-win32-x64': 0.3.209 + '@anthropic-ai/claude-agent-sdk-darwin-arm64': 0.3.216 + '@anthropic-ai/claude-agent-sdk-darwin-x64': 0.3.216 + '@anthropic-ai/claude-agent-sdk-linux-arm64': 0.3.216 + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl': 0.3.216 + '@anthropic-ai/claude-agent-sdk-linux-x64': 0.3.216 + '@anthropic-ai/claude-agent-sdk-linux-x64-musl': 0.3.216 + '@anthropic-ai/claude-agent-sdk-win32-arm64': 0.3.216 + '@anthropic-ai/claude-agent-sdk-win32-x64': 0.3.216 - '@anthropic-ai/sdk@0.111.0(zod@4.4.3)': + '@anthropic-ai/sdk@0.112.4(zod@4.4.3)': dependencies: json-schema-to-ts: 3.1.1 standardwebhooks: 1.0.0 @@ -7285,7 +7458,7 @@ snapshots: dependencies: '@types/ws': 8.18.1 effect: 4.0.0-beta.97 - ws: 8.21.0 + ws: 8.21.1 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -7296,7 +7469,7 @@ snapshots: effect: 4.0.0-beta.97 ioredis: 5.11.1 mime: 4.1.0 - undici: 8.7.0 + undici: 8.8.0 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -7719,6 +7892,13 @@ snapshots: '@napi-rs/keyring-win32-ia32-msvc': 1.3.0 '@napi-rs/keyring-win32-x64-msvc': 1.3.0 + '@napi-rs/wasm-runtime@0.2.12': + dependencies: + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 + '@tybys/wasm-util': 0.10.3 + optional: true + '@napi-rs/wasm-runtime@0.2.4': dependencies: '@emnapi/core': 1.4.5 @@ -7780,6 +7960,67 @@ snapshots: '@noble/hashes@1.8.0': {} + '@node-rs/xxhash-android-arm-eabi@1.7.6': + optional: true + + '@node-rs/xxhash-android-arm64@1.7.6': + optional: true + + '@node-rs/xxhash-darwin-arm64@1.7.6': + optional: true + + '@node-rs/xxhash-darwin-x64@1.7.6': + optional: true + + '@node-rs/xxhash-freebsd-x64@1.7.6': + optional: true + + '@node-rs/xxhash-linux-arm-gnueabihf@1.7.6': + optional: true + + '@node-rs/xxhash-linux-arm64-gnu@1.7.6': + optional: true + + '@node-rs/xxhash-linux-arm64-musl@1.7.6': + optional: true + + '@node-rs/xxhash-linux-x64-gnu@1.7.6': + optional: true + + '@node-rs/xxhash-linux-x64-musl@1.7.6': + optional: true + + '@node-rs/xxhash-wasm32-wasi@1.7.6': + dependencies: + '@napi-rs/wasm-runtime': 0.2.12 + optional: true + + '@node-rs/xxhash-win32-arm64-msvc@1.7.6': + optional: true + + '@node-rs/xxhash-win32-ia32-msvc@1.7.6': + optional: true + + '@node-rs/xxhash-win32-x64-msvc@1.7.6': + optional: true + + '@node-rs/xxhash@1.7.6': + optionalDependencies: + '@node-rs/xxhash-android-arm-eabi': 1.7.6 + '@node-rs/xxhash-android-arm64': 1.7.6 + '@node-rs/xxhash-darwin-arm64': 1.7.6 + '@node-rs/xxhash-darwin-x64': 1.7.6 + '@node-rs/xxhash-freebsd-x64': 1.7.6 + '@node-rs/xxhash-linux-arm-gnueabihf': 1.7.6 + '@node-rs/xxhash-linux-arm64-gnu': 1.7.6 + '@node-rs/xxhash-linux-arm64-musl': 1.7.6 + '@node-rs/xxhash-linux-x64-gnu': 1.7.6 + '@node-rs/xxhash-linux-x64-musl': 1.7.6 + '@node-rs/xxhash-wasm32-wasi': 1.7.6 + '@node-rs/xxhash-win32-arm64-msvc': 1.7.6 + '@node-rs/xxhash-win32-ia32-msvc': 1.7.6 + '@node-rs/xxhash-win32-x64-msvc': 1.7.6 + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -7792,13 +8033,13 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 - '@nx/devkit@23.1.0(nx@23.1.0(@swc-node/register@1.11.1(@swc/core@1.15.43)(@swc/types@0.1.27)(@typescript/typescript6@6.0.2))(@swc/core@1.15.43))': + '@nx/devkit@23.1.0(nx@23.1.0(@swc-node/register@1.12.1(@swc/core@1.15.46)(@swc/types@0.1.27)(@typescript/typescript6@6.0.2))(@swc/core@1.15.46))': dependencies: '@zkochan/js-yaml': 0.0.7 ejs: 5.0.1 enquirer: 2.3.6 minimatch: 10.2.5 - nx: 23.1.0(@swc-node/register@1.11.1(@swc/core@1.15.43)(@swc/types@0.1.27)(@typescript/typescript6@6.0.2))(@swc/core@1.15.43) + nx: 23.1.0(@swc-node/register@1.12.1(@swc/core@1.15.46)(@swc/types@0.1.27)(@typescript/typescript6@6.0.2))(@swc/core@1.15.46) semver: 7.8.5 tslib: 2.8.1 yaml: 2.9.0 @@ -8086,79 +8327,79 @@ snapshots: '@oxc-resolver/binding-win32-x64-msvc@11.24.2': optional: true - '@oxfmt/binding-android-arm-eabi@0.58.0': + '@oxfmt/binding-android-arm-eabi@0.59.0': optional: true - '@oxfmt/binding-android-arm64@0.58.0': + '@oxfmt/binding-android-arm64@0.59.0': optional: true - '@oxfmt/binding-darwin-arm64@0.58.0': + '@oxfmt/binding-darwin-arm64@0.59.0': optional: true - '@oxfmt/binding-darwin-x64@0.58.0': + '@oxfmt/binding-darwin-x64@0.59.0': optional: true - '@oxfmt/binding-freebsd-x64@0.58.0': + '@oxfmt/binding-freebsd-x64@0.59.0': optional: true - '@oxfmt/binding-linux-arm-gnueabihf@0.58.0': + '@oxfmt/binding-linux-arm-gnueabihf@0.59.0': optional: true - '@oxfmt/binding-linux-arm-musleabihf@0.58.0': + '@oxfmt/binding-linux-arm-musleabihf@0.59.0': optional: true - '@oxfmt/binding-linux-arm64-gnu@0.58.0': + '@oxfmt/binding-linux-arm64-gnu@0.59.0': optional: true - '@oxfmt/binding-linux-arm64-musl@0.58.0': + '@oxfmt/binding-linux-arm64-musl@0.59.0': optional: true - '@oxfmt/binding-linux-ppc64-gnu@0.58.0': + '@oxfmt/binding-linux-ppc64-gnu@0.59.0': optional: true - '@oxfmt/binding-linux-riscv64-gnu@0.58.0': + '@oxfmt/binding-linux-riscv64-gnu@0.59.0': optional: true - '@oxfmt/binding-linux-riscv64-musl@0.58.0': + '@oxfmt/binding-linux-riscv64-musl@0.59.0': optional: true - '@oxfmt/binding-linux-s390x-gnu@0.58.0': + '@oxfmt/binding-linux-s390x-gnu@0.59.0': optional: true - '@oxfmt/binding-linux-x64-gnu@0.58.0': + '@oxfmt/binding-linux-x64-gnu@0.59.0': optional: true - '@oxfmt/binding-linux-x64-musl@0.58.0': + '@oxfmt/binding-linux-x64-musl@0.59.0': optional: true - '@oxfmt/binding-openharmony-arm64@0.58.0': + '@oxfmt/binding-openharmony-arm64@0.59.0': optional: true - '@oxfmt/binding-win32-arm64-msvc@0.58.0': + '@oxfmt/binding-win32-arm64-msvc@0.59.0': optional: true - '@oxfmt/binding-win32-ia32-msvc@0.58.0': + '@oxfmt/binding-win32-ia32-msvc@0.59.0': optional: true - '@oxfmt/binding-win32-x64-msvc@0.58.0': + '@oxfmt/binding-win32-x64-msvc@0.59.0': optional: true - '@oxlint-tsgolint/darwin-arm64@0.24.0': + '@oxlint-tsgolint/darwin-arm64@0.25.0': optional: true - '@oxlint-tsgolint/darwin-x64@0.24.0': + '@oxlint-tsgolint/darwin-x64@0.25.0': optional: true - '@oxlint-tsgolint/linux-arm64@0.24.0': + '@oxlint-tsgolint/linux-arm64@0.25.0': optional: true - '@oxlint-tsgolint/linux-x64@0.24.0': + '@oxlint-tsgolint/linux-x64@0.25.0': optional: true - '@oxlint-tsgolint/win32-arm64@0.24.0': + '@oxlint-tsgolint/win32-arm64@0.25.0': optional: true - '@oxlint-tsgolint/win32-x64@0.24.0': + '@oxlint-tsgolint/win32-x64@0.25.0': optional: true '@oxlint/binding-android-arm-eabi@1.74.0': @@ -8218,65 +8459,61 @@ snapshots: '@oxlint/binding-win32-x64-msvc@1.74.0': optional: true - '@parcel/watcher-android-arm64@2.5.6': - optional: true - - '@parcel/watcher-darwin-arm64@2.5.6': + '@parcel/watcher-android-arm64@2.6.0': optional: true - '@parcel/watcher-darwin-x64@2.5.6': + '@parcel/watcher-darwin-arm64@2.6.0': optional: true - '@parcel/watcher-freebsd-x64@2.5.6': + '@parcel/watcher-darwin-x64@2.6.0': optional: true - '@parcel/watcher-linux-arm-glibc@2.5.6': + '@parcel/watcher-freebsd-x64@2.6.0': optional: true - '@parcel/watcher-linux-arm-musl@2.5.6': + '@parcel/watcher-linux-arm-glibc@2.6.0': optional: true - '@parcel/watcher-linux-arm64-glibc@2.5.6': + '@parcel/watcher-linux-arm-musl@2.6.0': optional: true - '@parcel/watcher-linux-arm64-musl@2.5.6': + '@parcel/watcher-linux-arm64-glibc@2.6.0': optional: true - '@parcel/watcher-linux-x64-glibc@2.5.6': + '@parcel/watcher-linux-arm64-musl@2.6.0': optional: true - '@parcel/watcher-linux-x64-musl@2.5.6': + '@parcel/watcher-linux-x64-glibc@2.6.0': optional: true - '@parcel/watcher-win32-arm64@2.5.6': + '@parcel/watcher-linux-x64-musl@2.6.0': optional: true - '@parcel/watcher-win32-ia32@2.5.6': + '@parcel/watcher-win32-arm64@2.6.0': optional: true - '@parcel/watcher-win32-x64@2.5.6': + '@parcel/watcher-win32-x64@2.6.0': optional: true - '@parcel/watcher@2.5.6': + '@parcel/watcher@2.6.0': dependencies: detect-libc: 2.1.2 is-glob: 4.0.3 node-addon-api: 7.1.1 picomatch: 4.0.5 optionalDependencies: - '@parcel/watcher-android-arm64': 2.5.6 - '@parcel/watcher-darwin-arm64': 2.5.6 - '@parcel/watcher-darwin-x64': 2.5.6 - '@parcel/watcher-freebsd-x64': 2.5.6 - '@parcel/watcher-linux-arm-glibc': 2.5.6 - '@parcel/watcher-linux-arm-musl': 2.5.6 - '@parcel/watcher-linux-arm64-glibc': 2.5.6 - '@parcel/watcher-linux-arm64-musl': 2.5.6 - '@parcel/watcher-linux-x64-glibc': 2.5.6 - '@parcel/watcher-linux-x64-musl': 2.5.6 - '@parcel/watcher-win32-arm64': 2.5.6 - '@parcel/watcher-win32-ia32': 2.5.6 - '@parcel/watcher-win32-x64': 2.5.6 + '@parcel/watcher-android-arm64': 2.6.0 + '@parcel/watcher-darwin-arm64': 2.6.0 + '@parcel/watcher-darwin-x64': 2.6.0 + '@parcel/watcher-freebsd-x64': 2.6.0 + '@parcel/watcher-linux-arm-glibc': 2.6.0 + '@parcel/watcher-linux-arm-musl': 2.6.0 + '@parcel/watcher-linux-arm64-glibc': 2.6.0 + '@parcel/watcher-linux-arm64-musl': 2.6.0 + '@parcel/watcher-linux-x64-glibc': 2.6.0 + '@parcel/watcher-linux-x64-musl': 2.6.0 + '@parcel/watcher-win32-arm64': 2.6.0 + '@parcel/watcher-win32-x64': 2.6.0 '@pinojs/redact@0.4.0': {} @@ -8292,34 +8529,34 @@ snapshots: '@pnpm/network.ca-file': 1.0.2 config-chain: 1.1.13 - '@posthog/core@1.40.2': + '@posthog/core@1.44.0': dependencies: - '@posthog/types': 1.393.0 + '@posthog/types': 1.397.1 - '@posthog/types@1.393.0': {} + '@posthog/types@1.397.1': {} '@radix-ui/number@1.1.2': {} - '@radix-ui/primitive@1.1.5': {} + '@radix-ui/primitive@1.1.6': {} - '@radix-ui/react-accordion@1.2.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-accordion@1.2.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/primitive': 1.1.5 - '@radix-ui/react-collapsible': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/primitive': 1.1.6 + '@radix-ui/react-collapsible': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-collection': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.4(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-arrow@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-arrow@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 @@ -8328,15 +8565,15 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-collapsible@1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-collapsible@1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/primitive': 1.1.5 + '@radix-ui/primitive': 1.1.6 '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-presence': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.4(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) @@ -8368,20 +8605,21 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 - '@radix-ui/react-dialog@1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-dialog@1.1.20(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/primitive': 1.1.5 + '@radix-ui/primitive': 1.1.6 '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-dismissable-layer': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-focus-scope': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-focus-scope': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-portal': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-presence': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) aria-hidden: 1.2.6 react: 19.2.7 react-dom: 19.2.7(react@19.2.7) @@ -8396,9 +8634,9 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 - '@radix-ui/react-dismissable-layer@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-dismissable-layer@1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/primitive': 1.1.5 + '@radix-ui/primitive': 1.1.6 '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) @@ -8415,7 +8653,7 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 - '@radix-ui/react-focus-scope@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-focus-scope@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -8433,43 +8671,43 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 - '@radix-ui/react-navigation-menu@1.2.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-navigation-menu@1.2.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/primitive': 1.1.5 + '@radix-ui/primitive': 1.1.6 '@radix-ui/react-collection': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-dismissable-layer': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-presence': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.4(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-visually-hidden': 1.2.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-visually-hidden': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-popover@1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-popover@1.1.20(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/primitive': 1.1.5 + '@radix-ui/primitive': 1.1.6 '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-dismissable-layer': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-focus-scope': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-focus-scope': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-popper': 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-portal': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-presence': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-popper': 1.3.4(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.4(@types/react@19.2.17)(react@19.2.7) aria-hidden: 1.2.6 react: 19.2.7 react-dom: 19.2.7(react@19.2.7) @@ -8478,10 +8716,10 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-popper@1.3.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-popper@1.3.4(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@floating-ui/react-dom': 2.1.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-arrow': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-arrow': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -8496,7 +8734,7 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-portal@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-portal@1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) @@ -8506,7 +8744,7 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-presence@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-presence@1.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 @@ -8524,9 +8762,9 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-roving-focus@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-roving-focus@1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/primitive': 1.1.5 + '@radix-ui/primitive': 1.1.6 '@radix-ui/react-collection': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) @@ -8534,7 +8772,7 @@ snapshots: '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.4(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-use-is-hydrated': 0.1.1(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 @@ -8543,14 +8781,14 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-scroll-area@1.2.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-scroll-area@1.2.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/number': 1.1.2 - '@radix-ui/primitive': 1.1.5 + '@radix-ui/primitive': 1.1.6 '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-presence': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) @@ -8567,16 +8805,16 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 - '@radix-ui/react-tabs@1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-tabs@1.1.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/primitive': 1.1.5 + '@radix-ui/primitive': 1.1.6 '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-presence': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-roving-focus': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.4(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: @@ -8589,8 +8827,9 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 - '@radix-ui/react-use-controllable-state@1.2.3(@types/react@19.2.17)(react@19.2.7)': + '@radix-ui/react-use-controllable-state@1.2.4(@types/react@19.2.17)(react@19.2.7)': dependencies: + '@radix-ui/primitive': 1.1.6 '@radix-ui/react-use-effect-event': 0.0.3(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 @@ -8636,7 +8875,7 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 - '@radix-ui/react-visually-hidden@1.2.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-visually-hidden@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 @@ -8700,7 +8939,7 @@ snapshots: '@sec-ant/readable-stream@0.4.1': {} - '@semantic-release/commit-analyzer@13.0.1(semantic-release@25.0.7(typescript@7.0.2))': + '@semantic-release/commit-analyzer@13.0.1(semantic-release@25.0.8(typescript@7.0.2))': dependencies: conventional-changelog-angular: 8.3.1 conventional-changelog-writer: 8.4.0 @@ -8710,13 +8949,13 @@ snapshots: import-from-esm: 2.0.0 lodash-es: 4.18.1 micromatch: 4.0.8 - semantic-release: 25.0.7(typescript@7.0.2) + semantic-release: 25.0.8(typescript@7.0.2) transitivePeerDependencies: - supports-color '@semantic-release/error@4.0.0': {} - '@semantic-release/github@12.0.9(semantic-release@25.0.7(typescript@7.0.2))': + '@semantic-release/github@12.0.9(semantic-release@25.0.8(typescript@7.0.2))': dependencies: '@octokit/core': 7.0.6 '@octokit/plugin-paginate-rest': 14.0.0(@octokit/core@7.0.6) @@ -8732,7 +8971,7 @@ snapshots: lodash-es: 4.18.1 mime: 4.1.0 p-filter: 4.1.0 - semantic-release: 25.0.7(typescript@7.0.2) + semantic-release: 25.0.8(typescript@7.0.2) tinyglobby: 0.2.17 undici: 7.28.0 url-join: 5.0.0 @@ -8740,7 +8979,7 @@ snapshots: - kerberos - supports-color - '@semantic-release/npm@13.1.5(semantic-release@25.0.7(typescript@7.0.2))': + '@semantic-release/npm@13.1.5(semantic-release@25.0.8(typescript@7.0.2))': dependencies: '@actions/core': 3.0.1 '@semantic-release/error': 4.0.0 @@ -8755,11 +8994,11 @@ snapshots: rc: 1.2.8 read-pkg: 10.1.0 registry-auth-token: 5.1.1 - semantic-release: 25.0.7(typescript@7.0.2) + semantic-release: 25.0.8(typescript@7.0.2) semver: 7.8.5 tempy: 3.2.0 - '@semantic-release/release-notes-generator@14.1.1(semantic-release@25.0.7(typescript@7.0.2))': + '@semantic-release/release-notes-generator@14.1.1(semantic-release@25.0.8(typescript@7.0.2))': dependencies: conventional-changelog-angular: 8.3.1 conventional-changelog-writer: 8.4.0 @@ -8769,7 +9008,7 @@ snapshots: import-from-esm: 2.0.0 lodash-es: 4.18.1 read-package-up: 11.0.0 - semantic-release: 25.0.7(typescript@7.0.2) + semantic-release: 25.0.8(typescript@7.0.2) transitivePeerDependencies: - supports-color @@ -8823,50 +9062,52 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@supabase/auth-js@2.110.5': + '@supabase/auth-js@2.110.7': dependencies: tslib: 2.8.1 - '@supabase/functions-js@2.110.5': + '@supabase/functions-js@2.110.7': dependencies: tslib: 2.8.1 - '@supabase/phoenix@0.4.4': {} + '@supabase/phoenix@0.4.5': {} - '@supabase/postgrest-js@2.110.5': + '@supabase/postgrest-js@2.110.7': dependencies: tslib: 2.8.1 - '@supabase/realtime-js@2.110.5': + '@supabase/realtime-js@2.110.7': dependencies: - '@supabase/phoenix': 0.4.4 + '@supabase/phoenix': 0.4.5 tslib: 2.8.1 - '@supabase/storage-js@2.110.5': + '@supabase/storage-js@2.110.7': dependencies: iceberg-js: 0.8.1 tslib: 2.8.1 - '@supabase/supabase-js@2.110.5': + '@supabase/supabase-js@2.110.7': dependencies: - '@supabase/auth-js': 2.110.5 - '@supabase/functions-js': 2.110.5 - '@supabase/postgrest-js': 2.110.5 - '@supabase/realtime-js': 2.110.5 - '@supabase/storage-js': 2.110.5 + '@supabase/auth-js': 2.110.7 + '@supabase/functions-js': 2.110.7 + '@supabase/postgrest-js': 2.110.7 + '@supabase/realtime-js': 2.110.7 + '@supabase/storage-js': 2.110.7 - '@swc-node/core@1.14.1(@swc/core@1.15.43)(@swc/types@0.1.27)': + '@swc-node/core@1.15.0(@swc/core@1.15.46)(@swc/types@0.1.27)': dependencies: - '@swc/core': 1.15.43 + '@swc/core': 1.15.46 '@swc/types': 0.1.27 - '@swc-node/register@1.11.1(@swc/core@1.15.43)(@swc/types@0.1.27)(@typescript/typescript6@6.0.2)': + '@swc-node/register@1.12.1(@swc/core@1.15.46)(@swc/types@0.1.27)(@typescript/typescript6@6.0.2)': dependencies: - '@swc-node/core': 1.14.1(@swc/core@1.15.43)(@swc/types@0.1.27) + '@node-rs/xxhash': 1.7.6 + '@swc-node/core': 1.15.0(@swc/core@1.15.46)(@swc/types@0.1.27) '@swc-node/sourcemap-support': 0.6.1 - '@swc/core': 1.15.43 + '@swc/core': 1.15.46 colorette: 2.0.20 debug: 4.4.3(supports-color@7.2.0) + fast-json-stable-stringify: 2.1.0 oxc-resolver: 11.24.2 pirates: 4.0.7 tslib: 2.8.1 @@ -8880,59 +9121,59 @@ snapshots: source-map-support: 0.5.21 tslib: 2.8.1 - '@swc/core-darwin-arm64@1.15.43': + '@swc/core-darwin-arm64@1.15.46': optional: true - '@swc/core-darwin-x64@1.15.43': + '@swc/core-darwin-x64@1.15.46': optional: true - '@swc/core-linux-arm-gnueabihf@1.15.43': + '@swc/core-linux-arm-gnueabihf@1.15.46': optional: true - '@swc/core-linux-arm64-gnu@1.15.43': + '@swc/core-linux-arm64-gnu@1.15.46': optional: true - '@swc/core-linux-arm64-musl@1.15.43': + '@swc/core-linux-arm64-musl@1.15.46': optional: true - '@swc/core-linux-ppc64-gnu@1.15.43': + '@swc/core-linux-ppc64-gnu@1.15.46': optional: true - '@swc/core-linux-s390x-gnu@1.15.43': + '@swc/core-linux-s390x-gnu@1.15.46': optional: true - '@swc/core-linux-x64-gnu@1.15.43': + '@swc/core-linux-x64-gnu@1.15.46': optional: true - '@swc/core-linux-x64-musl@1.15.43': + '@swc/core-linux-x64-musl@1.15.46': optional: true - '@swc/core-win32-arm64-msvc@1.15.43': + '@swc/core-win32-arm64-msvc@1.15.46': optional: true - '@swc/core-win32-ia32-msvc@1.15.43': + '@swc/core-win32-ia32-msvc@1.15.46': optional: true - '@swc/core-win32-x64-msvc@1.15.43': + '@swc/core-win32-x64-msvc@1.15.46': optional: true - '@swc/core@1.15.43': + '@swc/core@1.15.46': dependencies: '@swc/counter': 0.1.3 '@swc/types': 0.1.27 optionalDependencies: - '@swc/core-darwin-arm64': 1.15.43 - '@swc/core-darwin-x64': 1.15.43 - '@swc/core-linux-arm-gnueabihf': 1.15.43 - '@swc/core-linux-arm64-gnu': 1.15.43 - '@swc/core-linux-arm64-musl': 1.15.43 - '@swc/core-linux-ppc64-gnu': 1.15.43 - '@swc/core-linux-s390x-gnu': 1.15.43 - '@swc/core-linux-x64-gnu': 1.15.43 - '@swc/core-linux-x64-musl': 1.15.43 - '@swc/core-win32-arm64-msvc': 1.15.43 - '@swc/core-win32-ia32-msvc': 1.15.43 - '@swc/core-win32-x64-msvc': 1.15.43 + '@swc/core-darwin-arm64': 1.15.46 + '@swc/core-darwin-x64': 1.15.46 + '@swc/core-linux-arm-gnueabihf': 1.15.46 + '@swc/core-linux-arm64-gnu': 1.15.46 + '@swc/core-linux-arm64-musl': 1.15.46 + '@swc/core-linux-ppc64-gnu': 1.15.46 + '@swc/core-linux-s390x-gnu': 1.15.46 + '@swc/core-linux-x64-gnu': 1.15.46 + '@swc/core-linux-x64-musl': 1.15.46 + '@swc/core-win32-arm64-msvc': 1.15.46 + '@swc/core-win32-ia32-msvc': 1.15.46 + '@swc/core-win32-x64-msvc': 1.15.46 '@swc/counter@0.1.3': {} @@ -9349,6 +9590,43 @@ snapshots: '@yarnpkg/lockfile@1.1.0': {} + '@yuku-analyzer/binding-darwin-arm64@0.6.12': + optional: true + + '@yuku-analyzer/binding-darwin-x64@0.6.12': + optional: true + + '@yuku-analyzer/binding-freebsd-x64@0.6.12': + optional: true + + '@yuku-analyzer/binding-linux-arm-gnu@0.6.12': + optional: true + + '@yuku-analyzer/binding-linux-arm-musl@0.6.12': + optional: true + + '@yuku-analyzer/binding-linux-arm64-gnu@0.6.12': + optional: true + + '@yuku-analyzer/binding-linux-arm64-musl@0.6.12': + optional: true + + '@yuku-analyzer/binding-linux-x64-gnu@0.6.12': + optional: true + + '@yuku-analyzer/binding-linux-x64-musl@0.6.12': + optional: true + + '@yuku-analyzer/binding-win32-arm64@0.6.12': + optional: true + + '@yuku-analyzer/binding-win32-x64@0.6.12': + optional: true + + '@yuku-toolchain/types@0.6.11': {} + + '@yuku-toolchain/types@0.6.8': {} + '@zkochan/js-yaml@0.0.7': dependencies: argparse: 2.0.1 @@ -10289,6 +10567,8 @@ snapshots: merge2: 1.4.1 micromatch: 4.0.8 + fast-json-stable-stringify@2.1.0: {} + fast-sha256@1.3.0: {} fast-string-truncated-width@3.0.3: {} @@ -10415,7 +10695,7 @@ snapshots: fsevents@2.3.3: optional: true - fumadocs-core@16.11.4(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.24.0(react@19.2.7))(next@16.3.0-preview.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3): + fumadocs-core@16.11.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.7))(next@16.3.0-preview.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3): dependencies: '@orama/orama': 3.1.18 estree-util-value-to-estree: 3.5.0 @@ -10440,7 +10720,7 @@ snapshots: '@types/hast': 3.0.5 '@types/mdast': 4.0.4 '@types/react': 19.2.17 - lucide-react: 1.24.0(react@19.2.7) + lucide-react: 1.25.0(react@19.2.7) next: 16.3.0-preview.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) @@ -10448,15 +10728,16 @@ snapshots: transitivePeerDependencies: - supports-color - fumadocs-mdx@15.1.1(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.17)(fumadocs-core@16.11.4(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.24.0(react@19.2.7))(next@16.3.0-preview.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3))(next@16.3.0-preview.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)(rolldown@1.1.5)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)): + fumadocs-mdx@15.2.0(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.17)(fumadocs-core@16.11.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.7))(next@16.3.0-preview.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3))(next@16.3.0-preview.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)(rolldown@1.1.5)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)): dependencies: '@mdx-js/mdx': 3.1.1 '@standard-schema/spec': 1.1.0 chokidar: 5.0.0 esbuild: 0.28.1 estree-util-value-to-estree: 3.5.0 - fumadocs-core: 16.11.4(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.24.0(react@19.2.7))(next@16.3.0-preview.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3) + fumadocs-core: 16.11.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.7))(next@16.3.0-preview.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3) github-slugger: 2.0.0 + magic-string: 0.30.21 mdast-util-mdx: 3.0.0 picocolors: 1.1.1 picomatch: 4.0.5 @@ -10467,6 +10748,7 @@ snapshots: unist-util-visit: 5.1.0 vfile: 6.0.3 yaml: 2.9.0 + yuku-analyzer: 0.6.12 zod: 4.4.3 optionalDependencies: '@types/mdast': 4.0.4 @@ -10479,24 +10761,24 @@ snapshots: transitivePeerDependencies: - supports-color - fumadocs-ui@16.11.4(@types/mdx@2.0.14)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(fumadocs-core@16.11.4(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.24.0(react@19.2.7))(next@16.3.0-preview.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3))(next@16.3.0-preview.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + fumadocs-ui@16.11.5(@types/mdx@2.0.14)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(fumadocs-core@16.11.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.7))(next@16.3.0-preview.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3))(next@16.3.0-preview.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: '@fuma-translate/react': 1.0.2(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@fumadocs/tailwind': 0.1.1 - '@radix-ui/react-accordion': 1.2.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-collapsible': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-dialog': 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-accordion': 1.2.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-collapsible': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-dialog': 1.1.20(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-navigation-menu': 1.2.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-popover': 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-presence': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-scroll-area': 1.2.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-navigation-menu': 1.2.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-popover': 1.1.20(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-scroll-area': 1.2.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-tabs': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-tabs': 1.1.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) class-variance-authority: 0.7.1 cnfast: 0.0.8 - fumadocs-core: 16.11.4(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.24.0(react@19.2.7))(next@16.3.0-preview.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3) - lucide-react: 1.24.0(react@19.2.7) + fumadocs-core: 16.11.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.7))(next@16.3.0-preview.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3) + lucide-react: 1.25.0(react@19.2.7) motion: 12.42.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) next-themes: 0.4.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 @@ -10863,13 +11145,13 @@ snapshots: ini@7.0.0: {} - ink-spinner@5.0.0(ink@7.1.0(@types/react@19.2.17)(react-devtools-core@7.0.1)(react@19.2.7))(react@19.2.7): + ink-spinner@5.0.0(ink@7.1.1(@types/react@19.2.17)(react-devtools-core@7.0.1)(react@19.2.7))(react@19.2.7): dependencies: cli-spinners: 2.9.2 - ink: 7.1.0(@types/react@19.2.17)(react-devtools-core@7.0.1)(react@19.2.7) + ink: 7.1.1(@types/react@19.2.17)(react-devtools-core@7.0.1)(react@19.2.7) react: 19.2.7 - ink@7.1.0(@types/react@19.2.17)(react-devtools-core@7.0.1)(react@19.2.7): + ink@7.1.1(@types/react@19.2.17)(react-devtools-core@7.0.1)(react@19.2.7): dependencies: '@alcalzone/ansi-tokenize': 0.3.0 ansi-escapes: 7.3.0 @@ -10895,7 +11177,7 @@ snapshots: type-fest: 5.8.0 widest-line: 6.0.0 wrap-ansi: 10.0.0 - ws: 8.21.0 + ws: 8.21.1 yoga-layout: 3.2.1 optionalDependencies: '@types/react': 19.2.17 @@ -11096,7 +11378,7 @@ snapshots: dependencies: json-buffer: 3.0.1 - knip@6.26.0: + knip@6.27.0: dependencies: fdir: 6.5.0(picomatch@4.0.5) formatly: 0.3.0 @@ -11114,54 +11396,54 @@ snapshots: kubernetes-types@1.30.0: {} - lightningcss-android-arm64@1.32.0: + lightningcss-android-arm64@1.33.0: optional: true - lightningcss-darwin-arm64@1.32.0: + lightningcss-darwin-arm64@1.33.0: optional: true - lightningcss-darwin-x64@1.32.0: + lightningcss-darwin-x64@1.33.0: optional: true - lightningcss-freebsd-x64@1.32.0: + lightningcss-freebsd-x64@1.33.0: optional: true - lightningcss-linux-arm-gnueabihf@1.32.0: + lightningcss-linux-arm-gnueabihf@1.33.0: optional: true - lightningcss-linux-arm64-gnu@1.32.0: + lightningcss-linux-arm64-gnu@1.33.0: optional: true - lightningcss-linux-arm64-musl@1.32.0: + lightningcss-linux-arm64-musl@1.33.0: optional: true - lightningcss-linux-x64-gnu@1.32.0: + lightningcss-linux-x64-gnu@1.33.0: optional: true - lightningcss-linux-x64-musl@1.32.0: + lightningcss-linux-x64-musl@1.33.0: optional: true - lightningcss-win32-arm64-msvc@1.32.0: + lightningcss-win32-arm64-msvc@1.33.0: optional: true - lightningcss-win32-x64-msvc@1.32.0: + lightningcss-win32-x64-msvc@1.33.0: optional: true - lightningcss@1.32.0: + lightningcss@1.33.0: dependencies: detect-libc: 2.1.2 optionalDependencies: - lightningcss-android-arm64: 1.32.0 - lightningcss-darwin-arm64: 1.32.0 - lightningcss-darwin-x64: 1.32.0 - lightningcss-freebsd-x64: 1.32.0 - lightningcss-linux-arm-gnueabihf: 1.32.0 - lightningcss-linux-arm64-gnu: 1.32.0 - lightningcss-linux-arm64-musl: 1.32.0 - lightningcss-linux-x64-gnu: 1.32.0 - lightningcss-linux-x64-musl: 1.32.0 - lightningcss-win32-arm64-msvc: 1.32.0 - lightningcss-win32-x64-msvc: 1.32.0 + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 lines-and-columns@1.2.4: {} @@ -11234,7 +11516,7 @@ snapshots: lru-cache@7.18.3: {} - lucide-react@1.24.0(react@19.2.7): + lucide-react@1.25.0(react@19.2.7): dependencies: react: 19.2.7 @@ -11897,7 +12179,7 @@ snapshots: npm@11.18.0: {} - nx@23.1.0(@swc-node/register@1.11.1(@swc/core@1.15.43)(@swc/types@0.1.27)(@typescript/typescript6@6.0.2))(@swc/core@1.15.43): + nx@23.1.0(@swc-node/register@1.12.1(@swc/core@1.15.46)(@swc/types@0.1.27)(@typescript/typescript6@6.0.2))(@swc/core@1.15.46): dependencies: '@emnapi/core': 1.4.5 '@emnapi/runtime': 1.4.5 @@ -12025,8 +12307,8 @@ snapshots: '@nx/nx-linux-x64-musl': 23.1.0 '@nx/nx-win32-arm64-msvc': 23.1.0 '@nx/nx-win32-x64-msvc': 23.1.0 - '@swc-node/register': 1.11.1(@swc/core@1.15.43)(@swc/types@0.1.27)(@typescript/typescript6@6.0.2) - '@swc/core': 1.15.43 + '@swc-node/register': 1.12.1(@swc/core@1.15.46)(@swc/types@0.1.27)(@typescript/typescript6@6.0.2) + '@swc/core': 1.15.46 object-assign@4.1.1: {} @@ -12151,40 +12433,40 @@ snapshots: '@oxc-resolver/binding-win32-arm64-msvc': 11.24.2 '@oxc-resolver/binding-win32-x64-msvc': 11.24.2 - oxfmt@0.58.0: + oxfmt@0.59.0: dependencies: tinypool: 2.1.0 optionalDependencies: - '@oxfmt/binding-android-arm-eabi': 0.58.0 - '@oxfmt/binding-android-arm64': 0.58.0 - '@oxfmt/binding-darwin-arm64': 0.58.0 - '@oxfmt/binding-darwin-x64': 0.58.0 - '@oxfmt/binding-freebsd-x64': 0.58.0 - '@oxfmt/binding-linux-arm-gnueabihf': 0.58.0 - '@oxfmt/binding-linux-arm-musleabihf': 0.58.0 - '@oxfmt/binding-linux-arm64-gnu': 0.58.0 - '@oxfmt/binding-linux-arm64-musl': 0.58.0 - '@oxfmt/binding-linux-ppc64-gnu': 0.58.0 - '@oxfmt/binding-linux-riscv64-gnu': 0.58.0 - '@oxfmt/binding-linux-riscv64-musl': 0.58.0 - '@oxfmt/binding-linux-s390x-gnu': 0.58.0 - '@oxfmt/binding-linux-x64-gnu': 0.58.0 - '@oxfmt/binding-linux-x64-musl': 0.58.0 - '@oxfmt/binding-openharmony-arm64': 0.58.0 - '@oxfmt/binding-win32-arm64-msvc': 0.58.0 - '@oxfmt/binding-win32-ia32-msvc': 0.58.0 - '@oxfmt/binding-win32-x64-msvc': 0.58.0 - - oxlint-tsgolint@0.24.0: + '@oxfmt/binding-android-arm-eabi': 0.59.0 + '@oxfmt/binding-android-arm64': 0.59.0 + '@oxfmt/binding-darwin-arm64': 0.59.0 + '@oxfmt/binding-darwin-x64': 0.59.0 + '@oxfmt/binding-freebsd-x64': 0.59.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.59.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.59.0 + '@oxfmt/binding-linux-arm64-gnu': 0.59.0 + '@oxfmt/binding-linux-arm64-musl': 0.59.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.59.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.59.0 + '@oxfmt/binding-linux-riscv64-musl': 0.59.0 + '@oxfmt/binding-linux-s390x-gnu': 0.59.0 + '@oxfmt/binding-linux-x64-gnu': 0.59.0 + '@oxfmt/binding-linux-x64-musl': 0.59.0 + '@oxfmt/binding-openharmony-arm64': 0.59.0 + '@oxfmt/binding-win32-arm64-msvc': 0.59.0 + '@oxfmt/binding-win32-ia32-msvc': 0.59.0 + '@oxfmt/binding-win32-x64-msvc': 0.59.0 + + oxlint-tsgolint@0.25.0: optionalDependencies: - '@oxlint-tsgolint/darwin-arm64': 0.24.0 - '@oxlint-tsgolint/darwin-x64': 0.24.0 - '@oxlint-tsgolint/linux-arm64': 0.24.0 - '@oxlint-tsgolint/linux-x64': 0.24.0 - '@oxlint-tsgolint/win32-arm64': 0.24.0 - '@oxlint-tsgolint/win32-x64': 0.24.0 - - oxlint@1.74.0(oxlint-tsgolint@0.24.0): + '@oxlint-tsgolint/darwin-arm64': 0.25.0 + '@oxlint-tsgolint/darwin-x64': 0.25.0 + '@oxlint-tsgolint/linux-arm64': 0.25.0 + '@oxlint-tsgolint/linux-x64': 0.25.0 + '@oxlint-tsgolint/win32-arm64': 0.25.0 + '@oxlint-tsgolint/win32-x64': 0.25.0 + + oxlint@1.74.0(oxlint-tsgolint@0.25.0): optionalDependencies: '@oxlint/binding-android-arm-eabi': 1.74.0 '@oxlint/binding-android-arm64': 1.74.0 @@ -12205,7 +12487,7 @@ snapshots: '@oxlint/binding-win32-arm64-msvc': 1.74.0 '@oxlint/binding-win32-ia32-msvc': 1.74.0 '@oxlint/binding-win32-x64-msvc': 1.74.0 - oxlint-tsgolint: 0.24.0 + oxlint-tsgolint: 0.25.0 p-cancelable@2.1.1: {} @@ -12217,7 +12499,7 @@ snapshots: p-filter@4.1.0: dependencies: - p-map: 7.0.5 + p-map: 7.0.6 p-limit@1.3.0: dependencies: @@ -12227,7 +12509,7 @@ snapshots: dependencies: p-limit: 1.3.0 - p-map@7.0.5: {} + p-map@7.0.6: {} p-reduce@3.0.0: {} @@ -12412,7 +12694,7 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 - postcss@8.5.19: + postcss@8.5.20: dependencies: nanoid: 3.3.16 picocolors: 1.1.1 @@ -12440,9 +12722,9 @@ snapshots: postgres-range@1.1.4: {} - posthog-node@5.41.0: + posthog-node@5.46.0: dependencies: - '@posthog/core': 1.40.2 + '@posthog/core': 1.44.0 pretty-ms@9.3.0: dependencies: @@ -12826,13 +13108,13 @@ snapshots: dependencies: compute-scroll-into-view: 3.1.1 - semantic-release@25.0.7(typescript@7.0.2): + semantic-release@25.0.8(typescript@7.0.2): dependencies: - '@semantic-release/commit-analyzer': 13.0.1(semantic-release@25.0.7(typescript@7.0.2)) + '@semantic-release/commit-analyzer': 13.0.1(semantic-release@25.0.8(typescript@7.0.2)) '@semantic-release/error': 4.0.0 - '@semantic-release/github': 12.0.9(semantic-release@25.0.7(typescript@7.0.2)) - '@semantic-release/npm': 13.1.5(semantic-release@25.0.7(typescript@7.0.2)) - '@semantic-release/release-notes-generator': 14.1.1(semantic-release@25.0.7(typescript@7.0.2)) + '@semantic-release/github': 12.0.9(semantic-release@25.0.8(typescript@7.0.2)) + '@semantic-release/npm': 13.1.5(semantic-release@25.0.8(typescript@7.0.2)) + '@semantic-release/release-notes-generator': 14.1.1(semantic-release@25.0.8(typescript@7.0.2)) aggregate-error: 5.0.0 cosmiconfig: 9.0.2(typescript@7.0.2) debug: 4.4.3(supports-color@7.2.0) @@ -13279,15 +13561,15 @@ snapshots: tldts-core@6.1.86: {} - tldts-core@7.4.8: {} + tldts-core@7.4.9: {} tldts@6.1.86: dependencies: tldts-core: 6.1.86 - tldts@7.4.8: + tldts@7.4.9: dependencies: - tldts-core: 7.4.8 + tldts-core: 7.4.9 tmp@0.2.7: {} @@ -13392,7 +13674,7 @@ snapshots: undici@7.28.0: {} - undici@8.7.0: {} + undici@8.8.0: {} unicode-emoji-modifier-base@1.0.0: {} @@ -13584,9 +13866,9 @@ snapshots: vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0): dependencies: - lightningcss: 1.32.0 + lightningcss: 1.33.0 picomatch: 4.0.5 - postcss: 8.5.19 + postcss: 8.5.20 rolldown: 1.1.5 tinyglobby: 0.2.17 optionalDependencies: @@ -13682,7 +13964,7 @@ snapshots: ws@7.5.11: {} - ws@8.21.0: {} + ws@8.21.1: {} xtend@4.0.2: {} @@ -13731,6 +14013,27 @@ snapshots: yoga-layout@3.2.1: {} + yuku-analyzer@0.6.12: + dependencies: + '@yuku-toolchain/types': 0.6.11 + yuku-ast: 0.6.11 + optionalDependencies: + '@yuku-analyzer/binding-darwin-arm64': 0.6.12 + '@yuku-analyzer/binding-darwin-x64': 0.6.12 + '@yuku-analyzer/binding-freebsd-x64': 0.6.12 + '@yuku-analyzer/binding-linux-arm-gnu': 0.6.12 + '@yuku-analyzer/binding-linux-arm-musl': 0.6.12 + '@yuku-analyzer/binding-linux-arm64-gnu': 0.6.12 + '@yuku-analyzer/binding-linux-arm64-musl': 0.6.12 + '@yuku-analyzer/binding-linux-x64-gnu': 0.6.12 + '@yuku-analyzer/binding-linux-x64-musl': 0.6.12 + '@yuku-analyzer/binding-win32-arm64': 0.6.12 + '@yuku-analyzer/binding-win32-x64': 0.6.12 + + yuku-ast@0.6.11: + dependencies: + '@yuku-toolchain/types': 0.6.8 + zod-to-json-schema@3.25.2(zod@4.4.3): dependencies: zod: 4.4.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 42ff83fbac..5cc2f4607d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -18,19 +18,19 @@ catalog: "@effect/sql-pg": "4.0.0-beta.97" "@effect/vitest": "4.0.0-beta.97" "@nx/devkit": "^23.0.2" - "@swc-node/register": "^1.10.9" - "@swc/core": "^1.15.43" + "@swc-node/register": "^1.12.1" + "@swc/core": "^1.15.46" "@tsconfig/bun": "^1.0.10" "@types/bun": "^1.3.14" "@typescript/native-preview": "7.0.0-dev.20260707.2" "@vitest/coverage-istanbul": "^4.1.10" "effect": "4.0.0-beta.97" - "knip": "^6.26.0" + "knip": "^6.27.0" "nx": "^23.0.2" - "oxfmt": "^0.58.0" + "oxfmt": "^0.59.0" "oxlint": "^1.72.0" - "oxlint-tsgolint": "^0.24.0" - "tldts": "^7.4.8" + "oxlint-tsgolint": "^0.25.0" + "tldts": "^7.4.9" "vitest": "^4.1.10" blockExoticSubdeps: true