Skip to content
Merged
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
10 changes: 10 additions & 0 deletions apps/cli/src/legacy/commands/db/dump/SIDE_EFFECTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ script run inside the local Postgres image to stdout or `--file`.
| `SUPABASE_INTERNAL_IMAGE_REGISTRY` | rewrite the pg image registry |
| `SUPABASE_USE_SLIM_IMAGES` | resolve the current Postgres pin from the slim `ghcr.io/supabase/cli` builds (`true`/`1` enable); majors 13/15 use `15.14.1.167` when the flag is on; historical pins, PG14, OrioleDB, and flag-off `15.8.1.085` stay on docker.io |
| `DOCKER_HOST` | docker daemon endpoint |
| `MSYSTEM`, `TERM_PROGRAM` | suppress the piped-stdout non-ASCII warning in MSYS/mintty sessions |

## Exit Codes

Expand All @@ -62,6 +63,15 @@ On a linked dump whose container fails with an IPv6 connectivity error (no IPv4
pooler retry available, or the retry also fails), the error is followed on stderr by
the IPv4 transaction-pooler suggestion.

On Windows only, a **successful** stdout dump (no `--file`) whose stdout is a
**pipe** (PowerShell interposes one for `>`/`|`; MSYS/mintty sessions excluded
via `MSYSTEM`/`TERM_PROGRAM`) and whose bytes contain non-ASCII is followed by a
stderr warning pointing at `--file` — PowerShell 5.1 re-encodes piped native
stdout with the legacy console code page (issue #6397), which the CLI cannot
prevent. The gate is a best-effort heuristic: a pipe to a byte-faithful reader
(e.g. cmd.exe `a | b`) still warns, and a PowerShell launched from an MSYS
shell inherits the suppressing variables and is missed.

> **Credential warning:** `--dry-run` expands the pg_dump script with live env
> values, so the resolved `PGPASSWORD` (for a remote/linked project, the database
> password) is printed **in cleartext** to stdout. Operators piping `--dry-run`
Expand Down
37 changes: 35 additions & 2 deletions apps/cli/src/legacy/commands/db/dump/dump.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@ import {
legacyIpv6Suggestion,
legacyIsIPv6ConnectivityError,
} from "../../../shared/legacy-connect-errors.ts";
import { legacyBold } from "../../../shared/legacy-colors.ts";
import { legacyBold, legacyYellow } from "../../../shared/legacy-colors.ts";
import { LegacyDnsResolverFlag } from "../../../../shared/legacy/global-flags.ts";
import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts";
import { Tty } from "../../../../shared/runtime/tty.service.ts";
import { cobraMutuallyExclusiveErrorMessage } from "../../../../shared/cli/cobra-flag-groups.ts";
import { Output } from "../../../../shared/output/output.service.ts";
import type { LegacyDbDumpFlags } from "./dump.command.ts";
Expand Down Expand Up @@ -69,6 +71,8 @@ export const legacyDbDump = Effect.fn("legacy.db.dump")(function* (flags: Legacy
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const dnsResolver = yield* LegacyDnsResolverFlag;
const tty = yield* Tty;
const runtimeInfo = yield* RuntimeInfo;

// Resolved linked ref, captured so the post-run finalizer can cache the project
// (GET /v1/projects/{ref}) AFTER the command's own API calls.
Expand Down Expand Up @@ -266,6 +270,17 @@ export const legacyDbDump = Effect.fn("legacy.db.dump")(function* (flags: Legacy
// paths unchanged.
const resolvedFile = Option.map(fileFlag, (file) => path.resolve(cliSettings.workdir, file));

// PowerShell interposes a pipe for `>`/`|` and re-encodes what it reads with
// the legacy console code page, mangling multi-byte UTF-8 (#6397); TTYs,
// disk-file handles, and MSYS/mintty pipes are byte-faithful and never warn.
const trackNonAscii =
runtimeInfo.platform === "win32" &&
tty.stdoutIsPipe &&
Option.isNone(resolvedFile) &&
(process.env["MSYSTEM"] ?? "") === "" &&
process.env["TERM_PROGRAM"] !== "mintty";
Comment thread
7ttp marked this conversation as resolved.
let sawNonAscii = false;
Comment thread
7ttp marked this conversation as resolved.

// Open (create + truncate) the output file up front so an unwritable
// `--file` path fails before the dump runs.
if (Option.isSome(resolvedFile)) {
Expand Down Expand Up @@ -318,7 +333,15 @@ export const legacyDbDump = Effect.fn("legacy.db.dump")(function* (flags: Legacy
image,
script: mode.script,
env,
onStdout: (chunk) => output.rawBytes(chunk),
onStdout: trackNonAscii
? (chunk) =>
Effect.suspend(() => {
for (let i = 0; !sawNonAscii && i < chunk.length; i += 1) {
if (chunk[i]! > 0x7f) sawNonAscii = true;
}
Comment thread
7ttp marked this conversation as resolved.
return output.rawBytes(chunk);
})
: (chunk) => output.rawBytes(chunk),
projectEnvValues: projectEnv,
});

Expand Down Expand Up @@ -378,6 +401,16 @@ export const legacyDbDump = Effect.fn("legacy.db.dump")(function* (flags: Legacy
if (Option.isSome(resolvedFile)) {
yield* output.raw(`Dumped schema to ${legacyBold(resolvedFile.value)}.\n`, "stderr");
}

if (sawNonAscii) {
yield* output.raw(
`${legacyYellow("WARNING:")} The dump contains non-ASCII characters. ` +
"Some Windows shells (notably Windows PowerShell 5.1) corrupt them when redirecting " +
"or piping output. If the result looks garbled, re-run with --file (e.g. -f dump.sql) " +
"to write the dump directly to disk.\n",
"stderr",
);
}
}).pipe(
// Cache the linked project (telemetry groups) in post-run, after the
// command's own API calls, then flush telemetry. The cache layer no-ops
Expand Down
115 changes: 104 additions & 11 deletions apps/cli/src/legacy/commands/db/dump/dump.integration.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { spawnSync } from "node:child_process";
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, writeFileSync } from "node:fs";
import process from "node:process";
import { join } from "node:path";
import { BunServices } from "@effect/platform-bun";
import { describe, expect, it } from "@effect/vitest";
import { Cause, Effect, Exit, Layer, Option } from "effect";

import { mockOutput } from "../../../../../tests/helpers/mocks.ts";
import { mockOutput, mockTty, processEnvLayer } from "../../../../../tests/helpers/mocks.ts";
import {
LEGACY_VALID_REF,
mockLegacyCliSettings,
Expand Down Expand Up @@ -222,14 +224,15 @@ function mockDockerRun(opts: {
};
}

const runtimeInfoLayer = Layer.succeed(RuntimeInfo, {
cwd: "/work/project",
platform: "linux",
arch: "x64",
homeDir: "/home/user",
execPath: "/usr/bin/supabase",
pid: 1234,
});
const runtimeInfoLayer = (platform: NodeJS.Platform) =>
Layer.succeed(RuntimeInfo, {
cwd: "/work/project",
platform,
arch: "x64",
homeDir: "/home/user",
execPath: "/usr/bin/supabase",
pid: 1234,
});

interface SetupOpts {
format?: "text" | "json" | "stream-json";
Expand All @@ -248,6 +251,9 @@ interface SetupOpts {
resolveFails?: boolean;
ref?: string;
linkedFails?: boolean;
platform?: NodeJS.Platform;
stdoutIsPipe?: boolean;
env?: Readonly<Record<string, string>>;
}

function setup(opts: SetupOpts = {}) {
Expand Down Expand Up @@ -279,7 +285,9 @@ function setup(opts: SetupOpts = {}) {
}),
telemetry.layer,
cache.layer,
runtimeInfoLayer,
runtimeInfoLayer(opts.platform ?? "linux"),
mockTty({ stdoutIsPipe: opts.stdoutIsPipe }),
processEnvLayer(opts.env ?? {}),
Layer.succeed(
LegacyNetworkIdFlag,
opts.networkId === undefined ? Option.none() : Option.some(opts.networkId),
Expand Down Expand Up @@ -941,4 +949,89 @@ describe("legacy db dump integration", () => {
expect(out.stdoutText).toBe("CREATE SCHEMA x;\n");
}).pipe(Effect.provide(layer));
});

const UNICODE_SQL = "insert into t values ('Oranges \u{1F34A}', 'd\u00f6Terra');\n";
const NON_ASCII_WARNING = "The dump contains non-ASCII characters";
const PIPED_WIN32 = { platform: "win32", stdoutIsPipe: true } as const;

// Real-runtime probe of the classification `ttyLayer` ships for
// `stdoutIsPipe`. A shell pipeline is used for the pipe case: spawnSync's
// own "pipe" stdio is a socketpair under Bun, which fstats as a socket.
const PROBE = 'process.stdout.write(String(require("node:fs").fstatSync(1).isFIFO()));';

it.skipIf(process.platform === "win32")("classifies a real piped stdout as a pipe", () => {
const result = spawnSync("/bin/sh", ["-c", `"${process.execPath}" -e '${PROBE}' | cat`], {
encoding: "utf8",
});
expect(result.status).toBe(0);
expect(result.stdout).toBe("true");
});

it.skipIf(process.platform === "win32")("classifies a file-backed stdout as not a pipe", () => {
const file = join(tmp.current, "pipe-probe.txt");
const fd = openSync(file, "w");
try {
const result = spawnSync(process.execPath, ["-e", PROBE], {
stdio: ["ignore", fd, "inherit"],
});
expect(result.status).toBe(0);
} finally {
closeSync(fd);
}
expect(readFileSync(file, "utf8")).toBe("false");
});

it.live("windows: warns when a piped stdout dump contains non-ASCII text", () => {
const { layer, out } = setup({
isLocal: true,
stdout: UNICODE_SQL,
...PIPED_WIN32,
env: { MSYSTEM: "" },
});
return Effect.gen(function* () {
yield* legacyDbDump(flags({ local: Option.some(true) }));
expect(out.stdoutText).toBe(UNICODE_SQL);
expect(out.stderrText).toContain("WARNING:");
expect(out.stderrText).toContain(NON_ASCII_WARNING);
expect(out.stderrText).toContain("re-run with --file");
}).pipe(Effect.provide(layer));
});

it.live("stays silent when a piped Windows dump writes to --file", () => {
const { layer, out } = setup({
isLocal: true,
stdout: UNICODE_SQL,
...PIPED_WIN32,
workdir: tmp.current,
});
return Effect.gen(function* () {
yield* legacyDbDump(flags({ local: Option.some(true), file: Option.some("out.sql") }));
expect(readFileSync(join(tmp.current, "out.sql"), "utf8")).toBe(UNICODE_SQL);
expect(out.stderrText).not.toContain(NON_ASCII_WARNING);
}).pipe(Effect.provide(layer));
});

const SILENT: ReadonlyArray<[string, Partial<SetupOpts>]> = [
["on non-Windows platforms", { stdoutIsPipe: true }],
[
"when stdout is not a pipe (TTY, or cmd.exe / Git Bash `>` file handle)",
{ platform: "win32" },
],
[
"in a Git Bash / MSYS session (byte-faithful mintty pipe)",
{ ...PIPED_WIN32, env: { MSYSTEM: "MINGW64" } },
],
["under a mintty terminal outside MSYS", { ...PIPED_WIN32, env: { TERM_PROGRAM: "mintty" } }],
["when the dump is ASCII-only", { ...PIPED_WIN32, stdout: "select 'plain \x7f';\n" }],
];
for (const [scenario, over] of SILENT) {
it.live(`stays silent ${scenario}`, () => {
const { layer, out } = setup({ isLocal: true, stdout: UNICODE_SQL, ...over });
return Effect.gen(function* () {
yield* legacyDbDump(flags({ local: Option.some(true) }));
expect(out.stdoutText).toBe(over.stdout ?? UNICODE_SQL);
expect(out.stderrText).not.toContain(NON_ASCII_WARNING);
}).pipe(Effect.provide(layer));
});
}
});
8 changes: 8 additions & 0 deletions apps/cli/src/shared/runtime/tty.layer.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { fstatSync } from "node:fs";
import process from "node:process";
import { Layer } from "effect";

Expand All @@ -7,5 +8,12 @@ export const ttyLayer = Layer.sync(Tty, () =>
Tty.of({
stdinIsTty: !!process.stdin.isTTY,
stdoutIsTty: !!process.stdout.isTTY,
stdoutIsPipe: (() => {
try {
return fstatSync(1).isFIFO();
} catch {
return false;
}
})(),
Comment thread
7ttp marked this conversation as resolved.
}),
);
6 changes: 6 additions & 0 deletions apps/cli/src/shared/runtime/tty.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ import { Context } from "effect";
interface TtyShape {
readonly stdinIsTty: boolean;
readonly stdoutIsTty: boolean;
/**
* fd 1 is a FIFO per fstat — the signal for the Windows piped-dump warning
* (PowerShell `>`/`|` interpose one). False on fstat errors and for non-FIFO
* channels such as the socketpairs Node/Bun parents use for "pipe" stdio.
*/
readonly stdoutIsPipe: boolean;
}

export class Tty extends Context.Service<Tty, TtyShape>()("supabase/runtime/Tty") {}
2 changes: 2 additions & 0 deletions apps/cli/tests/helpers/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,11 +140,13 @@ export function mockTty(
opts: {
stdinIsTty?: boolean;
stdoutIsTty?: boolean;
stdoutIsPipe?: boolean;
} = {},
): Layer.Layer<Tty> {
return Layer.succeed(Tty, {
stdinIsTty: opts.stdinIsTty ?? false,
stdoutIsTty: opts.stdoutIsTty ?? false,
stdoutIsPipe: opts.stdoutIsPipe ?? false,
});
}

Expand Down
Loading