diff --git a/apps/cli/src/legacy/commands/db/dump/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/dump/SIDE_EFFECTS.md index fe226a36f7..c39951ea6b 100644 --- a/apps/cli/src/legacy/commands/db/dump/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/dump/SIDE_EFFECTS.md @@ -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 @@ -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` diff --git a/apps/cli/src/legacy/commands/db/dump/dump.handler.ts b/apps/cli/src/legacy/commands/db/dump/dump.handler.ts index ab2177dca2..24159646eb 100644 --- a/apps/cli/src/legacy/commands/db/dump/dump.handler.ts +++ b/apps/cli/src/legacy/commands/db/dump/dump.handler.ts @@ -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"; @@ -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. @@ -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"; + let sawNonAscii = false; + // Open (create + truncate) the output file up front so an unwritable // `--file` path fails before the dump runs. if (Option.isSome(resolvedFile)) { @@ -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; + } + return output.rawBytes(chunk); + }) + : (chunk) => output.rawBytes(chunk), projectEnvValues: projectEnv, }); @@ -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 diff --git a/apps/cli/src/legacy/commands/db/dump/dump.integration.test.ts b/apps/cli/src/legacy/commands/db/dump/dump.integration.test.ts index b24b059046..9e399ceb22 100644 --- a/apps/cli/src/legacy/commands/db/dump/dump.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/dump/dump.integration.test.ts @@ -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, @@ -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"; @@ -248,6 +251,9 @@ interface SetupOpts { resolveFails?: boolean; ref?: string; linkedFails?: boolean; + platform?: NodeJS.Platform; + stdoutIsPipe?: boolean; + env?: Readonly>; } function setup(opts: SetupOpts = {}) { @@ -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), @@ -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]> = [ + ["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)); + }); + } }); diff --git a/apps/cli/src/shared/runtime/tty.layer.ts b/apps/cli/src/shared/runtime/tty.layer.ts index 609c8464db..f174015e70 100644 --- a/apps/cli/src/shared/runtime/tty.layer.ts +++ b/apps/cli/src/shared/runtime/tty.layer.ts @@ -1,3 +1,4 @@ +import { fstatSync } from "node:fs"; import process from "node:process"; import { Layer } from "effect"; @@ -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; + } + })(), }), ); diff --git a/apps/cli/src/shared/runtime/tty.service.ts b/apps/cli/src/shared/runtime/tty.service.ts index 71eb678225..aa1e3373fc 100644 --- a/apps/cli/src/shared/runtime/tty.service.ts +++ b/apps/cli/src/shared/runtime/tty.service.ts @@ -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()("supabase/runtime/Tty") {} diff --git a/apps/cli/tests/helpers/mocks.ts b/apps/cli/tests/helpers/mocks.ts index 6e766d28c2..3037c551eb 100644 --- a/apps/cli/tests/helpers/mocks.ts +++ b/apps/cli/tests/helpers/mocks.ts @@ -140,11 +140,13 @@ export function mockTty( opts: { stdinIsTty?: boolean; stdoutIsTty?: boolean; + stdoutIsPipe?: boolean; } = {}, ): Layer.Layer { return Layer.succeed(Tty, { stdinIsTty: opts.stdinIsTty ?? false, stdoutIsTty: opts.stdoutIsTty ?? false, + stdoutIsPipe: opts.stdoutIsPipe ?? false, }); }