Skip to content
300 changes: 150 additions & 150 deletions apps/cli/docs/go-cli-porting-status.md

Large diffs are not rendered by default.

9 changes: 8 additions & 1 deletion apps/cli/src/legacy/commands/config/push/push.command.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { Layer } from "effect";
import { Command, Flag } from "effect/unstable/cli";
import type * as CliCommand from "effect/unstable/cli/Command";

import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts";
import { legacyManagementApiRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts";
import { legacyPromptInputRuntimeLayer } from "../../../shared/legacy-prompt-input.layer.ts";
import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts";
import { legacyConfigPush } from "./push.handler.ts";

Expand Down Expand Up @@ -34,5 +36,10 @@ export const legacyConfigPushCommand = Command.make("push", config).pipe(
withJsonErrorHandling,
),
),
Command.provide(legacyManagementApiRuntimeLayer(["config", "push"])),
Command.provide(
Layer.mergeAll(
legacyManagementApiRuntimeLayer(["config", "push"]),
legacyPromptInputRuntimeLayer,
),
),
);
18 changes: 6 additions & 12 deletions apps/cli/src/legacy/commands/config/push/push.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { legacyResolveYes } from "../../../../shared/legacy/global-flags.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 { legacyPromptYesNo } from "../../../shared/legacy-prompt-yes-no.ts";
import { apiSubsetFromConfig, apiToUpdateBody, diffApiWithRemote } from "./config-sync/api.sync.ts";
import {
applyRemoteAuthConfig,
Expand Down Expand Up @@ -154,24 +155,17 @@ export const legacyConfigPush = Effect.fn("legacy.config.push")(function* (

yield* output.raw(`Pushing config to project: ${projectId}\n`, "stderr");

// keep(name): Go push.go `keep` + console.PromptYesNo(title, true).
const keep = (name: string): Effect.Effect<boolean> =>
// keep(name): Go push.go `keep` + console.PromptYesNo(title, true). The shared
// helper mirrors Go's prompt across all modes, including scanning piped stdin on
// a non-TTY before falling back to the default (`console.go:64-82`).
const keep = (name: string) =>
Effect.gen(function* () {
const item = cost.get(name);
const title =
item === undefined
? `Do you want to push ${name} config to remote?`
: `Enabling ${item.name} will cost you ${item.price}. Keep it enabled?`;
if (output.format !== "text") {
return true;
}
if (yes) {
yield* output.raw(`${title} [Y/n] y\n`, "stderr");
return true;
}
return yield* output
.promptConfirm(title, { defaultValue: true })
.pipe(Effect.orElseSucceed(() => true));
return yield* legacyPromptYesNo(output, yes, title, true);
});

const services: Array<LegacyConfigPushServiceResult> = [];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ import {
mockLegacyTelemetryStateTracked,
useLegacyTempWorkdir,
} from "../../../../../tests/helpers/legacy-mocks.ts";
import { mockRuntimeInfo } from "../../../../../tests/helpers/mocks.ts";
import { mockRuntimeInfo, mockTty } from "../../../../../tests/helpers/mocks.ts";
import { mockLegacyPromptInput } from "../../../../../tests/helpers/legacy-prompt-input.ts";
import { LegacyYesFlag } from "../../../../shared/legacy/global-flags.ts";
import { legacyConfigPush } from "./push.handler.ts";

Expand Down Expand Up @@ -57,6 +58,10 @@ function setup(opts: {
readonly yes?: boolean;
readonly confirm?: ReadonlyArray<boolean>;
readonly promptFail?: boolean;
/** stdin interactivity; defaults to a TTY so prompt-driven tests reach the confirm. */
readonly stdinIsTty?: boolean;
/** Piped (non-TTY) stdin answers, one consumed per confirmation prompt. */
readonly pipedAnswers?: ReadonlyArray<string>;
}) {
writeConfig(opts.toml);
const routes = opts.routes ?? {};
Expand Down Expand Up @@ -114,7 +119,9 @@ function setup(opts: {
runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }),
telemetry: telemetry.layer,
linkedProjectCache: linkedProjectCache.layer,
tty: mockTty({ stdinIsTty: opts.stdinIsTty ?? true, stdoutIsTty: false }),
}),
mockLegacyPromptInput({ pipedLines: opts.pipedAnswers }),
Layer.succeed(LegacyYesFlag, opts.yes ?? false),
);
return { layer, out, api, telemetry, linkedProjectCache };
Expand Down Expand Up @@ -296,10 +303,13 @@ project_id = "abcdefghijklmnopqrst"
}).pipe(Effect.provide(layer));
});

it.live("defaults to yes in non-TTY text without --yes", () => {
const { layer, api } = setup({
it.live("defaults to yes on empty non-TTY stdin, echoing the prompt", () => {
// Go's `PromptYesNo(..., true)` (`push.go:36`) prints the label and scans
// stdin even on a non-terminal (`console.go:96-102`); with no piped input the
// scan is empty and it falls back to the default (`true`), so the push proceeds.
const { layer, api, out } = setup({
toml: API_ONLY_TOML,
promptFail: true,
stdinIsTty: false,
routes: {
postgrestGet: { status: 200, body: POSTGREST_DISABLED },
postgresGet: { status: 200, body: {} },
Expand All @@ -310,6 +320,30 @@ project_id = "abcdefghijklmnopqrst"
expect(api.requests.some((r) => r.method === "PATCH" && r.url.includes("/postgrest"))).toBe(
true,
);
// Label printed + empty answer echoed (Go's non-TTY `PromptText`).
expect(out.stderrText).toContain("Do you want to push api config to remote? [Y/n] \n");
}).pipe(Effect.provide(layer));
});

it.live("honors a piped 'n' decline on non-TTY stdin (no update)", () => {
// Regression: Go scans piped stdin before defaulting (`console.go:74-82`), so a
// piped `n` cancels the push even on a non-terminal — it must not silently apply.
const { layer, api, out } = setup({
toml: API_ONLY_TOML,
stdinIsTty: false,
pipedAnswers: ["n"],
routes: {
postgrestGet: { status: 200, body: POSTGREST_DISABLED },
postgresGet: { status: 200, body: {} },
},
});
return Effect.gen(function* () {
yield* legacyConfigPush({ projectRef: Option.none() });
expect(api.requests.some((r) => r.method === "PATCH" && r.url.includes("/postgrest"))).toBe(
false,
);
// The consumed answer is echoed to stderr (Go's non-TTY `PromptText`).
expect(out.stderrText).toContain("Do you want to push api config to remote? [Y/n] n");
}).pipe(Effect.provide(layer));
});

Expand Down Expand Up @@ -394,6 +428,7 @@ file_size_limit = "50MiB"
cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }),
runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }),
}),
mockLegacyPromptInput(),
Layer.succeed(LegacyYesFlag, true),
);
return Effect.gen(function* () {
Expand Down Expand Up @@ -462,7 +497,10 @@ function setupService(opts: {
runtimeInfo: mockRuntimeInfo({ cwd: opts.runtimeCwd ?? tempRoot.current }),
telemetry: telemetry.layer,
linkedProjectCache: linkedProjectCache.layer,
// Gated-service prompts model an interactive user answering via `confirm`.
tty: mockTty({ stdinIsTty: true, stdoutIsTty: false }),
}),
mockLegacyPromptInput(),
Layer.succeed(LegacyYesFlag, opts.yes ?? false),
);
return { layer, out, apiMock };
Expand Down
59 changes: 18 additions & 41 deletions apps/cli/src/legacy/commands/db/dump/dump.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,13 @@ import type { LegacyDbConnType } from "../../../shared/legacy-db-target-flags.ts
import { legacyReadDbToml } from "../../../shared/legacy-db-config.toml-read.ts";
import { legacyReadProjectRefFile } from "../../../shared/legacy-temp-paths.ts";
import { legacyResolveDbImage } from "../../../shared/legacy-db-image.ts";
import { LegacyDockerRun } from "../../../shared/legacy-docker-run.service.ts";
import { legacyGetRegistryImageUrl } from "../../../shared/legacy-docker-registry.ts";
import {
legacyIpv6Suggestion,
legacyIsIPv6ConnectivityError,
} from "../../../shared/legacy-connect-errors.ts";
import { legacyBold, legacyYellow } from "../../../shared/legacy-colors.ts";
import {
LegacyDnsResolverFlag,
LegacyNetworkIdFlag,
} from "../../../../shared/legacy/global-flags.ts";
import { LegacyDnsResolverFlag } from "../../../../shared/legacy/global-flags.ts";
import { Output } from "../../../../shared/output/output.service.ts";
import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts";
import type { LegacyDbDumpFlags } from "./dump.command.ts";
import {
LegacyDbDumpMutuallyExclusiveFlagsError,
Expand All @@ -33,12 +27,13 @@ import {
legacyBuildRoleDumpEnv,
legacyBuildSchemaDumpEnv,
legacyExpandScript,
} from "./dump.env.ts";
} from "../shared/legacy-pg-dump.env.ts";
import { legacyStreamPgDump } from "../shared/legacy-pg-dump.run.ts";
import {
legacyDumpDataScript,
legacyDumpRoleScript,
legacyDumpSchemaScript,
} from "./dump.scripts.ts";
} from "../shared/legacy-pg-dump.scripts.ts";

/**
* Mutually-exclusive flag groups, in cobra's check order (it sorts the joined
Expand All @@ -62,15 +57,12 @@ const toOpenFileError = (cause: { readonly message: string }) =>
export const legacyDbDump = Effect.fn("legacy.db.dump")(function* (flags: LegacyDbDumpFlags) {
const output = yield* Output;
const resolver = yield* LegacyDbConfigResolver;
const docker = yield* LegacyDockerRun;
const cliConfig = yield* LegacyCliConfig;
const runtimeInfo = yield* RuntimeInfo;
const telemetryState = yield* LegacyTelemetryState;
const linkedProjectCache = yield* LegacyLinkedProjectCache;
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const dnsResolver = yield* LegacyDnsResolverFlag;
const networkIdFlag = yield* LegacyNetworkIdFlag;

// Resolved linked ref, captured so the post-run finalizer can cache the project
// (GET /v1/projects/{ref}) AFTER the command's own API calls — matching Go's
Expand Down Expand Up @@ -190,7 +182,7 @@ export const legacyDbDump = Effect.fn("legacy.db.dump")(function* (flags: Legacy
// remote `project_id`) fails rather than silently printing a script.
const tomlValues = yield* legacyReadDbToml(fs, path, cliConfig.workdir, linkedRef);

// 4. Pick the mode-specific script + env (pure builders, `dump.env.ts`).
// 4. Pick the mode-specific script + env (pure builders, `legacy-pg-dump.env.ts`).
// Go declares --schema/-s and --exclude/-x as cobra StringSlice
// (`apps/cli-go/cmd/db.go:432,444`); both flags are CSV-parsed at the flag
// level via `legacyParseSchemaFlags` (pflag `readAsCSV` semantics, quoted
Expand Down Expand Up @@ -267,33 +259,14 @@ export const legacyDbDump = Effect.fn("legacy.db.dump")(function* (flags: Legacy
// 6. Diagnostic to stderr (Go writes this for both real and dry-run paths).
yield* output.raw(`Dumping ${mode.verb} from ${db} database...\n`, "stderr");

// 7. Run the pg_dump container, capturing stdout. dump always uses host
// networking (`dockerExec` sets `NetworkMode: NetworkHost`), overridden only
// by `--network-id` (Go's `DockerStart`). No `SecurityOpt` is set.
const networkId = Option.getOrUndefined(networkIdFlag);
const network =
networkId !== undefined && networkId.length > 0
? { _tag: "named" as const, name: networkId }
: { _tag: "host" as const };
const extraHosts =
runtimeInfo.platform === "linux" ? ["host.docker.internal:host-gateway"] : [];

const dockerOpts = (env: Readonly<Record<string, string>>) => ({
image: legacyGetRegistryImageUrl(image),
cmd: ["bash", "-c", mode.script, "--"],
env,
binds: [],
workingDir: Option.none(),
securityOpt: [],
extraHosts,
network,
});

// 7. Run the pg_dump container, streaming stdout. `legacyStreamPgDump` applies
// the registry mirror + host networking (overridden by `--network-id`) and
// tees stderr, mirroring Go's `dockerExec` (`internal/db/dump/dump.go`).
//
// Go streams pg_dump stdout straight to the destination sink (the `--file` handle
// or `os.Stdout`) via `stdcopy.StdCopy` with `Follow:true`, at constant memory
// (`apps/cli-go/internal/utils/docker.go:374,394`). Mirror that: write each chunk
// to the destination as it arrives instead of buffering the whole dump. stderr is
// teed live (Go's `io.MultiWriter(os.Stderr, errBuf)`).
// to the destination as it arrives instead of buffering the whole dump.
const runContainer = (env: Readonly<Record<string, string>>) =>
Option.isSome(resolvedFile)
? // `--file`: (re)truncate then append-stream. Truncating per attempt
Expand All @@ -309,10 +282,12 @@ export const legacyDbDump = Effect.fn("legacy.db.dump")(function* (flags: Legacy
const file = yield* fs
.open(resolvedFile.value, { flag: "a" })
.pipe(Effect.mapError(toOpenFileError));
return yield* docker.runStream(dockerOpts(env), {
return yield* legacyStreamPgDump({
image,
script: mode.script,
env,
onStdout: (chunk) =>
file.writeAll(chunk).pipe(Effect.mapError(toOpenFileError)),
teeStderr: true,
});
}),
),
Expand All @@ -321,9 +296,11 @@ export const legacyDbDump = Effect.fn("legacy.db.dump")(function* (flags: Legacy
: // stdout: write each chunk straight to stdout (binary-safe, no decode).
// On a pooler retry Go leaves the partial first-attempt bytes on stdout
// (its `resetOutput` can't rewind a pipe); streaming matches that.
docker.runStream(dockerOpts(env), {
legacyStreamPgDump({
image,
script: mode.script,
env,
onStdout: (chunk) => output.rawBytes(chunk),
teeStderr: true,
});

let result = yield* runContainer(modeEnv);
Expand Down
46 changes: 46 additions & 0 deletions apps/cli/src/legacy/commands/db/dump/dump.live.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { existsSync, mkdtempSync, rmSync } 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";

const LIVE_TIMEOUT_MS = 300_000;

// A fresh, isolated temp workdir so the CLI writes the dump there and never touches
// the repo tree. The provisioned project ref is supplied to `--linked` via the
// `SUPABASE_PROJECT_ID` env var — that is the `--linked` resolver chain in both Go
// and the legacy port (flag → `SUPABASE_PROJECT_ID` → `supabase/.temp/project-ref`);
// `config.toml`'s `project_id` is NOT consulted for `--linked`.
function tempWorkdir(): string {
return mkdtempSync(join(tmpdir(), "sb-db-dump-live-"));
}

// Project-scoped + data-plane: needs a provisioned project whose database is
// routable (the cli-e2e-ci Linux runner). Skipped on a control-plane-only stack
// (`SUPABASE_LIVE_PROJECT_REF` unset), e.g. local macOS.
describeLiveProject("supabase db dump (live)", () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Gate DB live tests on data-plane readiness

In a live environment that sets SUPABASE_LIVE_PROJECT_REF but the project's Postgres instance is not ACTIVE_HEALTHY, describeLiveProject still runs this suite; tests/helpers/live-env.ts documents that this is the current cli-e2e-ci control-plane-only case and that db/migration/storage suites should skip via describeLiveDataPlane. As written, this new db dump live test (and the matching db pull live test) will attempt pg_dump/db connections against an unreachable project database and fail or time out instead of being skipped.

Useful? React with 👍 / 👎.

test("dumps the linked project's schema to a file", { timeout: LIVE_TIMEOUT_MS }, async () => {
const ref = requireLiveProjectRef();
const dir = tempWorkdir();
try {
const outFile = join(dir, "schema.sql");
const { exitCode, stdout, stderr } = await runSupabaseLive(
["db", "dump", "--linked", "-f", outFile],
{ cwd: dir, env: { SUPABASE_PROJECT_ID: ref }, exitTimeoutMs: LIVE_TIMEOUT_MS - 20_000 },
);
expect(`${stdout}${stderr}`).not.toContain("Unauthorized");
expect(exitCode).toBe(0);
// The native pg_dump container (shared `legacyStreamPgDump`) opened + wrote
// the dump file. A fresh project's public schema may be near-empty, so assert
// the file was created rather than its size.
expect(existsSync(outFile)).toBe(true);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
Loading
Loading