diff --git a/apps/cli/docs/go-cli-porting-status.md b/apps/cli/docs/go-cli-porting-status.md index defaffc3e2..194bdfa3ab 100644 --- a/apps/cli/docs/go-cli-porting-status.md +++ b/apps/cli/docs/go-cli-porting-status.md @@ -54,6 +54,7 @@ These commands exist in the TS CLI today but have no direct top-level equivalent | `api` | [`../src/next/commands/platform/api.command.ts`](../src/next/commands/platform/api.command.ts) | Low-level Management API client. It supersedes the old generated tree with explicit discovery via `supabase api routes` and execution via `supabase api request [--method ]`. | | `stack` | [`../src/next/cli/root.ts`](../src/next/cli/root.ts) | TS-only local runtime namespace exposing `stack start`, `stack stop`, `stack status`, `stack list`, and `stack update`. Top-level `start`, `stop`, and `status` remain aliases. | | `branches switch` | [`../src/next/commands/branches/switch/switch.command.ts`](../src/next/commands/branches/switch/switch.command.ts) | No direct Go equivalent. Updates local active-branch state so subsequent commands target the selected branch. | +| `config pull` | [`../src/legacy/commands/config/pull/pull.command.ts`](../src/legacy/commands/config/pull/pull.command.ts) | Compares config-schema-shaped hosted values for an exact preview branch with the local project config. | ## Quick Start diff --git a/apps/cli/src/legacy/commands/config/config.command.ts b/apps/cli/src/legacy/commands/config/config.command.ts index efec9afa22..91c4f31be1 100644 --- a/apps/cli/src/legacy/commands/config/config.command.ts +++ b/apps/cli/src/legacy/commands/config/config.command.ts @@ -1,8 +1,9 @@ import { Command } from "effect/unstable/cli"; +import { legacyConfigPullCommand } from "./pull/pull.command.ts"; import { legacyConfigPushCommand } from "./push/push.command.ts"; export const legacyConfigCommand = Command.make("config").pipe( Command.withDescription("Manage Supabase project configurations."), Command.withShortDescription("Manage project configurations"), - Command.withSubcommands([legacyConfigPushCommand]), + Command.withSubcommands([legacyConfigPullCommand, legacyConfigPushCommand]), ); diff --git a/apps/cli/src/legacy/commands/config/pull/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/config/pull/SIDE_EFFECTS.md new file mode 100644 index 0000000000..bfd10ad418 --- /dev/null +++ b/apps/cli/src/legacy/commands/config/pull/SIDE_EFFECTS.md @@ -0,0 +1,61 @@ +# `supabase config pull` + +Pulls the effective hosted configuration for a preview branch and compares it +with the local project configuration. The command does not modify local or +remote configuration. + +## Files read + +| Path | Format | When | +| ---------------------------------------------- | ---------- | ----------------------------------------------------------- | +| `/supabase/config.toml` or `.json` | TOML/JSON | Before the Management API request | +| `/supabase/.env` and `.env.local` | dotenv | While resolving `env(...)` references in project config | +| `/supabase/.temp/project-ref` | plain text | When no project reference is set through flags or the shell | +| `/supabase/.temp/linked-project.json` | JSON | When resolving linked-project telemetry state | +| `~/.supabase/access-token` | plain text | When `SUPABASE_ACCESS_TOKEN` and keyring access are absent | + +## Files written + +| Path | Format | When | +| ---------------------------------------------- | ------ | ------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | After the project reference resolves, including failure | +| `~/.supabase/telemetry.json` | JSON | After the command runs, including failure | + +The command never writes `supabase/config.toml` or `supabase/config.json`. + +## API routes + +The request uses bearer authentication. + +| Method | Path | Query | Success | +| ------ | --------------------------- | ----------------- | ------- | +| `GET` | `/v1/projects/{ref}/config` | `branch={target}` | `200` | + +The response contains `auth` and `api` objects shaped like the Supabase config +schema. The endpoint omits credential values. A `404` response means the exact +target branch does not exist. + +## Environment variables + +| Variable | Purpose | +| ----------------------- | -------------------------------------------------- | +| `SUPABASE_PROJECT_ID` | Resolves the parent project reference | +| `SUPABASE_ACCESS_TOKEN` | Authenticates the Management API request | +| `SUPABASE_PROFILE` | Selects the Management API profile | +| `env(VAR)` references | Resolves values while loading local project config | + +## Exit codes + +| Code | Condition | +| ---- | ----------------------------------------------------- | +| `0` | The comparison completes, with or without differences | +| `1` | The target is empty or does not exist | +| `1` | Local project config is missing or invalid | +| `1` | The API request fails or returns an invalid response | + +## Output + +Text output lists each changed config path with its local and remote values. +`--output-format json` and `stream-json` emit a structured result containing +`project_ref`, `target`, and `changes`. The legacy `--output` formats remain +available for compatibility. diff --git a/apps/cli/src/legacy/commands/config/pull/pull.command.ts b/apps/cli/src/legacy/commands/config/pull/pull.command.ts new file mode 100644 index 0000000000..7b06068db6 --- /dev/null +++ b/apps/cli/src/legacy/commands/config/pull/pull.command.ts @@ -0,0 +1,35 @@ +import { Command, Flag } from "effect/unstable/cli"; +import type * as CliCommand from "effect/unstable/cli/Command"; + +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { legacyManagementApiRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; +import { legacyConfigPull } from "./pull.handler.ts"; + +const config = { + target: Flag.string("target").pipe( + Flag.withDescription("Remote branch name to compare with local config."), + ), +} as const; + +export type LegacyConfigPullFlags = CliCommand.Command.Config.Infer; + +export const legacyConfigPullCommand = Command.make("pull", config).pipe( + Command.withDescription( + "Pull hosted configuration for a remote branch and compare it with the local project config.", + ), + Command.withShortDescription("Compare remote branch config with local config"), + Command.withExamples([ + { + command: "supabase config pull --target feature/login", + description: "Show config differences for a remote branch", + }, + ]), + Command.withHandler((flags) => + legacyConfigPull(flags).pipe( + withLegacyCommandInstrumentation({ flags }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyManagementApiRuntimeLayer(["config", "pull"])), +); diff --git a/apps/cli/src/legacy/commands/config/pull/pull.errors.ts b/apps/cli/src/legacy/commands/config/pull/pull.errors.ts new file mode 100644 index 0000000000..b5b5eb088d --- /dev/null +++ b/apps/cli/src/legacy/commands/config/pull/pull.errors.ts @@ -0,0 +1,31 @@ +import { Data } from "effect"; + +export class LegacyConfigPullFileNotFoundError extends Data.TaggedError( + "LegacyConfigPullFileNotFoundError", +)<{ + readonly message: string; + readonly suggestion: string; +}> {} + +export class LegacyConfigPullTargetNotFoundError extends Data.TaggedError( + "LegacyConfigPullTargetNotFoundError", +)<{ + readonly message: string; + readonly suggestion: string; +}> {} + +export class LegacyConfigPullTargetEmptyError extends Data.TaggedError( + "LegacyConfigPullTargetEmptyError", +)<{ + readonly message: string; +}> {} + +export class LegacyConfigPullNetworkError extends Data.TaggedError("LegacyConfigPullNetworkError")<{ + readonly message: string; +}> {} + +export class LegacyConfigPullStatusError extends Data.TaggedError("LegacyConfigPullStatusError")<{ + readonly status: number; + readonly body: string; + readonly message: string; +}> {} diff --git a/apps/cli/src/legacy/commands/config/pull/pull.handler.ts b/apps/cli/src/legacy/commands/config/pull/pull.handler.ts new file mode 100644 index 0000000000..56cc522dd3 --- /dev/null +++ b/apps/cli/src/legacy/commands/config/pull/pull.handler.ts @@ -0,0 +1,162 @@ +import { loadProjectConfig, resolveProjectValue } from "@supabase/config"; +import { Effect, Option } from "effect"; + +import { LegacyOutputFlag } from "../../../../shared/legacy/global-flags.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; +import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; +import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { + encodeEnv, + encodeGoJson, + encodeToml, + encodeYaml, +} from "../../../shared/legacy-go-output.encoders.ts"; +import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; +import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import type { LegacyConfigPullFlags } from "./pull.command.ts"; +import { + LegacyConfigPullFileNotFoundError, + LegacyConfigPullNetworkError, + LegacyConfigPullStatusError, + LegacyConfigPullTargetEmptyError, + LegacyConfigPullTargetNotFoundError, +} from "./pull.errors.ts"; + +interface LegacyConfigChange { + readonly path: string; + readonly local: unknown; + readonly remote: unknown; +} + +function isRecord(value: unknown): value is Readonly> { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function sameValue(local: unknown, remote: unknown): boolean { + return JSON.stringify(local) === JSON.stringify(remote); +} + +function diffRemoteConfig( + local: unknown, + remote: unknown, + path: ReadonlyArray = [], +): ReadonlyArray { + if (isRecord(remote)) { + const localRecord = isRecord(local) ? local : {}; + return Object.entries(remote).flatMap(([key, value]) => + diffRemoteConfig(localRecord[key], value, [...path, key]), + ); + } + + if (sameValue(local, remote)) { + return []; + } + + return [{ path: path.join("."), local, remote }]; +} + +function formatValue(value: unknown): string { + if (value === undefined) return ""; + return String(JSON.stringify(value)); +} + +const fetchRemoteConfig = Effect.fnUntraced(function* (ref: string, target: string) { + const api = yield* LegacyPlatformApi; + return yield* api.v1.getProjectConfig({ ref, branch: target }).pipe( + Effect.catch( + mapLegacyHttpError({ + networkError: LegacyConfigPullNetworkError, + statusError: LegacyConfigPullStatusError, + networkMessage: (cause) => `failed to pull config: ${cause}`, + statusMessage: (status, body) => `unexpected config pull status ${status}: ${body}`, + }), + ), + Effect.mapError((cause) => + cause._tag === "LegacyConfigPullStatusError" && cause.status === 404 + ? new LegacyConfigPullTargetNotFoundError({ + message: `Branch '${target}' not found.`, + suggestion: "Run `supabase branches list` to see available branches.", + }) + : cause, + ), + ); +}); + +export const legacyConfigPull = Effect.fn("legacy.config.pull")(function* ( + flags: LegacyConfigPullFlags, +) { + const output = yield* Output; + const goOutputFlag = yield* LegacyOutputFlag; + const resolver = yield* LegacyProjectRefResolver; + const linkedProjectCache = yield* LegacyLinkedProjectCache; + const telemetryState = yield* LegacyTelemetryState; + const runtimeInfo = yield* RuntimeInfo; + + if (flags.target.length === 0) { + return yield* new LegacyConfigPullTargetEmptyError({ + message: "--target must not be empty.", + }); + } + + const ref = yield* resolver.resolve(Option.none()); + yield* Effect.gen(function* () { + yield* output.intro("Config pull"); + const pulling = yield* output.task(`Pulling config from '${flags.target}'...`); + + const result = yield* Effect.gen(function* () { + const loaded = yield* loadProjectConfig(runtimeInfo.cwd, { + projectRef: ref, + goViperCompat: true, + }); + if (loaded === null) { + return yield* new LegacyConfigPullFileNotFoundError({ + message: "No supabase/config.toml or supabase/config.json file was found.", + suggestion: "Run `supabase init` first.", + }); + } + + const remote = yield* fetchRemoteConfig(ref, flags.target); + const local = yield* resolveProjectValue(loaded.config, { values: {} }, "", { + goViperCompat: true, + }); + return diffRemoteConfig(local, remote); + }).pipe(Effect.tapError(() => pulling.fail())); + + yield* pulling.clear(); + const data = { project_ref: ref, target: flags.target, changes: result }; + const goOutput = Option.getOrUndefined(goOutputFlag); + if (goOutput === "json") { + yield* output.raw(encodeGoJson(data)); + return; + } + if (goOutput === "yaml") { + yield* output.raw(encodeYaml(data)); + return; + } + if (goOutput === "toml") { + yield* output.raw(`${encodeToml(data)}\n`); + return; + } + if (goOutput === "env") { + yield* output.raw(`${encodeEnv(data)}\n`); + return; + } + if (output.format !== "text") { + yield* output.success("Config diff", data); + return; + } + + for (const change of result) { + yield* output.info( + `${change.path}\n local: ${formatValue(change.local)}\n remote: ${formatValue(change.remote)}`, + ); + } + yield* output.outro( + result.length === 0 + ? `Config matches '${flags.target}'.` + : `Found ${result.length} config difference${result.length === 1 ? "" : "s"} for '${flags.target}'.`, + ); + }).pipe(Effect.ensuring(linkedProjectCache.cache(ref)), Effect.ensuring(telemetryState.flush)); +}); diff --git a/apps/cli/src/legacy/commands/config/pull/pull.integration.test.ts b/apps/cli/src/legacy/commands/config/pull/pull.integration.test.ts new file mode 100644 index 0000000000..788130cddf --- /dev/null +++ b/apps/cli/src/legacy/commands/config/pull/pull.integration.test.ts @@ -0,0 +1,309 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, Option } from "effect"; +import * as HttpClientError from "effect/unstable/http/HttpClientError"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; + +import { + buildLegacyTestRuntime, + LEGACY_VALID_REF, + legacyJsonResponse, + legacyTransportFailure, + mockLegacyCliConfig, + mockLegacyLinkedProjectCacheTracked, + mockLegacyPlatformApi, + mockLegacyTelemetryStateTracked, + useLegacyTempWorkdir, +} from "../../../../../tests/helpers/legacy-mocks.ts"; +import { mockOutput, mockRuntimeInfo } from "../../../../../tests/helpers/mocks.ts"; +import { legacyConfigPull } from "./pull.handler.ts"; + +const tempRoot = useLegacyTempWorkdir("supabase-config-pull-int-"); + +function writeConfig(): void { + const dir = join(tempRoot.current, "supabase"); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "config.toml"), + `project_id = "test" + +[auth] +site_url = "http://127.0.0.1:3000" +`, + ); +} + +interface SetupOptions { + readonly response?: unknown; + readonly rawBody?: string; + readonly status?: number; + readonly network?: "fail" | "fail-without-description"; + readonly format?: "text" | "json" | "stream-json"; + readonly goOutput?: "env" | "pretty" | "json" | "toml" | "yaml"; + readonly configFound?: boolean; + readonly tracked?: boolean; +} + +function setup(options: SetupOptions = {}) { + if (options.configFound !== false) writeConfig(); + + const out = mockOutput({ format: options.format ?? "text" }); + const api = mockLegacyPlatformApi({ + handler: (request) => + options.network === "fail" + ? Effect.fail(legacyTransportFailure(request)) + : options.network === "fail-without-description" + ? Effect.fail( + new HttpClientError.HttpClientError({ + reason: new HttpClientError.TransportError({ request }), + }), + ) + : options.rawBody !== undefined + ? Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(options.rawBody, { + status: options.status ?? 200, + headers: { "content-type": "application/json" }, + }), + ), + ) + : Effect.succeed( + legacyJsonResponse( + request, + options.status ?? 200, + options.response ?? { auth: {}, api: {} }, + ), + ), + }); + const telemetry = mockLegacyTelemetryStateTracked(); + const cache = mockLegacyLinkedProjectCacheTracked(); + const layer = buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), + telemetry: options.tracked ? telemetry.layer : undefined, + linkedProjectCache: options.tracked ? cache.layer : undefined, + goOutput: options.goOutput === undefined ? Option.none() : Option.some(options.goOutput), + }); + return { api, cache, layer, out, telemetry }; +} + +describe("legacy config pull integration", () => { + it.live("fetches config for the exact target and reports remote differences", () => { + const { api, layer, out } = setup({ + response: { + auth: { + site_url: "https://preview.example.com", + additional_redirect_urls: ["https://preview.example.com/auth/callback"], + unmapped: { nested: null }, + }, + api: {}, + }, + }); + + return Effect.gen(function* () { + yield* legacyConfigPull({ target: "release/v1.2.3" }); + + expect(api.requests).toHaveLength(1); + expect(api.requests[0]?.url).toBe( + `https://api.supabase.com/v1/projects/${LEGACY_VALID_REF}/config`, + ); + expect(new URLSearchParams(api.requests[0]?.urlParams).get("branch")).toBe("release/v1.2.3"); + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "info", + message: expect.stringContaining("auth.site_url"), + }), + ); + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "outro", + message: "Found 3 config differences for 'release/v1.2.3'.", + }), + ); + }).pipe(Effect.provide(layer)); + }); + + it.live("reports when the remote config matches local defaults", () => { + const { layer, out } = setup(); + return Effect.gen(function* () { + yield* legacyConfigPull({ target: "feature/login" }); + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "outro", + message: "Config matches 'feature/login'.", + }), + ); + }).pipe(Effect.provide(layer)); + }); + + it.live("does not report remote values that equal local values", () => { + const { layer, out } = setup({ + response: { + auth: { site_url: "http://127.0.0.1:3000" }, + api: { max_rows: 1000 }, + }, + }); + return Effect.gen(function* () { + yield* legacyConfigPull({ target: "feature/login" }); + expect(out.messages.filter((message) => message.type === "info")).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }); + + it.live("uses the singular label for one difference", () => { + const { layer, out } = setup({ + response: { auth: { site_url: "https://preview.example.com" }, api: {} }, + }); + return Effect.gen(function* () { + yield* legacyConfigPull({ target: "feature/login" }); + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "outro", + message: "Found 1 config difference for 'feature/login'.", + }), + ); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a structured diff for JSON output", () => { + const { layer, out } = setup({ + format: "json", + response: { auth: { site_url: "https://preview.example.com" }, api: {} }, + }); + return Effect.gen(function* () { + yield* legacyConfigPull({ target: "feature/login" }); + expect(out.messages.find((message) => message.type === "success")?.data).toMatchObject({ + project_ref: LEGACY_VALID_REF, + target: "feature/login", + changes: [ + { + path: "auth.site_url", + local: "http://127.0.0.1:3000", + remote: "https://preview.example.com", + }, + ], + }); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a structured diff for stream JSON output", () => { + const { layer, out } = setup({ format: "stream-json" }); + return Effect.gen(function* () { + yield* legacyConfigPull({ target: "feature/login" }); + expect(out.messages.find((message) => message.type === "success")).toBeDefined(); + }).pipe(Effect.provide(layer)); + }); + + it.live("honors the legacy machine-output formats", () => { + const json = setup({ goOutput: "json" }); + const yaml = setup({ goOutput: "yaml" }); + const toml = setup({ goOutput: "toml" }); + const env = setup({ goOutput: "env" }); + return Effect.gen(function* () { + yield* legacyConfigPull({ target: "feature/login" }).pipe(Effect.provide(json.layer)); + yield* legacyConfigPull({ target: "feature/login" }).pipe(Effect.provide(yaml.layer)); + yield* legacyConfigPull({ target: "feature/login" }).pipe(Effect.provide(toml.layer)); + yield* legacyConfigPull({ target: "feature/login" }).pipe(Effect.provide(env.layer)); + expect(json.out.stdoutText).toContain('"target": "feature/login"'); + expect(yaml.out.stdoutText).toContain("target: feature/login"); + expect(toml.out.stdoutText).toContain('target = "feature/login"'); + expect(env.out.stdoutText).toContain('TARGET="feature/login"'); + }); + }); + + it.live("fails before reading local config when target is empty", () => { + const { api, layer } = setup({ configFound: false }); + return Effect.gen(function* () { + const exit = yield* legacyConfigPull({ target: "" }).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(JSON.stringify(exit)).toContain("LegacyConfigPullTargetEmptyError"); + expect(api.requests).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails before the remote request when local config is missing", () => { + const { api, layer } = setup({ configFound: false }); + return Effect.gen(function* () { + const exit = yield* legacyConfigPull({ target: "feature/login" }).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(JSON.stringify(exit)).toContain("LegacyConfigPullFileNotFoundError"); + expect(api.requests).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }); + + it.live("maps a missing target response to a target error", () => { + const { layer } = setup({ status: 404, response: { message: "not found" } }); + return Effect.gen(function* () { + const exit = yield* legacyConfigPull({ target: "missing" }).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(JSON.stringify(exit)).toContain("LegacyConfigPullTargetNotFoundError"); + }).pipe(Effect.provide(layer)); + }); + + it.live("maps non-success responses to a status error", () => { + const { layer } = setup({ status: 503, response: { message: "unavailable" } }); + return Effect.gen(function* () { + const exit = yield* legacyConfigPull({ target: "feature/login" }).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(JSON.stringify(exit)).toContain("LegacyConfigPullStatusError"); + expect(JSON.stringify(exit)).toContain("unexpected config pull status 503"); + }).pipe(Effect.provide(layer)); + }); + + it.live("maps transport failures to a network error", () => { + const { layer } = setup({ network: "fail" }); + return Effect.gen(function* () { + const exit = yield* legacyConfigPull({ target: "feature/login" }).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(JSON.stringify(exit)).toContain("LegacyConfigPullNetworkError"); + }).pipe(Effect.provide(layer)); + }); + + it.live("maps transport failures without a description to a network error", () => { + const { layer } = setup({ network: "fail-without-description" }); + return Effect.gen(function* () { + const exit = yield* legacyConfigPull({ target: "feature/login" }).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(JSON.stringify(exit)).toContain("TransportError"); + }).pipe(Effect.provide(layer)); + }); + + it.live("rejects an invalid response shape", () => { + const { layer } = setup({ response: { auth: {} } }); + return Effect.gen(function* () { + const exit = yield* legacyConfigPull({ target: "feature/login" }).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(JSON.stringify(exit)).toContain("LegacyConfigPullNetworkError"); + }).pipe(Effect.provide(layer)); + }); + + it.live("rejects invalid JSON", () => { + const { layer } = setup({ rawBody: "not-json" }); + return Effect.gen(function* () { + const exit = yield* legacyConfigPull({ target: "feature/login" }).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(JSON.stringify(exit)).toContain("LegacyConfigPullStatusError"); + }).pipe(Effect.provide(layer)); + }); + + it.live("flushes telemetry and linked-project state on success and failure", () => { + const success = setup({ tracked: true }); + return Effect.gen(function* () { + yield* legacyConfigPull({ target: "feature/login" }).pipe(Effect.provide(success.layer)); + expect(success.telemetry.flushed).toBe(true); + expect(success.cache.cachedRef).toBe(LEGACY_VALID_REF); + + const failure = setup({ tracked: true, status: 503 }); + yield* legacyConfigPull({ target: "feature/login" }).pipe( + Effect.provide(failure.layer), + Effect.exit, + ); + expect(failure.telemetry.flushed).toBe(true); + expect(failure.cache.cachedRef).toBe(LEGACY_VALID_REF); + }); + }); +}); diff --git a/apps/cli/src/legacy/commands/config/pull/pull.live.test.ts b/apps/cli/src/legacy/commands/config/pull/pull.live.test.ts new file mode 100644 index 0000000000..01ea8a9ab4 --- /dev/null +++ b/apps/cli/src/legacy/commands/config/pull/pull.live.test.ts @@ -0,0 +1,50 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { expect, test } from "vitest"; + +import { + describeLiveProject, + requireLiveProjectRef, + runSupabaseLive, +} from "../../../../../tests/helpers/live.ts"; + +function readGitBranch(value: unknown): string | undefined { + if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined; + + const gitBranch = Object.entries(value).find(([key]) => key === "git_branch")?.[1]; + return typeof gitBranch === "string" ? gitBranch : undefined; +} + +describeLiveProject("supabase config pull (live)", () => { + test("compares hosted config for a preview branch", async () => { + const ref = requireLiveProjectRef(); + const listed = await runSupabaseLive(["branches", "list", "--project-ref", ref, "-o", "json"]); + expect(listed.exitCode).toBe(0); + + const parsed: unknown = JSON.parse(listed.stdout); + const branches: ReadonlyArray = Array.isArray(parsed) ? parsed : []; + const target = branches.map(readGitBranch).find((gitBranch) => gitBranch !== undefined); + if (target === undefined) return; + + const workdir = mkdtempSync(join(tmpdir(), "supabase-config-pull-live-")); + try { + const supabaseDir = join(workdir, "supabase"); + mkdirSync(supabaseDir, { recursive: true }); + writeFileSync(join(supabaseDir, "config.toml"), 'project_id = "live-test"\n'); + + const pulled = await runSupabaseLive( + ["config", "pull", "--target", target, "--output-format", "json"], + { + cwd: workdir, + env: { SUPABASE_PROJECT_ID: ref }, + }, + ); + expect(pulled.exitCode).toBe(0); + expect(JSON.parse(pulled.stdout)).toMatchObject({ project_ref: ref, target }); + } finally { + rmSync(workdir, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/cli/src/legacy/shared/legacy-db-target-flags.ts b/apps/cli/src/legacy/shared/legacy-db-target-flags.ts index b8b7ffdf6f..f8e9b4eb3c 100644 --- a/apps/cli/src/legacy/shared/legacy-db-target-flags.ts +++ b/apps/cli/src/legacy/shared/legacy-db-target-flags.ts @@ -141,6 +141,7 @@ export const VALUE_CONSUMING_LONG_FLAGS = new Set([ "status", "sub", "swift-access-control", + "target", "template", "timestamp", "to", diff --git a/packages/api/src/generated/contracts.ts b/packages/api/src/generated/contracts.ts index 6e5f36f43b..03d1078e6d 100644 --- a/packages/api/src/generated/contracts.ts +++ b/packages/api/src/generated/contracts.ts @@ -5085,6 +5085,22 @@ export const V1GetProjectClaimTokenOutput = Schema.Struct({ }), ), }); +export const V1GetProjectConfigInput = Schema.Struct({ + ref: Schema.String.check( + Schema.isMinLength(20).annotate({ expected: "a value with a length of at least 20" }), + ) + .check(Schema.isMaxLength(20).annotate({ expected: "a value with a length of at most 20" })) + .check( + Schema.isPattern(new RegExp("^[a-z]+$")).annotate({ + expected: "a string matching the RegExp ^[a-z]+$", + }), + ), + branch: Schema.optionalKey(Schema.String), +}); +export const V1GetProjectConfigOutput = Schema.Struct({ + auth: Schema.Record(Schema.String, Schema.Json.annotate({ expected: "JSON value" })), + api: Schema.Record(Schema.String, Schema.Json.annotate({ expected: "JSON value" })), +}); export const V1GetProjectDiskAutoscaleConfigInput = Schema.Struct({ ref: Schema.String.check( Schema.isMinLength(20).annotate({ expected: "a value with a length of at least 20" }), @@ -10709,6 +10725,7 @@ export const openApiOperationIdMap = { "v1-get-project-api-key": "v1GetProjectApiKey", "v1-get-project-api-keys": "v1GetProjectApiKeys", "v1-get-project-claim-token": "v1GetProjectClaimToken", + "v1-get-project-config": "v1GetProjectConfig", "v1-get-project-disk-autoscale-config": "v1GetProjectDiskAutoscaleConfig", "v1-get-project-function-combined-stats": "v1GetProjectFunctionCombinedStats", "v1-get-project-legacy-api-keys": "v1GetProjectLegacyApiKeys", @@ -11979,6 +11996,20 @@ export const operationDefinitions = { inputSchema: V1GetProjectClaimTokenInput, outputSchema: V1GetProjectClaimTokenOutput, }, + v1GetProjectConfig: { + id: "v1GetProjectConfig", + description: + "Returns only the Auth and PostgREST fields that differ from their hosted defaults, as JSON shaped by the Supabase config schema. Credential fields are omitted. Defaults to the project itself (production); pass `branch` to target a preview branch instead.", + method: "GET", + path: "/v1/projects/{ref}/config", + pathParams: ["ref"], + queryParams: ["branch"], + headerParams: [], + requestBody: { kind: "none" }, + response: { kind: "json" }, + inputSchema: V1GetProjectConfigInput, + outputSchema: V1GetProjectConfigOutput, + }, v1GetProjectDiskAutoscaleConfig: { id: "v1GetProjectDiskAutoscaleConfig", description: "Gets project disk autoscale config", diff --git a/packages/api/src/generated/effect-client.ts b/packages/api/src/generated/effect-client.ts index f8651d8acf..f73d239531 100644 --- a/packages/api/src/generated/effect-client.ts +++ b/packages/api/src/generated/effect-client.ts @@ -1143,6 +1143,20 @@ export const versionedEffectOperations = { input, ); }), + getProjectConfig: ( + input: typeof operationDefinitions.v1GetProjectConfig.inputSchema.Type, + ): Effect.Effect< + typeof operationDefinitions.v1GetProjectConfig.outputSchema.Type, + SupabaseApiError, + SupabaseApiClient + > => + Effect.gen(function* () { + const client = yield* SupabaseApiClient; + return yield* client.execute<"v1GetProjectConfig">( + operationDefinitions.v1GetProjectConfig, + input, + ); + }), getProjectDiskAutoscaleConfig: ( input: typeof operationDefinitions.v1GetProjectDiskAutoscaleConfig.inputSchema.Type, ): Effect.Effect< @@ -2683,6 +2697,10 @@ export function executeApiClientOperation( return Schema.decodeUnknownEffect(operationDefinitions.v1GetProjectClaimToken.inputSchema)( input, ).pipe(Effect.flatMap((decoded) => api.v1.getProjectClaimToken(decoded))); + case "v1GetProjectConfig": + return Schema.decodeUnknownEffect(operationDefinitions.v1GetProjectConfig.inputSchema)( + input, + ).pipe(Effect.flatMap((decoded) => api.v1.getProjectConfig(decoded))); case "v1GetProjectDiskAutoscaleConfig": return Schema.decodeUnknownEffect( operationDefinitions.v1GetProjectDiskAutoscaleConfig.inputSchema, diff --git a/packages/api/src/generated/openapi.json b/packages/api/src/generated/openapi.json index 07f8909630..298a64e116 100644 --- a/packages/api/src/generated/openapi.json +++ b/packages/api/src/generated/openapi.json @@ -5009,6 +5009,76 @@ "x-oauth-scope": "projects:read" } }, + "/v1/projects/{ref}/config": { + "get": { + "description": "Returns only the Auth and PostgREST fields that differ from their hosted defaults, as JSON shaped by the Supabase config schema. Credential fields are omitted. Defaults to the project itself (production); pass `branch` to target a preview branch instead.", + "operationId": "v1-get-project-config", + "parameters": [ + { + "name": "ref", + "required": true, + "in": "path", + "description": "Project ref", + "schema": { + "minLength": 20, + "maxLength": 20, + "pattern": "^[a-z]+$", + "example": "abcdefghijklmnopqrst", + "type": "string" + } + }, + { + "name": "branch", + "required": false, + "in": "query", + "description": "Preview branch name. Defaults to the project itself (production) when omitted.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetProjectConfigResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden action" + }, + "429": { + "description": "Rate limit exceeded" + }, + "500": { + "description": "Failed to retrieve project's config" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "Gets a project's effective configuration", + "tags": ["Projects"], + "x-badges": [ + { + "name": "OAuth scope: auth:read", + "position": "after" + } + ], + "x-endpoint-owners": ["management-api", "dev-workflows"], + "x-fga-permissions": [["auth_config_read", "data_api_config_read"]], + "x-oauth-scope": "auth:read" + } + }, "/v1/projects/{ref}/config/auth/signing-keys/legacy": { "post": { "operationId": "v1-create-legacy-signing-key", @@ -17521,6 +17591,30 @@ "query": "select * from pg_stat_activity limit 1;" } }, + "GetProjectConfigResponse": { + "type": "object", + "properties": { + "auth": { + "type": "object", + "additionalProperties": {} + }, + "api": { + "type": "object", + "additionalProperties": {} + } + }, + "required": ["auth", "api"], + "example": { + "auth": { + "site_url": "https://example.com", + "enable_signup": false + }, + "api": { + "schemas": ["public", "storage"], + "max_rows": 500 + } + } + }, "GetProjectDbMetadataResponse": { "type": "object", "properties": {