Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/cli/docs/go-cli-porting-status.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <route> [--method <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

Expand Down
3 changes: 2 additions & 1 deletion apps/cli/src/legacy/commands/config/config.command.ts
Original file line number Diff line number Diff line change
@@ -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]),
);
61 changes: 61 additions & 0 deletions apps/cli/src/legacy/commands/config/pull/SIDE_EFFECTS.md
Original file line number Diff line number Diff line change
@@ -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 |
| ---------------------------------------------- | ---------- | ----------------------------------------------------------- |
| `<workdir>/supabase/config.toml` or `.json` | TOML/JSON | Before the Management API request |
| `<workdir>/supabase/.env` and `.env.local` | dotenv | While resolving `env(...)` references in project config |
| `<workdir>/supabase/.temp/project-ref` | plain text | When no project reference is set through flags or the shell |
| `<workdir>/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 |
| ---------------------------------------------- | ------ | ------------------------------------------------------- |
| `<workdir>/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.
35 changes: 35 additions & 0 deletions apps/cli/src/legacy/commands/config/pull/pull.command.ts
Original file line number Diff line number Diff line change
@@ -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<typeof config>;

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"])),
);
31 changes: 31 additions & 0 deletions apps/cli/src/legacy/commands/config/pull/pull.errors.ts
Original file line number Diff line number Diff line change
@@ -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;
}> {}
162 changes: 162 additions & 0 deletions apps/cli/src/legacy/commands/config/pull/pull.handler.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>> {
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<string> = [],
): ReadonlyArray<LegacyConfigChange> {
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 "<unset>";
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));
});
Loading