Skip to content

Commit bd39bcf

Browse files
authored
fix(cli): preserve flag/env channel when delegating to the Go proxy (#5546)
## What changed Ported legacy commands delegate to the bundled Go binary ("the proxy") via `LegacyGoProxy.exec`. The proxy must be called **1:1 with the user's input channel**: a value supplied as a CLI flag must reach the proxy as a flag, and a value supplied as an environment variable must reach the proxy as an environment variable. Neither should be cross-mapped. `bootstrap` violated this for the DB password. It resolved the password from `--password`, the `SUPABASE_DB_PASSWORD` env var, **or** an interactive prompt, then always re-emitted it to the delegated `db push` subprocess as a `--password` flag — mapping an env-sourced value onto a flag. ## Why this is the correct mapping In Go, bootstrap's `-p` flag is `BindPFlag`'d to viper `DB_PASSWORD` (`cmd/bootstrap.go:67`); `create.Run` funnels the resolved value into the same viper key (`create.go:31`); and `db push` reads it back via `viper.GetString("DB_PASSWORD")` (`flags/db_url.go:128`). With `SetEnvPrefix("SUPABASE") + AutomaticEnv()` (`root.go:320-322`), the standalone `db push` subprocess resolves `DB_PASSWORD` from **either** its own `-p` flag **or** the `SUPABASE_DB_PASSWORD` env var. So the faithful mapping is flag→flag, env→env. ## Changes - **`go-proxy.service.ts` / `go-proxy.layer.ts`** — add an optional per-call `{ env }` overlay to `LegacyGoProxy.exec`, merged over the construction-time env on top of the inherited process env (`extendEnv: true`). Backward-compatible; all other call sites are unchanged. - **`bootstrap.handler.ts`** (step L) — `--password` flag is forwarded as `--password` (flag→flag); an env- or prompt-sourced password is forwarded as the `SUPABASE_DB_PASSWORD` env var (env→env). - **`bootstrap.integration.test.ts`** — the proxy mock now captures env; the existing flag test asserts flag-only, plus two new tests cover the env-source and prompt-source channels. - **`SIDE_EFFECTS.md`** — documents the channel-preserving behavior of the delegated `db push`. ## Audit An inventory of all ~74 proxy handlers under `apps/cli/src/legacy/commands/` found `bootstrap`'s password to be the **only** cross-mapping instance. Other commands that forward `--password` only do so from their own `--password` flag (flag→flag), none write `process.env` before `exec`, and global flags in `legacy/cli/root.ts` are all flag→flag. No other command needed changes. Resolves CLI-1617.
1 parent 9f4047d commit bd39bcf

5 files changed

Lines changed: 138 additions & 20 deletions

File tree

apps/cli/src/legacy/commands/bootstrap/SIDE_EFFECTS.md

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -97,17 +97,20 @@ Human banners are suppressed; a single structured result is emitted:
9797
## Notes
9898

9999
- **Interim Go-proxy delegation for migration push.** The push step shells out to the bundled
100-
Go binary (`db push --include-roles --include-seed [--password …]`) until `db push` gets its
101-
own native port (separate Linear issue). The sub-step is **not** instrumentation-wrapped (the
102-
subprocess fires its own push telemetry). Known divergence: `LegacyGoProxy.exec` propagates the
103-
exit code, so Go's push backoff is **not** reproduced (single attempt) — to be restored when
104-
`db push` is natively ported. (`LegacyGoProxy.exec` exits the process on a non-zero exit rather
105-
than returning a failure, so the step cannot be wrapped in `Effect.retry`.)
106-
- **Interim credential exposure.** Because the push step runs in a subprocess, the DB password is
107-
passed as `--password <value>` and is therefore briefly visible in the OS process table for the
108-
lifetime of that subprocess (Go runs `push.Run` in-process and never exposes it). The same
109-
password is already written in plaintext to `<workdir>/.env` in the same directory, so the
110-
incremental exposure is small; it is eliminated when `db push` is natively ported (no subprocess).
100+
Go binary (`db push --include-roles --include-seed`) until `db push` gets its own native port
101+
(separate Linear issue). The sub-step is **not** instrumentation-wrapped (the subprocess fires
102+
its own push telemetry). Known divergence: `LegacyGoProxy.exec` propagates the exit code, so Go's
103+
push backoff is **not** reproduced (single attempt) — to be restored when `db push` is natively
104+
ported. (`LegacyGoProxy.exec` exits the process on a non-zero exit rather than returning a
105+
failure, so the step cannot be wrapped in `Effect.retry`.)
106+
- **DB password is forwarded on the same channel the user supplied it (CLI-1617).** The proxy must
107+
be called 1:1 with the user's input: a flag stays a flag, an env var stays an env var. So when the
108+
user passed `-p/--password`, the push sub-step receives `--password <value>` (flag → flag); when
109+
the password came from the `SUPABASE_DB_PASSWORD` env var **or** the interactive prompt, it is
110+
forwarded as the `SUPABASE_DB_PASSWORD` env var instead (env → env), matching Go, which binds `-p`
111+
to viper `DB_PASSWORD` and reads it back from viper in `db push`. A consequence is that an
112+
env-/prompt-sourced password is no longer placed in the OS process table; only an explicit
113+
`--password` flag is (the same password is already written in plaintext to `<workdir>/.env`).
111114
- The api-keys and health retries use the full Go `utils.NewBackoffPolicy` policy: exponential
112115
backoff, 3s initial interval, multiplier 1.5, 60s max interval (capped before jitter), ±50% jitter
113116
(randomization factor 0.5), 15m max-elapsed cap, and 8 retries (9 total attempts). The per-attempt

apps/cli/src/legacy/commands/bootstrap/bootstrap.handler.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -253,9 +253,33 @@ export const legacyBootstrap = Effect.fn("legacy.bootstrap")(function* (
253253
// No instrumentation wrap: the subprocess fires its own push telemetry.
254254
// `bootstrap.go:122-127` -> push.Run(..., includeRoles, includeSeed) =>
255255
// `--include-roles --include-seed` (no `--include-all`).
256+
//
257+
// Channel parity (CLI-1617): the proxy must be called 1:1 with the user's
258+
// input — a flag stays a flag, an env var stays an env var. Go binds
259+
// bootstrap's `-p` to viper `DB_PASSWORD` and `db push` reads it from viper
260+
// (== the `SUPABASE_DB_PASSWORD` env var for the subprocess), so only a
261+
// flag-sourced password travels as `--password`; an env-/prompt-sourced one
262+
// travels as the env var.
263+
//
264+
// The flag branch keys on a *non-empty* flag value: an explicit `--password ""`
265+
// (e.g. an unset `$SUPABASE_DB_PASSWORD` expanded by the shell) leaves viper
266+
// `DB_PASSWORD` empty in Go too, so `create.promptMissingParams` prompts and
267+
// `viper.Set`s the resolved value — which in-process `db push` then reads.
268+
// Forwarding the literal empty flag would lose that prompted password, so an
269+
// empty flag falls through to the resolved `created.dbPassword` (which carries
270+
// the env- or prompt-sourced value) on the env channel.
256271
const pushArgs = ["db", "push", "--include-roles", "--include-seed"];
257-
if (created.dbPassword.length > 0) pushArgs.push("--password", created.dbPassword);
258-
yield* proxy.exec(pushArgs);
272+
if (Option.isSome(flags.password) && flags.password.value.length > 0) {
273+
pushArgs.push("--password", flags.password.value);
274+
yield* proxy.exec(pushArgs);
275+
} else {
276+
yield* proxy.exec(
277+
pushArgs,
278+
created.dbPassword.length > 0
279+
? { env: { SUPABASE_DB_PASSWORD: created.dbPassword } }
280+
: undefined,
281+
);
282+
}
259283

260284
// M. Start suggestion. `bootstrap.go:128-130`.
261285
if (isText) {

apps/cli/src/legacy/commands/bootstrap/bootstrap.integration.test.ts

Lines changed: 85 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -80,13 +80,15 @@ interface SetupOpts {
8080
readonly health?: { readonly status: number; readonly body: unknown };
8181
readonly promptTextResponses?: ReadonlyArray<string>;
8282
readonly promptConfirmResponses?: ReadonlyArray<boolean>;
83+
readonly promptPasswordResponses?: ReadonlyArray<string>;
8384
}
8485

8586
function setup(opts: SetupOpts = {}) {
8687
const out = mockOutput({
8788
format: opts.format ?? "text",
8889
promptTextResponses: opts.promptTextResponses,
8990
promptConfirmResponses: opts.promptConfirmResponses,
91+
promptPasswordResponses: opts.promptPasswordResponses,
9092
});
9193
const telemetry = mockLegacyTelemetryStateTracked();
9294
const linkedCache = mockLegacyLinkedProjectCacheTracked();
@@ -136,11 +138,11 @@ function setup(opts: SetupOpts = {}) {
136138
}),
137139
});
138140

139-
const proxyCalls: Array<ReadonlyArray<string>> = [];
141+
const proxyCalls: Array<{ args: ReadonlyArray<string>; env?: Record<string, string> }> = [];
140142
const proxyLayer = Layer.succeed(LegacyGoProxy, {
141-
exec: (args: ReadonlyArray<string>) =>
143+
exec: (args: ReadonlyArray<string>, execOpts?: { env?: Record<string, string> }) =>
142144
Effect.sync(() => {
143-
proxyCalls.push(args);
145+
proxyCalls.push({ args, env: execOpts?.env });
144146
}),
145147
});
146148

@@ -394,25 +396,103 @@ describe("legacy bootstrap integration", () => {
394396
}).pipe(Effect.provide(s.layer));
395397
});
396398

397-
it.live("delegates the db push step to the Go proxy with the resolved password", () => {
399+
it.live("forwards a --password flag to the Go proxy as a flag (flag stays a flag)", () => {
398400
const s = setup();
399401
return Effect.gen(function* () {
400402
yield* legacyBootstrap(
401403
flags({ template: Option.some("scratch"), password: Option.some("pw123") }),
402404
FAST_BACKOFF,
403405
);
404406
expect(s.proxyCalls).toHaveLength(1);
405-
expect(s.proxyCalls[0]).toEqual([
407+
// Flag-sourced password travels as a flag, never re-mapped to an env var.
408+
expect(s.proxyCalls[0]?.args).toEqual([
406409
"db",
407410
"push",
408411
"--include-roles",
409412
"--include-seed",
410413
"--password",
411414
"pw123",
412415
]);
416+
expect(s.proxyCalls[0]?.env).toBeUndefined();
413417
}).pipe(Effect.provide(s.layer));
414418
});
415419

420+
it.live("forwards the prompted password (not the empty flag) when --password is empty", () => {
421+
// An explicit `--password ""` (e.g. unset `$SUPABASE_DB_PASSWORD` expanded by
422+
// the shell) leaves the password empty, so the create step prompts. Go reads
423+
// the prompted value from viper for the in-process push, so the subprocess
424+
// must receive the prompted password — never the literal empty flag value.
425+
const s = setup({ promptPasswordResponses: ["prompted-pw"] });
426+
const prev = process.env["SUPABASE_DB_PASSWORD"];
427+
delete process.env["SUPABASE_DB_PASSWORD"];
428+
return Effect.gen(function* () {
429+
yield* legacyBootstrap(
430+
flags({ template: Option.some("scratch"), password: Option.some("") }),
431+
FAST_BACKOFF,
432+
);
433+
expect(s.proxyCalls).toHaveLength(1);
434+
expect(s.proxyCalls[0]?.args).toEqual(["db", "push", "--include-roles", "--include-seed"]);
435+
expect(s.proxyCalls[0]?.env).toEqual({ SUPABASE_DB_PASSWORD: "prompted-pw" });
436+
}).pipe(
437+
Effect.provide(s.layer),
438+
Effect.ensuring(
439+
Effect.sync(() => {
440+
if (prev === undefined) delete process.env["SUPABASE_DB_PASSWORD"];
441+
else process.env["SUPABASE_DB_PASSWORD"] = prev;
442+
}),
443+
),
444+
);
445+
});
446+
447+
it.live("forwards a SUPABASE_DB_PASSWORD env var to the proxy as an env var", () => {
448+
const s = setup();
449+
const prev = process.env["SUPABASE_DB_PASSWORD"];
450+
process.env["SUPABASE_DB_PASSWORD"] = "env-pw";
451+
return Effect.gen(function* () {
452+
yield* legacyBootstrap(
453+
flags({ template: Option.some("scratch"), password: Option.none() }),
454+
FAST_BACKOFF,
455+
);
456+
expect(s.proxyCalls).toHaveLength(1);
457+
// Env-sourced password stays an env var — no --password flag mapping (CLI-1617).
458+
expect(s.proxyCalls[0]?.args).toEqual(["db", "push", "--include-roles", "--include-seed"]);
459+
expect(s.proxyCalls[0]?.env).toEqual({ SUPABASE_DB_PASSWORD: "env-pw" });
460+
}).pipe(
461+
Effect.provide(s.layer),
462+
Effect.ensuring(
463+
Effect.sync(() => {
464+
if (prev === undefined) delete process.env["SUPABASE_DB_PASSWORD"];
465+
else process.env["SUPABASE_DB_PASSWORD"] = prev;
466+
}),
467+
),
468+
);
469+
});
470+
471+
it.live("forwards a prompted password to the proxy as an env var, not a flag", () => {
472+
const s = setup({ promptPasswordResponses: ["prompted-pw"] });
473+
const prev = process.env["SUPABASE_DB_PASSWORD"];
474+
delete process.env["SUPABASE_DB_PASSWORD"];
475+
return Effect.gen(function* () {
476+
yield* legacyBootstrap(
477+
flags({ template: Option.some("scratch"), password: Option.none() }),
478+
FAST_BACKOFF,
479+
);
480+
expect(s.proxyCalls).toHaveLength(1);
481+
// Go funnels the prompted value into viper DB_PASSWORD; the subprocess
482+
// equivalent is the SUPABASE_DB_PASSWORD env var.
483+
expect(s.proxyCalls[0]?.args).toEqual(["db", "push", "--include-roles", "--include-seed"]);
484+
expect(s.proxyCalls[0]?.env).toEqual({ SUPABASE_DB_PASSWORD: "prompted-pw" });
485+
}).pipe(
486+
Effect.provide(s.layer),
487+
Effect.ensuring(
488+
Effect.sync(() => {
489+
if (prev === undefined) delete process.env["SUPABASE_DB_PASSWORD"];
490+
else process.env["SUPABASE_DB_PASSWORD"] = prev;
491+
}),
492+
),
493+
);
494+
});
495+
416496
it.live("flushes telemetry and caches the linked project via ensuring", () => {
417497
const s = setup();
418498
return Effect.gen(function* () {

apps/cli/src/shared/legacy/go-proxy.layer.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,9 +197,13 @@ export function makeGoProxyLayer(opts?: {
197197
// Scoped via `Effect.scoped` so listeners are always removed on
198198
// normal completion, failure, or fiber interruption.
199199
yield* processControl.holdSignals(["SIGINT", "SIGTERM", "SIGHUP"]);
200+
// Per-call env (execOpts.env) overlays the construction-time env;
201+
// `extendEnv: true` keeps both on top of the inherited process env.
202+
const env =
203+
opts?.env || execOpts?.env ? { ...opts?.env, ...execOpts?.env } : undefined;
200204
const command = ChildProcess.make(binary, [...globalArgs, ...args], {
201205
cwd: execOpts?.cwd ?? opts?.cwd,
202-
env: opts?.env,
206+
env,
203207
extendEnv: true,
204208
stdin: "inherit",
205209
stdout: "inherit",

apps/cli/src/shared/legacy/go-proxy.service.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,17 @@ interface LegacyGoProxyShape {
66
* Forward the given args to the Go binary, inheriting stdin/stdout/stderr
77
* and propagating the exit code. On a non-zero exit the process exits with
88
* the same code — callers do not need to handle the failure case.
9+
*
10+
* `opts.cwd` overrides the working directory for this call (falls back to the
11+
* layer's construction-time cwd). `opts.env` overlays extra environment
12+
* variables onto the subprocess (merged on top of the inherited process env);
13+
* use it to pass values the user supplied as environment variables back to the
14+
* proxy as environment variables, rather than cross-mapping them onto CLI
15+
* flags (CLI-1617).
916
*/
1017
readonly exec: (
1118
args: ReadonlyArray<string>,
12-
opts?: { readonly cwd?: string },
19+
opts?: { readonly cwd?: string; readonly env?: Record<string, string> },
1320
) => Effect.Effect<void>;
1421
}
1522

0 commit comments

Comments
 (0)