From 56b598bf83befe0bad67f62d382562ab9024fc30 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Fri, 22 May 2026 11:21:23 +0200 Subject: [PATCH 01/13] fix(cli): support older x64 CPUs (#5339) Switch the x64 Bun compile targets to baseline for the published Linux glibc, Linux musl, and Windows platform builds. Bun's default x64 targets can require newer AVX2-capable CPUs, while the baseline targets are intended for older x64 machines. Using the baseline targets avoids illegal-instruction failures on those systems without changing the published package names, release archive names, or amd64 packaging metadata. This also keeps the local release helper aligned with the production build matrix and updates libc detection so the Linux x64 musl baseline target is still classified as musl. Fixes https://github.com/supabase/cli/issues/5338 --- apps/cli/scripts/build.ts | 12 +- tools/release/local-release.ts | 514 ++++++++++++++++----------------- 2 files changed, 255 insertions(+), 271 deletions(-) diff --git a/apps/cli/scripts/build.ts b/apps/cli/scripts/build.ts index 07cca0def..ca2a67a6d 100644 --- a/apps/cli/scripts/build.ts +++ b/apps/cli/scripts/build.ts @@ -12,7 +12,7 @@ const MUSL_TARGETS = [ nfpmArch: "arm64", }, { - bunTarget: "bun-linux-x64-musl", + bunTarget: "bun-linux-x64-musl-baseline", pkg: "cli-linux-x64-musl", nfpmArch: "amd64", }, @@ -62,14 +62,14 @@ const TARGETS = [ ext: "", }, { - bunTarget: "bun-linux-x64", + bunTarget: "bun-linux-x64-baseline", pkg: "cli-linux-x64", archive: `supabase_${version}_linux_amd64.tar.gz`, nfpmArch: "amd64", ext: "", }, { - bunTarget: "bun-windows-x64", + bunTarget: "bun-windows-x64-baseline", pkg: "cli-windows-x64", archive: `supabase_${version}_windows_amd64.zip`, ext: ".exe", @@ -93,8 +93,8 @@ const GO_TARGETS: Record = { "bun-darwin-arm64": { goos: "darwin", goarch: "arm64" }, "bun-darwin-x64": { goos: "darwin", goarch: "amd64" }, "bun-linux-arm64": { goos: "linux", goarch: "arm64" }, - "bun-linux-x64": { goos: "linux", goarch: "amd64" }, - "bun-windows-x64": { goos: "windows", goarch: "amd64" }, + "bun-linux-x64-baseline": { goos: "linux", goarch: "amd64" }, + "bun-windows-x64-baseline": { goos: "windows", goarch: "amd64" }, "bun-windows-arm64": { goos: "windows", goarch: "arm64" }, }; @@ -102,7 +102,7 @@ function libcForBunTarget(target: string): "glibc" | "musl" | "" { if (!target.startsWith("bun-linux-")) { return ""; } - return target.endsWith("-musl") ? "musl" : "glibc"; + return target.includes("-musl") ? "musl" : "glibc"; } async function buildTarget(target: (typeof TARGETS)[number]) { diff --git a/tools/release/local-release.ts b/tools/release/local-release.ts index 06ccfc596..e3ad73590 100644 --- a/tools/release/local-release.ts +++ b/tools/release/local-release.ts @@ -25,294 +25,278 @@ const tokenPath = path.join(root, "tmp", "verdaccio-token"); // All seven platform packages that appear in optionalDependencies. const PLATFORM_PACKAGES = [ - "cli-darwin-arm64", - "cli-darwin-x64", - "cli-linux-arm64", - "cli-linux-arm64-musl", - "cli-linux-x64", - "cli-linux-x64-musl", - "cli-windows-arm64", - "cli-windows-x64", + "cli-darwin-arm64", + "cli-darwin-x64", + "cli-linux-arm64", + "cli-linux-arm64-musl", + "cli-linux-x64", + "cli-linux-x64-musl", + "cli-windows-arm64", + "cli-windows-x64", ] as const; type PlatformInfo = { - bunTarget: string; - platformPkg: string; - ext: string; - goos: string; - goarch: string; + bunTarget: string; + platformPkg: string; + ext: string; + goos: string; + goarch: string; }; const PLATFORM_MAP: Record = { - "darwin-arm64": { - bunTarget: "bun-darwin-arm64", - platformPkg: "cli-darwin-arm64", - ext: "", - goos: "darwin", - goarch: "arm64", - }, - "darwin-x64": { - bunTarget: "bun-darwin-x64", - platformPkg: "cli-darwin-x64", - ext: "", - goos: "darwin", - goarch: "amd64", - }, - "linux-arm64": { - bunTarget: "bun-linux-arm64", - platformPkg: "cli-linux-arm64", - ext: "", - goos: "linux", - goarch: "arm64", - }, - "linux-x64": { - bunTarget: "bun-linux-x64", - platformPkg: "cli-linux-x64", - ext: "", - goos: "linux", - goarch: "amd64", - }, - "win32-x64": { - bunTarget: "bun-windows-x64", - platformPkg: "cli-windows-x64", - ext: ".exe", - goos: "windows", - goarch: "amd64", - }, - "win32-arm64": { - bunTarget: "bun-windows-arm64", - platformPkg: "cli-windows-arm64", - ext: ".exe", - goos: "windows", - goarch: "arm64", - }, + "darwin-arm64": { + bunTarget: "bun-darwin-arm64", + platformPkg: "cli-darwin-arm64", + ext: "", + goos: "darwin", + goarch: "arm64", + }, + "darwin-x64": { + bunTarget: "bun-darwin-x64", + platformPkg: "cli-darwin-x64", + ext: "", + goos: "darwin", + goarch: "amd64", + }, + "linux-arm64": { + bunTarget: "bun-linux-arm64", + platformPkg: "cli-linux-arm64", + ext: "", + goos: "linux", + goarch: "arm64", + }, + "linux-x64": { + bunTarget: "bun-linux-x64-baseline", + platformPkg: "cli-linux-x64", + ext: "", + goos: "linux", + goarch: "amd64", + }, + "win32-x64": { + bunTarget: "bun-windows-x64-baseline", + platformPkg: "cli-windows-x64", + ext: ".exe", + goos: "windows", + goarch: "amd64", + }, + "win32-arm64": { + bunTarget: "bun-windows-arm64", + platformPkg: "cli-windows-arm64", + ext: ".exe", + goos: "windows", + goarch: "arm64", + }, }; function getPlatformInfo(): PlatformInfo { - const key = `${process.platform}-${process.arch}`; - const info = PLATFORM_MAP[key]; - if (!info) { - console.error(`\nError: Unsupported platform: ${key}`); - console.error( - "Supported: darwin-arm64, darwin-x64, linux-arm64, linux-x64, win32-x64\n", - ); - process.exit(1); - } - return info; + const key = `${process.platform}-${process.arch}`; + const info = PLATFORM_MAP[key]; + if (!info) { + console.error(`\nError: Unsupported platform: ${key}`); + console.error("Supported: darwin-arm64, darwin-x64, linux-arm64, linux-x64, win32-x64\n"); + process.exit(1); + } + return info; } function libcForBunTarget(target: string): "glibc" | "musl" | "" { - if (!target.startsWith("bun-linux-")) { - return ""; - } - return target.endsWith("-musl") ? "musl" : "glibc"; + if (!target.startsWith("bun-linux-")) { + return ""; + } + return target.includes("-musl") ? "musl" : "glibc"; } async function checkRegistry(): Promise { - try { - const res = await fetch(`${REGISTRY}/-/ping`, { - signal: AbortSignal.timeout(3000), - }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - } catch { - console.error(`\nError: Local registry not responding at ${REGISTRY}`); - console.error("Start it first with: pnpm local-registry\n"); - process.exit(1); - } + try { + const res = await fetch(`${REGISTRY}/-/ping`, { + signal: AbortSignal.timeout(3000), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + } catch { + console.error(`\nError: Local registry not responding at ${REGISTRY}`); + console.error("Start it first with: pnpm local-registry\n"); + process.exit(1); + } } async function readToken(): Promise { - try { - return (await Bun.file(tokenPath).text()).trim(); - } catch { - console.error(`\nError: Auth token not found at ${tokenPath}`); - console.error( - "The local registry must be running before you release: pnpm local-registry\n", - ); - process.exit(1); - } + try { + return (await Bun.file(tokenPath).text()).trim(); + } catch { + console.error(`\nError: Auth token not found at ${tokenPath}`); + console.error("The local registry must be running before you release: pnpm local-registry\n"); + process.exit(1); + } } async function checkGo(): Promise { - try { - await $`go version`.quiet(); - } catch { - console.error("\nError: `go` not found in PATH."); - console.error("Install Go from https://go.dev/dl/ to build the legacy shell.\n"); - process.exit(1); - } + try { + await $`go version`.quiet(); + } catch { + console.error("\nError: `go` not found in PATH."); + console.error("Install Go from https://go.dev/dl/ to build the legacy shell.\n"); + process.exit(1); + } } async function checkGoSource(): Promise { - const goSource = path.join(root, "apps", "cli-go"); - const goMod = Bun.file(path.join(goSource, "go.mod")); - if (!(await goMod.exists())) { - console.error("\nError: Go CLI source not found at apps/cli-go"); - console.error("Run: pnpm repos:install\n"); - process.exit(1); - } - return goSource; + const goSource = path.join(root, "apps", "cli-go"); + const goMod = Bun.file(path.join(goSource, "go.mod")); + if (!(await goMod.exists())) { + console.error("\nError: Go CLI source not found at apps/cli-go"); + console.error("Run: pnpm repos:install\n"); + process.exit(1); + } + return goSource; } async function main() { - const { values } = parseArgs({ - options: { - legacy: { type: "boolean", default: false }, - next: { type: "boolean", default: false }, - version: { type: "string" }, - }, - }); - - if (!values.legacy && !values.next) { - console.error("Usage: pnpm cli-release --next | --legacy [--version ]"); - process.exit(1); - } - if (values.legacy && values.next) { - console.error("Error: Specify either --next or --legacy, not both."); - process.exit(1); - } - - const shell = values.legacy ? "legacy" : "next"; - const version = values.version ?? `0.0.0-local.${Math.floor(Date.now() / 1000)}`; - - await checkRegistry(); - const token = await readToken(); - const platform = getPlatformInfo(); - - let goSource: string | undefined; - if (shell === "legacy") { - await checkGo(); - goSource = await checkGoSource(); - - if (process.platform === "linux") { - console.warn( - "Note: local-release builds the glibc variant only (cli-linux-*). " + - "The musl variant is skipped for local dev.\n", - ); - } - } - - // All build output goes into a system temp directory — never into the git repo. - const tmpDir = await mkdtemp(path.join(tmpdir(), "supabase-local-release-")); - - try { - // Read once up front so log lines and the published package.json agree. - const cliPkgJson = await Bun.file( - path.join(root, "apps", "cli", "package.json"), - ).json(); - const umbrellaName: string = cliPkgJson.name; - - console.log( - `\nBuilding ${umbrellaName}@${version} (${shell}, ${platform.platformPkg})...\n`, - ); - - // ── Build platform package ──────────────────────────────────────────── - - const tmpPlatformDir = path.join(tmpDir, platform.platformPkg); - const tmpPlatformBinDir = path.join(tmpPlatformDir, "bin"); - await mkdir(tmpPlatformBinDir, { recursive: true }); - - const entrypoint = path.join(root, "apps", "cli", "src", shell, "main.ts"); - const bunBinary = path.join(tmpPlatformBinDir, `supabase${platform.ext}`); - const libc = libcForBunTarget(platform.bunTarget); - - console.log(`[1/${shell === "legacy" ? 3 : 2}] Compiling ${shell} CLI binary...`); - await $`bun build ${entrypoint} --compile --target=${platform.bunTarget} --define=SUPABASE_LIBC=${JSON.stringify(libc)} --outfile=${bunBinary}`; - - if (shell === "legacy" && goSource) { - const goBinary = path.join(tmpPlatformBinDir, `supabase-go${platform.ext}`); - console.log( - `[2/3] Compiling Go CLI binary (${platform.goos}/${platform.goarch})...`, - ); - // Run go build from within the Go source directory so Go can find - // the go.mod there. Passing an absolute path as a positional arg - // causes Go to resolve the module from CWD instead, which fails - // because the repo root has no go.mod. - await $`go build -trimpath -ldflags="-s -w" -o ${goBinary} .` - .cwd(goSource) - .env({ - ...process.env, - GOOS: platform.goos, - GOARCH: platform.goarch, - CGO_ENABLED: "0", - }); - } - - // ── Build umbrella package shim ─────────────────────────────────────── - - const tmpCliDir = path.join(tmpDir, "cli"); - const tmpCliDistDir = path.join(tmpCliDir, "dist"); - await mkdir(tmpCliDistDir, { recursive: true }); - - const shimSrc = path.join(root, "apps", "cli", "src", "shared", "cli", "bin.ts"); - const shimOut = path.join(tmpCliDistDir, "supabase.js"); - const shimStep = shell === "legacy" ? 3 : 2; - console.log(`[${shimStep}/${shimStep}] Building Node.js shim...`); - await $`bun build ${shimSrc} --outfile=${shimOut} --target=node`; - - // ── Write package.json files ────────────────────────────────────────── - - // Platform package: copy as-is, bump version. - const platformPkgJson = await Bun.file( - path.join(root, "packages", platform.platformPkg, "package.json"), - ).json(); - platformPkgJson.version = version; - await Bun.write( - path.join(tmpPlatformDir, "package.json"), - `${JSON.stringify(platformPkgJson, null, "\t")}\n`, - ); - - // Umbrella package: build a minimal package.json. - // The shim only uses Node built-ins — all @supabase/* and catalog: deps - // are bundled in the platform binary and must not appear in the published - // package.json (catalog: and workspace:* are invalid outside pnpm workspaces). - const resolvedOptionalDeps: Record = {}; - for (const pkg of PLATFORM_PACKAGES) { - resolvedOptionalDeps[`@supabase/${pkg}`] = version; - } - - const publishPkgJson = { - name: cliPkgJson.name, - version, - type: cliPkgJson.type, - bin: cliPkgJson.bin, - files: cliPkgJson.files, - publishConfig: cliPkgJson.publishConfig, - optionalDependencies: resolvedOptionalDeps, - }; - await Bun.write( - path.join(tmpCliDir, "package.json"), - `${JSON.stringify(publishPkgJson, null, "\t")}\n`, - ); - - // ── Write .npmrc with registry and auth token ───────────────────────── - - const npmrc = [ - `registry=${REGISTRY}`, - `//localhost:${PORT}/:_authToken=${token}`, - "", - ].join("\n"); - await Bun.write(path.join(tmpPlatformDir, ".npmrc"), npmrc); - await Bun.write(path.join(tmpCliDir, ".npmrc"), npmrc); - - // ── Publish ─────────────────────────────────────────────────────────── - - console.log( - `\nPublishing @supabase/${platform.platformPkg}@${version} to local registry...`, - ); - // Use bun publish for the platform binary package: pnpm normalises file - // modes in tarballs and strips the execute bit from files not in the - // package's `bin` field. bun publish preserves modes, matching production. - await $`bun publish --access public --tag local --registry ${REGISTRY} --no-git-checks`.cwd( - tmpPlatformDir, - ); - - console.log(`Publishing ${umbrellaName}@${version} to local registry...`); - await $`pnpm publish --access public --tag local --registry ${REGISTRY} --no-git-checks`.cwd( - tmpCliDir, - ); - - console.log(` + const { values } = parseArgs({ + options: { + legacy: { type: "boolean", default: false }, + next: { type: "boolean", default: false }, + version: { type: "string" }, + }, + }); + + if (!values.legacy && !values.next) { + console.error("Usage: pnpm cli-release --next | --legacy [--version ]"); + process.exit(1); + } + if (values.legacy && values.next) { + console.error("Error: Specify either --next or --legacy, not both."); + process.exit(1); + } + + const shell = values.legacy ? "legacy" : "next"; + const version = values.version ?? `0.0.0-local.${Math.floor(Date.now() / 1000)}`; + + await checkRegistry(); + const token = await readToken(); + const platform = getPlatformInfo(); + + let goSource: string | undefined; + if (shell === "legacy") { + await checkGo(); + goSource = await checkGoSource(); + + if (process.platform === "linux") { + console.warn( + "Note: local-release builds the glibc variant only (cli-linux-*). " + + "The musl variant is skipped for local dev.\n", + ); + } + } + + // All build output goes into a system temp directory — never into the git repo. + const tmpDir = await mkdtemp(path.join(tmpdir(), "supabase-local-release-")); + + try { + // Read once up front so log lines and the published package.json agree. + const cliPkgJson = await Bun.file(path.join(root, "apps", "cli", "package.json")).json(); + const umbrellaName: string = cliPkgJson.name; + + console.log(`\nBuilding ${umbrellaName}@${version} (${shell}, ${platform.platformPkg})...\n`); + + // ── Build platform package ──────────────────────────────────────────── + + const tmpPlatformDir = path.join(tmpDir, platform.platformPkg); + const tmpPlatformBinDir = path.join(tmpPlatformDir, "bin"); + await mkdir(tmpPlatformBinDir, { recursive: true }); + + const entrypoint = path.join(root, "apps", "cli", "src", shell, "main.ts"); + const bunBinary = path.join(tmpPlatformBinDir, `supabase${platform.ext}`); + const libc = libcForBunTarget(platform.bunTarget); + + console.log(`[1/${shell === "legacy" ? 3 : 2}] Compiling ${shell} CLI binary...`); + await $`bun build ${entrypoint} --compile --target=${platform.bunTarget} --define=SUPABASE_LIBC=${JSON.stringify(libc)} --outfile=${bunBinary}`; + + if (shell === "legacy" && goSource) { + const goBinary = path.join(tmpPlatformBinDir, `supabase-go${platform.ext}`); + console.log(`[2/3] Compiling Go CLI binary (${platform.goos}/${platform.goarch})...`); + // Run go build from within the Go source directory so Go can find + // the go.mod there. Passing an absolute path as a positional arg + // causes Go to resolve the module from CWD instead, which fails + // because the repo root has no go.mod. + await $`go build -trimpath -ldflags="-s -w" -o ${goBinary} .`.cwd(goSource).env({ + ...process.env, + GOOS: platform.goos, + GOARCH: platform.goarch, + CGO_ENABLED: "0", + }); + } + + // ── Build umbrella package shim ─────────────────────────────────────── + + const tmpCliDir = path.join(tmpDir, "cli"); + const tmpCliDistDir = path.join(tmpCliDir, "dist"); + await mkdir(tmpCliDistDir, { recursive: true }); + + const shimSrc = path.join(root, "apps", "cli", "src", "shared", "cli", "bin.ts"); + const shimOut = path.join(tmpCliDistDir, "supabase.js"); + const shimStep = shell === "legacy" ? 3 : 2; + console.log(`[${shimStep}/${shimStep}] Building Node.js shim...`); + await $`bun build ${shimSrc} --outfile=${shimOut} --target=node`; + + // ── Write package.json files ────────────────────────────────────────── + + // Platform package: copy as-is, bump version. + const platformPkgJson = await Bun.file( + path.join(root, "packages", platform.platformPkg, "package.json"), + ).json(); + platformPkgJson.version = version; + await Bun.write( + path.join(tmpPlatformDir, "package.json"), + `${JSON.stringify(platformPkgJson, null, "\t")}\n`, + ); + + // Umbrella package: build a minimal package.json. + // The shim only uses Node built-ins — all @supabase/* and catalog: deps + // are bundled in the platform binary and must not appear in the published + // package.json (catalog: and workspace:* are invalid outside pnpm workspaces). + const resolvedOptionalDeps: Record = {}; + for (const pkg of PLATFORM_PACKAGES) { + resolvedOptionalDeps[`@supabase/${pkg}`] = version; + } + + const publishPkgJson = { + name: cliPkgJson.name, + version, + type: cliPkgJson.type, + bin: cliPkgJson.bin, + files: cliPkgJson.files, + publishConfig: cliPkgJson.publishConfig, + optionalDependencies: resolvedOptionalDeps, + }; + await Bun.write( + path.join(tmpCliDir, "package.json"), + `${JSON.stringify(publishPkgJson, null, "\t")}\n`, + ); + + // ── Write .npmrc with registry and auth token ───────────────────────── + + const npmrc = [`registry=${REGISTRY}`, `//localhost:${PORT}/:_authToken=${token}`, ""].join( + "\n", + ); + await Bun.write(path.join(tmpPlatformDir, ".npmrc"), npmrc); + await Bun.write(path.join(tmpCliDir, ".npmrc"), npmrc); + + // ── Publish ─────────────────────────────────────────────────────────── + + console.log(`\nPublishing @supabase/${platform.platformPkg}@${version} to local registry...`); + // Use bun publish for the platform binary package: pnpm normalises file + // modes in tarballs and strips the execute bit from files not in the + // package's `bin` field. bun publish preserves modes, matching production. + await $`bun publish --access public --tag local --registry ${REGISTRY} --no-git-checks`.cwd( + tmpPlatformDir, + ); + + console.log(`Publishing ${umbrellaName}@${version} to local registry...`); + await $`pnpm publish --access public --tag local --registry ${REGISTRY} --no-git-checks`.cwd( + tmpCliDir, + ); + + console.log(` ✓ Published ${umbrellaName}@${version} Test with npx: @@ -322,10 +306,10 @@ Or install globally: npm install -g --registry ${REGISTRY} ${umbrellaName}@${version} supabase --version `); - } finally { - // Always remove the temp directory — even on failure. - await rm(tmpDir, { recursive: true, force: true }); - } + } finally { + // Always remove the temp directory — even on failure. + await rm(tmpDir, { recursive: true, force: true }); + } } await main(); From 627e534b4f2b5f2f367e7e9a46fc4a0975a92ebe Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 22 May 2026 11:09:06 +0100 Subject: [PATCH 02/13] feat(cli): port backups list and restore to native TypeScript (#5331) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Replaces the Phase-0 Go-proxy handlers for `supabase backups list` and `supabase backups restore` with native Effect-based implementations. Adds the supporting legacy infrastructure (`legacy/auth`, `legacy/config`, project-ref resolver, Glamour table renderer) that subsequent ports will reuse. ## Highlights - **Strict Go parity on the wire.** Byte-identical `--output json` (alphabetical struct-field order, `backups: null` for empty slices to match Go's nil-slice semantics), Glamour-styled tables verified byte-for-byte against Go test fixtures, restore stderr line preserved. - **`Output.raw(text, stream)` service method.** Handlers now route stdout/stderr writes through the `Output` service instead of calling `process.stdout/stderr.write` directly. `mockOutput` captures these into `rawChunks` + `stdoutText` / `stderrText` getters, eliminating ~30 lines of `process.*.write` monkey-patching per integration test file. - **Shared backups infrastructure.** New `backups.layers.ts` exposes a `legacyBackupsRuntimeLayer(subcommand)` factory so each subcommand wires the platform-API + project-ref stack identically. New `mapLegacyBackupHttpError` factory in `backups.errors.ts` consolidates `RESPONSE_ERROR_TAGS` + HTTP-error dispatch and truncates response bodies to 1024 chars before embedding them in tagged errors. - **Flag-type discipline.** Both `*.command.ts` files mark `config as const` and `export type LegacyBackups*Flags = CliCommand.Command.Config.Infer` (canonical `login.command.ts` pattern); handlers import the type instead of duplicating private interfaces. - **Spinner suppressed in non-text modes.** `output.task("Fetching backups...")` / `"Initiating PITR restore..."` only run when `output.format === "text"`, eliminating dangling `[task] start:` lines on stderr in JSON / stream-json modes. - **API contracts regenerated.** `packages/api/src/generated/contracts.ts` rebuilt from upstream OpenAPI — adds the missing `id` field on backup items, plus broader spec drift since the last sync. - **Tests + checks pass.** Unit + integration suites green, including the new `backups.encoders.unit.test.ts` and the byte-stable `--output json` assertion (against the Go fixture from `apps/cli-go/internal/backups/list/list_test.go`). Targeted e2e `--help` smoke tests for both `list` and `restore`. ## Known Gaps (documented, not blocking) - `V1RestorePitrBackupInput.recovery_time_target_unix` retains an upstream `>= 0` constraint that Go's `int64` does not enforce. A negative timestamp surfaces a local schema-decode error rather than the API's own error. Noted in `restore/SIDE_EFFECTS.md`; resolving requires an upstream OpenAPI change. ## Reviewer Notes - The handlers do not log any token, error body, or response field that isn't already part of the documented Go output. Bodies are capped at 1024 chars even though the Management API is trusted, to set the right precedent for future ports against less-trusted endpoints. - The OpenAPI regen also touches `effect-client.ts` and `openapi.json`. Diff scope is large but mechanical — all the meaningful schema deltas land in `contracts.ts`. One unrelated `next/` snapshot expectation (`platform-schema.integration.test.ts`) updated to match the new upstream description text for `v1ListAllProjects`. Closes CLI-1301 --- apps/cli/AGENTS.md | 66 ++ apps/cli/docs/go-cli-porting-status.md | 204 ++-- apps/cli/package.json | 4 +- .../legacy/auth/legacy-credentials.layer.ts | 155 +++ .../legacy-credentials.layer.unit.test.ts | 230 +++++ .../legacy/auth/legacy-credentials.service.ts | 17 + apps/cli/src/legacy/auth/legacy-errors.ts | 13 + .../legacy/auth/legacy-http-debug.layer.ts | 43 + .../legacy/auth/legacy-platform-api.layer.ts | 37 + .../legacy-platform-api.layer.unit.test.ts | 138 +++ .../auth/legacy-platform-api.service.ts | 6 + .../commands/backups/backups.encoders.ts | 118 +++ .../backups/backups.encoders.unit.test.ts | 186 ++++ .../legacy/commands/backups/backups.errors.ts | 95 ++ .../legacy/commands/backups/backups.format.ts | 51 + .../backups/backups.format.unit.test.ts | 55 ++ .../legacy/commands/backups/backups.layers.ts | 47 + .../commands/backups/list/SIDE_EFFECTS.md | 75 +- .../commands/backups/list/list.command.ts | 18 +- .../commands/backups/list/list.handler.ts | 114 ++- .../backups/list/list.integration.test.ts | 499 ++++++++++ .../commands/backups/restore/SIDE_EFFECTS.md | 66 +- .../backups/restore/restore.command.ts | 16 +- .../backups/restore/restore.handler.ts | 73 +- .../restore/restore.integration.test.ts | 418 ++++++++ .../legacy/config/legacy-cli-config.layer.ts | 154 +++ .../legacy-cli-config.layer.unit.test.ts | 205 ++++ .../config/legacy-cli-config.service.ts | 24 + .../config/legacy-project-ref.errors.ts | 10 + .../legacy/config/legacy-project-ref.layer.ts | 100 ++ .../legacy-project-ref.layer.unit.test.ts | 228 +++++ .../config/legacy-project-ref.service.ts | 25 + .../src/legacy/output/legacy-glamour-table.ts | 45 + .../output/legacy-glamour-table.unit.test.ts | 43 + .../legacy-linked-project-cache.layer.ts | 81 ++ .../legacy-linked-project-cache.service.ts | 19 + .../telemetry/legacy-telemetry-state.layer.ts | 117 +++ .../legacy-telemetry-state.service.ts | 17 + .../platform/platform-input.unit.test.ts | 1 + .../platform-schema.integration.test.ts | 3 +- .../output/json-error-handling.unit.test.ts | 1 + apps/cli/src/shared/output/output.layer.ts | 37 +- .../shared/output/output.layer.unit.test.ts | 81 +- apps/cli/src/shared/output/output.service.ts | 8 + apps/cli/tests/helpers/mocks.ts | 18 + packages/api/src/generated/contracts.ts | 202 +++- packages/api/src/generated/effect-client.ts | 54 ++ packages/api/src/generated/openapi.json | 895 ++++++++++++------ packages/cli-test-helpers/src/harness.ts | 15 +- packages/cli-test-helpers/src/normalize.ts | 7 + pnpm-lock.yaml | 10 +- 51 files changed, 4622 insertions(+), 522 deletions(-) create mode 100644 apps/cli/src/legacy/auth/legacy-credentials.layer.ts create mode 100644 apps/cli/src/legacy/auth/legacy-credentials.layer.unit.test.ts create mode 100644 apps/cli/src/legacy/auth/legacy-credentials.service.ts create mode 100644 apps/cli/src/legacy/auth/legacy-errors.ts create mode 100644 apps/cli/src/legacy/auth/legacy-http-debug.layer.ts create mode 100644 apps/cli/src/legacy/auth/legacy-platform-api.layer.ts create mode 100644 apps/cli/src/legacy/auth/legacy-platform-api.layer.unit.test.ts create mode 100644 apps/cli/src/legacy/auth/legacy-platform-api.service.ts create mode 100644 apps/cli/src/legacy/commands/backups/backups.encoders.ts create mode 100644 apps/cli/src/legacy/commands/backups/backups.encoders.unit.test.ts create mode 100644 apps/cli/src/legacy/commands/backups/backups.errors.ts create mode 100644 apps/cli/src/legacy/commands/backups/backups.format.ts create mode 100644 apps/cli/src/legacy/commands/backups/backups.format.unit.test.ts create mode 100644 apps/cli/src/legacy/commands/backups/backups.layers.ts create mode 100644 apps/cli/src/legacy/commands/backups/list/list.integration.test.ts create mode 100644 apps/cli/src/legacy/commands/backups/restore/restore.integration.test.ts create mode 100644 apps/cli/src/legacy/config/legacy-cli-config.layer.ts create mode 100644 apps/cli/src/legacy/config/legacy-cli-config.layer.unit.test.ts create mode 100644 apps/cli/src/legacy/config/legacy-cli-config.service.ts create mode 100644 apps/cli/src/legacy/config/legacy-project-ref.errors.ts create mode 100644 apps/cli/src/legacy/config/legacy-project-ref.layer.ts create mode 100644 apps/cli/src/legacy/config/legacy-project-ref.layer.unit.test.ts create mode 100644 apps/cli/src/legacy/config/legacy-project-ref.service.ts create mode 100644 apps/cli/src/legacy/output/legacy-glamour-table.ts create mode 100644 apps/cli/src/legacy/output/legacy-glamour-table.unit.test.ts create mode 100644 apps/cli/src/legacy/telemetry/legacy-linked-project-cache.layer.ts create mode 100644 apps/cli/src/legacy/telemetry/legacy-linked-project-cache.service.ts create mode 100644 apps/cli/src/legacy/telemetry/legacy-telemetry-state.layer.ts create mode 100644 apps/cli/src/legacy/telemetry/legacy-telemetry-state.service.ts diff --git a/apps/cli/AGENTS.md b/apps/cli/AGENTS.md index e59fed8f5..cc5658b0a 100644 --- a/apps/cli/AGENTS.md +++ b/apps/cli/AGENTS.md @@ -88,6 +88,17 @@ Always check `src/shared/` before writing new infrastructure. Do not duplicate w | `shared/runtime/` | `Browser`, `Stdin`, `Tty`, `ProcessControl`, `RuntimeInfo` services + layers | | `shared/telemetry/` | `withCommandInstrumentation`, `Analytics`, tracing | +Also check the following `legacy/` infrastructure before writing equivalent helpers from scratch: + +| Path | What it provides | +| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `legacy/config/legacy-cli-config.layer.ts` | `LegacyCliConfig` — resolves `SUPABASE_PROFILE` (built-in name **or** YAML file path), `--workdir`, `--experimental`, project-id from `supabase/config.toml` | +| `legacy/config/legacy-project-ref.layer.ts` | `LegacyProjectRefResolver` — `--project-ref` flag → env → linked-project.json → config fallback chain; matches Go's resolver order | +| `legacy/telemetry/legacy-telemetry-state.layer.ts` | `LegacyTelemetryState.flush` — writes `~/.supabase/telemetry.json`, runs in every command's `Effect.ensuring` | +| `legacy/telemetry/legacy-linked-project-cache.layer.ts` | `LegacyLinkedProjectCache.cache(ref)` — writes `~/.supabase//linked-project.json` after `--project-ref` resolves; bypasses generated schema validation (uses raw HTTP client) | +| `legacy/auth/legacy-http-debug.layer.ts` | `legacyHttpClientLayer` — wraps the HTTP transport with a `--debug` stderr logger in Go's `log.LstdFlags` format | +| `legacy/output/legacy-glamour-table.ts` | `renderGlamourTable(headers, rows)` — byte-exact ASCII match for Go's `glamour.RenderTable(..., AsciiStyle)` | + --- ## Phase 0: Go Binary Wrapper @@ -139,6 +150,21 @@ src/legacy/commands// SIDE_EFFECTS.md # Required for every legacy command — see section below ``` +When a command grows beyond a single handler file, follow the optional helper-file shape that emerged from the backups port: + +``` +src/legacy/commands// + .command.ts # Effect CLI Command + flag wiring + layer provide + .handler.ts # native Effect handler + .errors.ts # Data.TaggedError types + .layers.ts # runtime layer composition for the command family + .format.ts # text formatters (timestamps, regions, booleans) + .encoders.ts # Go-compatible JSON / YAML / TOML / env encoders + SIDE_EFFECTS.md +``` + +The `.format.ts` and `.encoders.ts` files should be pure functions with no Effect or service dependencies — that keeps them unit-testable and makes Go-parity rules explicit (e.g. JSON key sort order, env-var SCREAMING_SNAKE_CASE flattening, empty arrays coerced to null). + Commands with subcommands use nested directories: ``` @@ -192,6 +218,27 @@ Many Management API commands in `next/commands/` have already been implemented. --- +## Legacy Port: Hoist Before You Duplicate + +Before writing handler code for a new port, scan the already-ported commands for overlapping logic. If two commands need the same helper (HTTP-error mapping, output encoder, formatter, runtime layer composition), hoist it instead of inlining a copy. + +Decision rule: + +- **Used by one command only** → keep it in the command's own directory (e.g. `backups/backups.errors.ts`). +- **Used by ≥2 commands in the same command family** → keep it in the family root (e.g. `backups/backups.encoders.ts` is shared by `list` and `restore`). +- **Used by ≥2 commands across families** → hoist to `src/legacy/shared/` (create the directory if it doesn't exist) and refactor the existing call sites in the same change. Do not leave the older command using its inlined copy while the new command uses the hoisted version. + +Concrete examples worth watching for as more commands land: + +- HTTP-error → tagged-error mapping (`backups.errors.ts:mapLegacyBackupHttpError`) — almost every Management API command will need this shape. +- Go-compatible JSON / YAML / TOML / env encoders (`backups.encoders.ts`) — the flag `--output {json,yaml,toml,env}` is supported by many Go subcommands. +- Glamour-table rendering helpers and column padding — currently in `legacy/output/legacy-glamour-table.ts`, already correctly hoisted. +- Timestamp / region / boolean formatters (`backups.format.ts`) — likely shared the moment a second command renders a backup/project/region field. + +This rule is consistent with the repo-wide **Refactoring Policy** ("delete obsolete helpers, shims, and parallel code paths as part of the refactor") — it just makes the policy concrete for the legacy-port workflow. + +--- + ## Legacy Port: Go CLI Output Parity The legacy shell is a **strict 1:1 port** — not a redesign. The compatibility contract covers: @@ -206,6 +253,24 @@ When in doubt about expected output or behavior, run the equivalent command agai --- +## Legacy Port: Go Parity Checklist + +When porting a Management-API-style command, verify each item before marking the command as `ported`: + +1. **Telemetry + linked-project writes run on every invocation** — Go uses `PersistentPostRun` (see `apps/cli-go/cmd/root.go:176`). Wrap the handler body in `.pipe(Effect.ensuring(linkedProjectCache.cache(ref)), Effect.ensuring(telemetryState.flush))` so both files are written on success **and** failure. See `backups/list/list.handler.ts:74-114` as the canonical pattern. + +2. **Errors go to stderr in text mode, byte-matching Go's template** — `Output.fail` now writes a frame-free message to stderr followed by the "Try rerunning the command with --debug to get more details." suggestion when `--debug` is unset. Don't reintroduce clack's `■ … │` frame. Reference: commits `ee041834`, `cf4f574b`. + +3. **`--debug` logs every HTTP request on stderr** — Format `"HTTP YYYY/MM/DD HH:MM:SS : \n"` (Go's `log.LstdFlags|log.Lmsgprefix`). Provided automatically by `legacyHttpClientLayer`; ensure that layer (not the raw `HttpClient.layer`) is what every legacy command's runtime composes. Reference: commit `39cfec20`. + +4. **`SUPABASE_PROFILE` is dual-mode** — accept either a built-in name (`supabase`, `supabase-staging`, `supabase-local`) **or** a filesystem path to a YAML file with `api_url:` / `gotrue_url:` / `db_url:` keys. cli-e2e harness relies on the file-path mode. Reference: commit `288c2937`. + +5. **`Layer.provide` does not share to siblings inside `Layer.mergeAll`** — if two sibling layers each require `LegacyCliConfig`, provide it to both explicitly. Smoke-test the bundled binary (`bun run build && ./dist/supabase-legacy …`) when changing production layer wiring; in-process tests don't always catch the missing-service panic. Reference: commit `a816b12e`, `backups.layers.ts:32-46`. + +6. **Both `--output` (Go) and `--output-format` (TS) must be honored** — Go's `--output` (`pretty|json|yaml|toml|env`) takes priority when set. Pattern in `backups/list/list.handler.ts:85-113`: branch on `goOutputFlag` first, then fall through to TS `--output-format` text/json/stream-json. + +--- + ## Legacy Port: File Location Compatibility The legacy shell bridges two worlds: it must behave exactly like the Go CLI for existing users, and it must lay the groundwork for a seamless upgrade to the next shell. @@ -311,6 +376,7 @@ Read https://www.effect.solutions/testing for Effect testing patterns. Note that - If a test needs multiple service replacements or `Layer.mergeAll(...)`, it likely belongs in `*.integration.test.ts`. - Prefer assertions on outputs and accumulated state over spy-heavy interaction tests. - Keep `*.e2e.test.ts` focused on golden paths, CLI surface behavior, and subprocess correctness, not branch-by-branch coverage. +- **Forbidden pattern (do not add):** spawning the CLI to assert that `--help` renders a flag. Help text is dynamic over flag wiring and is exercised by the integration test's flag parser. The two backups e2e files removed alongside this guidance update are the canonical example of what not to write. --- diff --git a/apps/cli/docs/go-cli-porting-status.md b/apps/cli/docs/go-cli-porting-status.md index ed5588abf..0178a0335 100644 --- a/apps/cli/docs/go-cli-porting-status.md +++ b/apps/cli/docs/go-cli-porting-status.md @@ -210,105 +210,105 @@ Legend: - `wrapped`: Phase 0 proxy wrapper exists in the legacy shell - `missing`: no legacy shell command yet -| Command | Legacy status | Legacy command path | -| -------------------------------------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `orgs list` | `wrapped` | [`../src/legacy/commands/orgs/list/list.command.ts`](../src/legacy/commands/orgs/list/list.command.ts) | -| `orgs create` | `wrapped` | [`../src/legacy/commands/orgs/create/create.command.ts`](../src/legacy/commands/orgs/create/create.command.ts) | -| `projects list` | `wrapped` | [`../src/legacy/commands/projects/list/list.command.ts`](../src/legacy/commands/projects/list/list.command.ts) | -| `projects create` | `wrapped` | [`../src/legacy/commands/projects/create/create.command.ts`](../src/legacy/commands/projects/create/create.command.ts) | -| `projects delete` | `wrapped` | [`../src/legacy/commands/projects/delete/delete.command.ts`](../src/legacy/commands/projects/delete/delete.command.ts) | -| `projects api-keys` | `wrapped` | [`../src/legacy/commands/projects/api-keys/api-keys.command.ts`](../src/legacy/commands/projects/api-keys/api-keys.command.ts) | -| `branches list` | `wrapped` | [`../src/legacy/commands/branches/list/list.command.ts`](../src/legacy/commands/branches/list/list.command.ts) | -| `branches create` | `wrapped` | [`../src/legacy/commands/branches/create/create.command.ts`](../src/legacy/commands/branches/create/create.command.ts) | -| `branches get` | `wrapped` | [`../src/legacy/commands/branches/get/get.command.ts`](../src/legacy/commands/branches/get/get.command.ts) | -| `branches update` | `wrapped` | [`../src/legacy/commands/branches/update/update.command.ts`](../src/legacy/commands/branches/update/update.command.ts) | -| `branches pause` | `wrapped` | [`../src/legacy/commands/branches/pause/pause.command.ts`](../src/legacy/commands/branches/pause/pause.command.ts) | -| `branches unpause` | `wrapped` | [`../src/legacy/commands/branches/unpause/unpause.command.ts`](../src/legacy/commands/branches/unpause/unpause.command.ts) | -| `branches delete` | `wrapped` | [`../src/legacy/commands/branches/delete/delete.command.ts`](../src/legacy/commands/branches/delete/delete.command.ts) | -| `branches disable` | `wrapped` | [`../src/legacy/commands/branches/disable/disable.command.ts`](../src/legacy/commands/branches/disable/disable.command.ts) | -| `secrets list` | `wrapped` | [`../src/legacy/commands/secrets/list/list.command.ts`](../src/legacy/commands/secrets/list/list.command.ts) | -| `secrets set` | `wrapped` | [`../src/legacy/commands/secrets/set/set.command.ts`](../src/legacy/commands/secrets/set/set.command.ts) | -| `secrets unset` | `wrapped` | [`../src/legacy/commands/secrets/unset/unset.command.ts`](../src/legacy/commands/secrets/unset/unset.command.ts) | -| `config push` | `wrapped` | [`../src/legacy/commands/config/push/push.command.ts`](../src/legacy/commands/config/push/push.command.ts) | -| `backups list` | `wrapped` | [`../src/legacy/commands/backups/list/list.command.ts`](../src/legacy/commands/backups/list/list.command.ts) | -| `backups restore` | `wrapped` | [`../src/legacy/commands/backups/restore/restore.command.ts`](../src/legacy/commands/backups/restore/restore.command.ts) | -| `snippets list` | `wrapped` | [`../src/legacy/commands/snippets/list/list.command.ts`](../src/legacy/commands/snippets/list/list.command.ts) | -| `snippets download` | `wrapped` | [`../src/legacy/commands/snippets/download/download.command.ts`](../src/legacy/commands/snippets/download/download.command.ts) | -| `sso list` | `wrapped` | [`../src/legacy/commands/sso/list/list.command.ts`](../src/legacy/commands/sso/list/list.command.ts) | -| `sso add` | `wrapped` | [`../src/legacy/commands/sso/add/add.command.ts`](../src/legacy/commands/sso/add/add.command.ts) | -| `sso remove` | `wrapped` | [`../src/legacy/commands/sso/remove/remove.command.ts`](../src/legacy/commands/sso/remove/remove.command.ts) | -| `sso update` | `wrapped` | [`../src/legacy/commands/sso/update/update.command.ts`](../src/legacy/commands/sso/update/update.command.ts) | -| `sso show` | `wrapped` | [`../src/legacy/commands/sso/show/show.command.ts`](../src/legacy/commands/sso/show/show.command.ts) | -| `sso info` | `wrapped` | [`../src/legacy/commands/sso/info/info.command.ts`](../src/legacy/commands/sso/info/info.command.ts) | -| `domains create` | `wrapped` | [`../src/legacy/commands/domains/create/create.command.ts`](../src/legacy/commands/domains/create/create.command.ts) | -| `domains get` | `wrapped` | [`../src/legacy/commands/domains/get/get.command.ts`](../src/legacy/commands/domains/get/get.command.ts) | -| `domains reverify` | `wrapped` | [`../src/legacy/commands/domains/reverify/reverify.command.ts`](../src/legacy/commands/domains/reverify/reverify.command.ts) | -| `domains activate` | `wrapped` | [`../src/legacy/commands/domains/activate/activate.command.ts`](../src/legacy/commands/domains/activate/activate.command.ts) | -| `domains delete` | `wrapped` | [`../src/legacy/commands/domains/delete/delete.command.ts`](../src/legacy/commands/domains/delete/delete.command.ts) | -| `vanity-subdomains get` | `wrapped` | [`../src/legacy/commands/vanity-subdomains/get/get.command.ts`](../src/legacy/commands/vanity-subdomains/get/get.command.ts) | -| `vanity-subdomains check-availability` | `wrapped` | [`../src/legacy/commands/vanity-subdomains/check-availability/check-availability.command.ts`](../src/legacy/commands/vanity-subdomains/check-availability/check-availability.command.ts) | -| `vanity-subdomains activate` | `wrapped` | [`../src/legacy/commands/vanity-subdomains/activate/activate.command.ts`](../src/legacy/commands/vanity-subdomains/activate/activate.command.ts) | -| `vanity-subdomains delete` | `wrapped` | [`../src/legacy/commands/vanity-subdomains/delete/delete.command.ts`](../src/legacy/commands/vanity-subdomains/delete/delete.command.ts) | -| `network-bans get` | `wrapped` | [`../src/legacy/commands/network-bans/get/get.command.ts`](../src/legacy/commands/network-bans/get/get.command.ts) | -| `network-bans remove` | `wrapped` | [`../src/legacy/commands/network-bans/remove/remove.command.ts`](../src/legacy/commands/network-bans/remove/remove.command.ts) | -| `network-restrictions get` | `wrapped` | [`../src/legacy/commands/network-restrictions/get/get.command.ts`](../src/legacy/commands/network-restrictions/get/get.command.ts) | -| `network-restrictions update` | `wrapped` | [`../src/legacy/commands/network-restrictions/update/update.command.ts`](../src/legacy/commands/network-restrictions/update/update.command.ts) | -| `encryption get-root-key` | `wrapped` | [`../src/legacy/commands/encryption/get-root-key/get-root-key.command.ts`](../src/legacy/commands/encryption/get-root-key/get-root-key.command.ts) | -| `encryption update-root-key` | `wrapped` | [`../src/legacy/commands/encryption/update-root-key/update-root-key.command.ts`](../src/legacy/commands/encryption/update-root-key/update-root-key.command.ts) | -| `ssl-enforcement get` | `wrapped` | [`../src/legacy/commands/ssl-enforcement/get/get.command.ts`](../src/legacy/commands/ssl-enforcement/get/get.command.ts) | -| `ssl-enforcement update` | `wrapped` | [`../src/legacy/commands/ssl-enforcement/update/update.command.ts`](../src/legacy/commands/ssl-enforcement/update/update.command.ts) | -| `postgres-config get` | `wrapped` | [`../src/legacy/commands/postgres-config/get/get.command.ts`](../src/legacy/commands/postgres-config/get/get.command.ts) | -| `postgres-config update` | `wrapped` | [`../src/legacy/commands/postgres-config/update/update.command.ts`](../src/legacy/commands/postgres-config/update/update.command.ts) | -| `postgres-config delete` | `wrapped` | [`../src/legacy/commands/postgres-config/delete/delete.command.ts`](../src/legacy/commands/postgres-config/delete/delete.command.ts) | -| `login` | `wrapped` | [`../src/legacy/commands/login/login.command.ts`](../src/legacy/commands/login/login.command.ts) | -| `logout` | `wrapped` | [`../src/legacy/commands/logout/logout.command.ts`](../src/legacy/commands/logout/logout.command.ts) | -| `link` | `wrapped` | [`../src/legacy/commands/link/link.command.ts`](../src/legacy/commands/link/link.command.ts) | -| `unlink` | `wrapped` | [`../src/legacy/commands/unlink/unlink.command.ts`](../src/legacy/commands/unlink/unlink.command.ts) | -| `bootstrap` | `wrapped` | [`../src/legacy/commands/bootstrap/bootstrap.command.ts`](../src/legacy/commands/bootstrap/bootstrap.command.ts) | -| `init` | `wrapped` | [`../src/legacy/commands/init/init.command.ts`](../src/legacy/commands/init/init.command.ts) | -| `services` | `wrapped` | [`../src/legacy/commands/services/services.command.ts`](../src/legacy/commands/services/services.command.ts) | -| `start` | `wrapped` | [`../src/legacy/commands/start/start.command.ts`](../src/legacy/commands/start/start.command.ts) | -| `stop` | `wrapped` | [`../src/legacy/commands/stop/stop.command.ts`](../src/legacy/commands/stop/stop.command.ts) | -| `status` | `wrapped` | [`../src/legacy/commands/status/status.command.ts`](../src/legacy/commands/status/status.command.ts) | -| `migration list` | `wrapped` | [`../src/legacy/commands/migration/list/list.command.ts`](../src/legacy/commands/migration/list/list.command.ts) | -| `migration new` | `wrapped` | [`../src/legacy/commands/migration/new/new.command.ts`](../src/legacy/commands/migration/new/new.command.ts) | -| `migration repair` | `wrapped` | [`../src/legacy/commands/migration/repair/repair.command.ts`](../src/legacy/commands/migration/repair/repair.command.ts) | -| `migration squash` | `wrapped` | [`../src/legacy/commands/migration/squash/squash.command.ts`](../src/legacy/commands/migration/squash/squash.command.ts) | -| `migration up` | `wrapped` | [`../src/legacy/commands/migration/up/up.command.ts`](../src/legacy/commands/migration/up/up.command.ts) | -| `migration down` | `wrapped` | [`../src/legacy/commands/migration/down/down.command.ts`](../src/legacy/commands/migration/down/down.command.ts) | -| `migration fetch` | `wrapped` | [`../src/legacy/commands/migration/fetch/fetch.command.ts`](../src/legacy/commands/migration/fetch/fetch.command.ts) | -| `gen types` | `wrapped` | [`../src/legacy/commands/gen/types/types.command.ts`](../src/legacy/commands/gen/types/types.command.ts) | -| `gen signing-key` | `wrapped` | [`../src/legacy/commands/gen/signing-key/signing-key.command.ts`](../src/legacy/commands/gen/signing-key/signing-key.command.ts) | -| `gen bearer-jwt` | `wrapped` | [`../src/legacy/commands/gen/bearer-jwt/bearer-jwt.command.ts`](../src/legacy/commands/gen/bearer-jwt/bearer-jwt.command.ts) | -| `gen keys` | `wrapped` | [`../src/legacy/commands/gen/keys/keys.command.ts`](../src/legacy/commands/gen/keys/keys.command.ts) | -| `functions list` | `wrapped` | [`../src/legacy/commands/functions/list/list.command.ts`](../src/legacy/commands/functions/list/list.command.ts) | -| `functions delete` | `wrapped` | [`../src/legacy/commands/functions/delete/delete.command.ts`](../src/legacy/commands/functions/delete/delete.command.ts) | -| `functions download` | `wrapped` | [`../src/legacy/commands/functions/download/download.command.ts`](../src/legacy/commands/functions/download/download.command.ts) | -| `functions deploy` | `wrapped` | [`../src/legacy/commands/functions/deploy/deploy.command.ts`](../src/legacy/commands/functions/deploy/deploy.command.ts) | -| `functions new` | `wrapped` | [`../src/legacy/commands/functions/new/new.command.ts`](../src/legacy/commands/functions/new/new.command.ts) | -| `functions serve` | `wrapped` | [`../src/legacy/commands/functions/serve/serve.command.ts`](../src/legacy/commands/functions/serve/serve.command.ts) | -| `storage ls` | `wrapped` | [`../src/legacy/commands/storage/ls/ls.command.ts`](../src/legacy/commands/storage/ls/ls.command.ts) | -| `storage cp` | `wrapped` | [`../src/legacy/commands/storage/cp/cp.command.ts`](../src/legacy/commands/storage/cp/cp.command.ts) | -| `storage mv` | `wrapped` | [`../src/legacy/commands/storage/mv/mv.command.ts`](../src/legacy/commands/storage/mv/mv.command.ts) | -| `storage rm` | `wrapped` | [`../src/legacy/commands/storage/rm/rm.command.ts`](../src/legacy/commands/storage/rm/rm.command.ts) | -| `test db` | `wrapped` | [`../src/legacy/commands/test/db/db.command.ts`](../src/legacy/commands/test/db/db.command.ts) | -| `test new` | `wrapped` | [`../src/legacy/commands/test/new/new.command.ts`](../src/legacy/commands/test/new/new.command.ts) | -| `seed buckets` | `wrapped` | [`../src/legacy/commands/seed/buckets/buckets.command.ts`](../src/legacy/commands/seed/buckets/buckets.command.ts) | -| `db diff` | `wrapped` | [`../src/legacy/commands/db/diff/diff.command.ts`](../src/legacy/commands/db/diff/diff.command.ts) | -| `db dump` | `wrapped` | [`../src/legacy/commands/db/dump/dump.command.ts`](../src/legacy/commands/db/dump/dump.command.ts) | -| `db push` | `wrapped` | [`../src/legacy/commands/db/push/push.command.ts`](../src/legacy/commands/db/push/push.command.ts) | -| `db pull` | `wrapped` | [`../src/legacy/commands/db/pull/pull.command.ts`](../src/legacy/commands/db/pull/pull.command.ts) — includes `--diff-engine` (migra\|pg-delta, mutually exclusive with `--use-pg-delta`) | -| `db reset` | `wrapped` | [`../src/legacy/commands/db/reset/reset.command.ts`](../src/legacy/commands/db/reset/reset.command.ts) | -| `db lint` | `wrapped` | [`../src/legacy/commands/db/lint/lint.command.ts`](../src/legacy/commands/db/lint/lint.command.ts) | -| `db start` | `wrapped` | [`../src/legacy/commands/db/start/start.command.ts`](../src/legacy/commands/db/start/start.command.ts) | -| `db query` | `wrapped` | [`../src/legacy/commands/db/query/query.command.ts`](../src/legacy/commands/db/query/query.command.ts) | -| `db advisors` | `wrapped` | [`../src/legacy/commands/db/advisors/advisors.command.ts`](../src/legacy/commands/db/advisors/advisors.command.ts) | -| `db test` | `wrapped` | [`../src/legacy/commands/db/test/test.command.ts`](../src/legacy/commands/db/test/test.command.ts) | -| `db branch create` | `wrapped` | [`../src/legacy/commands/db/branch/create/create.command.ts`](../src/legacy/commands/db/branch/create/create.command.ts) | -| `db branch delete` | `wrapped` | [`../src/legacy/commands/db/branch/delete/delete.command.ts`](../src/legacy/commands/db/branch/delete/delete.command.ts) | -| `db branch list` | `wrapped` | [`../src/legacy/commands/db/branch/list/list.command.ts`](../src/legacy/commands/db/branch/list/list.command.ts) | -| `db branch switch` | `wrapped` | [`../src/legacy/commands/db/branch/switch/switch.command.ts`](../src/legacy/commands/db/branch/switch/switch.command.ts) | -| `db remote changes` | `wrapped` | [`../src/legacy/commands/db/remote/changes/changes.command.ts`](../src/legacy/commands/db/remote/changes/changes.command.ts) | -| `db remote commit` | `wrapped` | [`../src/legacy/commands/db/remote/commit/commit.command.ts`](../src/legacy/commands/db/remote/commit/commit.command.ts) | -| `db schema declarative sync` | `wrapped` | [`../src/legacy/commands/db/schema/declarative/sync/sync.command.ts`](../src/legacy/commands/db/schema/declarative/sync/sync.command.ts) — `--apply` and `--no-apply` are mutually exclusive; `--no-apply` overrides the global `--yes` flag | -| `db schema declarative generate` | `wrapped` | [`../src/legacy/commands/db/schema/declarative/generate/generate.command.ts`](../src/legacy/commands/db/schema/declarative/generate/generate.command.ts) | +| Command | Legacy status | Legacy command path | +| -------------------------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `orgs list` | `wrapped` | [`../src/legacy/commands/orgs/list/list.command.ts`](../src/legacy/commands/orgs/list/list.command.ts) | +| `orgs create` | `wrapped` | [`../src/legacy/commands/orgs/create/create.command.ts`](../src/legacy/commands/orgs/create/create.command.ts) | +| `projects list` | `wrapped` | [`../src/legacy/commands/projects/list/list.command.ts`](../src/legacy/commands/projects/list/list.command.ts) | +| `projects create` | `wrapped` | [`../src/legacy/commands/projects/create/create.command.ts`](../src/legacy/commands/projects/create/create.command.ts) | +| `projects delete` | `wrapped` | [`../src/legacy/commands/projects/delete/delete.command.ts`](../src/legacy/commands/projects/delete/delete.command.ts) | +| `projects api-keys` | `wrapped` | [`../src/legacy/commands/projects/api-keys/api-keys.command.ts`](../src/legacy/commands/projects/api-keys/api-keys.command.ts) | +| `branches list` | `wrapped` | [`../src/legacy/commands/branches/list/list.command.ts`](../src/legacy/commands/branches/list/list.command.ts) | +| `branches create` | `wrapped` | [`../src/legacy/commands/branches/create/create.command.ts`](../src/legacy/commands/branches/create/create.command.ts) | +| `branches get` | `wrapped` | [`../src/legacy/commands/branches/get/get.command.ts`](../src/legacy/commands/branches/get/get.command.ts) | +| `branches update` | `wrapped` | [`../src/legacy/commands/branches/update/update.command.ts`](../src/legacy/commands/branches/update/update.command.ts) | +| `branches pause` | `wrapped` | [`../src/legacy/commands/branches/pause/pause.command.ts`](../src/legacy/commands/branches/pause/pause.command.ts) | +| `branches unpause` | `wrapped` | [`../src/legacy/commands/branches/unpause/unpause.command.ts`](../src/legacy/commands/branches/unpause/unpause.command.ts) | +| `branches delete` | `wrapped` | [`../src/legacy/commands/branches/delete/delete.command.ts`](../src/legacy/commands/branches/delete/delete.command.ts) | +| `branches disable` | `wrapped` | [`../src/legacy/commands/branches/disable/disable.command.ts`](../src/legacy/commands/branches/disable/disable.command.ts) | +| `secrets list` | `wrapped` | [`../src/legacy/commands/secrets/list/list.command.ts`](../src/legacy/commands/secrets/list/list.command.ts) | +| `secrets set` | `wrapped` | [`../src/legacy/commands/secrets/set/set.command.ts`](../src/legacy/commands/secrets/set/set.command.ts) | +| `secrets unset` | `wrapped` | [`../src/legacy/commands/secrets/unset/unset.command.ts`](../src/legacy/commands/secrets/unset/unset.command.ts) | +| `config push` | `wrapped` | [`../src/legacy/commands/config/push/push.command.ts`](../src/legacy/commands/config/push/push.command.ts) | +| `backups list` | `ported` | [`../src/legacy/commands/backups/list/list.command.ts`](../src/legacy/commands/backups/list/list.command.ts) | +| `backups restore` | `ported` | [`../src/legacy/commands/backups/restore/restore.command.ts`](../src/legacy/commands/backups/restore/restore.command.ts) | +| `snippets list` | `wrapped` | [`../src/legacy/commands/snippets/list/list.command.ts`](../src/legacy/commands/snippets/list/list.command.ts) | +| `snippets download` | `wrapped` | [`../src/legacy/commands/snippets/download/download.command.ts`](../src/legacy/commands/snippets/download/download.command.ts) | +| `sso list` | `wrapped` | [`../src/legacy/commands/sso/list/list.command.ts`](../src/legacy/commands/sso/list/list.command.ts) | +| `sso add` | `wrapped` | [`../src/legacy/commands/sso/add/add.command.ts`](../src/legacy/commands/sso/add/add.command.ts) | +| `sso remove` | `wrapped` | [`../src/legacy/commands/sso/remove/remove.command.ts`](../src/legacy/commands/sso/remove/remove.command.ts) | +| `sso update` | `wrapped` | [`../src/legacy/commands/sso/update/update.command.ts`](../src/legacy/commands/sso/update/update.command.ts) | +| `sso show` | `wrapped` | [`../src/legacy/commands/sso/show/show.command.ts`](../src/legacy/commands/sso/show/show.command.ts) | +| `sso info` | `wrapped` | [`../src/legacy/commands/sso/info/info.command.ts`](../src/legacy/commands/sso/info/info.command.ts) | +| `domains create` | `wrapped` | [`../src/legacy/commands/domains/create/create.command.ts`](../src/legacy/commands/domains/create/create.command.ts) | +| `domains get` | `wrapped` | [`../src/legacy/commands/domains/get/get.command.ts`](../src/legacy/commands/domains/get/get.command.ts) | +| `domains reverify` | `wrapped` | [`../src/legacy/commands/domains/reverify/reverify.command.ts`](../src/legacy/commands/domains/reverify/reverify.command.ts) | +| `domains activate` | `wrapped` | [`../src/legacy/commands/domains/activate/activate.command.ts`](../src/legacy/commands/domains/activate/activate.command.ts) | +| `domains delete` | `wrapped` | [`../src/legacy/commands/domains/delete/delete.command.ts`](../src/legacy/commands/domains/delete/delete.command.ts) | +| `vanity-subdomains get` | `wrapped` | [`../src/legacy/commands/vanity-subdomains/get/get.command.ts`](../src/legacy/commands/vanity-subdomains/get/get.command.ts) | +| `vanity-subdomains check-availability` | `wrapped` | [`../src/legacy/commands/vanity-subdomains/check-availability/check-availability.command.ts`](../src/legacy/commands/vanity-subdomains/check-availability/check-availability.command.ts) | +| `vanity-subdomains activate` | `wrapped` | [`../src/legacy/commands/vanity-subdomains/activate/activate.command.ts`](../src/legacy/commands/vanity-subdomains/activate/activate.command.ts) | +| `vanity-subdomains delete` | `wrapped` | [`../src/legacy/commands/vanity-subdomains/delete/delete.command.ts`](../src/legacy/commands/vanity-subdomains/delete/delete.command.ts) | +| `network-bans get` | `wrapped` | [`../src/legacy/commands/network-bans/get/get.command.ts`](../src/legacy/commands/network-bans/get/get.command.ts) | +| `network-bans remove` | `wrapped` | [`../src/legacy/commands/network-bans/remove/remove.command.ts`](../src/legacy/commands/network-bans/remove/remove.command.ts) | +| `network-restrictions get` | `wrapped` | [`../src/legacy/commands/network-restrictions/get/get.command.ts`](../src/legacy/commands/network-restrictions/get/get.command.ts) | +| `network-restrictions update` | `wrapped` | [`../src/legacy/commands/network-restrictions/update/update.command.ts`](../src/legacy/commands/network-restrictions/update/update.command.ts) | +| `encryption get-root-key` | `wrapped` | [`../src/legacy/commands/encryption/get-root-key/get-root-key.command.ts`](../src/legacy/commands/encryption/get-root-key/get-root-key.command.ts) | +| `encryption update-root-key` | `wrapped` | [`../src/legacy/commands/encryption/update-root-key/update-root-key.command.ts`](../src/legacy/commands/encryption/update-root-key/update-root-key.command.ts) | +| `ssl-enforcement get` | `wrapped` | [`../src/legacy/commands/ssl-enforcement/get/get.command.ts`](../src/legacy/commands/ssl-enforcement/get/get.command.ts) | +| `ssl-enforcement update` | `wrapped` | [`../src/legacy/commands/ssl-enforcement/update/update.command.ts`](../src/legacy/commands/ssl-enforcement/update/update.command.ts) | +| `postgres-config get` | `wrapped` | [`../src/legacy/commands/postgres-config/get/get.command.ts`](../src/legacy/commands/postgres-config/get/get.command.ts) | +| `postgres-config update` | `wrapped` | [`../src/legacy/commands/postgres-config/update/update.command.ts`](../src/legacy/commands/postgres-config/update/update.command.ts) | +| `postgres-config delete` | `wrapped` | [`../src/legacy/commands/postgres-config/delete/delete.command.ts`](../src/legacy/commands/postgres-config/delete/delete.command.ts) | +| `login` | `wrapped` | [`../src/legacy/commands/login/login.command.ts`](../src/legacy/commands/login/login.command.ts) | +| `logout` | `wrapped` | [`../src/legacy/commands/logout/logout.command.ts`](../src/legacy/commands/logout/logout.command.ts) | +| `link` | `wrapped` | [`../src/legacy/commands/link/link.command.ts`](../src/legacy/commands/link/link.command.ts) | +| `unlink` | `wrapped` | [`../src/legacy/commands/unlink/unlink.command.ts`](../src/legacy/commands/unlink/unlink.command.ts) | +| `bootstrap` | `wrapped` | [`../src/legacy/commands/bootstrap/bootstrap.command.ts`](../src/legacy/commands/bootstrap/bootstrap.command.ts) | +| `init` | `wrapped` | [`../src/legacy/commands/init/init.command.ts`](../src/legacy/commands/init/init.command.ts) | +| `services` | `wrapped` | [`../src/legacy/commands/services/services.command.ts`](../src/legacy/commands/services/services.command.ts) | +| `start` | `wrapped` | [`../src/legacy/commands/start/start.command.ts`](../src/legacy/commands/start/start.command.ts) | +| `stop` | `wrapped` | [`../src/legacy/commands/stop/stop.command.ts`](../src/legacy/commands/stop/stop.command.ts) | +| `status` | `wrapped` | [`../src/legacy/commands/status/status.command.ts`](../src/legacy/commands/status/status.command.ts) | +| `migration list` | `wrapped` | [`../src/legacy/commands/migration/list/list.command.ts`](../src/legacy/commands/migration/list/list.command.ts) | +| `migration new` | `wrapped` | [`../src/legacy/commands/migration/new/new.command.ts`](../src/legacy/commands/migration/new/new.command.ts) | +| `migration repair` | `wrapped` | [`../src/legacy/commands/migration/repair/repair.command.ts`](../src/legacy/commands/migration/repair/repair.command.ts) | +| `migration squash` | `wrapped` | [`../src/legacy/commands/migration/squash/squash.command.ts`](../src/legacy/commands/migration/squash/squash.command.ts) | +| `migration up` | `wrapped` | [`../src/legacy/commands/migration/up/up.command.ts`](../src/legacy/commands/migration/up/up.command.ts) | +| `migration down` | `wrapped` | [`../src/legacy/commands/migration/down/down.command.ts`](../src/legacy/commands/migration/down/down.command.ts) | +| `migration fetch` | `wrapped` | [`../src/legacy/commands/migration/fetch/fetch.command.ts`](../src/legacy/commands/migration/fetch/fetch.command.ts) | +| `gen types` | `wrapped` | [`../src/legacy/commands/gen/types/types.command.ts`](../src/legacy/commands/gen/types/types.command.ts) | +| `gen signing-key` | `wrapped` | [`../src/legacy/commands/gen/signing-key/signing-key.command.ts`](../src/legacy/commands/gen/signing-key/signing-key.command.ts) | +| `gen bearer-jwt` | `wrapped` | [`../src/legacy/commands/gen/bearer-jwt/bearer-jwt.command.ts`](../src/legacy/commands/gen/bearer-jwt/bearer-jwt.command.ts) | +| `gen keys` | `wrapped` | [`../src/legacy/commands/gen/keys/keys.command.ts`](../src/legacy/commands/gen/keys/keys.command.ts) | +| `functions list` | `wrapped` | [`../src/legacy/commands/functions/list/list.command.ts`](../src/legacy/commands/functions/list/list.command.ts) | +| `functions delete` | `wrapped` | [`../src/legacy/commands/functions/delete/delete.command.ts`](../src/legacy/commands/functions/delete/delete.command.ts) | +| `functions download` | `wrapped` | [`../src/legacy/commands/functions/download/download.command.ts`](../src/legacy/commands/functions/download/download.command.ts) | +| `functions deploy` | `wrapped` | [`../src/legacy/commands/functions/deploy/deploy.command.ts`](../src/legacy/commands/functions/deploy/deploy.command.ts) | +| `functions new` | `wrapped` | [`../src/legacy/commands/functions/new/new.command.ts`](../src/legacy/commands/functions/new/new.command.ts) | +| `functions serve` | `wrapped` | [`../src/legacy/commands/functions/serve/serve.command.ts`](../src/legacy/commands/functions/serve/serve.command.ts) | +| `storage ls` | `wrapped` | [`../src/legacy/commands/storage/ls/ls.command.ts`](../src/legacy/commands/storage/ls/ls.command.ts) | +| `storage cp` | `wrapped` | [`../src/legacy/commands/storage/cp/cp.command.ts`](../src/legacy/commands/storage/cp/cp.command.ts) | +| `storage mv` | `wrapped` | [`../src/legacy/commands/storage/mv/mv.command.ts`](../src/legacy/commands/storage/mv/mv.command.ts) | +| `storage rm` | `wrapped` | [`../src/legacy/commands/storage/rm/rm.command.ts`](../src/legacy/commands/storage/rm/rm.command.ts) | +| `test db` | `wrapped` | [`../src/legacy/commands/test/db/db.command.ts`](../src/legacy/commands/test/db/db.command.ts) | +| `test new` | `wrapped` | [`../src/legacy/commands/test/new/new.command.ts`](../src/legacy/commands/test/new/new.command.ts) | +| `seed buckets` | `wrapped` | [`../src/legacy/commands/seed/buckets/buckets.command.ts`](../src/legacy/commands/seed/buckets/buckets.command.ts) | +| `db diff` | `wrapped` | [`../src/legacy/commands/db/diff/diff.command.ts`](../src/legacy/commands/db/diff/diff.command.ts) | +| `db dump` | `wrapped` | [`../src/legacy/commands/db/dump/dump.command.ts`](../src/legacy/commands/db/dump/dump.command.ts) | +| `db push` | `wrapped` | [`../src/legacy/commands/db/push/push.command.ts`](../src/legacy/commands/db/push/push.command.ts) | +| `db pull` | `wrapped` | [`../src/legacy/commands/db/pull/pull.command.ts`](../src/legacy/commands/db/pull/pull.command.ts) — includes `--diff-engine` (migra\|pg-delta, mutually exclusive with `--use-pg-delta`) | +| `db reset` | `wrapped` | [`../src/legacy/commands/db/reset/reset.command.ts`](../src/legacy/commands/db/reset/reset.command.ts) | +| `db lint` | `wrapped` | [`../src/legacy/commands/db/lint/lint.command.ts`](../src/legacy/commands/db/lint/lint.command.ts) | +| `db start` | `wrapped` | [`../src/legacy/commands/db/start/start.command.ts`](../src/legacy/commands/db/start/start.command.ts) | +| `db query` | `wrapped` | [`../src/legacy/commands/db/query/query.command.ts`](../src/legacy/commands/db/query/query.command.ts) | +| `db advisors` | `wrapped` | [`../src/legacy/commands/db/advisors/advisors.command.ts`](../src/legacy/commands/db/advisors/advisors.command.ts) | +| `db test` | `wrapped` | [`../src/legacy/commands/db/test/test.command.ts`](../src/legacy/commands/db/test/test.command.ts) | +| `db branch create` | `wrapped` | [`../src/legacy/commands/db/branch/create/create.command.ts`](../src/legacy/commands/db/branch/create/create.command.ts) | +| `db branch delete` | `wrapped` | [`../src/legacy/commands/db/branch/delete/delete.command.ts`](../src/legacy/commands/db/branch/delete/delete.command.ts) | +| `db branch list` | `wrapped` | [`../src/legacy/commands/db/branch/list/list.command.ts`](../src/legacy/commands/db/branch/list/list.command.ts) | +| `db branch switch` | `wrapped` | [`../src/legacy/commands/db/branch/switch/switch.command.ts`](../src/legacy/commands/db/branch/switch/switch.command.ts) | +| `db remote changes` | `wrapped` | [`../src/legacy/commands/db/remote/changes/changes.command.ts`](../src/legacy/commands/db/remote/changes/changes.command.ts) | +| `db remote commit` | `wrapped` | [`../src/legacy/commands/db/remote/commit/commit.command.ts`](../src/legacy/commands/db/remote/commit/commit.command.ts) | +| `db schema declarative sync` | `wrapped` | [`../src/legacy/commands/db/schema/declarative/sync/sync.command.ts`](../src/legacy/commands/db/schema/declarative/sync/sync.command.ts) | +| `db schema declarative generate` | `wrapped` | [`../src/legacy/commands/db/schema/declarative/generate/generate.command.ts`](../src/legacy/commands/db/schema/declarative/generate/generate.command.ts) | diff --git a/apps/cli/package.json b/apps/cli/package.json index 67ab24316..e0e6ebc09 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -64,7 +64,9 @@ "react": "^19.2.6", "react-devtools-core": "^7.0.1", "semantic-release": "^24.2.9", - "vitest": "catalog:" + "smol-toml": "^1.6.1", + "vitest": "catalog:", + "yaml": "^2.9.0" }, "optionalDependencies": { "@supabase/cli-darwin-arm64": "workspace:*", diff --git a/apps/cli/src/legacy/auth/legacy-credentials.layer.ts b/apps/cli/src/legacy/auth/legacy-credentials.layer.ts new file mode 100644 index 000000000..fca5e4c84 --- /dev/null +++ b/apps/cli/src/legacy/auth/legacy-credentials.layer.ts @@ -0,0 +1,155 @@ +import { Effect, FileSystem, Layer, Option, Path, Redacted } from "effect"; + +import { RuntimeInfo } from "../../shared/runtime/runtime-info.service.ts"; +import { LegacyCliConfig } from "../config/legacy-cli-config.service.ts"; +import { LegacyCredentials } from "./legacy-credentials.service.ts"; +import { LegacyInvalidAccessTokenError } from "./legacy-errors.ts"; + +const KEYRING_SERVICE = "Supabase CLI"; +const LEGACY_KEYRING_ACCOUNT = "access-token"; +const WSL_OSRELEASE_PATH = "/proc/sys/kernel/osrelease"; + +const ACCESS_TOKEN_PATTERN = /^sbp_(oauth_)?[a-f0-9]{40}$/; + +const INVALID_TOKEN_MESSAGE = "Invalid access token format. Must be like `sbp_0102...1920`."; + +type KeyringModule = typeof import("@napi-rs/keyring"); + +const detectWsl = (fs: FileSystem.FileSystem): Effect.Effect => + Effect.gen(function* () { + const exists = yield* fs.exists(WSL_OSRELEASE_PATH).pipe(Effect.orElseSucceed(() => false)); + if (!exists) return false; + const content = yield* fs + .readFileString(WSL_OSRELEASE_PATH) + .pipe(Effect.orElseSucceed(() => "")); + return content.includes("WSL") || content.includes("Microsoft"); + }); + +const tryKeyringRead = ( + module: KeyringModule, + account: string, +): Effect.Effect> => + Effect.try({ + try: () => { + const entry = new module.Entry(KEYRING_SERVICE, account); + const value = entry.getPassword(); + return value && value.length > 0 ? Option.some(value) : Option.none(); + }, + catch: () => Option.none(), + }).pipe(Effect.orElseSucceed(() => Option.none())); + +const tryKeyringWrite = ( + module: KeyringModule, + account: string, + token: string, +): Effect.Effect => + Effect.try({ + try: () => { + const entry = new module.Entry(KEYRING_SERVICE, account); + entry.setPassword(token); + return true; + }, + catch: () => false, + }).pipe(Effect.orElseSucceed(() => false)); + +const tryKeyringDelete = (module: KeyringModule, account: string): Effect.Effect => + Effect.try({ + try: () => { + const entry = new module.Entry(KEYRING_SERVICE, account); + const value = entry.getPassword(); + if (!value) return false; + entry.deleteCredential(); + return true; + }, + catch: () => false, + }).pipe(Effect.orElseSucceed(() => false)); + +const makeLegacyCredentials = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const runtimeInfo = yield* RuntimeInfo; + const cliConfig = yield* LegacyCliConfig; + const profileAccount = cliConfig.profile; + + // ~/.supabase/access-token — fallback file path + const fallbackDir = path.join(runtimeInfo.homeDir, ".supabase"); + const fallbackPath = path.join(fallbackDir, "access-token"); + + const wsl = yield* detectWsl(fs); + const keyringModule = wsl + ? Option.none() + : yield* Effect.tryPromise(() => import("@napi-rs/keyring")).pipe(Effect.option); + + const validate = (token: string): Effect.Effect => + ACCESS_TOKEN_PATTERN.test(token) + ? Effect.succeed(token) + : Effect.fail(new LegacyInvalidAccessTokenError({ message: INVALID_TOKEN_MESSAGE })); + + const readKeyring = Effect.gen(function* () { + if (Option.isNone(keyringModule)) return Option.none(); + const profileResult = yield* tryKeyringRead(keyringModule.value, profileAccount); + if (Option.isSome(profileResult)) return profileResult; + return yield* tryKeyringRead(keyringModule.value, LEGACY_KEYRING_ACCOUNT); + }); + + const readFile = Effect.gen(function* () { + const exists = yield* fs.exists(fallbackPath).pipe(Effect.orElseSucceed(() => false)); + if (!exists) return Option.none(); + const content = yield* fs.readFileString(fallbackPath).pipe(Effect.orElseSucceed(() => "")); + const trimmed = content.trim(); + return trimmed.length === 0 ? Option.none() : Option.some(trimmed); + }); + + return LegacyCredentials.of({ + getAccessToken: Effect.gen(function* () { + // Env takes precedence (matches access_token.go:38). + if (Option.isSome(cliConfig.accessToken)) { + yield* validate(Redacted.value(cliConfig.accessToken.value)); + return Option.some(cliConfig.accessToken.value); + } + + // Keyring (profile key, then legacy key). Skipped on WSL. + const keyringValue = yield* readKeyring; + if (Option.isSome(keyringValue)) { + yield* validate(keyringValue.value); + return Option.some(Redacted.make(keyringValue.value)); + } + + // Filesystem fallback at ~/.supabase/access-token. + const fileValue = yield* readFile; + if (Option.isSome(fileValue)) { + yield* validate(fileValue.value); + return Option.some(Redacted.make(fileValue.value)); + } + + return Option.none(); + }), + + saveAccessToken: (token: string) => + Effect.gen(function* () { + yield* validate(token); + if (Option.isSome(keyringModule)) { + const ok = yield* tryKeyringWrite(keyringModule.value, profileAccount, token); + if (ok) return; + } + yield* fs.makeDirectory(fallbackDir, { recursive: true, mode: 0o700 }).pipe(Effect.orDie); + yield* fs.writeFileString(fallbackPath, token, { mode: 0o600 }).pipe(Effect.orDie); + }), + + deleteAccessToken: Effect.gen(function* () { + let anyDeleted = false; + if (Option.isSome(keyringModule)) { + if (yield* tryKeyringDelete(keyringModule.value, profileAccount)) anyDeleted = true; + if (yield* tryKeyringDelete(keyringModule.value, LEGACY_KEYRING_ACCOUNT)) anyDeleted = true; + } + const exists = yield* fs.exists(fallbackPath).pipe(Effect.orElseSucceed(() => false)); + if (exists) { + yield* fs.remove(fallbackPath).pipe(Effect.orDie); + anyDeleted = true; + } + return anyDeleted; + }), + }); +}); + +export const legacyCredentialsLayer = Layer.effect(LegacyCredentials, makeLegacyCredentials); diff --git a/apps/cli/src/legacy/auth/legacy-credentials.layer.unit.test.ts b/apps/cli/src/legacy/auth/legacy-credentials.layer.unit.test.ts new file mode 100644 index 000000000..13b1a0286 --- /dev/null +++ b/apps/cli/src/legacy/auth/legacy-credentials.layer.unit.test.ts @@ -0,0 +1,230 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { describe, expect, it } from "@effect/vitest"; +import { BunServices } from "@effect/platform-bun"; +import { Effect, Layer, Option, Redacted } from "effect"; +import { afterEach, beforeEach, vi } from "vitest"; + +import { LegacyProfileFlag, LegacyWorkdirFlag } from "../../shared/legacy/global-flags.ts"; +import { mockRuntimeInfo, processEnvLayer } from "../../../tests/helpers/mocks.ts"; +import { legacyCliConfigLayer } from "../config/legacy-cli-config.layer.ts"; +import { legacyCredentialsLayer } from "./legacy-credentials.layer.ts"; +import { LegacyCredentials } from "./legacy-credentials.service.ts"; +import { LegacyInvalidAccessTokenError } from "./legacy-errors.ts"; + +// --------------------------------------------------------------------------- +// Keyring mock +// --------------------------------------------------------------------------- + +const passwords = new Map(); +let throwOnSetPassword = false; +const throwOnGetPasswordAccounts = new Set(); + +vi.mock("@napi-rs/keyring", () => ({ + Entry: class Entry { + service: string; + account: string; + constructor(service: string, account: string) { + this.service = service; + this.account = account; + } + getPassword(): string | null { + const key = `${this.service}/${this.account}`; + if (throwOnGetPasswordAccounts.has(key)) { + throw new Error("Keyring unavailable"); + } + return passwords.get(key) ?? null; + } + setPassword(value: string): void { + if (throwOnSetPassword) throw new Error("Keyring unavailable"); + passwords.set(`${this.service}/${this.account}`, value); + } + deleteCredential(): boolean { + const key = `${this.service}/${this.account}`; + if (!passwords.has(key)) throw new Error("not found"); + passwords.delete(key); + return true; + } + }, +})); + +// --------------------------------------------------------------------------- +// Layer wiring +// --------------------------------------------------------------------------- + +let tempHome: string; + +function makeLayer(opts: { env?: Record; home?: string } = {}) { + const home = opts.home ?? tempHome; + const env = { HOME: home, ...opts.env }; + const runtimeInfoLayer = mockRuntimeInfo({ homeDir: home, cwd: home }); + const cliConfigLayer = legacyCliConfigLayer.pipe( + Layer.provide(Layer.succeed(LegacyProfileFlag, "supabase")), + Layer.provide(Layer.succeed(LegacyWorkdirFlag, Option.none())), + Layer.provide(runtimeInfoLayer), + Layer.provide(BunServices.layer), + Layer.provide(processEnvLayer(env)), + ); + return legacyCredentialsLayer.pipe( + Layer.provide(cliConfigLayer), + Layer.provide(runtimeInfoLayer), + Layer.provide(BunServices.layer), + Layer.provide(processEnvLayer(env)), + ); +} + +beforeEach(() => { + passwords.clear(); + throwOnSetPassword = false; + throwOnGetPasswordAccounts.clear(); + tempHome = mkdtempSync(join(tmpdir(), "supabase-legacy-creds-")); +}); + +afterEach(() => { + rmSync(tempHome, { recursive: true, force: true }); +}); + +const VALID_TOKEN = "sbp_" + "a".repeat(40); +const VALID_OAUTH_TOKEN = "sbp_oauth_" + "b".repeat(40); + +const expectSomeToken = (token: Option.Option>, expected: string) => { + expect(Option.isSome(token)).toBe(true); + if (Option.isSome(token)) { + expect(Redacted.value(token.value)).toBe(expected); + } +}; + +describe("legacyCredentialsLayer.getAccessToken", () => { + it.effect("returns the SUPABASE_ACCESS_TOKEN env value (highest precedence)", () => { + passwords.set("Supabase CLI/supabase", "sbp_" + "9".repeat(40)); + return Effect.gen(function* () { + const { getAccessToken } = yield* LegacyCredentials; + const token = yield* getAccessToken; + expectSomeToken(token, VALID_TOKEN); + }).pipe(Effect.provide(makeLayer({ env: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN } }))); + }); + + it.effect("uses the keyring profile account when env is unset", () => { + passwords.set("Supabase CLI/supabase", VALID_TOKEN); + return Effect.gen(function* () { + const { getAccessToken } = yield* LegacyCredentials; + const token = yield* getAccessToken; + expectSomeToken(token, VALID_TOKEN); + }).pipe(Effect.provide(makeLayer())); + }); + + it.effect("falls through to the legacy access-token keyring entry", () => { + passwords.set("Supabase CLI/access-token", VALID_OAUTH_TOKEN); + return Effect.gen(function* () { + const { getAccessToken } = yield* LegacyCredentials; + const token = yield* getAccessToken; + expectSomeToken(token, VALID_OAUTH_TOKEN); + }).pipe(Effect.provide(makeLayer())); + }); + + it.effect("falls back to ~/.supabase/access-token when keyring entries miss", () => { + const supaDir = join(tempHome, ".supabase"); + mkdirSync(supaDir, { recursive: true }); + writeFileSync(join(supaDir, "access-token"), `${VALID_TOKEN}\n`, { mode: 0o600 }); + return Effect.gen(function* () { + const { getAccessToken } = yield* LegacyCredentials; + const token = yield* getAccessToken; + expectSomeToken(token, VALID_TOKEN); + }).pipe(Effect.provide(makeLayer())); + }); + + it.effect("returns None when no source provides a token", () => + Effect.gen(function* () { + const { getAccessToken } = yield* LegacyCredentials; + const token = yield* getAccessToken; + expect(token).toEqual(Option.none()); + }).pipe(Effect.provide(makeLayer())), + ); + + it.effect("fails with LegacyInvalidAccessTokenError when token format is invalid", () => { + passwords.set("Supabase CLI/supabase", "not-a-valid-token"); + return Effect.gen(function* () { + const { getAccessToken } = yield* LegacyCredentials; + const exit = yield* Effect.exit(getAccessToken); + expect(exit._tag).toBe("Failure"); + if (exit._tag === "Failure") { + const errorJson = JSON.stringify(exit.cause); + expect(errorJson).toContain("LegacyInvalidAccessTokenError"); + expect(errorJson).toContain("Invalid access token format"); + } + }).pipe(Effect.provide(makeLayer())); + }); + + it.effect("falls back to the filesystem when keyring throws", () => { + throwOnGetPasswordAccounts.add("Supabase CLI/supabase"); + throwOnGetPasswordAccounts.add("Supabase CLI/access-token"); + const supaDir = join(tempHome, ".supabase"); + mkdirSync(supaDir, { recursive: true }); + writeFileSync(join(supaDir, "access-token"), VALID_TOKEN, { mode: 0o600 }); + return Effect.gen(function* () { + const { getAccessToken } = yield* LegacyCredentials; + const token = yield* getAccessToken; + expectSomeToken(token, VALID_TOKEN); + }).pipe(Effect.provide(makeLayer())); + }); +}); + +describe("legacyCredentialsLayer.saveAccessToken", () => { + it.effect("rejects invalid token formats up front", () => + Effect.gen(function* () { + const { saveAccessToken } = yield* LegacyCredentials; + const exit = yield* Effect.exit(saveAccessToken("nope")); + expect(exit._tag).toBe("Failure"); + if (exit._tag === "Failure") { + expect(JSON.stringify(exit.cause)).toContain("LegacyInvalidAccessTokenError"); + } + }).pipe(Effect.provide(makeLayer())), + ); + + it.effect("writes to the keyring profile entry when available", () => + Effect.gen(function* () { + const { saveAccessToken } = yield* LegacyCredentials; + yield* saveAccessToken(VALID_TOKEN); + expect(passwords.get("Supabase CLI/supabase")).toBe(VALID_TOKEN); + }).pipe(Effect.provide(makeLayer())), + ); + + it.effect("falls back to the filesystem when the keyring write throws", () => { + throwOnSetPassword = true; + return Effect.gen(function* () { + const { saveAccessToken } = yield* LegacyCredentials; + yield* saveAccessToken(VALID_TOKEN); + const content = readFileSync(join(tempHome, ".supabase", "access-token"), "utf-8"); + expect(content).toBe(VALID_TOKEN); + }).pipe(Effect.provide(makeLayer())); + }); +}); + +describe("legacyCredentialsLayer.deleteAccessToken", () => { + it.effect("returns false when no token is stored anywhere", () => + Effect.gen(function* () { + const { deleteAccessToken } = yield* LegacyCredentials; + expect(yield* deleteAccessToken).toBe(false); + }).pipe(Effect.provide(makeLayer())), + ); + + it.effect("removes both keyring entries plus the filesystem file", () => { + passwords.set("Supabase CLI/supabase", VALID_TOKEN); + passwords.set("Supabase CLI/access-token", VALID_OAUTH_TOKEN); + const supaDir = join(tempHome, ".supabase"); + mkdirSync(supaDir, { recursive: true }); + writeFileSync(join(supaDir, "access-token"), VALID_TOKEN, { mode: 0o600 }); + return Effect.gen(function* () { + const { deleteAccessToken } = yield* LegacyCredentials; + expect(yield* deleteAccessToken).toBe(true); + expect(passwords.has("Supabase CLI/supabase")).toBe(false); + expect(passwords.has("Supabase CLI/access-token")).toBe(false); + expect(existsSync(join(supaDir, "access-token"))).toBe(false); + }).pipe(Effect.provide(makeLayer())); + }); +}); + +// Suppress unused-import nag — referenced in JSDoc. +void LegacyInvalidAccessTokenError; diff --git a/apps/cli/src/legacy/auth/legacy-credentials.service.ts b/apps/cli/src/legacy/auth/legacy-credentials.service.ts new file mode 100644 index 000000000..911b0f07d --- /dev/null +++ b/apps/cli/src/legacy/auth/legacy-credentials.service.ts @@ -0,0 +1,17 @@ +import type { Effect, Option, Redacted } from "effect"; +import { Context } from "effect"; + +import type { LegacyInvalidAccessTokenError } from "./legacy-errors.ts"; + +interface LegacyCredentialsShape { + readonly getAccessToken: Effect.Effect< + Option.Option>, + LegacyInvalidAccessTokenError + >; + readonly saveAccessToken: (token: string) => Effect.Effect; + readonly deleteAccessToken: Effect.Effect; +} + +export class LegacyCredentials extends Context.Service()( + "supabase/legacy/Credentials", +) {} diff --git a/apps/cli/src/legacy/auth/legacy-errors.ts b/apps/cli/src/legacy/auth/legacy-errors.ts new file mode 100644 index 000000000..ab5c93694 --- /dev/null +++ b/apps/cli/src/legacy/auth/legacy-errors.ts @@ -0,0 +1,13 @@ +import { Data } from "effect"; + +export class LegacyInvalidAccessTokenError extends Data.TaggedError( + "LegacyInvalidAccessTokenError", +)<{ + readonly message: string; +}> {} + +export class LegacyPlatformAuthRequiredError extends Data.TaggedError( + "LegacyPlatformAuthRequiredError", +)<{ + readonly message: string; +}> {} diff --git a/apps/cli/src/legacy/auth/legacy-http-debug.layer.ts b/apps/cli/src/legacy/auth/legacy-http-debug.layer.ts new file mode 100644 index 000000000..cd1ec08c5 --- /dev/null +++ b/apps/cli/src/legacy/auth/legacy-http-debug.layer.ts @@ -0,0 +1,43 @@ +import { Effect, Layer } from "effect"; +import { FetchHttpClient } from "effect/unstable/http"; +import * as HttpClient from "effect/unstable/http/HttpClient"; + +import { LegacyDebugFlag } from "../../shared/legacy/global-flags.ts"; + +const pad = (n: number): string => String(n).padStart(2, "0"); + +/** Formats a timestamp matching Go's `log.LstdFlags`: `YYYY/MM/DD HH:MM:SS`. */ +function formatTimestamp(now: Date): string { + return ( + `${now.getFullYear()}/${pad(now.getMonth() + 1)}/${pad(now.getDate())} ` + + `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}` + ); +} + +/** + * Wraps `FetchHttpClient.layer` so that, when `--debug` is set, every HTTP + * request is logged to stderr in the exact format Go uses + * (`apps/cli-go/internal/debug/http.go`): `HTTP : \n`. + * + * When `--debug` is unset, this is identity over `FetchHttpClient.layer` — no + * runtime overhead beyond a single boolean check at layer-construction time. + */ +export const legacyHttpClientLayer = Layer.unwrap( + Effect.gen(function* () { + const debug = yield* LegacyDebugFlag; + if (!debug) { + return FetchHttpClient.layer; + } + + return Layer.effect( + HttpClient.HttpClient, + Effect.gen(function* () { + const base = yield* HttpClient.HttpClient; + return HttpClient.mapRequest(base, (req) => { + process.stderr.write(`HTTP ${formatTimestamp(new Date())} ${req.method}: ${req.url}\n`); + return req; + }); + }), + ).pipe(Layer.provide(FetchHttpClient.layer)); + }), +); diff --git a/apps/cli/src/legacy/auth/legacy-platform-api.layer.ts b/apps/cli/src/legacy/auth/legacy-platform-api.layer.ts new file mode 100644 index 000000000..2735e82c3 --- /dev/null +++ b/apps/cli/src/legacy/auth/legacy-platform-api.layer.ts @@ -0,0 +1,37 @@ +import { makeApiClient } from "@supabase/api/effect"; +import { Effect, Layer, Option } from "effect"; + +import { LegacyCliConfig } from "../config/legacy-cli-config.service.ts"; +import { LegacyCredentials } from "./legacy-credentials.service.ts"; +import { LegacyPlatformAuthRequiredError } from "./legacy-errors.ts"; +import { LegacyPlatformApi } from "./legacy-platform-api.service.ts"; + +const MISSING_TOKEN_MESSAGE = + "Access token not provided. Supply an access token by running `supabase login` or setting the SUPABASE_ACCESS_TOKEN environment variable."; + +const makeLegacyPlatformApiServices = Effect.gen(function* () { + const cliConfig = yield* LegacyCliConfig; + const credentials = yield* LegacyCredentials; + + // Env takes precedence over keyring/file (already inside LegacyCredentials), but + // LegacyCliConfig.accessToken is the env value alone — read in the same order Go uses. + const configuredToken = cliConfig.accessToken; + const storedToken = Option.isSome(configuredToken) + ? configuredToken + : yield* credentials.getAccessToken; + + if (Option.isNone(storedToken)) { + return yield* Effect.fail( + new LegacyPlatformAuthRequiredError({ message: MISSING_TOKEN_MESSAGE }), + ); + } + + const api = yield* makeApiClient({ + baseUrl: cliConfig.apiUrl, + accessToken: storedToken.value, + userAgent: cliConfig.userAgent, + }); + return Layer.succeed(LegacyPlatformApi, api); +}); + +export const legacyPlatformApiLayer = Layer.unwrap(makeLegacyPlatformApiServices); diff --git a/apps/cli/src/legacy/auth/legacy-platform-api.layer.unit.test.ts b/apps/cli/src/legacy/auth/legacy-platform-api.layer.unit.test.ts new file mode 100644 index 000000000..205034f58 --- /dev/null +++ b/apps/cli/src/legacy/auth/legacy-platform-api.layer.unit.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, Layer, Option, Redacted } 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"; + +import { LegacyCliConfig } from "../config/legacy-cli-config.service.ts"; +import { LegacyCredentials } from "./legacy-credentials.service.ts"; +import { legacyPlatformApiLayer } from "./legacy-platform-api.layer.ts"; +import { LegacyPlatformApi } from "./legacy-platform-api.service.ts"; + +const VALID_TOKEN = "sbp_" + "a".repeat(40); + +function mockCliConfig(opts: { accessToken?: string; apiUrl?: string; userAgent?: string }) { + return Layer.succeed(LegacyCliConfig, { + profile: "supabase", + apiUrl: opts.apiUrl ?? "https://api.supabase.com", + accessToken: + opts.accessToken === undefined ? Option.none() : Option.some(Redacted.make(opts.accessToken)), + projectId: Option.none(), + workdir: "/tmp", + userAgent: opts.userAgent ?? "SupabaseCLI/0.0.0-dev", + }); +} + +function mockCredentials(token: Option.Option) { + return Layer.succeed(LegacyCredentials, { + getAccessToken: Effect.succeed(Option.map(token, Redacted.make)), + saveAccessToken: () => Effect.void, + deleteAccessToken: Effect.succeed(false), + }); +} + +function captureRequests() { + const requests: Array<{ + url: string; + headers: Readonly>; + }> = []; + const httpClient = HttpClient.make((request: HttpClientRequest.HttpClientRequest) => { + requests.push({ url: request.url, headers: request.headers }); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(JSON.stringify([]), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ), + ); + }); + return { layer: Layer.succeed(HttpClient.HttpClient, httpClient), requests }; +} + +describe("legacyPlatformApiLayer", () => { + it.effect("uses env access token over keyring-stored token", () => { + const http = captureRequests(); + const layer = legacyPlatformApiLayer.pipe( + Layer.provide(mockCliConfig({ accessToken: VALID_TOKEN })), + Layer.provide(mockCredentials(Option.some("sbp_" + "9".repeat(40)))), + Layer.provide(http.layer), + ); + return Effect.gen(function* () { + const api = yield* LegacyPlatformApi; + yield* api.v1.listAllProjects(); + expect(http.requests).toHaveLength(1); + expect(http.requests[0]?.headers.authorization).toBe(`Bearer ${VALID_TOKEN}`); + }).pipe(Effect.provide(layer)); + }); + + it.effect("uses LegacyCredentials.getAccessToken when env is unset", () => { + const http = captureRequests(); + const layer = legacyPlatformApiLayer.pipe( + Layer.provide(mockCliConfig({})), + Layer.provide(mockCredentials(Option.some(VALID_TOKEN))), + Layer.provide(http.layer), + ); + return Effect.gen(function* () { + const api = yield* LegacyPlatformApi; + yield* api.v1.listAllProjects(); + expect(http.requests[0]?.headers.authorization).toBe(`Bearer ${VALID_TOKEN}`); + }).pipe(Effect.provide(layer)); + }); + + it.effect("fails with LegacyPlatformAuthRequiredError when no token is configured", () => { + const http = captureRequests(); + const layer = legacyPlatformApiLayer.pipe( + Layer.provide(mockCliConfig({})), + Layer.provide(mockCredentials(Option.none())), + Layer.provide(http.layer), + ); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + Effect.gen(function* () { + const api = yield* LegacyPlatformApi; + return yield* api.v1.listAllProjects(); + }).pipe(Effect.provide(layer)), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const errorJson = JSON.stringify(exit.cause); + expect(errorJson).toContain("LegacyPlatformAuthRequiredError"); + expect(errorJson).toContain("Access token not provided"); + } + }); + }); + + it.effect("sends Go-style User-Agent and no X-Supabase-Command headers", () => { + const http = captureRequests(); + const layer = legacyPlatformApiLayer.pipe( + Layer.provide(mockCliConfig({ accessToken: VALID_TOKEN, userAgent: "SupabaseCLI/1.123.4" })), + Layer.provide(mockCredentials(Option.none())), + Layer.provide(http.layer), + ); + return Effect.gen(function* () { + const api = yield* LegacyPlatformApi; + yield* api.v1.listAllProjects(); + expect(http.requests[0]?.headers["user-agent"]).toBe("SupabaseCLI/1.123.4"); + expect(http.requests[0]?.headers["x-supabase-command"]).toBeUndefined(); + expect(http.requests[0]?.headers["x-supabase-command-run-id"]).toBeUndefined(); + }).pipe(Effect.provide(layer)); + }); + + it.effect("targets the configured apiUrl rather than SUPABASE_API_URL env", () => { + const http = captureRequests(); + const layer = legacyPlatformApiLayer.pipe( + Layer.provide( + mockCliConfig({ accessToken: VALID_TOKEN, apiUrl: "https://api.supabase.green" }), + ), + Layer.provide(mockCredentials(Option.none())), + Layer.provide(http.layer), + ); + return Effect.gen(function* () { + const api = yield* LegacyPlatformApi; + yield* api.v1.listAllProjects(); + expect(http.requests[0]?.url).toContain("https://api.supabase.green/"); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/cli/src/legacy/auth/legacy-platform-api.service.ts b/apps/cli/src/legacy/auth/legacy-platform-api.service.ts new file mode 100644 index 000000000..6631afa1e --- /dev/null +++ b/apps/cli/src/legacy/auth/legacy-platform-api.service.ts @@ -0,0 +1,6 @@ +import type { ApiClient } from "@supabase/api/effect"; +import { Context } from "effect"; + +export class LegacyPlatformApi extends Context.Service()( + "supabase/legacy/PlatformApi", +) {} diff --git a/apps/cli/src/legacy/commands/backups/backups.encoders.ts b/apps/cli/src/legacy/commands/backups/backups.encoders.ts new file mode 100644 index 000000000..58edb1d83 --- /dev/null +++ b/apps/cli/src/legacy/commands/backups/backups.encoders.ts @@ -0,0 +1,118 @@ +import type { V1ListAllBackupsOutput } from "@supabase/api/effect"; +import { stringify as stringifyToml } from "smol-toml"; +import { stringify as stringifyYaml } from "yaml"; + +/** + * Reproduces Go's `encoding/json` output for `V1BackupsResponse`: + * - Top-level and nested struct fields serialize in alphabetical declaration order. + * - Go emits `null` for a nil `Backups` slice. The TS schema decodes both `null` + * and `[]` upstream into `[]`, so we re-substitute `null` for empty arrays + * to match the common PITR-only response shape. + */ +export function encodeGoJson(response: typeof V1ListAllBackupsOutput.Type): string { + const source = response.backups.length > 0 ? response : { ...response, backups: null }; + return JSON.stringify(sortKeysDeep(source), null, 2) + "\n"; +} + +function sortKeysDeep(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sortKeysDeep); + if (value === null || typeof value !== "object") return value; + const sorted: Record = {}; + for (const key of Object.keys(value as Record).sort()) { + sorted[key] = sortKeysDeep((value as Record)[key]); + } + return sorted; +} + +export function encodeYaml(value: unknown): string { + return stringifyYaml(value); +} + +export function encodeToml(value: unknown): string { + // smol-toml refuses top-level non-object values; wrap if needed. + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return stringifyToml({ value }); + } + return stringifyToml(value as Record); +} + +/** + * Reproduces Go's `utils.ToEnvMap` + `godotenv.Marshal` byte shape for the + * Supabase CLI's `--output env` mode (see `apps/cli-go/internal/utils/output.go:86-107`). + * + * - Viper's `AllKeys()` descends into nested maps using dotted paths; the loop + * then `strings.ToUpper(strings.ReplaceAll(k, ".", "_"))` produces SCREAMING_SNAKE_CASE keys. + * - Viper does **not** descend into slices. An array value lands as a single + * leaf whose `GetString` rendering is the empty string — so e.g. + * `{backups: [{...}, {...}]}` becomes one `BACKUPS=""` entry, not indexed leaves. + * - Integer-parseable values are emitted unquoted (`KEY=123`), matching + * `godotenv.Marshal`'s `strconv.Atoi` branch. Everything else is double-quoted + * with `"` / `\\` escaped, matching the `fmt.Sprintf("%q", ...)` branch. + * - Lines are sorted lexicographically by key, then joined with `\n`. + */ +export function encodeEnv(value: unknown): string { + const flat = flatten(value); + const lines: string[] = []; + const keys = Object.keys(flat).sort(); + for (const key of keys) { + lines.push(`${key}=${formatEnvValue(flat[key] ?? "")}`); + } + return lines.join("\n"); +} + +function flatten( + value: unknown, + prefix = "", + out: Record = {}, +): Record { + if (value === null || value === undefined) { + if (prefix.length > 0) out[toEnvKey(prefix)] = ""; + return out; + } + if (Array.isArray(value)) { + // Go's viper does not descend into slices — the entire array collapses to a + // single empty-string leaf at the array's parent key. + if (prefix.length > 0) out[toEnvKey(prefix)] = ""; + return out; + } + if (typeof value === "object") { + // Go's viper.AllKeys() omits empty nested maps entirely (unlike empty + // slices, which leave a single empty-string leaf). Match that — recurse + // into populated maps; emit nothing for `{}`. + for (const [key, child] of Object.entries(value as Record)) { + flatten(child, prefix.length === 0 ? key : `${prefix}.${key}`, out); + } + return out; + } + if (prefix.length > 0) { + out[toEnvKey(prefix)] = stringifyScalar(value); + } + return out; +} + +function toEnvKey(key: string): string { + return key.replaceAll(".", "_").toUpperCase(); +} + +function stringifyScalar(value: unknown): string { + if (typeof value === "boolean") return value ? "true" : "false"; + if (typeof value === "number") return Number.isFinite(value) ? String(value) : ""; + return String(value); +} + +// strconv.Atoi accepts an optional +/- sign followed by base-10 digits. Match +// that surface so integer values flow through Go's unquoted `%d` branch. +const INTEGER_PATTERN = /^[+-]?\d+$/; + +function formatEnvValue(value: string): string { + if (INTEGER_PATTERN.test(value)) { + const parsed = Number(value); + // Mirror godotenv's `%d` formatting (round-trip through int — drops a leading + // `+` and any leading zeros, matching Go's strconv.Atoi + fmt.Sprintf("%d"). + if (Number.isSafeInteger(parsed)) { + return String(parsed); + } + } + const escaped = value.replaceAll("\\", "\\\\").replaceAll('"', '\\"'); + return `"${escaped}"`; +} diff --git a/apps/cli/src/legacy/commands/backups/backups.encoders.unit.test.ts b/apps/cli/src/legacy/commands/backups/backups.encoders.unit.test.ts new file mode 100644 index 000000000..0087b54a0 --- /dev/null +++ b/apps/cli/src/legacy/commands/backups/backups.encoders.unit.test.ts @@ -0,0 +1,186 @@ +import { V1ListAllBackupsOutput } from "@supabase/api/effect"; +import { describe, expect, it } from "vitest"; + +import { encodeEnv, encodeGoJson, encodeToml, encodeYaml } from "./backups.encoders.ts"; + +const SAMPLE_RESPONSE: typeof V1ListAllBackupsOutput.Type = { + region: "ap-southeast-1", + walg_enabled: true, + pitr_enabled: true, + backups: [ + { + id: 1, + is_physical_backup: true, + status: "COMPLETED", + inserted_at: "2026-02-08T16:44:07Z", + }, + ], + physical_backup_data: { + earliest_physical_backup_date_unix: 1700000000, + latest_physical_backup_date_unix: 1700001000, + }, +}; + +describe("encodeGoJson", () => { + it("emits Go's alphabetical struct-field order and trailing newline for a populated response", () => { + const out = encodeGoJson(SAMPLE_RESPONSE); + expect(out).toBe( + `{ + "backups": [ + { + "id": 1, + "inserted_at": "2026-02-08T16:44:07Z", + "is_physical_backup": true, + "status": "COMPLETED" + } + ], + "physical_backup_data": { + "earliest_physical_backup_date_unix": 1700000000, + "latest_physical_backup_date_unix": 1700001000 + }, + "pitr_enabled": true, + "region": "ap-southeast-1", + "walg_enabled": true +} +`, + ); + }); + + it("emits backups: null and an empty physical_backup_data object for a PITR-only response", () => { + // Matches Go's `apps/cli-go/internal/backups/list/list_test.go` "encodes json output" fixture + // — empty backups slice serializes as null, omitempty physical_backup_data fields drop out. + const out = encodeGoJson({ + region: "ap-southeast-1", + walg_enabled: false, + pitr_enabled: false, + backups: [], + physical_backup_data: {}, + }); + expect(out).toBe( + `{ + "backups": null, + "physical_backup_data": {}, + "pitr_enabled": false, + "region": "ap-southeast-1", + "walg_enabled": false +} +`, + ); + }); +}); + +describe("encodeYaml", () => { + it("renders nested objects as YAML", () => { + const out = encodeYaml(SAMPLE_RESPONSE); + expect(out).toContain("region: ap-southeast-1"); + expect(out).toContain("walg_enabled: true"); + expect(out).toContain("status: COMPLETED"); + expect(out).toContain("earliest_physical_backup_date_unix: 1700000000"); + }); +}); + +describe("encodeToml", () => { + it("renders a TOML document for the response", () => { + const out = encodeToml(SAMPLE_RESPONSE); + expect(out).toContain('region = "ap-southeast-1"'); + expect(out).toContain("walg_enabled = true"); + expect(out).toContain("[physical_backup_data]"); + expect(out).toContain("earliest_physical_backup_date_unix = 1700000000"); + }); +}); + +describe("encodeEnv", () => { + it("quotes string values and flattens nested fields to uppercased dotted keys", () => { + const out = encodeEnv(SAMPLE_RESPONSE); + const lines = out.split("\n"); + expect(lines).toContain('REGION="ap-southeast-1"'); + // Booleans are stringified to "true"/"false" — not integers under strconv.Atoi, + // so godotenv quotes them. + expect(lines).toContain('WALG_ENABLED="true"'); + expect(lines).toContain('PITR_ENABLED="true"'); + }); + + it("emits integer-parseable values unquoted (matches godotenv strconv.Atoi branch)", () => { + const out = encodeEnv(SAMPLE_RESPONSE); + const lines = out.split("\n"); + expect(lines).toContain("PHYSICAL_BACKUP_DATA_EARLIEST_PHYSICAL_BACKUP_DATE_UNIX=1700000000"); + expect(lines).toContain("PHYSICAL_BACKUP_DATA_LATEST_PHYSICAL_BACKUP_DATE_UNIX=1700001000"); + }); + + it("collapses arrays to a single empty leaf (Go viper does not descend into slices)", () => { + // Go output for `backups: [{...}]` is `BACKUPS=""`, not `BACKUPS_0_STATUS=...` + // — viper.AllKeys() stops at slice boundaries and GetString of a slice is "". + const out = encodeEnv(SAMPLE_RESPONSE); + const lines = out.split("\n"); + expect(lines).toContain('BACKUPS=""'); + expect(lines.some((line) => line.startsWith("BACKUPS_0_"))).toBe(false); + }); + + it("matches Go's full env output for the sample backup response", () => { + // Verified byte-for-byte against `apps/cli-go` invoking utils.EncodeOutput("env", ...). + expect(encodeEnv(SAMPLE_RESPONSE)).toBe( + [ + 'BACKUPS=""', + "PHYSICAL_BACKUP_DATA_EARLIEST_PHYSICAL_BACKUP_DATE_UNIX=1700000000", + "PHYSICAL_BACKUP_DATA_LATEST_PHYSICAL_BACKUP_DATE_UNIX=1700001000", + 'PITR_ENABLED="true"', + 'REGION="ap-southeast-1"', + 'WALG_ENABLED="true"', + ].join("\n"), + ); + }); + + it("escapes embedded backslashes and double quotes", () => { + const out = encodeEnv({ message: 'with "quotes" and \\backslash' }); + expect(out).toBe('MESSAGE="with \\"quotes\\" and \\\\backslash"'); + }); + + it("sorts keys deterministically and emits numeric leafs without quotes", () => { + const out = encodeEnv({ z: 1, a: 2, m: 3 }); + expect(out.split("\n")).toEqual(["A=2", "M=3", "Z=1"]); + }); + + it("omits empty nested maps entirely (Go viper parity)", () => { + // Go output for `{physical_backup_data: {}}` is empty — viper.AllKeys() + // does not surface a key for a map with no children. Contrast with empty + // arrays, which Go DOES surface as `KEY=""`. + expect(encodeEnv({ physical_backup_data: {} })).toBe(""); + }); + + it("matches Go for the PITR-only response shape with empty physical_backup_data", () => { + // Verified byte-for-byte against `apps/cli-go` invoking utils.EncodeOutput("env", ...) + // with a JSON-decoded V1BackupsResponse whose physical_backup_data is `{}`. + expect( + encodeEnv({ + region: "ap-southeast-1", + walg_enabled: true, + pitr_enabled: true, + backups: [], + physical_backup_data: {}, + }), + ).toBe( + ['BACKUPS=""', 'PITR_ENABLED="true"', 'REGION="ap-southeast-1"', 'WALG_ENABLED="true"'].join( + "\n", + ), + ); + }); + + it("emits an empty-string value for an explicit null leaf", () => { + // Go: viper does surface a nil leaf as `KEY=""` (it still has a key path). + expect(encodeEnv({ physical_backup_data: { earliest_physical_backup_date_unix: null } })).toBe( + 'PHYSICAL_BACKUP_DATA_EARLIEST_PHYSICAL_BACKUP_DATE_UNIX=""', + ); + }); + + it("treats non-integer numeric strings as strings (quoted)", () => { + const out = encodeEnv({ ratio: "3.14", empty: "" }); + const lines = out.split("\n"); + expect(lines).toContain('RATIO="3.14"'); + expect(lines).toContain('EMPTY=""'); + }); + + it("handles negative integers unquoted", () => { + const out = encodeEnv({ offset: -42 }); + expect(out).toBe("OFFSET=-42"); + }); +}); diff --git a/apps/cli/src/legacy/commands/backups/backups.errors.ts b/apps/cli/src/legacy/commands/backups/backups.errors.ts new file mode 100644 index 000000000..67c2fc46f --- /dev/null +++ b/apps/cli/src/legacy/commands/backups/backups.errors.ts @@ -0,0 +1,95 @@ +import type { SupabaseApiError } from "@supabase/api/effect"; +import { Data, Effect } from "effect"; +import * as HttpClientError from "effect/unstable/http/HttpClientError"; + +export class LegacyBackupListNetworkError extends Data.TaggedError("LegacyBackupListNetworkError")<{ + readonly message: string; +}> {} + +export class LegacyBackupListUnexpectedStatusError extends Data.TaggedError( + "LegacyBackupListUnexpectedStatusError", +)<{ + readonly status: number; + readonly body: string; + readonly message: string; +}> {} + +export class LegacyBackupRestoreNetworkError extends Data.TaggedError( + "LegacyBackupRestoreNetworkError", +)<{ + readonly message: string; +}> {} + +export class LegacyBackupRestoreUnexpectedStatusError extends Data.TaggedError( + "LegacyBackupRestoreUnexpectedStatusError", +)<{ + readonly status: number; + readonly body: string; + readonly message: string; +}> {} + +// HttpClientError reasons that indicate the server returned an actual response (vs a transport +// failure). Anything in this set surfaces as an `UnexpectedStatusError`; everything else maps +// to a `NetworkError`. +const RESPONSE_ERROR_TAGS: ReadonlySet = new Set([ + "StatusCodeError", + "DecodeError", + "EmptyBodyError", +]); + +// Caps the response body that gets embedded in error structures. The Management API is +// trusted, but capping prevents oversized error envelopes from flooding `--output-format json` +// and avoids forwarding arbitrary bytes verbatim if the trust boundary ever changes. +const MAX_BODY_LEN = 1024; + +type NetworkErrorFactory = new (args: { readonly message: string }) => E; + +type StatusErrorFactory = new (args: { + readonly status: number; + readonly body: string; + readonly message: string; +}) => E; + +/** + * Build an error mapper that classifies a `SupabaseApiError` into either a typed network + * error or a typed unexpected-status error. Pulled out of the handlers so both commands + * share the dispatch logic, the body truncation, and the `RESPONSE_ERROR_TAGS` policy. + * + * `networkMessage` and `statusMessage` are templates: they build the human-readable error + * string with the same exact phrasing the handlers used before, so existing error-message + * assertions (and Go parity for status messages) continue to hold. + */ +export function mapLegacyBackupHttpError(opts: { + readonly networkError: NetworkErrorFactory; + readonly statusError: StatusErrorFactory; + readonly networkMessage: (cause: string) => string; + readonly statusMessage: (status: number, body: string) => string; +}): (cause: SupabaseApiError) => Effect.Effect { + return (cause) => + Effect.gen(function* () { + if (HttpClientError.isHttpClientError(cause)) { + if (RESPONSE_ERROR_TAGS.has(cause.reason._tag) && cause.response !== undefined) { + const status = cause.response.status; + const rawBody = yield* cause.response.text.pipe( + Effect.orElseSucceed(() => cause.reason.description ?? ""), + ); + const body = rawBody.length > MAX_BODY_LEN ? rawBody.slice(0, MAX_BODY_LEN) : rawBody; + return yield* Effect.fail( + new opts.statusError({ + status, + body, + message: opts.statusMessage(status, body), + }), + ); + } + const description = cause.reason.description ?? cause.reason._tag; + return yield* Effect.fail( + new opts.networkError({ message: opts.networkMessage(description) }), + ); + } + // SchemaError or HttpBodyError — treat as transport-level network error. + return yield* Effect.fail( + new opts.networkError({ message: opts.networkMessage(String(cause)) }), + ); + }); +} diff --git a/apps/cli/src/legacy/commands/backups/backups.format.ts b/apps/cli/src/legacy/commands/backups/backups.format.ts new file mode 100644 index 000000000..a04ce811d --- /dev/null +++ b/apps/cli/src/legacy/commands/backups/backups.format.ts @@ -0,0 +1,51 @@ +const REGION_NAMES: Readonly> = { + "ap-east-1": "East Asia (Hong Kong)", + "ap-northeast-1": "Northeast Asia (Tokyo)", + "ap-northeast-2": "Northeast Asia (Seoul)", + "ap-south-1": "South Asia (Mumbai)", + "ap-southeast-1": "Southeast Asia (Singapore)", + "ap-southeast-2": "Oceania (Sydney)", + "ca-central-1": "Canada (Central)", + "eu-central-1": "Central EU (Frankfurt)", + "eu-central-2": "Central Europe (Zurich)", + "eu-north-1": "North EU (Stockholm)", + "eu-west-1": "West EU (Ireland)", + "eu-west-2": "West Europe (London)", + "eu-west-3": "West EU (Paris)", + "sa-east-1": "South America (São Paulo)", + "us-east-1": "East US (North Virginia)", + "us-east-2": "East US (Ohio)", + "us-west-1": "West US (North California)", + "us-west-2": "West US (Oregon)", +}; + +export function formatRegion(region: string): string { + return REGION_NAMES[region] ?? region; +} + +function pad2(value: number): string { + return value.toString().padStart(2, "0"); +} + +/** + * Reproduces `utils.FormatTimestamp` from `apps/cli-go/internal/utils/render.go:17`: + * parse RFC3339; on success format as UTC "YYYY-MM-DD HH:MM:SS"; on failure + * return the input verbatim. + */ +export function formatBackupTimestamp(value: string): string { + if (value.length === 0) return value; + // Go uses time.Parse(time.RFC3339, value). Date.parse accepts a broader format + // surface, so we additionally require the year-month-day prefix to weed out + // values like "2026-02-08 16:44:07" (already-formatted) that Date.parse would + // happily accept but Go's strict RFC3339 parser would reject. + if (!/^\d{4}-\d{2}-\d{2}T/.test(value)) { + return value; + } + const parsed = Date.parse(value); + if (Number.isNaN(parsed)) return value; + const date = new Date(parsed); + return ( + `${date.getUTCFullYear()}-${pad2(date.getUTCMonth() + 1)}-${pad2(date.getUTCDate())} ` + + `${pad2(date.getUTCHours())}:${pad2(date.getUTCMinutes())}:${pad2(date.getUTCSeconds())}` + ); +} diff --git a/apps/cli/src/legacy/commands/backups/backups.format.unit.test.ts b/apps/cli/src/legacy/commands/backups/backups.format.unit.test.ts new file mode 100644 index 000000000..4d270cdc2 --- /dev/null +++ b/apps/cli/src/legacy/commands/backups/backups.format.unit.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; + +import { formatBackupTimestamp, formatRegion } from "./backups.format.ts"; + +describe("formatRegion", () => { + it.each([ + ["ap-east-1", "East Asia (Hong Kong)"], + ["ap-northeast-1", "Northeast Asia (Tokyo)"], + ["ap-northeast-2", "Northeast Asia (Seoul)"], + ["ap-south-1", "South Asia (Mumbai)"], + ["ap-southeast-1", "Southeast Asia (Singapore)"], + ["ap-southeast-2", "Oceania (Sydney)"], + ["ca-central-1", "Canada (Central)"], + ["eu-central-1", "Central EU (Frankfurt)"], + ["eu-central-2", "Central Europe (Zurich)"], + ["eu-north-1", "North EU (Stockholm)"], + ["eu-west-1", "West EU (Ireland)"], + ["eu-west-2", "West Europe (London)"], + ["eu-west-3", "West EU (Paris)"], + ["sa-east-1", "South America (São Paulo)"], + ["us-east-1", "East US (North Virginia)"], + ["us-east-2", "East US (Ohio)"], + ["us-west-1", "West US (North California)"], + ["us-west-2", "West US (Oregon)"], + ])("maps %s to %s", (input, expected) => { + expect(formatRegion(input)).toBe(expected); + }); + + it("returns the region unchanged when unknown", () => { + expect(formatRegion("xx-unknown-9")).toBe("xx-unknown-9"); + }); +}); + +describe("formatBackupTimestamp", () => { + it("formats valid RFC3339 to YYYY-MM-DD HH:MM:SS UTC", () => { + expect(formatBackupTimestamp("2026-02-08T16:44:07Z")).toBe("2026-02-08 16:44:07"); + }); + + it("handles offsets by normalizing to UTC", () => { + expect(formatBackupTimestamp("2026-02-08T18:44:07+02:00")).toBe("2026-02-08 16:44:07"); + }); + + it("falls back to the original value for already-formatted timestamps", () => { + // Go's time.Parse(time.RFC3339, ...) rejects "2026-02-08 16:44:07" (space, not T). + expect(formatBackupTimestamp("2026-02-08 16:44:07")).toBe("2026-02-08 16:44:07"); + }); + + it("falls back for malformed input", () => { + expect(formatBackupTimestamp("not-a-timestamp")).toBe("not-a-timestamp"); + }); + + it("returns empty string unchanged", () => { + expect(formatBackupTimestamp("")).toBe(""); + }); +}); diff --git a/apps/cli/src/legacy/commands/backups/backups.layers.ts b/apps/cli/src/legacy/commands/backups/backups.layers.ts new file mode 100644 index 000000000..17c67c6fc --- /dev/null +++ b/apps/cli/src/legacy/commands/backups/backups.layers.ts @@ -0,0 +1,47 @@ +import { Layer } from "effect"; + +import { legacyCredentialsLayer } from "../../auth/legacy-credentials.layer.ts"; +import { legacyHttpClientLayer } from "../../auth/legacy-http-debug.layer.ts"; +import { legacyPlatformApiLayer } from "../../auth/legacy-platform-api.layer.ts"; +import { legacyCliConfigLayer } from "../../config/legacy-cli-config.layer.ts"; +import { legacyProjectRefLayer } from "../../config/legacy-project-ref.layer.ts"; +import { legacyLinkedProjectCacheLayer } from "../../telemetry/legacy-linked-project-cache.layer.ts"; +import { legacyTelemetryStateLayer } from "../../telemetry/legacy-telemetry-state.layer.ts"; +import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; + +// Shared platform-API stack used by every `backups` subcommand. `legacyHttpClientLayer` +// wraps the default fetch transport with a debug logger when `--debug` is set. +const legacyBackupsPlatformApiLayer = legacyPlatformApiLayer.pipe( + Layer.provide(legacyCredentialsLayer), + Layer.provide(legacyCliConfigLayer), + Layer.provide(legacyHttpClientLayer), +); + +/** + * Composes the runtime layer for a `supabase backups ` invocation. + * + * `legacyCliConfigLayer` must be piped to both `legacyBackupsPlatformApiLayer` and + * `legacyProjectRefLayer`. `Layer.provide` satisfies a requirement on the target layer; + * it does not expose the provided service to siblings of a `Layer.mergeAll(...)`. The + * project-ref layer reads `LegacyCliConfig` directly for workdir/projectId resolution, + * so without an explicit provide here the bundled runtime panics with + * `Service not found: supabase/legacy/CliConfig`. + * + * @param subcommand - command path segments after `supabase`, e.g. `["backups", "list"]`. + */ +export function legacyBackupsRuntimeLayer(subcommand: ReadonlyArray) { + return Layer.mergeAll( + legacyBackupsPlatformApiLayer, + legacyProjectRefLayer.pipe( + Layer.provide(legacyBackupsPlatformApiLayer), + Layer.provide(legacyCliConfigLayer), + ), + legacyLinkedProjectCacheLayer.pipe( + Layer.provide(legacyCredentialsLayer), + Layer.provide(legacyCliConfigLayer), + Layer.provide(legacyHttpClientLayer), + ), + legacyTelemetryStateLayer, + commandRuntimeLayer([...subcommand]), + ); +} diff --git a/apps/cli/src/legacy/commands/backups/list/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/backups/list/SIDE_EFFECTS.md index 1b5c3b886..af7dc4817 100644 --- a/apps/cli/src/legacy/commands/backups/list/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/backups/list/SIDE_EFFECTS.md @@ -2,9 +2,13 @@ ## Files Read -| Path | Format | When | -| -------------------------- | ------------------------- | ---------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| Path | Format | When | +| ----------------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------- | +| `/proc/sys/kernel/osrelease` (Linux) | plain text | once on layer init — disables keyring on WSL (`WSL` / `Microsoft` substring match) | +| keyring `"Supabase CLI"` / `` | OS keychain | when `SUPABASE_ACCESS_TOKEN` unset and keyring available; account = `LegacyCliConfig.profile` | +| keyring `"Supabase CLI"` / `access-token` | OS keychain | legacy-key fallback when the profile-keyed lookup misses | +| `~/.supabase/access-token` | plain text (token string) | last-resort fallback after env + keyring miss | +| `/supabase/.temp/project-ref` | plain text | when `--project-ref` and `SUPABASE_PROJECT_ID` are both unset | ## Files Written @@ -14,44 +18,67 @@ ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ------------------------------------- | ------------ | ------------ | -------------------------------------------------------------------------------------------- | -| `GET` | `/v1/projects/{ref}/database/backups` | Bearer token | none | `{region, walg_enabled, pitr_enabled, backups: [{inserted_at, status, is_physical_backup}]}` | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ------------------------------------- | ------------ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/v1/projects/{ref}/database/backups` | Bearer token | none | `{region, walg_enabled, pitr_enabled, backups: [{inserted_at, status, is_physical_backup}], physical_backup_data: {earliest_physical_backup_date_unix?, latest_physical_backup_date_unix?}}` | +| `GET` | `/v1/projects` | Bearer token | none | `[{id, ref, name, organization_slug, region, ...}]` — TTY-prompt fallback only | ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| Variable | Purpose | Required? | +| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | selects API base URL: `supabase` → `api.supabase.com`, `supabase-staging` → `api.supabase.green`, `supabase-local` → `http://localhost:8080`. May alternatively be a filesystem path to a YAML profile with at least `api_url:` and optional `name:` (Go parity — used by the cli-e2e test harness). | no (defaults to `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (also reads `/supabase/.temp/project-ref` then prompts on TTY) | +| `SUPABASE_WORKDIR` | base directory for the `.temp/project-ref` lookup | no (walks up from CWD looking for `supabase/config.toml`) | +| ~~`SUPABASE_API_URL`~~ | **not honored** — Go parity. Use `SUPABASE_PROFILE` to override the API base URL. | — | ## Exit Codes -| Code | Condition | -| ---- | ------------------------------------------------------ | -| `0` | success — backup list printed to stdout | -| `1` | authentication error — no valid token found | -| `1` | missing `--project-ref` and no linked project | -| `1` | API error — non-2xx response from the backups endpoint | -| `1` | network / connection failure | +| Code | Condition | +| ---- | ------------------------------------------------------------------------------------------ | +| `0` | success — backup list printed to stdout | +| `1` | `LegacyPlatformAuthRequiredError` — no token in env/keyring/file | +| `1` | `LegacyInvalidAccessTokenError` — token violates `^sbp_(oauth_)?[a-f0-9]{40}$` | +| `1` | `LegacyProjectNotLinkedError` — `--project-ref` unset, env/file empty, and stdin not a TTY | +| `1` | `LegacyInvalidProjectRefError` — resolved ref violates `^[a-z]{20}$` | +| `1` | `LegacyBackupListUnexpectedStatusError` — non-2xx response from the backups endpoint | +| `1` | `LegacyBackupListNetworkError` — transport-level network failure | ## Output -### `--output-format text` (Go CLI compatible) +The legacy `--output {pretty,json,yaml,toml,env}` flag (Go-compatible) and the new global `--output-format {text,json,stream-json}` flag are both honored. `--output` wins when both are supplied. `pretty` and `text` map to the same render path. -For PITR-enabled projects, prints a table with columns: `REGION`, `WALG`, `PITR`, `EARLIEST TIMESTAMP`, `LATEST TIMESTAMP`. +### `--output pretty` (Go default) / `--output-format text` -For projects with physical backups, prints a table with columns: `REGION`, `BACKUP TYPE`, `STATUS`, `CREATED AT (UTC)`. +For PITR-only projects, prints a Glamour-styled markdown table with columns: `REGION`, `WALG`, `PITR`, `EARLIEST TIMESTAMP`, `LATEST TIMESTAMP`. For projects with logical/physical backups, prints columns: `REGION`, `BACKUP TYPE`, `STATUS`, `CREATED AT (UTC)`. The table is rendered byte-for-byte to match Go's `glamour.WithStandardStyle(styles.AsciiStyle)` output. + +### `--output json` (Go-compat) + +Indented JSON (`json.MarshalIndent(resp, "", " ")` equivalent) of the full backup response, terminated by a newline. + +### `--output yaml` + +YAML document (`yaml@2` equivalent of Go's `yaml.v3`) of the full backup response. + +### `--output toml` + +TOML document (`smol-toml` equivalent of Go's `BurntSushi/toml`) of the full backup response. JSON shape is preserved; leaf order may differ from Go. + +### `--output env` + +`KEY=VALUE` lines (one per leaf), one per line, sorted lexicographically. Keys are flattened with `.` separators then converted to SCREAMING_SNAKE_CASE; values are double-quoted with `"` and `\\` escaped. ### `--output-format json` -Single JSON object with the full backup response as returned by the Management API. +Single JSON object emitted via `Output.success` with the full backup response as the `data` field. ### `--output-format stream-json` -One `result` event on success containing the backup response object. +One `result` NDJSON event on success containing the backup response object. ## Notes -- Requires `--project-ref` or a linked project (resolved from `.supabase/config.json`). -- Phase 0 proxy: all invocations are forwarded to the bundled Go binary via `LegacyGoProxy`. +- Requires `--project-ref`, `SUPABASE_PROJECT_ID`, a populated `/supabase/.temp/project-ref` file, or a TTY for the interactive project picker. +- The interactive picker calls `GET /v1/projects` and writes `"Selected project: "` to stderr in text mode (matches Go `project_ref.go:50`). It does **not** persist the choice; only `supabase link` and `supabase bootstrap` write the temp file. +- Sends `User-Agent: SupabaseCLI/` and Bearer auth. No `X-Supabase-Command` headers — Go parity. diff --git a/apps/cli/src/legacy/commands/backups/list/list.command.ts b/apps/cli/src/legacy/commands/backups/list/list.command.ts index 99a08adff..df4690590 100644 --- a/apps/cli/src/legacy/commands/backups/list/list.command.ts +++ b/apps/cli/src/legacy/commands/backups/list/list.command.ts @@ -1,4 +1,9 @@ +import type * as CliCommand from "effect/unstable/cli/Command"; import { Command, Flag } from "effect/unstable/cli"; + +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { withCommandInstrumentation } from "../../../../shared/telemetry/command-instrumentation.ts"; +import { legacyBackupsRuntimeLayer } from "../backups.layers.ts"; import { legacyBackupsList } from "./list.handler.ts"; const config = { @@ -6,11 +11,13 @@ const config = { Flag.withDescription("Project ref of the Supabase project."), Flag.optional, ), -}; +} as const; + +export type LegacyBackupsListFlags = CliCommand.Command.Config.Infer; export const legacyBackupsListCommand = Command.make("list", config).pipe( - Command.withDescription("Lists available physical backups for the linked project."), - Command.withShortDescription("List available physical backups"), + Command.withDescription("Lists available physical backups"), + Command.withShortDescription("Lists available physical backups"), Command.withExamples([ { command: "supabase backups list", @@ -21,5 +28,8 @@ export const legacyBackupsListCommand = Command.make("list", config).pipe( description: "List backups for a specific project", }, ]), - Command.withHandler((flags) => legacyBackupsList(flags)), + Command.withHandler((flags) => + legacyBackupsList(flags).pipe(withCommandInstrumentation(), withJsonErrorHandling), + ), + Command.provide(legacyBackupsRuntimeLayer(["backups", "list"])), ); diff --git a/apps/cli/src/legacy/commands/backups/list/list.handler.ts b/apps/cli/src/legacy/commands/backups/list/list.handler.ts index 410bdae09..4a4a6e19a 100644 --- a/apps/cli/src/legacy/commands/backups/list/list.handler.ts +++ b/apps/cli/src/legacy/commands/backups/list/list.handler.ts @@ -1,15 +1,115 @@ +import type { V1ListAllBackupsOutput } from "@supabase/api/effect"; import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; -interface LegacyBackupsListFlags { - readonly projectRef: Option.Option; +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 { Output } from "../../../../shared/output/output.service.ts"; +import { renderGlamourTable } from "../../../output/legacy-glamour-table.ts"; +import { + LegacyBackupListNetworkError, + LegacyBackupListUnexpectedStatusError, + mapLegacyBackupHttpError, +} from "../backups.errors.ts"; +import { encodeEnv, encodeGoJson, encodeToml, encodeYaml } from "../backups.encoders.ts"; +import { formatBackupTimestamp, formatRegion } from "../backups.format.ts"; +import type { LegacyBackupsListFlags } from "./list.command.ts"; + +type BackupsResponse = typeof V1ListAllBackupsOutput.Type; + +const mapListError = mapLegacyBackupHttpError({ + networkError: LegacyBackupListNetworkError, + statusError: LegacyBackupListUnexpectedStatusError, + networkMessage: (cause) => `failed to list physical backups: ${cause}`, + statusMessage: (status, body) => `unexpected list backup status ${status}: ${body}`, +}); + +const PITR_HEADERS = ["REGION", "WALG", "PITR", "EARLIEST TIMESTAMP", "LATEST TIMESTAMP"] as const; + +const LOGICAL_HEADERS = ["REGION", "BACKUP TYPE", "STATUS", "CREATED AT (UTC)"] as const; + +function renderPitrTable(response: BackupsResponse): string { + const region = formatRegion(response.region); + const earliest = response.physical_backup_data.earliest_physical_backup_date_unix ?? 0; + const latest = response.physical_backup_data.latest_physical_backup_date_unix ?? 0; + return renderGlamourTable(PITR_HEADERS, [ + [ + region, + response.walg_enabled ? "true" : "false", + response.pitr_enabled ? "true" : "false", + String(earliest), + String(latest), + ], + ]); +} + +function renderLogicalTable(response: BackupsResponse): string { + const region = formatRegion(response.region); + const rows = response.backups.map((backup) => [ + region, + backup.is_physical_backup ? "PHYSICAL" : "LOGICAL", + backup.status, + formatBackupTimestamp(backup.inserted_at), + ]); + return renderGlamourTable(LOGICAL_HEADERS, rows); } export const legacyBackupsList = Effect.fn("legacy.backups.list")(function* ( flags: LegacyBackupsListFlags, ) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["backups", "list"]; - if (Option.isSome(flags.projectRef)) args.push("--project-ref", flags.projectRef.value); - yield* proxy.exec(args); + const output = yield* Output; + const goOutputFlag = yield* LegacyOutputFlag; + const api = yield* LegacyPlatformApi; + const resolver = yield* LegacyProjectRefResolver; + const linkedProjectCache = yield* LegacyLinkedProjectCache; + const telemetryState = yield* LegacyTelemetryState; + + const ref = yield* resolver.resolve(flags.projectRef); + + // Mirror Go's PersistentPostRun (`apps/cli-go/cmd/root.go:176`): write the + // linked-project cache and persist the telemetry state file whether the main + // API call succeeds or fails. + yield* Effect.gen(function* () { + // The fetching spinner is only meaningful in human-facing text mode — in JSON / stream-json + // it would surface dangling `[task] start:` lines on stderr with no completion message. + const fetching = + output.format === "text" ? yield* output.task("Fetching backups...") : undefined; + const response = yield* api.v1.listAllBackups({ ref }).pipe( + Effect.tapError(() => fetching?.fail() ?? Effect.void), + Effect.catch(mapListError), + ); + yield* fetching?.clear() ?? Effect.void; + + const goFmt = Option.getOrUndefined(goOutputFlag); + + if (goFmt === "json") { + yield* output.raw(encodeGoJson(response)); + return; + } + if (goFmt === "yaml") { + yield* output.raw(encodeYaml(response)); + return; + } + if (goFmt === "toml") { + yield* output.raw(encodeToml(response) + "\n"); + return; + } + if (goFmt === "env") { + yield* output.raw(encodeEnv(response) + "\n"); + return; + } + + // goFmt is undefined or "pretty" — defer to TS --output-format for JSON/stream-json, + // otherwise render the Glamour-styled table (Go --output pretty parity). + if (output.format === "json" || output.format === "stream-json") { + yield* output.success("", response); + return; + } + + const table = + response.backups.length > 0 ? renderLogicalTable(response) : renderPitrTable(response); + yield* output.raw(table); + }).pipe(Effect.ensuring(linkedProjectCache.cache(ref)), Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/backups/list/list.integration.test.ts b/apps/cli/src/legacy/commands/backups/list/list.integration.test.ts new file mode 100644 index 000000000..5ba057835 --- /dev/null +++ b/apps/cli/src/legacy/commands/backups/list/list.integration.test.ts @@ -0,0 +1,499 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { type V1ListAllBackupsOutput, makeApiClient } from "@supabase/api/effect"; +import { describe, expect, it } from "@effect/vitest"; +import { BunServices } from "@effect/platform-bun"; +import { Effect, Exit, Layer, Option, Redacted } from "effect"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientError from "effect/unstable/http/HttpClientError"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; +import type * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import { afterEach, beforeEach } from "vitest"; + +import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; +import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; +import { legacyProjectRefLayer } from "../../../config/legacy-project-ref.layer.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 { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { mockOutput, mockTty } from "../../../../../tests/helpers/mocks.ts"; +import { mockProcessControl } from "../../../../../tests/helpers/mocks.ts"; +import { legacyBackupsList } from "./list.handler.ts"; + +const mockLinkedProjectCacheLayer = Layer.succeed(LegacyLinkedProjectCache, { + cache: () => Effect.void, +}); + +const mockTelemetryStateLayer = Layer.succeed(LegacyTelemetryState, { flush: Effect.void }); + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const VALID_REF = "abcdefghijklmnopqrst"; +const VALID_TOKEN = "sbp_" + "a".repeat(40); + +const PITR_RESPONSE: typeof V1ListAllBackupsOutput.Type = { + region: "ap-southeast-1", + walg_enabled: true, + pitr_enabled: true, + backups: [], + physical_backup_data: {}, +}; + +const LOGICAL_RESPONSE: typeof V1ListAllBackupsOutput.Type = { + region: "ap-southeast-1", + walg_enabled: true, + pitr_enabled: true, + backups: [ + { + id: 1, + is_physical_backup: true, + status: "COMPLETED", + inserted_at: "2026-02-08T16:44:07Z", + }, + ], + physical_backup_data: {}, +}; + +function jsonResponse(request: HttpClientRequest.HttpClientRequest, status: number, body: unknown) { + return HttpClientResponse.fromWeb( + request, + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }), + ); +} + +function httpClientLayer( + handler: ( + request: HttpClientRequest.HttpClientRequest, + ) => Effect.Effect, +) { + return Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => handler(request)), + ); +} + +function mockPlatformApi(opts: { + response?: typeof V1ListAllBackupsOutput.Type; + status?: number; + network?: "fail"; + apiUrl?: string; + userAgent?: string; +}) { + const requests: Array<{ + url: string; + method: string; + headers: Readonly>; + }> = []; + + const status = opts.status ?? 200; + const handler = ( + request: HttpClientRequest.HttpClientRequest, + ): Effect.Effect => { + requests.push({ url: request.url, method: request.method, headers: request.headers }); + if (opts.network === "fail") { + return Effect.fail( + new HttpClientError.HttpClientError({ + reason: new HttpClientError.TransportError({ + request, + description: "ECONNREFUSED", + }), + }), + ); + } + return Effect.succeed(jsonResponse(request, status, opts.response ?? PITR_RESPONSE)); + }; + + const layer = Layer.effect( + LegacyPlatformApi, + makeApiClient({ + baseUrl: opts.apiUrl ?? "https://api.supabase.com", + accessToken: VALID_TOKEN, + userAgent: opts.userAgent ?? "SupabaseCLI/0.0.0-dev", + }), + ).pipe(Layer.provide(httpClientLayer(handler))); + + return { layer, requests }; +} + +function mockCliConfig(opts: { workdir: string; apiUrl?: string; userAgent?: string }) { + return Layer.succeed(LegacyCliConfig, { + profile: "supabase", + apiUrl: opts.apiUrl ?? "https://api.supabase.com", + accessToken: Option.some(Redacted.make(VALID_TOKEN)), + projectId: Option.some(VALID_REF), + workdir: opts.workdir, + userAgent: opts.userAgent ?? "SupabaseCLI/0.0.0-dev", + }); +} + +interface SetupOpts { + format?: "text" | "json" | "stream-json"; + goOutput?: "env" | "pretty" | "json" | "toml" | "yaml"; + response?: typeof V1ListAllBackupsOutput.Type; + status?: number; + network?: "fail"; + stdinIsTty?: boolean; + apiUrl?: string; + userAgent?: string; +} + +let tempRoot: string; +let currentOut: ReturnType; + +function setup(opts: SetupOpts = {}) { + const out = mockOutput({ format: opts.format ?? "text" }); + currentOut = out; + const api = mockPlatformApi({ + response: opts.response, + status: opts.status, + network: opts.network, + apiUrl: opts.apiUrl, + userAgent: opts.userAgent, + }); + const cliConfig = mockCliConfig({ + workdir: tempRoot, + apiUrl: opts.apiUrl, + userAgent: opts.userAgent, + }); + const processCtl = mockProcessControl(); + const goOutputValue = opts.goOutput === undefined ? Option.none() : Option.some(opts.goOutput); + const layer = Layer.mergeAll( + out.layer, + api.layer, + cliConfig, + mockTty({ stdinIsTty: opts.stdinIsTty ?? false, stdoutIsTty: false }), + processCtl.layer, + legacyProjectRefLayer.pipe( + Layer.provide(api.layer), + Layer.provide(cliConfig), + Layer.provide(mockTty({ stdinIsTty: opts.stdinIsTty ?? false, stdoutIsTty: false })), + Layer.provide(out.layer), + Layer.provide(BunServices.layer), + ), + BunServices.layer, + Layer.succeed(LegacyOutputFlag, goOutputValue), + mockLinkedProjectCacheLayer, + mockTelemetryStateLayer, + ); + return { layer, out, api, processCtl, tempRoot }; +} + +const stdoutText = () => currentOut.stdoutText; + +beforeEach(() => { + tempRoot = mkdtempSync(join(tmpdir(), "supabase-backups-list-int-")); +}); + +afterEach(() => { + rmSync(tempRoot, { recursive: true, force: true }); +}); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("legacy backups list integration", () => { + it.live("renders a PITR-only table when no physical backups exist", () => { + const { layer } = setup({ response: PITR_RESPONSE }); + return Effect.gen(function* () { + yield* legacyBackupsList({ projectRef: Option.none() }); + const out = stdoutText(); + expect(out).toContain("REGION"); + expect(out).toContain("WALG"); + expect(out).toContain("PITR"); + expect(out).toContain("EARLIEST TIMESTAMP"); + expect(out).toContain("LATEST TIMESTAMP"); + expect(out).toContain("Southeast Asia (Singapore)"); + expect(out).toContain("| true "); + }).pipe(Effect.provide(layer)); + }); + + it.live("renders a logical backups table with PHYSICAL classification", () => { + const { layer } = setup({ response: LOGICAL_RESPONSE }); + return Effect.gen(function* () { + yield* legacyBackupsList({ projectRef: Option.none() }); + const out = stdoutText(); + expect(out).toContain("BACKUP TYPE"); + expect(out).toContain("PHYSICAL"); + expect(out).toContain("COMPLETED"); + expect(out).toContain("2026-02-08 16:44:07"); + }).pipe(Effect.provide(layer)); + }); + + it.live("translates ap-southeast-1 to Southeast Asia (Singapore)", () => { + const { layer } = setup({ response: PITR_RESPONSE }); + return Effect.gen(function* () { + yield* legacyBackupsList({ projectRef: Option.none() }); + expect(stdoutText()).toContain("Southeast Asia (Singapore)"); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a JSON success event when --output-format=json", () => { + const { layer, out } = setup({ format: "json", response: PITR_RESPONSE }); + return Effect.gen(function* () { + yield* legacyBackupsList({ projectRef: Option.none() }); + const success = out.messages.find((m) => m.type === "success"); + expect(success).toBeDefined(); + expect(success?.data).toMatchObject({ region: "ap-southeast-1", walg_enabled: true }); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a result event for --output-format=stream-json", () => { + const { layer, out } = setup({ format: "stream-json", response: PITR_RESPONSE }); + return Effect.gen(function* () { + yield* legacyBackupsList({ projectRef: Option.none() }); + const success = out.messages.find((m) => m.type === "success"); + expect(success).toBeDefined(); + expect(success?.data).toMatchObject({ region: "ap-southeast-1" }); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits indented JSON to stdout for --output json (Go-compat)", () => { + const { layer } = setup({ goOutput: "json", response: PITR_RESPONSE }); + return Effect.gen(function* () { + yield* legacyBackupsList({ projectRef: Option.none() }); + // Byte-identical to Go's `encoding/json` output: alphabetical struct-field order, + // and a nil Backups slice serializes as `null` (matches + // `apps/cli-go/internal/backups/list/list_test.go` fixture). + expect(stdoutText()).toBe( + `{ + "backups": null, + "physical_backup_data": {}, + "pitr_enabled": true, + "region": "ap-southeast-1", + "walg_enabled": true +} +`, + ); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits YAML to stdout for --output yaml", () => { + const { layer } = setup({ goOutput: "yaml", response: PITR_RESPONSE }); + return Effect.gen(function* () { + yield* legacyBackupsList({ projectRef: Option.none() }); + const out = stdoutText(); + expect(out).toContain("region: ap-southeast-1"); + expect(out).toContain("walg_enabled: true"); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits TOML to stdout for --output toml", () => { + const { layer } = setup({ goOutput: "toml", response: PITR_RESPONSE }); + return Effect.gen(function* () { + yield* legacyBackupsList({ projectRef: Option.none() }); + const out = stdoutText(); + expect(out).toContain('region = "ap-southeast-1"'); + expect(out).toContain("walg_enabled = true"); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits KEY=VALUE lines for --output env", () => { + const { layer } = setup({ goOutput: "env", response: PITR_RESPONSE }); + return Effect.gen(function* () { + yield* legacyBackupsList({ projectRef: Option.none() }); + const out = stdoutText(); + expect(out).toContain('REGION="ap-southeast-1"'); + expect(out).toContain('WALG_ENABLED="true"'); + }).pipe(Effect.provide(layer)); + }); + + it.live("treats --output pretty as identical to text mode (Glamour table)", () => { + const { layer } = setup({ goOutput: "pretty", response: PITR_RESPONSE }); + return Effect.gen(function* () { + yield* legacyBackupsList({ projectRef: Option.none() }); + expect(stdoutText()).toContain("Southeast Asia (Singapore)"); + }).pipe(Effect.provide(layer)); + }); + + it.live("--output flag value wins over --output-format when both provided", () => { + const { layer } = setup({ + format: "json", + goOutput: "yaml", + response: PITR_RESPONSE, + }); + return Effect.gen(function* () { + yield* legacyBackupsList({ projectRef: Option.none() }); + const out = stdoutText(); + expect(out).toContain("region: ap-southeast-1"); + // YAML-shape rather than indented JSON. + expect(out.startsWith("{")).toBe(false); + }).pipe(Effect.provide(layer)); + }); + + it.live("passes the resolved project ref into the listAllBackups URL", () => { + const { layer, api } = setup({ response: PITR_RESPONSE }); + return Effect.gen(function* () { + yield* legacyBackupsList({ projectRef: Option.none() }); + expect(api.requests).toHaveLength(1); + expect(api.requests[0]?.url).toContain(`/v1/projects/${VALID_REF}/database/backups`); + }).pipe(Effect.provide(layer)); + }); + + it.live("uses --project-ref flag value over LegacyCliConfig.projectId env", () => { + const flagRef = "zzzzzzzzzzzzzzzzzzzz"; + const { layer, api } = setup({ response: PITR_RESPONSE }); + return Effect.gen(function* () { + yield* legacyBackupsList({ projectRef: Option.some(flagRef) }); + expect(api.requests[0]?.url).toContain(`/v1/projects/${flagRef}/`); + }).pipe(Effect.provide(layer)); + }); + + it.live("reads supabase/.temp/project-ref when env and flag are unset", () => { + const tempRoot = mkdtempSync(join(tmpdir(), "supabase-backups-list-int-fileref-")); + const fileRef = "filerefabcdefghijklm"; + mkdirSync(join(tempRoot, "supabase", ".temp"), { recursive: true }); + writeFileSync(join(tempRoot, "supabase", ".temp", "project-ref"), fileRef); + + const out = mockOutput({ format: "text" }); + const api = mockPlatformApi({ response: PITR_RESPONSE }); + const cliConfig = Layer.succeed(LegacyCliConfig, { + profile: "supabase", + apiUrl: "https://api.supabase.com", + accessToken: Option.some(Redacted.make(VALID_TOKEN)), + projectId: Option.none(), + workdir: tempRoot, + userAgent: "SupabaseCLI/0.0.0-dev", + }); + const processCtl = mockProcessControl(); + const layer = Layer.mergeAll( + out.layer, + api.layer, + cliConfig, + mockTty({ stdinIsTty: false, stdoutIsTty: false }), + processCtl.layer, + legacyProjectRefLayer.pipe( + Layer.provide(api.layer), + Layer.provide(cliConfig), + Layer.provide(mockTty({ stdinIsTty: false, stdoutIsTty: false })), + Layer.provide(out.layer), + Layer.provide(BunServices.layer), + ), + BunServices.layer, + Layer.succeed(LegacyOutputFlag, Option.none()), + mockLinkedProjectCacheLayer, + mockTelemetryStateLayer, + ); + + return Effect.gen(function* () { + yield* legacyBackupsList({ projectRef: Option.none() }); + expect(api.requests[0]?.url).toContain(`/v1/projects/${fileRef}/`); + }).pipe( + Effect.provide(layer), + Effect.ensuring(Effect.sync(() => rmSync(tempRoot, { recursive: true, force: true }))), + ); + }); + + it.live("fails with LegacyProjectNotLinkedError when no ref source matches off-TTY", () => { + const tempRoot = mkdtempSync(join(tmpdir(), "supabase-backups-list-int-no-ref-")); + const out = mockOutput({ format: "text" }); + const api = mockPlatformApi({ response: PITR_RESPONSE }); + const cliConfig = Layer.succeed(LegacyCliConfig, { + profile: "supabase", + apiUrl: "https://api.supabase.com", + accessToken: Option.some(Redacted.make(VALID_TOKEN)), + projectId: Option.none(), + workdir: tempRoot, + userAgent: "SupabaseCLI/0.0.0-dev", + }); + const processCtl = mockProcessControl(); + const layer = Layer.mergeAll( + out.layer, + api.layer, + cliConfig, + mockTty({ stdinIsTty: false, stdoutIsTty: false }), + processCtl.layer, + legacyProjectRefLayer.pipe( + Layer.provide(api.layer), + Layer.provide(cliConfig), + Layer.provide(mockTty({ stdinIsTty: false, stdoutIsTty: false })), + Layer.provide(out.layer), + Layer.provide(BunServices.layer), + ), + BunServices.layer, + Layer.succeed(LegacyOutputFlag, Option.none()), + mockLinkedProjectCacheLayer, + mockTelemetryStateLayer, + ); + + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacyBackupsList({ projectRef: Option.none() }).pipe(Effect.provide(layer)), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyProjectNotLinkedError"); + } + }).pipe(Effect.ensuring(Effect.sync(() => rmSync(tempRoot, { recursive: true, force: true })))); + }); + + it.live("fails with LegacyInvalidProjectRefError when the resolved ref is malformed", () => { + const { layer } = setup({ response: PITR_RESPONSE }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyBackupsList({ projectRef: Option.some("BADREF") })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyInvalidProjectRefError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with LegacyBackupListUnexpectedStatusError on HTTP 503", () => { + const { layer } = setup({ status: 503, response: PITR_RESPONSE }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyBackupsList({ projectRef: Option.none() })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const errorJson = JSON.stringify(exit.cause); + expect(errorJson).toContain("LegacyBackupListUnexpectedStatusError"); + expect(errorJson).toContain("unexpected list backup status 503"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with LegacyBackupListNetworkError on transport failure", () => { + const { layer } = setup({ network: "fail", response: PITR_RESPONSE }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyBackupsList({ projectRef: Option.none() })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const errorJson = JSON.stringify(exit.cause); + expect(errorJson).toContain("LegacyBackupListNetworkError"); + expect(errorJson).toContain("failed to list physical backups"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a fail event when withJsonErrorHandling wraps a JSON-mode error", () => { + const { layer, out } = setup({ format: "json", status: 503, response: PITR_RESPONSE }); + return Effect.gen(function* () { + yield* legacyBackupsList({ projectRef: Option.none() }).pipe(withJsonErrorHandling); + expect(out.messages.some((m) => m.type === "fail")).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live( + "sends User-Agent SupabaseCLI/ and no X-Supabase-Command headers (Go parity)", + () => { + const { layer, api } = setup({ + response: PITR_RESPONSE, + userAgent: "SupabaseCLI/1.42.0", + }); + return Effect.gen(function* () { + yield* legacyBackupsList({ projectRef: Option.none() }); + const headers = api.requests[0]?.headers; + expect(headers?.["user-agent"]).toBe("SupabaseCLI/1.42.0"); + expect(headers?.["x-supabase-command"]).toBeUndefined(); + expect(headers?.["x-supabase-command-run-id"]).toBeUndefined(); + }).pipe(Effect.provide(layer)); + }, + ); +}); diff --git a/apps/cli/src/legacy/commands/backups/restore/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/backups/restore/SIDE_EFFECTS.md index 07bc78a5d..60f93b970 100644 --- a/apps/cli/src/legacy/commands/backups/restore/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/backups/restore/SIDE_EFFECTS.md @@ -2,9 +2,13 @@ ## Files Read -| Path | Format | When | -| -------------------------- | ------------------------- | ---------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| Path | Format | When | +| ----------------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------- | +| `/proc/sys/kernel/osrelease` (Linux) | plain text | once on layer init — disables keyring on WSL (`WSL` / `Microsoft` substring match) | +| keyring `"Supabase CLI"` / `` | OS keychain | when `SUPABASE_ACCESS_TOKEN` unset and keyring available; account = `LegacyCliConfig.profile` | +| keyring `"Supabase CLI"` / `access-token` | OS keychain | legacy-key fallback when the profile-keyed lookup misses | +| `~/.supabase/access-token` | plain text (token string) | last-resort fallback after env + keyring miss | +| `/supabase/.temp/project-ref` | plain text | when `--project-ref` and `SUPABASE_PROJECT_ID` are both unset | ## Files Written @@ -14,43 +18,57 @@ ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | -------------------------------------------------- | ------------ | ------------------------------------ | ---------------------- | -| `POST` | `/v1/projects/{ref}/database/backups/restore-pitr` | Bearer token | `{recovery_time_target_unix: int64}` | none (201 Created) | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | -------------------------------------------------- | ------------ | ------------------------------------ | ------------------------------------------------------------------------------ | +| `POST` | `/v1/projects/{ref}/database/backups/restore-pitr` | Bearer token | `{recovery_time_target_unix: int64}` | none (201 Created) | +| `GET` | `/v1/projects` | Bearer token | none | `[{id, ref, name, organization_slug, region, ...}]` — TTY-prompt fallback only | ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| Variable | Purpose | Required? | +| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | selects API base URL: `supabase` → `api.supabase.com`, `supabase-staging` → `api.supabase.green`, `supabase-local` → `http://localhost:8080`. May alternatively be a filesystem path to a YAML profile with at least `api_url:` and optional `name:` (Go parity — used by the cli-e2e test harness). | no (defaults to `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (also reads `/supabase/.temp/project-ref` then prompts on TTY) | +| `SUPABASE_WORKDIR` | base directory for the `.temp/project-ref` lookup | no (walks up from CWD looking for `supabase/config.toml`) | +| ~~`SUPABASE_API_URL`~~ | **not honored** — Go parity. Use `SUPABASE_PROFILE` to override the API base URL. | — | ## Exit Codes -| Code | Condition | -| ---- | ----------------------------------------------------------- | -| `0` | success — restore initiated | -| `1` | authentication error — no valid token found | -| `1` | missing `--project-ref` and no linked project | -| `1` | API error — non-2xx response from the restore-pitr endpoint | -| `1` | network / connection failure | +| Code | Condition | +| ---- | ------------------------------------------------------------------------------------------ | +| `0` | success — restore initiated | +| `1` | `LegacyPlatformAuthRequiredError` — no token in env/keyring/file | +| `1` | `LegacyInvalidAccessTokenError` — token violates `^sbp_(oauth_)?[a-f0-9]{40}$` | +| `1` | `LegacyProjectNotLinkedError` — `--project-ref` unset, env/file empty, and stdin not a TTY | +| `1` | `LegacyInvalidProjectRefError` — resolved ref violates `^[a-z]{20}$` | +| `1` | `LegacyBackupRestoreUnexpectedStatusError` — non-201 response from the restore endpoint | +| `1` | `LegacyBackupRestoreNetworkError` — transport-level network failure | ## Output -### `--output-format text` (Go CLI compatible) +Go's `restore` command ignores `--output` entirely (`apps/cli-go/internal/backups/restore/restore.go:22`) and always writes the success line to **stderr**. The legacy port mirrors that for every Go `--output` value. The `--output-format` (TS-only) JSON modes get a structured payload — non-breaking because Go has no JSON for restore. -Prints a confirmation message on success. No table output. +### `--output pretty|yaml|toml|env` (Go-compat) / `--output-format text` + +Writes `"Started PITR restore: \n"` to **stderr** (byte-identical to Go). + +### `--output json` (Go-compat) + +Indented JSON object on stdout: `{ "message": "Started PITR restore", "project_ref": "" }`. ### `--output-format json` -No structured JSON output (command is action-only). +Single JSON success event via `Output.success("Started PITR restore", { project_ref })`. ### `--output-format stream-json` -One `result` event on success. +One `result` NDJSON event on success with the project ref payload. ## Notes -- `--timestamp` / `-t` accepts seconds since Unix epoch (int64). Defaults to `0`. -- Requires `--project-ref` or a linked project. -- Phase 0 proxy: all invocations are forwarded to the bundled Go binary via `LegacyGoProxy`. +- `--timestamp` / `-t` accepts seconds since Unix epoch (int64). Defaults to `0`, which the API interprets as "now". +- Known Go-parity gap: the generated `V1RestorePitrBackupInput` schema enforces `recovery_time_target_unix >= 0`. Go's `int64` has no lower bound, so a negative value is rejected locally with a schema decode error instead of being forwarded to the API. Resolving this requires an upstream OpenAPI spec change. +- Requires `--project-ref`, `SUPABASE_PROJECT_ID`, a populated `/supabase/.temp/project-ref` file, or a TTY for the interactive project picker. +- The interactive picker calls `GET /v1/projects` and writes `"Selected project: "` to stderr in text mode (matches Go `project_ref.go:50`). It does **not** persist the choice; only `supabase link` and `supabase bootstrap` write the temp file. +- Sends `User-Agent: SupabaseCLI/` and Bearer auth. No `X-Supabase-Command` headers — Go parity. diff --git a/apps/cli/src/legacy/commands/backups/restore/restore.command.ts b/apps/cli/src/legacy/commands/backups/restore/restore.command.ts index 99a5cda4e..445931730 100644 --- a/apps/cli/src/legacy/commands/backups/restore/restore.command.ts +++ b/apps/cli/src/legacy/commands/backups/restore/restore.command.ts @@ -1,4 +1,9 @@ +import type * as CliCommand from "effect/unstable/cli/Command"; import { Command, Flag } from "effect/unstable/cli"; + +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { withCommandInstrumentation } from "../../../../shared/telemetry/command-instrumentation.ts"; +import { legacyBackupsRuntimeLayer } from "../backups.layers.ts"; import { legacyBackupsRestore } from "./restore.handler.ts"; const config = { @@ -11,10 +16,12 @@ const config = { Flag.withDescription("The recovery time target in seconds since epoch."), Flag.optional, ), -}; +} as const; + +export type LegacyBackupsRestoreFlags = CliCommand.Command.Config.Infer; export const legacyBackupsRestoreCommand = Command.make("restore", config).pipe( - Command.withDescription("Restore to a specific timestamp using Point-in-Time Recovery (PITR)."), + Command.withDescription("Restore to a specific timestamp using PITR"), Command.withShortDescription("Restore to a specific timestamp using PITR"), Command.withExamples([ { @@ -22,5 +29,8 @@ export const legacyBackupsRestoreCommand = Command.make("restore", config).pipe( description: "Restore to the given Unix epoch timestamp", }, ]), - Command.withHandler((flags) => legacyBackupsRestore(flags)), + Command.withHandler((flags) => + legacyBackupsRestore(flags).pipe(withCommandInstrumentation(), withJsonErrorHandling), + ), + Command.provide(legacyBackupsRuntimeLayer(["backups", "restore"])), ); diff --git a/apps/cli/src/legacy/commands/backups/restore/restore.handler.ts b/apps/cli/src/legacy/commands/backups/restore/restore.handler.ts index 7dbf6a3e1..7ca658e72 100644 --- a/apps/cli/src/legacy/commands/backups/restore/restore.handler.ts +++ b/apps/cli/src/legacy/commands/backups/restore/restore.handler.ts @@ -1,17 +1,70 @@ import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; -interface LegacyBackupsRestoreFlags { - readonly projectRef: Option.Option; - readonly timestamp: Option.Option; -} +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 { Output } from "../../../../shared/output/output.service.ts"; +import { + LegacyBackupRestoreNetworkError, + LegacyBackupRestoreUnexpectedStatusError, + mapLegacyBackupHttpError, +} from "../backups.errors.ts"; +import type { LegacyBackupsRestoreFlags } from "./restore.command.ts"; + +const mapRestoreError = mapLegacyBackupHttpError({ + networkError: LegacyBackupRestoreNetworkError, + statusError: LegacyBackupRestoreUnexpectedStatusError, + networkMessage: (cause) => `failed to restore backup: ${cause}`, + statusMessage: (status, body) => `unexpected restore backup status ${status}: ${body}`, +}); export const legacyBackupsRestore = Effect.fn("legacy.backups.restore")(function* ( flags: LegacyBackupsRestoreFlags, ) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["backups", "restore"]; - if (Option.isSome(flags.projectRef)) args.push("--project-ref", flags.projectRef.value); - if (Option.isSome(flags.timestamp)) args.push("--timestamp", String(flags.timestamp.value)); - yield* proxy.exec(args); + const output = yield* Output; + const goOutputFlag = yield* LegacyOutputFlag; + const api = yield* LegacyPlatformApi; + const resolver = yield* LegacyProjectRefResolver; + const linkedProjectCache = yield* LegacyLinkedProjectCache; + const telemetryState = yield* LegacyTelemetryState; + + const ref = yield* resolver.resolve(flags.projectRef); + const recoveryTimeTargetUnix = Option.getOrElse(flags.timestamp, () => 0); + + // Mirror Go's PersistentPostRun — cache + telemetry flush whether the main + // call succeeds or fails. + yield* Effect.gen(function* () { + // Spinner only in human-facing text mode — see list.handler.ts. + const restoring = + output.format === "text" ? yield* output.task("Initiating PITR restore...") : undefined; + yield* api.v1 + .restorePitrBackup({ ref, recovery_time_target_unix: recoveryTimeTargetUnix }) + .pipe( + Effect.tapError(() => restoring?.fail() ?? Effect.void), + Effect.catch(mapRestoreError), + ); + yield* restoring?.clear() ?? Effect.void; + + const goFmt = Option.getOrUndefined(goOutputFlag); + + // Go ignores --output entirely (restore.go:22) and always writes the text line to stderr. + // We mirror that for every Go --output value except `json`, where we provide a TS-only + // structured payload (Go has no JSON for restore — adding one is non-breaking). + if (goFmt === "json") { + yield* output.raw( + JSON.stringify({ message: "Started PITR restore", project_ref: ref }, null, 2) + "\n", + ); + return; + } + + if (goFmt === undefined && (output.format === "json" || output.format === "stream-json")) { + yield* output.success("Started PITR restore", { project_ref: ref }); + return; + } + + // pretty/yaml/toml/env (Go-compat) + TS text mode → byte-identical text line on stderr. + yield* output.raw(`Started PITR restore: ${ref}\n`, "stderr"); + }).pipe(Effect.ensuring(linkedProjectCache.cache(ref)), Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/backups/restore/restore.integration.test.ts b/apps/cli/src/legacy/commands/backups/restore/restore.integration.test.ts new file mode 100644 index 000000000..2a1df38f6 --- /dev/null +++ b/apps/cli/src/legacy/commands/backups/restore/restore.integration.test.ts @@ -0,0 +1,418 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { makeApiClient } from "@supabase/api/effect"; +import { describe, expect, it } from "@effect/vitest"; +import { BunServices } from "@effect/platform-bun"; +import { Effect, Exit, Layer, Option, Redacted } from "effect"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientError from "effect/unstable/http/HttpClientError"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; +import type * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import { afterEach, beforeEach } from "vitest"; + +import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; +import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; +import { legacyProjectRefLayer } from "../../../config/legacy-project-ref.layer.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 { mockOutput, mockProcessControl, mockTty } from "../../../../../tests/helpers/mocks.ts"; +import { legacyBackupsRestore } from "./restore.handler.ts"; + +const mockLinkedProjectCacheLayer = Layer.succeed(LegacyLinkedProjectCache, { + cache: () => Effect.void, +}); + +const mockTelemetryStateLayer = Layer.succeed(LegacyTelemetryState, { flush: Effect.void }); + +const VALID_REF = "abcdefghijklmnopqrst"; +const VALID_TOKEN = "sbp_" + "a".repeat(40); + +function httpClientLayer( + handler: ( + request: HttpClientRequest.HttpClientRequest, + ) => Effect.Effect, +) { + return Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => handler(request)), + ); +} + +function mockPlatformApi(opts: { status?: number; network?: "fail" }) { + const requests: Array<{ + url: string; + method: string; + body?: unknown; + }> = []; + const handler = (request: HttpClientRequest.HttpClientRequest) => + Effect.gen(function* () { + let body: unknown = undefined; + if (request.body._tag === "Uint8Array") { + const decoded = new TextDecoder().decode(request.body.body); + try { + body = JSON.parse(decoded); + } catch { + body = decoded; + } + } + requests.push({ url: request.url, method: request.method, body }); + if (opts.network === "fail") { + return yield* Effect.fail( + new HttpClientError.HttpClientError({ + reason: new HttpClientError.TransportError({ + request, + description: "ECONNREFUSED", + }), + }), + ); + } + return HttpClientResponse.fromWeb( + request, + new Response(null, { status: opts.status ?? 201 }), + ); + }); + + const layer = Layer.effect( + LegacyPlatformApi, + makeApiClient({ + baseUrl: "https://api.supabase.com", + accessToken: VALID_TOKEN, + userAgent: "SupabaseCLI/0.0.0-dev", + }), + ).pipe(Layer.provide(httpClientLayer(handler))); + + return { layer, requests }; +} + +function mockCliConfig(workdir: string) { + return Layer.succeed(LegacyCliConfig, { + profile: "supabase", + apiUrl: "https://api.supabase.com", + accessToken: Option.some(Redacted.make(VALID_TOKEN)), + projectId: Option.some(VALID_REF), + workdir, + userAgent: "SupabaseCLI/0.0.0-dev", + }); +} + +interface SetupOpts { + format?: "text" | "json" | "stream-json"; + goOutput?: "env" | "pretty" | "json" | "toml" | "yaml"; + status?: number; + network?: "fail"; + stdinIsTty?: boolean; +} + +let tempRoot: string; +let currentOut: ReturnType; + +function setup(opts: SetupOpts = {}) { + const out = mockOutput({ format: opts.format ?? "text" }); + currentOut = out; + const api = mockPlatformApi({ status: opts.status, network: opts.network }); + const cliConfig = mockCliConfig(tempRoot); + const processCtl = mockProcessControl(); + const goOutputValue = opts.goOutput === undefined ? Option.none() : Option.some(opts.goOutput); + const layer = Layer.mergeAll( + out.layer, + api.layer, + cliConfig, + mockTty({ stdinIsTty: opts.stdinIsTty ?? false, stdoutIsTty: false }), + processCtl.layer, + legacyProjectRefLayer.pipe( + Layer.provide(api.layer), + Layer.provide(cliConfig), + Layer.provide(mockTty({ stdinIsTty: opts.stdinIsTty ?? false, stdoutIsTty: false })), + Layer.provide(out.layer), + Layer.provide(BunServices.layer), + ), + BunServices.layer, + Layer.succeed(LegacyOutputFlag, goOutputValue), + mockLinkedProjectCacheLayer, + mockTelemetryStateLayer, + ); + return { layer, out, api, tempRoot }; +} + +const stdoutText = () => currentOut.stdoutText; +const stderrText = () => currentOut.stderrText; + +beforeEach(() => { + tempRoot = mkdtempSync(join(tmpdir(), "supabase-backups-restore-int-")); +}); + +afterEach(() => { + rmSync(tempRoot, { recursive: true, force: true }); +}); + +describe("legacy backups restore integration", () => { + it.live("sends recovery_time_target_unix=0 when --timestamp is omitted", () => { + const { layer, api } = setup(); + return Effect.gen(function* () { + yield* legacyBackupsRestore({ + projectRef: Option.none(), + timestamp: Option.none(), + }); + expect(api.requests).toHaveLength(1); + expect(api.requests[0]?.body).toEqual({ recovery_time_target_unix: 0 }); + }).pipe(Effect.provide(layer)); + }); + + it.live("sends the supplied timestamp when --timestamp is provided", () => { + const { layer, api } = setup(); + return Effect.gen(function* () { + yield* legacyBackupsRestore({ + projectRef: Option.none(), + timestamp: Option.some(1_707_407_047), + }); + expect(api.requests[0]?.body).toEqual({ recovery_time_target_unix: 1_707_407_047 }); + }).pipe(Effect.provide(layer)); + }); + + it.live("writes 'Started PITR restore: \\n' to stderr in text mode (Go parity)", () => { + const { layer } = setup(); + return Effect.gen(function* () { + yield* legacyBackupsRestore({ + projectRef: Option.none(), + timestamp: Option.none(), + }); + expect(stderrText()).toBe(`Started PITR restore: ${VALID_REF}\n`); + expect(stdoutText()).toBe(""); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a JSON success event for --output-format=json", () => { + const { layer, out } = setup({ format: "json" }); + return Effect.gen(function* () { + yield* legacyBackupsRestore({ + projectRef: Option.none(), + timestamp: Option.none(), + }); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.message).toBe("Started PITR restore"); + expect(success?.data).toEqual({ project_ref: VALID_REF }); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a result event for --output-format=stream-json", () => { + const { layer, out } = setup({ format: "stream-json" }); + return Effect.gen(function* () { + yield* legacyBackupsRestore({ + projectRef: Option.none(), + timestamp: Option.none(), + }); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.data).toEqual({ project_ref: VALID_REF }); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits indented JSON to stdout for --output json (Go-compat)", () => { + const { layer } = setup({ goOutput: "json" }); + return Effect.gen(function* () { + yield* legacyBackupsRestore({ + projectRef: Option.none(), + timestamp: Option.none(), + }); + const out = stdoutText(); + expect(out).toContain('"message": "Started PITR restore"'); + expect(out).toContain(`"project_ref": "${VALID_REF}"`); + }).pipe(Effect.provide(layer)); + }); + + it.live( + "renders the stderr text line for --output {pretty,yaml,toml,env} (Go ignores --output)", + () => { + const { layer } = setup({ goOutput: "yaml" }); + return Effect.gen(function* () { + yield* legacyBackupsRestore({ + projectRef: Option.none(), + timestamp: Option.none(), + }); + expect(stderrText()).toBe(`Started PITR restore: ${VALID_REF}\n`); + expect(stdoutText()).toBe(""); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("uses --project-ref flag over LegacyCliConfig.projectId", () => { + const flagRef = "zzzzzzzzzzzzzzzzzzzz"; + const { layer, api } = setup(); + return Effect.gen(function* () { + yield* legacyBackupsRestore({ + projectRef: Option.some(flagRef), + timestamp: Option.none(), + }); + expect(api.requests[0]?.url).toContain(`/v1/projects/${flagRef}/`); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with LegacyBackupRestoreUnexpectedStatusError on HTTP 503", () => { + const { layer } = setup({ status: 503 }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacyBackupsRestore({ projectRef: Option.none(), timestamp: Option.none() }), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const errorJson = JSON.stringify(exit.cause); + expect(errorJson).toContain("LegacyBackupRestoreUnexpectedStatusError"); + expect(errorJson).toContain("unexpected restore backup status 503"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with LegacyBackupRestoreNetworkError on transport failure", () => { + const { layer } = setup({ network: "fail" }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacyBackupsRestore({ projectRef: Option.none(), timestamp: Option.none() }), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const errorJson = JSON.stringify(exit.cause); + expect(errorJson).toContain("LegacyBackupRestoreNetworkError"); + expect(errorJson).toContain("failed to restore backup"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with LegacyProjectNotLinkedError non-interactively when no ref source", () => { + const tempRoot = mkdtempSync(join(tmpdir(), "supabase-backups-restore-int-noref-")); + const out = mockOutput({ format: "text" }); + const api = mockPlatformApi({}); + const cliConfig = Layer.succeed(LegacyCliConfig, { + profile: "supabase", + apiUrl: "https://api.supabase.com", + accessToken: Option.some(Redacted.make(VALID_TOKEN)), + projectId: Option.none(), + workdir: tempRoot, + userAgent: "SupabaseCLI/0.0.0-dev", + }); + const processCtl = mockProcessControl(); + const layer = Layer.mergeAll( + out.layer, + api.layer, + cliConfig, + mockTty({ stdinIsTty: false, stdoutIsTty: false }), + processCtl.layer, + legacyProjectRefLayer.pipe( + Layer.provide(api.layer), + Layer.provide(cliConfig), + Layer.provide(mockTty({ stdinIsTty: false, stdoutIsTty: false })), + Layer.provide(out.layer), + Layer.provide(BunServices.layer), + ), + BunServices.layer, + Layer.succeed(LegacyOutputFlag, Option.none()), + mockLinkedProjectCacheLayer, + mockTelemetryStateLayer, + ); + + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacyBackupsRestore({ projectRef: Option.none(), timestamp: Option.none() }).pipe( + Effect.provide(layer), + ), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyProjectNotLinkedError"); + } + }).pipe(Effect.ensuring(Effect.sync(() => rmSync(tempRoot, { recursive: true, force: true })))); + }); + + it.live("prompts via TTY when no ref source matches and stdin is a TTY", () => { + const tempRoot = mkdtempSync(join(tmpdir(), "supabase-backups-restore-int-prompt-")); + const out = mockOutput({ + format: "text", + promptSelectResponses: [VALID_REF], + }); + const handler = (request: HttpClientRequest.HttpClientRequest) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response( + JSON.stringify([ + { + id: VALID_REF, + ref: VALID_REF, + organization_id: "org_123", + organization_slug: "acme", + name: "alpha", + region: "us-east-1", + created_at: "2026-01-01T00:00:00Z", + status: "ACTIVE_HEALTHY", + database: { + host: "db.example.com", + version: "15.0", + postgres_engine: "supabase-postgres", + release_channel: "ga", + }, + }, + ]), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ), + ); + const api = Layer.effect( + LegacyPlatformApi, + makeApiClient({ + baseUrl: "https://api.supabase.com", + accessToken: VALID_TOKEN, + userAgent: "SupabaseCLI/0.0.0-dev", + }), + ).pipe(Layer.provide(httpClientLayer(handler))); + + const cliConfig = Layer.succeed(LegacyCliConfig, { + profile: "supabase", + apiUrl: "https://api.supabase.com", + accessToken: Option.some(Redacted.make(VALID_TOKEN)), + projectId: Option.none(), + workdir: tempRoot, + userAgent: "SupabaseCLI/0.0.0-dev", + }); + const processCtl = mockProcessControl(); + const layer = Layer.mergeAll( + out.layer, + api, + cliConfig, + mockTty({ stdinIsTty: true, stdoutIsTty: true }), + processCtl.layer, + legacyProjectRefLayer.pipe( + Layer.provide(api), + Layer.provide(cliConfig), + Layer.provide(mockTty({ stdinIsTty: true, stdoutIsTty: true })), + Layer.provide(out.layer), + Layer.provide(BunServices.layer), + ), + BunServices.layer, + Layer.succeed(LegacyOutputFlag, Option.none()), + mockLinkedProjectCacheLayer, + mockTelemetryStateLayer, + ); + + return Effect.gen(function* () { + yield* legacyBackupsRestore({ projectRef: Option.none(), timestamp: Option.none() }).pipe( + Effect.provide(layer), + ); + expect(out.promptSelectCalls).toHaveLength(1); + expect(out.stderrText).toContain(`Started PITR restore: ${VALID_REF}\n`); + }).pipe(Effect.ensuring(Effect.sync(() => rmSync(tempRoot, { recursive: true, force: true })))); + }); + + it.live("accepts --timestamp short alias -t in the same way (no separate parse path)", () => { + // The flag layer is responsible for parsing -t into `timestamp`; once parsed, + // the handler does not differentiate, so we just verify the handler honors the value. + const { layer, api } = setup(); + return Effect.gen(function* () { + yield* legacyBackupsRestore({ + projectRef: Option.none(), + timestamp: Option.some(42), + }); + expect(api.requests[0]?.body).toEqual({ recovery_time_target_unix: 42 }); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/cli/src/legacy/config/legacy-cli-config.layer.ts b/apps/cli/src/legacy/config/legacy-cli-config.layer.ts new file mode 100644 index 000000000..3046a1367 --- /dev/null +++ b/apps/cli/src/legacy/config/legacy-cli-config.layer.ts @@ -0,0 +1,154 @@ +import { Effect, FileSystem, Layer, Option, Path, Redacted } from "effect"; +import { parse as parseYaml } from "yaml"; +import { CLI_VERSION } from "../../shared/cli/version.ts"; +import { LegacyProfileFlag, LegacyWorkdirFlag } from "../../shared/legacy/global-flags.ts"; +import { RuntimeInfo } from "../../shared/runtime/runtime-info.service.ts"; +import { LegacyCliConfig, type LegacyProfileName } from "./legacy-cli-config.service.ts"; + +interface ResolvedProfile { + readonly name: string; + readonly apiUrl: string; +} + +const BUILTIN_PROFILES: Record = { + supabase: { name: "supabase", apiUrl: "https://api.supabase.com" }, + "supabase-staging": { name: "supabase-staging", apiUrl: "https://api.supabase.green" }, + "supabase-local": { name: "supabase-local", apiUrl: "http://localhost:8080" }, +}; + +function isBuiltinProfileName(value: string): value is LegacyProfileName { + return value in BUILTIN_PROFILES; +} + +function safeParseYaml(text: string): { name?: unknown; api_url?: unknown } | undefined { + try { + const value = parseYaml(text); + return value !== null && typeof value === "object" + ? (value as { name?: unknown; api_url?: unknown }) + : undefined; + } catch { + return undefined; + } +} + +/** + * Resolves the profile that produces the API URL. Mirrors Go's `LoadProfile` + * (`apps/cli-go/internal/utils/profile.go:96-118`): + * + * 1. If the token matches a built-in profile name, use that. + * 2. Otherwise treat the token as a path to a YAML config file with `api_url:`. + * 3. Fall back to the `supabase` built-in if the file is missing or malformed. + * + * The cli-e2e harness depends on (2) — it writes a per-test YAML profile and + * sets `SUPABASE_PROFILE=` so both the Go and ts-legacy binaries + * route requests to the local replay server. + */ +function resolveProfile( + flagValue: string, + envValue: string | undefined, + fs: FileSystem.FileSystem, +): Effect.Effect { + return Effect.gen(function* () { + const token = flagValue !== "supabase" ? flagValue : (envValue ?? "supabase"); + + if (isBuiltinProfileName(token)) { + return BUILTIN_PROFILES[token]; + } + + const content = yield* fs.readFileString(token).pipe(Effect.option); + if (Option.isNone(content)) return BUILTIN_PROFILES.supabase; + + const parsed = safeParseYaml(content.value); + if (parsed === undefined || typeof parsed.api_url !== "string") { + return BUILTIN_PROFILES.supabase; + } + return { + name: typeof parsed.name === "string" ? parsed.name : "supabase", + apiUrl: parsed.api_url, + }; + }); +} + +function resolveWorkdir( + flagValue: Option.Option, + envValue: string | undefined, + cwd: string, + configTomlExists: (path: string) => Effect.Effect, + path: Path.Path, +): Effect.Effect { + return Effect.gen(function* () { + if (Option.isSome(flagValue) && flagValue.value.length > 0) { + return flagValue.value; + } + if (envValue !== undefined && envValue.length > 0) { + return envValue; + } + let current = cwd; + // Walk up until we hit a directory containing supabase/config.toml or the FS root. + while (true) { + const candidate = path.join(current, "supabase", "config.toml"); + if (yield* configTomlExists(candidate)) { + return current; + } + const parent = path.dirname(current); + if (parent === current) { + return cwd; + } + current = parent; + } + }); +} + +export const legacyCliConfigLayer = Layer.unwrap( + Effect.gen(function* () { + const profileFlag = yield* LegacyProfileFlag; + const workdirFlag = yield* LegacyWorkdirFlag; + + return Layer.effect( + LegacyCliConfig, + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const runtimeInfo = yield* RuntimeInfo; + const env = process.env; + + const { name: profile, apiUrl } = yield* resolveProfile( + profileFlag, + env["SUPABASE_PROFILE"], + fs, + ); + + const rawAccessToken = env["SUPABASE_ACCESS_TOKEN"]; + const accessToken = + rawAccessToken === undefined || rawAccessToken.length === 0 + ? Option.none>() + : Option.some(Redacted.make(rawAccessToken, { label: "SUPABASE_ACCESS_TOKEN" })); + + const rawProjectId = env["SUPABASE_PROJECT_ID"]; + const projectId = + rawProjectId === undefined || rawProjectId.length === 0 + ? Option.none() + : Option.some(rawProjectId); + + const workdir = yield* resolveWorkdir( + workdirFlag, + env["SUPABASE_WORKDIR"], + runtimeInfo.cwd, + (filePath) => fs.exists(filePath).pipe(Effect.orElseSucceed(() => false)), + path, + ); + + const userAgent = `SupabaseCLI/${CLI_VERSION}`; + + return LegacyCliConfig.of({ + profile, + apiUrl, + accessToken, + projectId, + workdir, + userAgent, + }); + }), + ); + }), +); diff --git a/apps/cli/src/legacy/config/legacy-cli-config.layer.unit.test.ts b/apps/cli/src/legacy/config/legacy-cli-config.layer.unit.test.ts new file mode 100644 index 000000000..7c311d8c5 --- /dev/null +++ b/apps/cli/src/legacy/config/legacy-cli-config.layer.unit.test.ts @@ -0,0 +1,205 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { describe, expect, it } from "@effect/vitest"; +import { BunServices } from "@effect/platform-bun"; +import { Effect, Layer, Option, Redacted } from "effect"; +import { afterEach, beforeEach } from "vitest"; + +import { LegacyProfileFlag, LegacyWorkdirFlag } from "../../shared/legacy/global-flags.ts"; +import { mockRuntimeInfo, processEnvLayer } from "../../../tests/helpers/mocks.ts"; +import { legacyCliConfigLayer } from "./legacy-cli-config.layer.ts"; +import { LegacyCliConfig } from "./legacy-cli-config.service.ts"; + +function makeLayer(opts: { + profileFlag?: string; + workdirFlag?: Option.Option; + env?: Record; + cwd?: string; +}) { + const profileFlag = opts.profileFlag ?? "supabase"; + const workdirFlag = opts.workdirFlag ?? Option.none(); + return legacyCliConfigLayer.pipe( + Layer.provide(Layer.succeed(LegacyProfileFlag, profileFlag)), + Layer.provide(Layer.succeed(LegacyWorkdirFlag, workdirFlag)), + Layer.provide(mockRuntimeInfo({ cwd: opts.cwd ?? "/test/cwd" })), + Layer.provide(BunServices.layer), + Layer.provide(processEnvLayer(opts.env ?? {})), + ); +} + +let tempRoot: string; + +beforeEach(() => { + tempRoot = mkdtempSync(join(tmpdir(), "supabase-legacy-cli-config-")); +}); + +afterEach(() => { + rmSync(tempRoot, { recursive: true, force: true }); +}); + +describe("legacyCliConfigLayer", () => { + it.effect("defaults to supabase profile and api.supabase.com when no flags or env", () => + Effect.gen(function* () { + const config = yield* LegacyCliConfig; + expect(config.profile).toBe("supabase"); + expect(config.apiUrl).toBe("https://api.supabase.com"); + }).pipe(Effect.provide(makeLayer({ cwd: tempRoot }))), + ); + + it.effect("uses SUPABASE_PROFILE env when the flag is left at default", () => + Effect.gen(function* () { + const config = yield* LegacyCliConfig; + expect(config.profile).toBe("supabase-staging"); + expect(config.apiUrl).toBe("https://api.supabase.green"); + }).pipe( + Effect.provide(makeLayer({ env: { SUPABASE_PROFILE: "supabase-staging" }, cwd: tempRoot })), + ), + ); + + it.effect("uses supabase-local profile and localhost API URL", () => + Effect.gen(function* () { + const config = yield* LegacyCliConfig; + expect(config.apiUrl).toBe("http://localhost:8080"); + }).pipe(Effect.provide(makeLayer({ profileFlag: "supabase-local", cwd: tempRoot }))), + ); + + it.effect( + "falls back to supabase profile when SUPABASE_PROFILE is neither a known name nor a readable file", + () => + Effect.gen(function* () { + const config = yield* LegacyCliConfig; + expect(config.profile).toBe("supabase"); + expect(config.apiUrl).toBe("https://api.supabase.com"); + }).pipe( + Effect.provide(makeLayer({ env: { SUPABASE_PROFILE: "rogue-profile" }, cwd: tempRoot })), + ), + ); + + it.effect("loads api_url and name from a YAML profile file (Go-parity dual semantics)", () => { + const profilePath = join(tempRoot, "profile.yaml"); + writeFileSync( + profilePath, + ["name: cli-e2e", 'api_url: "http://127.0.0.1:9999"', "project_host: localhost"].join("\n"), + ); + return Effect.gen(function* () { + const config = yield* LegacyCliConfig; + expect(config.profile).toBe("cli-e2e"); + expect(config.apiUrl).toBe("http://127.0.0.1:9999"); + }).pipe(Effect.provide(makeLayer({ env: { SUPABASE_PROFILE: profilePath }, cwd: tempRoot }))); + }); + + it.effect( + "falls back to supabase profile when SUPABASE_PROFILE points to a non-existent file", + () => + Effect.gen(function* () { + const config = yield* LegacyCliConfig; + expect(config.profile).toBe("supabase"); + expect(config.apiUrl).toBe("https://api.supabase.com"); + }).pipe( + Effect.provide( + makeLayer({ + env: { SUPABASE_PROFILE: join(tempRoot, "missing.yaml") }, + cwd: tempRoot, + }), + ), + ), + ); + + it.effect("falls back to supabase profile when SUPABASE_PROFILE points to malformed YAML", () => { + const profilePath = join(tempRoot, "broken.yaml"); + writeFileSync(profilePath, "::: not yaml :::\n[unbalanced"); + return Effect.gen(function* () { + const config = yield* LegacyCliConfig; + expect(config.profile).toBe("supabase"); + expect(config.apiUrl).toBe("https://api.supabase.com"); + }).pipe(Effect.provide(makeLayer({ env: { SUPABASE_PROFILE: profilePath }, cwd: tempRoot }))); + }); + + it.effect("ignores SUPABASE_API_URL — Go parity", () => + Effect.gen(function* () { + const config = yield* LegacyCliConfig; + expect(config.apiUrl).toBe("https://api.supabase.com"); + }).pipe( + Effect.provide( + makeLayer({ env: { SUPABASE_API_URL: "https://nope.example.com" }, cwd: tempRoot }), + ), + ), + ); + + it.effect("captures SUPABASE_ACCESS_TOKEN as a Redacted value", () => + Effect.gen(function* () { + const config = yield* LegacyCliConfig; + expect(Option.isSome(config.accessToken)).toBe(true); + if (Option.isSome(config.accessToken)) { + expect(Redacted.value(config.accessToken.value)).toBe("sbp_test"); + } + }).pipe( + Effect.provide(makeLayer({ env: { SUPABASE_ACCESS_TOKEN: "sbp_test" }, cwd: tempRoot })), + ), + ); + + it.effect("captures SUPABASE_PROJECT_ID env", () => + Effect.gen(function* () { + const config = yield* LegacyCliConfig; + expect(Option.getOrUndefined(config.projectId)).toBe("myrefabcdefghijklmno"); + }).pipe( + Effect.provide( + makeLayer({ env: { SUPABASE_PROJECT_ID: "myrefabcdefghijklmno" }, cwd: tempRoot }), + ), + ), + ); + + it.effect("prefers --workdir flag over env and walk-up", () => + Effect.gen(function* () { + const config = yield* LegacyCliConfig; + expect(config.workdir).toBe("/flag/workdir"); + }).pipe( + Effect.provide( + makeLayer({ + workdirFlag: Option.some("/flag/workdir"), + env: { SUPABASE_WORKDIR: "/env/workdir" }, + cwd: tempRoot, + }), + ), + ), + ); + + it.effect("uses SUPABASE_WORKDIR env when flag is unset", () => + Effect.gen(function* () { + const config = yield* LegacyCliConfig; + expect(config.workdir).toBe("/env/workdir"); + }).pipe( + Effect.provide(makeLayer({ env: { SUPABASE_WORKDIR: "/env/workdir" }, cwd: tempRoot })), + ), + ); + + it.effect("walks up from CWD looking for supabase/config.toml", () => { + const projectRoot = join(tempRoot, "project"); + const nested = join(projectRoot, "deep", "child"); + mkdirSync(join(projectRoot, "supabase"), { recursive: true }); + mkdirSync(nested, { recursive: true }); + writeFileSync(join(projectRoot, "supabase", "config.toml"), 'project_id = "x"\n'); + + return Effect.gen(function* () { + const config = yield* LegacyCliConfig; + expect(config.workdir).toBe(projectRoot); + }).pipe(Effect.provide(makeLayer({ cwd: nested }))); + }); + + it.effect("falls back to CWD when no supabase/config.toml found", () => + Effect.gen(function* () { + const config = yield* LegacyCliConfig; + expect(config.workdir).toBe(tempRoot); + }).pipe(Effect.provide(makeLayer({ cwd: tempRoot }))), + ); + + it.effect("populates userAgent from CLI_VERSION", () => + Effect.gen(function* () { + const config = yield* LegacyCliConfig; + // The sentinel `0.0.0-dev` value applies when SUPABASE_CLI_VERSION is unset (tests). + expect(config.userAgent).toMatch(/^SupabaseCLI\//); + }).pipe(Effect.provide(makeLayer({ cwd: tempRoot }))), + ); +}); diff --git a/apps/cli/src/legacy/config/legacy-cli-config.service.ts b/apps/cli/src/legacy/config/legacy-cli-config.service.ts new file mode 100644 index 000000000..166edbcce --- /dev/null +++ b/apps/cli/src/legacy/config/legacy-cli-config.service.ts @@ -0,0 +1,24 @@ +import type { Option, Redacted } from "effect"; +import { Context } from "effect"; + +/** + * Built-in profile names with hard-coded API URLs (matches Go's `allProfiles`). + * + * `LegacyCliConfig.profile` is typed as `string` (not this union) because Go also + * supports YAML profile files where `name:` is arbitrary user input. See + * `legacy-cli-config.layer.ts` for the resolution semantics. + */ +export type LegacyProfileName = "supabase" | "supabase-staging" | "supabase-local"; + +interface LegacyCliConfigShape { + readonly profile: string; + readonly apiUrl: string; + readonly accessToken: Option.Option>; + readonly projectId: Option.Option; + readonly workdir: string; + readonly userAgent: string; +} + +export class LegacyCliConfig extends Context.Service()( + "supabase/legacy/CliConfig", +) {} diff --git a/apps/cli/src/legacy/config/legacy-project-ref.errors.ts b/apps/cli/src/legacy/config/legacy-project-ref.errors.ts new file mode 100644 index 000000000..9b672d325 --- /dev/null +++ b/apps/cli/src/legacy/config/legacy-project-ref.errors.ts @@ -0,0 +1,10 @@ +import { Data } from "effect"; + +export class LegacyProjectNotLinkedError extends Data.TaggedError("LegacyProjectNotLinkedError")<{ + readonly message: string; +}> {} + +export class LegacyInvalidProjectRefError extends Data.TaggedError("LegacyInvalidProjectRefError")<{ + readonly ref: string; + readonly message: string; +}> {} diff --git a/apps/cli/src/legacy/config/legacy-project-ref.layer.ts b/apps/cli/src/legacy/config/legacy-project-ref.layer.ts new file mode 100644 index 000000000..f729eb13d --- /dev/null +++ b/apps/cli/src/legacy/config/legacy-project-ref.layer.ts @@ -0,0 +1,100 @@ +import { Effect, FileSystem, Layer, Option, Path } from "effect"; + +import { LegacyPlatformApi } from "../auth/legacy-platform-api.service.ts"; +import { Output } from "../../shared/output/output.service.ts"; +import { Tty } from "../../shared/runtime/tty.service.ts"; +import { LegacyCliConfig } from "./legacy-cli-config.service.ts"; +import { + LegacyInvalidProjectRefError, + LegacyProjectNotLinkedError, +} from "./legacy-project-ref.errors.ts"; +import { + INVALID_PROJECT_REF_MESSAGE, + LegacyProjectRefResolver, + PROJECT_NOT_LINKED_MESSAGE, + PROJECT_REF_PATTERN, +} from "./legacy-project-ref.service.ts"; + +function assertValid(ref: string): Effect.Effect { + if (PROJECT_REF_PATTERN.test(ref)) { + return Effect.succeed(ref); + } + return Effect.fail( + new LegacyInvalidProjectRefError({ ref, message: INVALID_PROJECT_REF_MESSAGE }), + ); +} + +export const legacyProjectRefLayer = Layer.effect( + LegacyProjectRefResolver, + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cliConfig = yield* LegacyCliConfig; + const tty = yield* Tty; + const output = yield* Output; + const api = yield* LegacyPlatformApi; + + const refPath = path.join(cliConfig.workdir, "supabase", ".temp", "project-ref"); + + const readRefFile = Effect.gen(function* () { + const exists = yield* fs.exists(refPath).pipe(Effect.orElseSucceed(() => false)); + if (!exists) return Option.none(); + const content = yield* fs.readFileString(refPath).pipe(Effect.orElseSucceed(() => "")); + const trimmed = content.trim(); + return trimmed.length === 0 ? Option.none() : Option.some(trimmed); + }); + + const promptForProjectRef = Effect.gen(function* () { + const projects = yield* api.v1.listAllProjects().pipe( + Effect.mapError( + (cause) => + new LegacyProjectNotLinkedError({ + message: `${PROJECT_NOT_LINKED_MESSAGE}\n Reason: failed to retrieve projects: ${String( + cause, + )}`, + }), + ), + ); + const options = projects.map((project) => ({ + value: project.id, + label: project.id, + hint: `name: ${project.name}, org: ${project.organization_slug}, region: ${project.region}`, + })); + const chosen = yield* output.promptSelect("Select a project:", options).pipe( + Effect.mapError( + (cause) => + new LegacyProjectNotLinkedError({ + message: `${PROJECT_NOT_LINKED_MESSAGE}\n Reason: ${cause.detail}`, + }), + ), + ); + // Go writes "Selected project: " to stderr (project_ref.go:50). In text mode + // `output.info` lands on stderr; in json/stream-json modes it is a no-op. + yield* output.info(`Selected project: ${chosen}`); + return chosen; + }); + + return LegacyProjectRefResolver.of({ + resolve: (flagValue) => + Effect.gen(function* () { + if (Option.isSome(flagValue) && flagValue.value.length > 0) { + return yield* assertValid(flagValue.value); + } + if (Option.isSome(cliConfig.projectId)) { + return yield* assertValid(cliConfig.projectId.value); + } + const fileValue = yield* readRefFile; + if (Option.isSome(fileValue)) { + return yield* assertValid(fileValue.value); + } + if (tty.stdinIsTty && output.interactive) { + const chosen = yield* promptForProjectRef; + return yield* assertValid(chosen); + } + return yield* Effect.fail( + new LegacyProjectNotLinkedError({ message: PROJECT_NOT_LINKED_MESSAGE }), + ); + }), + }); + }), +); diff --git a/apps/cli/src/legacy/config/legacy-project-ref.layer.unit.test.ts b/apps/cli/src/legacy/config/legacy-project-ref.layer.unit.test.ts new file mode 100644 index 000000000..5bbef7847 --- /dev/null +++ b/apps/cli/src/legacy/config/legacy-project-ref.layer.unit.test.ts @@ -0,0 +1,228 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import type { ApiClient } from "@supabase/api/effect"; +import { describe, expect, it } from "@effect/vitest"; +import { BunServices } from "@effect/platform-bun"; +import { Effect, Exit, Layer, Option } from "effect"; +import { afterEach, beforeEach } from "vitest"; + +import { LegacyPlatformApi } from "../auth/legacy-platform-api.service.ts"; +import { mockOutput, mockTty } from "../../../tests/helpers/mocks.ts"; +import { LegacyCliConfig } from "./legacy-cli-config.service.ts"; +import { LegacyProjectRefResolver } from "./legacy-project-ref.service.ts"; +import { legacyProjectRefLayer } from "./legacy-project-ref.layer.ts"; + +const VALID_REF = "abcdefghijklmnopqrst"; +const ANOTHER_REF = "qrstuvwxyzabcdefghij"; + +function mockCliConfig(opts: { workdir: string; projectId?: string }) { + return Layer.succeed(LegacyCliConfig, { + profile: "supabase", + apiUrl: "https://api.supabase.com", + accessToken: Option.none(), + projectId: opts.projectId === undefined ? Option.none() : Option.some(opts.projectId), + workdir: opts.workdir, + userAgent: "SupabaseCLI/0.0.0-dev", + }); +} + +function mockPlatformApi( + projects: ReadonlyArray<{ + id: string; + name: string; + organization_slug: string; + region: string; + }>, +) { + const api = { + v1: { + listAllProjects: () => Effect.succeed(projects), + }, + } as unknown as ApiClient; + return Layer.succeed(LegacyPlatformApi, api); +} + +function makeLayer(opts: { + workdir: string; + projectId?: string; + stdinIsTty?: boolean; + format?: "text" | "json" | "stream-json"; + projects?: ReadonlyArray<{ + id: string; + name: string; + organization_slug: string; + region: string; + }>; + promptSelectResponses?: ReadonlyArray; +}) { + const out = mockOutput({ + format: opts.format ?? "text", + promptSelectResponses: opts.promptSelectResponses, + }); + const layer = legacyProjectRefLayer.pipe( + Layer.provide(mockCliConfig(opts)), + Layer.provide(mockTty({ stdinIsTty: opts.stdinIsTty ?? false, stdoutIsTty: false })), + Layer.provide(out.layer), + Layer.provide(mockPlatformApi(opts.projects ?? [])), + Layer.provide(BunServices.layer), + ); + return { layer, out }; +} + +let tempRoot: string; + +beforeEach(() => { + tempRoot = mkdtempSync(join(tmpdir(), "supabase-legacy-project-ref-")); +}); + +afterEach(() => { + rmSync(tempRoot, { recursive: true, force: true }); +}); + +function writeRefFile(workdir: string, content: string) { + const tempDir = join(workdir, "supabase", ".temp"); + mkdirSync(tempDir, { recursive: true }); + writeFileSync(join(tempDir, "project-ref"), content); +} + +describe("legacyProjectRefLayer", () => { + it.effect("prefers --project-ref flag over env and file", () => { + writeRefFile(tempRoot, ANOTHER_REF); + const { layer } = makeLayer({ workdir: tempRoot, projectId: ANOTHER_REF }); + return Effect.gen(function* () { + const { resolve } = yield* LegacyProjectRefResolver; + const ref = yield* resolve(Option.some(VALID_REF)); + expect(ref).toBe(VALID_REF); + }).pipe(Effect.provide(layer)); + }); + + it.effect("uses SUPABASE_PROJECT_ID when flag is unset", () => { + writeRefFile(tempRoot, ANOTHER_REF); + const { layer } = makeLayer({ workdir: tempRoot, projectId: VALID_REF }); + return Effect.gen(function* () { + const { resolve } = yield* LegacyProjectRefResolver; + const ref = yield* resolve(Option.none()); + expect(ref).toBe(VALID_REF); + }).pipe(Effect.provide(layer)); + }); + + it.effect("reads /supabase/.temp/project-ref when env and flag are unset", () => { + writeRefFile(tempRoot, VALID_REF); + const { layer } = makeLayer({ workdir: tempRoot }); + return Effect.gen(function* () { + const { resolve } = yield* LegacyProjectRefResolver; + const ref = yield* resolve(Option.none()); + expect(ref).toBe(VALID_REF); + }).pipe(Effect.provide(layer)); + }); + + it.effect("trims whitespace from the temp/project-ref file content", () => { + writeRefFile(tempRoot, ` ${VALID_REF}\n\n`); + const { layer } = makeLayer({ workdir: tempRoot }); + return Effect.gen(function* () { + const { resolve } = yield* LegacyProjectRefResolver; + const ref = yield* resolve(Option.none()); + expect(ref).toBe(VALID_REF); + }).pipe(Effect.provide(layer)); + }); + + it.effect("prompts via Output.promptSelect when on a TTY with no other source", () => { + const projects = [ + { id: VALID_REF, name: "alpha", organization_slug: "acme", region: "us-east-1" }, + { id: ANOTHER_REF, name: "beta", organization_slug: "acme", region: "eu-west-1" }, + ]; + const { layer, out } = makeLayer({ + workdir: tempRoot, + stdinIsTty: true, + projects, + promptSelectResponses: [ANOTHER_REF], + }); + return Effect.gen(function* () { + const { resolve } = yield* LegacyProjectRefResolver; + const ref = yield* resolve(Option.none()); + expect(ref).toBe(ANOTHER_REF); + const call = out.promptSelectCalls[0]; + expect(call?.message).toBe("Select a project:"); + expect(call?.options[0]).toEqual({ + value: VALID_REF, + label: VALID_REF, + hint: "name: alpha, org: acme, region: us-east-1", + }); + // "Selected project: ..." is emitted via output.info (-> stderr in text mode). + const infos = out.messages.filter((m) => m.type === "info").map((m) => m.message); + expect(infos).toContain(`Selected project: ${ANOTHER_REF}`); + }).pipe(Effect.provide(layer)); + }); + + it.effect("does not persist the selected ref to the temp file (Go parity)", () => { + const projects = [ + { id: VALID_REF, name: "alpha", organization_slug: "acme", region: "us-east-1" }, + ]; + const refPath = join(tempRoot, "supabase", ".temp", "project-ref"); + const { layer } = makeLayer({ + workdir: tempRoot, + stdinIsTty: true, + projects, + promptSelectResponses: [VALID_REF], + }); + return Effect.gen(function* () { + const { resolve } = yield* LegacyProjectRefResolver; + yield* resolve(Option.none()); + // The resolver must not write the file — only `supabase link` does. + const exists = yield* Effect.tryPromise({ + try: () => import("node:fs").then((m) => m.existsSync(refPath)), + catch: () => false, + }); + expect(exists).toBe(false); + }).pipe(Effect.provide(layer)); + }); + + it.effect("fails with LegacyProjectNotLinkedError on non-TTY with no source", () => { + const { layer } = makeLayer({ workdir: tempRoot }); + return Effect.gen(function* () { + const { resolve } = yield* LegacyProjectRefResolver; + const exit = yield* Effect.exit(resolve(Option.none())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const errorJson = JSON.stringify(exit.cause); + expect(errorJson).toContain("LegacyProjectNotLinkedError"); + expect(errorJson).toContain("supabase link"); + } + }).pipe(Effect.provide(layer)); + }); + + it.effect("fails with LegacyInvalidProjectRefError when the resolved ref is malformed", () => { + const { layer } = makeLayer({ workdir: tempRoot, projectId: "not-a-valid-ref" }); + return Effect.gen(function* () { + const { resolve } = yield* LegacyProjectRefResolver; + const exit = yield* Effect.exit(resolve(Option.none())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const errorJson = JSON.stringify(exit.cause); + expect(errorJson).toContain("LegacyInvalidProjectRefError"); + expect(errorJson).toContain("Invalid project ref format"); + } + }).pipe(Effect.provide(layer)); + }); + + it.effect("rejects invalid ref from --project-ref flag", () => { + const { layer } = makeLayer({ workdir: tempRoot }); + return Effect.gen(function* () { + const { resolve } = yield* LegacyProjectRefResolver; + const exit = yield* Effect.exit(resolve(Option.some("BADREF"))); + expect(Exit.isFailure(exit)).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.effect("rejects invalid ref from temp/project-ref file", () => { + writeRefFile(tempRoot, "BADREF"); + const { layer } = makeLayer({ workdir: tempRoot }); + return Effect.gen(function* () { + const { resolve } = yield* LegacyProjectRefResolver; + const exit = yield* Effect.exit(resolve(Option.none())); + expect(Exit.isFailure(exit)).toBe(true); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/cli/src/legacy/config/legacy-project-ref.service.ts b/apps/cli/src/legacy/config/legacy-project-ref.service.ts new file mode 100644 index 000000000..647233038 --- /dev/null +++ b/apps/cli/src/legacy/config/legacy-project-ref.service.ts @@ -0,0 +1,25 @@ +import type { Effect, Option } from "effect"; +import { Context } from "effect"; + +import type { + LegacyInvalidProjectRefError, + LegacyProjectNotLinkedError, +} from "./legacy-project-ref.errors.ts"; + +interface LegacyProjectRefResolverShape { + readonly resolve: ( + flagValue: Option.Option, + ) => Effect.Effect; +} + +export class LegacyProjectRefResolver extends Context.Service< + LegacyProjectRefResolver, + LegacyProjectRefResolverShape +>()("supabase/legacy/ProjectRefResolver") {} + +export const PROJECT_REF_PATTERN = /^[a-z]{20}$/; + +export const PROJECT_NOT_LINKED_MESSAGE = "Cannot find project ref. Have you run `supabase link`?"; + +export const INVALID_PROJECT_REF_MESSAGE = + "Invalid project ref format. Must be like `abcdefghijklmnopqrst`."; diff --git a/apps/cli/src/legacy/output/legacy-glamour-table.ts b/apps/cli/src/legacy/output/legacy-glamour-table.ts new file mode 100644 index 000000000..7c8d6cf42 --- /dev/null +++ b/apps/cli/src/legacy/output/legacy-glamour-table.ts @@ -0,0 +1,45 @@ +/** + * renderGlamourTable - Reproduces the byte output of Go's `glamour.RenderTable` + * using `styles.AsciiStyle` for the markdown tables the Go CLI emits (see + * `apps/cli-go/internal/utils/output.go:109-122`). + * + * Output shape (each line terminated by "\n"): + * + * + * <2-space prefix> <- decorative empty line Glamour emits + * ||... + * <2-space prefix>||... + * |... + * ... + * + * + * Each cell is padded to the column width: max(len(header), max(len(row[i]))). + * The padded cell is wrapped with " ... " (one space either side), so the cell + * width in the output is colWidth + 2. The separator row uses dashes of the + * same width, joined by "|". + */ +export function renderGlamourTable( + headers: ReadonlyArray, + rows: ReadonlyArray>, +): string { + const widths = headers.map((header, columnIndex) => + Math.max(header.length, ...rows.map((row) => (row[columnIndex] ?? "").length)), + ); + + const renderRow = (cells: ReadonlyArray): string => + " " + + cells.map((cell, columnIndex) => " " + cell.padEnd(widths[columnIndex] ?? 0) + " ").join("|"); + + const separator = " " + widths.map((width) => "-".repeat(width + 2)).join("|"); + + const lines: string[] = []; + lines.push(""); + lines.push(" "); + lines.push(renderRow(headers)); + lines.push(separator); + for (const row of rows) { + lines.push(renderRow(row)); + } + lines.push(""); + return lines.join("\n") + "\n"; +} diff --git a/apps/cli/src/legacy/output/legacy-glamour-table.unit.test.ts b/apps/cli/src/legacy/output/legacy-glamour-table.unit.test.ts new file mode 100644 index 000000000..90b3701c0 --- /dev/null +++ b/apps/cli/src/legacy/output/legacy-glamour-table.unit.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; + +import { renderGlamourTable } from "./legacy-glamour-table.ts"; + +describe("renderGlamourTable", () => { + // Byte-for-byte parity with the Go fixture in + // apps/cli-go/internal/backups/list/list_test.go (TestListBackup/lists PITR backup). + it("matches the Go PITR-backup table fixture", () => { + const out = renderGlamourTable( + ["REGION", "WALG", "PITR", "EARLIEST TIMESTAMP", "LATEST TIMESTAMP"], + [["Southeast Asia (Singapore)", "true", "true", "0", "0"]], + ); + + const expected = + "\n" + + " \n" + + " REGION | WALG | PITR | EARLIEST TIMESTAMP | LATEST TIMESTAMP \n" + + " ----------------------------|------|------|--------------------|------------------\n" + + " Southeast Asia (Singapore) | true | true | 0 | 0 \n" + + "\n"; + + expect(out).toBe(expected); + }); + + // Byte-for-byte parity with the Go fixture in + // apps/cli-go/internal/backups/list/list_test.go (TestListBackup/lists WALG backup). + it("matches the Go logical-backup table fixture", () => { + const out = renderGlamourTable( + ["REGION", "BACKUP TYPE", "STATUS", "CREATED AT (UTC)"], + [["Southeast Asia (Singapore)", "PHYSICAL", "COMPLETED", "2026-02-08 16:44:07"]], + ); + + const expected = + "\n" + + " \n" + + " REGION | BACKUP TYPE | STATUS | CREATED AT (UTC) \n" + + " ----------------------------|-------------|-----------|---------------------\n" + + " Southeast Asia (Singapore) | PHYSICAL | COMPLETED | 2026-02-08 16:44:07 \n" + + "\n"; + + expect(out).toBe(expected); + }); +}); diff --git a/apps/cli/src/legacy/telemetry/legacy-linked-project-cache.layer.ts b/apps/cli/src/legacy/telemetry/legacy-linked-project-cache.layer.ts new file mode 100644 index 000000000..4b0de6378 --- /dev/null +++ b/apps/cli/src/legacy/telemetry/legacy-linked-project-cache.layer.ts @@ -0,0 +1,81 @@ +import { Effect, FileSystem, Layer, Option, Path, Redacted } from "effect"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; + +import { LegacyCredentials } from "../auth/legacy-credentials.service.ts"; +import { LegacyCliConfig } from "../config/legacy-cli-config.service.ts"; +import { LegacyLinkedProjectCache } from "./legacy-linked-project-cache.service.ts"; + +function readString(obj: unknown, key: string): string { + if (typeof obj === "object" && obj !== null && key in obj) { + const value = (obj as Record)[key]; + return typeof value === "string" ? value : ""; + } + return ""; +} + +/** + * Writes `/supabase/.temp/linked-project.json` after a `--project-ref` + * has been resolved. Mirrors Go's `ensureProjectGroupsCached` + * (`apps/cli-go/cmd/root.go:213-234`): + * + * - No write if the cache already exists (`supabase link` is authoritative). + * - Best-effort: any API / filesystem / parse error is swallowed. + * - Body shape matches `LinkedProject` from + * `apps/cli-go/internal/telemetry/project.go:15-20`. + * + * Bypasses `LegacyPlatformApi`'s strict schema decode by calling the API + * directly with `HttpClient`. The generated `V1ProjectWithDatabaseResponse` + * schema enforces a 20-char project-ref length that the cli-e2e replay + * fixtures (which store `__PROJECT_REF__` placeholders) cannot satisfy. + * The cache only needs four string fields and doesn't validate them. + */ +export const legacyLinkedProjectCacheLayer = Layer.effect( + LegacyLinkedProjectCache, + Effect.gen(function* () { + const httpClient = yield* HttpClient.HttpClient; + const cliConfig = yield* LegacyCliConfig; + const credentials = yield* LegacyCredentials; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + return LegacyLinkedProjectCache.of({ + cache: (ref: string) => + Effect.gen(function* () { + const cachePath = path.join( + cliConfig.workdir, + "supabase", + ".temp", + "linked-project.json", + ); + const exists = yield* fs.exists(cachePath).pipe(Effect.orElseSucceed(() => false)); + if (exists) return; + + // Resolve token: env wins over keyring/file lookup (Go-parity). + const tokenOpt = Option.isSome(cliConfig.accessToken) + ? cliConfig.accessToken + : yield* credentials.getAccessToken; + if (Option.isNone(tokenOpt)) return; + const token = Redacted.value(tokenOpt.value); + + const request = HttpClientRequest.get(`${cliConfig.apiUrl}/v1/projects/${ref}`).pipe( + HttpClientRequest.setHeader("Authorization", `Bearer ${token}`), + HttpClientRequest.setHeader("User-Agent", cliConfig.userAgent), + ); + const response = yield* httpClient.execute(request); + if (response.status !== 200) return; + const body = yield* response.json; + + const linked = { + ref: readString(body, "ref"), + name: readString(body, "name"), + organization_id: readString(body, "organization_id"), + organization_slug: readString(body, "organization_slug"), + }; + + yield* fs.makeDirectory(path.dirname(cachePath), { recursive: true }); + yield* fs.writeFileString(cachePath, JSON.stringify(linked)); + }).pipe(Effect.ignore), + }); + }), +); diff --git a/apps/cli/src/legacy/telemetry/legacy-linked-project-cache.service.ts b/apps/cli/src/legacy/telemetry/legacy-linked-project-cache.service.ts new file mode 100644 index 000000000..eacfaca6c --- /dev/null +++ b/apps/cli/src/legacy/telemetry/legacy-linked-project-cache.service.ts @@ -0,0 +1,19 @@ +import type { Effect } from "effect"; +import { Context } from "effect"; + +interface LegacyLinkedProjectCacheShape { + /** + * Fire-and-forget: fetches the project metadata from the Management API and + * writes `/supabase/.temp/linked-project.json` if no cache exists yet. + * + * Best-effort. Never fails the calling effect — auth errors, network errors, + * and write errors are all swallowed (matches Go's `ensureProjectGroupsCached` + * which logs to debug and returns). + */ + readonly cache: (ref: string) => Effect.Effect; +} + +export class LegacyLinkedProjectCache extends Context.Service< + LegacyLinkedProjectCache, + LegacyLinkedProjectCacheShape +>()("supabase/legacy/LinkedProjectCache") {} diff --git a/apps/cli/src/legacy/telemetry/legacy-telemetry-state.layer.ts b/apps/cli/src/legacy/telemetry/legacy-telemetry-state.layer.ts new file mode 100644 index 000000000..057fbfba5 --- /dev/null +++ b/apps/cli/src/legacy/telemetry/legacy-telemetry-state.layer.ts @@ -0,0 +1,117 @@ +import { Effect, FileSystem, Layer, Path } from "effect"; +import { homedir } from "node:os"; + +import { LegacyTelemetryState } from "./legacy-telemetry-state.service.ts"; + +interface State { + readonly enabled: boolean; + readonly device_id: string; + readonly session_id: string; + readonly session_last_active: string; + readonly distinct_id?: string; + readonly schema_version: number; +} + +const SCHEMA_VERSION = 1; +const SESSION_ROTATION_MS = 30 * 60 * 1000; + +function telemetryPath(env: Record, pathSvc: Path.Path): string { + const supabaseHome = env["SUPABASE_HOME"]?.trim(); + if (supabaseHome !== undefined && supabaseHome.length > 0) { + return pathSvc.join(supabaseHome, "telemetry.json"); + } + return pathSvc.join(homedir(), ".supabase", "telemetry.json"); +} + +function isStringField(value: unknown, key: string): boolean { + if (typeof value !== "object" || value === null) return false; + const field = (value as Record)[key]; + return typeof field === "string" && field.length > 0; +} + +interface PriorState { + enabled?: boolean; + device_id?: string; + session_id?: string; + session_last_active?: string; + distinct_id?: string; +} + +function readExistingState(text: string): PriorState | undefined { + try { + const parsed = JSON.parse(text); + if (typeof parsed !== "object" || parsed === null) return undefined; + const record = parsed as Record; + const out: PriorState = {}; + if (typeof record.enabled === "boolean") out.enabled = record.enabled; + if (isStringField(parsed, "device_id")) out.device_id = record.device_id as string; + if (isStringField(parsed, "session_id")) out.session_id = record.session_id as string; + if (isStringField(parsed, "session_last_active")) { + out.session_last_active = record.session_last_active as string; + } + if (isStringField(parsed, "distinct_id")) out.distinct_id = record.distinct_id as string; + return out; + } catch { + return undefined; + } +} + +/** + * Writes `/telemetry.json` on every command run. + * Mirrors Go's `LoadOrCreateState` (`apps/cli-go/internal/telemetry/state.go:74-98`): + * + * - Reuses an existing `device_id` if the file is present. + * - Rotates `session_id` if `session_last_active` is older than 30 minutes. + * - Always sets `enabled: true` on a fresh state (matches Go — the field is + * only flipped to `false` if the user has run `supabase telemetry disable`, + * in which case the prior value is preserved). The + * `SUPABASE_TELEMETRY_DISABLED` / `DO_NOT_TRACK` env vars suppress event + * delivery, not state-file writes. + * - Always writes — Go persists the state file even when telemetry is + * disabled; only event delivery is suppressed. + * + * Best-effort: filesystem or JSON parse errors are swallowed. + */ +export const legacyTelemetryStateLayer = Layer.effect( + LegacyTelemetryState, + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const pathSvc = yield* Path.Path; + const env = process.env; + + return LegacyTelemetryState.of({ + flush: Effect.gen(function* () { + const filePath = telemetryPath(env, pathSvc); + + const existing = yield* fs.readFileString(filePath).pipe( + Effect.option, + Effect.map((opt) => (opt._tag === "Some" ? opt.value : undefined)), + ); + const prior = existing !== undefined ? readExistingState(existing) : undefined; + + const now = new Date(); + const nowIso = now.toISOString(); + + const priorActive = + prior?.session_last_active !== undefined + ? new Date(prior.session_last_active).getTime() + : 0; + const expired = + !Number.isFinite(priorActive) || now.getTime() - priorActive > SESSION_ROTATION_MS; + + const state: State = { + enabled: prior?.enabled ?? true, + device_id: prior?.device_id ?? crypto.randomUUID(), + session_id: + !expired && prior?.session_id !== undefined ? prior.session_id : crypto.randomUUID(), + session_last_active: nowIso, + ...(prior?.distinct_id !== undefined ? { distinct_id: prior.distinct_id } : {}), + schema_version: SCHEMA_VERSION, + }; + + yield* fs.makeDirectory(pathSvc.dirname(filePath), { recursive: true }); + yield* fs.writeFileString(filePath, JSON.stringify(state)); + }).pipe(Effect.ignore), + }); + }), +); diff --git a/apps/cli/src/legacy/telemetry/legacy-telemetry-state.service.ts b/apps/cli/src/legacy/telemetry/legacy-telemetry-state.service.ts new file mode 100644 index 000000000..c39e09448 --- /dev/null +++ b/apps/cli/src/legacy/telemetry/legacy-telemetry-state.service.ts @@ -0,0 +1,17 @@ +import type { Effect } from "effect"; +import { Context } from "effect"; + +interface LegacyTelemetryStateShape { + /** + * Persists the legacy telemetry state to disk (matches Go's + * `LoadOrCreateState` in `apps/cli-go/internal/telemetry/state.go:74-98`). + * + * Best-effort: any filesystem error is swallowed. + */ + readonly flush: Effect.Effect; +} + +export class LegacyTelemetryState extends Context.Service< + LegacyTelemetryState, + LegacyTelemetryStateShape +>()("supabase/legacy/TelemetryState") {} diff --git a/apps/cli/src/next/commands/platform/platform-input.unit.test.ts b/apps/cli/src/next/commands/platform/platform-input.unit.test.ts index 4d63cfd0d..4ab663afa 100644 --- a/apps/cli/src/next/commands/platform/platform-input.unit.test.ts +++ b/apps/cli/src/next/commands/platform/platform-input.unit.test.ts @@ -390,6 +390,7 @@ describe("platform input", () => { }), success: () => Effect.void, fail: () => Effect.void, + raw: () => Effect.void, }); return Effect.gen(function* () { diff --git a/apps/cli/src/next/commands/platform/platform-schema.integration.test.ts b/apps/cli/src/next/commands/platform/platform-schema.integration.test.ts index 8b43cbe20..2921958df 100644 --- a/apps/cli/src/next/commands/platform/platform-schema.integration.test.ts +++ b/apps/cli/src/next/commands/platform/platform-schema.integration.test.ts @@ -38,8 +38,7 @@ describe("api schema payload", () => { route: "/v1/projects", method: "GET", summary: "List all projects", - description: - "Returns a list of all projects you've previously created.\n\nUse `/v1/organizations/{slug}/projects` instead when possible to get more precise results and pagination support.", + description: "Returns a list of all projects you've previously created.", }); }); diff --git a/apps/cli/src/shared/output/json-error-handling.unit.test.ts b/apps/cli/src/shared/output/json-error-handling.unit.test.ts index 73ca62355..a6e7ca02e 100644 --- a/apps/cli/src/shared/output/json-error-handling.unit.test.ts +++ b/apps/cli/src/shared/output/json-error-handling.unit.test.ts @@ -75,6 +75,7 @@ function mockOutput(format: "text" | "json" | "stream-json" = "text") { promptSelect: (_message, options) => Effect.succeed(options[0]!.value), promptMultiSelect: (_message, options) => Effect.succeed(options.map((option) => option.value)), + raw: (_text: string, _stream?: "stdout" | "stderr") => Effect.void, }), get failCalls() { return failCalls; diff --git a/apps/cli/src/shared/output/output.layer.ts b/apps/cli/src/shared/output/output.layer.ts index 88a46a221..1b16c86ed 100644 --- a/apps/cli/src/shared/output/output.layer.ts +++ b/apps/cli/src/shared/output/output.layer.ts @@ -330,15 +330,30 @@ export const textOutputLayer = Layer.effect( }), success: (message: string) => Effect.sync(() => log.success(message)), fail: (err: { code: string; message: string; detail?: string; suggestion?: string }) => - Effect.gen(function* () { - yield* Effect.sync(() => log.error(styleText("red", err.message))); - const detail = err.detail; - if (detail) { - yield* Effect.sync(() => log.message(styleText("gray", detail))); + Effect.sync(() => { + // Matches Go's `recoverAndExit` (apps/cli-go/cmd/root.go:300-303): a + // red-styled message on stderr, optionally followed by a suggestion. + // Bypasses clack's `log.error` framing (`│` guide + `■` icon) so the + // output byte-matches the Go CLI for parity tests. + process.stderr.write(styleText("red", err.message) + "\n"); + if (err.detail !== undefined && err.detail !== err.message) { + process.stderr.write(styleText("gray", err.detail) + "\n"); } - const suggestion = err.suggestion; - if (suggestion) { - yield* Effect.sync(() => outro(suggestion)); + 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). + process.stderr.write( + "Try rerunning the command with --debug to troubleshoot the error.\n", + ); + } + }), + raw: (text: string, stream: "stdout" | "stderr" = "stdout") => + Effect.sync(() => { + if (stream === "stderr") { + process.stderr.write(text); + } else { + process.stdout.write(text); } }), }); @@ -408,6 +423,8 @@ export const jsonOutputLayer = Layer.effect( writeStdout(JSON.stringify({ ...data, message }) + "\n"), fail: (err: { code: string; message: string; detail?: string; suggestion?: string }) => writeStdout(JSON.stringify({ _tag: "Error", error: err }) + "\n"), + raw: (text: string, stream: "stdout" | "stderr" = "stdout") => + stream === "stderr" ? writeStderr(text) : writeStdout(text), }); }), ); @@ -420,6 +437,8 @@ export const streamJsonOutputLayer = Layer.effect( const writeStdout = (s: string) => Stream.make(s).pipe(Stream.run(stdio.stdout()), Effect.orDie); + const writeStderr = (s: string) => + Stream.make(s).pipe(Stream.run(stdio.stderr()), Effect.orDie); const emitLog = (level: "info" | "warn" | "success" | "error", message: string) => { const event: StreamEvent = { type: "log", @@ -502,6 +521,8 @@ export const streamJsonOutputLayer = Layer.effect( }; return writeStdout(JSON.stringify(event) + "\n"); }, + raw: (text: string, stream: "stdout" | "stderr" = "stdout") => + stream === "stderr" ? writeStderr(text) : writeStdout(text), }); }), ); 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 0e2b7b18a..7592e6789 100644 --- a/apps/cli/src/shared/output/output.layer.unit.test.ts +++ b/apps/cli/src/shared/output/output.layer.unit.test.ts @@ -168,8 +168,14 @@ describe("Output", () => { }).pipe(Effect.provide(layer)), ); - it.effect("fail renders an error, gray context, and closing suggestion", () => - Effect.gen(function* () { + it.effect("fail writes Go-byte-identical red message + suggestion to stderr", () => { + 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; yield* out.fail({ code: "E_TEST", @@ -177,11 +183,72 @@ describe("Output", () => { detail: "extra detail", suggestion: "try again", }); - expect(mockClack.log.error).toHaveBeenCalledWith("\x1B[31mtest error\x1B[39m"); - expect(mockClack.log.message).toHaveBeenCalledWith("\x1B[90mextra detail\x1B[39m"); - expect(mockClack.outro).toHaveBeenCalledWith("try again"); - }).pipe(Effect.provide(layer)), - ); + expect(writes).toEqual([ + "\x1B[31mtest error\x1B[39m\n", + "\x1B[90mextra detail\x1B[39m\n", + "try again\n", + ]); + }).pipe( + Effect.provide(layer), + Effect.ensuring( + Effect.sync(() => { + process.stderr.write = originalWrite; + }), + ), + ); + }); + + it.effect("fail falls back to the --debug suggestion when caller provides none", () => { + 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 the fallback fires. + process.argv = originalArgv.filter((arg) => arg !== "--debug"); + return Effect.gen(function* () { + const out = yield* Output; + yield* out.fail({ code: "E_TEST", message: "boom" }); + expect(writes).toEqual([ + "\x1B[31mboom\x1B[39m\n", + "Try rerunning the command with --debug to troubleshoot the error.\n", + ]); + }).pipe( + Effect.provide(layer), + Effect.ensuring( + Effect.sync(() => { + process.stderr.write = originalWrite; + process.argv = originalArgv; + }), + ), + ); + }); + + it.effect("fail omits the --debug suggestion when --debug is set", () => { + 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; + process.argv = [...originalArgv, "--debug"]; + return Effect.gen(function* () { + const out = yield* Output; + yield* out.fail({ code: "E_TEST", message: "boom" }); + expect(writes).toEqual(["\x1B[31mboom\x1B[39m\n"]); + }).pipe( + Effect.provide(layer), + Effect.ensuring( + Effect.sync(() => { + process.stderr.write = originalWrite; + process.argv = originalArgv; + }), + ), + ); + }); it.effect("promptText passes validate callback to clack", () => { mockClack.text.mockImplementation( diff --git a/apps/cli/src/shared/output/output.service.ts b/apps/cli/src/shared/output/output.service.ts index d3b5cd4eb..5f394b618 100644 --- a/apps/cli/src/shared/output/output.service.ts +++ b/apps/cli/src/shared/output/output.service.ts @@ -74,6 +74,14 @@ interface OutputShape { readonly detail?: string; readonly suggestion?: string; }) => Effect.Effect; + /** + * Writes a raw chunk to stdout or stderr without framing. + * + * Reserved for byte-exact parity output (legacy Go-format encoders, Glamour-styled tables) + * where structured framing would change the bytes on the wire. Routes through the active + * output layer so tests can capture it without monkey-patching `process.stdout` / `process.stderr`. + */ + readonly raw: (text: string, stream?: "stdout" | "stderr") => Effect.Effect; } /** diff --git a/apps/cli/tests/helpers/mocks.ts b/apps/cli/tests/helpers/mocks.ts index d5ab3253d..aeb12f008 100644 --- a/apps/cli/tests/helpers/mocks.ts +++ b/apps/cli/tests/helpers/mocks.ts @@ -229,6 +229,7 @@ export function mockOutput( const messages: OutputMessage[] = []; const progressEvents: ProgressEvent[] = []; const events: OutputEvent[] = []; + const rawChunks: Array<{ text: string; stream: "stdout" | "stderr" }> = []; const promptSelectCalls: Array<{ message: string; options: ReadonlyArray<{ @@ -378,11 +379,28 @@ export function mockOutput( }), promptMultiSelect: (_message, options) => Effect.succeed(options.map((option) => option.value)), + raw: (text: string, stream: "stdout" | "stderr" = "stdout") => + Effect.sync(() => { + rawChunks.push({ text, stream }); + }), }), messages, progressEvents, events, promptSelectCalls, + rawChunks, + get stdoutText() { + return rawChunks + .filter((c) => c.stream === "stdout") + .map((c) => c.text) + .join(""); + }, + get stderrText() { + return rawChunks + .filter((c) => c.stream === "stderr") + .map((c) => c.text) + .join(""); + }, }; } diff --git a/packages/api/src/generated/contracts.ts b/packages/api/src/generated/contracts.ts index 19cd2765f..5b2998217 100644 --- a/packages/api/src/generated/contracts.ts +++ b/packages/api/src/generated/contracts.ts @@ -22,7 +22,9 @@ export const BranchResponse = Schema.Struct({ "MIGRATIONS_FAILED", "FUNCTIONS_DEPLOYED", "FUNCTIONS_FAILED", - ]), + ]).annotate({ + description: "This field is deprecated. List action runs to get branch status instead.", + }), created_at: Schema.String.annotate({ format: "date-time" }), updated_at: Schema.String.annotate({ format: "date-time" }), review_requested_at: Schema.optionalKey(Schema.String.annotate({ format: "date-time" })), @@ -333,6 +335,7 @@ export const V1AuthorizeJitAccessOutput = Schema.Struct({ allowed_cidrs_v6: Schema.optionalKey(Schema.Array(Schema.Struct({ cidr: Schema.String }))), }), ), + branches_only: Schema.optionalKey(Schema.Boolean), }), }); export const V1AuthorizeUserInput = Schema.Struct({ @@ -506,7 +509,9 @@ export const V1CreateABranchOutput = Schema.Struct({ "MIGRATIONS_FAILED", "FUNCTIONS_DEPLOYED", "FUNCTIONS_FAILED", - ]), + ]).annotate({ + description: "This field is deprecated. List action runs to get branch status instead.", + }), created_at: Schema.String.annotate({ format: "date-time" }), updated_at: Schema.String.annotate({ format: "date-time" }), review_requested_at: Schema.optionalKey(Schema.String.annotate({ format: "date-time" })), @@ -992,6 +997,7 @@ export const V1DeleteHostnameConfigInput = Schema.Struct({ ref: Schema.String.check(Schema.isMinLength(20)) .check(Schema.isMaxLength(20)) .check(Schema.isPattern(new RegExp("^[a-z]+$"))), + remove_addon: Schema.optionalKey(Schema.Boolean), }); export const V1DeleteABranchInput = Schema.Struct({ branch_id_or_ref: Schema.Union( @@ -1269,7 +1275,9 @@ export const V1GetABranchOutput = Schema.Struct({ "MIGRATIONS_FAILED", "FUNCTIONS_DEPLOYED", "FUNCTIONS_FAILED", - ]), + ]).annotate({ + description: "This field is deprecated. List action runs to get branch status instead.", + }), created_at: Schema.String.annotate({ format: "date-time" }), updated_at: Schema.String.annotate({ format: "date-time" }), review_requested_at: Schema.optionalKey(Schema.String.annotate({ format: "date-time" })), @@ -1801,6 +1809,10 @@ export const V1GetAuthServiceConfigOutput = Schema.Struct({ mfa_phone_verify_enabled: Schema.Union([Schema.Boolean, Schema.Null]), mfa_web_authn_enroll_enabled: Schema.Union([Schema.Boolean, Schema.Null]), mfa_web_authn_verify_enabled: Schema.Union([Schema.Boolean, Schema.Null]), + passkey_enabled: Schema.Boolean, + webauthn_rp_display_name: Schema.Union([Schema.String, Schema.Null]), + webauthn_rp_id: Schema.Union([Schema.String, Schema.Null]), + webauthn_rp_origins: Schema.Union([Schema.String, Schema.Null]), mfa_phone_otp_length: Schema.Number.check(Schema.isInt()), mfa_phone_template: Schema.Union([Schema.String, Schema.Null]), mfa_phone_max_frequency: Schema.Union([Schema.Number.check(Schema.isInt()), Schema.Null]), @@ -1987,6 +1999,20 @@ export const V1GetAvailableRegionsOutput = Schema.Struct({ ), }), }); +export const V1GetBackupScheduleInput = Schema.Struct({ + ref: Schema.String.check(Schema.isMinLength(20)) + .check(Schema.isMaxLength(20)) + .check(Schema.isPattern(new RegExp("^[a-z]+$"))), +}); +export const V1GetBackupScheduleOutput = Schema.Struct({ + schedule_for: Schema.String.annotate({ + description: "Time of day to schedule daily backups, in UTC. Format: HH:MM:SS.", + }), + updated_at: Schema.String.annotate({ + description: "Timestamp of when the backup schedule was last updated.", + format: "date-time", + }), +}); export const V1GetDatabaseDiskInput = Schema.Struct({ ref: Schema.String.check(Schema.isMinLength(20)) .check(Schema.isMaxLength(20)) @@ -2113,6 +2139,7 @@ export const V1GetJitAccessOutput = Schema.Struct({ ), }), ), + branches_only: Schema.optionalKey(Schema.Boolean), }), ), }); @@ -2121,23 +2148,23 @@ export const V1GetJitAccessConfigInput = Schema.Struct({ .check(Schema.isMaxLength(20)) .check(Schema.isPattern(new RegExp("^[a-z]+$"))), }); -export const V1GetJitAccessConfigOutput = Schema.Struct({ - user_id: Schema.String.annotate({ format: "uuid" }), - user_roles: Schema.Array( +export const V1GetJitAccessConfigOutput = Schema.Union( + [ Schema.Struct({ - role: Schema.String.check(Schema.isMinLength(1)), - expires_at: Schema.optionalKey(Schema.Number.check(Schema.isFinite())), - allowed_networks: Schema.optionalKey( - Schema.Struct({ - allowed_cidrs: Schema.optionalKey(Schema.Array(Schema.Struct({ cidr: Schema.String }))), - allowed_cidrs_v6: Schema.optionalKey( - Schema.Array(Schema.Struct({ cidr: Schema.String })), - ), - }), - ), + state: Schema.Literals(["enabled", "disabled"]), + appliedSuccessfully: Schema.optionalKey(Schema.Boolean), }), - ), -}); + Schema.Struct({ + state: Schema.Literal("unavailable"), + unavailableReason: Schema.Literals([ + "manual_migration_required", + "postgres_upgrade_required", + "temporarily_unavailable", + ]), + }), + ], + { mode: "oneOf" }, +); export const V1GetLegacySigningKeyInput = Schema.Struct({ ref: Schema.String.check(Schema.isMinLength(20)) .check(Schema.isMaxLength(20)) @@ -2200,6 +2227,7 @@ export const V1GetOrganizationEntitlementsOutput = Schema.Struct({ "security.audit_logs_days", "security.questionnaire", "security.soc2_report", + "security.iso27001_certificate", "security.private_link", "security.enforce_mfa", "log.retention_days", @@ -2208,6 +2236,7 @@ export const V1GetOrganizationEntitlementsOutput = Schema.Struct({ "ipv4", "pitr.available_variants", "log_drains", + "audit_log_drains", "branching_limit", "branching_persistent", "auth.mfa_phone", @@ -2222,8 +2251,10 @@ export const V1GetOrganizationEntitlementsOutput = Schema.Struct({ "auth.advanced_auth_settings", "auth.performance_settings", "auth.password_hibp", + "auth.custom_oauth.max_providers", "backup.retention_days", "backup.restore_to_new_project", + "backup.schedule", "function.max_count", "function.size_limit_mb", "realtime.max_concurrent_users", @@ -2508,10 +2539,17 @@ export const V1GetPostgresUpgradeEligibilityOutput = Schema.Struct({ type: Schema.Literal("active_replication_slot"), slot_name: Schema.String, }), + Schema.Struct({ type: Schema.Literal("x86_architecture") }), + Schema.Struct({ type: Schema.Literal("project_hibernating") }), ], { mode: "oneOf" }, ), ), + warnings: Schema.Array( + Schema.Union([Schema.Struct({ type: Schema.Literal("pg_graphql_introspection_change") })], { + mode: "oneOf", + }), + ), }); export const V1GetPostgresUpgradeStatusInput = Schema.Struct({ ref: Schema.String.check(Schema.isMinLength(20)) @@ -3192,6 +3230,7 @@ export const V1ListAllBackupsOutput = Schema.Struct({ pitr_enabled: Schema.Boolean, backups: Schema.Array( Schema.Struct({ + id: Schema.Number.check(Schema.isInt()), is_physical_backup: Schema.Boolean, status: Schema.Literals([ "COMPLETED", @@ -3380,6 +3419,7 @@ export const V1ListJitAccessOutput = Schema.Struct({ ), }), ), + branches_only: Schema.optionalKey(Schema.Boolean), }), ), }), @@ -3750,6 +3790,12 @@ export const V1RestoreAProjectInput = Schema.Struct({ .check(Schema.isMaxLength(20)) .check(Schema.isPattern(new RegExp("^[a-z]+$"))), }); +export const V1RestorePhysicalBackupInput = Schema.Struct({ + ref: Schema.String.check(Schema.isMinLength(20)) + .check(Schema.isMaxLength(20)) + .check(Schema.isPattern(new RegExp("^[a-z]+$"))), + id: Schema.Number.check(Schema.isInt()), +}); export const V1RestorePitrBackupInput = Schema.Struct({ ref: Schema.String.check(Schema.isMinLength(20)) .check(Schema.isMaxLength(20)) @@ -3872,7 +3918,9 @@ export const V1UpdateABranchConfigOutput = Schema.Struct({ "MIGRATIONS_FAILED", "FUNCTIONS_DEPLOYED", "FUNCTIONS_FAILED", - ]), + ]).annotate({ + description: "This field is deprecated. List action runs to get branch status instead.", + }), created_at: Schema.String.annotate({ format: "date-time" }), updated_at: Schema.String.annotate({ format: "date-time" }), review_requested_at: Schema.optionalKey(Schema.String.annotate({ format: "date-time" })), @@ -4493,6 +4541,10 @@ export const V1UpdateAuthServiceConfigInput = Schema.Struct({ mfa_totp_verify_enabled: Schema.optionalKey(Schema.Union([Schema.Boolean, Schema.Null])), mfa_web_authn_enroll_enabled: Schema.optionalKey(Schema.Union([Schema.Boolean, Schema.Null])), mfa_web_authn_verify_enabled: Schema.optionalKey(Schema.Union([Schema.Boolean, Schema.Null])), + passkey_enabled: Schema.optionalKey(Schema.Boolean), + webauthn_rp_display_name: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + webauthn_rp_id: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + webauthn_rp_origins: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), mfa_phone_enroll_enabled: Schema.optionalKey(Schema.Union([Schema.Boolean, Schema.Null])), mfa_phone_verify_enabled: Schema.optionalKey(Schema.Union([Schema.Boolean, Schema.Null])), mfa_phone_max_frequency: Schema.optionalKey( @@ -4703,6 +4755,10 @@ export const V1UpdateAuthServiceConfigOutput = Schema.Struct({ mfa_phone_verify_enabled: Schema.Union([Schema.Boolean, Schema.Null]), mfa_web_authn_enroll_enabled: Schema.Union([Schema.Boolean, Schema.Null]), mfa_web_authn_verify_enabled: Schema.Union([Schema.Boolean, Schema.Null]), + passkey_enabled: Schema.Boolean, + webauthn_rp_display_name: Schema.Union([Schema.String, Schema.Null]), + webauthn_rp_id: Schema.Union([Schema.String, Schema.Null]), + webauthn_rp_origins: Schema.Union([Schema.String, Schema.Null]), mfa_phone_otp_length: Schema.Number.check(Schema.isInt()), mfa_phone_template: Schema.Union([Schema.String, Schema.Null]), mfa_phone_max_frequency: Schema.Union([Schema.Number.check(Schema.isInt()), Schema.Null]), @@ -4788,6 +4844,23 @@ export const V1UpdateAuthServiceConfigOutput = Schema.Struct({ custom_oauth_enabled: Schema.Boolean, custom_oauth_max_providers: Schema.Number.check(Schema.isInt()), }); +export const V1UpdateBackupScheduleInput = Schema.Struct({ + ref: Schema.String.check(Schema.isMinLength(20)) + .check(Schema.isMaxLength(20)) + .check(Schema.isPattern(new RegExp("^[a-z]+$"))), + schedule_for: Schema.String.annotate({ + description: "Time of day to schedule daily backups, in UTC. Format: HH:MM:SS.", + }), +}); +export const V1UpdateBackupScheduleOutput = Schema.Struct({ + schedule_for: Schema.String.annotate({ + description: "Time of day to schedule daily backups, in UTC. Format: HH:MM:SS.", + }), + updated_at: Schema.String.annotate({ + description: "Timestamp of when the backup schedule was last updated.", + format: "date-time", + }), +}); export const V1UpdateDatabasePasswordInput = Schema.Struct({ ref: Schema.String.check(Schema.isMinLength(20)) .check(Schema.isMaxLength(20)) @@ -4854,6 +4927,7 @@ export const V1UpdateJitAccessInput = Schema.Struct({ ), }), ), + branches_only: Schema.optionalKey(Schema.Boolean), }), ), }); @@ -4871,6 +4945,7 @@ export const V1UpdateJitAccessOutput = Schema.Struct({ ), }), ), + branches_only: Schema.optionalKey(Schema.Boolean), }), ), }); @@ -4878,25 +4953,25 @@ export const V1UpdateJitAccessConfigInput = Schema.Struct({ ref: Schema.String.check(Schema.isMinLength(20)) .check(Schema.isMaxLength(20)) .check(Schema.isPattern(new RegExp("^[a-z]+$"))), - state: Schema.Literals(["enabled", "disabled", "unavailable"]), + state: Schema.Literals(["enabled", "disabled"]), }); -export const V1UpdateJitAccessConfigOutput = Schema.Struct({ - user_id: Schema.String.annotate({ format: "uuid" }), - user_roles: Schema.Array( +export const V1UpdateJitAccessConfigOutput = Schema.Union( + [ Schema.Struct({ - role: Schema.String.check(Schema.isMinLength(1)), - expires_at: Schema.optionalKey(Schema.Number.check(Schema.isFinite())), - allowed_networks: Schema.optionalKey( - Schema.Struct({ - allowed_cidrs: Schema.optionalKey(Schema.Array(Schema.Struct({ cidr: Schema.String }))), - allowed_cidrs_v6: Schema.optionalKey( - Schema.Array(Schema.Struct({ cidr: Schema.String })), - ), - }), - ), + state: Schema.Literals(["enabled", "disabled"]), + appliedSuccessfully: Schema.optionalKey(Schema.Boolean), }), - ), -}); + Schema.Struct({ + state: Schema.Literal("unavailable"), + unavailableReason: Schema.Literals([ + "manual_migration_required", + "postgres_upgrade_required", + "temporarily_unavailable", + ]), + }), + ], + { mode: "oneOf" }, +); export const V1UpdateNetworkRestrictionsInput = Schema.Struct({ ref: Schema.String.check(Schema.isMinLength(20)) .check(Schema.isMaxLength(20)) @@ -5370,6 +5445,7 @@ export const V1ReadOnlyQueryOutput = Schema.Void; export const V1RemoveAReadReplicaOutput = Schema.Void; export const V1RemoveProjectAddonOutput = Schema.Void; export const V1RestoreAProjectOutput = Schema.Void; +export const V1RestorePhysicalBackupOutput = Schema.Void; export const V1RestorePitrBackupOutput = Schema.Void; export const V1RevokeTokenOutput = Schema.Void; export const V1RollbackMigrationsOutput = Schema.Void; @@ -5439,6 +5515,7 @@ export const openApiOperationIdMap = { "v1-get-an-organization": "v1GetAnOrganization", "v1-get-auth-service-config": "v1GetAuthServiceConfig", "v1-get-available-regions": "v1GetAvailableRegions", + "v1-get-backup-schedule": "v1GetBackupSchedule", "v1-get-database-disk": "v1GetDatabaseDisk", "v1-get-database-metadata": "v1GetDatabaseMetadata", "v1-get-database-openapi": "v1GetDatabaseOpenapi", @@ -5512,6 +5589,7 @@ export const openApiOperationIdMap = { "v1-reset-a-branch": "v1ResetABranch", "v1-restore-a-branch": "v1RestoreABranch", "v1-restore-a-project": "v1RestoreAProject", + "v1-restore-physical-backup": "v1RestorePhysicalBackup", "v1-restore-pitr-backup": "v1RestorePitrBackup", "v1-revoke-token": "v1RevokeToken", "v1-rollback-migrations": "v1RollbackMigrations", @@ -5525,6 +5603,7 @@ export const openApiOperationIdMap = { "v1-update-a-sso-provider": "v1UpdateASsoProvider", "v1-update-action-run-status": "v1UpdateActionRunStatus", "v1-update-auth-service-config": "v1UpdateAuthServiceConfig", + "v1-update-backup-schedule": "v1UpdateBackupSchedule", "v1-update-database-password": "v1UpdateDatabasePassword", "v1-update-hostname-config": "v1UpdateHostnameConfig", "v1-update-jit-access": "v1UpdateJitAccess", @@ -5975,7 +6054,7 @@ export const operationDefinitions = { method: "DELETE", path: "/v1/projects/{ref}/custom-hostname", pathParams: ["ref"], - queryParams: [], + queryParams: ["remove_addon"], headerParams: [], requestBody: { kind: "none" }, response: { kind: "void" }, @@ -6378,6 +6457,19 @@ export const operationDefinitions = { inputSchema: V1GetAvailableRegionsInput, outputSchema: V1GetAvailableRegionsOutput, }, + v1GetBackupSchedule: { + id: "v1GetBackupSchedule", + description: "Gets the backup schedule for a project", + method: "GET", + path: "/v1/projects/{ref}/database/backups/schedule", + pathParams: ["ref"], + queryParams: [], + headerParams: [], + requestBody: { kind: "none" }, + response: { kind: "json" }, + inputSchema: V1GetBackupScheduleInput, + outputSchema: V1GetBackupScheduleOutput, + }, v1GetDatabaseDisk: { id: "v1GetDatabaseDisk", description: "Get database disk attributes", @@ -6460,7 +6552,7 @@ export const operationDefinitions = { }, v1GetJitAccessConfig: { id: "v1GetJitAccessConfig", - description: "[Beta] Get project's just-in-time access configuration.", + description: "[Beta] Get project's temporary access configuration.", method: "GET", path: "/v1/projects/{ref}/jit-access", pathParams: ["ref"], @@ -7026,8 +7118,7 @@ export const operationDefinitions = { }, v1ListAllProjects: { id: "v1ListAllProjects", - description: - "Returns a list of all projects you've previously created.\n\nUse `/v1/organizations/{slug}/projects` instead when possible to get more precise results and pagination support.", + description: "Returns a list of all projects you've previously created.", method: "GET", path: "/v1/projects", pathParams: [], @@ -7350,6 +7441,19 @@ export const operationDefinitions = { inputSchema: V1RestoreAProjectInput, outputSchema: V1RestoreAProjectOutput, }, + v1RestorePhysicalBackup: { + id: "v1RestorePhysicalBackup", + description: "Restores a physical backup for a database", + method: "POST", + path: "/v1/projects/{ref}/database/backups/restore", + pathParams: ["ref"], + queryParams: [], + headerParams: [], + requestBody: { kind: "json", contentType: "application/json", fields: ["id"] }, + response: { kind: "void" }, + inputSchema: V1RestorePhysicalBackupInput, + outputSchema: V1RestorePhysicalBackupOutput, + }, v1RestorePitrBackup: { id: "v1RestorePitrBackup", description: "Restores a PITR backup for a database", @@ -7777,6 +7881,10 @@ export const operationDefinitions = { "mfa_totp_verify_enabled", "mfa_web_authn_enroll_enabled", "mfa_web_authn_verify_enabled", + "passkey_enabled", + "webauthn_rp_display_name", + "webauthn_rp_id", + "webauthn_rp_origins", "mfa_phone_enroll_enabled", "mfa_phone_verify_enabled", "mfa_phone_max_frequency", @@ -7794,6 +7902,20 @@ export const operationDefinitions = { inputSchema: V1UpdateAuthServiceConfigInput, outputSchema: V1UpdateAuthServiceConfigOutput, }, + v1UpdateBackupSchedule: { + id: "v1UpdateBackupSchedule", + description: + "Sets the time at which the daily backup runs. The change takes effect on the next backup window that includes the new time. If the new time has already passed for today, the first backup at the new time will occur the following day. It can only be updated 3 times per 24 hours.", + method: "PATCH", + path: "/v1/projects/{ref}/database/backups/schedule", + pathParams: ["ref"], + queryParams: [], + headerParams: [], + requestBody: { kind: "json", contentType: "application/json", fields: ["schedule_for"] }, + response: { kind: "json" }, + inputSchema: V1UpdateBackupScheduleInput, + outputSchema: V1UpdateBackupScheduleOutput, + }, v1UpdateDatabasePassword: { id: "v1UpdateDatabasePassword", description: "Updates the database password", @@ -7835,7 +7957,7 @@ export const operationDefinitions = { }, v1UpdateJitAccessConfig: { id: "v1UpdateJitAccessConfig", - description: "[Beta] Update project's just-in-time access configuration.", + description: "[Beta] Update project's temporary access configuration.", method: "PUT", path: "/v1/projects/{ref}/jit-access", pathParams: ["ref"], diff --git a/packages/api/src/generated/effect-client.ts b/packages/api/src/generated/effect-client.ts index ae7e49835..4c2fe4600 100644 --- a/packages/api/src/generated/effect-client.ts +++ b/packages/api/src/generated/effect-client.ts @@ -790,6 +790,20 @@ export const versionedEffectOperations = { input, ); }), + getBackupSchedule: ( + input: typeof operationDefinitions.v1GetBackupSchedule.inputSchema.Type, + ): Effect.Effect< + typeof operationDefinitions.v1GetBackupSchedule.outputSchema.Type, + SupabaseApiError, + SupabaseApiClient + > => + Effect.gen(function* () { + const client = yield* SupabaseApiClient; + return yield* client.execute<"v1GetBackupSchedule">( + operationDefinitions.v1GetBackupSchedule, + input, + ); + }), getDatabaseDisk: ( input: typeof operationDefinitions.v1GetDatabaseDisk.inputSchema.Type, ): Effect.Effect< @@ -1788,6 +1802,20 @@ export const versionedEffectOperations = { input, ); }), + restorePhysicalBackup: ( + input: typeof operationDefinitions.v1RestorePhysicalBackup.inputSchema.Type, + ): Effect.Effect< + typeof operationDefinitions.v1RestorePhysicalBackup.outputSchema.Type, + SupabaseApiError, + SupabaseApiClient + > => + Effect.gen(function* () { + const client = yield* SupabaseApiClient; + return yield* client.execute<"v1RestorePhysicalBackup">( + operationDefinitions.v1RestorePhysicalBackup, + input, + ); + }), restorePitrBackup: ( input: typeof operationDefinitions.v1RestorePitrBackup.inputSchema.Type, ): Effect.Effect< @@ -1961,6 +1989,20 @@ export const versionedEffectOperations = { input, ); }), + updateBackupSchedule: ( + input: typeof operationDefinitions.v1UpdateBackupSchedule.inputSchema.Type, + ): Effect.Effect< + typeof operationDefinitions.v1UpdateBackupSchedule.outputSchema.Type, + SupabaseApiError, + SupabaseApiClient + > => + Effect.gen(function* () { + const client = yield* SupabaseApiClient; + return yield* client.execute<"v1UpdateBackupSchedule">( + operationDefinitions.v1UpdateBackupSchedule, + input, + ); + }), updateDatabasePassword: ( input: typeof operationDefinitions.v1UpdateDatabasePassword.inputSchema.Type, ): Effect.Effect< @@ -2453,6 +2495,10 @@ export function executeApiClientOperation( return Schema.decodeUnknownEffect(operationDefinitions.v1GetAvailableRegions.inputSchema)( input, ).pipe(Effect.flatMap((decoded) => api.v1.getAvailableRegions(decoded))); + case "v1GetBackupSchedule": + return Schema.decodeUnknownEffect(operationDefinitions.v1GetBackupSchedule.inputSchema)( + input, + ).pipe(Effect.flatMap((decoded) => api.v1.getBackupSchedule(decoded))); case "v1GetDatabaseDisk": return Schema.decodeUnknownEffect(operationDefinitions.v1GetDatabaseDisk.inputSchema)( input, @@ -2745,6 +2791,10 @@ export function executeApiClientOperation( return Schema.decodeUnknownEffect(operationDefinitions.v1RestoreAProject.inputSchema)( input, ).pipe(Effect.flatMap((decoded) => api.v1.restoreAProject(decoded))); + case "v1RestorePhysicalBackup": + return Schema.decodeUnknownEffect(operationDefinitions.v1RestorePhysicalBackup.inputSchema)( + input, + ).pipe(Effect.flatMap((decoded) => api.v1.restorePhysicalBackup(decoded))); case "v1RestorePitrBackup": return Schema.decodeUnknownEffect(operationDefinitions.v1RestorePitrBackup.inputSchema)( input, @@ -2797,6 +2847,10 @@ export function executeApiClientOperation( return Schema.decodeUnknownEffect(operationDefinitions.v1UpdateAuthServiceConfig.inputSchema)( input, ).pipe(Effect.flatMap((decoded) => api.v1.updateAuthServiceConfig(decoded))); + case "v1UpdateBackupSchedule": + return Schema.decodeUnknownEffect(operationDefinitions.v1UpdateBackupSchedule.inputSchema)( + input, + ).pipe(Effect.flatMap((decoded) => api.v1.updateBackupSchedule(decoded))); case "v1UpdateDatabasePassword": return Schema.decodeUnknownEffect(operationDefinitions.v1UpdateDatabasePassword.inputSchema)( input, diff --git a/packages/api/src/generated/openapi.json b/packages/api/src/generated/openapi.json index ef0825e9d..9ddc8fced 100644 --- a/packages/api/src/generated/openapi.json +++ b/packages/api/src/generated/openapi.json @@ -617,7 +617,7 @@ }, "/v1/projects": { "get": { - "description": "Returns a list of all projects you've previously created.\n\nUse `/v1/organizations/{slug}/projects` instead when possible to get more precise results and pagination support.", + "description": "Returns a list of all projects you've previously created.", "operationId": "v1-list-all-projects", "parameters": [], "responses": { @@ -633,6 +633,15 @@ } } } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden action" + }, + "429": { + "description": "Rate limit exceeded" } }, "security": [ @@ -677,6 +686,15 @@ } } } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden action" + }, + "429": { + "description": "Rate limit exceeded" } }, "security": [ @@ -803,6 +821,15 @@ } } }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden action" + }, + "429": { + "description": "Rate limit exceeded" + }, "500": { "description": "Unexpected error listing organizations" } @@ -850,6 +877,15 @@ } } }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden action" + }, + "429": { + "description": "Rate limit exceeded" + }, "500": { "description": "Unexpected error creating an organization" } @@ -1136,6 +1172,15 @@ "responses": { "204": { "description": "" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden action" + }, + "429": { + "description": "Rate limit exceeded" } }, "security": [ @@ -1216,6 +1261,15 @@ } } }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden action" + }, + "429": { + "description": "Rate limit exceeded" + }, "500": { "description": "Failed to list user's SQL snippets" } @@ -1266,6 +1320,15 @@ } } }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden action" + }, + "429": { + "description": "Rate limit exceeded" + }, "500": { "description": "Failed to retrieve SQL snippet" } @@ -2261,15 +2324,6 @@ } } }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Forbidden action" - }, - "429": { - "description": "Rate limit exceeded" - }, "500": { "description": "Failed to retrieve database branches" } @@ -2335,15 +2389,6 @@ } } }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Forbidden action" - }, - "429": { - "description": "Rate limit exceeded" - }, "500": { "description": "Failed to create database branch" } @@ -2464,15 +2509,6 @@ } } }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Forbidden action" - }, - "429": { - "description": "Rate limit exceeded" - }, "500": { "description": "Failed to fetch database branch" } @@ -2576,6 +2612,16 @@ "example": "abcdefghijklmnopqrst", "type": "string" } + }, + { + "name": "remove_addon", + "required": false, + "in": "query", + "description": "If true, also removes the custom domain add-on from the project subscription.", + "schema": { + "default": "false", + "type": "boolean" + } } ], "responses": { @@ -2835,7 +2881,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/JitAccessResponse" + "$ref": "#/components/schemas/JitStateResponse" } } } @@ -2850,7 +2896,7 @@ "description": "Rate limit exceeded" }, "500": { - "description": "Failed to retrieve project's JIT access config" + "description": "Failed to retrieve project's temporary access configuration." } }, "security": [ @@ -2861,7 +2907,7 @@ "fga_permissions": ["project_admin_read"] } ], - "summary": "[Beta] Get project's just-in-time access configuration.", + "summary": "[Beta] Get project's temporary access configuration.", "tags": ["Database"], "x-badges": [ { @@ -2905,7 +2951,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/JitAccessResponse" + "$ref": "#/components/schemas/JitStateResponse" } } } @@ -2920,7 +2966,7 @@ "description": "Rate limit exceeded" }, "500": { - "description": "Failed to update project's just-in-time access configuration." + "description": "Failed to update project's temporary access configuration." } }, "security": [ @@ -2931,7 +2977,7 @@ "fga_permissions": ["project_admin_write"] } ], - "summary": "[Beta] Update project's just-in-time access configuration.", + "summary": "[Beta] Update project's temporary access configuration.", "tags": ["Database"], "x-badges": [ { @@ -10316,9 +10362,9 @@ "x-oauth-scope": "database:read" } }, - "/v1/projects/{ref}/database/backups/undo": { + "/v1/projects/{ref}/database/backups/restore": { "post": { - "operationId": "v1-undo", + "operationId": "v1-restore-physical-backup", "parameters": [ { "name": "ref", @@ -10339,7 +10385,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V1UndoBody" + "$ref": "#/components/schemas/V1RestoreBackupBody" } } } @@ -10366,7 +10412,7 @@ "fga_permissions": ["backups_write"] } ], - "summary": "Initiates an undo to a given restore point", + "summary": "Restores a physical backup for a database", "tags": ["Database"], "x-badges": [ { @@ -10379,19 +10425,20 @@ "x-oauth-scope": "database:write" } }, - "/v1/organizations/{slug}/entitlements": { + "/v1/projects/{ref}/database/backups/schedule": { "get": { - "description": "Returns the entitlements available to the organization based on their plan and any overrides.", - "operationId": "v1-get-organization-entitlements", + "operationId": "v1-get-backup-schedule", "parameters": [ { - "name": "slug", + "name": "ref", "required": true, "in": "path", - "description": "Organization slug", + "description": "Project ref", "schema": { - "pattern": "^[\\w-]+$", - "example": "tsrqponmlkjihgfedcba", + "minLength": 20, + "maxLength": 20, + "pattern": "^[a-z]+$", + "example": "abcdefghijklmnopqrst", "type": "string" } } @@ -10402,7 +10449,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V1ListEntitlementsResponse" + "$ref": "#/components/schemas/V1BackupScheduleResponse" } } } @@ -10410,11 +10457,20 @@ "401": { "description": "Unauthorized" }, + "402": { + "description": "Feature requires a higher plan" + }, "403": { "description": "Forbidden action" }, + "404": { + "description": "Project or backup schedule not found" + }, "429": { "description": "Rate limit exceeded" + }, + "500": { + "description": "Failed to retrieve backup schedule" } }, "security": [ @@ -10422,50 +10478,79 @@ "bearer": [] }, { - "fga_permissions": ["organization_projects_read"] + "fga_permissions": ["backups_read"] } ], - "summary": "Get entitlements for an organization", - "tags": ["Organizations"], + "summary": "Gets the backup schedule for a project", + "tags": ["Database"], "x-badges": [ { - "name": "OAuth scope: organizations:read", + "name": "OAuth scope: database:read", "position": "after" } ], - "x-endpoint-owners": ["billing"], - "x-oauth-scope": "organizations:read" - } - }, - "/v1/organizations/{slug}/members": { - "get": { - "operationId": "v1-list-organization-members", + "x-endpoint-owners": ["infra"], + "x-oauth-scope": "database:read" + }, + "patch": { + "description": "Sets the time at which the daily backup runs. The change takes effect on the next backup window that includes the new time. If the new time has already passed for today, the first backup at the new time will occur the following day. It can only be updated 3 times per 24 hours.", + "operationId": "v1-update-backup-schedule", "parameters": [ { - "name": "slug", + "name": "ref", "required": true, "in": "path", - "description": "Organization slug", + "description": "Project ref", "schema": { - "pattern": "^[\\w-]+$", - "example": "tsrqponmlkjihgfedcba", + "minLength": 20, + "maxLength": 20, + "pattern": "^[a-z]+$", + "example": "abcdefghijklmnopqrst", "type": "string" } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V1UpdateBackupScheduleBody" + } + } + } + }, "responses": { "200": { "description": "", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/V1OrganizationMemberResponse" - } + "$ref": "#/components/schemas/V1BackupScheduleResponse" } } } + }, + "400": { + "description": "Invalid schedule_for format" + }, + "401": { + "description": "Unauthorized" + }, + "402": { + "description": "Feature requires a higher plan" + }, + "403": { + "description": "Forbidden action" + }, + "404": { + "description": "Project or backup schedule not found" + }, + "429": { + "description": "Rate limit exceeded" + }, + "500": { + "description": "Failed to update backup schedule" } }, "security": [ @@ -10473,47 +10558,52 @@ "bearer": [] }, { - "fga_permissions": ["members_read"] + "fga_permissions": ["backups_write"] } ], - "summary": "List members of an organization", - "tags": ["Organizations"], + "summary": "Updates the backup schedule time for a project", + "tags": ["Database"], "x-badges": [ { - "name": "OAuth scope: organizations:read", + "name": "OAuth scope: database:write", "position": "after" } ], - "x-endpoint-owners": ["management-api"], - "x-oauth-scope": "organizations:read" + "x-endpoint-owners": ["infra"], + "x-oauth-scope": "database:write" } }, - "/v1/organizations/{slug}": { - "get": { - "operationId": "v1-get-an-organization", + "/v1/projects/{ref}/database/backups/undo": { + "post": { + "operationId": "v1-undo", "parameters": [ { - "name": "slug", + "name": "ref", "required": true, "in": "path", - "description": "Organization slug", + "description": "Project ref", "schema": { - "pattern": "^[\\w-]+$", - "example": "tsrqponmlkjihgfedcba", + "minLength": 20, + "maxLength": 20, + "pattern": "^[a-z]+$", + "example": "abcdefghijklmnopqrst", "type": "string" } } ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V1OrganizationSlugResponse" - } + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V1UndoBody" } } + } + }, + "responses": { + "201": { + "description": "" }, "401": { "description": "Unauthorized" @@ -10530,24 +10620,26 @@ "bearer": [] }, { - "fga_permissions": ["organization_admin_read"] + "fga_permissions": ["backups_write"] } ], - "summary": "Gets information about the organization", - "tags": ["Organizations"], + "summary": "Initiates an undo to a given restore point", + "tags": ["Database"], "x-badges": [ { - "name": "OAuth scope: organizations:read", + "name": "OAuth scope: database:write", "position": "after" } ], - "x-endpoint-owners": ["management-api"], - "x-oauth-scope": "organizations:read" + "x-endpoint-owners": ["infra"], + "x-internal": true, + "x-oauth-scope": "database:write" } }, - "/v1/organizations/{slug}/project-claim/{token}": { + "/v1/organizations/{slug}/entitlements": { "get": { - "operationId": "v1-get-organization-project-claim", + "description": "Returns the entitlements available to the organization based on their plan and any overrides.", + "operationId": "v1-get-organization-entitlements", "parameters": [ { "name": "slug", @@ -10559,15 +10651,6 @@ "example": "tsrqponmlkjihgfedcba", "type": "string" } - }, - { - "name": "token", - "required": true, - "in": "path", - "schema": { - "example": "0123456789abcdef0123456789abcdef01234567", - "type": "string" - } } ], "responses": { @@ -10576,7 +10659,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OrganizationProjectClaimResponse" + "$ref": "#/components/schemas/V1ListEntitlementsResponse" } } } @@ -10596,7 +10679,181 @@ "bearer": [] }, { - "fga_permissions": ["organization_admin_write"] + "fga_permissions": ["organization_admin_read"] + } + ], + "summary": "Get entitlements for an organization", + "tags": ["Organizations"], + "x-badges": [ + { + "name": "OAuth scope: organizations:read", + "position": "after" + } + ], + "x-endpoint-owners": ["billing"], + "x-oauth-scope": "organizations:read" + } + }, + "/v1/organizations/{slug}/members": { + "get": { + "operationId": "v1-list-organization-members", + "parameters": [ + { + "name": "slug", + "required": true, + "in": "path", + "description": "Organization slug", + "schema": { + "pattern": "^[\\w-]+$", + "example": "tsrqponmlkjihgfedcba", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V1OrganizationMemberResponse" + } + } + } + } + } + }, + "security": [ + { + "bearer": [] + }, + { + "fga_permissions": ["members_read"] + } + ], + "summary": "List members of an organization", + "tags": ["Organizations"], + "x-badges": [ + { + "name": "OAuth scope: organizations:read", + "position": "after" + } + ], + "x-endpoint-owners": ["management-api"], + "x-oauth-scope": "organizations:read" + } + }, + "/v1/organizations/{slug}": { + "get": { + "operationId": "v1-get-an-organization", + "parameters": [ + { + "name": "slug", + "required": true, + "in": "path", + "description": "Organization slug", + "schema": { + "pattern": "^[\\w-]+$", + "example": "tsrqponmlkjihgfedcba", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V1OrganizationSlugResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden action" + }, + "429": { + "description": "Rate limit exceeded" + } + }, + "security": [ + { + "bearer": [] + }, + { + "fga_permissions": ["organization_admin_read"] + } + ], + "summary": "Gets information about the organization", + "tags": ["Organizations"], + "x-badges": [ + { + "name": "OAuth scope: organizations:read", + "position": "after" + } + ], + "x-endpoint-owners": ["management-api"], + "x-oauth-scope": "organizations:read" + } + }, + "/v1/organizations/{slug}/project-claim/{token}": { + "get": { + "operationId": "v1-get-organization-project-claim", + "parameters": [ + { + "name": "slug", + "required": true, + "in": "path", + "description": "Organization slug", + "schema": { + "pattern": "^[\\w-]+$", + "example": "tsrqponmlkjihgfedcba", + "type": "string" + } + }, + { + "name": "token", + "required": true, + "in": "path", + "schema": { + "example": "0123456789abcdef0123456789abcdef01234567", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrganizationProjectClaimResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden action" + }, + "429": { + "description": "Rate limit exceeded" + } + }, + "security": [ + { + "bearer": [] + }, + { + "fga_permissions": ["organization_admin_write"] } ], "summary": "Gets project details for the specified organization and claim token", @@ -10741,6 +10998,15 @@ } } }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden action" + }, + "429": { + "description": "Rate limit exceeded" + }, "500": { "description": "Failed to retrieve projects" } @@ -10755,7 +11021,14 @@ ], "summary": "Gets all projects for the given organization", "tags": ["Projects"], - "x-endpoint-owners": ["management-api"] + "x-badges": [ + { + "name": "OAuth scope: projects:read", + "position": "after" + } + ], + "x-endpoint-owners": ["management-api"], + "x-oauth-scope": "projects:read" } } }, @@ -10912,7 +11185,9 @@ "MIGRATIONS_FAILED", "FUNCTIONS_DEPLOYED", "FUNCTIONS_FAILED" - ] + ], + "description": "This field is deprecated. List action runs to get branch status instead.", + "deprecated": true }, "created_at": { "type": "string", @@ -11015,126 +11290,6 @@ }, "required": ["message"] }, - "V1ListProjectsPaginatedResponse": { - "type": "object", - "properties": { - "projects": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "number" - }, - "cloud_provider": { - "type": "string" - }, - "inserted_at": { - "type": "string", - "nullable": true - }, - "name": { - "type": "string" - }, - "organization_id": { - "type": "number" - }, - "organization_slug": { - "type": "string" - }, - "ref": { - "type": "string" - }, - "region": { - "type": "string" - }, - "status": { - "type": "string" - }, - "subscription_id": { - "type": "string", - "nullable": true - }, - "is_branch_enabled": { - "type": "boolean" - }, - "is_physical_backups_enabled": { - "type": "boolean", - "nullable": true - }, - "preview_branch_refs": { - "type": "array", - "items": { - "type": "string" - } - }, - "disk_volume_size_gb": { - "type": "number" - }, - "infra_compute_size": { - "type": "string", - "enum": [ - "pico", - "nano", - "micro", - "small", - "medium", - "large", - "xlarge", - "2xlarge", - "4xlarge", - "8xlarge", - "12xlarge", - "16xlarge", - "24xlarge", - "24xlarge_optimized_memory", - "24xlarge_optimized_cpu", - "24xlarge_high_memory", - "48xlarge", - "48xlarge_optimized_memory", - "48xlarge_optimized_cpu", - "48xlarge_high_memory" - ] - } - }, - "required": [ - "id", - "cloud_provider", - "inserted_at", - "name", - "organization_id", - "organization_slug", - "ref", - "region", - "status", - "subscription_id", - "is_branch_enabled", - "is_physical_backups_enabled", - "preview_branch_refs" - ] - } - }, - "pagination": { - "type": "object", - "properties": { - "count": { - "type": "number", - "description": "Total number of projects. Use this to calculate the total number of pages." - }, - "limit": { - "type": "number", - "description": "Maximum number of projects per page (actual number may be less)" - }, - "offset": { - "type": "number", - "description": "Number of projects skipped in this response" - } - }, - "required": ["count", "limit", "offset"] - } - }, - "required": ["projects", "pagination"] - }, "V1ProjectWithDatabaseResponse": { "type": "object", "properties": { @@ -12450,67 +12605,50 @@ "custom_hostname": "docs.example.com" } }, - "JitAccessResponse": { - "type": "object", - "properties": { - "user_id": { - "type": "string", - "format": "uuid" + "JitStateResponse": { + "discriminator": { + "propertyName": "state" + }, + "oneOf": [ + { + "type": "object", + "properties": { + "state": { + "type": "string", + "enum": ["enabled", "disabled"] + }, + "appliedSuccessfully": { + "type": "boolean" + } + }, + "required": ["state"] }, - "user_roles": { - "type": "array", - "items": { - "type": "object", - "properties": { - "role": { - "type": "string", - "minLength": 1 - }, - "expires_at": { - "type": "number" - }, - "allowed_networks": { - "type": "object", - "properties": { - "allowed_cidrs": { - "type": "array", - "items": { - "type": "object", - "properties": { - "cidr": { - "type": "string" - } - }, - "required": ["cidr"] - } - }, - "allowed_cidrs_v6": { - "type": "array", - "items": { - "type": "object", - "properties": { - "cidr": { - "type": "string" - } - }, - "required": ["cidr"] - } - } - } - } + { + "type": "object", + "properties": { + "state": { + "type": "string", + "enum": ["unavailable"] }, - "required": ["role"] - } + "unavailableReason": { + "type": "string", + "enum": [ + "manual_migration_required", + "postgres_upgrade_required", + "temporarily_unavailable" + ] + } + }, + "required": ["state", "unavailableReason"] } - }, - "required": ["user_id", "user_roles"] + ] }, "JitAccessRequestRequest": { "type": "object", "properties": { "state": { "type": "string", - "enum": ["enabled", "disabled", "unavailable"] + "enum": ["enabled", "disabled"] } }, "required": ["state"], @@ -13270,6 +13408,46 @@ } }, "required": ["type", "slot_name"] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["x86_architecture"] + } + }, + "required": ["type"] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["project_hibernating"] + } + }, + "required": ["type"] + } + ] + } + }, + "warnings": { + "type": "array", + "items": { + "discriminator": { + "propertyName": "type" + }, + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["pg_graphql_introspection_change"] + } + }, + "required": ["type"] } ] } @@ -13286,7 +13464,8 @@ "objects_to_be_dropped", "unsupported_extensions", "user_defined_objects_in_internal_schemas", - "validation_errors" + "validation_errors", + "warnings" ] }, "DatabaseUpgradeStatusResponse": { @@ -14462,6 +14641,21 @@ "type": "boolean", "nullable": true }, + "passkey_enabled": { + "type": "boolean" + }, + "webauthn_rp_display_name": { + "type": "string", + "nullable": true + }, + "webauthn_rp_id": { + "type": "string", + "nullable": true + }, + "webauthn_rp_origins": { + "type": "string", + "nullable": true + }, "mfa_phone_otp_length": { "type": "integer" }, @@ -14902,6 +15096,10 @@ "mfa_phone_verify_enabled", "mfa_web_authn_enroll_enabled", "mfa_web_authn_verify_enabled", + "passkey_enabled", + "webauthn_rp_display_name", + "webauthn_rp_id", + "webauthn_rp_origins", "mfa_phone_otp_length", "mfa_phone_template", "mfa_phone_max_frequency", @@ -15899,6 +16097,21 @@ "type": "boolean", "nullable": true }, + "passkey_enabled": { + "type": "boolean" + }, + "webauthn_rp_display_name": { + "type": "string", + "nullable": true + }, + "webauthn_rp_id": { + "type": "string", + "nullable": true + }, + "webauthn_rp_origins": { + "type": "string", + "nullable": true + }, "mfa_phone_enroll_enabled": { "type": "boolean", "nullable": true @@ -16907,6 +17120,64 @@ }, "required": ["message"] }, + "JitAccessResponse": { + "type": "object", + "properties": { + "user_id": { + "type": "string", + "format": "uuid" + }, + "user_roles": { + "type": "array", + "items": { + "type": "object", + "properties": { + "role": { + "type": "string", + "minLength": 1 + }, + "expires_at": { + "type": "number" + }, + "allowed_networks": { + "type": "object", + "properties": { + "allowed_cidrs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "cidr": { + "type": "string" + } + }, + "required": ["cidr"] + } + }, + "allowed_cidrs_v6": { + "type": "array", + "items": { + "type": "object", + "properties": { + "cidr": { + "type": "string" + } + }, + "required": ["cidr"] + } + } + } + }, + "branches_only": { + "type": "boolean" + } + }, + "required": ["role"] + } + } + }, + "required": ["user_id", "user_roles"] + }, "AuthorizeJitAccessBody": { "type": "object", "properties": { @@ -16970,6 +17241,9 @@ } } } + }, + "branches_only": { + "type": "boolean" } }, "required": ["role"] @@ -17029,6 +17303,9 @@ } } } + }, + "branches_only": { + "type": "boolean" } }, "required": ["role"] @@ -17089,6 +17366,9 @@ } } } + }, + "branches_only": { + "type": "boolean" } }, "required": ["role"] @@ -17108,7 +17388,8 @@ "cidr": "203.0.113.0/24" } ] - } + }, + "branches_only": false } ] } @@ -19063,6 +19344,9 @@ "items": { "type": "object", "properties": { + "id": { + "type": "integer" + }, "is_physical_backup": { "type": "boolean" }, @@ -19074,7 +19358,7 @@ "type": "string" } }, - "required": ["is_physical_backup", "status", "inserted_at"] + "required": ["id", "is_physical_backup", "status", "inserted_at"] } }, "physical_backup_data": { @@ -19136,6 +19420,49 @@ }, "required": ["name", "status", "completed_on"] }, + "V1RestoreBackupBody": { + "type": "object", + "properties": { + "id": { + "type": "integer" + } + }, + "required": ["id"], + "example": { + "id": 12345 + } + }, + "V1BackupScheduleResponse": { + "type": "object", + "properties": { + "schedule_for": { + "type": "string", + "description": "Time of day to schedule daily backups, in UTC. Format: HH:MM:SS.", + "example": "04:00:00" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "description": "Timestamp of when the backup schedule was last updated.", + "example": "2026-05-04T14:40:44+00:00" + } + }, + "required": ["schedule_for", "updated_at"] + }, + "V1UpdateBackupScheduleBody": { + "type": "object", + "properties": { + "schedule_for": { + "type": "string", + "description": "Time of day to schedule daily backups, in UTC. Format: HH:MM:SS.", + "example": "04:00:00" + } + }, + "required": ["schedule_for"], + "example": { + "schedule_for": "04:00:00" + } + }, "V1UndoBody": { "type": "object", "properties": { @@ -19177,6 +19504,7 @@ "security.audit_logs_days", "security.questionnaire", "security.soc2_report", + "security.iso27001_certificate", "security.private_link", "security.enforce_mfa", "log.retention_days", @@ -19185,6 +19513,7 @@ "ipv4", "pitr.available_variants", "log_drains", + "audit_log_drains", "branching_limit", "branching_persistent", "auth.mfa_phone", @@ -19199,8 +19528,10 @@ "auth.advanced_auth_settings", "auth.performance_settings", "auth.password_hibp", + "auth.custom_oauth.max_providers", "backup.retention_days", "backup.restore_to_new_project", + "backup.schedule", "function.max_count", "function.size_limit_mb", "realtime.max_concurrent_users", diff --git a/packages/cli-test-helpers/src/harness.ts b/packages/cli-test-helpers/src/harness.ts index 112b39f82..2a19abadf 100644 --- a/packages/cli-test-helpers/src/harness.ts +++ b/packages/cli-test-helpers/src/harness.ts @@ -121,12 +121,15 @@ export async function exec( ...opts?.env, }; - // The Go CLI (and the ts-legacy CLI which shells out to Go) uses a profile - // system rather than SUPABASE_API_URL. Write a temporary profile file - // pointing to the replay server. SUPABASE_PROFILE is picked up by Go's viper - // (prefix SUPABASE_ + AutomaticEnv). For ts-legacy, the profile file is - // inherited by the Go subprocess because it spawns with extendEnv: true. - // ts-next reads SUPABASE_API_URL directly, so it doesn't need a profile file. + // The Go CLI uses a profile system rather than SUPABASE_API_URL. Write a + // temporary profile file pointing to the replay server. + // - Go's viper reads SUPABASE_PROFILE as a config file path (prefix + // SUPABASE_ + AutomaticEnv) when the value isn't a built-in profile name. + // - The ts-legacy CLI mirrors this dual semantics in `LegacyCliConfig` + // (built-in name first, YAML file path second) for any natively-ported + // command; proxy-wrapped commands still shell out to Go which reads the + // same file directly. + // - ts-next reads SUPABASE_API_URL directly, so it doesn't need a profile file. let profilePath: string | undefined; if (harness.target === "go" || harness.target === "ts-legacy") { profilePath = join(tmpdir(), `cli-e2e-profile-${randomUUID()}.yaml`); diff --git a/packages/cli-test-helpers/src/normalize.ts b/packages/cli-test-helpers/src/normalize.ts index 5fa42d0eb..6fafaf3ce 100644 --- a/packages/cli-test-helpers/src/normalize.ts +++ b/packages/cli-test-helpers/src/normalize.ts @@ -71,6 +71,13 @@ export function normalize(output: string): string { ) // 12. Go goroutine stack trace blocks (goroutine N [state]:\n...) .replace(/^goroutine \d+ \[.*?\]:(?:\n[^\n]+)*/gm, "") + // 12b. github.com/go-errors/errors stack frames. The Go CLI prints these in + // dev builds (`utils.Version == ""`) before the actual error message: + // (0xADDR) + // \t: + // The TS port intentionally doesn't reconstruct these — strip the + // frame block plus the trailing blank line so parity comparisons ignore them. + .replace(/(?:^ \(0xADDR\)\n\t[^\n]+\n)+\n?/gm, "") // 13. Node/Bun stack trace lines (one or more consecutive " at …" lines) .replace(/(?:^[ \t]+at [^\n]+\n?)+/gm, "\n") // 14. File reference line numbers (file.ts:123 or file.ts:123:45) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index da3dd252e..66a956fd9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -161,9 +161,15 @@ importers: semantic-release: specifier: ^24.2.9 version: 24.2.9(typescript@6.0.3) + smol-toml: + specifier: ^1.6.1 + version: 1.6.1 vitest: specifier: 'catalog:' version: 4.1.6(@types/node@25.8.0)(@vitest/coverage-istanbul@4.1.6)(vite@8.0.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@25.8.0)(esbuild@0.27.4)(jiti@2.7.0)(yaml@2.9.0)) + yaml: + specifier: ^2.9.0 + version: 2.9.0 optionalDependencies: '@supabase/cli-darwin-arm64': specifier: workspace:* @@ -10392,7 +10398,7 @@ snapshots: make-dir@4.0.0: dependencies: - semver: 7.8.0 + semver: 7.7.4 markdown-extensions@2.0.0: {} @@ -11892,7 +11898,7 @@ snapshots: dependencies: '@img/colour': 1.1.0 detect-libc: 2.1.2 - semver: 7.8.0 + semver: 7.7.4 optionalDependencies: '@img/sharp-darwin-arm64': 0.34.5 '@img/sharp-darwin-x64': 0.34.5 From c16564aad2462b3c8be0775a996bd516eafb731a Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 22 May 2026 13:03:19 +0100 Subject: [PATCH 03/13] feat(cli): port ssl-enforcement to native TypeScript (#5340) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the Phase-0 Go-binary proxies for `supabase ssl-enforcement get` and `supabase ssl-enforcement update` with native Effect handlers. Output, error messages, exit codes, and filesystem side effects are byte-identical to the Go CLI. ## What changed - **`ssl-enforcement get` / `update` are now native TS** — both handlers call the typed Management API client directly, with `withCommandInstrumentation` + `withJsonErrorHandling` middleware, and honour all five Go `--output {pretty,json,yaml,toml,env}` encoders as well as the TS `--output-format {text,json,stream-json}`. - **Hoist refactor**: three artifacts move from `apps/cli/src/legacy/commands/backups/` to a new `apps/cli/src/legacy/shared/` directory because they are now reused across command families: - `legacyManagementApiRuntimeLayer` (was `legacyBackupsRuntimeLayer`) - `mapLegacyHttpError` (was `mapLegacyBackupHttpError`) — generic over the network/status error class pair - Go-compatible `encodeGoJson` / `encodeYaml` / `encodeToml` / `encodeEnv` — `encodeGoJson` now takes an optional `nullForEmptyArrays` option so backups can preserve its PITR-only `"backups": null` shape while ssl-enforcement omits it - **Backups list/restore call sites are refactored** in the same change to use the hoisted shared modules. No behaviour change for backups. ## Reviewer-relevant context - The `update` handler validates the mutually-exclusive `--enable-db-ssl-enforcement` / `--disable-db-ssl-enforcement` flags at handler entry — Effect CLI has no cobra-equivalent `MarkFlagsMutuallyExclusive`. Validation error messages are verbatim cobra strings for parity with the Go binary. - `Effect.ensuring` nesting in both handlers mirrors Go's `PersistentPostRun`: `telemetryState.flush` wraps the entire body (so it flushes even on ref-resolution failure), while `linkedProjectCache.cache(ref)` only wraps the post-resolution sub-effect (it requires a resolved ref). - The SSL-enforcement `SIDE_EFFECTS.md` files were previously stubs with wrong API paths (`/v1/projects/{ref}/config/ssl-enforcement`) and wrong response shapes (`{enforced, override_enabled}`). Both are rewritten with the correct path (`/v1/projects/{ref}/ssl-enforcement`) and response (`{currentConfig: {database}, appliedSuccessfully}`). - `encodeEnv` was hardened to escape `\n` / `\r` / `\t` (Go `%q` parity). Latent bug — boolean-only ssl-enforcement schema never triggered it, but fixing it now in the shared encoder prevents future ported commands with string fields from injecting newlines into env output that a downstream `eval` / `source` would treat as separate KEY=VALUE assignments. - `docs/go-cli-porting-status.md` flips both ssl-enforcement rows from `wrapped` to `ported`. Closes CLI-1297 --- apps/cli/docs/go-cli-porting-status.md | 4 +- .../legacy/commands/backups/backups.errors.ts | 70 +- .../legacy/commands/backups/backups.layers.ts | 47 -- .../commands/backups/list/list.command.ts | 4 +- .../commands/backups/list/list.handler.ts | 13 +- .../backups/restore/restore.command.ts | 4 +- .../backups/restore/restore.handler.ts | 4 +- .../ssl-enforcement/get/SIDE_EFFECTS.md | 87 +- .../ssl-enforcement/get/get.command.ts | 11 +- .../ssl-enforcement/get/get.handler.ts | 85 +- .../get/get.integration.test.ts | 578 ++++++++++++++ .../ssl-enforcement/ssl-enforcement.errors.ts | 56 ++ .../ssl-enforcement/ssl-enforcement.format.ts | 20 + .../ssl-enforcement/update/SIDE_EFFECTS.md | 88 ++- .../ssl-enforcement/update/update.command.ts | 9 +- .../ssl-enforcement/update/update.handler.ts | 102 ++- .../update/update.integration.test.ts | 740 ++++++++++++++++++ .../legacy-go-output.encoders.ts} | 50 +- .../legacy-go-output.encoders.unit.test.ts} | 60 +- .../src/legacy/shared/legacy-http-errors.ts | 69 ++ .../legacy-management-api-runtime.layer.ts | 48 ++ 21 files changed, 1935 insertions(+), 214 deletions(-) delete mode 100644 apps/cli/src/legacy/commands/backups/backups.layers.ts create mode 100644 apps/cli/src/legacy/commands/ssl-enforcement/get/get.integration.test.ts create mode 100644 apps/cli/src/legacy/commands/ssl-enforcement/ssl-enforcement.errors.ts create mode 100644 apps/cli/src/legacy/commands/ssl-enforcement/ssl-enforcement.format.ts create mode 100644 apps/cli/src/legacy/commands/ssl-enforcement/update/update.integration.test.ts rename apps/cli/src/legacy/{commands/backups/backups.encoders.ts => shared/legacy-go-output.encoders.ts} (71%) rename apps/cli/src/legacy/{commands/backups/backups.encoders.unit.test.ts => shared/legacy-go-output.encoders.unit.test.ts} (78%) create mode 100644 apps/cli/src/legacy/shared/legacy-http-errors.ts create mode 100644 apps/cli/src/legacy/shared/legacy-management-api-runtime.layer.ts diff --git a/apps/cli/docs/go-cli-porting-status.md b/apps/cli/docs/go-cli-porting-status.md index 0178a0335..7e0b17101 100644 --- a/apps/cli/docs/go-cli-porting-status.md +++ b/apps/cli/docs/go-cli-porting-status.md @@ -255,8 +255,8 @@ Legend: | `network-restrictions update` | `wrapped` | [`../src/legacy/commands/network-restrictions/update/update.command.ts`](../src/legacy/commands/network-restrictions/update/update.command.ts) | | `encryption get-root-key` | `wrapped` | [`../src/legacy/commands/encryption/get-root-key/get-root-key.command.ts`](../src/legacy/commands/encryption/get-root-key/get-root-key.command.ts) | | `encryption update-root-key` | `wrapped` | [`../src/legacy/commands/encryption/update-root-key/update-root-key.command.ts`](../src/legacy/commands/encryption/update-root-key/update-root-key.command.ts) | -| `ssl-enforcement get` | `wrapped` | [`../src/legacy/commands/ssl-enforcement/get/get.command.ts`](../src/legacy/commands/ssl-enforcement/get/get.command.ts) | -| `ssl-enforcement update` | `wrapped` | [`../src/legacy/commands/ssl-enforcement/update/update.command.ts`](../src/legacy/commands/ssl-enforcement/update/update.command.ts) | +| `ssl-enforcement get` | `ported` | [`../src/legacy/commands/ssl-enforcement/get/get.command.ts`](../src/legacy/commands/ssl-enforcement/get/get.command.ts) | +| `ssl-enforcement update` | `ported` | [`../src/legacy/commands/ssl-enforcement/update/update.command.ts`](../src/legacy/commands/ssl-enforcement/update/update.command.ts) | | `postgres-config get` | `wrapped` | [`../src/legacy/commands/postgres-config/get/get.command.ts`](../src/legacy/commands/postgres-config/get/get.command.ts) | | `postgres-config update` | `wrapped` | [`../src/legacy/commands/postgres-config/update/update.command.ts`](../src/legacy/commands/postgres-config/update/update.command.ts) | | `postgres-config delete` | `wrapped` | [`../src/legacy/commands/postgres-config/delete/delete.command.ts`](../src/legacy/commands/postgres-config/delete/delete.command.ts) | diff --git a/apps/cli/src/legacy/commands/backups/backups.errors.ts b/apps/cli/src/legacy/commands/backups/backups.errors.ts index 67c2fc46f..b439a7210 100644 --- a/apps/cli/src/legacy/commands/backups/backups.errors.ts +++ b/apps/cli/src/legacy/commands/backups/backups.errors.ts @@ -1,6 +1,4 @@ -import type { SupabaseApiError } from "@supabase/api/effect"; -import { Data, Effect } from "effect"; -import * as HttpClientError from "effect/unstable/http/HttpClientError"; +import { Data } from "effect"; export class LegacyBackupListNetworkError extends Data.TaggedError("LegacyBackupListNetworkError")<{ readonly message: string; @@ -27,69 +25,3 @@ export class LegacyBackupRestoreUnexpectedStatusError extends Data.TaggedError( readonly body: string; readonly message: string; }> {} - -// HttpClientError reasons that indicate the server returned an actual response (vs a transport -// failure). Anything in this set surfaces as an `UnexpectedStatusError`; everything else maps -// to a `NetworkError`. -const RESPONSE_ERROR_TAGS: ReadonlySet = new Set([ - "StatusCodeError", - "DecodeError", - "EmptyBodyError", -]); - -// Caps the response body that gets embedded in error structures. The Management API is -// trusted, but capping prevents oversized error envelopes from flooding `--output-format json` -// and avoids forwarding arbitrary bytes verbatim if the trust boundary ever changes. -const MAX_BODY_LEN = 1024; - -type NetworkErrorFactory = new (args: { readonly message: string }) => E; - -type StatusErrorFactory = new (args: { - readonly status: number; - readonly body: string; - readonly message: string; -}) => E; - -/** - * Build an error mapper that classifies a `SupabaseApiError` into either a typed network - * error or a typed unexpected-status error. Pulled out of the handlers so both commands - * share the dispatch logic, the body truncation, and the `RESPONSE_ERROR_TAGS` policy. - * - * `networkMessage` and `statusMessage` are templates: they build the human-readable error - * string with the same exact phrasing the handlers used before, so existing error-message - * assertions (and Go parity for status messages) continue to hold. - */ -export function mapLegacyBackupHttpError(opts: { - readonly networkError: NetworkErrorFactory; - readonly statusError: StatusErrorFactory; - readonly networkMessage: (cause: string) => string; - readonly statusMessage: (status: number, body: string) => string; -}): (cause: SupabaseApiError) => Effect.Effect { - return (cause) => - Effect.gen(function* () { - if (HttpClientError.isHttpClientError(cause)) { - if (RESPONSE_ERROR_TAGS.has(cause.reason._tag) && cause.response !== undefined) { - const status = cause.response.status; - const rawBody = yield* cause.response.text.pipe( - Effect.orElseSucceed(() => cause.reason.description ?? ""), - ); - const body = rawBody.length > MAX_BODY_LEN ? rawBody.slice(0, MAX_BODY_LEN) : rawBody; - return yield* Effect.fail( - new opts.statusError({ - status, - body, - message: opts.statusMessage(status, body), - }), - ); - } - const description = cause.reason.description ?? cause.reason._tag; - return yield* Effect.fail( - new opts.networkError({ message: opts.networkMessage(description) }), - ); - } - // SchemaError or HttpBodyError — treat as transport-level network error. - return yield* Effect.fail( - new opts.networkError({ message: opts.networkMessage(String(cause)) }), - ); - }); -} diff --git a/apps/cli/src/legacy/commands/backups/backups.layers.ts b/apps/cli/src/legacy/commands/backups/backups.layers.ts deleted file mode 100644 index 17c67c6fc..000000000 --- a/apps/cli/src/legacy/commands/backups/backups.layers.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { Layer } from "effect"; - -import { legacyCredentialsLayer } from "../../auth/legacy-credentials.layer.ts"; -import { legacyHttpClientLayer } from "../../auth/legacy-http-debug.layer.ts"; -import { legacyPlatformApiLayer } from "../../auth/legacy-platform-api.layer.ts"; -import { legacyCliConfigLayer } from "../../config/legacy-cli-config.layer.ts"; -import { legacyProjectRefLayer } from "../../config/legacy-project-ref.layer.ts"; -import { legacyLinkedProjectCacheLayer } from "../../telemetry/legacy-linked-project-cache.layer.ts"; -import { legacyTelemetryStateLayer } from "../../telemetry/legacy-telemetry-state.layer.ts"; -import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; - -// Shared platform-API stack used by every `backups` subcommand. `legacyHttpClientLayer` -// wraps the default fetch transport with a debug logger when `--debug` is set. -const legacyBackupsPlatformApiLayer = legacyPlatformApiLayer.pipe( - Layer.provide(legacyCredentialsLayer), - Layer.provide(legacyCliConfigLayer), - Layer.provide(legacyHttpClientLayer), -); - -/** - * Composes the runtime layer for a `supabase backups ` invocation. - * - * `legacyCliConfigLayer` must be piped to both `legacyBackupsPlatformApiLayer` and - * `legacyProjectRefLayer`. `Layer.provide` satisfies a requirement on the target layer; - * it does not expose the provided service to siblings of a `Layer.mergeAll(...)`. The - * project-ref layer reads `LegacyCliConfig` directly for workdir/projectId resolution, - * so without an explicit provide here the bundled runtime panics with - * `Service not found: supabase/legacy/CliConfig`. - * - * @param subcommand - command path segments after `supabase`, e.g. `["backups", "list"]`. - */ -export function legacyBackupsRuntimeLayer(subcommand: ReadonlyArray) { - return Layer.mergeAll( - legacyBackupsPlatformApiLayer, - legacyProjectRefLayer.pipe( - Layer.provide(legacyBackupsPlatformApiLayer), - Layer.provide(legacyCliConfigLayer), - ), - legacyLinkedProjectCacheLayer.pipe( - Layer.provide(legacyCredentialsLayer), - Layer.provide(legacyCliConfigLayer), - Layer.provide(legacyHttpClientLayer), - ), - legacyTelemetryStateLayer, - commandRuntimeLayer([...subcommand]), - ); -} diff --git a/apps/cli/src/legacy/commands/backups/list/list.command.ts b/apps/cli/src/legacy/commands/backups/list/list.command.ts index df4690590..cc08f09bb 100644 --- a/apps/cli/src/legacy/commands/backups/list/list.command.ts +++ b/apps/cli/src/legacy/commands/backups/list/list.command.ts @@ -3,7 +3,7 @@ import { Command, Flag } from "effect/unstable/cli"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { withCommandInstrumentation } from "../../../../shared/telemetry/command-instrumentation.ts"; -import { legacyBackupsRuntimeLayer } from "../backups.layers.ts"; +import { legacyManagementApiRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts"; import { legacyBackupsList } from "./list.handler.ts"; const config = { @@ -31,5 +31,5 @@ export const legacyBackupsListCommand = Command.make("list", config).pipe( Command.withHandler((flags) => legacyBackupsList(flags).pipe(withCommandInstrumentation(), withJsonErrorHandling), ), - Command.provide(legacyBackupsRuntimeLayer(["backups", "list"])), + Command.provide(legacyManagementApiRuntimeLayer(["backups", "list"])), ); diff --git a/apps/cli/src/legacy/commands/backups/list/list.handler.ts b/apps/cli/src/legacy/commands/backups/list/list.handler.ts index 4a4a6e19a..85892779e 100644 --- a/apps/cli/src/legacy/commands/backups/list/list.handler.ts +++ b/apps/cli/src/legacy/commands/backups/list/list.handler.ts @@ -11,15 +11,20 @@ import { renderGlamourTable } from "../../../output/legacy-glamour-table.ts"; import { LegacyBackupListNetworkError, LegacyBackupListUnexpectedStatusError, - mapLegacyBackupHttpError, } from "../backups.errors.ts"; -import { encodeEnv, encodeGoJson, encodeToml, encodeYaml } from "../backups.encoders.ts"; +import { + encodeEnv, + encodeGoJson, + encodeToml, + encodeYaml, +} from "../../../shared/legacy-go-output.encoders.ts"; +import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; import { formatBackupTimestamp, formatRegion } from "../backups.format.ts"; import type { LegacyBackupsListFlags } from "./list.command.ts"; type BackupsResponse = typeof V1ListAllBackupsOutput.Type; -const mapListError = mapLegacyBackupHttpError({ +const mapListError = mapLegacyHttpError({ networkError: LegacyBackupListNetworkError, statusError: LegacyBackupListUnexpectedStatusError, networkMessage: (cause) => `failed to list physical backups: ${cause}`, @@ -85,7 +90,7 @@ export const legacyBackupsList = Effect.fn("legacy.backups.list")(function* ( const goFmt = Option.getOrUndefined(goOutputFlag); if (goFmt === "json") { - yield* output.raw(encodeGoJson(response)); + yield* output.raw(encodeGoJson(response, { nullForEmptyArrays: ["backups"] })); return; } if (goFmt === "yaml") { diff --git a/apps/cli/src/legacy/commands/backups/restore/restore.command.ts b/apps/cli/src/legacy/commands/backups/restore/restore.command.ts index 445931730..425b52ed2 100644 --- a/apps/cli/src/legacy/commands/backups/restore/restore.command.ts +++ b/apps/cli/src/legacy/commands/backups/restore/restore.command.ts @@ -3,7 +3,7 @@ import { Command, Flag } from "effect/unstable/cli"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { withCommandInstrumentation } from "../../../../shared/telemetry/command-instrumentation.ts"; -import { legacyBackupsRuntimeLayer } from "../backups.layers.ts"; +import { legacyManagementApiRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts"; import { legacyBackupsRestore } from "./restore.handler.ts"; const config = { @@ -32,5 +32,5 @@ export const legacyBackupsRestoreCommand = Command.make("restore", config).pipe( Command.withHandler((flags) => legacyBackupsRestore(flags).pipe(withCommandInstrumentation(), withJsonErrorHandling), ), - Command.provide(legacyBackupsRuntimeLayer(["backups", "restore"])), + Command.provide(legacyManagementApiRuntimeLayer(["backups", "restore"])), ); diff --git a/apps/cli/src/legacy/commands/backups/restore/restore.handler.ts b/apps/cli/src/legacy/commands/backups/restore/restore.handler.ts index 7ca658e72..4ae6524f9 100644 --- a/apps/cli/src/legacy/commands/backups/restore/restore.handler.ts +++ b/apps/cli/src/legacy/commands/backups/restore/restore.handler.ts @@ -9,11 +9,11 @@ import { Output } from "../../../../shared/output/output.service.ts"; import { LegacyBackupRestoreNetworkError, LegacyBackupRestoreUnexpectedStatusError, - mapLegacyBackupHttpError, } from "../backups.errors.ts"; +import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; import type { LegacyBackupsRestoreFlags } from "./restore.command.ts"; -const mapRestoreError = mapLegacyBackupHttpError({ +const mapRestoreError = mapLegacyHttpError({ networkError: LegacyBackupRestoreNetworkError, statusError: LegacyBackupRestoreUnexpectedStatusError, networkMessage: (cause) => `failed to restore backup: ${cause}`, diff --git a/apps/cli/src/legacy/commands/ssl-enforcement/get/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/ssl-enforcement/get/SIDE_EFFECTS.md index 1ef02ea97..cfcf688b4 100644 --- a/apps/cli/src/legacy/commands/ssl-enforcement/get/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/ssl-enforcement/get/SIDE_EFFECTS.md @@ -2,56 +2,93 @@ ## Files Read -| Path | Format | When | -| -------------------------- | ------------------------- | ---------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| Path | Format | When | +| -------------------------------------- | ------------------------- | ------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `PROJECT_ID` env are both unset | ## Files Written -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | +| Path | Format | When | +| ------------------------------------------------ | ------ | ----------------------------------------------------------------------------- | +| `~/.supabase//linked-project.json` | JSON | always (after ref resolution), via `Effect.ensuring` — on success and failure | +| `~/.supabase/telemetry.json` | JSON | always, via `Effect.ensuring` — on success and failure | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ------------------------------------------- | ------------ | ------------ | ------------------------------ | -| `GET` | `/v1/projects/{ref}/config/ssl-enforcement` | Bearer token | none | `{enforced, override_enabled}` | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ------------------------------------ | ------------ | ------------ | -------------------------------------------------------------------- | +| `GET` | `/v1/projects/{ref}/ssl-enforcement` | Bearer token | none | `{currentConfig: {database: boolean}, appliedSuccessfully: boolean}` | ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| Variable | Purpose | Required? | +| ----------------------- | ---------------------------------------------------- | -------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| `PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` → prompt) | ## Exit Codes -| Code | Condition | -| ---- | ---------------------------------------------------------- | -| `0` | success — SSL enforcement config printed to stdout | -| `1` | authentication error — no valid token found | -| `1` | API error — non-2xx response from SSL enforcement endpoint | -| `1` | network / connection failure | +| Code | Condition | +| ---- | --------------------------------------------------------------------------------------- | +| `0` | success — SSL enforcement status printed to stdout | +| `1` | project ref unresolved (`LegacyProjectNotLinkedError` / `LegacyInvalidProjectRefError`) | +| `1` | API non-200 (`LegacySslEnforcementGetUnexpectedStatusError`) | +| `1` | transport failure (`LegacySslEnforcementGetNetworkError`) | ## Output -### `--output-format text` (Go CLI compatible) +### `--output-format text` (default) — Go CLI compatible -Prints SSL enforcement configuration to stdout. +Single status line to stdout: + +``` +SSL is being enforced. +``` + +or + +``` +SSL is *NOT* being enforced. +``` + +The "_NOT_" form is emitted when `currentConfig.database` is `false` **or** when +`appliedSuccessfully` is `false` (i.e. the requested config has not yet propagated). + +### Go `--output {json,yaml,toml,env}` + +Byte-identical to the Go CLI's encoders (`apps/cli-go/internal/utils/output.go`). + +- `json` — alphabetical struct-field order with trailing newline. +- `yaml` — `stringifyYaml(response)`. +- `toml` — `stringifyToml(response)` with trailing newline. +- `env` — Viper-flattened SCREAMING_SNAKE_CASE keys (e.g. + `APPLIEDSUCCESSFULLY="true"\nCURRENTCONFIG_DATABASE="true"\n`). + +### Go `--output pretty` + +Same as `text` mode (Go's default). ### `--output-format json` -Single JSON object emitted to stdout on success. +The full response object emitted as the `success` event payload: + +```json +{ "currentConfig": { "database": true }, "appliedSuccessfully": true } +``` ### `--output-format stream-json` -One `result` event on success. +One `result` event: ```ndjson -{"type":"result","data":{...}} +{"type":"result","data":{"currentConfig":{"database":true},"appliedSuccessfully":true}} ``` ## Notes -- Requires `--project-ref` or a linked project (`.supabase/config.json`). +- 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. diff --git a/apps/cli/src/legacy/commands/ssl-enforcement/get/get.command.ts b/apps/cli/src/legacy/commands/ssl-enforcement/get/get.command.ts index b7706f47b..e4e3ce902 100644 --- a/apps/cli/src/legacy/commands/ssl-enforcement/get/get.command.ts +++ b/apps/cli/src/legacy/commands/ssl-enforcement/get/get.command.ts @@ -1,5 +1,9 @@ import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; + +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { withCommandInstrumentation } from "../../../../shared/telemetry/command-instrumentation.ts"; +import { legacyManagementApiRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts"; import { legacySslEnforcementGet } from "./get.handler.ts"; const config = { @@ -7,12 +11,15 @@ const config = { Flag.withDescription("Project ref of the Supabase project."), Flag.optional, ), -}; +} as const; export type LegacySslEnforcementGetFlags = CliCommand.Command.Config.Infer; export const legacySslEnforcementGetCommand = Command.make("get", config).pipe( Command.withDescription("Get the current SSL enforcement configuration."), Command.withShortDescription("Get SSL enforcement configuration"), - Command.withHandler((flags) => legacySslEnforcementGet(flags)), + Command.withHandler((flags) => + legacySslEnforcementGet(flags).pipe(withCommandInstrumentation(), withJsonErrorHandling), + ), + Command.provide(legacyManagementApiRuntimeLayer(["ssl-enforcement", "get"])), ); diff --git a/apps/cli/src/legacy/commands/ssl-enforcement/get/get.handler.ts b/apps/cli/src/legacy/commands/ssl-enforcement/get/get.handler.ts index 986cc427a..c3891ad57 100644 --- a/apps/cli/src/legacy/commands/ssl-enforcement/get/get.handler.ts +++ b/apps/cli/src/legacy/commands/ssl-enforcement/get/get.handler.ts @@ -1,12 +1,87 @@ import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; + +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 { Output } from "../../../../shared/output/output.service.ts"; +import { + encodeEnv, + encodeGoJson, + encodeToml, + encodeYaml, +} from "../../../shared/legacy-go-output.encoders.ts"; +import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; +import { + LegacySslEnforcementGetNetworkError, + LegacySslEnforcementGetUnexpectedStatusError, +} from "../ssl-enforcement.errors.ts"; +import { printSslStatus } from "../ssl-enforcement.format.ts"; import type { LegacySslEnforcementGetFlags } from "./get.command.ts"; +// Templates lifted verbatim from `apps/cli-go/internal/ssl_enforcement/get/get.go:17,19`. +const mapGetError = mapLegacyHttpError({ + networkError: LegacySslEnforcementGetNetworkError, + statusError: LegacySslEnforcementGetUnexpectedStatusError, + networkMessage: (cause) => `failed to retrieve SSL enforcement config: ${cause}`, + statusMessage: (status, body) => `unexpected SSL enforcement status ${status}: ${body}`, +}); + export const legacySslEnforcementGet = Effect.fn("legacy.ssl-enforcement.get")(function* ( flags: LegacySslEnforcementGetFlags, ) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["ssl-enforcement", "get"]; - if (Option.isSome(flags.projectRef)) args.push("--project-ref", flags.projectRef.value); - yield* proxy.exec(args); + const output = yield* Output; + const goOutputFlag = yield* LegacyOutputFlag; + const api = yield* LegacyPlatformApi; + const resolver = yield* LegacyProjectRefResolver; + const linkedProjectCache = yield* LegacyLinkedProjectCache; + const telemetryState = yield* LegacyTelemetryState; + + // Mirror Go's PersistentPostRun (`apps/cli-go/cmd/root.go:176`): telemetry must flush + // whether ref resolution, the API call, or output emission fails. `linkedProjectCache.cache` + // requires a resolved ref, so it wraps the inner sub-effect only. + yield* Effect.gen(function* () { + const ref = yield* resolver.resolve(flags.projectRef); + + yield* Effect.gen(function* () { + const fetching = + output.format === "text" + ? yield* output.task("Fetching SSL enforcement config...") + : undefined; + const response = yield* api.v1.getSslEnforcementConfig({ ref }).pipe( + Effect.tapError(() => fetching?.fail() ?? Effect.void), + Effect.catch(mapGetError), + ); + yield* fetching?.clear() ?? Effect.void; + + const goFmt = Option.getOrUndefined(goOutputFlag); + + if (goFmt === "json") { + yield* output.raw(encodeGoJson(response)); + return; + } + if (goFmt === "yaml") { + yield* output.raw(encodeYaml(response)); + return; + } + if (goFmt === "toml") { + yield* output.raw(encodeToml(response) + "\n"); + return; + } + if (goFmt === "env") { + yield* output.raw(encodeEnv(response) + "\n"); + return; + } + + // goFmt is undefined or "pretty" — defer to TS --output-format for JSON/stream-json, + // otherwise print the Go text-mode status line (Go --output pretty parity). + if (output.format === "json" || output.format === "stream-json") { + yield* output.success("", response); + return; + } + + yield* output.raw(printSslStatus(response)); + }).pipe(Effect.ensuring(linkedProjectCache.cache(ref))); + }).pipe(Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/ssl-enforcement/get/get.integration.test.ts b/apps/cli/src/legacy/commands/ssl-enforcement/get/get.integration.test.ts new file mode 100644 index 000000000..6bd58ad26 --- /dev/null +++ b/apps/cli/src/legacy/commands/ssl-enforcement/get/get.integration.test.ts @@ -0,0 +1,578 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { type V1GetSslEnforcementConfigOutput, makeApiClient } from "@supabase/api/effect"; +import { describe, expect, it } from "@effect/vitest"; +import { BunServices } from "@effect/platform-bun"; +import { Effect, Exit, Layer, Option, Redacted } from "effect"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientError from "effect/unstable/http/HttpClientError"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; +import type * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import { afterEach, beforeEach } from "vitest"; + +import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; +import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; +import { legacyProjectRefLayer } from "../../../config/legacy-project-ref.layer.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 { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { mockOutput, mockProcessControl, mockTty } from "../../../../../tests/helpers/mocks.ts"; +import { legacySslEnforcementGet } from "./get.handler.ts"; + +const mockLinkedProjectCacheLayer = Layer.succeed(LegacyLinkedProjectCache, { + cache: () => Effect.void, +}); + +const mockTelemetryStateLayer = Layer.succeed(LegacyTelemetryState, { flush: Effect.void }); + +function mockTelemetryStateTracked() { + let flushed = false; + const layer = Layer.succeed(LegacyTelemetryState, { + get flush() { + return Effect.sync(() => { + flushed = true; + }); + }, + }); + return { + layer, + get flushed() { + return flushed; + }, + }; +} + +function mockLinkedProjectCacheTracked() { + let cached = false; + const layer = Layer.succeed(LegacyLinkedProjectCache, { + cache: (_ref: string) => + Effect.sync(() => { + cached = true; + }), + }); + return { + layer, + get cached() { + return cached; + }, + }; +} + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const VALID_REF = "abcdefghijklmnopqrst"; +const VALID_TOKEN = "sbp_" + "a".repeat(40); + +const SSL_ENFORCED: typeof V1GetSslEnforcementConfigOutput.Type = { + currentConfig: { database: true }, + appliedSuccessfully: true, +}; + +const SSL_NOT_ENFORCED: typeof V1GetSslEnforcementConfigOutput.Type = { + currentConfig: { database: false }, + appliedSuccessfully: false, +}; + +const SSL_DESIRED_BUT_NOT_APPLIED: typeof V1GetSslEnforcementConfigOutput.Type = { + currentConfig: { database: true }, + appliedSuccessfully: false, +}; + +function jsonResponse(request: HttpClientRequest.HttpClientRequest, status: number, body: unknown) { + return HttpClientResponse.fromWeb( + request, + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }), + ); +} + +function httpClientLayer( + handler: ( + request: HttpClientRequest.HttpClientRequest, + ) => Effect.Effect, +) { + return Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => handler(request)), + ); +} + +function mockPlatformApi(opts: { + response?: typeof V1GetSslEnforcementConfigOutput.Type; + status?: number; + network?: "fail"; + apiUrl?: string; + userAgent?: string; +}) { + const requests: Array<{ + url: string; + method: string; + headers: Readonly>; + }> = []; + + const status = opts.status ?? 200; + const handler = ( + request: HttpClientRequest.HttpClientRequest, + ): Effect.Effect => { + requests.push({ url: request.url, method: request.method, headers: request.headers }); + if (opts.network === "fail") { + return Effect.fail( + new HttpClientError.HttpClientError({ + reason: new HttpClientError.TransportError({ + request, + description: "ECONNREFUSED", + }), + }), + ); + } + return Effect.succeed(jsonResponse(request, status, opts.response ?? SSL_ENFORCED)); + }; + + const layer = Layer.effect( + LegacyPlatformApi, + makeApiClient({ + baseUrl: opts.apiUrl ?? "https://api.supabase.com", + accessToken: VALID_TOKEN, + userAgent: opts.userAgent ?? "SupabaseCLI/0.0.0-dev", + }), + ).pipe(Layer.provide(httpClientLayer(handler))); + + return { layer, requests }; +} + +function mockCliConfig(opts: { workdir: string; apiUrl?: string; userAgent?: string }) { + return Layer.succeed(LegacyCliConfig, { + profile: "supabase", + apiUrl: opts.apiUrl ?? "https://api.supabase.com", + accessToken: Option.some(Redacted.make(VALID_TOKEN)), + projectId: Option.some(VALID_REF), + workdir: opts.workdir, + userAgent: opts.userAgent ?? "SupabaseCLI/0.0.0-dev", + }); +} + +interface SetupOpts { + format?: "text" | "json" | "stream-json"; + goOutput?: "env" | "pretty" | "json" | "toml" | "yaml"; + response?: typeof V1GetSslEnforcementConfigOutput.Type; + status?: number; + network?: "fail"; + stdinIsTty?: boolean; + apiUrl?: string; + userAgent?: string; +} + +let tempRoot: string; +let currentOut: ReturnType; + +function setup(opts: SetupOpts = {}) { + const out = mockOutput({ format: opts.format ?? "text" }); + currentOut = out; + const api = mockPlatformApi({ + response: opts.response, + status: opts.status, + network: opts.network, + apiUrl: opts.apiUrl, + userAgent: opts.userAgent, + }); + const cliConfig = mockCliConfig({ + workdir: tempRoot, + apiUrl: opts.apiUrl, + userAgent: opts.userAgent, + }); + const processCtl = mockProcessControl(); + const goOutputValue = opts.goOutput === undefined ? Option.none() : Option.some(opts.goOutput); + const layer = Layer.mergeAll( + out.layer, + api.layer, + cliConfig, + mockTty({ stdinIsTty: opts.stdinIsTty ?? false, stdoutIsTty: false }), + processCtl.layer, + legacyProjectRefLayer.pipe( + Layer.provide(api.layer), + Layer.provide(cliConfig), + Layer.provide(mockTty({ stdinIsTty: opts.stdinIsTty ?? false, stdoutIsTty: false })), + Layer.provide(out.layer), + Layer.provide(BunServices.layer), + ), + BunServices.layer, + Layer.succeed(LegacyOutputFlag, goOutputValue), + mockLinkedProjectCacheLayer, + mockTelemetryStateLayer, + ); + return { layer, out, api, processCtl, tempRoot }; +} + +const stdoutText = () => currentOut.stdoutText; + +beforeEach(() => { + tempRoot = mkdtempSync(join(tmpdir(), "supabase-ssl-enforcement-get-int-")); +}); + +afterEach(() => { + rmSync(tempRoot, { recursive: true, force: true }); +}); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("legacy ssl-enforcement get integration", () => { + it.live('prints "SSL is being enforced." when database=true and appliedSuccessfully=true', () => { + const { layer } = setup({ response: SSL_ENFORCED }); + return Effect.gen(function* () { + yield* legacySslEnforcementGet({ projectRef: Option.none() }); + expect(stdoutText()).toBe("SSL is being enforced.\n"); + }).pipe(Effect.provide(layer)); + }); + + it.live('prints "SSL is *NOT* being enforced." when database=false', () => { + const { layer } = setup({ response: SSL_NOT_ENFORCED }); + return Effect.gen(function* () { + yield* legacySslEnforcementGet({ projectRef: Option.none() }); + expect(stdoutText()).toBe("SSL is *NOT* being enforced.\n"); + }).pipe(Effect.provide(layer)); + }); + + it.live( + 'prints "SSL is *NOT* being enforced." when database=true but appliedSuccessfully=false', + () => { + const { layer } = setup({ response: SSL_DESIRED_BUT_NOT_APPLIED }); + return Effect.gen(function* () { + yield* legacySslEnforcementGet({ projectRef: Option.none() }); + expect(stdoutText()).toBe("SSL is *NOT* being enforced.\n"); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("emits Go-compatible env output for --output env (exact bytes)", () => { + const { layer } = setup({ goOutput: "env", response: SSL_ENFORCED }); + return Effect.gen(function* () { + yield* legacySslEnforcementGet({ projectRef: Option.none() }); + expect(stdoutText()).toBe('APPLIEDSUCCESSFULLY="true"\nCURRENTCONFIG_DATABASE="true"\n'); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits Go-compatible indented JSON for --output json (exact bytes)", () => { + const { layer } = setup({ goOutput: "json", response: SSL_ENFORCED }); + return Effect.gen(function* () { + yield* legacySslEnforcementGet({ projectRef: Option.none() }); + expect(stdoutText()).toBe( + `{ + "appliedSuccessfully": true, + "currentConfig": { + "database": true + } +} +`, + ); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits YAML for --output yaml", () => { + const { layer } = setup({ goOutput: "yaml", response: SSL_ENFORCED }); + return Effect.gen(function* () { + yield* legacySslEnforcementGet({ projectRef: Option.none() }); + const out = stdoutText(); + expect(out).toContain("appliedSuccessfully: true"); + expect(out).toContain("database: true"); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits TOML for --output toml", () => { + const { layer } = setup({ goOutput: "toml", response: SSL_ENFORCED }); + return Effect.gen(function* () { + yield* legacySslEnforcementGet({ projectRef: Option.none() }); + const out = stdoutText(); + expect(out).toContain("appliedSuccessfully = true"); + expect(out).toContain("[currentConfig]"); + }).pipe(Effect.provide(layer)); + }); + + it.live("treats --output pretty as identical to text mode", () => { + const { layer } = setup({ goOutput: "pretty", response: SSL_ENFORCED }); + return Effect.gen(function* () { + yield* legacySslEnforcementGet({ projectRef: Option.none() }); + expect(stdoutText()).toBe("SSL is being enforced.\n"); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a JSON success event when --output-format=json", () => { + const { layer, out } = setup({ format: "json", response: SSL_ENFORCED }); + return Effect.gen(function* () { + yield* legacySslEnforcementGet({ projectRef: Option.none() }); + const success = out.messages.find((m) => m.type === "success"); + expect(success).toBeDefined(); + expect(success?.data).toMatchObject({ + currentConfig: { database: true }, + appliedSuccessfully: true, + }); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a result event for --output-format=stream-json", () => { + const { layer, out } = setup({ format: "stream-json", response: SSL_ENFORCED }); + return Effect.gen(function* () { + yield* legacySslEnforcementGet({ projectRef: Option.none() }); + const success = out.messages.find((m) => m.type === "success"); + expect(success).toBeDefined(); + expect(success?.data).toMatchObject({ currentConfig: { database: true } }); + }).pipe(Effect.provide(layer)); + }); + + it.live("--output (Go) wins over --output-format (TS) when both provided", () => { + const { layer } = setup({ format: "json", goOutput: "yaml", response: SSL_ENFORCED }); + return Effect.gen(function* () { + yield* legacySslEnforcementGet({ projectRef: Option.none() }); + const out = stdoutText(); + expect(out).toContain("appliedSuccessfully: true"); + // YAML-shape rather than indented JSON + expect(out.startsWith("{")).toBe(false); + }).pipe(Effect.provide(layer)); + }); + + it.live("passes the resolved project ref into the getSslEnforcementConfig URL", () => { + const { layer, api } = setup({ response: SSL_ENFORCED }); + return Effect.gen(function* () { + yield* legacySslEnforcementGet({ projectRef: Option.none() }); + expect(api.requests).toHaveLength(1); + expect(api.requests[0]?.url).toContain(`/v1/projects/${VALID_REF}/ssl-enforcement`); + }).pipe(Effect.provide(layer)); + }); + + it.live("uses --project-ref flag value over LegacyCliConfig.projectId", () => { + const flagRef = "zzzzzzzzzzzzzzzzzzzz"; + const { layer, api } = setup({ response: SSL_ENFORCED }); + return Effect.gen(function* () { + yield* legacySslEnforcementGet({ projectRef: Option.some(flagRef) }); + expect(api.requests[0]?.url).toContain(`/v1/projects/${flagRef}/`); + }).pipe(Effect.provide(layer)); + }); + + it.live("reads supabase/.temp/project-ref when env and flag are unset", () => { + const localTempRoot = mkdtempSync(join(tmpdir(), "supabase-ssl-get-int-fileref-")); + const fileRef = "filerefabcdefghijklm"; + mkdirSync(join(localTempRoot, "supabase", ".temp"), { recursive: true }); + writeFileSync(join(localTempRoot, "supabase", ".temp", "project-ref"), fileRef); + + const out = mockOutput({ format: "text" }); + const api = mockPlatformApi({ response: SSL_ENFORCED }); + const cliConfig = Layer.succeed(LegacyCliConfig, { + profile: "supabase", + apiUrl: "https://api.supabase.com", + accessToken: Option.some(Redacted.make(VALID_TOKEN)), + projectId: Option.none(), + workdir: localTempRoot, + userAgent: "SupabaseCLI/0.0.0-dev", + }); + const processCtl = mockProcessControl(); + const layer = Layer.mergeAll( + out.layer, + api.layer, + cliConfig, + mockTty({ stdinIsTty: false, stdoutIsTty: false }), + processCtl.layer, + legacyProjectRefLayer.pipe( + Layer.provide(api.layer), + Layer.provide(cliConfig), + Layer.provide(mockTty({ stdinIsTty: false, stdoutIsTty: false })), + Layer.provide(out.layer), + Layer.provide(BunServices.layer), + ), + BunServices.layer, + Layer.succeed(LegacyOutputFlag, Option.none()), + mockLinkedProjectCacheLayer, + mockTelemetryStateLayer, + ); + + return Effect.gen(function* () { + yield* legacySslEnforcementGet({ projectRef: Option.none() }); + expect(api.requests[0]?.url).toContain(`/v1/projects/${fileRef}/`); + }).pipe( + Effect.provide(layer), + Effect.ensuring(Effect.sync(() => rmSync(localTempRoot, { recursive: true, force: true }))), + ); + }); + + it.live("fails with LegacyProjectNotLinkedError when no ref source matches off-TTY", () => { + const localTempRoot = mkdtempSync(join(tmpdir(), "supabase-ssl-get-int-no-ref-")); + const out = mockOutput({ format: "text" }); + const api = mockPlatformApi({ response: SSL_ENFORCED }); + const cliConfig = Layer.succeed(LegacyCliConfig, { + profile: "supabase", + apiUrl: "https://api.supabase.com", + accessToken: Option.some(Redacted.make(VALID_TOKEN)), + projectId: Option.none(), + workdir: localTempRoot, + userAgent: "SupabaseCLI/0.0.0-dev", + }); + const processCtl = mockProcessControl(); + const layer = Layer.mergeAll( + out.layer, + api.layer, + cliConfig, + mockTty({ stdinIsTty: false, stdoutIsTty: false }), + processCtl.layer, + legacyProjectRefLayer.pipe( + Layer.provide(api.layer), + Layer.provide(cliConfig), + Layer.provide(mockTty({ stdinIsTty: false, stdoutIsTty: false })), + Layer.provide(out.layer), + Layer.provide(BunServices.layer), + ), + BunServices.layer, + Layer.succeed(LegacyOutputFlag, Option.none()), + mockLinkedProjectCacheLayer, + mockTelemetryStateLayer, + ); + + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacySslEnforcementGet({ projectRef: Option.none() }).pipe(Effect.provide(layer)), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyProjectNotLinkedError"); + } + }).pipe( + Effect.ensuring(Effect.sync(() => rmSync(localTempRoot, { recursive: true, force: true }))), + ); + }); + + it.live("fails with LegacyInvalidProjectRefError when the resolved ref is malformed", () => { + const { layer } = setup({ response: SSL_ENFORCED }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacySslEnforcementGet({ projectRef: Option.some("BADREF") }), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyInvalidProjectRefError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with LegacySslEnforcementGetUnexpectedStatusError on HTTP 503", () => { + const { layer } = setup({ status: 503, response: SSL_ENFORCED }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacySslEnforcementGet({ projectRef: Option.none() })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const errorJson = JSON.stringify(exit.cause); + expect(errorJson).toContain("LegacySslEnforcementGetUnexpectedStatusError"); + expect(errorJson).toContain("unexpected SSL enforcement status 503"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with LegacySslEnforcementGetNetworkError on transport failure", () => { + const { layer } = setup({ network: "fail" }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacySslEnforcementGet({ projectRef: Option.none() })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const errorJson = JSON.stringify(exit.cause); + expect(errorJson).toContain("LegacySslEnforcementGetNetworkError"); + expect(errorJson).toContain("failed to retrieve SSL enforcement config"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a fail event when withJsonErrorHandling wraps a JSON-mode error", () => { + const { layer, out } = setup({ format: "json", status: 503, response: SSL_ENFORCED }); + return Effect.gen(function* () { + yield* legacySslEnforcementGet({ projectRef: Option.none() }).pipe(withJsonErrorHandling); + expect(out.messages.some((m) => m.type === "fail")).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + // ------------------------------------------------------------------------- + // PersistentPostRun parity — telemetry + linked-project cache scoping + // ------------------------------------------------------------------------- + + it.live("flushes telemetry and writes linked-project cache on success", () => { + const telemetry = mockTelemetryStateTracked(); + const cache = mockLinkedProjectCacheTracked(); + const out = mockOutput({ format: "text" }); + const api = mockPlatformApi({ response: SSL_ENFORCED }); + const cliConfig = mockCliConfig({ workdir: tempRoot }); + const processCtl = mockProcessControl(); + const layer = Layer.mergeAll( + out.layer, + api.layer, + cliConfig, + mockTty({ stdinIsTty: false, stdoutIsTty: false }), + processCtl.layer, + legacyProjectRefLayer.pipe( + Layer.provide(api.layer), + Layer.provide(cliConfig), + Layer.provide(mockTty({ stdinIsTty: false, stdoutIsTty: false })), + Layer.provide(out.layer), + Layer.provide(BunServices.layer), + ), + BunServices.layer, + Layer.succeed(LegacyOutputFlag, Option.none()), + cache.layer, + telemetry.layer, + ); + return Effect.gen(function* () { + yield* legacySslEnforcementGet({ projectRef: Option.none() }); + expect(telemetry.flushed).toBe(true); + expect(cache.cached).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("flushes telemetry even when ref resolution fails (no cache write)", () => { + // Pre-PersistentPostRun-fix regression guard: telemetry must flush whether or not the + // resolver succeeds. The linked-project cache only writes after a ref is resolved. + const localTempRoot = mkdtempSync(join(tmpdir(), "supabase-ssl-get-int-postrun-")); + const telemetry = mockTelemetryStateTracked(); + const cache = mockLinkedProjectCacheTracked(); + const out = mockOutput({ format: "text" }); + const api = mockPlatformApi({ response: SSL_ENFORCED }); + const cliConfig = Layer.succeed(LegacyCliConfig, { + profile: "supabase", + apiUrl: "https://api.supabase.com", + accessToken: Option.some(Redacted.make(VALID_TOKEN)), + projectId: Option.none(), + workdir: localTempRoot, + userAgent: "SupabaseCLI/0.0.0-dev", + }); + const processCtl = mockProcessControl(); + const layer = Layer.mergeAll( + out.layer, + api.layer, + cliConfig, + mockTty({ stdinIsTty: false, stdoutIsTty: false }), + processCtl.layer, + legacyProjectRefLayer.pipe( + Layer.provide(api.layer), + Layer.provide(cliConfig), + Layer.provide(mockTty({ stdinIsTty: false, stdoutIsTty: false })), + Layer.provide(out.layer), + Layer.provide(BunServices.layer), + ), + BunServices.layer, + Layer.succeed(LegacyOutputFlag, Option.none()), + cache.layer, + telemetry.layer, + ); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacySslEnforcementGet({ projectRef: Option.none() }).pipe(Effect.provide(layer)), + ); + expect(Exit.isFailure(exit)).toBe(true); + expect(telemetry.flushed).toBe(true); + expect(cache.cached).toBe(false); + }).pipe( + Effect.ensuring(Effect.sync(() => rmSync(localTempRoot, { recursive: true, force: true }))), + ); + }); +}); diff --git a/apps/cli/src/legacy/commands/ssl-enforcement/ssl-enforcement.errors.ts b/apps/cli/src/legacy/commands/ssl-enforcement/ssl-enforcement.errors.ts new file mode 100644 index 000000000..0ff460b5a --- /dev/null +++ b/apps/cli/src/legacy/commands/ssl-enforcement/ssl-enforcement.errors.ts @@ -0,0 +1,56 @@ +import { Data } from "effect"; + +export class LegacySslEnforcementGetNetworkError extends Data.TaggedError( + "LegacySslEnforcementGetNetworkError", +)<{ + readonly message: string; +}> {} + +export class LegacySslEnforcementGetUnexpectedStatusError extends Data.TaggedError( + "LegacySslEnforcementGetUnexpectedStatusError", +)<{ + readonly status: number; + readonly body: string; + readonly message: string; +}> {} + +export class LegacySslEnforcementUpdateNetworkError extends Data.TaggedError( + "LegacySslEnforcementUpdateNetworkError", +)<{ + readonly message: string; +}> {} + +export class LegacySslEnforcementUpdateUnexpectedStatusError extends Data.TaggedError( + "LegacySslEnforcementUpdateUnexpectedStatusError", +)<{ + readonly status: number; + readonly body: string; + readonly message: string; +}> {} + +// Verbatim Go string from `apps/cli-go/internal/ssl_enforcement/update/update.go:27`. +export class LegacySslEnforcementNoEnableDisableFlagError extends Data.TaggedError( + "LegacySslEnforcementNoEnableDisableFlagError", +)<{ + readonly message: string; +}> { + constructor() { + super({ message: "enable/disable not specified" }); + } +} + +// Verbatim cobra string for parity with Go's `MarkFlagsMutuallyExclusive` +// (`apps/cli-go/cmd/sslEnforcement.go:46`). Effect CLI has no built-in +// equivalent, so we enforce it at handler entry. +export class LegacySslEnforcementMutuallyExclusiveFlagsError extends Data.TaggedError( + "LegacySslEnforcementMutuallyExclusiveFlagsError", +)<{ + readonly message: string; +}> { + constructor() { + super({ + message: + "if any flags in the group [enable-db-ssl-enforcement disable-db-ssl-enforcement] are set none of the others can be", + }); + } +} diff --git a/apps/cli/src/legacy/commands/ssl-enforcement/ssl-enforcement.format.ts b/apps/cli/src/legacy/commands/ssl-enforcement/ssl-enforcement.format.ts new file mode 100644 index 000000000..177bb6f91 --- /dev/null +++ b/apps/cli/src/legacy/commands/ssl-enforcement/ssl-enforcement.format.ts @@ -0,0 +1,20 @@ +// Structural shape matches both `V1GetSslEnforcementConfigOutput.Type` and +// `V1UpdateSslEnforcementConfigOutput.Type` — Go's `update.Run` delegates to +// `get.PrintSSLStatus` after a successful PUT (`update.go:26`), and the two +// response schemas are byte-identical. Keeping the parameter type local +// decouples this formatter from the generated API types and survives any +// future divergence between the two schemas. +interface SslEnforcementStatus { + readonly currentConfig: { readonly database: boolean }; + readonly appliedSuccessfully: boolean; +} + +/** + * Reproduces `PrintSSLStatus` from `apps/cli-go/internal/ssl_enforcement/get/get.go:27-34`. + */ +export function printSslStatus(response: SslEnforcementStatus): string { + if (response.currentConfig.database && response.appliedSuccessfully) { + return "SSL is being enforced.\n"; + } + return "SSL is *NOT* being enforced.\n"; +} diff --git a/apps/cli/src/legacy/commands/ssl-enforcement/update/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/ssl-enforcement/update/SIDE_EFFECTS.md index fcc56d0d1..ec8cb978b 100644 --- a/apps/cli/src/legacy/commands/ssl-enforcement/update/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/ssl-enforcement/update/SIDE_EFFECTS.md @@ -2,59 +2,91 @@ ## Files Read -| Path | Format | When | -| -------------------------- | ------------------------- | ---------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| Path | Format | When | +| -------------------------------------- | ------------------------- | ------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `PROJECT_ID` env are both unset | ## Files Written -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | +| Path | Format | When | +| ------------------------------------------------ | ------ | ----------------------------------------------------------------------------------------- | +| `~/.supabase//linked-project.json` | JSON | after the project ref is resolved (only if flag validation passes), via `Effect.ensuring` | +| `~/.supabase/telemetry.json` | JSON | always, via `Effect.ensuring` — including flag-validation failures | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ------------------------------------------- | ------------ | --------------------- | ------------------------------ | -| `PUT` | `/v1/projects/{ref}/config/ssl-enforcement` | Bearer token | `{enforced: boolean}` | `{enforced, override_enabled}` | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ------------------------------------ | ------------ | ---------------------------------------- | -------------------------------------------------------------------- | +| `PUT` | `/v1/projects/{ref}/ssl-enforcement` | Bearer token | `{requestedConfig: {database: boolean}}` | `{currentConfig: {database: boolean}, appliedSuccessfully: boolean}` | ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| Variable | Purpose | Required? | +| ----------------------- | ---------------------------------------------------- | -------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| `PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` → prompt) | ## Exit Codes -| Code | Condition | -| ---- | -------------------------------------------------------------------------------------- | -| `0` | success — SSL enforcement config updated | -| `1` | authentication error — no valid token found | -| `1` | neither `--enable-db-ssl-enforcement` nor `--disable-db-ssl-enforcement` was specified | -| `1` | API error — non-2xx response from SSL enforcement endpoint | -| `1` | network / connection failure | +| Code | Condition | +| ---- | ----------------------------------------------------------------------------------------------------------------------------- | +| `0` | success — SSL enforcement status (post-update) printed to stdout | +| `1` | neither `--enable-db-ssl-enforcement` nor `--disable-db-ssl-enforcement` set (`LegacySslEnforcementNoEnableDisableFlagError`) | +| `1` | both `--enable-db-ssl-enforcement` and `--disable-db-ssl-enforcement` set (`LegacySslEnforcementMutuallyExclusiveFlagsError`) | +| `1` | project ref unresolved (`LegacyProjectNotLinkedError` / `LegacyInvalidProjectRefError`) | +| `1` | API non-200 (`LegacySslEnforcementUpdateUnexpectedStatusError`) | +| `1` | transport failure (`LegacySslEnforcementUpdateNetworkError`) | ## Output -### `--output-format text` (Go CLI compatible) +### `--output-format text` (default) — Go CLI compatible -Prints updated SSL enforcement configuration to stdout. +Same status-line shape as `get` (Go's `update.Run` delegates to `get.PrintSSLStatus`): + +``` +SSL is being enforced. +``` + +or + +``` +SSL is *NOT* being enforced. +``` + +### Go `--output {json,yaml,toml,env}` + +Byte-identical to the Go CLI's encoders (`apps/cli-go/internal/utils/output.go`). + +### Go `--output pretty` + +Same as `text` mode. ### `--output-format json` -Single JSON object emitted to stdout on success. +The full response object emitted as the `success` event payload: + +```json +{ "currentConfig": { "database": true }, "appliedSuccessfully": true } +``` ### `--output-format stream-json` -One `result` event on success. +One `result` event: ```ndjson -{"type":"result","data":{...}} +{"type":"result","data":{"currentConfig":{"database":true},"appliedSuccessfully":true}} ``` ## Notes -- Flags `--enable-db-ssl-enforcement` and `--disable-db-ssl-enforcement` are mutually exclusive. -- Exactly one of the two flags must be specified. -- Requires `--project-ref` or a linked project (`.supabase/config.json`). +- `--enable-db-ssl-enforcement` and `--disable-db-ssl-enforcement` are mutually exclusive, + enforced at handler entry (Effect CLI has no equivalent of cobra's + `MarkFlagsMutuallyExclusive`). The Go binary uses the cobra helper directly. +- The request body always carries `database: `; passing + `--disable-db-ssl-enforcement` is the user-facing way to send `database: false`. +- `linked-project.json` is **not** written if flag validation fails (no ref is + resolved). `telemetry.json` is written regardless, matching Go's + `PersistentPostRun` semantics. +- The Go `--output` flag wins over the TS `--output-format` flag when both are provided. diff --git a/apps/cli/src/legacy/commands/ssl-enforcement/update/update.command.ts b/apps/cli/src/legacy/commands/ssl-enforcement/update/update.command.ts index 009d51dc5..1dadbe649 100644 --- a/apps/cli/src/legacy/commands/ssl-enforcement/update/update.command.ts +++ b/apps/cli/src/legacy/commands/ssl-enforcement/update/update.command.ts @@ -1,5 +1,9 @@ import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; + +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { withCommandInstrumentation } from "../../../../shared/telemetry/command-instrumentation.ts"; +import { legacyManagementApiRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts"; import { legacySslEnforcementUpdate } from "./update.handler.ts"; const config = { @@ -24,5 +28,8 @@ export type LegacySslEnforcementUpdateFlags = CliCommand.Command.Config.Infer legacySslEnforcementUpdate(flags)), + Command.withHandler((flags) => + legacySslEnforcementUpdate(flags).pipe(withCommandInstrumentation(), withJsonErrorHandling), + ), + Command.provide(legacyManagementApiRuntimeLayer(["ssl-enforcement", "update"])), ); diff --git a/apps/cli/src/legacy/commands/ssl-enforcement/update/update.handler.ts b/apps/cli/src/legacy/commands/ssl-enforcement/update/update.handler.ts index 3afa028c0..615429cc0 100644 --- a/apps/cli/src/legacy/commands/ssl-enforcement/update/update.handler.ts +++ b/apps/cli/src/legacy/commands/ssl-enforcement/update/update.handler.ts @@ -1,14 +1,102 @@ import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; + +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 { Output } from "../../../../shared/output/output.service.ts"; +import { + encodeEnv, + encodeGoJson, + encodeToml, + encodeYaml, +} from "../../../shared/legacy-go-output.encoders.ts"; +import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; +import { + LegacySslEnforcementMutuallyExclusiveFlagsError, + LegacySslEnforcementNoEnableDisableFlagError, + LegacySslEnforcementUpdateNetworkError, + LegacySslEnforcementUpdateUnexpectedStatusError, +} from "../ssl-enforcement.errors.ts"; +import { printSslStatus } from "../ssl-enforcement.format.ts"; import type { LegacySslEnforcementUpdateFlags } from "./update.command.ts"; +// Templates lifted verbatim from `apps/cli-go/internal/ssl_enforcement/update/update.go:19,21`. +// (Lowercase `ssl` in the network message is intentional Go fidelity.) +const mapUpdateError = mapLegacyHttpError({ + networkError: LegacySslEnforcementUpdateNetworkError, + statusError: LegacySslEnforcementUpdateUnexpectedStatusError, + networkMessage: (cause) => `failed to update ssl enforcement: ${cause}`, + statusMessage: (status, body) => `unexpected update SSL status ${status}: ${body}`, +}); + export const legacySslEnforcementUpdate = Effect.fn("legacy.ssl-enforcement.update")(function* ( flags: LegacySslEnforcementUpdateFlags, ) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["ssl-enforcement", "update"]; - if (Option.isSome(flags.projectRef)) args.push("--project-ref", flags.projectRef.value); - if (flags.enableDbSslEnforcement) args.push("--enable-db-ssl-enforcement"); - if (flags.disableDbSslEnforcement) args.push("--disable-db-ssl-enforcement"); - yield* proxy.exec(args); + const output = yield* Output; + const goOutputFlag = yield* LegacyOutputFlag; + const api = yield* LegacyPlatformApi; + const resolver = yield* LegacyProjectRefResolver; + const linkedProjectCache = yield* LegacyLinkedProjectCache; + const telemetryState = yield* LegacyTelemetryState; + + // Telemetry flushes on every invocation, including validation failures — matches Go's + // PersistentPostRun semantics. The linked-project cache write happens only after the ref + // has been resolved (it requires `ref` as input), so it wraps the inner sub-effect. + yield* Effect.gen(function* () { + if (flags.enableDbSslEnforcement && flags.disableDbSslEnforcement) { + return yield* new LegacySslEnforcementMutuallyExclusiveFlagsError(); + } + if (!flags.enableDbSslEnforcement && !flags.disableDbSslEnforcement) { + return yield* new LegacySslEnforcementNoEnableDisableFlagError(); + } + + const ref = yield* resolver.resolve(flags.projectRef); + + yield* Effect.gen(function* () { + const updating = + output.format === "text" + ? yield* output.task("Updating SSL enforcement config...") + : undefined; + // Go only sends the `enforceDbSsl` boolean (`update.go:16`); `--disable-db-ssl-enforcement` + // is the user-facing way to send `database: false`. + const response = yield* api.v1 + .updateSslEnforcementConfig({ + ref, + requestedConfig: { database: flags.enableDbSslEnforcement }, + }) + .pipe( + Effect.tapError(() => updating?.fail() ?? Effect.void), + Effect.catch(mapUpdateError), + ); + yield* updating?.clear() ?? Effect.void; + + const goFmt = Option.getOrUndefined(goOutputFlag); + + if (goFmt === "json") { + yield* output.raw(encodeGoJson(response)); + return; + } + if (goFmt === "yaml") { + yield* output.raw(encodeYaml(response)); + return; + } + if (goFmt === "toml") { + yield* output.raw(encodeToml(response) + "\n"); + return; + } + if (goFmt === "env") { + yield* output.raw(encodeEnv(response) + "\n"); + return; + } + + if (output.format === "json" || output.format === "stream-json") { + yield* output.success("", response); + return; + } + + yield* output.raw(printSslStatus(response)); + }).pipe(Effect.ensuring(linkedProjectCache.cache(ref))); + }).pipe(Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/ssl-enforcement/update/update.integration.test.ts b/apps/cli/src/legacy/commands/ssl-enforcement/update/update.integration.test.ts new file mode 100644 index 000000000..ebb1b9caf --- /dev/null +++ b/apps/cli/src/legacy/commands/ssl-enforcement/update/update.integration.test.ts @@ -0,0 +1,740 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { type V1GetSslEnforcementConfigOutput, makeApiClient } from "@supabase/api/effect"; +import { describe, expect, it } from "@effect/vitest"; +import { BunServices } from "@effect/platform-bun"; +import { Effect, Exit, Layer, Option, Redacted } from "effect"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientError from "effect/unstable/http/HttpClientError"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; +import type * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import { afterEach, beforeEach } from "vitest"; + +import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; +import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; +import { legacyProjectRefLayer } from "../../../config/legacy-project-ref.layer.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 { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { mockOutput, mockProcessControl, mockTty } from "../../../../../tests/helpers/mocks.ts"; +import { legacySslEnforcementUpdate } from "./update.handler.ts"; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const VALID_REF = "abcdefghijklmnopqrst"; +const VALID_TOKEN = "sbp_" + "a".repeat(40); + +const SSL_ENFORCED: typeof V1GetSslEnforcementConfigOutput.Type = { + currentConfig: { database: true }, + appliedSuccessfully: true, +}; + +const SSL_NOT_ENFORCED: typeof V1GetSslEnforcementConfigOutput.Type = { + currentConfig: { database: false }, + appliedSuccessfully: false, +}; + +const SSL_DESIRED_BUT_NOT_APPLIED: typeof V1GetSslEnforcementConfigOutput.Type = { + currentConfig: { database: true }, + appliedSuccessfully: false, +}; + +// --------------------------------------------------------------------------- +// Mock helpers +// --------------------------------------------------------------------------- + +function httpClientLayer( + handler: ( + request: HttpClientRequest.HttpClientRequest, + ) => Effect.Effect, +) { + return Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => handler(request)), + ); +} + +function mockPlatformApi(opts: { + response?: typeof V1GetSslEnforcementConfigOutput.Type; + status?: number; + network?: "fail"; +}) { + const requests: Array<{ + url: string; + method: string; + headers: Readonly>; + body?: unknown; + }> = []; + + const handler = (request: HttpClientRequest.HttpClientRequest) => + Effect.gen(function* () { + let body: unknown = undefined; + if (request.body._tag === "Uint8Array") { + const decoded = new TextDecoder().decode(request.body.body); + try { + body = JSON.parse(decoded); + } catch { + body = decoded; + } + } + requests.push({ url: request.url, method: request.method, headers: request.headers, body }); + + if (opts.network === "fail") { + return yield* Effect.fail( + new HttpClientError.HttpClientError({ + reason: new HttpClientError.TransportError({ + request, + description: "ECONNREFUSED", + }), + }), + ); + } + + const status = opts.status ?? 200; + return HttpClientResponse.fromWeb( + request, + new Response(JSON.stringify(opts.response ?? SSL_ENFORCED), { + status, + headers: { "content-type": "application/json" }, + }), + ); + }); + + const layer = Layer.effect( + LegacyPlatformApi, + makeApiClient({ + baseUrl: "https://api.supabase.com", + accessToken: VALID_TOKEN, + userAgent: "SupabaseCLI/0.0.0-dev", + }), + ).pipe(Layer.provide(httpClientLayer(handler))); + + return { layer, requests }; +} + +function mockCliConfig(workdir: string) { + return Layer.succeed(LegacyCliConfig, { + profile: "supabase", + apiUrl: "https://api.supabase.com", + accessToken: Option.some(Redacted.make(VALID_TOKEN)), + projectId: Option.some(VALID_REF), + workdir, + userAgent: "SupabaseCLI/0.0.0-dev", + }); +} + +interface SetupOpts { + format?: "text" | "json" | "stream-json"; + goOutput?: "env" | "pretty" | "json" | "toml" | "yaml"; + response?: typeof V1GetSslEnforcementConfigOutput.Type; + status?: number; + network?: "fail"; + stdinIsTty?: boolean; +} + +let tempRoot: string; +let currentOut: ReturnType; + +function setup(opts: SetupOpts = {}) { + const out = mockOutput({ format: opts.format ?? "text" }); + currentOut = out; + const api = mockPlatformApi({ + response: opts.response, + status: opts.status, + network: opts.network, + }); + const cliConfig = mockCliConfig(tempRoot); + const processCtl = mockProcessControl(); + const goOutputValue = opts.goOutput === undefined ? Option.none() : Option.some(opts.goOutput); + const layer = Layer.mergeAll( + out.layer, + api.layer, + cliConfig, + mockTty({ stdinIsTty: opts.stdinIsTty ?? false, stdoutIsTty: false }), + processCtl.layer, + legacyProjectRefLayer.pipe( + Layer.provide(api.layer), + Layer.provide(cliConfig), + Layer.provide(mockTty({ stdinIsTty: opts.stdinIsTty ?? false, stdoutIsTty: false })), + Layer.provide(out.layer), + Layer.provide(BunServices.layer), + ), + BunServices.layer, + Layer.succeed(LegacyOutputFlag, goOutputValue), + mockLinkedProjectCacheLayer, + mockTelemetryStateLayer, + ); + return { layer, out, api, processCtl, tempRoot }; +} + +const stdoutText = () => currentOut.stdoutText; + +// --------------------------------------------------------------------------- +// Telemetry + linked-project cache tracking helpers +// --------------------------------------------------------------------------- + +function mockTelemetryStateTracked() { + let flushed = false; + const layer = Layer.succeed(LegacyTelemetryState, { + get flush() { + return Effect.sync(() => { + flushed = true; + }); + }, + }); + return { + layer, + get flushed() { + return flushed; + }, + }; +} + +function mockLinkedProjectCacheTracked() { + let cached = false; + const layer = Layer.succeed(LegacyLinkedProjectCache, { + cache: (_ref: string) => + Effect.sync(() => { + cached = true; + }), + }); + return { + layer, + get cached() { + return cached; + }, + }; +} + +const mockLinkedProjectCacheLayer = Layer.succeed(LegacyLinkedProjectCache, { + cache: () => Effect.void, +}); + +const mockTelemetryStateLayer = Layer.succeed(LegacyTelemetryState, { flush: Effect.void }); + +beforeEach(() => { + tempRoot = mkdtempSync(join(tmpdir(), "supabase-ssl-enforcement-update-int-")); +}); + +afterEach(() => { + rmSync(tempRoot, { recursive: true, force: true }); +}); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("legacy ssl-enforcement update integration", () => { + // ------------------------------------------------------------------------- + // Flag validation + // ------------------------------------------------------------------------- + + it.live( + "fails with LegacySslEnforcementNoEnableDisableFlagError when neither flag is set", + () => { + const { layer } = setup(); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacySslEnforcementUpdate({ + projectRef: Option.none(), + enableDbSslEnforcement: false, + disableDbSslEnforcement: false, + }), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const errorJson = JSON.stringify(exit.cause); + expect(errorJson).toContain("LegacySslEnforcementNoEnableDisableFlagError"); + expect(errorJson).toContain("enable/disable not specified"); + } + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "fails with LegacySslEnforcementMutuallyExclusiveFlagsError when both flags are set", + () => { + const { layer } = setup(); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacySslEnforcementUpdate({ + projectRef: Option.none(), + enableDbSslEnforcement: true, + disableDbSslEnforcement: true, + }), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const errorJson = JSON.stringify(exit.cause); + expect(errorJson).toContain("LegacySslEnforcementMutuallyExclusiveFlagsError"); + expect(errorJson).toContain( + "if any flags in the group [enable-db-ssl-enforcement disable-db-ssl-enforcement] are set", + ); + } + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("does not call the API when flag validation fails", () => { + const { layer, api } = setup(); + return Effect.gen(function* () { + yield* Effect.exit( + legacySslEnforcementUpdate({ + projectRef: Option.none(), + enableDbSslEnforcement: false, + disableDbSslEnforcement: false, + }), + ); + expect(api.requests).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }); + + it.live("writes telemetry but NOT linked-project cache when validation fails", () => { + const telemetry = mockTelemetryStateTracked(); + const cache = mockLinkedProjectCacheTracked(); + const out = mockOutput({ format: "text" }); + currentOut = out; + const api = mockPlatformApi({}); + const cliConfig = mockCliConfig(tempRoot); + const processCtl = mockProcessControl(); + const layer = Layer.mergeAll( + out.layer, + api.layer, + cliConfig, + mockTty({ stdinIsTty: false, stdoutIsTty: false }), + processCtl.layer, + legacyProjectRefLayer.pipe( + Layer.provide(api.layer), + Layer.provide(cliConfig), + Layer.provide(mockTty({ stdinIsTty: false, stdoutIsTty: false })), + Layer.provide(out.layer), + Layer.provide(BunServices.layer), + ), + BunServices.layer, + Layer.succeed(LegacyOutputFlag, Option.none()), + cache.layer, + telemetry.layer, + ); + + return Effect.gen(function* () { + yield* Effect.exit( + legacySslEnforcementUpdate({ + projectRef: Option.none(), + enableDbSslEnforcement: false, + disableDbSslEnforcement: false, + }), + ); + expect(telemetry.flushed).toBe(true); + expect(cache.cached).toBe(false); + }).pipe(Effect.provide(layer)); + }); + + // ------------------------------------------------------------------------- + // Request body + // ------------------------------------------------------------------------- + + it.live("sends requestedConfig.database = true when --enable-db-ssl-enforcement is set", () => { + const { layer, api } = setup({ response: SSL_ENFORCED }); + return Effect.gen(function* () { + yield* legacySslEnforcementUpdate({ + projectRef: Option.none(), + enableDbSslEnforcement: true, + disableDbSslEnforcement: false, + }); + expect(api.requests).toHaveLength(1); + expect(api.requests[0]?.body).toMatchObject({ + requestedConfig: { database: true }, + }); + }).pipe(Effect.provide(layer)); + }); + + it.live("sends requestedConfig.database = false when --disable-db-ssl-enforcement is set", () => { + const { layer, api } = setup({ response: SSL_NOT_ENFORCED }); + return Effect.gen(function* () { + yield* legacySslEnforcementUpdate({ + projectRef: Option.none(), + enableDbSslEnforcement: false, + disableDbSslEnforcement: true, + }); + expect(api.requests).toHaveLength(1); + expect(api.requests[0]?.body).toMatchObject({ + requestedConfig: { database: false }, + }); + }).pipe(Effect.provide(layer)); + }); + + // ------------------------------------------------------------------------- + // Text output modes (mirroring get scenarios with enable flag) + // ------------------------------------------------------------------------- + + it.live('prints "SSL is being enforced." when database=true and appliedSuccessfully=true', () => { + const { layer } = setup({ response: SSL_ENFORCED }); + return Effect.gen(function* () { + yield* legacySslEnforcementUpdate({ + projectRef: Option.none(), + enableDbSslEnforcement: true, + disableDbSslEnforcement: false, + }); + expect(stdoutText()).toBe("SSL is being enforced.\n"); + }).pipe(Effect.provide(layer)); + }); + + it.live('prints "SSL is *NOT* being enforced." when database=false', () => { + const { layer } = setup({ response: SSL_NOT_ENFORCED }); + return Effect.gen(function* () { + yield* legacySslEnforcementUpdate({ + projectRef: Option.none(), + enableDbSslEnforcement: false, + disableDbSslEnforcement: true, + }); + expect(stdoutText()).toBe("SSL is *NOT* being enforced.\n"); + }).pipe(Effect.provide(layer)); + }); + + it.live( + 'prints "SSL is *NOT* being enforced." when database=true but appliedSuccessfully=false', + () => { + const { layer } = setup({ response: SSL_DESIRED_BUT_NOT_APPLIED }); + return Effect.gen(function* () { + yield* legacySslEnforcementUpdate({ + projectRef: Option.none(), + enableDbSslEnforcement: true, + disableDbSslEnforcement: false, + }); + expect(stdoutText()).toBe("SSL is *NOT* being enforced.\n"); + }).pipe(Effect.provide(layer)); + }, + ); + + // ------------------------------------------------------------------------- + // Go output encoders + // ------------------------------------------------------------------------- + + it.live("emits Go-compatible env output for --output env (exact bytes)", () => { + const { layer } = setup({ goOutput: "env", response: SSL_ENFORCED }); + return Effect.gen(function* () { + yield* legacySslEnforcementUpdate({ + projectRef: Option.none(), + enableDbSslEnforcement: true, + disableDbSslEnforcement: false, + }); + expect(stdoutText()).toBe('APPLIEDSUCCESSFULLY="true"\nCURRENTCONFIG_DATABASE="true"\n'); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits Go-compatible indented JSON for --output json (exact bytes)", () => { + const { layer } = setup({ goOutput: "json", response: SSL_ENFORCED }); + return Effect.gen(function* () { + yield* legacySslEnforcementUpdate({ + projectRef: Option.none(), + enableDbSslEnforcement: true, + disableDbSslEnforcement: false, + }); + expect(stdoutText()).toBe( + `{ + "appliedSuccessfully": true, + "currentConfig": { + "database": true + } +} +`, + ); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits YAML for --output yaml", () => { + const { layer } = setup({ goOutput: "yaml", response: SSL_ENFORCED }); + return Effect.gen(function* () { + yield* legacySslEnforcementUpdate({ + projectRef: Option.none(), + enableDbSslEnforcement: true, + disableDbSslEnforcement: false, + }); + const out = stdoutText(); + expect(out).toContain("appliedSuccessfully: true"); + expect(out).toContain("database: true"); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits TOML for --output toml", () => { + const { layer } = setup({ goOutput: "toml", response: SSL_ENFORCED }); + return Effect.gen(function* () { + yield* legacySslEnforcementUpdate({ + projectRef: Option.none(), + enableDbSslEnforcement: true, + disableDbSslEnforcement: false, + }); + const out = stdoutText(); + expect(out).toContain("appliedSuccessfully = true"); + expect(out).toContain("[currentConfig]"); + }).pipe(Effect.provide(layer)); + }); + + it.live("treats --output pretty as identical to text mode", () => { + const { layer } = setup({ goOutput: "pretty", response: SSL_ENFORCED }); + return Effect.gen(function* () { + yield* legacySslEnforcementUpdate({ + projectRef: Option.none(), + enableDbSslEnforcement: true, + disableDbSslEnforcement: false, + }); + expect(stdoutText()).toBe("SSL is being enforced.\n"); + }).pipe(Effect.provide(layer)); + }); + + // ------------------------------------------------------------------------- + // TS output-format modes + // ------------------------------------------------------------------------- + + it.live("emits a JSON success event when --output-format=json", () => { + const { layer, out } = setup({ format: "json", response: SSL_ENFORCED }); + return Effect.gen(function* () { + yield* legacySslEnforcementUpdate({ + projectRef: Option.none(), + enableDbSslEnforcement: true, + disableDbSslEnforcement: false, + }); + const success = out.messages.find((m) => m.type === "success"); + expect(success).toBeDefined(); + expect(success?.data).toMatchObject({ + currentConfig: { database: true }, + appliedSuccessfully: true, + }); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a result event for --output-format=stream-json", () => { + const { layer, out } = setup({ format: "stream-json", response: SSL_ENFORCED }); + return Effect.gen(function* () { + yield* legacySslEnforcementUpdate({ + projectRef: Option.none(), + enableDbSslEnforcement: true, + disableDbSslEnforcement: false, + }); + const success = out.messages.find((m) => m.type === "success"); + expect(success).toBeDefined(); + expect(success?.data).toMatchObject({ currentConfig: { database: true } }); + }).pipe(Effect.provide(layer)); + }); + + it.live("--output (Go) wins over --output-format (TS) when both provided", () => { + const { layer } = setup({ format: "json", goOutput: "yaml", response: SSL_ENFORCED }); + return Effect.gen(function* () { + yield* legacySslEnforcementUpdate({ + projectRef: Option.none(), + enableDbSslEnforcement: true, + disableDbSslEnforcement: false, + }); + const out = stdoutText(); + expect(out).toContain("appliedSuccessfully: true"); + // YAML-shape rather than indented JSON + expect(out.startsWith("{")).toBe(false); + }).pipe(Effect.provide(layer)); + }); + + // ------------------------------------------------------------------------- + // Project ref resolution + // ------------------------------------------------------------------------- + + it.live("passes the resolved project ref into the updateSslEnforcementConfig URL", () => { + const { layer, api } = setup({ response: SSL_ENFORCED }); + return Effect.gen(function* () { + yield* legacySslEnforcementUpdate({ + projectRef: Option.none(), + enableDbSslEnforcement: true, + disableDbSslEnforcement: false, + }); + expect(api.requests).toHaveLength(1); + expect(api.requests[0]?.url).toContain(`/v1/projects/${VALID_REF}/ssl-enforcement`); + }).pipe(Effect.provide(layer)); + }); + + it.live("uses --project-ref flag value over LegacyCliConfig.projectId", () => { + const flagRef = "zzzzzzzzzzzzzzzzzzzz"; + const { layer, api } = setup({ response: SSL_ENFORCED }); + return Effect.gen(function* () { + yield* legacySslEnforcementUpdate({ + projectRef: Option.some(flagRef), + enableDbSslEnforcement: true, + disableDbSslEnforcement: false, + }); + expect(api.requests[0]?.url).toContain(`/v1/projects/${flagRef}/`); + }).pipe(Effect.provide(layer)); + }); + + it.live("reads supabase/.temp/project-ref when env and flag are unset", () => { + const localTempRoot = mkdtempSync(join(tmpdir(), "supabase-ssl-update-int-fileref-")); + const fileRef = "filerefabcdefghijklm"; + mkdirSync(join(localTempRoot, "supabase", ".temp"), { recursive: true }); + writeFileSync(join(localTempRoot, "supabase", ".temp", "project-ref"), fileRef); + + const out = mockOutput({ format: "text" }); + currentOut = out; + const api = mockPlatformApi({ response: SSL_ENFORCED }); + const cliConfig = Layer.succeed(LegacyCliConfig, { + profile: "supabase", + apiUrl: "https://api.supabase.com", + accessToken: Option.some(Redacted.make(VALID_TOKEN)), + projectId: Option.none(), + workdir: localTempRoot, + userAgent: "SupabaseCLI/0.0.0-dev", + }); + const processCtl = mockProcessControl(); + const layer = Layer.mergeAll( + out.layer, + api.layer, + cliConfig, + mockTty({ stdinIsTty: false, stdoutIsTty: false }), + processCtl.layer, + legacyProjectRefLayer.pipe( + Layer.provide(api.layer), + Layer.provide(cliConfig), + Layer.provide(mockTty({ stdinIsTty: false, stdoutIsTty: false })), + Layer.provide(out.layer), + Layer.provide(BunServices.layer), + ), + BunServices.layer, + Layer.succeed(LegacyOutputFlag, Option.none()), + mockLinkedProjectCacheLayer, + mockTelemetryStateLayer, + ); + + return Effect.gen(function* () { + yield* legacySslEnforcementUpdate({ + projectRef: Option.none(), + enableDbSslEnforcement: true, + disableDbSslEnforcement: false, + }); + expect(api.requests[0]?.url).toContain(`/v1/projects/${fileRef}/`); + }).pipe( + Effect.provide(layer), + Effect.ensuring(Effect.sync(() => rmSync(localTempRoot, { recursive: true, force: true }))), + ); + }); + + it.live("fails with LegacyProjectNotLinkedError when no ref source matches off-TTY", () => { + const localTempRoot = mkdtempSync(join(tmpdir(), "supabase-ssl-update-int-no-ref-")); + const out = mockOutput({ format: "text" }); + currentOut = out; + const api = mockPlatformApi({ response: SSL_ENFORCED }); + const cliConfig = Layer.succeed(LegacyCliConfig, { + profile: "supabase", + apiUrl: "https://api.supabase.com", + accessToken: Option.some(Redacted.make(VALID_TOKEN)), + projectId: Option.none(), + workdir: localTempRoot, + userAgent: "SupabaseCLI/0.0.0-dev", + }); + const processCtl = mockProcessControl(); + const layer = Layer.mergeAll( + out.layer, + api.layer, + cliConfig, + mockTty({ stdinIsTty: false, stdoutIsTty: false }), + processCtl.layer, + legacyProjectRefLayer.pipe( + Layer.provide(api.layer), + Layer.provide(cliConfig), + Layer.provide(mockTty({ stdinIsTty: false, stdoutIsTty: false })), + Layer.provide(out.layer), + Layer.provide(BunServices.layer), + ), + BunServices.layer, + Layer.succeed(LegacyOutputFlag, Option.none()), + mockLinkedProjectCacheLayer, + mockTelemetryStateLayer, + ); + + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacySslEnforcementUpdate({ + projectRef: Option.none(), + enableDbSslEnforcement: true, + disableDbSslEnforcement: false, + }).pipe(Effect.provide(layer)), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyProjectNotLinkedError"); + } + }).pipe( + Effect.ensuring(Effect.sync(() => rmSync(localTempRoot, { recursive: true, force: true }))), + ); + }); + + it.live("fails with LegacyInvalidProjectRefError when the resolved ref is malformed", () => { + const { layer } = setup({ response: SSL_ENFORCED }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacySslEnforcementUpdate({ + projectRef: Option.some("BADREF"), + enableDbSslEnforcement: true, + disableDbSslEnforcement: false, + }), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyInvalidProjectRefError"); + } + }).pipe(Effect.provide(layer)); + }); + + // ------------------------------------------------------------------------- + // Error cases + // ------------------------------------------------------------------------- + + it.live("fails with LegacySslEnforcementUpdateUnexpectedStatusError on HTTP 503", () => { + const { layer } = setup({ status: 503, response: SSL_ENFORCED }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacySslEnforcementUpdate({ + projectRef: Option.none(), + enableDbSslEnforcement: true, + disableDbSslEnforcement: false, + }), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const errorJson = JSON.stringify(exit.cause); + expect(errorJson).toContain("LegacySslEnforcementUpdateUnexpectedStatusError"); + expect(errorJson).toContain("unexpected update SSL status 503"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with LegacySslEnforcementUpdateNetworkError on transport failure", () => { + const { layer } = setup({ network: "fail" }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacySslEnforcementUpdate({ + projectRef: Option.none(), + enableDbSslEnforcement: true, + disableDbSslEnforcement: false, + }), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const errorJson = JSON.stringify(exit.cause); + expect(errorJson).toContain("LegacySslEnforcementUpdateNetworkError"); + expect(errorJson).toContain("failed to update ssl enforcement"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a fail event when withJsonErrorHandling wraps a JSON-mode error", () => { + const { layer, out } = setup({ format: "json", status: 503, response: SSL_ENFORCED }); + return Effect.gen(function* () { + yield* legacySslEnforcementUpdate({ + projectRef: Option.none(), + enableDbSslEnforcement: true, + disableDbSslEnforcement: false, + }).pipe(withJsonErrorHandling); + expect(out.messages.some((m) => m.type === "fail")).toBe(true); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/cli/src/legacy/commands/backups/backups.encoders.ts b/apps/cli/src/legacy/shared/legacy-go-output.encoders.ts similarity index 71% rename from apps/cli/src/legacy/commands/backups/backups.encoders.ts rename to apps/cli/src/legacy/shared/legacy-go-output.encoders.ts index 58edb1d83..c9ed3a57b 100644 --- a/apps/cli/src/legacy/commands/backups/backups.encoders.ts +++ b/apps/cli/src/legacy/shared/legacy-go-output.encoders.ts @@ -1,16 +1,39 @@ -import type { V1ListAllBackupsOutput } from "@supabase/api/effect"; import { stringify as stringifyToml } from "smol-toml"; import { stringify as stringifyYaml } from "yaml"; /** - * Reproduces Go's `encoding/json` output for `V1BackupsResponse`: - * - Top-level and nested struct fields serialize in alphabetical declaration order. - * - Go emits `null` for a nil `Backups` slice. The TS schema decodes both `null` - * and `[]` upstream into `[]`, so we re-substitute `null` for empty arrays - * to match the common PITR-only response shape. + * Reproduces Go's `encoding/json` output: + * - Top-level and nested struct fields serialize in alphabetical key order. + * - Trailing newline (matches `encoding/json` MarshalIndent + fmt.Println). + * + * The optional `nullForEmptyArrays` option mirrors Go's `null` serialization for nil + * slices: when the schema decodes both `null` and `[]` to `[]` upstream, the caller can + * list array keys that should re-substitute `null` for empty arrays so the JSON bytes + * match Go's output. Used by `backups list` to preserve its PITR-only `"backups": null` + * shape. Most commands don't need this option. */ -export function encodeGoJson(response: typeof V1ListAllBackupsOutput.Type): string { - const source = response.backups.length > 0 ? response : { ...response, backups: null }; +export function encodeGoJson( + value: T, + options?: { readonly nullForEmptyArrays?: ReadonlyArray }, +): string { + let source: unknown = value; + const nullKeys = options?.nullForEmptyArrays; + if ( + nullKeys !== undefined && + value !== null && + typeof value === "object" && + !Array.isArray(value) + ) { + const record = value as Record; + const patched: Record = { ...record }; + for (const key of nullKeys) { + const v = record[key]; + if (Array.isArray(v) && v.length === 0) { + patched[key] = null; + } + } + source = patched; + } return JSON.stringify(sortKeysDeep(source), null, 2) + "\n"; } @@ -113,6 +136,15 @@ function formatEnvValue(value: string): string { return String(parsed); } } - const escaped = value.replaceAll("\\", "\\\\").replaceAll('"', '\\"'); + // Match Go's `fmt.Sprintf("%q", ...)` escaping: backslash, double-quote, and the + // common C-style control characters \n / \r / \t. Without the control-character + // escapes a multi-line string value could become multiple KEY=VALUE assignments + // when a downstream shell `eval`s or `source`s the output. + const escaped = value + .replaceAll("\\", "\\\\") + .replaceAll('"', '\\"') + .replaceAll("\n", "\\n") + .replaceAll("\r", "\\r") + .replaceAll("\t", "\\t"); return `"${escaped}"`; } diff --git a/apps/cli/src/legacy/commands/backups/backups.encoders.unit.test.ts b/apps/cli/src/legacy/shared/legacy-go-output.encoders.unit.test.ts similarity index 78% rename from apps/cli/src/legacy/commands/backups/backups.encoders.unit.test.ts rename to apps/cli/src/legacy/shared/legacy-go-output.encoders.unit.test.ts index 0087b54a0..9b245558e 100644 --- a/apps/cli/src/legacy/commands/backups/backups.encoders.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-go-output.encoders.unit.test.ts @@ -1,8 +1,13 @@ import { V1ListAllBackupsOutput } from "@supabase/api/effect"; import { describe, expect, it } from "vitest"; -import { encodeEnv, encodeGoJson, encodeToml, encodeYaml } from "./backups.encoders.ts"; +import { encodeEnv, encodeGoJson, encodeToml, encodeYaml } from "./legacy-go-output.encoders.ts"; +// These encoders are type-generic. We keep one fixture shaped like the backups +// response because the `nullForEmptyArrays` option (and the Go-parity byte +// assertions that exercise it) were extracted from the backups port. The +// encoder itself has no backups coupling — see the `{ items, name }` fixtures +// below for plain-object coverage of the option path. const SAMPLE_RESPONSE: typeof V1ListAllBackupsOutput.Type = { region: "ap-southeast-1", walg_enabled: true, @@ -23,7 +28,7 @@ const SAMPLE_RESPONSE: typeof V1ListAllBackupsOutput.Type = { describe("encodeGoJson", () => { it("emits Go's alphabetical struct-field order and trailing newline for a populated response", () => { - const out = encodeGoJson(SAMPLE_RESPONSE); + const out = encodeGoJson(SAMPLE_RESPONSE, { nullForEmptyArrays: ["backups"] }); expect(out).toBe( `{ "backups": [ @@ -49,13 +54,16 @@ describe("encodeGoJson", () => { it("emits backups: null and an empty physical_backup_data object for a PITR-only response", () => { // Matches Go's `apps/cli-go/internal/backups/list/list_test.go` "encodes json output" fixture // — empty backups slice serializes as null, omitempty physical_backup_data fields drop out. - const out = encodeGoJson({ - region: "ap-southeast-1", - walg_enabled: false, - pitr_enabled: false, - backups: [], - physical_backup_data: {}, - }); + const out = encodeGoJson( + { + region: "ap-southeast-1", + walg_enabled: false, + pitr_enabled: false, + backups: [], + physical_backup_data: {}, + }, + { nullForEmptyArrays: ["backups"] }, + ); expect(out).toBe( `{ "backups": null, @@ -64,6 +72,32 @@ describe("encodeGoJson", () => { "region": "ap-southeast-1", "walg_enabled": false } +`, + ); + }); + + it("leaves arrays intact when nullForEmptyArrays is not provided", () => { + // Default behaviour for commands (e.g. ssl-enforcement) that have no nil-slice rewrite. + const out = encodeGoJson({ items: [], name: "x" }); + expect(out).toBe( + `{ + "items": [], + "name": "x" +} +`, + ); + }); + + it("does not substitute null for non-empty arrays even when listed in nullForEmptyArrays", () => { + const out = encodeGoJson({ items: [1, 2], name: "x" }, { nullForEmptyArrays: ["items"] }); + expect(out).toBe( + `{ + "items": [ + 1, + 2 + ], + "name": "x" +} `, ); }); @@ -135,6 +169,14 @@ describe("encodeEnv", () => { expect(out).toBe('MESSAGE="with \\"quotes\\" and \\\\backslash"'); }); + it("escapes embedded newlines, carriage returns, and tabs (Go %q parity)", () => { + // Without this, a multi-line string value would render as multiple lines in + // env output and be interpreted as separate KEY=VALUE assignments by a shell + // that `eval`s or `source`s the output. + const out = encodeEnv({ description: "line one\nline two\rwith\ttab" }); + expect(out).toBe('DESCRIPTION="line one\\nline two\\rwith\\ttab"'); + }); + it("sorts keys deterministically and emits numeric leafs without quotes", () => { const out = encodeEnv({ z: 1, a: 2, m: 3 }); expect(out.split("\n")).toEqual(["A=2", "M=3", "Z=1"]); diff --git a/apps/cli/src/legacy/shared/legacy-http-errors.ts b/apps/cli/src/legacy/shared/legacy-http-errors.ts new file mode 100644 index 000000000..f65efc69c --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-http-errors.ts @@ -0,0 +1,69 @@ +import type { SupabaseApiError } from "@supabase/api/effect"; +import { Effect } from "effect"; +import * as HttpClientError from "effect/unstable/http/HttpClientError"; + +// HttpClientError reasons that indicate the server returned an actual response (vs a transport +// failure). Anything in this set surfaces as an `UnexpectedStatusError`; everything else maps +// to a `NetworkError`. +const RESPONSE_ERROR_TAGS: ReadonlySet = new Set([ + "StatusCodeError", + "DecodeError", + "EmptyBodyError", +]); + +// Caps the response body that gets embedded in error structures. The Management API is +// trusted, but capping prevents oversized error envelopes from flooding `--output-format json` +// and avoids forwarding arbitrary bytes verbatim if the trust boundary ever changes. +const MAX_BODY_LEN = 1024; + +type NetworkErrorFactory = new (args: { readonly message: string }) => E; + +type StatusErrorFactory = new (args: { + readonly status: number; + readonly body: string; + readonly message: string; +}) => E; + +/** + * Build an error mapper that classifies a `SupabaseApiError` into either a typed network + * error or a typed unexpected-status error. Pulled out of individual command families so + * they share the dispatch logic, the body truncation, and the `RESPONSE_ERROR_TAGS` policy. + * + * `networkMessage` and `statusMessage` are templates: they build the human-readable error + * string with the same exact phrasing the Go CLI uses, so Go-parity status messages and + * existing error-message assertions continue to hold. + */ +export function mapLegacyHttpError(opts: { + readonly networkError: NetworkErrorFactory; + readonly statusError: StatusErrorFactory; + readonly networkMessage: (cause: string) => string; + readonly statusMessage: (status: number, body: string) => string; +}): (cause: SupabaseApiError) => Effect.Effect { + return (cause) => + Effect.gen(function* () { + if (HttpClientError.isHttpClientError(cause)) { + if (RESPONSE_ERROR_TAGS.has(cause.reason._tag) && cause.response !== undefined) { + const status = cause.response.status; + const rawBody = yield* cause.response.text.pipe( + Effect.orElseSucceed(() => cause.reason.description ?? ""), + ); + const body = rawBody.length > MAX_BODY_LEN ? rawBody.slice(0, MAX_BODY_LEN) : rawBody; + return yield* Effect.fail( + new opts.statusError({ + status, + body, + message: opts.statusMessage(status, body), + }), + ); + } + const description = cause.reason.description ?? cause.reason._tag; + return yield* Effect.fail( + new opts.networkError({ message: opts.networkMessage(description) }), + ); + } + // SchemaError or HttpBodyError — treat as transport-level network error. + return yield* Effect.fail( + new opts.networkError({ message: opts.networkMessage(String(cause)) }), + ); + }); +} diff --git a/apps/cli/src/legacy/shared/legacy-management-api-runtime.layer.ts b/apps/cli/src/legacy/shared/legacy-management-api-runtime.layer.ts new file mode 100644 index 000000000..868df6403 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-management-api-runtime.layer.ts @@ -0,0 +1,48 @@ +import { Layer } from "effect"; + +import { legacyCredentialsLayer } from "../auth/legacy-credentials.layer.ts"; +import { legacyHttpClientLayer } from "../auth/legacy-http-debug.layer.ts"; +import { legacyPlatformApiLayer } from "../auth/legacy-platform-api.layer.ts"; +import { legacyCliConfigLayer } from "../config/legacy-cli-config.layer.ts"; +import { legacyProjectRefLayer } from "../config/legacy-project-ref.layer.ts"; +import { legacyLinkedProjectCacheLayer } from "../telemetry/legacy-linked-project-cache.layer.ts"; +import { legacyTelemetryStateLayer } from "../telemetry/legacy-telemetry-state.layer.ts"; +import { commandRuntimeLayer } from "../../shared/runtime/command-runtime.layer.ts"; + +// Shared platform-API stack used by every Management-API legacy subcommand. +// `legacyHttpClientLayer` wraps the default fetch transport with a debug logger when `--debug` is set. +const legacyPlatformApiStack = legacyPlatformApiLayer.pipe( + Layer.provide(legacyCredentialsLayer), + Layer.provide(legacyCliConfigLayer), + Layer.provide(legacyHttpClientLayer), +); + +/** + * Composes the runtime layer for a Management-API-style `supabase ` + * invocation. + * + * `legacyCliConfigLayer` must be piped to both `legacyPlatformApiStack` and + * `legacyProjectRefLayer`. `Layer.provide` satisfies a requirement on the target layer; + * it does not expose the provided service to siblings of a `Layer.mergeAll(...)`. The + * project-ref layer reads `LegacyCliConfig` directly for workdir/projectId resolution, + * so without an explicit provide here the bundled runtime panics with + * `Service not found: supabase/legacy/CliConfig`. + * + * @param subcommand - command path segments after `supabase`, e.g. `["backups", "list"]`. + */ +export function legacyManagementApiRuntimeLayer(subcommand: ReadonlyArray) { + return Layer.mergeAll( + legacyPlatformApiStack, + legacyProjectRefLayer.pipe( + Layer.provide(legacyPlatformApiStack), + Layer.provide(legacyCliConfigLayer), + ), + legacyLinkedProjectCacheLayer.pipe( + Layer.provide(legacyCredentialsLayer), + Layer.provide(legacyCliConfigLayer), + Layer.provide(legacyHttpClientLayer), + ), + legacyTelemetryStateLayer, + commandRuntimeLayer([...subcommand]), + ); +} From d9f31cc89da79520feec99b8efe58a23851a6fd2 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 22 May 2026 22:33:18 +0100 Subject: [PATCH 04/13] fix(config): interpolate env() refs before schema decode (#5341) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the crash when `supabase/config.toml` uses `env(VAR)` on numeric or boolean fields (e.g. `analytics.port = "env(SUPABASE_ANALYTICS_PORT)"`). The strict Effect Schema decode ran immediately after raw TOML parse, with `interpolateValue` in `project.ts` only firing post-decode via `resolveProjectValue` — so it never got the chance to substitute the string before `Schema.Number` rejected it. ## What changed - **Pre-decode env() interpolation in `packages/config/src/io.ts`** — `loadProjectConfigFile` now loads the project environment (`.env`/`.env.local`/ambient) and runs a schema-aware walker on the parsed document before handing it to `Schema.decodeUnknownSync(ProjectConfigSchema)`. - **Schema-aware walker in `packages/config/src/lib/env.ts`** — traverses both the parsed document and `ProjectConfigSchema.ast` in parallel. For string leaves matching `env(VAR)`: substitutes against the env, then coerces to Number/Boolean if the schema at that path expects one. Mirrors Go's `LoadEnvHook` + mapstructure type chain (`apps/cli-go/pkg/config/decode_hooks.go:14-21` → subsequent string→type conversion hooks). - **Verbatim-on-missing semantics** — `interpolateLeafValue` in `project.ts` no longer throws `MissingProjectEnvVarError` when the referenced env var is unset. It returns the literal `env(VAR)` string, matching Go parity. The `MissingProjectEnvVarError` class and re-export are removed; `resolveProjectValue` / `resolveProjectSubtree` no longer have a failure channel. - **Fields declared with the `env()` schema helper opt out** via the `x-env-deferred` marker annotation. They still require the literal `env(VAR)` format for post-decode resolution by `resolveProjectValue` — the walker honors the marker and leaves those paths untouched. ## Reviewer-relevant context - **No schema-file edits.** Coercion lives entirely in the walker, so future fields added to any of the section schemas (`db.ts`, `analytics.ts`, `auth/*.ts`, etc.) automatically work with `env()` references — no risk of a contributor forgetting to use a coerced primitive at declaration time. - **The marker annotation lives on the check, not the outer AST.** `env() = Schema.String.check(isPattern(envRegex)).annotate({...})` attaches metadata to the resulting Filter rather than the base String AST, so `isDeferredEnvField` inspects both `node.annotations` and `node.checks[].annotations`. Caught by the `@supabase/stack` `functions.unit.test.ts` regression for `functions..env` (env() helper at a record value position). - **Missing-env semantics in `project.ts` are now non-failing.** Two existing "fails when missing env var" tests in `project.unit.test.ts:223,255` are rewritten to assert verbatim preservation. `redactValue` already skips redaction when the value is still an env reference (`!isEnvReference(value)`), so unresolved literals flow through as plain strings — no Redacted wrapping on missing secrets. - **The next/projectContextLayer workaround (PR #5281) is left in place.** That layer dropped the `loadProjectConfig` call entirely to avoid the crash; reintroducing it is a follow-up refactor since the workaround still functions correctly (it only loads env, not config). - The existing CLI-1489 regression test at `apps/cli/src/next/config/project-context.layer.unit.test.ts:30` is not modified — it asserts the workaround-era behavior of `projectContextLayer`, which is unchanged. Direct upstream regression coverage is added in `packages/config/src/io.unit.test.ts`: numeric coercion, boolean coercion, verbatim preservation, ambient fallback, and decode failure when an unset var is referenced from a numeric field. Fixes CLI-1489 --- packages/config/src/errors.ts | 5 - packages/config/src/index.ts | 1 - packages/config/src/io.ts | 33 +++- packages/config/src/io.unit.test.ts | 137 +++++++++++++ packages/config/src/lib/env.ts | 233 ++++++++++++++++++++++- packages/config/src/project.ts | 66 +++---- packages/config/src/project.unit.test.ts | 35 ++-- 7 files changed, 441 insertions(+), 69 deletions(-) diff --git a/packages/config/src/errors.ts b/packages/config/src/errors.ts index f1b6b5ea5..1cc1c4da1 100644 --- a/packages/config/src/errors.ts +++ b/packages/config/src/errors.ts @@ -12,11 +12,6 @@ export class ProjectEnvParseError extends Data.TaggedError("ProjectEnvParseError readonly line: number; }> {} -export class MissingProjectEnvVarError extends Data.TaggedError("MissingProjectEnvVarError")<{ - readonly configPath: string; - readonly envName: string; -}> {} - export class MissingProjectConfigValueError extends Data.TaggedError( "MissingProjectConfigValueError", )<{ diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index aca28d8be..788b00813 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -1,7 +1,6 @@ export { ProjectConfigSchema, type ProjectConfig, type ProjectConfigJson } from "./base.ts"; export { MissingProjectConfigValueError, - MissingProjectEnvVarError, ProjectConfigParseError, ProjectEnvParseError, } from "./errors.ts"; diff --git a/packages/config/src/io.ts b/packages/config/src/io.ts index 07fbfb883..b981a4bd8 100644 --- a/packages/config/src/io.ts +++ b/packages/config/src/io.ts @@ -2,7 +2,9 @@ import { Effect, FileSystem, Path, Schema } from "effect"; import * as SmolToml from "smol-toml"; import { ProjectConfigSchema, type ProjectConfig } from "./base.ts"; import { ProjectConfigParseError } from "./errors.ts"; +import { interpolateEnvReferencesAgainstSchema } from "./lib/env.ts"; import { findProjectPaths } from "./paths.ts"; +import { loadProjectEnvironment } from "./project.ts"; const projectConfigSchemaKey = "$schema"; @@ -203,18 +205,37 @@ function encodeProjectConfigToTomlDocument( return `${SmolToml.stringify(toConfigDocument(config, schemaRef))}\n`; } -export const loadProjectConfigFile = Effect.fnUntraced(function* (path: string) { +export const loadProjectConfigFile = Effect.fnUntraced(function* (filePath: string) { const fs = yield* FileSystem.FileSystem; - const format = path.endsWith(".json") ? "json" : "toml"; - const content = yield* fs.readFileString(path); + const path = yield* Path.Path; + const format = filePath.endsWith(".json") ? "json" : "toml"; + const content = yield* fs.readFileString(filePath); const document = yield* Effect.try({ try: () => parseProjectConfigDocument(content, format), - catch: (cause) => new ProjectConfigParseError({ path, format, cause }), + catch: (cause) => new ProjectConfigParseError({ path: filePath, format, cause }), + }); + + // Substitute `env(VAR)` references against `.env`/`.env.local`/ambient env + // before schema decode. Required for numeric/boolean fields, which would + // otherwise crash the strict decoder with `Expected number` (CLI-1489). + // The config file lives at `/supabase/config.{toml,json}`, so + // walking two directories up gives us the project root that + // `loadProjectEnvironment` expects. + const projectRoot = path.dirname(path.dirname(filePath)); + const projectEnv = yield* loadProjectEnvironment({ + cwd: projectRoot, + baseEnv: process.env, }); - const config = yield* parseProjectConfig(document, format, path); + const interpolated = interpolateEnvReferencesAgainstSchema( + document, + projectEnv?.values ?? {}, + ProjectConfigSchema, + ); + + const config = yield* parseProjectConfig(interpolated, format, filePath); return { - path, + path: filePath, format, config, schemaRef: getSchemaRef(document), diff --git a/packages/config/src/io.unit.test.ts b/packages/config/src/io.unit.test.ts index 4b934885c..e3154e9e0 100644 --- a/packages/config/src/io.unit.test.ts +++ b/packages/config/src/io.unit.test.ts @@ -675,4 +675,141 @@ major_version = 16 expect(schemaString).toContain("env"); expect(schemaString).not.toContain("versions"); }); + + test("resolves env() on numeric port fields (CLI-1489)", async () => { + const cwd = makeTempProject(); + + try { + await mkdir(join(cwd, "supabase"), { recursive: true }); + await writeFile( + join(cwd, "supabase", "config.toml"), + `project_id = "ref_123" + +[api] +port = "env(SUPABASE_API_PORT)" + +[db] +port = "env(SUPABASE_DB_PORT)" + +[analytics] +port = "env(SUPABASE_ANALYTICS_PORT)" +`, + ); + await writeFile( + join(cwd, "supabase", ".env"), + "SUPABASE_API_PORT=54321\nSUPABASE_DB_PORT=54322\nSUPABASE_ANALYTICS_PORT=54327\n", + ); + + const loaded = await runConfigEffect(loadProjectConfig(cwd)); + + expect(loaded).not.toBeNull(); + expect(loaded!.config.api.port).toBe(54321); + expect(loaded!.config.db.port).toBe(54322); + expect(loaded!.config.analytics.port).toBe(54327); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + + test("resolves env() on boolean fields", async () => { + const cwd = makeTempProject(); + + try { + await mkdir(join(cwd, "supabase"), { recursive: true }); + await writeFile( + join(cwd, "supabase", "config.toml"), + `project_id = "ref_123" + +[analytics] +enabled = "env(SUPABASE_ANALYTICS_ENABLED)" +`, + ); + await writeFile(join(cwd, "supabase", ".env"), "SUPABASE_ANALYTICS_ENABLED=false\n"); + + const loaded = await runConfigEffect(loadProjectConfig(cwd)); + expect(loaded!.config.analytics.enabled).toBe(false); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + + test("preserves env() literals on string fields when the var is unset (Go parity)", async () => { + const cwd = makeTempProject(); + + try { + await mkdir(join(cwd, "supabase"), { recursive: true }); + await writeFile( + join(cwd, "supabase", "config.toml"), + `project_id = "ref_123" + +[auth] +jwt_secret = "env(MISSING_SECRET)" +`, + ); + + const loaded = await runConfigEffect(loadProjectConfig(cwd)); + expect(loaded!.config.auth.jwt_secret).toBe("env(MISSING_SECRET)"); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + + test("fails to decode a numeric field when env var is unset", async () => { + const cwd = makeTempProject(); + + try { + await mkdir(join(cwd, "supabase"), { recursive: true }); + await writeFile( + join(cwd, "supabase", "config.toml"), + `project_id = "ref_123" + +[analytics] +port = "env(MISSING_PORT)" +`, + ); + + const exit = await Effect.runPromiseExit( + loadProjectConfig(cwd).pipe(Effect.provide(BunServices.layer)), + ); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const failure = Cause.findErrorOption(exit.cause); + expect(Option.isSome(failure)).toBe(true); + if (Option.isSome(failure)) { + expect((failure.value as { _tag: string })._tag).toBe("ProjectConfigParseError"); + } + } + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + + test("falls back to ambient process.env when .env is missing", async () => { + const cwd = makeTempProject(); + const previous = process.env.SUPABASE_DB_PORT_TEST; + process.env.SUPABASE_DB_PORT_TEST = "55555"; + + try { + await mkdir(join(cwd, "supabase"), { recursive: true }); + await writeFile( + join(cwd, "supabase", "config.toml"), + `project_id = "ref_123" + +[db] +port = "env(SUPABASE_DB_PORT_TEST)" +`, + ); + + const loaded = await runConfigEffect(loadProjectConfig(cwd)); + expect(loaded!.config.db.port).toBe(55555); + } finally { + if (previous === undefined) { + delete process.env.SUPABASE_DB_PORT_TEST; + } else { + process.env.SUPABASE_DB_PORT_TEST = previous; + } + await rm(cwd, { recursive: true, force: true }); + } + }); }); diff --git a/packages/config/src/lib/env.ts b/packages/config/src/lib/env.ts index 0339142f3..bd1681929 100644 --- a/packages/config/src/lib/env.ts +++ b/packages/config/src/lib/env.ts @@ -1,4 +1,4 @@ -import { Schema } from "effect"; +import { Schema, SchemaAST } from "effect"; export const ENV_PATTERN = "^env\\([A-Z_][A-Z0-9_]*\\)$"; export const ENV_CAPTURE_REGEX = /^env\(([A-Z_][A-Z0-9_]*)\)$/; @@ -12,10 +12,16 @@ interface EnvAnnotations extends Schema.Annotations.Documentation { readonly secret?: true; } +// Marker annotation: this field requires the `env(VAR)` literal form and is +// resolved post-decode via `resolveProjectValue` / `resolveProjectSubtree`. +// The pre-decode walker honors this and leaves the literal untouched. +const X_ENV_DEFERRED = "x-env-deferred" as const; + export const env = (annotations?: EnvAnnotations) => { const { secret, ...rest } = annotations ?? {}; return Schema.String.check(Schema.isPattern(envRegex)).annotate({ ...rest, + [X_ENV_DEFERRED]: true, ...(secret ? { "x-secret": true } : {}), }); }; @@ -27,3 +33,228 @@ export const secret = (annotations?: SecretAnnotations) => ...annotations, "x-secret": true, }); + +// --------------------------------------------------------------------------- +// Pre-decode env() interpolation with schema-aware type coercion +// --------------------------------------------------------------------------- +// +// TOML/JSON parsers turn `port = "env(SUPABASE_ANALYTICS_PORT)"` into a string +// at `analytics.port`, but the schema declares `port: Schema.Number`. Without +// pre-decode handling the strict decoder rejects the string and crashes +// `supabase db start` (CLI-1489). +// +// `interpolateEnvReferencesAgainstSchema` walks the parsed document and the +// schema AST in parallel: +// - For string leaves matching `env(VAR)`: substitute `env[VAR]` if set, or +// preserve the literal verbatim if unset (matches Go's +// `apps/cli-go/pkg/config/decode_hooks.go:14-21`). +// - After substitution, if the schema at that path expects Number or Boolean +// and the value is still a string, coerce it. This mirrors Go's +// mapstructure chain where `LoadEnvHook` returns a string and subsequent +// hooks convert it to the target type. +// - Coercion is only attempted on strings produced by env() substitution. +// Pre-existing string literals at non-string paths are left untouched — +// they'll surface as schema errors at decode time with their original +// value, preserving error clarity. + +type ExpectedType = "number" | "boolean" | "string" | "unknown"; + +// Unwrap Suspend (lazy AST refs from recursive schemas). Other transformation +// wrappers expose the target type via `.ast` directly, so no additional +// unwrapping is needed at this layer. +function unwrapAst(ast: SchemaAST.AST): SchemaAST.AST { + if (ast._tag === "Suspend") { + return unwrapAst(ast.thunk()); + } + return ast; +} + +function leafExpectedType(ast: SchemaAST.AST): ExpectedType { + const node = unwrapAst(ast); + switch (node._tag) { + case "Number": + return "number"; + case "Boolean": + return "boolean"; + case "String": + return "string"; + case "Union": { + // Walk Union branches in declared order; first concrete primitive wins. + // For unions like `Schema.Union(Schema.Number, Schema.Null)` this picks + // the meaningful side. If the union mixes Number and String we err on + // the side of the first match — the schema decode will still validate + // membership after coercion. + for (const variant of node.types) { + const t = leafExpectedType(variant); + if (t !== "unknown") { + return t; + } + } + return "unknown"; + } + default: + return "unknown"; + } +} + +function descendAst(ast: SchemaAST.AST, segment: string): SchemaAST.AST | null { + const node = unwrapAst(ast); + + if (node._tag === "Objects") { + const ps = node.propertySignatures.find((p) => p.name === segment); + if (ps !== undefined) { + return ps.type; + } + // Record-like sections (e.g. `[edge_runtime.secrets]`, `[remotes.]`) + // express their value shape via index signatures. + if (node.indexSignatures.length > 0) { + return node.indexSignatures[0]!.type; + } + return null; + } + + if (node._tag === "Arrays") { + const index = Number.parseInt(segment, 10); + if (Number.isInteger(index)) { + if (index >= 0 && index < node.elements.length) { + return node.elements[index]!; + } + if (node.rest.length > 0) { + return node.rest[0]!; + } + } + return null; + } + + if (node._tag === "Union") { + // Pick the first branch whose descent succeeds. + for (const variant of node.types) { + const next = descendAst(variant, segment); + if (next !== null) { + return next; + } + } + return null; + } + + return null; +} + +function coerceLeaf(value: unknown, expected: ExpectedType): unknown { + if (typeof value !== "string") { + return value; + } + if (expected === "number") { + const trimmed = value.trim(); + if (trimmed === "") { + return value; + } + const n = Number(trimmed); + if (Number.isFinite(n)) { + return n; + } + return value; + } + if (expected === "boolean") { + if (value === "true") return true; + if (value === "false") return false; + return value; + } + return value; +} + +function substituteEnvLeaf(value: string, env: Readonly>): string { + const match = ENV_CAPTURE_REGEX.exec(value); + if (match === null) { + return value; + } + const envName = match[1]; + if (envName === undefined || !Object.prototype.hasOwnProperty.call(env, envName)) { + return value; + } + return env[envName] ?? value; +} + +function isDeferredEnvField(ast: SchemaAST.AST): boolean { + const node = unwrapAst(ast); + if (node.annotations?.[X_ENV_DEFERRED] === true) { + return true; + } + // The env() helper threads its annotation through `.check(isPattern(...))`, + // which attaches the metadata to the Filter rather than the base AST. + for (const check of node.checks ?? []) { + if ( + (check as { annotations?: Record }).annotations?.[X_ENV_DEFERRED] === true + ) { + return true; + } + } + return false; +} + +function walk( + document: unknown, + env: Readonly>, + ast: SchemaAST.AST | null, +): unknown { + if (Array.isArray(document)) { + return document.map((item, index) => { + const child = ast === null ? null : descendAst(ast, String(index)); + return walk(item, env, child); + }); + } + + if (typeof document === "object" && document !== null) { + const result: Record = {}; + for (const [key, value] of Object.entries(document)) { + const child = ast === null ? null : descendAst(ast, key); + result[key] = walk(value, env, child); + } + return result; + } + + if (typeof document === "string") { + // Fields declared with the `env()` helper require the literal `env(VAR)` + // form for post-decode resolution. Skip substitution there so the schema + // pattern check still matches. + if (ast !== null && isDeferredEnvField(ast)) { + return document; + } + // Substitute env() then coerce based on the schema's expected type at this + // path. Only the substituted form is fed to coercion — literal strings at + // non-string paths are left untouched so the decoder can report them with + // their original value. + const substituted = substituteEnvLeaf(document, env); + if (substituted === document) { + return document; + } + if (ast === null) { + return substituted; + } + return coerceLeaf(substituted, leafExpectedType(ast)); + } + + return document; +} + +/** + * Pre-decode env() substitution + schema-aware coercion. + * + * Walks the raw parsed document and the schema AST in parallel. For every + * string leaf matching `env(VAR)`: + * 1. Substitutes `env[VAR]` if set, else preserves the literal verbatim + * (Go-parity with `apps/cli-go/pkg/config/decode_hooks.go:14-21`). + * 2. If the schema at that path expects Number or Boolean, coerces the + * substituted string to the expected primitive — mirroring Go's + * mapstructure chain where `LoadEnvHook` returns a string that the next + * hook converts to the target type. + * + * Returns a new structure; does not mutate the input. + */ +export function interpolateEnvReferencesAgainstSchema( + document: unknown, + env: Readonly>, + schema: { readonly ast: SchemaAST.AST }, +): unknown { + return walk(document, env, schema.ast); +} diff --git a/packages/config/src/project.ts b/packages/config/src/project.ts index 7db85b782..68953f2b1 100644 --- a/packages/config/src/project.ts +++ b/packages/config/src/project.ts @@ -1,6 +1,6 @@ import { Effect, FileSystem, Redacted } from "effect"; import { ProjectConfigSchema } from "./base.ts"; -import { MissingProjectEnvVarError, ProjectEnvParseError } from "./errors.ts"; +import { ProjectEnvParseError } from "./errors.ts"; import { ENV_CAPTURE_REGEX, isEnvReference } from "./lib/env.ts"; import { findProjectPaths, type ProjectPaths } from "./paths.ts"; @@ -216,11 +216,7 @@ function isSecretPath(path: ReadonlyArray): boolean { return secretPathPatterns.some((pattern) => matchesPathPattern(pattern, path)); } -function interpolateLeafValue( - value: string, - env: Readonly>, - configPath: ReadonlyArray, -): string { +function interpolateLeafValue(value: string, env: Readonly>): string { const match = envReferencePattern.exec(value); const envName = match?.[1]; @@ -228,11 +224,10 @@ function interpolateLeafValue( return value; } + // Preserve the literal `env(VAR)` verbatim when VAR is unset. Matches Go's + // `apps/cli-go/pkg/config/decode_hooks.go:14-21` (LoadEnvHook). if (!Object.prototype.hasOwnProperty.call(env, envName)) { - throw new MissingProjectEnvVarError({ - configPath: configPath.join("."), - envName, - }); + return value; } return env[envName] ?? value; @@ -246,27 +241,23 @@ function toPathSegments(path: string): ReadonlyArray { return path.split(".").filter((segment) => segment.length > 0); } -function interpolateValue( - value: unknown, - env: Readonly>, - path: ReadonlyArray = [], -): unknown { +function interpolateValue(value: unknown, env: Readonly>): unknown { if (Array.isArray(value)) { - return value.map((item, index) => interpolateValue(item, env, [...path, String(index)])); + return value.map((item) => interpolateValue(item, env)); } if (typeof value === "object" && value !== null) { const result: Record = {}; for (const [key, child] of Object.entries(value)) { - result[key] = interpolateValue(child, env, [...path, key]); + result[key] = interpolateValue(child, env); } return result; } if (typeof value === "string") { - return interpolateLeafValue(value, env, path); + return interpolateLeafValue(value, env); } return value; @@ -298,34 +289,37 @@ function resolveProjectValueAtPath( value: unknown, projectEnv: ProjectEnvironment, path: ReadonlyArray, -): Effect.Effect { - try { - const interpolated = interpolateValue(value, projectEnv.values, path); - return Effect.succeed(redactValue(interpolated, path)); - } catch (error) { - if (error instanceof MissingProjectEnvVarError) { - return Effect.fail(error); - } - throw error; - } +): unknown { + const interpolated = interpolateValue(value, projectEnv.values); + return redactValue(interpolated, path); } export function resolveProjectValue( value: T, projectEnv: ProjectEnvironment, configPath: string, -): Effect.Effect, MissingProjectEnvVarError> { - return Effect.suspend(() => - resolveProjectValueAtPath(value, projectEnv, toPathSegments(configPath)), - ).pipe(Effect.map((resolved) => resolved as ResolvedProjectValue)); +): Effect.Effect> { + return Effect.sync( + () => + resolveProjectValueAtPath( + value, + projectEnv, + toPathSegments(configPath), + ) as ResolvedProjectValue, + ); } export function resolveProjectSubtree( value: T, projectEnv: ProjectEnvironment, pathPrefix: string, -): Effect.Effect, MissingProjectEnvVarError> { - return Effect.suspend(() => - resolveProjectValueAtPath(value, projectEnv, toPathSegments(pathPrefix)), - ).pipe(Effect.map((resolved) => resolved as ResolvedProjectValue)); +): Effect.Effect> { + return Effect.sync( + () => + resolveProjectValueAtPath( + value, + projectEnv, + toPathSegments(pathPrefix), + ) as ResolvedProjectValue, + ); } diff --git a/packages/config/src/project.unit.test.ts b/packages/config/src/project.unit.test.ts index a0b4ffbdd..aae33d171 100644 --- a/packages/config/src/project.unit.test.ts +++ b/packages/config/src/project.unit.test.ts @@ -220,7 +220,7 @@ jwt_secret = "env(PREVIEW_JWT_SECRET)" } }); - test("resolveProjectValue fails when an explicit env() reference is missing", async () => { + test("resolveProjectValue preserves env() literal when the env var is missing (Go parity)", async () => { const cwd = makeTempProject(); const projectRoot = join(cwd, "repo"); @@ -238,21 +238,20 @@ jwt_secret = "env(MISSING_SECRET)" const loaded = await runConfigEffect(loadProjectConfig(projectRoot)); const projectEnv = await runConfigEffect(loadProjectEnvironment({ cwd: projectRoot })); - await expect( - runConfigEffect( - resolveProjectValue(loaded!.config.auth.jwt_secret, projectEnv!, "auth.jwt_secret"), - ), - ).rejects.toMatchObject({ - _tag: "MissingProjectEnvVarError", - configPath: "auth.jwt_secret", - envName: "MISSING_SECRET", - }); + const resolved = await runConfigEffect( + resolveProjectValue(loaded!.config.auth.jwt_secret, projectEnv!, "auth.jwt_secret"), + ); + + // Secret paths are normally redacted, but unresolved env() literals pass + // through as plain strings so callers can see the missing reference. + expect(Redacted.isRedacted(resolved)).toBe(false); + expect(resolved).toBe("env(MISSING_SECRET)"); } finally { await rm(cwd, { recursive: true, force: true }); } }); - test("resolveProjectSubtree fails when the selected subtree contains a missing env()", async () => { + test("resolveProjectSubtree preserves env() literals nested inside the selected subtree", async () => { const cwd = makeTempProject(); const projectRoot = join(cwd, "repo"); @@ -271,15 +270,11 @@ auth_token = "env(MISSING_SECRET)" const loaded = await runConfigEffect(loadProjectConfig(projectRoot)); const projectEnv = await runConfigEffect(loadProjectEnvironment({ cwd: projectRoot })); - await expect( - runConfigEffect( - resolveProjectSubtree(loaded!.config.auth.sms.twilio, projectEnv!, "auth.sms.twilio"), - ), - ).rejects.toMatchObject({ - _tag: "MissingProjectEnvVarError", - configPath: "auth.sms.twilio.auth_token", - envName: "MISSING_SECRET", - }); + const resolved = await runConfigEffect( + resolveProjectSubtree(loaded!.config.auth.sms.twilio, projectEnv!, "auth.sms.twilio"), + ); + + expect(resolved.auth_token).toBe("env(MISSING_SECRET)"); } finally { await rm(cwd, { recursive: true, force: true }); } From bbc55234016eacbcead8b96d0ddb6b7d4d7614b6 Mon Sep 17 00:00:00 2001 From: Andrew Valleteau Date: Tue, 26 May 2026 09:02:34 +0200 Subject: [PATCH 05/13] feat(config,stack): add auto_expose_new_tables configuration option (#5239) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new `[api].auto_expose_new_tables` configuration option to control whether newly-created tables, views, sequences, and functions in the `public` schema are reachable through the Data API roles (`anon`, `authenticated`, `service_role`) without explicit GRANTs. ## Why Cloud now exposes a "Default privileges for new entities" toggle (supabase/supabase#45329). When it is off, Studio revokes the default GRANTs on `public` at project creation so the Data API surface is opt-in per entity. Local Supabase had no equivalent: bootstrap always installed the default GRANTs, forcing users who opted in on cloud to either keep their local schema out of sync or ship a project-specific revoke migration. This adds a first-class `config.toml` flag and applies the same revoke SQL Studio runs. ## Migration design — tri-state field The field is intentionally optional today so the rollout can flip the implicit default without losing track of users who made an explicit choice: | State | Today's behaviour | 2026-05-30 | 2026-10-30 | | --- | --- | --- | --- | | unset (`init` default) | auto-expose (current local behaviour) | flips to revoke (new cloud default) | flag removed | | explicit `true` | auto-expose | auto-expose with deprecation warning | flag removed | | explicit `false` | revoke | revoke | flag removed | - Go: `api.AutoExposeNewTables` is `*bool` with `omitempty`, so unset round-trips as a missing key. `NewConfig` does not seed a value; `ApplyApiPrivileges` treats `nil` and `true` identically and runs the revoke SQL only when an explicit `false` is set. - TS: `api.auto_expose_new_tables` is `Schema.optionalKey(Schema.Boolean)` with no decoding default. The single read site (`start.command.ts`) uses `?? true`, so today's nil-as-true semantics live in one place. - The `init` config.toml template ships the field commented out with a brief timeline so users discover it without having a value injected. ## Implementation ### Config schema - `packages/config/src/api.ts`: optional boolean with timeline-bearing description. - `apps/cli-go/pkg/config/api.go`: `*bool` with `toml:",omitempty"`. - `apps/cli-go/pkg/config/templates/config.toml`: commented-out example with the rollout timeline. ### Database bootstrap - `REVOKE_DEFAULT_DATA_API_PRIVILEGES_SQL` mirrors exactly what Studio runs at cloud project creation when the toggle is off (revokes `select,insert,update,delete on tables`, `usage,select on sequences`, `execute on functions` from `anon`, `authenticated`, `service_role`). - Go (`internal/db/start/start.go`): new `ApplyApiPrivileges` runs the SQL when an explicit `false` is set. Called from `SetupDatabase` (covers v15 start, v15 reset, and v14 start via `SetupLocalDatabase`) and from `internal/db/reset/reset.go`'s `initDatabase` (covers the v14-only reset path that bypasses `SetupDatabase`). - TS (`packages/stack/src/services/postgres-init.ts`): the revoke block is appended to the postgres-init script only when the resolved `autoExposeNewTables` is false, inside the same first-init branch so `db reset` (which wipes the data dir) replays it. ### Stack wiring (TS) - `PostgresConfig` / `ResolvedPostgresConfig` gain `autoExposeNewTables`. `createStack.ts` defaults to `true` if the field is absent from the input config. - `StackBuilder.ts` passes the resolved value into `makePostgresInitService`. - `apps/cli/src/next/commands/start/start.command.ts` reads `ProjectContext.rawProjectConfig.api.auto_expose_new_tables` and threads it through, falling back to `true` when the project config or the field itself is absent. ### Tests - Go: `TestApiAutoExposeNewTablesDefault` (asserts the field stays nil on a fresh `NewConfig`), a new `TestSetupDatabase` case that mocks the exact statement ordering when the flag is `false`, refreshed `TestApiDiff/detects_differences` snapshot. - TS: `packages/config/src/project.unit.test.ts` round-trips unset/true/false through `config.toml`. `packages/stack/src/services/services.unit.test.ts` asserts the postgres-init script contains/omits the revoke block in each flag state. Existing `Stack`/`StackBuilder` fixtures updated for the new resolved field. ## Out of scope (tracked separately) - `supabase config push` round-trip to the Management API - `supabase link` drift detection for this field - Pulling the value down from a linked project - `apps/docs` config reference entry Closes: CLI-1454 https://claude.ai/code/session_011pZGRjHtkxjt1iZj5LYrqq --------- Co-authored-by: Claude Co-authored-by: Julien Goux --- apps/cli-go/internal/db/reset/reset.go | 5 ++- apps/cli-go/internal/db/start/start.go | 38 +++++++++++++++++++ apps/cli-go/internal/db/start/start_test.go | 37 ++++++++++++++++++ apps/cli-go/pkg/config/api.go | 9 +++++ apps/cli-go/pkg/config/api_test.go | 7 ++++ apps/cli-go/pkg/config/templates/config.toml | 6 +++ .../src/next/commands/start/start.command.ts | 13 ++++++- packages/config/src/api.ts | 8 ++++ packages/config/src/project.unit.test.ts | 31 +++++++++++++++ packages/stack/src/Stack.unit.test.ts | 1 + packages/stack/src/StackBuilder.ts | 10 +++++ packages/stack/src/StackBuilder.unit.test.ts | 1 + packages/stack/src/createStack.ts | 1 + packages/stack/src/services/postgres-init.ts | 32 +++++++++++++++- .../stack/src/services/services.unit.test.ts | 38 ++++++++++++++++++- 15 files changed, 233 insertions(+), 4 deletions(-) diff --git a/apps/cli-go/internal/db/reset/reset.go b/apps/cli-go/internal/db/reset/reset.go index d86f34497..7d841f4ba 100644 --- a/apps/cli-go/internal/db/reset/reset.go +++ b/apps/cli-go/internal/db/reset/reset.go @@ -146,7 +146,10 @@ func initDatabase(ctx context.Context, options ...func(*pgx.ConnConfig)) error { return err } defer conn.Close(context.Background()) - return start.InitSchema14(ctx, conn) + if err := start.InitSchema14(ctx, conn); err != nil { + return err + } + return start.ApplyApiPrivileges(ctx, conn) } // Recreate postgres database by connecting to template1 diff --git a/apps/cli-go/internal/db/start/start.go b/apps/cli-go/internal/db/start/start.go index cf9cdc8df..6f1eeacde 100644 --- a/apps/cli-go/internal/db/start/start.go +++ b/apps/cli-go/internal/db/start/start.go @@ -384,6 +384,9 @@ func SetupDatabase(ctx context.Context, conn *pgx.Conn, host string, w io.Writer if err := initSchema(ctx, conn, host, w); err != nil { return err } + if err := ApplyApiPrivileges(ctx, conn); err != nil { + return err + } // Create vault secrets first so roles.sql can reference them if err := vault.UpsertVaultSecrets(ctx, utils.Config.Db.Vault, conn); err != nil { return err @@ -394,3 +397,38 @@ func SetupDatabase(ctx context.Context, conn *pgx.Conn, host string, w io.Writer } return err } + +// RevokeDefaultDataApiPrivilegesSql matches the SQL that Studio runs at cloud project creation +// when the "Default privileges for new entities" toggle is off. It removes the default GRANTs +// applied by the initial schema so newly-created entities in `public` owned by `postgres` are +// not exposed through the Data API roles until explicit GRANTs are issued. +const RevokeDefaultDataApiPrivilegesSql = ` +alter default privileges for role postgres in schema public + revoke select, insert, update, delete on tables from anon, authenticated, service_role; +alter default privileges for role postgres in schema public + revoke usage, select on sequences from anon, authenticated, service_role; +alter default privileges for role postgres in schema public + revoke execute on functions from anon, authenticated, service_role; +` + +// ApplyApiPrivileges adjusts the default privileges on the `public` schema to match the +// `[api].auto_expose_new_tables` flag in config.toml. The flag is tri-state to give users a +// safe migration window: +// +// - unset (default today): keep the bundled initial-schema GRANTs in place, so local matches +// long-standing behaviour. This implicit default flips to false on May 30, 2026, and the +// flag is removed entirely in October 2026 (always-revoked behaviour). +// - true: explicit opt-in to today's behaviour. Treated identically to unset for now; from +// May 30 the CLI will warn that the flag is being deprecated. +// - false: revoke the default Data API GRANTs so newly-created entities in `public` require +// explicit GRANTs to surface through the Data API, matching the new cloud default. +func ApplyApiPrivileges(ctx context.Context, conn *pgx.Conn) error { + if utils.Config.Api.AutoExposeNewTables == nil || *utils.Config.Api.AutoExposeNewTables { + return nil + } + file, err := migration.NewMigrationFromReader(strings.NewReader(RevokeDefaultDataApiPrivilegesSql)) + if err != nil { + return err + } + return file.ExecBatch(ctx, conn) +} diff --git a/apps/cli-go/internal/db/start/start_test.go b/apps/cli-go/internal/db/start/start_test.go index 39f23d8ba..b4d6411bc 100644 --- a/apps/cli-go/internal/db/start/start_test.go +++ b/apps/cli-go/internal/db/start/start_test.go @@ -259,6 +259,43 @@ func TestSetupDatabase(t *testing.T) { assert.Empty(t, apitest.ListUnmatchedRequests()) }) + t.Run("revokes default data api privileges when auto_expose_new_tables is false", func(t *testing.T) { + utils.Config.Db.MajorVersion = 14 + flag := false + utils.Config.Api.AutoExposeNewTables = &flag + defer func() { + utils.Config.Db.MajorVersion = 15 + utils.Config.Api.AutoExposeNewTables = nil + }() + utils.Config.Db.Port = 5432 + utils.GlobalsSql = "create schema public" + utils.InitialSchemaPg14Sql = "create schema private" + // Setup in-memory fs + fsys := afero.NewMemMapFs() + roles := "create role postgres" + require.NoError(t, afero.WriteFile(fsys, utils.CustomRolesPath, []byte(roles), 0644)) + // Setup mock postgres: the revoke SQL must execute between the initial schema and roles.sql + conn := pgtest.NewConn() + defer conn.Close(t) + conn.Query(utils.GlobalsSql). + Reply("CREATE SCHEMA"). + Query(utils.InitialSchemaPg14Sql). + Reply("CREATE SCHEMA"). + Query("alter default privileges for role postgres in schema public\n revoke select, insert, update, delete on tables from anon, authenticated, service_role"). + Reply("ALTER DEFAULT PRIVILEGES"). + Query("alter default privileges for role postgres in schema public\n revoke usage, select on sequences from anon, authenticated, service_role"). + Reply("ALTER DEFAULT PRIVILEGES"). + Query("alter default privileges for role postgres in schema public\n revoke execute on functions from anon, authenticated, service_role"). + Reply("ALTER DEFAULT PRIVILEGES"). + Query(roles). + Reply("CREATE ROLE") + // Run test + err := SetupLocalDatabase(context.Background(), "", fsys, io.Discard, conn.Intercept) + // Check error + assert.NoError(t, err) + assert.Empty(t, apitest.ListUnmatchedRequests()) + }) + t.Run("throws error on connect failure", func(t *testing.T) { utils.Config.Db.Port = 0 // Run test diff --git a/apps/cli-go/pkg/config/api.go b/apps/cli-go/pkg/config/api.go index 3fd3b6f18..2fdcabe10 100644 --- a/apps/cli-go/pkg/config/api.go +++ b/apps/cli-go/pkg/config/api.go @@ -14,6 +14,15 @@ type ( Schemas []string `toml:"schemas" json:"schemas"` ExtraSearchPath []string `toml:"extra_search_path" json:"extra_search_path"` MaxRows uint `toml:"max_rows" json:"max_rows"` + // When unset (default today), new tables, views, sequences and functions created in + // the `public` schema by `postgres` are automatically reachable through the Data API + // roles `anon`, `authenticated`, and `service_role`, matching long-standing local + // behaviour. Set to false to match the new cloud default and require explicit GRANTs + // to expose entities through the Data API; set to true to opt out of the upcoming + // transition once the platform default flips. Stored as a pointer so the migration + // path (unset -> default true today, default false from May 30, removed in October) + // can flip the implicit value without losing the explicit user choice. + AutoExposeNewTables *bool `toml:"auto_expose_new_tables,omitempty" json:"auto_expose_new_tables,omitempty"` // Local only config Image string `toml:"-" json:"-"` KongImage string `toml:"-" json:"-"` diff --git a/apps/cli-go/pkg/config/api_test.go b/apps/cli-go/pkg/config/api_test.go index 92859c671..ff5bb4a10 100644 --- a/apps/cli-go/pkg/config/api_test.go +++ b/apps/cli-go/pkg/config/api_test.go @@ -134,3 +134,10 @@ func TestApiDiff(t *testing.T) { assertSnapshotEqual(t, diff) }) } + +func TestApiAutoExposeNewTablesDefault(t *testing.T) { + t.Run("is unset on a fresh config so today's implicit-true behaviour applies", func(t *testing.T) { + cfg := NewConfig() + assert.Nil(t, cfg.Api.AutoExposeNewTables) + }) +} diff --git a/apps/cli-go/pkg/config/templates/config.toml b/apps/cli-go/pkg/config/templates/config.toml index d34c36c9b..3958d9789 100644 --- a/apps/cli-go/pkg/config/templates/config.toml +++ b/apps/cli-go/pkg/config/templates/config.toml @@ -16,6 +16,12 @@ extra_search_path = ["public", "extensions"] # The maximum number of rows returns from a view, table, or stored procedure. Limits payload size # for accidental or malicious requests. max_rows = 1000 +# Controls whether new tables, views, sequences and functions created in the `public` schema by +# `postgres` are reachable through the Data API roles (`anon`, `authenticated`, `service_role`) +# without explicit GRANTs. Leave unset today to preserve local behaviour. The implicit default +# flips to `false` on 2026-05-30 to match the new cloud default, and the field is removed in +# 2026-10-30 once the always-revoked behaviour is permanent. Set to `false` to opt in early. +# auto_expose_new_tables = false [api.tls] # Enable HTTPS endpoints locally using a self-signed certificate. diff --git a/apps/cli/src/next/commands/start/start.command.ts b/apps/cli/src/next/commands/start/start.command.ts index 82bd6fd2d..fb68c9772 100644 --- a/apps/cli/src/next/commands/start/start.command.ts +++ b/apps/cli/src/next/commands/start/start.command.ts @@ -1,4 +1,5 @@ import { Effect, Layer, Option, Context } from "effect"; +import { loadProjectConfig } from "@supabase/config"; import { DEFAULT_MANAGED_STACK_NAME, StateManager, @@ -150,10 +151,20 @@ export const startCommand = Command.make("start", flags).pipe( onSome: (metadata) => metadata.services, }), ); - const stackConfig = withServiceVersions( + // The flag is tri-state in config.toml: unset / true / false. Today, unset and true both + // preserve the long-standing local behaviour of auto-exposing new entities in `public`. + // The implicit default flips to false on 2026-05-30 to match the new cloud default, and + // the field is removed in 2026-10-30. + const loadedProjectConfig = yield* loadProjectConfig(projectHome.projectRoot); + const autoExposeNewTables = loadedProjectConfig?.config.api.auto_expose_new_tables ?? true; + const baseStackConfig = withServiceVersions( toStartStackConfig(flags.exclude, flags.mode), serviceVersionContext.runtimeVersions, ); + const stackConfig = { + ...baseStackConfig, + postgres: { ...baseStackConfig.postgres, autoExposeNewTables }, + }; const resolvedConfig = yield* Effect.promise(() => resolveDaemonConfig({ cacheRoot: cliConfig.supabaseHome, diff --git a/packages/config/src/api.ts b/packages/config/src/api.ts index 9f7f5de7a..fc169044f 100644 --- a/packages/config/src/api.ts +++ b/packages/config/src/api.ts @@ -56,6 +56,14 @@ export const api = Schema.Struct({ tags, links, }).pipe(Schema.withDecodingDefaultKey(Effect.succeed(defaultMaxRows))), + auto_expose_new_tables: Schema.optionalKey( + Schema.Boolean.annotate({ + description: + "Controls whether newly-created tables, views, sequences and functions in the `public` schema by `postgres` are reachable through the Data API roles (`anon`, `authenticated`, `service_role`) without explicit GRANTs. Leave unset today to keep long-standing local behaviour. The implicit default flips to `false` on 2026-05-30 to match the new cloud default, and the field is removed in 2026-10-30 once the always-revoked behaviour is permanent. Set to `false` to opt in early; set to `true` to lock in today's behaviour through the deprecation window.", + tags, + links, + }), + ), tls: Schema.Struct({ enabled: Schema.Boolean.annotate({ default: defaultTlsEnabled, diff --git a/packages/config/src/project.unit.test.ts b/packages/config/src/project.unit.test.ts index aae33d171..de22a465f 100644 --- a/packages/config/src/project.unit.test.ts +++ b/packages/config/src/project.unit.test.ts @@ -107,6 +107,37 @@ describe("project discovery and lazy env resolution", () => { } }); + test("leaves [api].auto_expose_new_tables unset by default and round-trips an explicit value", async () => { + const cwd = makeTempProject(); + const projectRoot = join(cwd, "repo"); + + try { + await mkdir(join(projectRoot, "supabase"), { recursive: true }); + await writeFile(join(projectRoot, "supabase", "config.toml"), `project_id = "ref_123"\n`); + + const defaultLoaded = await runConfigEffect(loadProjectConfig(projectRoot)); + // Field is intentionally optional today so the implicit default can flip on 2026-05-30 + // without losing track of users who explicitly opted in either direction. + expect(defaultLoaded!.config.api.auto_expose_new_tables).toBeUndefined(); + + await writeFile( + join(projectRoot, "supabase", "config.toml"), + `project_id = "ref_123"\n\n[api]\nauto_expose_new_tables = false\n`, + ); + const explicitFalse = await runConfigEffect(loadProjectConfig(projectRoot)); + expect(explicitFalse!.config.api.auto_expose_new_tables).toBe(false); + + await writeFile( + join(projectRoot, "supabase", "config.toml"), + `project_id = "ref_123"\n\n[api]\nauto_expose_new_tables = true\n`, + ); + const explicitTrue = await runConfigEffect(loadProjectConfig(projectRoot)); + expect(explicitTrue!.config.api.auto_expose_new_tables).toBe(true); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + test("loads raw config without resolving explicit env() references", async () => { const cwd = makeTempProject(); const projectRoot = join(cwd, "repo"); diff --git a/packages/stack/src/Stack.unit.test.ts b/packages/stack/src/Stack.unit.test.ts index d3f4d491f..c27826895 100644 --- a/packages/stack/src/Stack.unit.test.ts +++ b/packages/stack/src/Stack.unit.test.ts @@ -57,6 +57,7 @@ const defaultConfig: ResolvedStackConfig = { port: 54322, dataDir: "/tmp/supabase/data", version: DEFAULT_VERSIONS.postgres, + autoExposeNewTables: true, }, postgrest: { port: 54323, diff --git a/packages/stack/src/StackBuilder.ts b/packages/stack/src/StackBuilder.ts index d85ca7e8f..504f8d57d 100644 --- a/packages/stack/src/StackBuilder.ts +++ b/packages/stack/src/StackBuilder.ts @@ -39,6 +39,14 @@ export interface PostgresConfig { readonly port?: number; readonly dataDir?: string; readonly version?: string; + /** + * When true (default), the bundled initial schema GRANTs that expose new tables, views, + * sequences, and functions in `public` to the Data API roles (`anon`, `authenticated`, + * `service_role`) are kept in place. When false, those default privileges are revoked so the + * local stack matches the new cloud default and requires explicit GRANTs to surface entities + * through the Data API. + */ + readonly autoExposeNewTables?: boolean; } export interface PostgrestConfig { @@ -160,6 +168,7 @@ export interface ResolvedPostgresConfig { readonly port: number; readonly dataDir: string; readonly version: string; + readonly autoExposeNewTables: boolean; } export interface ResolvedPostgrestConfig { @@ -569,6 +578,7 @@ export class StackBuilder extends Context.Service< ...makePostgresInitService({ postgresDir: postgresResolution.path, dbPort: config.dbPort, + autoExposeNewTables: config.postgres.autoExposeNewTables, }), enabled: true, }); diff --git a/packages/stack/src/StackBuilder.unit.test.ts b/packages/stack/src/StackBuilder.unit.test.ts index 81673564f..953fd7c01 100644 --- a/packages/stack/src/StackBuilder.unit.test.ts +++ b/packages/stack/src/StackBuilder.unit.test.ts @@ -56,6 +56,7 @@ const baseConfig: ResolvedStackConfig = { port: 5432, dataDir: "/tmp/pg-data", version: DEFAULT_VERSIONS.postgres, + autoExposeNewTables: true, }, postgrest: { port: 3001, diff --git a/packages/stack/src/createStack.ts b/packages/stack/src/createStack.ts index e05a95fa6..0684ac867 100644 --- a/packages/stack/src/createStack.ts +++ b/packages/stack/src/createStack.ts @@ -559,6 +559,7 @@ export async function resolveConfig( port: ports.dbPort, dataDir: postgresDataDir, version: postgresInput.version ?? DEFAULT_VERSIONS.postgres, + autoExposeNewTables: postgresInput.autoExposeNewTables ?? true, }, postgrest: resolvePostgrestConfig(postgrestInput, config.postgrest, ports), auth: resolveAuthConfig(authInput, config.auth, ports, ports.apiPort), diff --git a/packages/stack/src/services/postgres-init.ts b/packages/stack/src/services/postgres-init.ts index 3921df1db..694951230 100644 --- a/packages/stack/src/services/postgres-init.ts +++ b/packages/stack/src/services/postgres-init.ts @@ -3,8 +3,28 @@ import type { ServiceDef } from "@supabase/process-compose"; interface PostgresInitOptions { readonly postgresDir: string; readonly dbPort: number; + /** + * When false, append the SQL that Studio runs at cloud project creation to revoke the default + * Data API privileges on the `public` schema so newly-created entities require explicit GRANTs. + */ + readonly autoExposeNewTables: boolean; } +/** + * SQL that matches what Studio runs at cloud project creation when "Default privileges for new + * entities" is off. Revokes the default GRANTs installed by the bundled initial schema so new + * tables/sequences/functions in `public` owned by `postgres` are not reachable via the Data API + * roles without explicit GRANTs. + */ +export const REVOKE_DEFAULT_DATA_API_PRIVILEGES_SQL = ` +alter default privileges for role postgres in schema public + revoke select, insert, update, delete on tables from anon, authenticated, service_role; +alter default privileges for role postgres in schema public + revoke usage, select on sequences from anon, authenticated, service_role; +alter default privileges for role postgres in schema public + revoke execute on functions from anon, authenticated, service_role; +`.trim(); + export const makePostgresInitService = (opts: PostgresInitOptions): ServiceDef => { const pgBinDir = `${opts.postgresDir}/bin`; const pgLibDir = `${opts.postgresDir}/lib`; @@ -13,6 +33,16 @@ export const makePostgresInitService = (opts: PostgresInitOptions): ServiceDef = const psql = `${pgBinDir}/psql -h 127.0.0.1 -p ${opts.dbPort}`; const psqlOpts = `-v ON_ERROR_STOP=1 --no-password --no-psqlrc`; + const revokeStep = opts.autoExposeNewTables + ? "" + : ` + # Revoke default privileges for the Data API roles on schema public so new tables + # require explicit GRANTs. Mirrors Studio's behaviour at cloud project creation. + ${psql} ${psqlOpts} -U postgres -d postgres <<'EOSQL' +${REVOKE_DEFAULT_DATA_API_PRIVILEGES_SQL} +EOSQL +`; + // Replaces calling migrate.sh (which spawns ~57 separate psql processes) with // chained -f flags that run all SQL files in a single psql session, cutting // postgres-init time from ~5s to ~1s. @@ -61,7 +91,7 @@ EOSQL # Reset stats (non-fatal, matches migrate.sh) ${psql} ${psqlOpts} -U supabase_admin -d postgres -c 'SELECT extensions.pg_stat_statements_reset(); SELECT pg_stat_reset();' || true -fi +${revokeStep}fi # Backfill schemas/databases used by docker-backed auxiliary services. ${psql} ${psqlOpts} -U postgres -d postgres <<'EOSQL' diff --git a/packages/stack/src/services/services.unit.test.ts b/packages/stack/src/services/services.unit.test.ts index a2b65700b..6ddb1354c 100644 --- a/packages/stack/src/services/services.unit.test.ts +++ b/packages/stack/src/services/services.unit.test.ts @@ -7,7 +7,10 @@ import { makeAuthServiceNative, makeAuthServiceDocker } from "./auth.ts"; import { makeEdgeRuntimeServiceDocker, makeEdgeRuntimeServiceNative } from "./edge-runtime.ts"; import { makeImgproxyServiceDocker } from "./imgproxy.ts"; import { makeMailpitServiceDocker } from "./mailpit.ts"; -import { makePostgresInitService } from "./postgres-init.ts"; +import { + makePostgresInitService, + REVOKE_DEFAULT_DATA_API_PRIVILEGES_SQL, +} from "./postgres-init.ts"; import { makePostgresService, makePostgresServiceDocker } from "./postgres.ts"; import { makePostgrestService } from "./postgrest.ts"; import { makePoolerServiceDocker, poolerContainerPorts } from "./pooler.ts"; @@ -370,6 +373,7 @@ describe("makePostgresInitService", () => { const def = makePostgresInitService({ postgresDir: POSTGRES_BIN_PATH, dbPort: DB_PORT, + autoExposeNewTables: true, }); expect(def.name).toBe("postgres-init"); @@ -387,6 +391,7 @@ describe("makePostgresInitService", () => { const def = makePostgresInitService({ postgresDir: POSTGRES_BIN_PATH, dbPort: DB_PORT, + autoExposeNewTables: true, }); const script = def.args?.[1] as string; expect(script).not.toContain("set -e"); @@ -396,6 +401,7 @@ describe("makePostgresInitService", () => { const def = makePostgresInitService({ postgresDir: "/cache/postgres/17/darwin-arm64", dbPort: DB_PORT, + autoExposeNewTables: true, }); const script = def.args?.[1] as string; expect(script).toContain("authenticator"); @@ -406,6 +412,7 @@ describe("makePostgresInitService", () => { const def = makePostgresInitService({ postgresDir: "/cache/postgres/17/darwin-arm64", dbPort: DB_PORT, + autoExposeNewTables: true, }); const script = def.args?.[1] as string; @@ -420,6 +427,7 @@ describe("makePostgresInitService", () => { const def = makePostgresInitService({ postgresDir: "/cache/postgres/17/darwin-arm64", dbPort: DB_PORT, + autoExposeNewTables: true, }); const script = def.args?.[1] as string; expect(script).not.toMatch(/sh .+migrate\.sh/); @@ -427,6 +435,34 @@ describe("makePostgresInitService", () => { expect(script).toContain("init-scripts/*.sql"); expect(script).toContain("migrations/*.sql"); }); + + it("does not revoke default Data API privileges when autoExposeNewTables is true", () => { + const def = makePostgresInitService({ + postgresDir: POSTGRES_BIN_PATH, + dbPort: DB_PORT, + autoExposeNewTables: true, + }); + const script = def.args?.[1] as string; + expect(script).not.toContain("alter default privileges"); + expect(script).not.toContain("revoke select, insert, update, delete on tables"); + }); + + it("revokes default Data API privileges on `public` when autoExposeNewTables is false", () => { + const def = makePostgresInitService({ + postgresDir: POSTGRES_BIN_PATH, + dbPort: DB_PORT, + autoExposeNewTables: false, + }); + const script = def.args?.[1] as string; + expect(script).toContain(REVOKE_DEFAULT_DATA_API_PRIVILEGES_SQL); + expect(script).toContain( + "revoke select, insert, update, delete on tables from anon, authenticated, service_role", + ); + expect(script).toContain( + "revoke usage, select on sequences from anon, authenticated, service_role", + ); + expect(script).toContain("revoke execute on functions from anon, authenticated, service_role"); + }); }); describe("docker-backed auxiliary services", () => { From 0d53df01b7f8904b9be014e7da41573ac94206ef Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 26 May 2026 11:12:05 +0200 Subject: [PATCH 06/13] fix(cli): support legacy migrations alias (#5355) ## Summary - Restores Go CLI parity for the legacy TS proxy by accepting `supabase migrations ...` as an alias for `supabase migration ...`. - Adds a regression test proving the plural alias resolves through the TS command tree while forwarding canonical `migration` argv to the bundled Go binary. - Documents the alias in the legacy Go porting status. Co-authored-by: Cursor --- apps/cli/docs/go-cli-porting-status.md | 1 + .../commands/migration/migration.command.ts | 1 + .../migration/migration.integration.test.ts | 42 +++++++++++++++++++ 3 files changed, 44 insertions(+) create mode 100644 apps/cli/src/legacy/commands/migration/migration.integration.test.ts diff --git a/apps/cli/docs/go-cli-porting-status.md b/apps/cli/docs/go-cli-porting-status.md index 7e0b17101..316bb71d9 100644 --- a/apps/cli/docs/go-cli-porting-status.md +++ b/apps/cli/docs/go-cli-porting-status.md @@ -204,6 +204,7 @@ These route-first equivalents are intentionally lower-level than the old Go comm ## Legacy Shell Wrapping Status Phase 0 proxy wrappers in the legacy shell (`src/legacy/`). Each wrapped command forwards to the bundled Go binary via `LegacyGoProxy`. +The `migration` command group also accepts Go's top-level `migrations` alias and forwards singular `migration` argv to Go. Legend: diff --git a/apps/cli/src/legacy/commands/migration/migration.command.ts b/apps/cli/src/legacy/commands/migration/migration.command.ts index d095e97f9..ff92238ec 100644 --- a/apps/cli/src/legacy/commands/migration/migration.command.ts +++ b/apps/cli/src/legacy/commands/migration/migration.command.ts @@ -10,6 +10,7 @@ import { legacyMigrationFetchCommand } from "./fetch/fetch.command.ts"; export const legacyMigrationCommand = Command.make("migration").pipe( Command.withDescription("Manage database migration scripts."), Command.withShortDescription("Manage database migration scripts"), + Command.withAlias("migrations"), Command.withSubcommands([ legacyMigrationListCommand, legacyMigrationNewCommand, diff --git a/apps/cli/src/legacy/commands/migration/migration.integration.test.ts b/apps/cli/src/legacy/commands/migration/migration.integration.test.ts new file mode 100644 index 000000000..a568cf396 --- /dev/null +++ b/apps/cli/src/legacy/commands/migration/migration.integration.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; +import { CliOutput, Command } from "effect/unstable/cli"; + +import { textCliOutputFormatter } from "../../../shared/output/text-formatter.ts"; +import { LegacyGoProxy } from "../../../shared/legacy/go-proxy.service.ts"; +import { legacyMigrationCommand } from "./migration.command.ts"; + +function mockLegacyGoProxy() { + const calls: Array> = []; + const layer = Layer.succeed(LegacyGoProxy, { + exec: (args) => + Effect.sync(() => { + calls.push([...args]); + }), + }); + + return { layer, calls }; +} + +const legacyTestRoot = Command.make("supabase").pipe( + Command.withSubcommands([legacyMigrationCommand]), +); + +describe("legacy migration command integration", () => { + it.live("accepts the Go-compatible plural migrations alias", () => { + const proxy = mockLegacyGoProxy(); + const run = Effect.gen(function* () { + yield* Command.runWith(legacyTestRoot, { version: "0.0.0-test" })([ + "migrations", + "new", + "create_widgets", + ]); + + expect(proxy.calls).toEqual([["migration", "new", "create_widgets"]]); + }).pipe(Effect.provide(Layer.mergeAll(proxy.layer, CliOutput.layer(textCliOutputFormatter())))); + + // Command.runWith's Environment type is retained even though this path only needs CliOutput + // and the mocked proxy at runtime. + return run as Effect.Effect; + }); +}); From 524ea4e0bd5530e56d00f6f920811d94770c3fd0 Mon Sep 17 00:00:00 2001 From: Etienne Stalmans Date: Tue, 26 May 2026 11:48:08 +0200 Subject: [PATCH 07/13] chore: adds additional sensitive keys (#5356) ## What kind of change does this PR introduce? chore ## What is the current behavior? `--dry-run` could potentially display some sensitive values ## What is the new behavior? additional sensitive values are redacted --- .../commands/platform/platform-schema-introspection.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/cli/src/next/commands/platform/platform-schema-introspection.ts b/apps/cli/src/next/commands/platform/platform-schema-introspection.ts index 438cc81c5..ea934d9a3 100644 --- a/apps/cli/src/next/commands/platform/platform-schema-introspection.ts +++ b/apps/cli/src/next/commands/platform/platform-schema-introspection.ts @@ -21,7 +21,9 @@ function humanizeFieldName(name: string): string { } function isSensitiveField(name: string): boolean { - return /(pass(word)?|secret|token|jwt|private[_-]?key|db[_-]?pass)/i.test(name); + return /(pass(word)?|secret|token|jwt|private[_-]?key|api[_-]?key|access[_-]?key|root[_-]?key|db[_-]?pass)/i.test( + name, + ); } function enumValuesFor(schema: PlatformOpenApiSchema): ReadonlyArray | undefined { @@ -77,8 +79,10 @@ function stringOnlyUnionMetadataFor( variant, ): variant is | { readonly kind: "string" } - | { readonly kind: "enum"; readonly enumValues: ReadonlyArray } => - variant !== undefined, + | { + readonly kind: "enum"; + readonly enumValues: ReadonlyArray; + } => variant !== undefined, ); if (variantMetadata.length !== variants.length) { From 0cfdb72bc5413605c605742c9039eb0966d3c229 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Tue, 26 May 2026 11:27:56 +0100 Subject: [PATCH 08/13] feat(cli): port secrets commands to native TypeScript (#5357) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the Phase-0 Go-binary proxies for `supabase secrets list`, `secrets set`, and `secrets unset` with native Effect handlers. Output, error messages, exit codes, and filesystem side effects match the Go CLI. ## What changed - **`secrets list/set/unset` are now native TS** — typed Management API client with `withCommandInstrumentation` + `mapLegacyHttpError`, honoring all five Go `--output {pretty,json,yaml,toml,env}` encoders plus the TS `--output-format {text,json,stream-json}`. `secrets list` emits the Glamour-table ASCII output byte-for-byte. - **`secrets set` reads `[edge_runtime.secrets]` from `config.toml`** via `@supabase/config`'s `loadProjectConfig` + `resolveProjectSubtree`. Resolved secret values come back wrapped in `Redacted`; unresolved `env(VAR)` literals (env var unset) stay as plain strings and are filtered at the handler — matches Go's `set.go:48-52` which skips entries whose SHA256 is empty (the SHA256 is empty when `DecryptSecretHookFunc` sees a still-literal `env(VAR)`). - **No foundational `LegacyProjectConfig` wrapper.** The original port plan (CLI-1522) introduced a tolerant subtree reader at `legacy/config/legacy-project-config.{service,layer}.ts` to bypass CLI-1489's strict-decode crash. With CLI-1489 fixed upstream in #5341, that workaround is no longer needed — handlers consume the canonical loader directly. CLI-1522 is superseded. ## Reviewer-relevant context - **Config-source filter rule:** the handler only includes secrets where `Redacted.isRedacted(value)` is true. This naturally excludes both unresolved `env(VAR)` literals (which stay as plain strings per CLI-1489's verbatim-on-missing semantics) and any pre-existing literal whose env var couldn't be resolved — equivalent to Go's empty-SHA256 filter without re-implementing the hashing pipeline. - **`LegacySecretsConfigParseError` is kept as a domain-specific wrapper.** The handler catches `ProjectConfigParseError` from `@supabase/config` and re-raises as `LegacySecretsConfigParseError` so the legacy error tag remains stable for error-message parity and the integration test's tag assertion. - **TOML format change:** integration test fixtures previously used the encoded `{ sha256, value }` envelope (modeling the original tolerant reader's storage shape). They now use the plain-string form (`KEY = "value"` / `KEY = "env(VAR)"`) which is what `@supabase/config` decodes and what users actually write. - **Smoke-tested the bundled `dist/supabase-legacy` binary** (per the `Layer.provide` sibling-sharing footgun) against a fixture with both literal and unresolved-env secret entries — no service-not-found panic, POST body contains only resolved entries, unset env() refs filtered out. - `docs/go-cli-porting-status.md` flips all three secrets rows from `wrapped` to `ported`. Fixes CLI-1291 Closes CLI-1522 --------- Co-authored-by: Julien Goux --- apps/cli/docs/go-cli-porting-status.md | 6 +- apps/cli/package.json | 1 + .../commands/secrets/list/SIDE_EFFECTS.md | 101 ++-- .../commands/secrets/list/list.command.ts | 14 +- .../commands/secrets/list/list.handler.ts | 85 ++- .../secrets/list/list.integration.test.ts | 377 +++++++++++++ .../legacy/commands/secrets/secrets.errors.ts | 90 +++ .../legacy/commands/secrets/secrets.format.ts | 25 + .../secrets/secrets.format.unit.test.ts | 35 ++ .../commands/secrets/set/SIDE_EFFECTS.md | 85 +-- .../commands/secrets/set/set.command.ts | 16 +- .../commands/secrets/set/set.handler.ts | 170 +++++- .../secrets/set/set.integration.test.ts | 534 ++++++++++++++++++ .../commands/secrets/unset/SIDE_EFFECTS.md | 85 +-- .../commands/secrets/unset/unset.command.ts | 15 +- .../commands/secrets/unset/unset.handler.ts | 116 +++- .../secrets/unset/unset.integration.test.ts | 408 +++++++++++++ pnpm-lock.yaml | 9 + 18 files changed, 2002 insertions(+), 170 deletions(-) create mode 100644 apps/cli/src/legacy/commands/secrets/list/list.integration.test.ts create mode 100644 apps/cli/src/legacy/commands/secrets/secrets.errors.ts create mode 100644 apps/cli/src/legacy/commands/secrets/secrets.format.ts create mode 100644 apps/cli/src/legacy/commands/secrets/secrets.format.unit.test.ts create mode 100644 apps/cli/src/legacy/commands/secrets/set/set.integration.test.ts create mode 100644 apps/cli/src/legacy/commands/secrets/unset/unset.integration.test.ts diff --git a/apps/cli/docs/go-cli-porting-status.md b/apps/cli/docs/go-cli-porting-status.md index 316bb71d9..a4f8c070b 100644 --- a/apps/cli/docs/go-cli-porting-status.md +++ b/apps/cli/docs/go-cli-porting-status.md @@ -227,9 +227,9 @@ Legend: | `branches unpause` | `wrapped` | [`../src/legacy/commands/branches/unpause/unpause.command.ts`](../src/legacy/commands/branches/unpause/unpause.command.ts) | | `branches delete` | `wrapped` | [`../src/legacy/commands/branches/delete/delete.command.ts`](../src/legacy/commands/branches/delete/delete.command.ts) | | `branches disable` | `wrapped` | [`../src/legacy/commands/branches/disable/disable.command.ts`](../src/legacy/commands/branches/disable/disable.command.ts) | -| `secrets list` | `wrapped` | [`../src/legacy/commands/secrets/list/list.command.ts`](../src/legacy/commands/secrets/list/list.command.ts) | -| `secrets set` | `wrapped` | [`../src/legacy/commands/secrets/set/set.command.ts`](../src/legacy/commands/secrets/set/set.command.ts) | -| `secrets unset` | `wrapped` | [`../src/legacy/commands/secrets/unset/unset.command.ts`](../src/legacy/commands/secrets/unset/unset.command.ts) | +| `secrets list` | `ported` | [`../src/legacy/commands/secrets/list/list.command.ts`](../src/legacy/commands/secrets/list/list.command.ts) | +| `secrets set` | `ported` | [`../src/legacy/commands/secrets/set/set.command.ts`](../src/legacy/commands/secrets/set/set.command.ts) | +| `secrets unset` | `ported` | [`../src/legacy/commands/secrets/unset/unset.command.ts`](../src/legacy/commands/secrets/unset/unset.command.ts) | | `config push` | `wrapped` | [`../src/legacy/commands/config/push/push.command.ts`](../src/legacy/commands/config/push/push.command.ts) | | `backups list` | `ported` | [`../src/legacy/commands/backups/list/list.command.ts`](../src/legacy/commands/backups/list/list.command.ts) | | `backups restore` | `ported` | [`../src/legacy/commands/backups/restore/restore.command.ts`](../src/legacy/commands/backups/restore/restore.command.ts) | diff --git a/apps/cli/package.json b/apps/cli/package.json index e0e6ebc09..d0f29079e 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -53,6 +53,7 @@ "@typescript/native-preview": "catalog:", "@vercel/detect-agent": "^1.2.3", "@vitest/coverage-istanbul": "catalog:", + "dotenv": "^17.4.2", "effect": "catalog:", "ink": "^7.0.3", "ink-spinner": "^5.0.0", diff --git a/apps/cli/src/legacy/commands/secrets/list/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/secrets/list/SIDE_EFFECTS.md index 9fa7075ff..c7f041cc4 100644 --- a/apps/cli/src/legacy/commands/secrets/list/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/secrets/list/SIDE_EFFECTS.md @@ -2,80 +2,85 @@ ## Files Read -| Path | Format | When | -| -------------------------- | ------------------------- | ---------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| Path | Format | When | +| ----------------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------- | +| `/proc/sys/kernel/osrelease` (Linux) | plain text | once on layer init — disables keyring on WSL (`WSL` / `Microsoft` substring match) | +| keyring `"Supabase CLI"` / `` | OS keychain | when `SUPABASE_ACCESS_TOKEN` unset and keyring available; account = `LegacyCliConfig.profile` | +| keyring `"Supabase CLI"` / `access-token` | OS keychain | legacy-key fallback when the profile-keyed lookup misses | +| `~/.supabase/access-token` | plain text (token string) | last-resort fallback after env + keyring miss | +| `/supabase/.temp/project-ref` | plain text | when `--project-ref` and `SUPABASE_PROJECT_ID` are both unset | ## Files Written -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | +| Path | Format | When | +| ------------------------------------------------ | ------ | ------------------------------------------------------------------------ | +| `~/.supabase//linked-project.json` | JSON | always (in `Effect.ensuring`) after `--project-ref` resolves — Go parity | +| `~/.supabase/telemetry.json` | JSON | always (in `Effect.ensuring`) at end of command — Go parity | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ---------------------------- | ------------ | ------------ | ----------------------------------- | -| `GET` | `/v1/projects/{ref}/secrets` | Bearer token | none | `[{name, value}]` (value is digest) | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ---------------------------- | ------------ | ------------ | --------------------------------------------------- | +| `GET` | `/v1/projects/{ref}/secrets` | Bearer token | none | `[{name, value, updated_at?}]` (value is digest) | +| `GET` | `/v1/projects` | Bearer token | none | `[{id, ref, name, ...}]` — TTY-prompt fallback only | ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| Variable | Purpose | Required? | +| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | selects API base URL: `supabase` → `api.supabase.com`, `supabase-staging` → `api.supabase.green`, `supabase-local` → `http://localhost:8080`. May alternatively be a filesystem path to a YAML profile with at least `api_url:` and optional `name:` (Go parity — used by the cli-e2e test harness). | no (defaults to `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (also reads `/supabase/.temp/project-ref` then prompts on TTY) | +| `SUPABASE_WORKDIR` | base directory for the `.temp/project-ref` lookup | no (walks up from CWD looking for `supabase/config.toml`) | +| ~~`SUPABASE_API_URL`~~ | **not honored** — Go parity. Use `SUPABASE_PROFILE` to override the API base URL. | — | ## Exit Codes -| Code | Condition | -| ---- | -------------------------------------------------- | -| `0` | success — secrets printed to stdout | -| `1` | authentication error — no valid token found | -| `1` | API error — non-2xx response from secrets endpoint | -| `1` | network / connection failure | +| Code | Condition | +| ---- | ------------------------------------------------------------------------------------------ | +| `0` | success — secrets printed to stdout | +| `1` | `LegacyPlatformAuthRequiredError` — no token in env/keyring/file | +| `1` | `LegacyInvalidAccessTokenError` — token violates `^sbp_(oauth_)?[a-f0-9]{40}$` | +| `1` | `LegacyProjectNotLinkedError` — `--project-ref` unset, env/file empty, and stdin not a TTY | +| `1` | `LegacyInvalidProjectRefError` — resolved ref violates `^[a-z]{20}$` | +| `1` | `LegacySecretsListUnexpectedStatusError` — non-2xx response from the secrets endpoint | +| `1` | `LegacySecretsListNetworkError` — transport-level network failure | +| `1` | `LegacySecretsEnvNotSupportedError` — `--output env` flag is rejected | ## Output -### `--output-format text` (Go CLI compatible) +The legacy `--output {pretty,json,yaml,toml,env}` flag (Go-compatible) and the new global `--output-format {text,json,stream-json}` flag are both honored. `--output` wins when both are supplied. `pretty` and `text` map to the same render path. -Prints a Markdown-style table to stdout with a header row and one row per secret. -Column order: `NAME`, `DIGEST`. Columns are separated by `|`. +### `--output pretty` (Go default) / `--output-format text` -``` -|NAME|DIGEST| -|-|-| -|`MY_SECRET`|`dummy-digest-value`| -``` +Prints a Glamour-styled markdown table with columns `NAME` and `DIGEST`. The table is rendered byte-for-byte to match Go's `glamour.WithStandardStyle(styles.AsciiStyle)` output (verified against the Go binary fixture). Secrets are sorted alphabetically by `name`. -### `--output-format json` +### `--output json` (Go-compat) -Single JSON array emitted to stdout on success. Each element contains the name and digest value. +Indented JSON of the sorted `[{name, value, updated_at?}]` array, terminated by a newline. Field order is alphabetical (matches Go's struct declaration order for `SecretResponse`). -```json -[ - { - "name": "MY_SECRET", - "value": "dummy-digest-value" - } -] -``` +### `--output yaml` -### `--output-format stream-json` +YAML document of the sorted secret array. + +### `--output toml` + +TOML document wrapping the sorted array as `[[secrets]]`. JSON shape is preserved; leaf order may differ from Go's `BurntSushi/toml` encoder. -One `result` event on success. +### `--output env` -```ndjson -{"type":"result","data":[{"name":"MY_SECRET","value":"dummy-digest-value"}]} -``` +Fails immediately with `LegacySecretsEnvNotSupportedError("--output env flag is not supported")` — Go parity. -On failure, an `error` event is emitted instead: +### `--output-format json` + +Single JSON object emitted via `Output.success` with `{secrets: [...]}` as the `data` field. + +### `--output-format stream-json` -```ndjson -{"type":"error","code":"ApiError","message":"…"} -``` +One `result` NDJSON event on success containing `{secrets: [...]}`. ## Notes - The `value` field returned by the API is a digest/hash of the secret, not the plaintext value. -- Results are sorted alphabetically by name. -- Requires `--project-ref` or a linked project (`.supabase/config.json`). +- Results are sorted alphabetically by `name` before any encoding (Go `list.go:52-54`). +- Sends `User-Agent: SupabaseCLI/` and Bearer auth. No `X-Supabase-Command` headers — Go parity. diff --git a/apps/cli/src/legacy/commands/secrets/list/list.command.ts b/apps/cli/src/legacy/commands/secrets/list/list.command.ts index da810622f..620151843 100644 --- a/apps/cli/src/legacy/commands/secrets/list/list.command.ts +++ b/apps/cli/src/legacy/commands/secrets/list/list.command.ts @@ -1,4 +1,9 @@ +import type * as CliCommand from "effect/unstable/cli/Command"; import { Command, Flag } from "effect/unstable/cli"; + +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { withCommandInstrumentation } from "../../../../shared/telemetry/command-instrumentation.ts"; +import { legacyManagementApiRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts"; import { legacySecretsList } from "./list.handler.ts"; const config = { @@ -6,7 +11,9 @@ const config = { Flag.withDescription("Project ref of the Supabase project."), Flag.optional, ), -}; +} as const; + +export type LegacySecretsListFlags = CliCommand.Command.Config.Infer; export const legacySecretsListCommand = Command.make("list", config).pipe( Command.withDescription("List all secrets in the linked project."), @@ -21,5 +28,8 @@ export const legacySecretsListCommand = Command.make("list", config).pipe( description: "List secrets for a specific project", }, ]), - Command.withHandler((flags) => legacySecretsList(flags)), + Command.withHandler((flags) => + legacySecretsList(flags).pipe(withCommandInstrumentation(), withJsonErrorHandling), + ), + Command.provide(legacyManagementApiRuntimeLayer(["secrets", "list"])), ); diff --git a/apps/cli/src/legacy/commands/secrets/list/list.handler.ts b/apps/cli/src/legacy/commands/secrets/list/list.handler.ts index 98c66391c..335cf34de 100644 --- a/apps/cli/src/legacy/commands/secrets/list/list.handler.ts +++ b/apps/cli/src/legacy/commands/secrets/list/list.handler.ts @@ -1,15 +1,86 @@ +import type { V1ListAllSecretsOutput } from "@supabase/api/effect"; import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; -interface LegacySecretsListFlags { - readonly projectRef: Option.Option; +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 { Output } from "../../../../shared/output/output.service.ts"; +import { encodeGoJson, encodeToml, encodeYaml } from "../../../shared/legacy-go-output.encoders.ts"; +import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; +import { + LegacySecretsEnvNotSupportedError, + LegacySecretsListNetworkError, + LegacySecretsListUnexpectedStatusError, +} from "../secrets.errors.ts"; +import { renderSecretsListTable } from "../secrets.format.ts"; +import type { LegacySecretsListFlags } from "./list.command.ts"; + +type Secrets = typeof V1ListAllSecretsOutput.Type; + +const mapListError = mapLegacyHttpError({ + networkError: LegacySecretsListNetworkError, + statusError: LegacySecretsListUnexpectedStatusError, + networkMessage: (cause) => `failed to list secrets: ${cause}`, + statusMessage: (status, body) => `unexpected list secrets status ${status}: ${body}`, +}); + +function sortSecrets(secrets: Secrets): Secrets { + return [...secrets].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); } export const legacySecretsList = Effect.fn("legacy.secrets.list")(function* ( flags: LegacySecretsListFlags, ) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["secrets", "list"]; - if (Option.isSome(flags.projectRef)) args.push("--project-ref", flags.projectRef.value); - yield* proxy.exec(args); + const output = yield* Output; + const goOutputFlag = yield* LegacyOutputFlag; + const api = yield* LegacyPlatformApi; + const resolver = yield* LegacyProjectRefResolver; + const linkedProjectCache = yield* LegacyLinkedProjectCache; + const telemetryState = yield* LegacyTelemetryState; + + const ref = yield* resolver.resolve(flags.projectRef); + + // Mirror Go's PersistentPostRun: write the linked-project cache and persist + // the telemetry state file whether the main API call succeeds or fails. + yield* Effect.gen(function* () { + const fetching = + output.format === "text" ? yield* output.task("Fetching secrets...") : undefined; + const response = yield* api.v1.listAllSecrets({ ref }).pipe( + Effect.tapError(() => fetching?.fail() ?? Effect.void), + Effect.catch(mapListError), + ); + yield* fetching?.clear() ?? Effect.void; + + const sorted = sortSecrets(response); + const goFmt = Option.getOrUndefined(goOutputFlag); + + if (goFmt === "env") { + return yield* new LegacySecretsEnvNotSupportedError({ + message: "--output env flag is not supported", + }); + } + if (goFmt === "json") { + yield* output.raw(encodeGoJson(sorted)); + return; + } + if (goFmt === "yaml") { + yield* output.raw(encodeYaml(sorted)); + return; + } + if (goFmt === "toml") { + yield* output.raw(encodeToml({ secrets: sorted }) + "\n"); + return; + } + + // goFmt is undefined or "pretty" — defer to TS --output-format for JSON/stream-json, + // otherwise render the Glamour-styled table (Go --output pretty parity). + if (output.format === "json" || output.format === "stream-json") { + yield* output.success("", { secrets: sorted }); + return; + } + + yield* output.raw(renderSecretsListTable(sorted)); + }).pipe(Effect.ensuring(linkedProjectCache.cache(ref)), Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/secrets/list/list.integration.test.ts b/apps/cli/src/legacy/commands/secrets/list/list.integration.test.ts new file mode 100644 index 000000000..ae0a96b5d --- /dev/null +++ b/apps/cli/src/legacy/commands/secrets/list/list.integration.test.ts @@ -0,0 +1,377 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { type V1ListAllSecretsOutput, makeApiClient } from "@supabase/api/effect"; +import { describe, expect, it } from "@effect/vitest"; +import { BunServices } from "@effect/platform-bun"; +import { Effect, Exit, Layer, Option, Redacted } from "effect"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientError from "effect/unstable/http/HttpClientError"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; +import type * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import { afterEach, beforeEach } from "vitest"; + +import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; +import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; +import { legacyProjectRefLayer } from "../../../config/legacy-project-ref.layer.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 { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { mockOutput, mockProcessControl, mockTty } from "../../../../../tests/helpers/mocks.ts"; +import { legacySecretsList } from "./list.handler.ts"; + +const mockLinkedProjectCacheLayer = Layer.succeed(LegacyLinkedProjectCache, { + cache: () => Effect.void, +}); + +const mockTelemetryStateLayer = Layer.succeed(LegacyTelemetryState, { flush: Effect.void }); + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const VALID_REF = "abcdefghijklmnopqrst"; +const VALID_TOKEN = "sbp_" + "a".repeat(40); + +type SecretsResponse = typeof V1ListAllSecretsOutput.Type; + +const SAMPLE_SECRETS: SecretsResponse = [ + { name: "FOO", value: "digest-foo" }, + { name: "BAR", value: "digest-bar" }, +]; + +function jsonResponse(request: HttpClientRequest.HttpClientRequest, status: number, body: unknown) { + return HttpClientResponse.fromWeb( + request, + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }), + ); +} + +function httpClientLayer( + handler: ( + request: HttpClientRequest.HttpClientRequest, + ) => Effect.Effect, +) { + return Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => handler(request)), + ); +} + +function mockPlatformApi(opts: { + response?: SecretsResponse; + status?: number; + network?: "fail"; + apiUrl?: string; +}) { + const requests: Array<{ + url: string; + method: string; + }> = []; + + const status = opts.status ?? 200; + const handler = ( + request: HttpClientRequest.HttpClientRequest, + ): Effect.Effect => { + requests.push({ url: request.url, method: request.method }); + if (opts.network === "fail") { + return Effect.fail( + new HttpClientError.HttpClientError({ + reason: new HttpClientError.TransportError({ + request, + description: "ECONNREFUSED", + }), + }), + ); + } + return Effect.succeed(jsonResponse(request, status, opts.response ?? [])); + }; + + const layer = Layer.effect( + LegacyPlatformApi, + makeApiClient({ + baseUrl: opts.apiUrl ?? "https://api.supabase.com", + accessToken: VALID_TOKEN, + userAgent: "SupabaseCLI/0.0.0-dev", + }), + ).pipe(Layer.provide(httpClientLayer(handler))); + + return { layer, requests }; +} + +function mockCliConfig(opts: { workdir: string; projectId?: Option.Option }) { + return Layer.succeed(LegacyCliConfig, { + profile: "supabase", + apiUrl: "https://api.supabase.com", + accessToken: Option.some(Redacted.make(VALID_TOKEN)), + projectId: opts.projectId ?? Option.some(VALID_REF), + workdir: opts.workdir, + userAgent: "SupabaseCLI/0.0.0-dev", + }); +} + +interface SetupOpts { + format?: "text" | "json" | "stream-json"; + goOutput?: "env" | "pretty" | "json" | "toml" | "yaml"; + response?: SecretsResponse; + status?: number; + network?: "fail"; + projectId?: Option.Option; +} + +let tempRoot: string; +let currentOut: ReturnType; + +function setup(opts: SetupOpts = {}) { + const out = mockOutput({ format: opts.format ?? "text" }); + currentOut = out; + const api = mockPlatformApi({ + response: opts.response, + status: opts.status, + network: opts.network, + }); + const cliConfig = mockCliConfig({ workdir: tempRoot, projectId: opts.projectId }); + const processCtl = mockProcessControl(); + const goOutputValue = opts.goOutput === undefined ? Option.none() : Option.some(opts.goOutput); + const layer = Layer.mergeAll( + out.layer, + api.layer, + cliConfig, + mockTty({ stdinIsTty: false, stdoutIsTty: false }), + processCtl.layer, + legacyProjectRefLayer.pipe( + Layer.provide(api.layer), + Layer.provide(cliConfig), + Layer.provide(mockTty({ stdinIsTty: false, stdoutIsTty: false })), + Layer.provide(out.layer), + Layer.provide(BunServices.layer), + ), + BunServices.layer, + Layer.succeed(LegacyOutputFlag, goOutputValue), + mockLinkedProjectCacheLayer, + mockTelemetryStateLayer, + ); + return { layer, out, api, processCtl }; +} + +const stdoutText = () => currentOut.stdoutText; + +beforeEach(() => { + tempRoot = mkdtempSync(join(tmpdir(), "supabase-secrets-list-int-")); +}); + +afterEach(() => { + rmSync(tempRoot, { recursive: true, force: true }); +}); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("legacy secrets list integration", () => { + it.live("renders a Glamour ASCII table with NAME and DIGEST columns in text mode", () => { + const { layer } = setup({ response: SAMPLE_SECRETS }); + return Effect.gen(function* () { + yield* legacySecretsList({ projectRef: Option.none() }); + const out = stdoutText(); + expect(out).toContain("NAME"); + expect(out).toContain("DIGEST"); + expect(out).toContain("BAR"); + expect(out).toContain("FOO"); + expect(out).toContain("digest-foo"); + }).pipe(Effect.provide(layer)); + }); + + it.live("sorts secrets alphabetically by name regardless of API response order", () => { + const { layer } = setup({ + response: [ + { name: "ZED", value: "z-digest" }, + { name: "ALPHA", value: "a-digest" }, + { name: "MID", value: "m-digest" }, + ], + }); + return Effect.gen(function* () { + yield* legacySecretsList({ projectRef: Option.none() }); + const out = stdoutText(); + const alphaPos = out.indexOf("ALPHA"); + const midPos = out.indexOf("MID"); + const zedPos = out.indexOf("ZED"); + expect(alphaPos).toBeGreaterThan(-1); + expect(midPos).toBeGreaterThan(alphaPos); + expect(zedPos).toBeGreaterThan(midPos); + }).pipe(Effect.provide(layer)); + }); + + it.live("renders literal `|` characters in secret names without escaping (Go parity)", () => { + const { layer } = setup({ + response: [{ name: "with|pipe", value: "digest" }], + }); + return Effect.gen(function* () { + yield* legacySecretsList({ projectRef: Option.none() }); + // Go's pipeline: markdown `\|` → glamour decodes to literal `|`. Our + // renderer skips the markdown step and emits the literal pipe directly. + expect(stdoutText()).toContain("with|pipe"); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a success event with { secrets } for --output-format=json", () => { + const { layer, out } = setup({ format: "json", response: SAMPLE_SECRETS }); + return Effect.gen(function* () { + yield* legacySecretsList({ projectRef: Option.none() }); + const success = out.messages.find((m) => m.type === "success"); + expect(success).toBeDefined(); + expect(success?.data).toMatchObject({ + secrets: [ + { name: "BAR", value: "digest-bar" }, + { name: "FOO", value: "digest-foo" }, + ], + }); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a success event for --output-format=stream-json", () => { + const { layer, out } = setup({ format: "stream-json", response: SAMPLE_SECRETS }); + return Effect.gen(function* () { + yield* legacySecretsList({ projectRef: Option.none() }); + const success = out.messages.find((m) => m.type === "success"); + expect(success).toBeDefined(); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits Go-byte-exact indented JSON to stdout for --output json", () => { + const { layer } = setup({ goOutput: "json", response: SAMPLE_SECRETS }); + return Effect.gen(function* () { + yield* legacySecretsList({ projectRef: Option.none() }); + // Sorted (BAR before FOO) and alphabetical-key JSON; matches Go's struct + // declaration order for SecretResponse {Name, UpdatedAt, Value}. + expect(stdoutText()).toBe( + `[ + { + "name": "BAR", + "value": "digest-bar" + }, + { + "name": "FOO", + "value": "digest-foo" + } +] +`, + ); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a YAML array to stdout for --output yaml", () => { + const { layer } = setup({ goOutput: "yaml", response: SAMPLE_SECRETS }); + return Effect.gen(function* () { + yield* legacySecretsList({ projectRef: Option.none() }); + const out = stdoutText(); + expect(out).toContain("- name: BAR"); + expect(out).toContain("value: digest-bar"); + expect(out).toContain("- name: FOO"); + }).pipe(Effect.provide(layer)); + }); + + it.live("wraps the array as { secrets = [...] } for --output toml", () => { + const { layer } = setup({ goOutput: "toml", response: SAMPLE_SECRETS }); + return Effect.gen(function* () { + yield* legacySecretsList({ projectRef: Option.none() }); + const out = stdoutText(); + expect(out).toContain("[[secrets]]"); + expect(out).toContain('name = "BAR"'); + expect(out).toContain('value = "digest-bar"'); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with LegacySecretsEnvNotSupportedError for --output env", () => { + const { layer } = setup({ goOutput: "env", response: SAMPLE_SECRETS }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacySecretsList({ projectRef: Option.none() })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const errJson = JSON.stringify(exit.cause); + expect(errJson).toContain("LegacySecretsEnvNotSupportedError"); + expect(errJson).toContain("--output env flag is not supported"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("treats --output pretty as identical to text mode (Glamour table)", () => { + const { layer } = setup({ goOutput: "pretty", response: SAMPLE_SECRETS }); + return Effect.gen(function* () { + yield* legacySecretsList({ projectRef: Option.none() }); + expect(stdoutText()).toContain("DIGEST"); + }).pipe(Effect.provide(layer)); + }); + + it.live("--output flag value wins over --output-format when both provided", () => { + const { layer } = setup({ + format: "json", + goOutput: "yaml", + response: SAMPLE_SECRETS, + }); + return Effect.gen(function* () { + yield* legacySecretsList({ projectRef: Option.none() }); + const out = stdoutText(); + expect(out).toContain("- name: BAR"); + // YAML shape, not indented JSON. + expect(out.startsWith("[")).toBe(false); + }).pipe(Effect.provide(layer)); + }); + + it.live("passes the resolved project ref into the listAllSecrets URL", () => { + const { layer, api } = setup({ response: SAMPLE_SECRETS }); + return Effect.gen(function* () { + yield* legacySecretsList({ projectRef: Option.none() }); + expect(api.requests).toHaveLength(1); + expect(api.requests[0]?.url).toContain(`/v1/projects/${VALID_REF}/secrets`); + }).pipe(Effect.provide(layer)); + }); + + it.live("uses --project-ref flag value over LegacyCliConfig.projectId env", () => { + const flagRef = "zzzzzzzzzzzzzzzzzzzz"; + const { layer, api } = setup({ response: SAMPLE_SECRETS }); + return Effect.gen(function* () { + yield* legacySecretsList({ projectRef: Option.some(flagRef) }); + expect(api.requests[0]?.url).toContain(`/v1/projects/${flagRef}/`); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with LegacySecretsListUnexpectedStatusError on HTTP 503", () => { + const { layer } = setup({ status: 503, response: [] }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacySecretsList({ projectRef: Option.none() })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const errJson = JSON.stringify(exit.cause); + expect(errJson).toContain("LegacySecretsListUnexpectedStatusError"); + expect(errJson).toContain("unexpected list secrets status 503"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with LegacySecretsListNetworkError on transport failure", () => { + const { layer } = setup({ network: "fail" }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacySecretsList({ projectRef: Option.none() })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const errJson = JSON.stringify(exit.cause); + expect(errJson).toContain("LegacySecretsListNetworkError"); + expect(errJson).toContain("failed to list secrets"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("withJsonErrorHandling emits a fail event in JSON mode on 503", () => { + const { layer, out } = setup({ format: "json", status: 503, response: [] }); + return Effect.gen(function* () { + yield* legacySecretsList({ projectRef: Option.none() }).pipe(withJsonErrorHandling); + expect(out.messages.some((m) => m.type === "fail")).toBe(true); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/cli/src/legacy/commands/secrets/secrets.errors.ts b/apps/cli/src/legacy/commands/secrets/secrets.errors.ts new file mode 100644 index 000000000..7094cf233 --- /dev/null +++ b/apps/cli/src/legacy/commands/secrets/secrets.errors.ts @@ -0,0 +1,90 @@ +import { Data } from "effect"; + +// --------------------------------------------------------------------------- +// HTTP-bound errors (network + unexpected-status pairs) +// --------------------------------------------------------------------------- + +export class LegacySecretsListNetworkError extends Data.TaggedError( + "LegacySecretsListNetworkError", +)<{ + readonly message: string; +}> {} + +export class LegacySecretsListUnexpectedStatusError extends Data.TaggedError( + "LegacySecretsListUnexpectedStatusError", +)<{ + readonly status: number; + readonly body: string; + readonly message: string; +}> {} + +export class LegacySecretsSetNetworkError extends Data.TaggedError("LegacySecretsSetNetworkError")<{ + readonly message: string; +}> {} + +export class LegacySecretsSetUnexpectedStatusError extends Data.TaggedError( + "LegacySecretsSetUnexpectedStatusError", +)<{ + readonly status: number; + readonly body: string; + readonly message: string; +}> {} + +export class LegacySecretsUnsetNetworkError extends Data.TaggedError( + "LegacySecretsUnsetNetworkError", +)<{ + readonly message: string; +}> {} + +export class LegacySecretsUnsetUnexpectedStatusError extends Data.TaggedError( + "LegacySecretsUnsetUnexpectedStatusError", +)<{ + readonly status: number; + readonly body: string; + readonly message: string; +}> {} + +// --------------------------------------------------------------------------- +// Pure-path errors (validation, file I/O, user cancellation) +// --------------------------------------------------------------------------- + +export class LegacySecretsEnvFileOpenError extends Data.TaggedError( + "LegacySecretsEnvFileOpenError", +)<{ + readonly message: string; +}> {} + +export class LegacySecretsEnvFileParseError extends Data.TaggedError( + "LegacySecretsEnvFileParseError", +)<{ + readonly message: string; +}> {} + +export class LegacyInvalidSecretPairError extends Data.TaggedError("LegacyInvalidSecretPairError")<{ + readonly pair: string; + readonly message: string; +}> {} + +export class LegacySecretsNoArgumentsError extends Data.TaggedError( + "LegacySecretsNoArgumentsError", +)<{ + readonly message: string; +}> {} + +export class LegacySecretsEnvNotSupportedError extends Data.TaggedError( + "LegacySecretsEnvNotSupportedError", +)<{ + readonly message: string; +}> {} + +export class LegacySecretsUnsetCancelledError extends Data.TaggedError( + "LegacySecretsUnsetCancelledError", +)<{ + readonly message: string; +}> {} + +export class LegacySecretsConfigParseError extends Data.TaggedError( + "LegacySecretsConfigParseError", +)<{ + readonly message: string; +}> {} diff --git a/apps/cli/src/legacy/commands/secrets/secrets.format.ts b/apps/cli/src/legacy/commands/secrets/secrets.format.ts new file mode 100644 index 000000000..af6dab1ca --- /dev/null +++ b/apps/cli/src/legacy/commands/secrets/secrets.format.ts @@ -0,0 +1,25 @@ +import { renderGlamourTable } from "../../output/legacy-glamour-table.ts"; + +const SECRETS_HEADERS = ["NAME", "DIGEST"] as const; + +/** + * Reproduces the byte output of Go's `secrets list` pretty mode: + * + * 1. Go builds a markdown table source where each cell content is wrapped in + * backticks and any `|` in the name is markdown-escaped with `\|`. + * 2. Go pipes that through `glamour.RenderTable` (`AsciiStyle`). + * + * Glamour's renderer strips the backticks and converts the markdown-escaped + * `\|` back to a literal `|` in cell content before laying out columns. The + * net result is the cell content rendered as-is. `renderGlamourTable` skips + * the markdown intermediate step entirely and lays out columns directly, so + * we pass the raw name and digest with no escaping or wrapping. Verified + * byte-identical against `apps/cli-go` on `secrets list` with empty, single, + * and pipe-containing names. + */ +export function renderSecretsListTable( + secrets: ReadonlyArray<{ readonly name: string; readonly value: string }>, +): string { + const rows = secrets.map((s) => [s.name, s.value]); + return renderGlamourTable(SECRETS_HEADERS, rows); +} diff --git a/apps/cli/src/legacy/commands/secrets/secrets.format.unit.test.ts b/apps/cli/src/legacy/commands/secrets/secrets.format.unit.test.ts new file mode 100644 index 000000000..2d4b586c0 --- /dev/null +++ b/apps/cli/src/legacy/commands/secrets/secrets.format.unit.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; + +import { renderSecretsListTable } from "./secrets.format.ts"; + +describe("renderSecretsListTable", () => { + it("produces a header-only Glamour table when there are no secrets", () => { + expect(renderSecretsListTable([])).toBe("\n \n NAME | DIGEST \n ------|--------\n\n"); + }); + + it("aligns NAME and DIGEST columns for a two-row input (Go byte-parity)", () => { + expect( + renderSecretsListTable([ + { name: "MY_SECRET", value: "digest123" }, + { name: "OTHER", value: "digest456" }, + ]), + ).toBe( + "\n \n NAME | DIGEST \n" + + " -----------|-----------\n" + + " MY_SECRET | digest123 \n" + + " OTHER | digest456 \n\n", + ); + }); + + it("passes literal `|` characters in names through without escaping", () => { + // Go writes `\|` in its markdown source; Glamour decodes it back to a + // literal pipe in the rendered cell. Going direct to the row renderer + // produces the same byte output (verified against the Go binary). + const out = renderSecretsListTable([{ name: "with|pipe", value: "digest456" }]); + expect(out).toBe( + "\n \n NAME | DIGEST \n" + + " -----------|-----------\n" + + " with|pipe | digest456 \n\n", + ); + }); +}); diff --git a/apps/cli/src/legacy/commands/secrets/set/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/secrets/set/SIDE_EFFECTS.md index 20e0d41af..ff8400532 100644 --- a/apps/cli/src/legacy/commands/secrets/set/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/secrets/set/SIDE_EFFECTS.md @@ -2,64 +2,79 @@ ## Files Read -| Path | Format | When | -| -------------------------- | ------------------------- | ---------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `` | `.env` format | when `--env-file` flag is provided | +| Path | Format | When | +| ----------------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------- | +| `/proc/sys/kernel/osrelease` (Linux) | plain text | once on layer init — disables keyring on WSL (`WSL` / `Microsoft` substring match) | +| keyring `"Supabase CLI"` / `` | OS keychain | when `SUPABASE_ACCESS_TOKEN` unset and keyring available; account = `LegacyCliConfig.profile` | +| keyring `"Supabase CLI"` / `access-token` | OS keychain | legacy-key fallback when the profile-keyed lookup misses | +| `~/.supabase/access-token` | plain text (token string) | last-resort fallback after env + keyring miss | +| `/supabase/.temp/project-ref` | plain text | when `--project-ref` and `SUPABASE_PROJECT_ID` are both unset | +| `/supabase/config.toml` | TOML | always (for `[edge_runtime.secrets]`) — via `@supabase/config`'s `loadProjectConfig` | +| `/.env` | dotenv | always — context for `env(VAR)` interpolation in `[edge_runtime.secrets]` values | +| `/.env.local` | dotenv | always — overrides `.env` for `env(VAR)` interpolation context | +| `` (absolute or CWD-relative) | dotenv | when `--env-file` flag is provided | ## Files Written -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | +| Path | Format | When | +| ------------------------------------------------ | ------ | ------------------------------------------------------------------------ | +| `~/.supabase//linked-project.json` | JSON | always (in `Effect.ensuring`) after `--project-ref` resolves — Go parity | +| `~/.supabase/telemetry.json` | JSON | always (in `Effect.ensuring`) at end of command — Go parity | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ---------------------------- | ------------ | -------------------------------------- | ---------------------- | -| `POST` | `/v1/projects/{ref}/secrets` | Bearer token | `[{name: string, value: string}, ...]` | none (201 expected) | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ---------------------------- | ------------ | -------------------------------------- | ------------------------ | +| `POST` | `/v1/projects/{ref}/secrets` | Bearer token | `[{name: string, value: string}, ...]` | none (201 expected) | +| `GET` | `/v1/projects` | Bearer token | none | TTY-prompt fallback only | ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| Variable | Purpose | Required? | +| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | selects API base URL: `supabase` → `api.supabase.com`, `supabase-staging` → `api.supabase.green`, `supabase-local` → `http://localhost:8080`. May alternatively be a filesystem path to a YAML profile with at least `api_url:` and optional `name:` (Go parity — used by the cli-e2e test harness). | no (defaults to `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (also reads `/supabase/.temp/project-ref` then prompts on TTY) | +| `SUPABASE_WORKDIR` | base directory for the `.temp/project-ref` lookup | no (walks up from CWD looking for `supabase/config.toml`) | +| `env(VAR)` references | values matching `env(NAME)` in `[edge_runtime.secrets]` are resolved against the loaded env. Missing variables preserve the literal verbatim (Go parity). | — | +| ~~`SUPABASE_API_URL`~~ | **not honored** — Go parity. Use `SUPABASE_PROFILE` to override the API base URL. | — | ## Exit Codes -| Code | Condition | -| ---- | --------------------------------------------------- | -| `0` | success — secrets set on the linked project | -| `1` | no secrets provided (no args and no `--env-file`) | -| `1` | malformed secret pair (must be `NAME=VALUE` format) | -| `1` | authentication error — no valid token found | -| `1` | API error — non-2xx response from secrets endpoint | -| `1` | network / connection failure | +| Code | Condition | +| ---- | -------------------------------------------------------------------------------------------- | +| `0` | success — secrets set on the linked project | +| `1` | `LegacyPlatformAuthRequiredError` — no token in env/keyring/file | +| `1` | `LegacyInvalidAccessTokenError` — token violates `^sbp_(oauth_)?[a-f0-9]{40}$` | +| `1` | `LegacyProjectNotLinkedError` — `--project-ref` unset, env/file empty, and stdin not a TTY | +| `1` | `LegacyInvalidProjectRefError` — resolved ref violates `^[a-z]{20}$` | +| `1` | `LegacySecretsNoArgumentsError` — no positional pairs and no entries from env-file or config | +| `1` | `LegacyInvalidSecretPairError` — positional argument missing `=` | +| `1` | `LegacySecretsEnvFileOpenError` — `--env-file` cannot be opened | +| `1` | `LegacySecretsEnvFileParseError` — `--env-file` cannot be parsed | +| `1` | `LegacySecretsConfigParseError` — `supabase/config.toml` cannot be parsed | +| `1` | `LegacySecretsSetUnexpectedStatusError` — non-2xx response from POST | +| `1` | `LegacySecretsSetNetworkError` — transport-level network failure | ## Output -### `--output-format text` (Go CLI compatible) +### `--output pretty` (Go default) / `--output-format text` -Prints a confirmation message to stdout on success. +Stdout: `Finished supabase secrets set.\n`. Stderr: one `Env name cannot start with SUPABASE_, skipping: ` line per filtered entry. -``` -Finished supabase secrets set. -``` - -Skips secrets with names starting with `SUPABASE_` (writes warning to stderr). +Go's `--output {json,yaml,toml,env}` flags all collapse to the same text-mode `Finished` message (Go `set.go:42` ignores `--output`). ### `--output-format json` -Not applicable for this command (write operation). +Single JSON object emitted via `Output.success` with `{project_ref, count}` as the `data` field. ### `--output-format stream-json` -Not applicable for this command (write operation). +One `result` NDJSON event on success containing `{project_ref, count}`. ## Notes -- Accepts secrets as positional `NAME=VALUE` pairs or via `--env-file`. -- Secrets with names starting with `SUPABASE_` are silently skipped. -- Also reads from `config.toml` edge runtime secrets when available. -- Requires `--project-ref` or a linked project (`.supabase/config.json`). +- Source order for merging entries: `[edge_runtime.secrets]` from `config.toml` (only resolved entries — see below) → `--env-file` (overrides config) → CLI args (overrides env-file). +- `SUPABASE_`-prefixed entries are skipped post-merge with a stderr warning. +- `[edge_runtime.secrets]` from config.toml is read via `@supabase/config`'s `loadProjectConfig` + `resolveProjectSubtree`. Resolved secret values arrive wrapped in `Redacted`; unresolved `env(VAR)` literals (env var unset) stay as plain strings and are filtered out at the handler — matches Go's `set.go:48-52` which filters by `len(secret.SHA256) > 0` (the SHA256 is empty when `DecryptSecretHookFunc` sees a still-literal `env(VAR)`). +- Sends `User-Agent: SupabaseCLI/` and Bearer auth. No `X-Supabase-Command` headers — Go parity. diff --git a/apps/cli/src/legacy/commands/secrets/set/set.command.ts b/apps/cli/src/legacy/commands/secrets/set/set.command.ts index 1befa7c28..60a2952f3 100644 --- a/apps/cli/src/legacy/commands/secrets/set/set.command.ts +++ b/apps/cli/src/legacy/commands/secrets/set/set.command.ts @@ -1,4 +1,9 @@ +import type * as CliCommand from "effect/unstable/cli/Command"; import { Argument, Command, Flag } from "effect/unstable/cli"; + +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { withCommandInstrumentation } from "../../../../shared/telemetry/command-instrumentation.ts"; +import { legacyManagementApiRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts"; import { legacySecretsSet } from "./set.handler.ts"; const config = { @@ -14,7 +19,9 @@ const config = { Argument.withDescription("Secret name=value pairs to set."), Argument.variadic(), ), -}; +} as const; + +export type LegacySecretsSetFlags = CliCommand.Command.Config.Infer; export const legacySecretsSetCommand = Command.make("set", config).pipe( Command.withDescription("Set a secret(s) to the linked Supabase project."), @@ -30,10 +37,7 @@ export const legacySecretsSetCommand = Command.make("set", config).pipe( }, ]), Command.withHandler((flags) => - legacySecretsSet({ - projectRef: flags.projectRef, - envFile: flags.envFile, - secrets: flags.secrets.map(String), - }), + legacySecretsSet(flags).pipe(withCommandInstrumentation(), withJsonErrorHandling), ), + Command.provide(legacyManagementApiRuntimeLayer(["secrets", "set"])), ); diff --git a/apps/cli/src/legacy/commands/secrets/set/set.handler.ts b/apps/cli/src/legacy/commands/secrets/set/set.handler.ts index 85b77c294..422f50307 100644 --- a/apps/cli/src/legacy/commands/secrets/set/set.handler.ts +++ b/apps/cli/src/legacy/commands/secrets/set/set.handler.ts @@ -1,19 +1,163 @@ -import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { loadProjectConfig, loadProjectEnvironment, resolveProjectSubtree } from "@supabase/config"; +import { parse as parseDotenv } from "dotenv"; +import { Effect, FileSystem, Option, Path, Redacted } from "effect"; -interface LegacySecretsSetFlags { - readonly projectRef: Option.Option; - readonly envFile: Option.Option; - readonly secrets: ReadonlyArray; -} +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 { Output } from "../../../../shared/output/output.service.ts"; +import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; +import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; +import { + LegacyInvalidSecretPairError, + LegacySecretsConfigParseError, + LegacySecretsEnvFileOpenError, + LegacySecretsEnvFileParseError, + LegacySecretsNoArgumentsError, + LegacySecretsSetNetworkError, + LegacySecretsSetUnexpectedStatusError, +} from "../secrets.errors.ts"; +import type { LegacySecretsSetFlags } from "./set.command.ts"; + +const mapSetError = mapLegacyHttpError({ + networkError: LegacySecretsSetNetworkError, + statusError: LegacySecretsSetUnexpectedStatusError, + networkMessage: (cause) => `failed to set secrets: ${cause}`, + statusMessage: (_status, body) => `Unexpected error setting project secrets: ${body}`, +}); export const legacySecretsSet = Effect.fn("legacy.secrets.set")(function* ( flags: LegacySecretsSetFlags, ) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["secrets", "set"]; - if (Option.isSome(flags.projectRef)) args.push("--project-ref", flags.projectRef.value); - if (Option.isSome(flags.envFile)) args.push("--env-file", flags.envFile.value); - args.push(...flags.secrets); - yield* proxy.exec(args); + const output = yield* Output; + const api = yield* LegacyPlatformApi; + const resolver = yield* LegacyProjectRefResolver; + const linkedProjectCache = yield* LegacyLinkedProjectCache; + const telemetryState = yield* LegacyTelemetryState; + const runtimeInfo = yield* RuntimeInfo; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const ref = yield* resolver.resolve(flags.projectRef); + + yield* Effect.gen(function* () { + // Source 1: `[edge_runtime.secrets]` from `supabase/config.toml`. + // + // Only resolved secret values are sent — entries whose `env(VAR)` references + // are unresolved are skipped. This matches Go's `set.go:48-52`, which + // filters by `len(secret.SHA256) > 0`: the SHA256 is empty exactly when + // `DecryptSecretHookFunc` (`pkg/config/secret.go:98`) sees a still-literal + // `env(VAR)` and returns without hashing. In the TS path, `resolveProjectSubtree` + // wraps every resolved secret leaf in `Redacted`; unresolved env() + // literals stay as plain strings, so `Redacted.isRedacted(...)` is the + // equivalent guard. + const merged = new Map(); + const loaded = yield* loadProjectConfig(runtimeInfo.cwd).pipe( + Effect.catchTag("ProjectConfigParseError", (cause) => + Effect.fail( + new LegacySecretsConfigParseError({ + message: `failed to parse supabase/config.toml: ${String(cause.cause)}`, + }), + ), + ), + ); + if (loaded !== null) { + const projectEnv = yield* loadProjectEnvironment({ + cwd: runtimeInfo.cwd, + baseEnv: process.env, + }); + if (projectEnv !== null) { + const resolved = yield* resolveProjectSubtree( + loaded.config.edge_runtime, + projectEnv, + "edge_runtime", + ); + for (const [name, value] of Object.entries(resolved.secrets ?? {})) { + if (Redacted.isRedacted(value)) { + merged.set(name, Redacted.value(value)); + } + } + } + } + + // Source 2: --env-file entries override config. + if (Option.isSome(flags.envFile)) { + const rawPath = flags.envFile.value; + const absolutePath = path.isAbsolute(rawPath) ? rawPath : path.join(runtimeInfo.cwd, rawPath); + const content = yield* fs.readFileString(absolutePath).pipe( + Effect.mapError( + (cause) => + new LegacySecretsEnvFileOpenError({ + message: `failed to open env file: ${String(cause)}`, + }), + ), + ); + let parsed: Record; + try { + parsed = parseDotenv(content); + } catch (cause) { + return yield* Effect.fail( + new LegacySecretsEnvFileParseError({ + message: `failed to parse env file: ${String(cause)}`, + }), + ); + } + for (const [name, value] of Object.entries(parsed)) { + merged.set(name, value); + } + } + + // Source 3: positional NAME=VALUE pairs override env-file and config. + for (const pair of flags.secrets) { + const eqIdx = pair.indexOf("="); + if (eqIdx === -1) { + return yield* Effect.fail( + new LegacyInvalidSecretPairError({ + pair, + message: `Invalid secret pair: ${pair}. Must be NAME=VALUE.`, + }), + ); + } + merged.set(pair.slice(0, eqIdx), pair.slice(eqIdx + 1)); + } + + // Filter SUPABASE_-prefixed entries with stderr warning (Go `set.go:67-71`). + // The API rejects these names server-side anyway (`@supabase/api`'s schema + // also rejects them via regex), so the filter MUST happen client-side + // before any request is built — otherwise we'd surface a SchemaError instead. + const body: Array<{ name: string; value: string }> = []; + for (const [name, value] of merged) { + if (name.startsWith("SUPABASE_")) { + yield* output.raw(`Env name cannot start with SUPABASE_, skipping: ${name}\n`, "stderr"); + continue; + } + body.push({ name, value }); + } + + if (body.length === 0) { + return yield* Effect.fail( + new LegacySecretsNoArgumentsError({ + message: "No arguments found. Use --env-file to read from a .env file.", + }), + ); + } + + const setting = output.format === "text" ? yield* output.task("Setting secrets...") : undefined; + yield* api.v1.bulkCreateSecrets({ ref, body }).pipe( + Effect.tapError(() => setting?.fail() ?? Effect.void), + Effect.catch(mapSetError), + ); + yield* setting?.clear() ?? Effect.void; + + if (output.format === "json" || output.format === "stream-json") { + yield* output.success("Finished supabase secrets set.", { + project_ref: ref, + count: body.length, + }); + return; + } + + yield* output.raw("Finished supabase secrets set.\n"); + }).pipe(Effect.ensuring(linkedProjectCache.cache(ref)), Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/secrets/set/set.integration.test.ts b/apps/cli/src/legacy/commands/secrets/set/set.integration.test.ts new file mode 100644 index 000000000..1db671faf --- /dev/null +++ b/apps/cli/src/legacy/commands/secrets/set/set.integration.test.ts @@ -0,0 +1,534 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { makeApiClient } from "@supabase/api/effect"; +import { describe, expect, it } from "@effect/vitest"; +import { BunServices } from "@effect/platform-bun"; +import { Effect, Exit, Layer, Option, Redacted } from "effect"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientError from "effect/unstable/http/HttpClientError"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; +import type * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import { afterEach, beforeEach } from "vitest"; + +import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; +import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; +import { legacyProjectRefLayer } from "../../../config/legacy-project-ref.layer.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 { + mockOutput, + mockProcessControl, + mockRuntimeInfo, + mockTty, + processEnvLayer, +} from "../../../../../tests/helpers/mocks.ts"; +import { legacySecretsSet } from "./set.handler.ts"; + +const mockLinkedProjectCacheLayer = Layer.succeed(LegacyLinkedProjectCache, { + cache: () => Effect.void, +}); + +const mockTelemetryStateLayer = Layer.succeed(LegacyTelemetryState, { flush: Effect.void }); + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const VALID_REF = "abcdefghijklmnopqrst"; +const VALID_TOKEN = "sbp_" + "a".repeat(40); + +function jsonResponse(request: HttpClientRequest.HttpClientRequest, status: number, body: unknown) { + return HttpClientResponse.fromWeb( + request, + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }), + ); +} + +function httpClientLayer( + handler: ( + request: HttpClientRequest.HttpClientRequest, + ) => Effect.Effect, +) { + return Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => handler(request)), + ); +} + +interface ApiRequest { + url: string; + method: string; + body: string; +} + +function mockPlatformApi(opts: { status?: number; network?: "fail" } = {}) { + const requests: ApiRequest[] = []; + + const status = opts.status ?? 201; + const handler = ( + request: HttpClientRequest.HttpClientRequest, + ): Effect.Effect => { + return Effect.gen(function* () { + const body = + request.body._tag === "Uint8Array" + ? new TextDecoder().decode(request.body.body) + : request.body._tag === "Raw" + ? String(request.body.body) + : ""; + requests.push({ url: request.url, method: request.method, body }); + if (opts.network === "fail") { + return yield* Effect.fail( + new HttpClientError.HttpClientError({ + reason: new HttpClientError.TransportError({ + request, + description: "ECONNREFUSED", + }), + }), + ); + } + return jsonResponse(request, status, null); + }); + }; + + const layer = Layer.effect( + LegacyPlatformApi, + makeApiClient({ + baseUrl: "https://api.supabase.com", + accessToken: VALID_TOKEN, + userAgent: "SupabaseCLI/0.0.0-dev", + }), + ).pipe(Layer.provide(httpClientLayer(handler))); + + return { layer, requests }; +} + +function mockCliConfig(opts: { workdir: string }) { + return Layer.succeed(LegacyCliConfig, { + profile: "supabase", + apiUrl: "https://api.supabase.com", + accessToken: Option.some(Redacted.make(VALID_TOKEN)), + projectId: Option.some(VALID_REF), + workdir: opts.workdir, + userAgent: "SupabaseCLI/0.0.0-dev", + }); +} + +interface SetupOpts { + format?: "text" | "json" | "stream-json"; + goOutput?: "pretty" | "json" | "yaml" | "toml" | "env"; + status?: number; + network?: "fail"; + env?: Record; +} + +let tempRoot: string; +let currentOut: ReturnType; + +function setup(opts: SetupOpts = {}) { + const out = mockOutput({ format: opts.format ?? "text" }); + currentOut = out; + const api = mockPlatformApi({ status: opts.status, network: opts.network }); + const cliConfig = mockCliConfig({ workdir: tempRoot }); + const processCtl = mockProcessControl(); + const tty = mockTty({ stdinIsTty: false, stdoutIsTty: false }); + const runtimeInfo = mockRuntimeInfo({ cwd: tempRoot }); + const envLayer = processEnvLayer(opts.env ?? {}); + const goOutputValue = opts.goOutput === undefined ? Option.none() : Option.some(opts.goOutput); + + const layer = Layer.mergeAll( + out.layer, + api.layer, + cliConfig, + tty, + processCtl.layer, + runtimeInfo, + envLayer, + legacyProjectRefLayer.pipe( + Layer.provide(api.layer), + Layer.provide(cliConfig), + Layer.provide(tty), + Layer.provide(out.layer), + Layer.provide(BunServices.layer), + ), + BunServices.layer, + Layer.succeed(LegacyOutputFlag, goOutputValue), + mockLinkedProjectCacheLayer, + mockTelemetryStateLayer, + ); + return { layer, out, api, processCtl }; +} + +const stdoutText = () => currentOut.stdoutText; +const stderrText = () => currentOut.stderrText; + +beforeEach(() => { + tempRoot = mkdtempSync(join(tmpdir(), "supabase-secrets-set-int-")); +}); + +afterEach(() => { + rmSync(tempRoot, { recursive: true, force: true }); +}); + +function writeConfig(content: string) { + mkdirSync(join(tempRoot, "supabase"), { recursive: true }); + writeFileSync(join(tempRoot, "supabase", "config.toml"), content); +} + +function parsePostBody(body: string): Array<{ name: string; value: string }> { + return JSON.parse(body) as Array<{ name: string; value: string }>; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("legacy secrets set integration", () => { + it.live("sets a single secret via CLI arg FOO=bar", () => { + const { layer, api } = setup(); + return Effect.gen(function* () { + yield* legacySecretsSet({ + projectRef: Option.none(), + envFile: Option.none(), + secrets: ["FOO=bar"], + }); + expect(api.requests).toHaveLength(1); + expect(parsePostBody(api.requests[0]!.body)).toEqual([{ name: "FOO", value: "bar" }]); + expect(stdoutText()).toBe("Finished supabase secrets set.\n"); + }).pipe(Effect.provide(layer)); + }); + + it.live("sets multiple secrets via CLI args", () => { + const { layer, api } = setup(); + return Effect.gen(function* () { + yield* legacySecretsSet({ + projectRef: Option.none(), + envFile: Option.none(), + secrets: ["FOO=bar", "BAZ=qux"], + }); + const body = parsePostBody(api.requests[0]!.body); + expect(body).toEqual( + expect.arrayContaining([ + { name: "FOO", value: "bar" }, + { name: "BAZ", value: "qux" }, + ]), + ); + }).pipe(Effect.provide(layer)); + }); + + it.live("sets secrets from --env-file with a relative path (joined to CWD)", () => { + writeFileSync(join(tempRoot, "myfile.env"), "FROM_FILE=fromvalue\n"); + const { layer, api } = setup(); + return Effect.gen(function* () { + yield* legacySecretsSet({ + projectRef: Option.none(), + envFile: Option.some("myfile.env"), + secrets: [], + }); + expect(parsePostBody(api.requests[0]!.body)).toEqual([ + { name: "FROM_FILE", value: "fromvalue" }, + ]); + }).pipe(Effect.provide(layer)); + }); + + it.live("sets secrets from --env-file with an absolute path", () => { + const abs = join(tempRoot, "absolute.env"); + writeFileSync(abs, "ABS=value\n"); + const { layer, api } = setup(); + return Effect.gen(function* () { + yield* legacySecretsSet({ + projectRef: Option.none(), + envFile: Option.some(abs), + secrets: [], + }); + expect(parsePostBody(api.requests[0]!.body)).toEqual([{ name: "ABS", value: "value" }]); + }).pipe(Effect.provide(layer)); + }); + + it.live("CLI args override --env-file entries for the same key", () => { + writeFileSync(join(tempRoot, "override.env"), "FOO=from-file\n"); + const { layer, api } = setup(); + return Effect.gen(function* () { + yield* legacySecretsSet({ + projectRef: Option.none(), + envFile: Option.some("override.env"), + secrets: ["FOO=from-arg"], + }); + expect(parsePostBody(api.requests[0]!.body)).toEqual([{ name: "FOO", value: "from-arg" }]); + }).pipe(Effect.provide(layer)); + }); + + it.live( + "merges entries from supabase/config.toml [edge_runtime.secrets] ahead of env-file and CLI args", + () => { + writeConfig( + `[edge_runtime.secrets] +FROM_CONFIG = "config-value" +SHARED = "config-shared" +`, + ); + writeFileSync(join(tempRoot, ".env-file"), "SHARED=envfile-shared\n"); + const { layer, api } = setup(); + return Effect.gen(function* () { + yield* legacySecretsSet({ + projectRef: Option.none(), + envFile: Option.some(".env-file"), + secrets: ["SHARED=cli-shared"], + }); + const body = parsePostBody(api.requests[0]!.body); + expect(body).toEqual( + expect.arrayContaining([ + { name: "FROM_CONFIG", value: "config-value" }, + { name: "SHARED", value: "cli-shared" }, + ]), + ); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("interpolates env(VAR) in config.toml secrets when the env var is defined", () => { + writeConfig( + `[edge_runtime.secrets] +DB_URL = "env(MY_DB_URL)" +`, + ); + const { layer, api } = setup({ env: { MY_DB_URL: "postgres://x" } }); + return Effect.gen(function* () { + yield* legacySecretsSet({ + projectRef: Option.none(), + envFile: Option.none(), + secrets: [], + }); + expect(parsePostBody(api.requests[0]!.body)).toEqual([ + { name: "DB_URL", value: "postgres://x" }, + ]); + }).pipe(Effect.provide(layer)); + }); + + it.live("skips secrets whose env() reference cannot be resolved (Go set.go:48-52 parity)", () => { + writeConfig( + `[edge_runtime.secrets] +RESOLVED = "env(MY_DB_URL)" +UNRESOLVED = "env(NOT_SET_ANYWHERE)" +LITERAL = "plain-value" +`, + ); + // Go's DecryptSecretHookFunc leaves SHA256 empty when the value is still + // an `env(VAR)` literal at decode time; set.go:48-52 then skips those + // entries. In the TS path `resolveProjectSubtree` wraps resolved secret + // strings in `Redacted`, leaving unresolved literals as plain strings — + // the handler filters by `Redacted.isRedacted(...)`, so UNRESOLVED is + // dropped while RESOLVED and LITERAL survive. + const { layer, api } = setup({ env: { MY_DB_URL: "postgres://x" } }); + return Effect.gen(function* () { + yield* legacySecretsSet({ + projectRef: Option.none(), + envFile: Option.none(), + secrets: [], + }); + const body = parsePostBody(api.requests[0]!.body); + expect(body).toEqual( + expect.arrayContaining([ + { name: "RESOLVED", value: "postgres://x" }, + { name: "LITERAL", value: "plain-value" }, + ]), + ); + expect(body.find((entry) => entry.name === "UNRESOLVED")).toBeUndefined(); + }).pipe(Effect.provide(layer)); + }); + + it.live( + "does not crash when config.toml has env(NUMERIC_PORT) on an unrelated numeric field (CLI-1489 regression guard)", + () => { + writeConfig( + `[analytics] +port = "env(SUPABASE_ANALYTICS_PORT)" + +[edge_runtime.secrets] +FOO = "literal-foo" +`, + ); + // The CLI-1489 fix in `@supabase/config` interpolates env() refs on + // numeric fields before schema decode. With SUPABASE_ANALYTICS_PORT + // resolvable from the test env, the strict decoder no longer crashes. + const { layer, api } = setup({ env: { SUPABASE_ANALYTICS_PORT: "54327" } }); + return Effect.gen(function* () { + yield* legacySecretsSet({ + projectRef: Option.none(), + envFile: Option.none(), + secrets: [], + }); + expect(parsePostBody(api.requests[0]!.body)).toEqual([ + { name: "FOO", value: "literal-foo" }, + ]); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("skips SUPABASE_-prefixed entries with a stderr warning", () => { + const { layer, api } = setup(); + return Effect.gen(function* () { + yield* legacySecretsSet({ + projectRef: Option.none(), + envFile: Option.none(), + secrets: ["FOO=bar", "SUPABASE_BAD=x"], + }); + const body = parsePostBody(api.requests[0]!.body); + expect(body).toEqual([{ name: "FOO", value: "bar" }]); + expect(stderrText()).toContain( + "Env name cannot start with SUPABASE_, skipping: SUPABASE_BAD", + ); + }).pipe(Effect.provide(layer)); + }); + + it.live( + "fails with LegacySecretsNoArgumentsError when args and env-file produce zero non-SUPABASE_ entries", + () => { + const { layer, api } = setup(); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacySecretsSet({ + projectRef: Option.none(), + envFile: Option.none(), + secrets: ["SUPABASE_ONLY=x"], + }), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacySecretsNoArgumentsError"); + } + expect(api.requests).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("fails with LegacyInvalidSecretPairError when an arg has no `=`", () => { + const { layer, api } = setup(); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacySecretsSet({ + projectRef: Option.none(), + envFile: Option.none(), + secrets: ["NOTAPAIR"], + }), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const errJson = JSON.stringify(exit.cause); + expect(errJson).toContain("LegacyInvalidSecretPairError"); + expect(errJson).toContain("Invalid secret pair: NOTAPAIR"); + } + expect(api.requests).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with LegacySecretsEnvFileOpenError when env-file does not exist", () => { + const { layer } = setup(); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacySecretsSet({ + projectRef: Option.none(), + envFile: Option.some("does-not-exist.env"), + secrets: [], + }), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const errJson = JSON.stringify(exit.cause); + expect(errJson).toContain("LegacySecretsEnvFileOpenError"); + expect(errJson).toContain("failed to open env file"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with LegacySecretsConfigParseError when config.toml is malformed", () => { + writeConfig("this is not valid = = toml [[[\n"); + const { layer } = setup(); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacySecretsSet({ + projectRef: Option.none(), + envFile: Option.none(), + secrets: ["FOO=bar"], + }), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacySecretsConfigParseError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with LegacySecretsSetNetworkError on transport failure", () => { + const { layer } = setup({ network: "fail" }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacySecretsSet({ + projectRef: Option.none(), + envFile: Option.none(), + secrets: ["FOO=bar"], + }), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const errJson = JSON.stringify(exit.cause); + expect(errJson).toContain("LegacySecretsSetNetworkError"); + expect(errJson).toContain("failed to set secrets"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with LegacySecretsSetUnexpectedStatusError on HTTP 500", () => { + const { layer } = setup({ status: 500 }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacySecretsSet({ + projectRef: Option.none(), + envFile: Option.none(), + secrets: ["FOO=bar"], + }), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const errJson = JSON.stringify(exit.cause); + expect(errJson).toContain("LegacySecretsSetUnexpectedStatusError"); + expect(errJson).toContain("Unexpected error setting project secrets"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a success event with { project_ref, count } for --output-format=json", () => { + const { layer, out } = setup({ format: "json" }); + return Effect.gen(function* () { + yield* legacySecretsSet({ + projectRef: Option.none(), + envFile: Option.none(), + secrets: ["FOO=bar", "BAZ=qux"], + }); + const success = out.messages.find((m) => m.type === "success"); + expect(success).toBeDefined(); + expect(success?.data).toEqual({ project_ref: VALID_REF, count: 2 }); + }).pipe(Effect.provide(layer)); + }); + + it.live( + "text mode prints `Finished supabase secrets set.\\n` regardless of --output value", + () => { + const { layer } = setup({ goOutput: "json" }); + return Effect.gen(function* () { + yield* legacySecretsSet({ + projectRef: Option.none(), + envFile: Option.none(), + secrets: ["FOO=bar"], + }); + // Go ignores `--output` for `set` (set.go:42) — text-mode message lands regardless. + expect(stdoutText()).toBe("Finished supabase secrets set.\n"); + }).pipe(Effect.provide(layer)); + }, + ); +}); diff --git a/apps/cli/src/legacy/commands/secrets/unset/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/secrets/unset/SIDE_EFFECTS.md index 446d8a4b4..89c820520 100644 --- a/apps/cli/src/legacy/commands/secrets/unset/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/secrets/unset/SIDE_EFFECTS.md @@ -2,69 +2,74 @@ ## Files Read -| Path | Format | When | -| -------------------------- | ------------------------- | ---------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| Path | Format | When | +| ----------------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------- | +| `/proc/sys/kernel/osrelease` (Linux) | plain text | once on layer init — disables keyring on WSL (`WSL` / `Microsoft` substring match) | +| keyring `"Supabase CLI"` / `` | OS keychain | when `SUPABASE_ACCESS_TOKEN` unset and keyring available; account = `LegacyCliConfig.profile` | +| keyring `"Supabase CLI"` / `access-token` | OS keychain | legacy-key fallback when the profile-keyed lookup misses | +| `~/.supabase/access-token` | plain text (token string) | last-resort fallback after env + keyring miss | +| `/supabase/.temp/project-ref` | plain text | when `--project-ref` and `SUPABASE_PROJECT_ID` are both unset | ## Files Written -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | +| Path | Format | When | +| ------------------------------------------------ | ------ | ------------------------------------------------------------------------ | +| `~/.supabase//linked-project.json` | JSON | always (in `Effect.ensuring`) after `--project-ref` resolves — Go parity | +| `~/.supabase/telemetry.json` | JSON | always (in `Effect.ensuring`) at end of command — Go parity | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| -------- | ---------------------------- | ------------ | --------------- | ---------------------- | -| `GET` | `/v1/projects/{ref}/secrets` | Bearer token | none | `[{name, value}]` | -| `DELETE` | `/v1/projects/{ref}/secrets` | Bearer token | `["NAME", ...]` | none (200 expected) | - -Note: `GET` is only called when no secret names are passed as arguments (to fetch all non-SUPABASE\_ secrets). +| Method | Path | Auth | Request body | Response (used fields) | +| -------- | ---------------------------- | ------------ | --------------- | ------------------------------------------------------------- | +| `GET` | `/v1/projects/{ref}/secrets` | Bearer token | none | `[{name}]` — empty-args path only, filters `SUPABASE_` prefix | +| `DELETE` | `/v1/projects/{ref}/secrets` | Bearer token | `["NAME", ...]` | none (200 expected) | +| `GET` | `/v1/projects` | Bearer token | none | TTY-prompt fallback only | ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| Variable | Purpose | Required? | +| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | selects API base URL: `supabase` → `api.supabase.com`, `supabase-staging` → `api.supabase.green`, `supabase-local` → `http://localhost:8080`. May alternatively be a filesystem path to a YAML profile with at least `api_url:` and optional `name:` (Go parity — used by the cli-e2e test harness). | no (defaults to `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (also reads `/supabase/.temp/project-ref` then prompts on TTY) | +| `SUPABASE_WORKDIR` | base directory for the `.temp/project-ref` lookup | no (walks up from CWD looking for `supabase/config.toml`) | +| ~~`SUPABASE_API_URL`~~ | **not honored** — Go parity. Use `SUPABASE_PROFILE` to override the API base URL. | — | ## Exit Codes -| Code | Condition | -| ---- | ------------------------------------------------------------------ | -| `0` | success — secrets unset from the linked project | -| `0` | no secrets to unset (all existing secrets have `SUPABASE_` prefix) | -| `1` | user declined the confirmation prompt | -| `1` | authentication error — no valid token found | -| `1` | API error — non-2xx response from secrets endpoint | -| `1` | network / connection failure | +| Code | Condition | +| ---- | ------------------------------------------------------------------------------------------ | +| `0` | success — secrets unset from the linked project | +| `0` | empty-args path resolved to zero non-`SUPABASE_` secrets (stderr no-op, no DELETE call) | +| `1` | `LegacyPlatformAuthRequiredError` — no token in env/keyring/file | +| `1` | `LegacyInvalidAccessTokenError` — token violates `^sbp_(oauth_)?[a-f0-9]{40}$` | +| `1` | `LegacyProjectNotLinkedError` — `--project-ref` unset, env/file empty, and stdin not a TTY | +| `1` | `LegacyInvalidProjectRefError` — resolved ref violates `^[a-z]{20}$` | +| `1` | `LegacySecretsListUnexpectedStatusError` — non-2xx response from GET (empty-args path) | +| `1` | `LegacySecretsListNetworkError` — GET transport failure (empty-args path) | +| `1` | `LegacySecretsUnsetCancelledError` — user declined the confirmation prompt | +| `1` | `LegacySecretsUnsetUnexpectedStatusError` — non-2xx response from DELETE | +| `1` | `LegacySecretsUnsetNetworkError` — DELETE transport failure | ## Output -### `--output-format text` (Go CLI compatible) - -Prompts for confirmation before unsetting, then prints a confirmation message to stdout. - -``` -Finished supabase secrets unset. -``` +### `--output pretty` (Go default) / `--output-format text` -If no secrets to unset, prints to stderr: +Stdout: `Finished supabase secrets unset.\n`. Stderr: confirmation prompt label (when TTY without `--yes`) or `[Y/n] y` echo (with `--yes`) or the no-op message when empty-args resolves to no secrets. -``` -You have not set any function secrets, nothing to do. -``` +Go's `--output {json,yaml,toml,env}` flags all collapse to the same text-mode `Finished` message. ### `--output-format json` -Not applicable for this command (write operation). +Single JSON object emitted via `Output.success` with `{project_ref, count}` as the `data` field. ### `--output-format stream-json` -Not applicable for this command (write operation). +One `result` NDJSON event on success containing `{project_ref, count}`. ## Notes -- When called without arguments, unsets all secrets that do not have a `SUPABASE_` prefix. -- Requires interactive confirmation before deleting. -- Requires `--project-ref` or a linked project (`.supabase/config.json`). +- When called without arguments, fetches the full secret list and unsets all entries that do not have a `SUPABASE_` prefix. +- `--yes` bypasses the confirmation prompt with a stderr label echo. +- Non-TTY without `--yes` auto-confirms silently — matches Go's `PromptYesNo` (`apps/cli-go/internal/utils/console.go`), which defaults to true after a 100ms non-TTY read timeout. +- Sends `User-Agent: SupabaseCLI/` and Bearer auth. No `X-Supabase-Command` headers — Go parity. 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 71163522a..e68e304b2 100644 --- a/apps/cli/src/legacy/commands/secrets/unset/unset.command.ts +++ b/apps/cli/src/legacy/commands/secrets/unset/unset.command.ts @@ -1,4 +1,9 @@ +import type * as CliCommand from "effect/unstable/cli/Command"; import { Argument, Command, Flag } from "effect/unstable/cli"; + +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { withCommandInstrumentation } from "../../../../shared/telemetry/command-instrumentation.ts"; +import { legacyManagementApiRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts"; import { legacySecretsUnset } from "./unset.handler.ts"; const config = { @@ -10,7 +15,9 @@ const config = { Argument.withDescription("Secret names to unset."), Argument.variadic(), ), -}; +} as const; + +export type LegacySecretsUnsetFlags = CliCommand.Command.Config.Infer; export const legacySecretsUnsetCommand = Command.make("unset", config).pipe( Command.withDescription("Unset a secret(s) from the linked Supabase project."), @@ -26,9 +33,7 @@ export const legacySecretsUnsetCommand = Command.make("unset", config).pipe( }, ]), Command.withHandler((flags) => - legacySecretsUnset({ - projectRef: flags.projectRef, - names: flags.names.map(String), - }), + legacySecretsUnset(flags).pipe(withCommandInstrumentation(), withJsonErrorHandling), ), + Command.provide(legacyManagementApiRuntimeLayer(["secrets", "unset"])), ); 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 fb5e641b3..8e4af0197 100644 --- a/apps/cli/src/legacy/commands/secrets/unset/unset.handler.ts +++ b/apps/cli/src/legacy/commands/secrets/unset/unset.handler.ts @@ -1,17 +1,111 @@ -import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import type { V1ListAllSecretsOutput } from "@supabase/api/effect"; +import { Effect } from "effect"; -interface LegacySecretsUnsetFlags { - readonly projectRef: Option.Option; - readonly names: ReadonlyArray; -} +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 { LegacyYesFlag } from "../../../../shared/legacy/global-flags.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, + LegacySecretsListUnexpectedStatusError, + LegacySecretsUnsetCancelledError, + LegacySecretsUnsetNetworkError, + LegacySecretsUnsetUnexpectedStatusError, +} from "../secrets.errors.ts"; +import type { LegacySecretsUnsetFlags } from "./unset.command.ts"; + +type Secrets = typeof V1ListAllSecretsOutput.Type; + +// The empty-args path lists secrets first, so it shares the LIST error pair +// with the `list` handler. Templates match Go's `list.go:46-50` phrasing. +const mapListErrorForUnset = mapLegacyHttpError({ + networkError: LegacySecretsListNetworkError, + statusError: LegacySecretsListUnexpectedStatusError, + networkMessage: (cause) => `failed to list secrets: ${cause}`, + statusMessage: (status, body) => `unexpected list secrets status ${status}: ${body}`, +}); + +const mapUnsetError = mapLegacyHttpError({ + networkError: LegacySecretsUnsetNetworkError, + statusError: LegacySecretsUnsetUnexpectedStatusError, + networkMessage: (cause) => `failed to delete secrets: ${cause}`, + statusMessage: (_status, body) => `Unexpected error unsetting project secrets: ${body}`, +}); export const legacySecretsUnset = Effect.fn("legacy.secrets.unset")(function* ( flags: LegacySecretsUnsetFlags, ) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["secrets", "unset"]; - if (Option.isSome(flags.projectRef)) args.push("--project-ref", flags.projectRef.value); - args.push(...flags.names); - yield* proxy.exec(args); + const output = yield* Output; + const api = yield* LegacyPlatformApi; + const resolver = yield* LegacyProjectRefResolver; + const linkedProjectCache = yield* LegacyLinkedProjectCache; + const telemetryState = yield* LegacyTelemetryState; + const yes = yield* LegacyYesFlag; + const tty = yield* Tty; + + const ref = yield* resolver.resolve(flags.projectRef); + + yield* Effect.gen(function* () { + let names: ReadonlyArray = flags.names; + + if (names.length === 0) { + // Go fetches the full list and filters out SUPABASE_-prefixed entries + // (`unset.go:21-26`). Reuse the LIST error pair here. + const all: Secrets = yield* api.v1 + .listAllSecrets({ ref }) + .pipe(Effect.catch(mapListErrorForUnset)); + names = all.filter((s) => !s.name.startsWith("SUPABASE_")).map((s) => s.name); + } + + if (names.length === 0) { + yield* output.raw("You have not set any function secrets, nothing to do.\n", "stderr"); + return; + } + + 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)); + } + + if (!confirmed) { + return yield* Effect.fail( + new LegacySecretsUnsetCancelledError({ message: "context canceled" }), + ); + } + + const unsetting = + output.format === "text" ? yield* output.task("Unsetting secrets...") : undefined; + yield* api.v1.bulkDeleteSecrets({ ref, body: names }).pipe( + Effect.tapError(() => unsetting?.fail() ?? Effect.void), + Effect.catch(mapUnsetError), + ); + yield* unsetting?.clear() ?? Effect.void; + + if (output.format === "json" || output.format === "stream-json") { + yield* output.success("Finished supabase secrets unset.", { + project_ref: ref, + count: names.length, + }); + return; + } + + yield* output.raw("Finished supabase secrets unset.\n"); + }).pipe(Effect.ensuring(linkedProjectCache.cache(ref)), Effect.ensuring(telemetryState.flush)); }); 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 new file mode 100644 index 000000000..0e0ffd040 --- /dev/null +++ b/apps/cli/src/legacy/commands/secrets/unset/unset.integration.test.ts @@ -0,0 +1,408 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { type V1ListAllSecretsOutput, makeApiClient } from "@supabase/api/effect"; +import { describe, expect, it } from "@effect/vitest"; +import { BunServices } from "@effect/platform-bun"; +import { Effect, Exit, Layer, Option, Redacted } from "effect"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientError from "effect/unstable/http/HttpClientError"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; +import type * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import { afterEach, beforeEach } from "vitest"; + +import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; +import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; +import { legacyProjectRefLayer } from "../../../config/legacy-project-ref.layer.ts"; +import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { LegacyOutputFlag, LegacyYesFlag } from "../../../../shared/legacy/global-flags.ts"; +import { mockOutput, mockProcessControl, mockTty } from "../../../../../tests/helpers/mocks.ts"; +import { legacySecretsUnset } from "./unset.handler.ts"; + +const mockLinkedProjectCacheLayer = Layer.succeed(LegacyLinkedProjectCache, { + cache: () => Effect.void, +}); + +const mockTelemetryStateLayer = Layer.succeed(LegacyTelemetryState, { flush: Effect.void }); + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const VALID_REF = "abcdefghijklmnopqrst"; +const VALID_TOKEN = "sbp_" + "a".repeat(40); + +type SecretsList = typeof V1ListAllSecretsOutput.Type; + +function jsonResponse(request: HttpClientRequest.HttpClientRequest, status: number, body: unknown) { + return HttpClientResponse.fromWeb( + request, + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }), + ); +} + +function httpClientLayer( + handler: ( + request: HttpClientRequest.HttpClientRequest, + ) => Effect.Effect, +) { + return Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => handler(request)), + ); +} + +interface ApiRequest { + url: string; + method: string; + body: string; +} + +function mockPlatformApi( + opts: { + list?: SecretsList; + listStatus?: number; + listNetwork?: "fail"; + deleteStatus?: number; + deleteNetwork?: "fail"; + } = {}, +) { + const requests: ApiRequest[] = []; + + const handler = ( + request: HttpClientRequest.HttpClientRequest, + ): Effect.Effect => { + return Effect.gen(function* () { + const body = + request.body._tag === "Uint8Array" + ? new TextDecoder().decode(request.body.body) + : request.body._tag === "Raw" + ? String(request.body.body) + : ""; + requests.push({ url: request.url, method: request.method, body }); + + if (request.method === "GET") { + if (opts.listNetwork === "fail") { + return yield* Effect.fail( + new HttpClientError.HttpClientError({ + reason: new HttpClientError.TransportError({ + request, + description: "ECONNREFUSED", + }), + }), + ); + } + return jsonResponse(request, opts.listStatus ?? 200, opts.list ?? []); + } + + // DELETE + if (opts.deleteNetwork === "fail") { + return yield* Effect.fail( + new HttpClientError.HttpClientError({ + reason: new HttpClientError.TransportError({ + request, + description: "ECONNREFUSED", + }), + }), + ); + } + return jsonResponse(request, opts.deleteStatus ?? 200, null); + }); + }; + + const layer = Layer.effect( + LegacyPlatformApi, + makeApiClient({ + baseUrl: "https://api.supabase.com", + accessToken: VALID_TOKEN, + userAgent: "SupabaseCLI/0.0.0-dev", + }), + ).pipe(Layer.provide(httpClientLayer(handler))); + + return { layer, requests }; +} + +function mockCliConfig(opts: { workdir: string }) { + return Layer.succeed(LegacyCliConfig, { + profile: "supabase", + apiUrl: "https://api.supabase.com", + accessToken: Option.some(Redacted.make(VALID_TOKEN)), + projectId: Option.some(VALID_REF), + workdir: opts.workdir, + userAgent: "SupabaseCLI/0.0.0-dev", + }); +} + +interface SetupOpts { + format?: "text" | "json" | "stream-json"; + goOutput?: "pretty" | "json" | "yaml" | "toml" | "env"; + yes?: boolean; + stdinIsTty?: boolean; + confirm?: boolean; + list?: SecretsList; + listStatus?: number; + listNetwork?: "fail"; + deleteStatus?: number; + deleteNetwork?: "fail"; +} + +let tempRoot: string; +let currentOut: ReturnType; + +function setup(opts: SetupOpts = {}) { + const out = mockOutput({ + format: opts.format ?? "text", + confirmLogout: opts.confirm, + }); + currentOut = out; + const api = mockPlatformApi({ + list: opts.list, + listStatus: opts.listStatus, + listNetwork: opts.listNetwork, + deleteStatus: opts.deleteStatus, + deleteNetwork: opts.deleteNetwork, + }); + const cliConfig = mockCliConfig({ workdir: tempRoot }); + const processCtl = mockProcessControl(); + const tty = mockTty({ stdinIsTty: opts.stdinIsTty ?? false, stdoutIsTty: false }); + const goOutputValue = opts.goOutput === undefined ? Option.none() : Option.some(opts.goOutput); + + const layer = Layer.mergeAll( + out.layer, + api.layer, + cliConfig, + tty, + processCtl.layer, + legacyProjectRefLayer.pipe( + Layer.provide(api.layer), + Layer.provide(cliConfig), + Layer.provide(tty), + Layer.provide(out.layer), + Layer.provide(BunServices.layer), + ), + BunServices.layer, + Layer.succeed(LegacyOutputFlag, goOutputValue), + Layer.succeed(LegacyYesFlag, opts.yes ?? false), + mockLinkedProjectCacheLayer, + mockTelemetryStateLayer, + ); + return { layer, out, api }; +} + +const stderrText = () => currentOut.stderrText; +const stdoutText = () => currentOut.stdoutText; + +beforeEach(() => { + tempRoot = mkdtempSync(join(tmpdir(), "supabase-secrets-unset-int-")); +}); + +afterEach(() => { + rmSync(tempRoot, { recursive: true, force: true }); +}); + +function parseDeleteBody(body: string): string[] { + return JSON.parse(body) as string[]; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("legacy secrets unset integration", () => { + it.live("unsets a single secret given explicitly (with --yes)", () => { + const { layer, api } = setup({ yes: true }); + return Effect.gen(function* () { + yield* legacySecretsUnset({ projectRef: Option.none(), names: ["FOO"] }); + // No GET call: names came from args. + expect(api.requests.filter((r) => r.method === "GET")).toHaveLength(0); + const deletes = api.requests.filter((r) => r.method === "DELETE"); + expect(deletes).toHaveLength(1); + expect(parseDeleteBody(deletes[0]!.body)).toEqual(["FOO"]); + expect(stdoutText()).toBe("Finished supabase secrets unset.\n"); + }).pipe(Effect.provide(layer)); + }); + + it.live("unsets multiple secrets given explicitly", () => { + const { layer, api } = setup({ yes: true }); + return Effect.gen(function* () { + yield* legacySecretsUnset({ + projectRef: Option.none(), + names: ["FOO", "BAR"], + }); + const deletes = api.requests.filter((r) => r.method === "DELETE"); + expect(parseDeleteBody(deletes[0]!.body)).toEqual(["FOO", "BAR"]); + }).pipe(Effect.provide(layer)); + }); + + it.live("empty-args path lists secrets and DELETEs the non-SUPABASE_ subset", () => { + const { layer, api } = setup({ + yes: true, + list: [ + { name: "FOO", value: "d1" }, + { name: "SUPABASE_AUTH_TOKEN", value: "d2" }, + { name: "BAR", value: "d3" }, + ], + }); + return Effect.gen(function* () { + yield* legacySecretsUnset({ projectRef: Option.none(), names: [] }); + const gets = api.requests.filter((r) => r.method === "GET"); + const deletes = api.requests.filter((r) => r.method === "DELETE"); + expect(gets).toHaveLength(1); + expect(parseDeleteBody(deletes[0]!.body)).toEqual(["FOO", "BAR"]); + }).pipe(Effect.provide(layer)); + }); + + it.live("empty-args path with all-SUPABASE_ secrets writes stderr no-op and exits 0", () => { + const { layer, api } = setup({ + yes: true, + list: [{ name: "SUPABASE_ONLY", value: "d" }], + }); + return Effect.gen(function* () { + yield* legacySecretsUnset({ projectRef: Option.none(), names: [] }); + expect(stderrText()).toContain("You have not set any function secrets, nothing to do."); + expect(api.requests.filter((r) => r.method === "DELETE")).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }); + + it.live("empty-args path with empty server list writes the stderr no-op and exits 0", () => { + const { layer, api } = setup({ yes: true, list: [] }); + return Effect.gen(function* () { + yield* legacySecretsUnset({ projectRef: Option.none(), names: [] }); + expect(stderrText()).toContain("You have not set any function secrets, nothing to do."); + expect(api.requests.filter((r) => r.method === "DELETE")).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }); + + it.live("--yes bypasses the prompt and echoes [Y/n] y to stderr", () => { + const { layer } = setup({ yes: true }); + return Effect.gen(function* () { + yield* legacySecretsUnset({ projectRef: Option.none(), names: ["FOO"] }); + const stderr = stderrText(); + expect(stderr).toContain("Do you want to unset these function secrets?"); + expect(stderr).toContain(" • FOO"); + expect(stderr).toContain("[Y/n] y"); + }).pipe(Effect.provide(layer)); + }); + + it.live("non-TTY without --yes auto-confirms silently (Go parity)", () => { + const { layer, 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(stderrText()).not.toContain("[Y/n]"); + expect(api.requests.filter((r) => r.method === "DELETE")).toHaveLength(1); + }).pipe(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* () { + yield* legacySecretsUnset({ projectRef: Option.none(), names: ["FOO"] }); + expect(api.requests.filter((r) => r.method === "DELETE")).toHaveLength(1); + }).pipe(Effect.provide(layer)); + }); + + it.live("TTY without --yes fails with LegacySecretsUnsetCancelledError on decline", () => { + const { layer, api } = setup({ yes: false, stdinIsTty: true, confirm: false }); + 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"); + } + expect(api.requests.filter((r) => r.method === "DELETE")).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with LegacySecretsListNetworkError on GET failure (empty-args path)", () => { + const { layer } = setup({ yes: true, listNetwork: "fail" }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacySecretsUnset({ projectRef: Option.none(), names: [] })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacySecretsListNetworkError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with LegacySecretsListUnexpectedStatusError on GET 503 (empty-args path)", () => { + const { layer } = setup({ yes: true, listStatus: 503 }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacySecretsUnset({ projectRef: Option.none(), names: [] })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacySecretsListUnexpectedStatusError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with LegacySecretsUnsetNetworkError on DELETE transport failure", () => { + const { layer } = setup({ yes: true, deleteNetwork: "fail" }); + 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)) { + const errJson = JSON.stringify(exit.cause); + expect(errJson).toContain("LegacySecretsUnsetNetworkError"); + expect(errJson).toContain("failed to delete secrets"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with LegacySecretsUnsetUnexpectedStatusError on DELETE 500", () => { + const { layer } = setup({ yes: true, deleteStatus: 500 }); + 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)) { + const errJson = JSON.stringify(exit.cause); + expect(errJson).toContain("LegacySecretsUnsetUnexpectedStatusError"); + expect(errJson).toContain("Unexpected error unsetting project secrets"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a success event with { project_ref, count } for --output-format=json", () => { + const { layer, out } = setup({ yes: true, format: "json" }); + return Effect.gen(function* () { + yield* legacySecretsUnset({ + projectRef: Option.none(), + names: ["FOO", "BAR"], + }); + const success = out.messages.find((m) => m.type === "success"); + expect(success).toBeDefined(); + expect(success?.data).toEqual({ project_ref: VALID_REF, count: 2 }); + }).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* () { + yield* legacySecretsUnset({ projectRef: Option.none(), names: ["FOO"] }); + const success = out.messages.find((m) => m.type === "success"); + expect(success).toBeDefined(); + }).pipe(Effect.provide(layer)); + }); + + it.live( + "text mode prints `Finished supabase secrets unset.\\n` regardless of --output value", + () => { + const { layer } = setup({ yes: true, goOutput: "json" }); + return Effect.gen(function* () { + yield* legacySecretsUnset({ projectRef: Option.none(), names: ["FOO"] }); + expect(stdoutText()).toBe("Finished supabase secrets unset.\n"); + }).pipe(Effect.provide(layer)); + }, + ); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 66a956fd9..59ad051b7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -128,6 +128,9 @@ importers: '@vitest/coverage-istanbul': specifier: 'catalog:' version: 4.1.6(vitest@4.1.6) + dotenv: + specifier: ^17.4.2 + version: 17.4.2 effect: specifier: 'catalog:' version: 4.0.0-beta.67 @@ -3605,6 +3608,10 @@ packages: resolution: {integrity: sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==} engines: {node: '>=12'} + dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} + engines: {node: '>=12'} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -9143,6 +9150,8 @@ snapshots: dotenv@16.4.7: {} + dotenv@17.4.2: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 From 69280090fe8e90b1a876df52bb661b350610bdb2 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Tue, 26 May 2026 15:01:23 +0100 Subject: [PATCH 09/13] feat(cli): align legacy telemetry payload with Go CLI (#5359) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Audit (CLI-1531) found the native legacy ports were emitting a `cli_command_executed` payload that diverged from `apps/cli-go/` in three ways: - `flags_used` (string array) + `flag_values` (allowlisted) instead of Go's single `flags` map with `""` for non-safe values. - `ai_tool: string` instead of `is_agent: boolean`. - Missing `env_signals` map. Since the legacy/ shell sends events to the same PostHog pipeline as the Go binary, this drift was breaking dashboards and funnels silently. ## What changed - **`shared/telemetry/event-catalog.ts`** — single literal-string source mirroring `apps/cli-go/internal/telemetry/events.go` (5 events, 18 props, 35 env-presence + 6 env-value keys, 80-char cap). - **`shared/telemetry/posthog-config.ts`** — extracted PostHog host/key constants out of `next/config/cli-config.layer.ts` so both shells' analytics layers can share them without a cross-shell import. - **`legacy/telemetry/legacy-analytics.layer.ts`** — implements `Analytics` with Go-shape base properties (`is_agent`, `env_signals`, no `ai_tool`). Loads linked-project cache from `SUPABASE_WORKDIR ?? process.cwd()` so it doesn't depend on per-command flag services. - **`legacy/telemetry/legacy-command-instrumentation.ts`** — `withLegacyCommandInstrumentation({ flags, safeFlags })` emits a single `flags` map. Booleans + explicit `safeFlags` pass through verbatim (mirrors Go's `markFlagTelemetrySafe` annotation); everything else becomes `""`. Flag-name sort matches Go's `sort.Slice`. - **`shared/cli/run.ts`** no longer hard-wires the analytics layer; `legacy/cli/main.ts` and `next/cli/main.ts` each provide their own. - **7 native command files** (backups/{list,restore}, secrets/{list,set,unset}, ssl-enforcement/{get,update}) switched to `withLegacyCommandInstrumentation({ flags })`. - The `next/` shell intentionally keeps the richer `ai_tool` / `flags_used` / `flag_values` payload — parity is scoped to `legacy/` only. ## Scope decision: custom events deferred Every command that emits a custom event in Go — `login` (`cli_login_completed` + Alias/Identify stitching), `link` (`cli_project_linked`), `start` (`cli_stack_started`), `sso/*` and `branches/*` (`cli_upgrade_suggested`) — is still a Phase 0 `LegacyGoProxy` in the TS shell. The Go subprocess fires those events on its own; a TS wrapper would double-count. The native ports of those commands will need to reproduce the Go calls when they land — the canonical table is now documented in `AGENTS.md § Legacy Port: Telemetry Parity` and `apps/cli/src/legacy/SIDE_EFFECTS_TEMPLATE.md`. ## Reviewer notes - Native handlers wrap with `withLegacyCommandInstrumentation`; proxy handlers stay unwrapped. - `event-catalog.ts` is exempted from `knip:check` so future-use constants (e.g. `EventLoginCompleted`) don't get pruned before they're consumed. - The legacy `AnalyticsContext` shape now carries both `flags` (legacy) and `flags_used`/`flag_values` (next); each shell's analytics layer reads only its own fields. - Bundled `dist/supabase-legacy` was smoke-tested against both a native command (`backups list --help`) and a proxy command (`init --help`) per the [[feedback-layer-provide-semantics]] memory. Fixes CLI-1531 --- apps/cli/AGENTS.md | 26 ++ apps/cli/package.json | 3 +- apps/cli/src/legacy/SIDE_EFFECTS_TEMPLATE.md | 13 + apps/cli/src/legacy/cli/main.ts | 3 +- .../commands/backups/list/SIDE_EFFECTS.md | 8 + .../commands/backups/list/list.command.ts | 7 +- .../commands/backups/restore/SIDE_EFFECTS.md | 8 + .../backups/restore/restore.command.ts | 7 +- .../commands/secrets/list/SIDE_EFFECTS.md | 8 + .../commands/secrets/list/list.command.ts | 7 +- .../commands/secrets/set/SIDE_EFFECTS.md | 8 + .../commands/secrets/set/set.command.ts | 7 +- .../commands/secrets/unset/SIDE_EFFECTS.md | 8 + .../commands/secrets/unset/unset.command.ts | 7 +- .../ssl-enforcement/get/SIDE_EFFECTS.md | 8 + .../ssl-enforcement/get/get.command.ts | 7 +- .../ssl-enforcement/update/SIDE_EFFECTS.md | 8 + .../ssl-enforcement/update/update.command.ts | 7 +- .../telemetry/legacy-analytics.layer.ts | 246 +++++++++++++++++ .../legacy-analytics.layer.unit.test.ts | 89 ++++++ .../legacy-command-instrumentation.ts | 161 +++++++++++ ...egacy-command-instrumentation.unit.test.ts | 261 ++++++++++++++++++ apps/cli/src/next/cli/main.ts | 3 +- apps/cli/src/shared/cli/run.ts | 20 +- .../src/shared/telemetry/analytics-context.ts | 3 + .../cli/src/shared/telemetry/event-catalog.ts | 91 ++++++ .../src/shared/telemetry/posthog-config.ts | 14 + 27 files changed, 1016 insertions(+), 22 deletions(-) create mode 100644 apps/cli/src/legacy/telemetry/legacy-analytics.layer.ts create mode 100644 apps/cli/src/legacy/telemetry/legacy-analytics.layer.unit.test.ts create mode 100644 apps/cli/src/legacy/telemetry/legacy-command-instrumentation.ts create mode 100644 apps/cli/src/legacy/telemetry/legacy-command-instrumentation.unit.test.ts create mode 100644 apps/cli/src/shared/telemetry/event-catalog.ts create mode 100644 apps/cli/src/shared/telemetry/posthog-config.ts diff --git a/apps/cli/AGENTS.md b/apps/cli/AGENTS.md index cc5658b0a..dc2d2382b 100644 --- a/apps/cli/AGENTS.md +++ b/apps/cli/AGENTS.md @@ -269,6 +269,32 @@ When porting a Management-API-style command, verify each item before marking the 6. **Both `--output` (Go) and `--output-format` (TS) must be honored** — Go's `--output` (`pretty|json|yaml|toml|env`) takes priority when set. Pattern in `backups/list/list.handler.ts:85-113`: branch on `goOutputFlag` first, then fall through to TS `--output-format` text/json/stream-json. +7. **PostHog telemetry payload matches Go 1:1** — see the next section. + +--- + +## Legacy Port: Telemetry Parity + +The legacy shell sends the same PostHog events to the same product analytics pipeline as the Go CLI. Drift is silent (no test will catch it) and breaks dashboards. The rules: + +- **The canonical catalog is `shared/telemetry/event-catalog.ts`** — a 1:1 mirror of `apps/cli-go/internal/telemetry/events.go`. Reference its exported constants (`EventCommandExecuted`, `PropFlags`, `EnvSignalPresenceKeys`, …) instead of writing bare strings. When the Go catalog changes, update the TS catalog in the same PR. +- **Native legacy commands wrap with `withLegacyCommandInstrumentation`** (from `legacy/telemetry/legacy-command-instrumentation.ts`) — _not_ the shared `withCommandInstrumentation`. The legacy variant emits Go-shape properties: a single `flags` map (vs `flags_used`/`flag_values`), `is_agent: boolean` (vs `ai_tool: string`), and `env_signals`. +- **Pass `flags` to the wrapper** so boolean flag values can be detected and logged verbatim: `handler(flags).pipe(withLegacyCommandInstrumentation({ flags }), ...)`. Sensitive values become the literal string `""` to match Go. +- **Use `safeFlags: ["flag-name"]`** to whitelist flags that Go marks with `markFlagTelemetrySafe` (grep `apps/cli-go/cmd/*.go`). Today these are `--project-ref` (sso, branches, link, functions, projects/api-keys), `--project-id` (gen/types), `--org-id` (projects/create), and `--version` (migration/squash). +- **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)` + `analytics.identify(gotrueId)` 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}/` | + + Reference pattern for login: `next/commands/login/login.handler.ts:38-62`. + +- **Tracing layer is local-only observability**, not PostHog. Span names (`legacy..`) and the NDJSON exporter never leave the user's machine. No parity implication. + --- ## Legacy Port: File Location Compatibility diff --git a/apps/cli/package.json b/apps/cli/package.json index d0f29079e..835bfb382 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -101,7 +101,8 @@ ], "ignore": [ "scripts/*.ts", - "tests/**/*.ts" + "tests/**/*.ts", + "src/shared/telemetry/event-catalog.ts" ], "ignoreBinaries": [ "nx" diff --git a/apps/cli/src/legacy/SIDE_EFFECTS_TEMPLATE.md b/apps/cli/src/legacy/SIDE_EFFECTS_TEMPLATE.md index 50d8bd478..a27b7f3ab 100644 --- a/apps/cli/src/legacy/SIDE_EFFECTS_TEMPLATE.md +++ b/apps/cli/src/legacy/SIDE_EFFECTS_TEMPLATE.md @@ -74,6 +74,19 @@ | `1` | authentication error (no token found) | | `1` | network / connection failure | +## Telemetry Events Fired + + + +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | ----------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` | + ## Output

... (truncated)

Changelog

Sourced from @​supabase/supabase-js's changelog.

2.106.2 (2026-05-25)

🩹 Fixes

  • misc: add react-native export condition for Hermes-safe resolution (#2393)

❤️ Thank You

2.106.1 (2026-05-20)

🩹 Fixes

  • misc: hide dynamic import from hermesc (#2381)

❤️ Thank You

2.106.0 (2026-05-18)

🚀 Features

  • supabase: W3C/OpenTelemetry trace context propagation (#2163)

🩹 Fixes

  • release: mark @​supabase/tracing private and snapshot it for JSR (#2370)

❤️ Thank You

  • Claude Sonnet 4.5
  • Guilherme Souza
  • Katerina Skroumpelou @​mandarini
Commits
  • a5f09cf chore(repo): adopt pnpm catalog and clean up devDeps (#2389)
  • c72cc56 fix(misc): add react-native export condition for Hermes-safe resolution (#2393)
  • a7bdb23 docs(supabase): expand tracePropagation tsdoc with examples (#2388)
  • f4c149c chore(release): version 2.106.1 changelogs (#2384)
  • 3f9628a fix(misc): hide dynamic import from hermesc (#2381)
  • 1761a62 chore(release): version 2.106.0 changelogs (#2379)
  • 1c48755 chore(deps): cleanups and updates (#2371)
  • 9dfba1c chore(repo): migrate to pnpm (#2368)
  • 6731c4a fix(release): mark @​supabase/tracing private and snapshot it for JSR (#2370)
  • 2fe1801 feat(supabase): W3C/OpenTelemetry trace context propagation (#2163)
  • Additional commits viewable in compare view

Updates `@types/react` from 19.2.14 to 19.2.15
Commits

Updates `ink` from 7.0.3 to 7.0.4
Release notes

Sourced from ink's releases.

v7.0.4

  • Fix: Share resize listener via emitLayoutListeners instead of per-hook listeners (#952) 89d43d8
  • Fix: Remove useEffectEvent functions from useEffect dependency arrays (#960) 9d534f7

https://github.com/vadimdemedes/ink/compare/v7.0.3...v7.0.4

Commits
  • 40b3a75 7.0.4
  • 89d43d8 Fix: Share resize listener via emitLayoutListeners instead of per-hook list...
  • 9d534f7 Fix: Remove useEffectEvent functions from useEffect dependency arrays (#960)
  • See full diff in compare view

Updates `posthog-node` from 5.34.3 to 5.35.4
Release notes

Sourced from posthog-node's releases.

posthog-node@5.35.4

5.35.4

Patch Changes

  • Updated dependencies []:
    • @​posthog/core@​1.29.11

posthog-node@5.35.3

5.35.3

Patch Changes

  • Updated dependencies [5568f12]:
    • @​posthog/core@​1.29.10

posthog-node@5.35.2

5.35.2

Patch Changes

  • #3658 5d7a2d3 Thanks @​gustavohstrassburger! - Include group context in the $feature_flag_called deduplication key in _captureFlagCalledEventIfNeeded, so events fire independently per group combination. (2026-05-25)

posthog-node@5.35.1

5.35.1

Patch Changes

  • Updated dependencies [c806cca]:
    • @​posthog/core@​1.29.9

posthog-node@5.35.0

5.35.0

Minor Changes

  • #3642 18ea8b5 Thanks @​dustinbyrne! - Promote feature flag definition cache provider types to the main posthog-node export and deprecate posthog-node/experimental imports. (2026-05-21)

Patch Changes

  • Updated dependencies []:
    • @​posthog/core@​1.29.8

posthog-node@5.34.10

5.34.10

Patch Changes

... (truncated)

Changelog

Sourced from posthog-node's changelog.

5.35.4

Patch Changes

  • Updated dependencies []:
    • @​posthog/core@​1.29.11

5.35.3

Patch Changes

  • Updated dependencies [5568f12]:
    • @​posthog/core@​1.29.10

5.35.2

Patch Changes

  • #3658 5d7a2d3 Thanks @​gustavohstrassburger! - Include group context in the $feature_flag_called deduplication key in _captureFlagCalledEventIfNeeded, so events fire independently per group combination. (2026-05-25)

5.35.1

Patch Changes

  • Updated dependencies [c806cca]:
    • @​posthog/core@​1.29.9

5.35.0

Minor Changes

  • #3642 18ea8b5 Thanks @​dustinbyrne! - Promote feature flag definition cache provider types to the main posthog-node export and deprecate posthog-node/experimental imports. (2026-05-21)

Patch Changes

  • Updated dependencies []:
    • @​posthog/core@​1.29.8

5.34.10

Patch Changes

  • #3643 f42f371 Thanks @​dmarticus! - Reject semver values with leading zeros in local flag evaluation. Per semver 2.0.0 §2, numeric identifiers must not include leading zeros — values like 1.07.3 are not valid semver and should not match targeting conditions. Both override values and flag values are now validated; invalid inputs surface as InconclusiveMatchError so the condition does not match. (2026-05-21)

5.34.9

Patch Changes

... (truncated)

Commits
  • e5a89ac chore: update versions and lockfile [version bump]
  • 55b3c42 chore: update versions and lockfile [version bump]
  • 1d0daf0 chore: update versions and lockfile [version bump]
  • 5d7a2d3 fix(node): fire separate $feature_flag_called events per group context (#3658)
  • 3d41c1d chore: update versions and lockfile [version bump]
  • a05405d chore: update versions and lockfile [version bump]
  • 18ea8b5 feat(node): promote flag definition cache provider types (#3642)
  • 1fcb5ae chore: update versions and lockfile [version bump]
  • f42f371 fix(node): reject leading-zero semver values in local evaluation (#3643)
  • 2f46fe6 chore: update versions and lockfile [version bump]
  • Additional commits viewable in compare view

Updates `semantic-release` from 24.2.9 to 25.0.3
Release notes

Sourced from semantic-release's releases.

v25.0.3

25.0.3 (2026-01-30)

Bug Fixes

v25.0.2

25.0.2 (2025-11-07)

Bug Fixes

  • deps: update dependency read-package-up to v12 (#3935) (1494cb9)

v25.0.1

25.0.1 (2025-10-19)

Bug Fixes

v25.0.1-beta.3

25.0.1-beta.3 (2025-10-19)

Bug Fixes

  • deps: update to latest npm plugin (a96aced)

v25.0.1-beta.2

25.0.1-beta.2 (2025-10-19)

Bug Fixes

v25.0.1-beta.1

25.0.1-beta.1 (2025-10-16)

Bug Fixes

... (truncated)

Commits
  • f404124 fix(deps): remove deprecated semver-diff (#3980)
  • fef7e34 docs: warn against using registry-url in setup-node (#4024)
  • 699d470 chore(deps): update dependency lockfile-lint to v5 (#4022)
  • c7c6f7a chore(deps): update dependency tempy to v3.1.2 (#4021)
  • 1ce5088 ci(action): update github/codeql-action action to v4.32.0 (#4019)
  • 9bb0d87 chore(deps): lock file maintenance (#4016)
  • 490171c chore(deps): update npm to v11.8.0 (#4015)
  • f6411e9 chore(deps): update dependency prettier to v3.8.1 (#4014)
  • c71c576 chore(deps): update dependency publint to v0.3.17 (#4013)
  • 989e18c chore(deps): update dependency tempy to v3.1.1 (#4012)
  • Additional commits viewable in compare view
Maintainer changes

This version was pushed to npm by GitHub Actions, a new releaser for semantic-release since your current version.


Updates `fumadocs-core` from 16.8.11 to 16.9.1
Release notes

Sourced from fumadocs-core's releases.

fumadocs-core@16.9.1

Patch Changes

  • e77b9b3: Introduce pagesIndex property to explicitly define the index page for folder
  • 334c8fd: [i18n] support different orders of preset() calls

fumadocs-core@16.8.12

Patch Changes

  • 768b676: Standardize structuredData in page data
Commits
  • 4aa3082 Merge pull request #3301 from fuma-nama/changeset-release/dev
  • e77b9b3 feat(core): Introduce pagesIndex property to explicitly define the index pa...
  • dca5b49 fix(mdx): fix compatibility with ?raw query string
  • 334c8fd feat(core): support different orders of preset() calls
  • bbf936c docs: introduce i18n support for story
  • 07a06b4 chore: use Waku for stackblitz example
  • 97c9ad3 Version Packages
  • da4d897 fix CI
  • 5a5e5c8 add separate example for demo
  • e4710be Docs: introduce new i18n API
  • Additional commits viewable in compare view

Updates `fumadocs-mdx` from 15.0.6 to 15.0.8
Release notes

Sourced from fumadocs-mdx's releases.

fumadocs-mdx@15.0.8

Patch Changes

  • dca5b49: Fix compatibility with ?raw query string
  • Updated dependencies [e77b9b3]
  • Updated dependencies [334c8fd]
    • fumadocs-core@16.9.1

fumadocs-mdx@15.0.7

Patch Changes

  • 768b676: Standardize structuredData in page data
  • Updated dependencies [768b676]
    • fumadocs-core@16.8.12
Commits
  • 4aa3082 Merge pull request #3301 from fuma-nama/changeset-release/dev
  • e77b9b3 feat(core): Introduce pagesIndex property to explicitly define the index pa...
  • dca5b49 fix(mdx): fix compatibility with ?raw query string
  • 334c8fd feat(core): support different orders of preset() calls
  • bbf936c docs: introduce i18n support for story
  • 07a06b4 chore: use Waku for stackblitz example
  • 97c9ad3 Version Packages
  • da4d897 fix CI
  • 5a5e5c8 add separate example for demo
  • e4710be Docs: introduce new i18n API
  • Additional commits viewable in compare view

Updates `fumadocs-ui` from 16.8.11 to 16.9.1
Release notes

Sourced from fumadocs-ui's releases.

fumadocs-ui@16.9.1

Patch Changes

  • Updated dependencies [e77b9b3]
  • Updated dependencies [334c8fd]
    • fumadocs-core@16.9.1

fumadocs-ui@16.8.12

Patch Changes

  • Updated dependencies [768b676]
    • fumadocs-core@16.8.12
Commits
  • 4aa3082 Merge pull request #3301 from fuma-nama/changeset-release/dev
  • e77b9b3 feat(core): Introduce pagesIndex property to explicitly define the index pa...
  • dca5b49 fix(mdx): fix compatibility with ?raw query string
  • 334c8fd feat(core): support different orders of preset() calls
  • bbf936c docs: introduce i18n support for story
  • 07a06b4 chore: use Waku for stackblitz example
  • 97c9ad3 Version Packages
  • da4d897 fix CI
  • 5a5e5c8 add separate example for demo
  • e4710be Docs: introduce new i18n API
  • Additional commits viewable in compare view

Updates `@types/node` from 25.8.0 to 25.9.1
Commits

Updates `@effect/atom-react` from 4.0.0-beta.67 to 4.0.0-beta.70
Release notes

Sourced from @​effect/atom-react's releases.

@​effect/atom-react@​4.0.0-beta.70

Patch Changes

@​effect/atom-react@​4.0.0-beta.69

Patch Changes

@​effect/atom-react@​4.0.0-beta.68

Patch Changes

Changelog

Sourced from @​effect/atom-react's changelog.

4.0.0-beta.70

Patch Changes

4.0.0-beta.69

Patch Changes

4.0.0-beta.68

Patch Changes

Commits

Updates `@effect/platform-bun` from 4.0.0-beta.67 to 4.0.0-beta.70
Commits

Updates `@effect/platform-node` from 4.0.0-beta.67 to 4.0.0-beta.70
Commits

Updates `@effect/vitest` from 4.0.0-beta.43 to 4.0.0-beta.70
Commits

Updates `@nx/devkit` from 22.7.2 to 22.7.4
Release notes

Sourced from @​nx/devkit's releases.

22.7.4 (2026-05-25)

🩹 Fixes

  • core: update brace-expansion and yaml (#35790)

❤️ Thank You

22.7.3 (2026-05-22)

🚀 Features

  • js: support pnpm 11.2.2 (#35772)

🩹 Fixes

  • angular: only add @​oxc-project/runtime on the vitest-analog path (#35734)
  • angular-rspack: exclude eslint config from tailwind v4 source scan (#35663)
  • core: warn before installing unknown npm packages as preset (#35644)
  • core: preserve input order in createNodes plugin results (#35595)
  • core: resolve local plugin subpath imports from source (#35631)
  • core: treat undefined task parallelism as parallel when scheduling (#35736)
  • core: handle object form of bin field in getPrettierPath (#35680)
  • core: detect vscode copilot ai agent (#35757)
  • core: allow local plugin subpath imports without custom conditions (#35751, #35631)
  • dotnet: include Directory.. files in inputs (#35738)
  • gradle: add transitive:true to all tasks (#35677)
  • gradle: pin generated e2e project toolchain to installed JDK (#35703)
  • js: fall back to npm publish when bun publish fails with auth error (#35756)
  • linter: improve convert-to-flat-config output fidelity (#35330)
  • linter: only rewrite workspace-package peer deps to workspace:* (#35423, #35318, #33417)
  • misc: stop inferring projects: 'self' in dependsOn entries (#35686)
  • misc: skip $ escaping in file paths on windows (#35692)
  • repo: run dotnet restore before publish (#35771)
  • repo: run dotnet restore before macos e2e job (#35774)
  • rsbuild: infer build outputs from distPath.root directly (#35707)
  • rsbuild: lazy-require @​rsbuild/core in plugin so spec mocks work after jest.resetModules (#35707)
  • testing: correct yargs-parser import in getJestProjectsAsync (#35672, #35654)

❤️ Thank You

... (truncated)

Commits
  • e9e447b chore(core): remove unused replaceNrwlPackageWithNxPackage devkit utility (#3...
  • See full diff in compare view

Updates `@swc/core` from 1.15.33 to 1.15.40
Changelog

Sourced from @​swc/core's changelog.

[1.15.40] - 2026-05-23

Bug Fixes

  • (es/minifier) Preserve args for destructured callbacks (#11830) (21873b0)

  • (es/minifier) Avoid generating mangled property names that collide with existing properties (#11839) (9b4fab5)

  • (es/minifier) Respect ecma for iife temp vars (#11873) (e481934)

  • (es/minifier) Preserve default parameter object props (#11884) (71ff84f)

  • (es/parser) Reject object-rest assignment to array/object literal (#11875) (7b57d1f)

  • (es/parser) Reject object rest assignment to literals (#11881) (4ec2eaf)

  • (es/react) Exclude self-recursive hooks from refresh dependency array (#11838) (9101c71)

  • (ts/fast-dts) Strip definite assertions in dts (#11858) (2ab1b8a)

  • (ts/fast-strip) Reject unsafe assertion erasure in binary expressions (#11828) (aa5b539)

  • (typescript) Strip parameter binding defaults in dts (#11857) (800bc17)

Documentation

... (truncated)

Commits
  • 112729b chore: Publish 1.15.40 with swc_core v66.0.5
  • 13a5608 chore: Publish 1.15.40-nightly-20260523.1 with swc_core v66.0.5
  • bc6ee83 chore: Publish 1.15.39-nightly-20260523.1 with swc_core v66.0.5
  • 3a68ad5 chore: Publish 1.15.38-nightly-20260522.1 with swc_core v66.0.5
  • d0f0d5a chore: Publish 1.15.37-nightly-20260522.1 with swc_core v66.0.5
  • 969df79 chore: Publish 1.15.36-nightly-20260522.1 with swc_core v66.0.5
  • 38c2a44 chore: Publish 1.15.35-nightly-20260522.1 with swc_core v66.0.4
  • 18df110 chore: Publish 1.15.34-nightly-20260522.1 with swc_core v66.0.4
  • 20d92eb security: update rkyv and Rust dependencies (#11851)
  • 0d8e651 chore: Publish crates with swc_core v65.0.3
  • See full diff in compare view

Updates `@typescript/native-preview` from 7.0.0-dev.20260518.1 to 7.0.0-dev.20260526.1
Commits

Updates `@vitest/coverage-istanbul` from 4.1.6 to 4.1.7
Release notes

Sourced from @​vitest/coverage-istanbul's releases.

v4.1.7

   🐞 Bug Fixes

    View changes on GitHub
Commits

Updates `effect` from 4.0.0-beta.67 to 4.0.0-beta.70
Commits

Updates `knip` from 6.14.1 to 6.14.2
Release notes

Sourced from knip's releases.

Release 6.14.2

  • Fix vscode-knip build: pin native oxc bindings to bundled JS version (1b45a4103312c9c059560ae2e1eac25d86b4e2ac)
  • Release vscode-knip@2.1.5 (328892eb04e65b4702e1ef2303db3156b8f2e1a3)
  • Fix Astro plugin to support both possible middleware entry points (#1749) (33e0cc1a530a8cf5b6b05c8b3a3ca55f8fce8a75) - thanks @​schmalz-dmi!
  • Fix LICENSE link (#1760) (829620f9077ddea086a610c279c7c1250dd66e11) - thanks @​vortispy!
  • Fix GraphQL Codegen script config dependencies (#1756) (e841c6355e7eff240e74010bfd2be8bbb22ff2b6) - thanks @​jakeleventhal!
  • Set pnpm config via env vars, disable verify-deps in ecosystem tests (53c12248cc3e79fd79f3efde691d463fc795c40f)
  • Update slonik ecosystem snapshot (f18410b34c8554364a9f003660bebae5e826de57)
  • Fix Serverless TypeScript plugin dependencies (#1757) (ebde7f8f3e3004db7f51fb5d60a0bdc2452116ef) - thanks @​jakeleventhal!
  • Fix extended tsconfig type dependency attribution (#1758) (f600b09e562317a37844ed8cdf1b9b46e06c9405) - thanks @​jakeleventhal!
  • Fix Bun binary dependency tracking (#1759) (1b289239f35ff2912195b7e39a96c667c54c1fc5) - thanks @​jakeleventhal!
  • Detect Babel plugins/presets in Vite plugin options (resolve #1761) (2753d6910743a12a207fca81cb8325c00803963a)
Commits

Updates `nx` from 22.7.2 to 22.7.4
Release notes

Sourced from nx's releases.

22.7.4 (2026-05-25)

🩹 Fixes

  • core: update brace-expansion and yaml (#35790)

❤️ Thank You

22.7.3 (2026-05-22)

🚀 Features

  • js: support pnpm 11.2.2 (#35772)

🩹 Fixes