From a6c6cb942bb08c65a762397c2e10b161b21deabe Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Wed, 5 Aug 2026 14:04:16 +0100 Subject: [PATCH 01/22] fix(functions): port functions download to native TypeScript (CLI-1963) Ports `supabase functions download`'s default Docker-unbundle path (`--use-docker`, default true) from wholesale Go-binary delegation to native TypeScript, in both the legacy and next shells. `--use-api` was already native; `--legacy-bundle` (hidden, deprecated pre-1.120.0 fallback requiring a host Deno-binary install with no precedent elsewhere in this codebase) is deliberately left delegating to the Go binary, per the parity-audit rationale recorded on the Linear issue. Hoists the Docker-orchestration primitives `download.ts` needs (`runChildProcess`, `isDockerRunning`, `ensureDockerNetwork`, `ensureDockerNamedVolume`, `localDockerId`, `resolveEdgeRuntimeVersion`, etc.) out of `deploy.ts` into a new `functions-docker.ts`, and deduplicates the `edge-runtime-version` pin file lookup that was copy-pasted across all four `deploy`/`download` handler files into a single `resolveEdgeRuntimeVersionPin` helper. Along the way, fixes: - CLI-1891-class validation gap: slugs sourced from the Management API's function list weren't validated before the new Docker path's temp-file write, reopening a path-traversal vector Go's own `downloadAll` already guards against. - The `next` shell's `--use-docker` flag was missing `Flag.withDefault(true)`, a real default-value divergence from both `legacy` and Go. - A brotli-decompression bug: this CLI's HTTP transport already auto-decodes `Content-Encoding: br` responses (confirmed empirically), so re-running `brotliDecompressSync` on the eszip body threw on already-decoded bytes. - Temp eszip cleanup only ran after a successful Docker run; wrapped in `Effect.ensuring` so it also runs on network/volume/spawn failures, matching Go's `defer`. - The `.suggestion` field's leading newline (needed to reproduce Go's blank separator line before the `--legacy-bundle` hint) was being trimmed away by the generic CLI error normalizer. --- apps/cli/docs/go-cli-porting-status.md | 2 +- .../functions/deploy/deploy.handler.ts | 11 +- .../functions/download/SIDE_EFFECTS.md | 128 +-- .../functions/download/download.handler.ts | 11 +- .../download/download.integration.test.ts | 776 +++++++++++++++--- .../functions/serve/serve.integration.test.ts | 10 +- .../commands/start/lib/container-lifecycle.ts | 14 +- .../functions/deploy/deploy.handler.ts | 12 +- .../functions/download/download.command.ts | 1 + .../functions/download/download.handler.ts | 8 +- .../download/download.integration.test.ts | 355 ++++++-- apps/cli/src/shared/cli/cobra-flag-groups.ts | 25 + .../src/shared/cli/hidden-flag.unit.test.ts | 28 +- apps/cli/src/shared/functions/deploy.ts | 226 +---- apps/cli/src/shared/functions/download.ts | 441 +++++++++- .../src/shared/functions/functions-docker.ts | 188 +++++ .../src/shared/functions/functions.shared.ts | 22 + apps/cli/src/shared/functions/serve.ts | 12 +- apps/cli/src/shared/output/normalize-error.ts | 10 +- 19 files changed, 1783 insertions(+), 497 deletions(-) create mode 100644 apps/cli/src/shared/functions/functions-docker.ts diff --git a/apps/cli/docs/go-cli-porting-status.md b/apps/cli/docs/go-cli-porting-status.md index 5f461d0990..2c8eaa667a 100644 --- a/apps/cli/docs/go-cli-porting-status.md +++ b/apps/cli/docs/go-cli-porting-status.md @@ -287,7 +287,7 @@ Legend: | `gen keys` | `wrapped` | [`../src/legacy/commands/gen/keys/keys.command.ts`](../src/legacy/commands/gen/keys/keys.command.ts) | | `functions list` | `wrapped` | [`../src/legacy/commands/functions/list/list.command.ts`](../src/legacy/commands/functions/list/list.command.ts) | | `functions delete` | `ported` | [`../src/legacy/commands/functions/delete/delete.command.ts`](../src/legacy/commands/functions/delete/delete.command.ts) | -| `functions download` | `ported` | [`../src/legacy/commands/functions/download/download.command.ts`](../src/legacy/commands/functions/download/download.command.ts) | +| `functions download` | `ported` | [`../src/legacy/commands/functions/download/download.command.ts`](../src/legacy/commands/functions/download/download.command.ts) — native for `--use-api` and the default Docker-unbundle path (`--use-docker`, CLI-1963); hidden `--legacy-bundle` still delegates to the Go binary (pre-1.120.0 fallback requiring a host Deno-binary install with no precedent elsewhere in this codebase — tracked separately, see CLI-1963) | | `functions deploy` | `ported` | [`../src/legacy/commands/functions/deploy/deploy.command.ts`](../src/legacy/commands/functions/deploy/deploy.command.ts) | | `functions new` | `ported` | [`../src/legacy/commands/functions/new/new.command.ts`](../src/legacy/commands/functions/new/new.command.ts) | | `functions serve` | `ported` | [`../src/legacy/commands/functions/serve/serve.command.ts`](../src/legacy/commands/functions/serve/serve.command.ts) | diff --git a/apps/cli/src/legacy/commands/functions/deploy/deploy.handler.ts b/apps/cli/src/legacy/commands/functions/deploy/deploy.handler.ts index 00bb20b7ba..59bec4fd78 100644 --- a/apps/cli/src/legacy/commands/functions/deploy/deploy.handler.ts +++ b/apps/cli/src/legacy/commands/functions/deploy/deploy.handler.ts @@ -1,8 +1,7 @@ -import { DEFAULT_VERSIONS } from "@supabase/stack/effect"; -import { readFile } from "node:fs/promises"; import { join } from "node:path"; import { Effect, Option, Stdio } from "effect"; import { deployFunctions } from "../../../../shared/functions/deploy.ts"; +import { resolveEdgeRuntimeVersionPin } from "../../../../shared/functions/functions.shared.ts"; import { legacyAqua, legacyBold } from "../../../shared/legacy-colors.ts"; import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; @@ -29,12 +28,8 @@ export const legacyFunctionsDeploy = Effect.fn("legacy.functions.deploy")(functi const runtimeInfo = yield* RuntimeInfo; const stdio = yield* Stdio.Stdio; const rawArgs = yield* stdio.args; - const edgeRuntimeVersion = yield* Effect.tryPromise(() => - readFile(join(cliConfig.workdir, "supabase", ".temp", "edge-runtime-version"), "utf8"), - ).pipe( - Effect.map((version) => version.trim()), - Effect.catch(() => Effect.succeed("")), - Effect.map((version) => version || DEFAULT_VERSIONS["edge-runtime"]), + const edgeRuntimeVersion = yield* resolveEdgeRuntimeVersionPin( + join(cliConfig.workdir, "supabase"), ); let resolvedProjectRef = Option.none(); diff --git a/apps/cli/src/legacy/commands/functions/download/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/functions/download/SIDE_EFFECTS.md index 9c9c6a1845..a32ee01293 100644 --- a/apps/cli/src/legacy/commands/functions/download/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/functions/download/SIDE_EFFECTS.md @@ -2,45 +2,53 @@ ## Files Read -| Path | Format | When | -| ----------------------------------------------- | ---------- | ------------------------------------------------------------- | -| `/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/profile` | plain text | when `--profile` and `SUPABASE_PROFILE` are both unset | -| `.yaml` | YAML | when `SUPABASE_PROFILE` or `--profile` points to a file | -| `/supabase/.temp/project-ref` | plain text | when `--project-ref` and `SUPABASE_PROJECT_ID` are both unset | -| `/telemetry.json` | JSON | when present, before post-run telemetry state is refreshed | +| Path | Format | When | +| --------------------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/profile` | plain text | when `--profile` and `SUPABASE_PROFILE` are both unset | +| `.yaml` | YAML | when `SUPABASE_PROFILE` or `--profile` points to a file | +| `/supabase/.temp/project-ref` | plain text | when `--project-ref` and `SUPABASE_PROJECT_ID` are both unset | +| `/supabase/.temp/edge-runtime-version` | plain text | Docker-unbundle path: overrides the default edge-runtime image tag when present | +| `/supabase/config.toml` (or `config.json`) | TOML/JSON | Docker-unbundle path: resolves `edge_runtime.deno_version` and `project_id` (`loadProjectConfig`) — a new file-read surface versus the `--use-api` path, which reads no project config | +| `/telemetry.json` | JSON | when present, before post-run telemetry state is refreshed | ## Files Written -| Path | Format | When | -| --------------------------------------------------- | ------ | ----------------------------------------------------------------------- | -| `/supabase/functions//` | bytes | for each source file returned by the API | -| `/supabase/.temp/linked-project.json` | JSON | after resolving a project ref, cached on both success and failure paths | -| `/telemetry.json` | JSON | after command completion, flushed on both success and failure paths | +| Path | Format | When | +| --------------------------------------------------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/functions//` | bytes | for each source file returned by the API (`--use-api`, or the Docker-unbundle fallback when Docker isn't running) | +| `/supabase/.temp/output_.eszip` | bytes | Docker-unbundle path (default): downloaded eszip, extracted into `supabase/functions//...` by the edge-runtime container; removed after the attempt unless `--debug` is set | +| `/supabase/.temp/linked-project.json` | JSON | after resolving a project ref, cached on both success and failure paths | +| `/telemetry.json` | JSON | after command completion, flushed on both success and failure paths | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ------------------------------------------ | ------------ | ------------ | ----------------------------------------------------- | -| `GET` | `/v1/projects/{ref}/functions` | Bearer token | none | function slugs, when downloading all | -| `GET` | `/v1/projects/{ref}/functions/{slug}` | Bearer token | none | entrypoint path, when absent from metadata | -| `GET` | `/v1/projects/{ref}/functions/{slug}/body` | Bearer token | none | multipart function source | -| `GET` | `/v1/projects` | Bearer token | none | project picker options when no ref is supplied in TTY | -| `GET` | `/v1/projects/{ref}` | Bearer token | none | linked project metadata used by the post-run cache | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ------------------------------------------ | ------------ | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/v1/projects/{ref}/functions` | Bearer token | none | function slugs, when downloading all | +| `GET` | `/v1/projects/{ref}/functions/{slug}` | Bearer token | none | entrypoint path, when absent from multipart metadata (`--use-api` path only) | +| `GET` | `/v1/projects/{ref}/functions/{slug}/body` | Bearer token | none | `--use-api`: multipart function source (`Accept: multipart/form-data`). Docker-unbundle: raw eszip bytes; a `Content-Encoding: br` response is decoded transparently by the HTTP transport, not by this command | +| `GET` | `/v1/projects` | Bearer token | none | project picker options when no ref is supplied in TTY | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | linked project metadata used by the post-run cache | ## Subprocesses -| Command | When | Purpose | -| ------------------------------------ | ----------------------------------------------------------------- | ----------------------------------- | -| `supabase-go functions download ...` | `--use-docker` (default) or `--legacy-bundle`, unless `--use-api` | preserve hidden compatibility modes | - -The delegated call runs with `SUPABASE_TELEMETRY_DISABLED=1` so the Go child's -own `cli_command_executed` doesn't double-count on top of this command's own -telemetry (mirrors `db pull`/`db diff`'s delegated-call pattern). In -`--output-format json|stream-json`, the child's stdout is captured and -discarded instead of inherited (`LegacyGoProxy.execCapture`) — the raw text -never reaches the terminal, and this command emits the `Output` envelope -itself once the child exits successfully. +| Command | When | Purpose | +| ---------------------------------------------------------------------------- | -------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| `docker info` | `--use-docker` (default), unless `--use-api` | check whether Docker is running before choosing the Docker-unbundle downloader | +| `docker network inspect` / `network create` / `volume create` | Docker-unbundle path, when Docker is running | ensure the shared per-project network/named volume exist (same primitives as `functions deploy`'s Docker bundler) | +| `docker run --rm ... unbundle --eszip ... --output ...` | Docker-unbundle path, when Docker is running | extract the downloaded eszip into `supabase/functions//...` | +| `supabase-go functions download ... --legacy-bundle` | `--legacy-bundle` only | preserve the hidden, deprecated pre-1.120.0 bundling fallback (native TS port tracked separately, CLI-1963) | + +The `--legacy-bundle` delegated call runs with `SUPABASE_TELEMETRY_DISABLED=1` +so the Go child's own `cli_command_executed` doesn't double-count on top of +this command's own telemetry (mirrors `db pull`/`db diff`'s delegated-call +pattern). In `--output-format json|stream-json`, the child's stdout is +captured and discarded instead of inherited (`LegacyGoProxy.execCapture`) — +the raw text never reaches the terminal, and this command emits the `Output` +envelope itself once the child exits successfully. The Docker-unbundle path's +own container stdout is routed the same way: to the real stdout in text mode, +to stderr in machine-output modes (CLI-1546). ## Environment Variables @@ -55,13 +63,14 @@ itself once the child exits successfully. ## Exit Codes -| Code | Condition | -| ---- | -------------------------------------- | -| `0` | success | -| `1` | API error (non-2xx response) | -| `1` | authentication error (no token found) | -| `1` | network / connection failure | -| `1` | invalid function slug or flag conflict | +| Code | Condition | +| ---- | ---------------------------------------------------------------------- | +| `0` | success | +| `1` | API error (non-2xx response) | +| `1` | authentication error (no token found) | +| `1` | network / connection failure | +| `1` | invalid function slug or flag conflict | +| `1` | Docker-unbundle container exited non-zero (suggests `--legacy-bundle`) | ## Telemetry Events Fired @@ -73,25 +82,50 @@ itself once the child exits successfully. ### `--output-format text` (Go CLI compatible) -Prints progress and success messages as functions are downloaded. +Prints progress and success messages as functions are downloaded. The Docker-unbundle path prints +`Downloading function: ` (lowercase "function", unlike the `--use-api` path's "Downloading +Function:") and does **not** print a final "Downloaded Function ... from project ..." line — that +line only appears on the `--use-api` and `--legacy-bundle` paths (Go parity, `download.go`). ### `--output-format json` Prints a structured success result with the downloaded function slugs and project ref. On the -Docker/legacy-bundle proxy path, the Go child's stdout is captured/discarded (never inherited) so -it can't corrupt the envelope; the slug list is resolved independently for the payload. +`--legacy-bundle` proxy path, the Go child's stdout is captured/discarded (never inherited) so it +can't corrupt the envelope; the slug list is resolved independently for the payload. On the +Docker-unbundle path, the `unbundle` container's own stdout is routed to stderr instead of stdout +for the same reason. ### `--output-format stream-json` -Same envelope as `json` above (including on the proxy path). +Same envelope as `json` above (including on the proxy and Docker-unbundle paths). ## Notes - If no function name is provided, downloads all functions. - Requires a linked project (`--project-ref` or linked project config). -- Native downloads reject path traversal and symlink escapes before writing source files. -- `--use-docker` and `--legacy-bundle` are hidden flags forwarded to the Go binary for backward compatibility; they are mutually exclusive with `--use-api`. -- `--use-docker` defaults to `true` (Go parity), so a bare `supabase functions download` proxies to the Go binary's Docker-based unbundler unless `--use-api` resolves to `true`, which forces the native server-side download path instead (`apps/cli-go/cmd/functions.go:51-53`: `if useApi { useDocker = false }` reads the resolved flag value, not presence — `--use-api=false` still proxies). -- If Docker is not running, the Go binary itself prints `WARNING: Docker is not running` to stderr and falls back to its own server-side unbundler — the command still exits `0` without Docker installed or running. -- The mutual-exclusivity check only counts flags the user explicitly passed on the command line, not `--use-docker`'s default value — so `--use-api` alone never trips the "mutually exclusive" error. The Go proxy call itself also only ever forwards one of `--use-docker`/`--legacy-bundle`, never both, even though `--use-docker` defaults to `true`. -- Refreshes the linked-project telemetry cache and flushes telemetry state after resolving a project ref. +- The `--use-api` path rejects path traversal and symlink escapes before writing source files + (`resolveDownloadDestination`/`ensureContainedPath`) — the Docker-unbundle path has no equivalent + check of its own; it delegates the actual file writes to the `unbundle` subcommand running inside + the edge-runtime container, through the `supabase/functions` bind mount, matching Go's own + `extractOne` (which has no path-containment check either — this is a pre-existing, not + CLI-1963-introduced, gap shared with the Go CLI). Slugs sourced from the Management API's function + list (downloading-all) are validated against the same pattern as user-supplied slugs, on both + paths, before any per-slug download runs (CLI-1891 parity). +- `--legacy-bundle` is a hidden flag forwarded to the Go binary for backward compatibility — it + requires installing a real Deno binary on the host (`InstallOrUpgradeDeno`) and is a pre-1.120.0 + compatibility fallback; native TS port tracked separately (CLI-1963). `--use-docker` is a hidden + flag but now runs natively. +- `--use-docker`, `--use-api`, and `--legacy-bundle` are mutually exclusive. +- `--use-docker` defaults to `true` (Go parity), so a bare `supabase functions download` runs the + native Docker-unbundle downloader unless `--use-api` resolves to `true`, which forces the native + server-side download path instead (`apps/cli-go/cmd/functions.go:51-53`: `if useApi { useDocker = +false }` reads the resolved flag value, not presence — `--use-api=false` still runs Docker-unbundle). +- If Docker is not running, this command itself prints `WARNING: Docker is not running` to stderr + and falls back to the native server-side unbundler — the command still exits `0` without Docker + installed or running. +- The mutual-exclusivity check only counts flags the user explicitly passed on the command line, + not `--use-docker`'s default value — so `--use-api` alone never trips the "mutually exclusive" + error. The `--legacy-bundle` Go proxy call itself only ever forwards `--legacy-bundle`, never + `--use-docker` alongside it, even though `--use-docker` defaults to `true`. +- Refreshes the linked-project telemetry cache and flushes telemetry state after resolving a + project ref. diff --git a/apps/cli/src/legacy/commands/functions/download/download.handler.ts b/apps/cli/src/legacy/commands/functions/download/download.handler.ts index 4b666da7f6..53886acf72 100644 --- a/apps/cli/src/legacy/commands/functions/download/download.handler.ts +++ b/apps/cli/src/legacy/commands/functions/download/download.handler.ts @@ -1,8 +1,10 @@ +import { join } from "node:path"; import { Effect, Option, Stdio } from "effect"; import { downloadFunctions, - makeGoProxyDownloadArgs, + makeGoProxyLegacyBundleArgs, } from "../../../../shared/functions/download.ts"; +import { resolveEdgeRuntimeVersionPin } from "../../../../shared/functions/functions.shared.ts"; import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; @@ -22,12 +24,17 @@ export const legacyFunctionsDownload = Effect.fn("legacy.functions.download")(fu const proxy = yield* LegacyGoProxy; const stdio = yield* Stdio.Stdio; const rawArgs = yield* stdio.args; + const edgeRuntimeVersion = yield* resolveEdgeRuntimeVersionPin( + join(cliConfig.workdir, "supabase"), + ); let resolvedProjectRef = Option.none(); yield* downloadFunctions(flags, { api, projectRoot: cliConfig.workdir, rawArgs, + goViperCompat: true, + edgeRuntimeVersion, resolveProjectRef: (projectRef) => resolver.resolve(projectRef).pipe( Effect.tap((ref) => @@ -47,7 +54,7 @@ export const legacyFunctionsDownload = Effect.fn("legacy.functions.download")(fu // pattern for the CLI-1546 "stdout is payload-only in machine mode" // invariant — `downloadFunctions` emits the `Output` envelope itself. proxyDownload: (proxyFlags, projectRef, captureOutput) => { - const args = makeGoProxyDownloadArgs(proxyFlags, projectRef); + const args = makeGoProxyLegacyBundleArgs(proxyFlags.functionName, projectRef); const env = { SUPABASE_TELEMETRY_DISABLED: "1" }; return captureOutput ? Effect.asVoid(proxy.execCapture(args, { env, stdin: "ignore" })) diff --git a/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts b/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts index af37969828..b58604ab77 100644 --- a/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts +++ b/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from "@effect/vitest"; -import { readFile } from "node:fs/promises"; -import { join } from "node:path"; -import { Effect, Exit, Layer, Option, Stdio } from "effect"; +import { DEFAULT_VERSIONS } from "@supabase/stack/effect"; +import { existsSync } from "node:fs"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { Deferred, Effect, Exit, Layer, Option, PlatformError, Sink, Stdio, Stream } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; @@ -17,12 +20,107 @@ import { useLegacyTempWorkdir, } from "../../../../../tests/helpers/legacy-mocks.ts"; import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { mockChildProcessSpawner } from "../../../../../../../packages/process-compose/tests/helpers/mocks.ts"; import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { legacyContainerRuntimeNotFoundMessage } from "../../../shared/legacy-container-cli.ts"; import { ConflictingFunctionDownloadFlagsError } from "../../../../shared/functions/download.errors.ts"; import { legacyFunctionsDownloadHandler } from "./download.command.ts"; import type { LegacyFunctionsDownloadFlags } from "./download.command.ts"; import { legacyFunctionsDownload } from "./download.handler.ts"; +const PROJECT_ID = "abcdefghijklmnopqrst"; + +/** + * Mutates the shared spawner options object from inside `onSpawn`, scoped to + * the `docker run ... unbundle` invocation specifically — every earlier + * Docker call (`info`, `network inspect`, `volume create`) in the same test + * already resolved by the time this fires, since `download.ts` awaits each + * child process sequentially, so this only ever affects the unbundle step's + * own exit code/stdio. + */ +function mockDockerUnbundle( + opts: { + readonly runExitCode?: number; + readonly runStdout?: ReadonlyArray; + readonly runStderr?: ReadonlyArray; + } = {}, +) { + const spawnerOpts: { + exitCode?: number; + stdout?: string[]; + stderr?: string[]; + onSpawn?: (record: { command: string; args: ReadonlyArray }) => void; + } = { exitCode: 0 }; + spawnerOpts.onSpawn = (record) => { + if (record.command === "docker" && record.args[0] === "run") { + spawnerOpts.exitCode = opts.runExitCode ?? 0; + spawnerOpts.stdout = opts.runStdout === undefined ? [] : [...opts.runStdout]; + spawnerOpts.stderr = opts.runStderr === undefined ? [] : [...opts.runStderr]; + } + }; + return mockChildProcessSpawner(spawnerOpts); +} + +/** + * A real ENOENT-style spawn failure for the `docker run ... unbundle` step + * specifically — distinct from `mockDockerUnbundle`'s non-zero exit code, + * which models the container starting but the `unbundle` binary itself + * failing. This models `child_process.spawn` (or the container runtime + * binary) never starting at all, which `runChildProcess` surfaces as an + * `unknown` cause rather than an `{ exitCode, stdout, stderr }` result. + * Mirrors `legacy-container-cli.unit.test.ts`'s `mockSpawner({ bothMissing: + * true })`: failing both the `docker` and `podman` fallback attempts for the + * `run` step is what makes `spawnContainerCli` surface + * `legacyContainerRuntimeNotFoundMessage` instead of retrying indefinitely. + * Every other Docker call (`info`, `network inspect`, `volume create`) + * succeeds with exit code 0, so only the unbundle step itself fails. + */ +function mockDockerRunSpawnFailure() { + const spawned: Array<{ command: string; args: ReadonlyArray }> = []; + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + const cmd = command._tag === "StandardCommand" ? command.command : ""; + const args = command._tag === "StandardCommand" ? command.args : []; + spawned.push({ command: cmd, args }); + + if (args[0] === "run") { + return yield* Effect.fail( + PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: `${cmd} not found`, + }), + ); + } + + const exitDeferred = yield* Deferred.make(); + yield* Deferred.succeed(exitDeferred, ChildProcessSpawner.ExitCode(0)); + + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1000 + spawned.length), + stdout: Stream.empty, + stderr: Stream.empty, + all: Stream.empty, + exitCode: Deferred.await(exitDeferred), + isRunning: Effect.succeed(false), + stdin: Sink.drain, + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + }), + ); + + return { + get spawned() { + return spawned; + }, + layer: Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), + }; +} + const tempRoot = useLegacyTempWorkdir("supabase-functions-download-legacy-"); // `withLegacyCommandInstrumentation` threads `flags`/`command`/etc. through @@ -157,49 +255,70 @@ describe("legacy functions download", () => { }).pipe(Effect.provide(layer)); }); - it.live("proxies to Docker by default (Go parity), with no flags passed", () => { - const out = mockOutput({ format: "text" }); - const api = mockLegacyPlatformApi(); - const proxy = mockProxy(); - const layer = Layer.mergeAll( - buildLegacyTestRuntime({ - out, - api, - cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), - }), - proxy.layer, - Stdio.layerTest({ - args: Effect.succeed([ - "functions", - "download", - "hello-world", - "--project-ref", - "abcdefghijklmnopqrst", - ]), - }), - ); + it.live( + "runs the native Docker unbundle path by default (Go parity), with no flags passed", + () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi(); + const proxy = mockProxy(); + // Non-empty stdout/stderr on the `docker run` step exercises both the + // text-mode stdout routing branch and the always-to-stderr container + // stderr branch in `downloadWithDockerUnbundle`. + const child = mockDockerUnbundle({ + runStdout: ["unbundle: wrote index.ts"], + runStderr: ["unbundle: warning about deno.json"], + }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + }), + proxy.layer, + child.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "hello-world", + "--project-ref", + PROJECT_ID, + ]), + }), + ); - return Effect.gen(function* () { - // `useDocker: true` mirrors what the CLI parser now resolves to by - // default (CLI-1862) — no `--use-docker` flag appears in argv above. - yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }); + return Effect.gen(function* () { + // `useDocker: true` mirrors what the CLI parser now resolves to by + // default (CLI-1862) — no `--use-docker` flag appears in argv above. + // CLI-1963: this now runs the native Docker-unbundle path instead of + // delegating to the Go proxy. + yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }); - expect(api.requests).toEqual([]); - expect(proxy.calls).toEqual([ - [ - "functions", - "download", - "hello-world", - "--project-ref", - "abcdefghijklmnopqrst", - "--use-docker", - ], - ]); - // The delegated Go binary must not also fire its own - // `cli_command_executed` on top of this command's own instrumentation. - expect(proxy.envs).toEqual([{ SUPABASE_TELEMETRY_DISABLED: "1" }]); - }).pipe(Effect.provide(layer)); - }); + expect(proxy.calls).toEqual([]); + expect(proxy.captureCalls).toEqual([]); + expect(api.requests.some((request) => request.url.endsWith("/hello-world/body"))).toBe( + true, + ); + expect( + child.spawned.some( + (spawned) => spawned.command === "docker" && spawned.args[0] === "run", + ), + ).toBe(true); + expect(out.stderrText).toContain("Downloading function: hello-world\n"); + expect(out.stdoutText).toContain("unbundle: wrote index.ts\n"); + expect(out.stderrText).toContain("unbundle: warning about deno.json\n"); + // Go parity finding (CLI-1963 audit): unlike the server-side and + // `--legacy-bundle` paths, `downloadWithDockerUnbundle` never prints + // a "Downloaded Function ... from project ..." success line — + // guarded here against a future accidental regression. + expect(out.stderrText).not.toContain("Downloaded Function"); + // No `--debug` — the temp eszip file is removed after the run. + expect( + existsSync(join(tempRoot.current, "supabase", ".temp", "output_hello-world.eszip")), + ).toBe(false); + }).pipe(Effect.provide(layer)); + }, + ); it.live( "does not treat the --use-docker default as conflicting with an explicit --use-api", @@ -251,10 +370,124 @@ describe("legacy functions download", () => { }, ); - it.live("still proxies to Docker when --use-api=false is passed explicitly", () => { - const out = mockOutput({ format: "text" }); - const api = mockLegacyPlatformApi(); + it.live( + "still runs the native Docker unbundle path when --use-api=false is passed explicitly", + () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi(); + const proxy = mockProxy(); + const child = mockChildProcessSpawner({ exitCode: 0 }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + }), + proxy.layer, + child.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "hello-world", + "--use-api=false", + "--project-ref", + PROJECT_ID, + ]), + }), + ); + + return Effect.gen(function* () { + // Go's override is value-based (`if useApi { useDocker = false }`, + // apps/cli-go/cmd/functions.go:51-53), not presence-based. An + // explicit `--use-api=false` must not be treated like `--use-api` — + // it should leave the `--use-docker` default (true) in effect and + // still run the native Docker path (CLI-1963). + yield* legacyFunctionsDownload({ ...baseFlags, useApi: false, useDocker: true }); + + expect(proxy.calls).toEqual([]); + expect(proxy.captureCalls).toEqual([]); + expect( + child.spawned.some( + (spawned) => spawned.command === "docker" && spawned.args[0] === "run", + ), + ).toBe(true); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "emits a JSON success envelope when running the native Docker path in machine-output mode", + () => { + const out = mockOutput({ format: "json" }); + const api = mockLegacyPlatformApi(); + const proxy = mockProxy(); + // Non-empty container stdout exercises the machine-mode branch that + // routes it to stderr instead of stdout (CLI-1546: stdout stays + // payload-only in json/stream-json modes). + const child = mockDockerUnbundle({ runStdout: ["unbundle: wrote index.ts"] }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + }), + proxy.layer, + child.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "hello-world", + "--project-ref", + PROJECT_ID, + "--output-format", + "json", + ]), + }), + ); + + return Effect.gen(function* () { + // CLI-1963: `--use-docker` now runs the native Docker-unbundle path; + // this asserts the JSON envelope this command emits itself still + // shows up correctly, with no delegated Go child's stdout to worry + // about capturing. + yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }); + + expect(proxy.calls).toEqual([]); + expect(proxy.captureCalls).toEqual([]); + expect( + child.spawned.some( + (spawned) => spawned.command === "docker" && spawned.args[0] === "run", + ), + ).toBe(true); + expect(out.stdoutText).toBe(""); + expect(out.stderrText).toContain("unbundle: wrote index.ts\n"); + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "success", + data: { function_slugs: ["hello-world"], project_ref: PROJECT_ID }, + }), + ); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("lists remote functions and downloads each natively via Docker in machine mode", () => { + const out = mockOutput({ format: "json" }); + const api = mockLegacyPlatformApi({ + handler: (request) => + request.url.endsWith("/functions") + ? Effect.succeed( + legacyJsonResponse(request, 200, [ + { slug: "hello-world" }, + { slug: "goodbye-world" }, + ]), + ) + : Effect.succeed(legacyJsonResponse(request, 200, {})), + }); const proxy = mockProxy(); + const child = mockChildProcessSpawner({ exitCode: 0 }); const layer = Layer.mergeAll( buildLegacyTestRuntime({ out, @@ -262,44 +495,128 @@ describe("legacy functions download", () => { cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), }), proxy.layer, + child.layer, Stdio.layerTest({ args: Effect.succeed([ "functions", "download", - "hello-world", - "--use-api=false", "--project-ref", - "abcdefghijklmnopqrst", + PROJECT_ID, + "--output-format", + "json", ]), }), ); return Effect.gen(function* () { - // Go's override is value-based (`if useApi { useDocker = false }`, - // apps/cli-go/cmd/functions.go:51-53), not presence-based. An explicit - // `--use-api=false` must not be treated like `--use-api` — it should - // leave the `--use-docker` default (true) in effect and still proxy. - yield* legacyFunctionsDownload({ ...baseFlags, useApi: false, useDocker: true }); + yield* legacyFunctionsDownload({ + ...baseFlags, + functionName: Option.none(), + useDocker: true, + }); - expect(api.requests).toEqual([]); - expect(proxy.calls).toEqual([ - [ + expect(proxy.calls).toEqual([]); + expect(proxy.captureCalls).toEqual([]); + expect( + child.spawned.filter( + (spawned) => spawned.command === "docker" && spawned.args[0] === "run", + ), + ).toHaveLength(2); + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "success", + data: { + function_slugs: ["hello-world", "goodbye-world"], + project_ref: PROJECT_ID, + }, + }), + ); + }).pipe(Effect.provide(layer)); + }); + + it.live("runs docker with the expected binds, network, and unbundle command", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi(); + const proxy = mockProxy(); + const child = mockChildProcessSpawner({ exitCode: 0 }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + }), + proxy.layer, + child.layer, + Stdio.layerTest({ + args: Effect.succeed([ "functions", "download", "hello-world", - "--project-ref", - "abcdefghijklmnopqrst", "--use-docker", + "--project-ref", + PROJECT_ID, + ]), + }), + ); + + return Effect.gen(function* () { + yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }); + + // Go: `extractOne` (`download.go:260-266`) — bind order and network + // reuse the same primitives `deploy.ts`'s own Docker-bundling path + // already uses. + expect(child.spawned.find((spawned) => spawned.args[0] === "network")).toEqual({ + command: "docker", + args: ["network", "inspect", `supabase_network_${PROJECT_ID}`], + }); + expect(child.spawned.find((spawned) => spawned.args[0] === "volume")).toEqual({ + command: "docker", + args: [ + "volume", + "create", + "--label", + `com.supabase.cli.project=${PROJECT_ID}`, + "--label", + `com.docker.compose.project=${PROJECT_ID}`, + `supabase_edge_runtime_${PROJECT_ID}`, ], + }); + + const runCommand = child.spawned.find((spawned) => spawned.args[0] === "run"); + const hostEszipPath = resolve( + tempRoot.current, + "supabase", + ".temp", + "output_hello-world.eszip", + ); + const functionsDir = resolve(tempRoot.current, "supabase", "functions"); + expect(runCommand?.args).toContain( + `supabase_edge_runtime_${PROJECT_ID}:/root/.cache/deno:rw`, + ); + expect(runCommand?.args).toContain( + `${hostEszipPath}:/root/eszips/output_hello-world.eszip:ro`, + ); + expect(runCommand?.args).toContain(`${functionsDir}:/home/deno:rw`); + expect(runCommand?.args).toContain("--network"); + expect(runCommand?.args).toContain(`supabase_network_${PROJECT_ID}`); + // The unbundle tail is always the LAST 6 args regardless of whether + // `--add-host` (Linux-only) was inserted before it. + expect(runCommand?.args.slice(-6)).toEqual([ + `public.ecr.aws/supabase/edge-runtime:v${DEFAULT_VERSIONS["edge-runtime"]}`, + "unbundle", + "--eszip", + "/root/eszips/output_hello-world.eszip", + "--output", + "/home/deno/hello-world", ]); - expect(proxy.envs).toEqual([{ SUPABASE_TELEMETRY_DISABLED: "1" }]); }).pipe(Effect.provide(layer)); }); - it.live("emits a JSON success envelope when proxying to Docker in machine-output mode", () => { - const out = mockOutput({ format: "json" }); + it.live("uses an explicit --network-id override instead of the derived network name", () => { + const out = mockOutput({ format: "text" }); const api = mockLegacyPlatformApi(); const proxy = mockProxy(); + const child = mockChildProcessSpawner({ exitCode: 0 }); const layer = Layer.mergeAll( buildLegacyTestRuntime({ out, @@ -307,63 +624,282 @@ describe("legacy functions download", () => { cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), }), proxy.layer, + child.layer, Stdio.layerTest({ args: Effect.succeed([ "functions", "download", "hello-world", + "--use-docker", "--project-ref", - "abcdefghijklmnopqrst", - "--output-format", - "json", + PROJECT_ID, + "--network-id", + "custom-network", ]), }), ); return Effect.gen(function* () { - // CLI-1546: stdout is payload-only in machine mode, so the Go child's - // raw output must be captured/discarded (not inherited) and this - // command must emit the `Output` envelope itself, matching the native - // path's shape. + // `--network-id` is a persistent root flag (`cmd/root.go:328`), not + // registered on `functions download` itself — `explicitStringFlag` + // scans the whole argv unscoped. yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }); - expect(proxy.calls).toEqual([]); - expect(proxy.captureCalls).toEqual([ - [ + expect(child.spawned.find((spawned) => spawned.args[0] === "network")).toEqual({ + command: "docker", + args: ["network", "inspect", "custom-network"], + }); + const runCommand = child.spawned.find((spawned) => spawned.args[0] === "run"); + expect(runCommand?.args).toContain("custom-network"); + expect(runCommand?.args).not.toContain(`supabase_network_${PROJECT_ID}`); + }).pipe(Effect.provide(layer)); + }); + + it.live("keeps the temporary eszip file when --debug is passed", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi(); + const proxy = mockProxy(); + const child = mockChildProcessSpawner({ exitCode: 0 }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + }), + proxy.layer, + child.layer, + Stdio.layerTest({ + args: Effect.succeed([ "functions", "download", "hello-world", - "--project-ref", - "abcdefghijklmnopqrst", "--use-docker", - ], - ]); - expect(proxy.captureEnvs).toEqual([{ SUPABASE_TELEMETRY_DISABLED: "1" }]); - expect(out.messages).toContainEqual( - expect.objectContaining({ - type: "success", - data: { function_slugs: ["hello-world"], project_ref: "abcdefghijklmnopqrst" }, + "--project-ref", + PROJECT_ID, + "--debug", + ]), + }), + ); + + return Effect.gen(function* () { + yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }); + + expect( + existsSync(join(tempRoot.current, "supabase", ".temp", "output_hello-world.eszip")), + ).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + describe("docker unbundle container failures", () => { + it.live("fails with the legacy-bundle suggestion when the container exits non-zero", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi(); + const proxy = mockProxy(); + const child = mockDockerUnbundle({ runExitCode: 1, runStderr: ["boom"] }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), }), + proxy.layer, + child.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "hello-world", + "--use-docker", + "--project-ref", + PROJECT_ID, + ]), + }), + ); + + return Effect.gen(function* () { + const error = yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }).pipe( + Effect.flip, + ); + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe("error running container: exit 1"); + expect((error as Error & { suggestion?: string }).suggestion).toBe( + "\nIf your function is deployed using CLI < 1.120.0, trying running supabase functions download --legacy-bundle hello-world instead.", + ); + }).pipe(Effect.provide(layer)); + }); + + it.live( + "prepends the deno v2 suggestion when deno_version is 1 and the container reports an invalid eszip", + () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi(); + const proxy = mockProxy(); + const child = mockDockerUnbundle({ + runExitCode: 1, + // Go's scanner requires a full-line, case-insensitive match + // (`strings.EqualFold(line, "invalid eszip v2")`, `download.go:295`) + // — a line merely containing the phrase as a substring (e.g. + // "error: invalid eszip v2 header") does not fire the suggestion. + runStderr: ["invalid eszip v2"], + }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + }), + proxy.layer, + child.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "hello-world", + "--use-docker", + "--project-ref", + PROJECT_ID, + ]), + }), + ); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + mkdir(join(tempRoot.current, "supabase"), { recursive: true }), + ); + yield* Effect.tryPromise(() => + writeFile( + join(tempRoot.current, "supabase", "config.toml"), + ["[edge_runtime]", "deno_version = 1", ""].join("\n"), + ), + ); + + const error = yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }).pipe( + Effect.flip, + ); + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe("error running container: exit 1"); + expect((error as Error & { suggestion?: string }).suggestion).toBe( + "Please use deno v2 in supabase/config.toml to download this Function:\n\n[edge_runtime]\ndeno_version = 2\n" + + "\nIf your function is deployed using CLI < 1.120.0, trying running supabase functions download --legacy-bundle hello-world instead.", + ); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "does not prepend the deno v2 suggestion when deno_version is 1 but the container's error is unrelated", + () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi(); + const proxy = mockProxy(); + const child = mockDockerUnbundle({ runExitCode: 1, runStderr: ["permission denied"] }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + }), + proxy.layer, + child.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "hello-world", + "--use-docker", + "--project-ref", + PROJECT_ID, + ]), + }), + ); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + mkdir(join(tempRoot.current, "supabase"), { recursive: true }), + ); + yield* Effect.tryPromise(() => + writeFile( + join(tempRoot.current, "supabase", "config.toml"), + ["[edge_runtime]", "deno_version = 1", ""].join("\n"), + ), + ); + + const error = yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }).pipe( + Effect.flip, + ); + + expect((error as Error & { suggestion?: string }).suggestion).toBe( + "\nIf your function is deployed using CLI < 1.120.0, trying running supabase functions download --legacy-bundle hello-world instead.", + ); + }).pipe(Effect.provide(layer)); + }, + ); + }); + + it.live("fails when ensureDockerNetwork can't create a missing network", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi(); + const proxy = mockProxy(); + const spawnerOpts: { + exitCode?: number; + stderr?: string[]; + onSpawn?: (record: { command: string; args: ReadonlyArray }) => void; + } = { exitCode: 0 }; + spawnerOpts.onSpawn = (record) => { + spawnerOpts.exitCode = record.command === "docker" && record.args[0] === "network" ? 1 : 0; + spawnerOpts.stderr = ["permission denied"]; + }; + const child = mockChildProcessSpawner(spawnerOpts); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + }), + proxy.layer, + child.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "hello-world", + "--use-docker", + "--project-ref", + PROJECT_ID, + ]), + }), + ); + + return Effect.gen(function* () { + const error = yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }).pipe( + Effect.flip, ); + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe( + `failed to create docker network: supabase_network_${PROJECT_ID}`, + ); + expect(child.spawned.some((spawned) => spawned.args[0] === "volume")).toBe(false); + expect(child.spawned.some((spawned) => spawned.args[0] === "run")).toBe(false); + // Go parity fix (CLI-1963 review): `Effect.ensuring` wraps the whole + // Docker-extraction sequence, so the temp eszip written just before it + // is still cleaned up even though the failure happened before Docker + // ever ran — not only after a successful `runChildProcess` call. + expect( + existsSync(join(tempRoot.current, "supabase", ".temp", "output_hello-world.eszip")), + ).toBe(false); }).pipe(Effect.provide(layer)); }); it.live( - "lists remote functions before delegating when no function name is given in machine mode", + "fails with the docker-step prefix when the unbundle container itself cannot be spawned", () => { - const out = mockOutput({ format: "json" }); - const api = mockLegacyPlatformApi({ - handler: (request) => - request.url.endsWith("/functions") - ? Effect.succeed( - legacyJsonResponse(request, 200, [ - { slug: "hello-world" }, - { slug: "goodbye-world" }, - ]), - ) - : Effect.succeed(legacyJsonResponse(request, 200, {})), - }); + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi(); const proxy = mockProxy(); + const child = mockDockerRunSpawnFailure(); const layer = Layer.mergeAll( buildLegacyTestRuntime({ out, @@ -371,38 +907,40 @@ describe("legacy functions download", () => { cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), }), proxy.layer, + child.layer, Stdio.layerTest({ args: Effect.succeed([ "functions", "download", + "hello-world", + "--use-docker", "--project-ref", - "abcdefghijklmnopqrst", - "--output-format", - "json", + PROJECT_ID, ]), }), ); return Effect.gen(function* () { - yield* legacyFunctionsDownload({ - ...baseFlags, - functionName: Option.none(), - useDocker: true, - }); + const error = yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }).pipe( + Effect.flip, + ); - expect(proxy.calls).toEqual([]); - expect(proxy.captureCalls).toEqual([ - ["functions", "download", "--project-ref", "abcdefghijklmnopqrst", "--use-docker"], - ]); - expect(out.messages).toContainEqual( - expect.objectContaining({ - type: "success", - data: { - function_slugs: ["hello-world", "goodbye-world"], - project_ref: "abcdefghijklmnopqrst", - }, - }), + // Distinct from `ensureDockerNetwork`/`ensureDockerNamedVolume` + // failures (asserted above), which already self-describe and must + // NOT gain this prefix — a bare spawn/runtime-not-found failure from + // `runChildProcess` itself carries no context of its own about which + // command was running, so `withDockerStepFailure` adds one. + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe( + `failed to run the edge-runtime unbundle container: ${legacyContainerRuntimeNotFoundMessage}`, ); + expect((error as Error & { suggestion?: string }).suggestion).toBe( + "\nIf your function is deployed using CLI < 1.120.0, trying running supabase functions download --legacy-bundle hello-world instead.", + ); + expect(child.spawned.some((spawned) => spawned.args[0] === "run")).toBe(true); + expect( + existsSync(join(tempRoot.current, "supabase", ".temp", "output_hello-world.eszip")), + ).toBe(false); }).pipe(Effect.provide(layer)); }, ); @@ -418,6 +956,11 @@ describe("legacy functions download", () => { : Effect.succeed(legacyJsonResponse(request, 200, {})), }); const proxy = mockProxy(); + // Deterministic stand-in for `emptyEnv()`'s real `ChildProcessSpawner` + // (via `BunServices`, pulled in by `buildLegacyTestRuntime`) — `useDocker: + // true` still probes `docker info` even though this project has no + // functions to download, so this must not spawn a real `docker` process. + const child = mockChildProcessSpawner({ exitCode: 0 }); const layer = Layer.mergeAll( buildLegacyTestRuntime({ out, @@ -425,6 +968,7 @@ describe("legacy functions download", () => { cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), }), proxy.layer, + child.layer, Stdio.layerTest({ args: Effect.succeed([ "functions", @@ -470,6 +1014,7 @@ describe("legacy functions download", () => { : Effect.succeed(legacyJsonResponse(request, 200, {})), }); const proxy = mockProxy(); + const child = mockChildProcessSpawner({ exitCode: 0 }); const layer = Layer.mergeAll( buildLegacyTestRuntime({ out, @@ -477,6 +1022,7 @@ describe("legacy functions download", () => { cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), }), proxy.layer, + child.layer, Stdio.layerTest({ args: Effect.succeed([ "functions", diff --git a/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts b/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts index 8391ab53d7..a0c9dcd02e 100644 --- a/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts +++ b/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts @@ -14,7 +14,7 @@ import { mockLegacyTelemetryStateTracked, useLegacyTempWorkdir, } from "../../../../../tests/helpers/legacy-mocks.ts"; -import { toDockerPath } from "../../../../shared/functions/deploy.ts"; +import { toDockerPath } from "../../../../shared/functions/functions-docker.ts"; import { mockOutput, mockProcessControl, @@ -72,10 +72,10 @@ const deployMockState = vi.hoisted(() => ({ }, })); -vi.mock("../../../../shared/functions/deploy.ts", async () => { - const actual = await vi.importActual( - "../../../../shared/functions/deploy.ts", - ); +vi.mock("../../../../shared/functions/functions-docker.ts", async () => { + const actual = await vi.importActual< + typeof import("../../../../shared/functions/functions-docker.ts") + >("../../../../shared/functions/functions-docker.ts"); const { Effect } = await import("effect"); return { diff --git a/apps/cli/src/legacy/commands/start/lib/container-lifecycle.ts b/apps/cli/src/legacy/commands/start/lib/container-lifecycle.ts index 96d39ce92b..c5cb7ee66d 100644 --- a/apps/cli/src/legacy/commands/start/lib/container-lifecycle.ts +++ b/apps/cli/src/legacy/commands/start/lib/container-lifecycle.ts @@ -30,7 +30,7 @@ import { LEGACY_CLI_PROJECT_LABEL, LEGACY_CLI_WORKDIR_LABEL, } from "../../../shared/legacy-docker-ids.ts"; -import { isUserDefinedDockerNetwork } from "../../../../shared/functions/deploy.ts"; +import { isUserDefinedDockerNetwork } from "../../../../shared/functions/functions-docker.ts"; import { legacyBuildStartContainerCreateArgs, legacyApplyBitbucketStartContainerFilter, @@ -54,9 +54,10 @@ type Spawner = ChildProcessSpawner["Service"]; * otherwise silently stop recognizing the local stack's containers. * * A same-value private constant already exists at - * `shared/functions/deploy.ts` (`dockerComposeProjectLabel`, for the unrelated - * `functions deploy` Docker Desktop extension gateway) but is neither exported - * nor in the same Docker-usage domain as `start` — not hoisted from there. + * `shared/functions/functions-docker.ts` (`dockerComposeProjectLabel`, for the + * unrelated `functions deploy`/`functions serve` Docker Desktop extension + * gateway) but is neither exported nor in the same Docker-usage domain as + * `start` — not hoisted from there. */ export const LEGACY_COMPOSE_PROJECT_LABEL = "com.docker.compose.project"; @@ -229,8 +230,9 @@ function legacyPortConflictSuggestion(hostPort: string, serviceLabel: string): s * created (`docker network create host` errors with "operation is not * permitted on predefined host network"), so this returns immediately without * spawning `docker network create` at all for those names, reusing the same - * `isUserDefinedDockerNetwork` check `shared/functions/deploy.ts` already - * applies for the unrelated `functions deploy` extension-gateway network. + * `isUserDefinedDockerNetwork` check `shared/functions/functions-docker.ts` + * already applies for the unrelated `functions deploy`/`functions serve` + * extension-gateway network. */ export function legacyEnsureStartNetwork( spawner: Spawner, diff --git a/apps/cli/src/next/commands/functions/deploy/deploy.handler.ts b/apps/cli/src/next/commands/functions/deploy/deploy.handler.ts index acd17b7a1b..8d31bfaaf6 100644 --- a/apps/cli/src/next/commands/functions/deploy/deploy.handler.ts +++ b/apps/cli/src/next/commands/functions/deploy/deploy.handler.ts @@ -1,12 +1,10 @@ -import { DEFAULT_VERSIONS } from "@supabase/stack/effect"; -import { readFile } from "node:fs/promises"; -import { join } from "node:path"; import { Effect, Stdio } from "effect"; import { CliConfig } from "../../../config/cli-config.service.ts"; import { PlatformApi } from "../../../auth/platform-api.service.ts"; import { ProjectHome } from "../../../config/project-home.service.ts"; import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; import { deployFunctions } from "../../../../shared/functions/deploy.ts"; +import { resolveEdgeRuntimeVersionPin } from "../../../../shared/functions/functions.shared.ts"; import { resolveProjectRef } from "../functions.shared.ts"; import type { FunctionsDeployFlags } from "./deploy.command.ts"; @@ -19,13 +17,7 @@ export const functionsDeploy = Effect.fn("functions.deploy")(function* ( const runtimeInfo = yield* RuntimeInfo; const stdio = yield* Stdio.Stdio; const rawArgs = yield* stdio.args; - const edgeRuntimeVersion = yield* Effect.tryPromise(() => - readFile(join(projectHome.supabaseDir, ".temp", "edge-runtime-version"), "utf8"), - ).pipe( - Effect.map((version) => version.trim()), - Effect.catch(() => Effect.succeed("")), - Effect.map((version) => version || DEFAULT_VERSIONS["edge-runtime"]), - ); + const edgeRuntimeVersion = yield* resolveEdgeRuntimeVersionPin(projectHome.supabaseDir); yield* deployFunctions(flags, { api, diff --git a/apps/cli/src/next/commands/functions/download/download.command.ts b/apps/cli/src/next/commands/functions/download/download.command.ts index 4432db7c0b..aecd8adc61 100644 --- a/apps/cli/src/next/commands/functions/download/download.command.ts +++ b/apps/cli/src/next/commands/functions/download/download.command.ts @@ -25,6 +25,7 @@ const config = { ), useDocker: Flag.boolean("use-docker").pipe( Flag.withDescription("Use Docker to unbundle functions client-side."), + Flag.withDefault(true), Flag.withHidden, ), legacyBundle: Flag.boolean("legacy-bundle").pipe( diff --git a/apps/cli/src/next/commands/functions/download/download.handler.ts b/apps/cli/src/next/commands/functions/download/download.handler.ts index 15c6238e84..bb63eaf913 100644 --- a/apps/cli/src/next/commands/functions/download/download.handler.ts +++ b/apps/cli/src/next/commands/functions/download/download.handler.ts @@ -3,8 +3,9 @@ import { PlatformApi } from "../../../auth/platform-api.service.ts"; import { ProjectHome } from "../../../config/project-home.service.ts"; import { downloadFunctions, - makeGoProxyDownloadArgs, + makeGoProxyLegacyBundleArgs, } from "../../../../shared/functions/download.ts"; +import { resolveEdgeRuntimeVersionPin } from "../../../../shared/functions/functions.shared.ts"; import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; import { resolveProjectRef } from "../functions.shared.ts"; import type { FunctionsDownloadFlags } from "./download.command.ts"; @@ -15,17 +16,20 @@ export const functionsDownload = Effect.fnUntraced(function* (flags: FunctionsDo const proxy = yield* LegacyGoProxy; const stdio = yield* Stdio.Stdio; const rawArgs = yield* stdio.args; + const edgeRuntimeVersion = yield* resolveEdgeRuntimeVersionPin(projectHome.supabaseDir); yield* downloadFunctions(flags, { api, projectRoot: projectHome.projectRoot, rawArgs, + goViperCompat: false, + edgeRuntimeVersion, resolveProjectRef, // In machine-output mode the child's stdout is captured and discarded // instead of inherited (CLI-1546: stdout is payload-only in machine // mode) — `downloadFunctions` emits the `Output` envelope itself. proxyDownload: (proxyFlags, projectRef, captureOutput) => { - const args = makeGoProxyDownloadArgs(proxyFlags, projectRef); + const args = makeGoProxyLegacyBundleArgs(proxyFlags.functionName, projectRef); const cwd = projectHome.projectRoot; return captureOutput ? Effect.asVoid(proxy.execCapture(args, { cwd, stdin: "ignore" })) diff --git a/apps/cli/src/next/commands/functions/download/download.integration.test.ts b/apps/cli/src/next/commands/functions/download/download.integration.test.ts index 9c1fa1a75d..72f23a0413 100644 --- a/apps/cli/src/next/commands/functions/download/download.integration.test.ts +++ b/apps/cli/src/next/commands/functions/download/download.integration.test.ts @@ -23,6 +23,7 @@ import { mockProjectLinkState, mockRuntimeInfo, } from "../../../../../tests/helpers/mocks.ts"; +import { mockChildProcessSpawner } from "../../../../../../../packages/process-compose/tests/helpers/mocks.ts"; import type { FunctionsDownloadFlags } from "./download.command.ts"; import { ConflictingFunctionDownloadFlagsError, @@ -30,6 +31,7 @@ import { InvalidFunctionSlugError, UnsafeFunctionDownloadPathError, } from "../../../../shared/functions/download.errors.ts"; +import { invalidFunctionSlugDetail } from "../../../../shared/functions/functions.shared.ts"; import { functionsDownload } from "./download.handler.ts"; const PROJECT_REF = "abcdefghijklmnopqrst"; @@ -74,6 +76,7 @@ function textResponse( status: number, body: ResponseBody = "", contentType = "text/plain", + extraHeaders: Readonly> = {}, ): HttpClientResponse.HttpClientResponse { return HttpClientResponse.fromWeb( request, @@ -81,6 +84,7 @@ function textResponse( status, headers: { "content-type": contentType, + ...extraHeaders, }, }), ); @@ -182,7 +186,15 @@ function mockDownloadApi(opts: { functionStatusBySlug?: Readonly>; functionBodyBySlug?: Readonly>; bodyBySlug?: Readonly< - Record + Record< + string, + { + status?: number; + body: ResponseBody; + contentType: string; + headers?: Readonly>; + } + > >; bodyErrorBySlug?: Readonly>; }) { @@ -233,6 +245,7 @@ function mockDownloadApi(opts: { response?.status ?? 200, response?.body ?? "", response?.contentType ?? "multipart/form-data; boundary=missing", + response?.headers ?? {}, ), ); } @@ -277,6 +290,7 @@ function setup( linked?: boolean; projectRoot?: string; rawArgs?: ReadonlyArray; + childLayer?: ReturnType["layer"]; } = {}, ) { const out = mockOutput({ format: opts.format ?? "text", interactive: false }); @@ -293,6 +307,10 @@ function setup( Stdio.layerTest({ args: Effect.succeed(opts.rawArgs ?? ["functions", "download"]), }), + // Overrides `emptyEnv()`'s real `ChildProcessSpawner` (via `BunServices`) + // so `--use-docker`'s now-default-true native path never spawns a real + // `docker` process — CLI-1963. + opts.childLayer ?? mockChildProcessSpawner({ exitCode: 0 }).layer, ); return { out, api, layer, proxy }; @@ -709,43 +727,40 @@ describe("functions download", () => { ); }); - it.live("downloads remote slugs from download-all without local slug validation", () => { + it.live("rejects a malicious remote slug from download-all before any per-slug work", () => { const tempDir = makeTempDir(); - const multipart = multipartBody([ - { - headers: { - "Content-Disposition": 'form-data; name="metadata"', - "Content-Type": "application/json", - }, - body: JSON.stringify({ deno2_entrypoint_path: "source/index.ts" }), - }, - { - headers: { - "Content-Disposition": 'form-data; name="file"; filename="source/index.ts"', - }, - body: "console.log('remote')", - }, - ]); + // Mirrors Go's own `TestDownloadAllRejectsMaliciousSlug` regression test + // (`apps/cli-go/internal/functions/download/download_test.go`) — a + // path-traversal-shaped slug returned by the (untrusted) list endpoint. + const maliciousSlug = "../../../../../poc-escaped-outside-project"; return Effect.gen(function* () { yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); - const { layer } = setup(tempDir, { - list: [makeFunction({ slug: "1remote" })], - bodyBySlug: { - "1remote": multipart, - }, + const { api, layer } = setup(tempDir, { + list: [makeFunction({ slug: maliciousSlug })], }); - yield* functionsDownload({ + // CLI-1891 (Go parity): every slug sourced from the Management API's + // function list must be validated before any per-slug network or + // filesystem work — not just user-supplied CLI arguments. + const error = yield* functionsDownload({ ...BASE_FLAGS, functionName: Option.none(), - }).pipe(Effect.provide(layer)); + }).pipe(Effect.provide(layer), Effect.flip); - expect( - yield* Effect.tryPromise(() => - readFile(join(tempDir, "supabase", "functions", "1remote", "index.ts"), "utf8"), - ), - ).toBe("console.log('remote')"); + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe( + `failed to download function ${maliciousSlug}: ${invalidFunctionSlugDetail}`, + ); + expect((error as Error & { suggestion?: string }).suggestion).toBe( + `The Supabase API returned an unexpected function slug (${maliciousSlug}). Retry the command, and if this keeps happening, verify your network connection is not being intercepted before contacting Supabase support.`, + ); + // Only the list call happened — no GET to the malicious slug's own + // body/metadata endpoints, and nothing was written to disk. + expect(api.requests).toEqual([ + `https://api.supabase.com/v1/projects/${PROJECT_REF}/functions`, + ]); + expect(existsSync(join(tempDir, "supabase", "functions"))).toBe(false); }).pipe( Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), ); @@ -851,56 +866,125 @@ describe("functions download", () => { ); }); - it.live("delegates --use-docker with the linked project ref to the Go proxy", () => { + it.live( + "runs the native Docker unbundle path for --use-docker with the linked project ref", + () => { + const tempDir = makeTempDir(); + const child = mockChildProcessSpawner({ exitCode: 0 }); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); + const { out, layer, proxy } = setup(tempDir, { + bodyBySlug: { + "hello-world": { body: "fake-eszip-bytes", contentType: "application/octet-stream" }, + }, + rawArgs: ["functions", "download", "hello-world", "--use-docker"], + childLayer: child.layer, + }); + + // CLI-1963: `--use-docker` now runs the native Docker-unbundle path + // instead of delegating to the Go proxy. + yield* functionsDownload({ + ...BASE_FLAGS, + useDocker: true, + }).pipe(Effect.provide(layer)); + + expect(proxy.calls).toEqual([]); + expect(proxy.captureCalls).toEqual([]); + const runCommand = child.spawned.find( + (spawned) => spawned.command === "docker" && spawned.args[0] === "run", + ); + expect(runCommand?.args).toContain("unbundle"); + expect(out.stderrText).toContain("Downloading function: hello-world\n"); + // No `--debug` — the temp eszip file is removed after the run. + expect(existsSync(join(tempDir, "supabase", ".temp", "output_hello-world.eszip"))).toBe( + false, + ); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }, + ); + + it.live("runs the native Docker path and emits a JSON envelope in machine mode", () => { const tempDir = makeTempDir(); + const child = mockChildProcessSpawner({ exitCode: 0 }); return Effect.gen(function* () { yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); - const { layer, proxy } = setup(tempDir, { + const { out, layer, proxy } = setup(tempDir, { + format: "json", + bodyBySlug: { + "hello-world": { body: "fake-eszip-bytes", contentType: "application/octet-stream" }, + }, rawArgs: ["functions", "download", "hello-world", "--use-docker"], + childLayer: child.layer, }); + // CLI-1963: `--use-docker` now runs the native Docker-unbundle path; + // this asserts the JSON envelope this command emits itself still + // shows up correctly once the native path is exercised in machine mode. yield* functionsDownload({ ...BASE_FLAGS, useDocker: true, }).pipe(Effect.provide(layer)); - expect(proxy.calls).toEqual([ - ["functions", "download", "hello-world", "--project-ref", PROJECT_REF, "--use-docker"], - ]); + expect(proxy.calls).toEqual([]); + expect(proxy.captureCalls).toEqual([]); + expect( + child.spawned.some((spawned) => spawned.command === "docker" && spawned.args[0] === "run"), + ).toBe(true); + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "success", + message: "Downloaded Edge Function source.", + data: { + function_slugs: ["hello-world"], + project_ref: PROJECT_REF, + }, + }), + ); }).pipe( Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), ); }); - it.live("captures the Go proxy's output and emits a JSON envelope in machine mode", () => { + it.live("lists remote functions and downloads each natively via Docker in machine mode", () => { const tempDir = makeTempDir(); + const child = mockChildProcessSpawner({ exitCode: 0 }); return Effect.gen(function* () { yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); const { out, layer, proxy } = setup(tempDir, { format: "json", - rawArgs: ["functions", "download", "hello-world", "--use-docker"], + list: [makeFunction({ slug: "hello-world" }), makeFunction({ slug: "goodbye-world" })], + bodyBySlug: { + "hello-world": { body: "fake-eszip-bytes", contentType: "application/octet-stream" }, + "goodbye-world": { body: "fake-eszip-bytes", contentType: "application/octet-stream" }, + }, + rawArgs: ["functions", "download", "--use-docker"], + childLayer: child.layer, }); - // CLI-1546: stdout is payload-only in machine mode, so the delegated - // Go child's raw output must be captured/discarded (not inherited), - // and this command must emit the `Output` envelope itself. yield* functionsDownload({ ...BASE_FLAGS, + functionName: Option.none(), useDocker: true, }).pipe(Effect.provide(layer)); expect(proxy.calls).toEqual([]); - expect(proxy.captureCalls).toEqual([ - ["functions", "download", "hello-world", "--project-ref", PROJECT_REF, "--use-docker"], - ]); + expect(proxy.captureCalls).toEqual([]); + expect( + child.spawned.filter( + (spawned) => spawned.command === "docker" && spawned.args[0] === "run", + ), + ).toHaveLength(2); expect(out.messages).toContainEqual( expect.objectContaining({ type: "success", message: "Downloaded Edge Function source.", data: { - function_slugs: ["hello-world"], + function_slugs: ["hello-world", "goodbye-world"], project_ref: PROJECT_REF, }, }), @@ -911,38 +995,130 @@ describe("functions download", () => { }); it.live( - "lists remote functions before delegating when no function name is given in machine mode", + "defaults --use-docker to true so a bare invocation still runs the native Docker path", () => { const tempDir = makeTempDir(); + const child = mockChildProcessSpawner({ exitCode: 0 }); return Effect.gen(function* () { yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); - const { out, layer, proxy } = setup(tempDir, { - format: "json", - list: [makeFunction({ slug: "hello-world" }), makeFunction({ slug: "goodbye-world" })], - rawArgs: ["functions", "download", "--use-docker"], + const { layer, proxy } = setup(tempDir, { + bodyBySlug: { + "hello-world": { body: "fake-eszip-bytes", contentType: "application/octet-stream" }, + }, + // No `--use-docker` at all — mirrors a bare `supabase functions + // download hello-world` invocation relying on the flag's default. + rawArgs: ["functions", "download", "hello-world"], + childLayer: child.layer, }); + // `useDocker: true` is what `download.command.ts`'s + // `Flag.withDefault(true)` resolves to when the flag is omitted + // (CLI-1963 parity fix — `next` was previously missing this default, + // unlike the legacy shell's equivalent command). yield* functionsDownload({ ...BASE_FLAGS, - functionName: Option.none(), useDocker: true, }).pipe(Effect.provide(layer)); expect(proxy.calls).toEqual([]); - expect(proxy.captureCalls).toEqual([ - ["functions", "download", "--project-ref", PROJECT_REF, "--use-docker"], - ]); - expect(out.messages).toContainEqual( - expect.objectContaining({ - type: "success", - message: "Downloaded Edge Function source.", - data: { - function_slugs: ["hello-world", "goodbye-world"], - project_ref: PROJECT_REF, + expect( + child.spawned.some( + (spawned) => spawned.command === "docker" && spawned.args[0] === "run", + ), + ).toBe(true); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }, + ); + + it.live( + "falls back to the native server-side path with a warning when Docker is not running", + () => { + const tempDir = makeTempDir(); + const child = mockChildProcessSpawner({ exitCode: 1 }); + const multipart = multipartBody([ + { + headers: { + "Content-Disposition": 'form-data; name="metadata"', + "Content-Type": "application/json", + }, + body: JSON.stringify({ deno2_entrypoint_path: "source/index.ts" }), + }, + { + headers: { + "Content-Disposition": 'form-data; name="file"; filename="source/index.ts"', + }, + body: "console.log('fallback')", + }, + ]); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); + const { out, layer } = setup(tempDir, { + bodyBySlug: { "hello-world": multipart }, + rawArgs: ["functions", "download", "hello-world", "--use-docker"], + childLayer: child.layer, + }); + + yield* functionsDownload({ + ...BASE_FLAGS, + useDocker: true, + }).pipe(Effect.provide(layer)); + + expect(child.spawned).toEqual([{ command: "docker", args: ["info"] }]); + expect(out.stderrText).toContain("WARNING: Docker is not running\n"); + expect( + yield* Effect.tryPromise(() => + readFile(join(tempDir, "supabase", "functions", "hello-world", "index.ts"), "utf8"), + ), + ).toBe("console.log('fallback')"); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }, + ); + + it.live( + "writes the eszip response body to disk exactly as received, regardless of Content-Encoding", + () => { + const tempDir = makeTempDir(); + const child = mockChildProcessSpawner({ exitCode: 0 }); + // Arbitrary binary bytes, not valid brotli — this mocked `Response` (a + // hand-built `new Response(body, {...})`, unlike a real `fetch()`) + // never applies transport-level content-decoding, so a + // `Content-Encoding: br` header here must have zero effect on what + // `downloadEszipBody` does with it. If production code ever tried to + // brotli-decompress this body again, decompression itself would throw + // on these bytes, failing this test. + const rawEszipBytes = new Uint8Array([0, 1, 2, 253, 254, 255, 10, 13, 0, 128, 200]); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); + const { layer } = setup(tempDir, { + bodyBySlug: { + "hello-world": { + body: new Blob([rawEszipBytes]), + contentType: "application/octet-stream", + headers: { "content-encoding": "br" }, }, - }), + }, + // `--debug` keeps the temp eszip file on disk after a successful + // run so this test can inspect the exact bytes that were written. + rawArgs: ["functions", "download", "hello-world", "--use-docker", "--debug"], + childLayer: child.layer, + }); + + yield* functionsDownload({ + ...BASE_FLAGS, + useDocker: true, + }).pipe(Effect.provide(layer)); + + const written = yield* Effect.tryPromise(() => + readFile(join(tempDir, "supabase", ".temp", "output_hello-world.eszip")), ); + expect(new Uint8Array(written)).toEqual(rawEszipBytes); }).pipe( Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), ); @@ -1346,6 +1522,65 @@ describe("functions download", () => { ); }); + it.live("maps eszip body transport errors with Go-style wording (Docker path)", () => { + const tempDir = makeTempDir(); + const child = mockChildProcessSpawner({ exitCode: 0 }); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); + const { layer } = setup(tempDir, { + bodyErrorBySlug: { + "hello-world": new Error("network error"), + }, + rawArgs: ["functions", "download", "hello-world", "--use-docker"], + childLayer: child.layer, + }); + + // `downloadEszipBody` (the Docker path's own GET) uses a distinct + // error prefix ("failed to get function body") from the server-side + // `downloadBody`'s ("failed to download function") — Go parity. + const error = yield* functionsDownload({ + ...BASE_FLAGS, + useDocker: true, + }).pipe(Effect.provide(layer), Effect.flip); + + expect(error).toBeInstanceOf(Error); + expect(error.message).toBe("failed to get function body: network error"); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }); + + it.live("maps unexpected eszip body statuses with Go-style wording (Docker path)", () => { + const tempDir = makeTempDir(); + const child = mockChildProcessSpawner({ exitCode: 0 }); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); + const { layer } = setup(tempDir, { + bodyBySlug: { + "hello-world": { + status: 503, + body: "unavailable", + contentType: "text/plain", + }, + }, + rawArgs: ["functions", "download", "hello-world", "--use-docker"], + childLayer: child.layer, + }); + + const error = yield* functionsDownload({ + ...BASE_FLAGS, + useDocker: true, + }).pipe(Effect.provide(layer), Effect.flip); + + expect(error).toBeInstanceOf(Error); + expect(error.message).toBe("Error status 503: unavailable"); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }); + it.live("maps metadata fallback transport errors with Go-style wording", () => { const tempDir = makeTempDir(); const multipart = multipartBody([ diff --git a/apps/cli/src/shared/cli/cobra-flag-groups.ts b/apps/cli/src/shared/cli/cobra-flag-groups.ts index 8b2d6c0727..2ec3d23d3d 100644 --- a/apps/cli/src/shared/cli/cobra-flag-groups.ts +++ b/apps/cli/src/shared/cli/cobra-flag-groups.ts @@ -28,6 +28,31 @@ export function hasExplicitLongFlag( return false; } +/** + * Raw value of `--`/`--=value` anywhere in argv + * (unscoped — no command-path anchoring), or `undefined` if absent. + */ +export function explicitStringFlag(rawArgs: ReadonlyArray, flagName: string) { + for (let index = 0; index < rawArgs.length; index += 1) { + const token = rawArgs[index]; + if (token === `--${flagName}`) { + return rawArgs[index + 1]; + } + if (token?.startsWith(`--${flagName}=`)) { + return token.slice(flagName.length + 3); + } + } + return undefined; +} + +/** + * Whether `--` (or `--=`) appears anywhere in argv, + * unscoped. + */ +export function hasGlobalLongFlag(rawArgs: ReadonlyArray, flagName: string) { + return rawArgs.some((token) => token === `--${flagName}` || token.startsWith(`--${flagName}=`)); +} + /** * Value-taking long flags registered persistently on the Go root command * (`apps/cli-go/cmd/root.go:324-333`: `--workdir`, `--network-id`, diff --git a/apps/cli/src/shared/cli/hidden-flag.unit.test.ts b/apps/cli/src/shared/cli/hidden-flag.unit.test.ts index 7c9a9bbe07..1cab6b610b 100644 --- a/apps/cli/src/shared/cli/hidden-flag.unit.test.ts +++ b/apps/cli/src/shared/cli/hidden-flag.unit.test.ts @@ -137,13 +137,30 @@ describe("native hidden flags", () => { "--backup=false", ]).pipe(Effect.exit); expect(JSON.stringify(stopExit)).not.toContain("UnrecognizedFlag"); - yield* Command.runWith(legacyTestRoot, { version: "0.0.0-test" })([ + // `functions download --use-docker` now runs the native Docker-unbundle + // path (CLI-1963) instead of forwarding to `LegacyGoProxy` — it can fail + // for Docker-related reasons in this proxy-only test layer, same as + // `start`/`stop` above, so this only proves the hidden flag still parses. + // `--legacy-bundle` is the one remaining case that still forwards to the + // proxy, asserted below. + const downloadUseDockerExit = yield* Command.runWith(legacyTestRoot, { + version: "0.0.0-test", + })([ "functions", "download", "hello", "--project-ref", "abcdefghijklmnopqrst", "--use-docker", + ]).pipe(Effect.exit); + expect(JSON.stringify(downloadUseDockerExit)).not.toContain("UnrecognizedFlag"); + yield* Command.runWith(legacyTestRoot, { version: "0.0.0-test" })([ + "functions", + "download", + "hello", + "--project-ref", + "abcdefghijklmnopqrst", + "--legacy-bundle", ]); const useDockerExit = yield* Command.runWith(legacyTestRoot, { version: "0.0.0-test", @@ -171,7 +188,14 @@ describe("native hidden flags", () => { ); expect(proxy.calls).toEqual([ - ["functions", "download", "hello", "--project-ref", "abcdefghijklmnopqrst", "--use-docker"], + [ + "functions", + "download", + "hello", + "--project-ref", + "abcdefghijklmnopqrst", + "--legacy-bundle", + ], ]); }); diff --git a/apps/cli/src/shared/functions/deploy.ts b/apps/cli/src/shared/functions/deploy.ts index 1043a3a149..7d59849035 100644 --- a/apps/cli/src/shared/functions/deploy.ts +++ b/apps/cli/src/shared/functions/deploy.ts @@ -8,19 +8,19 @@ import { loadProjectConfig, type ResolvedFunctionConfig as ManifestFunctionConfig, } from "@supabase/config"; -import { Duration, Effect, Option, Schema, Stream } from "effect"; -import { ChildProcessSpawner } from "effect/unstable/process"; +import { Duration, Effect, Option, Schema } from "effect"; import * as HttpClientError from "effect/unstable/http/HttpClientError"; import { legacyPromptYesNo } from "../legacy/legacy-prompt-yes-no.ts"; import { CONTEXT_CANCELED_MESSAGE } from "../output/errors.ts"; import { Output } from "../output/output.service.ts"; -import { spawnContainerCli } from "../../legacy/shared/legacy-container-cli.ts"; import { legacyBold } from "../../legacy/shared/legacy-colors.ts"; import { legacyGetRegistryImageUrl } from "../../legacy/shared/legacy-docker-registry.ts"; import { findGitRootPath } from "../git/git-root.ts"; import { cobraMutuallyExclusiveErrorMessage, + explicitStringFlag, hasExplicitLongFlag, + hasGlobalLongFlag, } from "../cli/cobra-flag-groups.ts"; import { FUNCTIONS_BUNDLER_MUTEX_GROUP, @@ -33,14 +33,21 @@ import { InvalidFunctionDeploySlugError, NoFunctionsToDeployError, } from "./deploy.errors.ts"; +import { + ensureDockerNamedVolume, + ensureDockerNetwork, + isDockerRunning, + localDockerId, + resolveEdgeRuntimeVersion, + runChildProcess, + toDockerPath, + toSlash, +} from "./functions-docker.ts"; const COMPRESSED_ESZIP_MAGIC = "EZBR"; -const DENO1_EDGE_RUNTIME_VERSION = "1.68.4"; const DEPLOY_RATE_LIMIT_MAX_RETRIES = 8; const SUPABASE_FUNCTIONS_DIR = "supabase/functions"; const IMPORT_MAP_GUIDE_URL = "https://supabase.com/docs/guides/functions/import-maps"; -const INVALID_PROJECT_ID = /[^a-zA-Z0-9_.-]+/g; -const MAX_PROJECT_ID_LENGTH = 40; const WINDOWS_ABSOLUTE_PATH = /^[A-Za-z]:\//; const importPathPattern = /(?:import|export)\s+(?:type\s+)?(?:{[^{}]+}|.*?)\s*(?:from)?\s*['"](.*?)['"]|import\(\s*['"](.*?)['"]\)/gi; @@ -213,6 +220,18 @@ function validateDeploySlug(slug: string): Effect.Effect` was passed + * explicitly after `commandPath`, matching cobra's `Changed()`; + * `Option.none()` otherwise. Used only by `deployFunctions`'s + * `--no-verify-jwt` override below — kept private per this file's own + * "used by one command only -> keep it in the command's own directory" rule. + */ function explicitBooleanFlag( rawArgs: ReadonlyArray, commandPath: ReadonlyArray, @@ -222,45 +241,6 @@ function explicitBooleanFlag( return hasExplicitLongFlag(rawArgs, commandPath, flagName) ? Option.some(value) : Option.none(); } -function explicitStringFlag(rawArgs: ReadonlyArray, flagName: string) { - for (let index = 0; index < rawArgs.length; index += 1) { - const token = rawArgs[index]; - if (token === `--${flagName}`) { - return rawArgs[index + 1]; - } - if (token?.startsWith(`--${flagName}=`)) { - return token.slice(flagName.length + 3); - } - } - return undefined; -} - -function hasGlobalLongFlag(rawArgs: ReadonlyArray, flagName: string) { - return rawArgs.some((token) => token === `--${flagName}` || token.startsWith(`--${flagName}=`)); -} - -function isDenoConfigFile(pathname: string) { - const name = basename(pathname).toLowerCase(); - return name === "deno.json" || name === "deno.jsonc"; -} - -function toSlash(pathname: string) { - return pathname.replaceAll("\\", "/"); -} - -export function normalizeProjectId(source: string) { - const sanitized = source.replaceAll(INVALID_PROJECT_ID, "_").replace(/^[_.-]+/, ""); - return sanitized.length > MAX_PROJECT_ID_LENGTH - ? sanitized.slice(0, MAX_PROJECT_ID_LENGTH) - : sanitized; -} - -export function localDockerId(name: string, projectId: string) { - return `supabase_${name}_${normalizeProjectId(projectId)}`; -} - -const dockerCliProjectLabel = "com.supabase.cli.project"; -const dockerComposeProjectLabel = "com.docker.compose.project"; /** * Must stay in sync with `LEGACY_CLI_WORKDIR_LABEL` * (`legacy/shared/legacy-docker-ids.ts:95`) — same string literal, kept as a @@ -280,18 +260,6 @@ export const dockerWorkdirLabel = "com.supabase.cli.workdir"; */ const dockerNpmEnvNames = ["NPM_CONFIG_REGISTRY"] as const; -export function dockerProjectLabels(projectId: string) { - return { - [dockerCliProjectLabel]: projectId, - [dockerComposeProjectLabel]: projectId, - }; -} - -export function toDockerPath(hostPath: string) { - const normalized = toSlash(resolve(hostPath)); - return normalized.replace(/^[A-Za-z]:/, ""); -} - function toBundledFileUrl(hostPath: string) { const url = new URL("file:///"); url.pathname = toDockerPath(hostPath).replaceAll("%", "%25"); @@ -1107,15 +1075,6 @@ function createBundledMetadata( }; } -function collectByteStream(stream: Stream.Stream) { - const decoder = new TextDecoder(); - return Stream.runFold( - stream, - () => "", - (text, chunk) => text + decoder.decode(chunk, { stream: true }), - ).pipe(Effect.map((text) => text + decoder.decode())); -} - function sanitizeDockerBinds( binds: ReadonlyArray, functionsDir: string, @@ -1261,84 +1220,6 @@ function shouldUseDenoJsonDiscovery(entrypoint: string, importMap: string) { return isDenoConfigFile(importMap) && dirname(importMap) === dirname(entrypoint); } -export function isUserDefinedDockerNetwork(networkMode: string) { - return ( - networkMode.length > 0 && - networkMode !== "default" && - networkMode !== "bridge" && - networkMode !== "host" && - networkMode !== "none" - ); -} - -export const ensureDockerNetwork = Effect.fnUntraced(function* ( - networkMode: string, - projectId: string, -) { - if (!isUserDefinedDockerNetwork(networkMode)) { - return; - } - - const inspect = yield* runChildProcess("docker", ["network", "inspect", networkMode], { - stdout: "ignore", - stderr: "ignore", - }).pipe(Effect.catch(() => Effect.succeed({ exitCode: 1, stdout: "", stderr: "" }))); - if (inspect.exitCode === 0) { - return; - } - - const labels = dockerProjectLabels(projectId); - const create = yield* runChildProcess( - "docker", - [ - "network", - "create", - "--label", - `${dockerCliProjectLabel}=${labels[dockerCliProjectLabel]}`, - "--label", - `${dockerComposeProjectLabel}=${labels[dockerComposeProjectLabel]}`, - networkMode, - ], - { - stdout: "ignore", - stderr: "pipe", - }, - ); - if (create.exitCode !== 0 && !create.stderr.includes("already exists")) { - return yield* Effect.fail(new Error(`failed to create docker network: ${networkMode}`)); - } -}); - -export const ensureDockerNamedVolume = Effect.fnUntraced(function* ( - volumeName: string, - projectId: string, -) { - if (process.env["BITBUCKET_CLONE_DIR"] !== undefined) { - return; - } - - const labels = dockerProjectLabels(projectId); - const create = yield* runChildProcess( - "docker", - [ - "volume", - "create", - "--label", - `${dockerCliProjectLabel}=${labels[dockerCliProjectLabel]}`, - "--label", - `${dockerComposeProjectLabel}=${labels[dockerComposeProjectLabel]}`, - volumeName, - ], - { - stdout: "ignore", - stderr: "pipe", - }, - ); - if (create.exitCode !== 0 && !create.stderr.includes("already exists")) { - return yield* Effect.fail(new Error(`failed to create docker volume: ${volumeName}`)); - } -}); - async function shouldUsePackageJsonDiscovery(entrypoint: string, importMap: string) { if (importMap.length > 0) { return false; @@ -1351,48 +1232,6 @@ async function shouldUsePackageJsonDiscovery(entrypoint: string, importMap: stri } } -// Runs a container CLI command and collects its output. Every caller runs -// `docker`, so the spawn goes through `spawnContainerCli` to fall back to -// `podman` on Docker-less hosts. `command` is retained for the extendEnv -// default and the `functions serve` dependency-injection seam. -export const runChildProcess = Effect.fnUntraced(function* ( - command: string, - args: ReadonlyArray, - opts: { - readonly stdout?: "pipe" | "ignore"; - readonly stderr?: "pipe" | "ignore"; - readonly env?: Readonly>; - readonly extendEnv?: boolean; - } = {}, -) { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const child = yield* spawnContainerCli(spawner, [...args], { - stdin: "ignore", - stdout: opts.stdout ?? "pipe", - stderr: opts.stderr ?? "pipe", - env: opts.env, - extendEnv: opts.extendEnv ?? command === "docker", - }); - - const [stdout, stderr, exitCode] = yield* Effect.all( - [ - opts.stdout === "ignore" ? Effect.succeed("") : collectByteStream(child.stdout), - opts.stderr === "ignore" ? Effect.succeed("") : collectByteStream(child.stderr), - child.exitCode.pipe(Effect.map(Number)), - ], - { concurrency: "unbounded" }, - ); - return { exitCode, stdout, stderr }; -}); - -const isDockerRunning = Effect.fnUntraced(function* () { - const result = yield* runChildProcess("docker", ["info"], { - stdout: "ignore", - stderr: "ignore", - }).pipe(Effect.catch(() => Effect.succeed({ exitCode: 1, stdout: "", stderr: "" }))); - return result.exitCode === 0; -}); - const bundleFunctionWithDocker = Effect.fnUntraced(function* ( projectId: string, edgeRuntimeVersion: string, @@ -2161,21 +2000,6 @@ const deployViaDocker = Effect.fnUntraced(function* ( } }); -export function resolveEdgeRuntimeVersion( - denoVersion: number | undefined, - defaultVersion: string, -): Effect.Effect { - if (denoVersion === undefined || denoVersion === 2) { - return Effect.succeed(defaultVersion); - } - if (denoVersion === 1) { - return Effect.succeed(DENO1_EDGE_RUNTIME_VERSION); - } - return Effect.fail( - new Error(`Failed reading config: Invalid edge_runtime.deno_version: ${denoVersion}.`), - ); -} - const pruneFunctions = Effect.fnUntraced(function* ( projectRef: string, configs: ReadonlyArray, diff --git a/apps/cli/src/shared/functions/download.ts b/apps/cli/src/shared/functions/download.ts index 7b9882f6dd..bff0926d2c 100644 --- a/apps/cli/src/shared/functions/download.ts +++ b/apps/cli/src/shared/functions/download.ts @@ -1,6 +1,7 @@ import { operationDefinitions, type ApiClient } from "@supabase/api/effect"; +import { loadProjectConfig } from "@supabase/config"; import { randomUUID } from "node:crypto"; -import { open, rename, rm } from "node:fs/promises"; +import { mkdir, open, rename, rm, writeFile } from "node:fs/promises"; import { dirname, isAbsolute, join, posix, relative, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; import { Effect, FileSystem, Option } from "effect"; @@ -9,8 +10,20 @@ import type * as HttpClientResponse from "effect/unstable/http/HttpClientRespons import { Output } from "../output/output.service.ts"; import { cobraMutuallyExclusiveErrorMessage, + explicitStringFlag, hasExplicitLongFlag, + hasGlobalLongFlag, } from "../cli/cobra-flag-groups.ts"; +import { legacyDescribeContainerCliFailure } from "../../legacy/shared/legacy-container-cli.ts"; +import { legacyGetRegistryImageUrl } from "../../legacy/shared/legacy-docker-registry.ts"; +import { + ensureDockerNamedVolume, + ensureDockerNetwork, + isDockerRunning, + localDockerId, + resolveEdgeRuntimeVersion, + runChildProcess, +} from "./functions-docker.ts"; import { FUNCTIONS_BUNDLER_MUTEX_GROUP, invalidFunctionSlugDetail, @@ -25,6 +38,11 @@ import { } from "./download.errors.ts"; const legacyEntrypointPath = "file:///src/index.ts"; +// Go: `utils.DockerDenoDir`/`utils.DockerEszipDir` (`internal/utils/deno.go:34-35`) +// — fixed container-side paths for the docker-unbundle path, unrelated to +// deploy's `toDockerPath` host-mirroring scheme. +const DOCKER_DENO_DIR = "/home/deno"; +const DOCKER_ESZIP_DIR = "/root/eszips"; export interface DownloadFunctionsOptions { readonly functionName: Option.Option; @@ -34,15 +52,46 @@ export interface DownloadFunctionsOptions { readonly legacyBundle: boolean; } +interface DownloadRuntimeDependencies { + readonly api: ApiClient; + readonly projectRoot: string; +} + +/** Adds what the Docker-unbundle path needs beyond the server-side path. */ +interface DownloadDockerRuntimeDependencies extends DownloadRuntimeDependencies { + readonly rawArgs: ReadonlyArray; +} + +/** + * What {@link resolveEdgeRuntimeImage} needs to resolve the Docker + * edge-runtime image tag — split out so it's declared once instead of + * duplicated across `DownloadFunctionsDependencies`'s fields. + */ +interface EdgeRuntimeImageDependencies { + readonly projectRoot: string; + /** + * `true` in the legacy shell, `false` in `next` — forwarded verbatim to + * `loadProjectConfig`'s `goViperCompat` option (matches every other + * `functions`-family command, e.g. `deploy.ts`'s own `DeployFunctionsDependencies`). + */ + readonly goViperCompat: boolean; + /** + * Fallback edge-runtime image tag used when the project config doesn't pin + * `edge_runtime.deno_version` to `1` (which forces the older + * `DENO1_EDGE_RUNTIME_VERSION`) — mirrors `deploy.ts`'s own + * `edgeRuntimeVersion` dependency, read via + * `resolveEdgeRuntimeVersionPin` by the shell-specific handler. + */ + readonly edgeRuntimeVersion: string; +} + export interface DownloadFunctionsDependencies< ResolveError, ResolveRequirements, ProxyError, ProxyRequirements, -> { - readonly api: ApiClient; - readonly projectRoot: string; - readonly rawArgs: ReadonlyArray; +> + extends DownloadDockerRuntimeDependencies, EdgeRuntimeImageDependencies { readonly resolveProjectRef: ( projectRef: Option.Option, ) => Effect.Effect; @@ -51,7 +100,8 @@ export interface DownloadFunctionsDependencies< * child's raw stdout must not reach the terminal (it would corrupt the * JSON/NDJSON envelope, CLI-1546's "stdout is payload-only in machine * mode" invariant), so the dependency must capture/discard it (e.g. via - * `LegacyGoProxy.execCapture`) instead of inheriting stdio. + * `LegacyGoProxy.execCapture`) instead of inheriting stdio. Only invoked + * for `--legacy-bundle` today — `--use-docker` now runs natively (CLI-1963). */ readonly proxyDownload: ( flags: DownloadFunctionsOptions, @@ -60,29 +110,18 @@ export interface DownloadFunctionsDependencies< ) => Effect.Effect; } -interface DownloadRuntimeDependencies { - readonly api: ApiClient; - readonly projectRoot: string; -} - -export function makeGoProxyDownloadArgs( - flags: DownloadFunctionsOptions, +// `--legacy-bundle` is the only case `downloadFunctions()` still delegates to +// the Go binary for (CLI-1963) — `functionName` is the one remaining piece of +// user input the delegating branch needs to forward. +export function makeGoProxyLegacyBundleArgs( + functionName: Option.Option, projectRef: string, ): ReadonlyArray { const args: string[] = ["functions", "download"]; - if (Option.isSome(flags.functionName)) { - args.push(flags.functionName.value); - } - args.push("--project-ref", projectRef); - // At most one of these may reach the Go binary — it re-parses this argv - // fresh and enforces the same mutual exclusivity itself. `legacyBundle` - // takes priority since `useDocker` now defaults to `true` (CLI-1862) and - // would otherwise ride along on every `--legacy-bundle` invocation. - if (flags.legacyBundle) { - args.push("--legacy-bundle"); - } else if (flags.useDocker) { - args.push("--use-docker"); + if (Option.isSome(functionName)) { + args.push(functionName.value); } + args.push("--project-ref", projectRef, "--legacy-bundle"); return args; } @@ -127,6 +166,31 @@ function validateSlug(slug: string): Effect.Effect` argument), which fails with a plain `InvalidFunctionSlugError` and no + * "failed to download function" prefix or suggestion. + */ +function validateRemoteSlug(slug: string): Effect.Effect { + if (validateFunctionSlugMessage(slug) === undefined) { + return Effect.void; + } + + return Effect.fail( + Object.assign(new Error(`failed to download function ${slug}: ${invalidFunctionSlugDetail}`), { + suggestion: `The Supabase API returned an unexpected function slug (${slug}). Retry the command, and if this keeps happening, verify your network connection is not being intercepted before contacting Supabase support.`, + }), + ); +} + const downloadCommandPath = ["functions", "download"] as const; function validateDownloadFlags( @@ -678,6 +742,273 @@ const downloadBody = Effect.fnUntraced(function* ( return yield* Effect.fail(new Error(`Error status ${response.status}: ${body}`)); }); +// Go: `downloadOne` (`apps/cli-go/internal/functions/download/download.go:218-245`) +// — no `Accept` override (contrast `downloadBody` above, which requests +// `multipart/form-data` for the server-side path). Go explicitly decodes a +// brotli `Content-Encoding` itself because Go's `http.Transport` only +// auto-decodes `gzip`; this TS CLI's transport (`effect/unstable/http`'s +// `FetchHttpClient`, backed by the platform `fetch`) already transparently +// decodes `br` per the Fetch spec — while still reporting +// `Content-Encoding: br` on the exposed `Response.headers` (confirmed +// empirically: a `fetch()` against a real `Content-Encoding: br` response +// returns already-decompressed bytes from `arrayBuffer()`). Re-running +// `brotliDecompressSync` here would therefore throw on already-decoded +// bytes, so this reads the body as-is and does not re-implement Go's manual +// decode step. Error prefix ("failed to get function body") is deliberately +// distinct from `downloadBody`'s ("failed to download function") — the two +// Go call sites use different wording. +const downloadEszipBody = Effect.fnUntraced(function* ( + api: ApiClient, + projectRef: string, + slug: string, +) { + const response = yield* api + .executeRaw(operationDefinitions.v1GetAFunctionBody, { + ref: projectRef, + function_slug: slug, + }) + .pipe(Effect.mapError((error) => mapTransportError("failed to get function body", error))); + + if (response.status !== 200) { + const body = yield* response.text.pipe(Effect.orElseSucceed(() => "")); + return yield* Effect.fail(new Error(`Error status ${response.status}: ${body}`)); + } + + return new Uint8Array( + yield* response.arrayBuffer.pipe( + Effect.mapError( + (cause) => + new Error( + `failed to download file: ${cause instanceof Error ? cause.message : String(cause)}`, + ), + ), + ), + ); +}); + +function suggestLegacyBundle(slug: string): string { + // Go: `suggestLegacyBundle` (`download.go:314-316`) — verbatim, including + // the source's own "trying running" wording and its leading newline. + return `\nIf your function is deployed using CLI < 1.120.0, trying running supabase functions download --legacy-bundle ${slug} instead.`; +} + +function suggestDenoV2(): string { + // Go: `suggestDenoV2` (`download.go:306-312`), verbatim including its + // trailing newline. + return "Please use deno v2 in supabase/config.toml to download this Function:\n\n[edge_runtime]\ndeno_version = 2\n"; +} + +/** + * Attaches Go's `suggestLegacyBundle` hint to any Docker-extraction failure — + * matches `downloadWithDockerUnbundle`'s `CmdSuggestion += + * suggestLegacyBundle(slug)` (`download.go:211-214`), which runs whenever + * `extractOne` fails for *any* reason (network/volume creation, container + * create/start, log streaming, container inspect), not just a non-zero exit + * code. `ensureDockerNetwork`/`ensureDockerNamedVolume` already prefix their + * own "failed to create docker network/volume: ..." context on the failures + * they raise themselves (`functions-docker.ts`), so this only normalizes + * (never re-prefixes) whatever `legacyDescribeContainerCliFailure` reports. + */ +function withLegacyBundleSuggestion(slug: string) { + return (cause: unknown): Error => + Object.assign(new Error(legacyDescribeContainerCliFailure(cause)), { + suggestion: suggestLegacyBundle(slug), + }); +} + +/** + * Same as {@link withLegacyBundleSuggestion}, plus a `step` prefix — for + * `runChildProcess` itself, whose own failure (a spawn error, or the + * `PlatformError` `functions-docker.ts`'s hoisted `collectByteStream` erases + * to `unknown`) carries no context of its own about which command was + * running, unlike `ensureDockerNetwork`/`ensureDockerNamedVolume`'s + * self-describing errors. + */ +function withDockerStepFailure(step: string, slug: string) { + return (cause: unknown): Error => + Object.assign(new Error(`${step}: ${legacyDescribeContainerCliFailure(cause)}`), { + suggestion: suggestLegacyBundle(slug), + }); +} + +// Go: `Config.EdgeRuntime.Image` (`extractOne`, `download.go:271`) resolves +// from `edge_runtime.deno_version` — `1` pins the older +// `DENO1_EDGE_RUNTIME_VERSION`, anything else (including unset) uses the +// project's configured/default tag (`resolveEdgeRuntimeVersion`, shared with +// `deploy.ts`). `project_id` mirrors `deploy.ts`'s own +// `deployConfig?.project_id ?? projectRef` fallback for Docker network/volume +// naming (`GetId`, `internal/utils/config.go:57-58`). Resolved once per +// invocation by the caller (`downloadFunctions`), not once per slug — Go's +// `Config` is likewise loaded once, before any per-function work. +const resolveEdgeRuntimeImage = Effect.fnUntraced(function* ( + dependencies: EdgeRuntimeImageDependencies, + projectRef: string, +) { + const loadedConfig = yield* loadProjectConfig(dependencies.projectRoot, { + projectRef, + goViperCompat: dependencies.goViperCompat, + }); + const denoVersion = loadedConfig?.config?.edge_runtime.deno_version; + const projectId = loadedConfig?.config?.project_id ?? projectRef; + const edgeRuntimeVersion = yield* resolveEdgeRuntimeVersion( + denoVersion, + dependencies.edgeRuntimeVersion, + ); + return { + projectId, + denoVersion, + image: legacyGetRegistryImageUrl(`supabase/edge-runtime:v${edgeRuntimeVersion}`), + }; +}); + +interface EdgeRuntimeImage { + readonly projectId: string; + readonly denoVersion: number | undefined; + readonly image: string; +} + +// Go: `downloadWithDockerUnbundle`/`extractOne` +// (`download.go:198-282`) — downloads the function body as an eszip, writes +// it to a temp file, then runs the edge-runtime image's `unbundle` +// subcommand against it, mounting the *shared* `supabase/functions` +// directory (not the slug's own subdirectory — `download_test.go:267-271` +// asserts this explicitly). +const downloadWithDockerUnbundle = Effect.fnUntraced(function* ( + dependencies: DownloadDockerRuntimeDependencies, + edgeRuntimeImage: EdgeRuntimeImage, + projectRef: string, + slug: string, +) { + const output = yield* Output; + + // Go: `downloadOne` (`download.go:219`) — lowercase "function", distinct + // from the server-side path's "Downloading Function:" (capital F, + // `downloadWithServerSideUnbundle`, `download.go:329`). + yield* output.raw(`Downloading function: ${slug}\n`, "stderr"); + + const eszip = yield* downloadEszipBody(dependencies.api, projectRef, slug); + + const tempDir = join(dependencies.projectRoot, "supabase", ".temp"); + yield* Effect.tryPromise({ + try: () => mkdir(tempDir, { recursive: true }), + catch: (cause) => + new Error(`failed to mkdir: ${cause instanceof Error ? cause.message : String(cause)}`), + }); + const eszipFileName = `output_${slug}.eszip`; + const eszipPath = join(tempDir, eszipFileName); + yield* Effect.tryPromise({ + try: () => writeFile(eszipPath, eszip), + catch: (cause) => + new Error( + `failed to download file: ${cause instanceof Error ? cause.message : String(cause)}`, + ), + }); + + // Go: the `defer fsys.Remove(eszipPath)` cleanup is registered right after + // the write and covers the whole of `extractOne`, including the container + // run — it fires on every return path, success or failure + // (`download.go:203-209`). `Effect.ensuring` below is the equivalent: it + // wraps every step from here on so a failure resolving the network/volume, + // spawning Docker, or a non-zero container exit all still clean up the + // temp eszip, matching Go instead of only doing so on the happy path. + const debugEnabled = hasGlobalLongFlag(dependencies.rawArgs, "debug"); + const cleanupEszip = debugEnabled + ? Effect.void + : Effect.tryPromise({ + try: () => rm(eszipPath, { force: true }), + catch: (cause) => (cause instanceof Error ? cause.message : String(cause)), + }).pipe(Effect.catch((message) => output.raw(`${message}\n`, "stderr"))); + + const { projectId, denoVersion, image } = edgeRuntimeImage; + const functionsDir = resolve(dependencies.projectRoot, "supabase", "functions"); + const hostEszipPath = resolve(eszipPath); + const dockerEszipPath = posix.join(DOCKER_ESZIP_DIR, eszipFileName); + const dockerOutputPath = posix.join(DOCKER_DENO_DIR, slug); + + // Go: `viper.GetString("network-id")` else `NetId` (`docker.go:379-383`) — + // `--network-id` is a persistent root flag (`cmd/root.go:328`), not + // registered on `functions download` itself. + const networkMode = + explicitStringFlag(dependencies.rawArgs, "network-id") ?? localDockerId("network", projectId); + + const extract = Effect.gen(function* () { + yield* ensureDockerNetwork(networkMode, projectId).pipe( + Effect.mapError(withLegacyBundleSuggestion(slug)), + ); + yield* ensureDockerNamedVolume(localDockerId("edge_runtime", projectId), projectId).pipe( + Effect.mapError(withLegacyBundleSuggestion(slug)), + ); + + // Bind order matches `extractOne` (`download.go:260-266`) exactly. + const binds = [ + `${localDockerId("edge_runtime", projectId)}:/root/.cache/deno:rw`, + `${hostEszipPath}:${dockerEszipPath}:ro`, + `${functionsDir}:${DOCKER_DENO_DIR}:rw`, + ]; + const command = [ + "run", + "--rm", + ...binds.flatMap((bind) => ["-v", bind]), + "--network", + networkMode, + ]; + if (process.platform === "linux") { + command.push("--add-host", "host.docker.internal:host-gateway"); + } + command.push(image, "unbundle", "--eszip", dockerEszipPath, "--output", dockerOutputPath); + + const result = yield* runChildProcess("docker", command, { + stdout: "pipe", + stderr: "pipe", + }).pipe( + Effect.mapError( + withDockerStepFailure("failed to run the edge-runtime unbundle container", slug), + ), + ); + + // Go pipes the container's stdout straight to `os.Stdout` (`download.go:279`); + // machine-output modes must keep stdout payload-only (CLI-1546), so this + // mirrors `deploy.ts`'s own `bundleFunctionWithDocker` routing. + if (result.stdout.length > 0) { + yield* output.raw(result.stdout, output.format === "text" ? "stdout" : "stderr"); + } + if (result.stderr.length > 0) { + yield* output.raw(result.stderr, "stderr"); + } + + if (result.exitCode !== 0) { + // Go's `getErrorLogger` (deno-v1 only) sets `CmdSuggestion = + // suggestDenoV2()` (assignment) as soon as a full stderr line reads + // "invalid eszip v2" (case-insensitive), then `downloadWithDockerUnbundle` + // appends `suggestLegacyBundle` (`+=`) once extraction has failed + // (`download.go:213,284-304`). Go's own implementation races these two + // goroutines (the pipe writer is never closed) — this resolves that + // race deterministically to the common (non-race) ordering instead of + // reproducing the nondeterminism. The line match is exact (not a + // substring) to match Go's `strings.EqualFold(line, "invalid eszip v2")`. + const invalidEszipV2 = + denoVersion === 1 && + result.stderr + .split(/\r?\n/) + .some((line) => line.trim().toLowerCase() === "invalid eszip v2"); + const suggestion = (invalidEszipV2 ? suggestDenoV2() : "") + suggestLegacyBundle(slug); + return yield* Effect.fail( + Object.assign(new Error(`error running container: exit ${result.exitCode}`), { + suggestion, + }), + ); + } + + // Go: `downloadWithDockerUnbundle` has no final "Downloaded Function ..." + // print, unlike `RunLegacy`/`downloadWithServerSideUnbundle` — its only + // stdout/stderr text is "Downloading function: ..." above plus whatever + // the `unbundle` container itself wrote. + return slug; + }); + + return yield* extract.pipe(Effect.ensuring(cleanupEszip)); +}); + const downloadSingle = Effect.fnUntraced(function* ( dependencies: DownloadRuntimeDependencies, projectRef: string, @@ -764,11 +1095,17 @@ export function downloadFunctions MAX_PROJECT_ID_LENGTH + ? sanitized.slice(0, MAX_PROJECT_ID_LENGTH) + : sanitized; +} + +export function localDockerId(name: string, projectId: string) { + return `supabase_${name}_${normalizeProjectId(projectId)}`; +} + +const dockerCliProjectLabel = "com.supabase.cli.project"; +const dockerComposeProjectLabel = "com.docker.compose.project"; + +export function dockerProjectLabels(projectId: string) { + return { + [dockerCliProjectLabel]: projectId, + [dockerComposeProjectLabel]: projectId, + }; +} + +export function toDockerPath(hostPath: string) { + const normalized = toSlash(resolve(hostPath)); + return normalized.replace(/^[A-Za-z]:/, ""); +} + +function collectByteStream(stream: Stream.Stream) { + const decoder = new TextDecoder(); + return Stream.runFold( + stream, + () => "", + (text, chunk) => text + decoder.decode(chunk, { stream: true }), + ).pipe(Effect.map((text) => text + decoder.decode())); +} + +// Runs a container CLI command and collects its output. Every caller runs +// `docker`, so the spawn goes through `spawnContainerCli` to fall back to +// `podman` on Docker-less hosts. `command` is retained for the extendEnv +// default and the `functions serve` dependency-injection seam. +export const runChildProcess = Effect.fnUntraced(function* ( + command: string, + args: ReadonlyArray, + opts: { + readonly stdout?: "pipe" | "ignore"; + readonly stderr?: "pipe" | "ignore"; + readonly env?: Readonly>; + readonly extendEnv?: boolean; + } = {}, +) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const child = yield* spawnContainerCli(spawner, [...args], { + stdin: "ignore", + stdout: opts.stdout ?? "pipe", + stderr: opts.stderr ?? "pipe", + env: opts.env, + extendEnv: opts.extendEnv ?? command === "docker", + }); + + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + opts.stdout === "ignore" ? Effect.succeed("") : collectByteStream(child.stdout), + opts.stderr === "ignore" ? Effect.succeed("") : collectByteStream(child.stderr), + child.exitCode.pipe(Effect.map(Number)), + ], + { concurrency: "unbounded" }, + ); + return { exitCode, stdout, stderr }; +}); + +export function isUserDefinedDockerNetwork(networkMode: string) { + return ( + networkMode.length > 0 && + networkMode !== "default" && + networkMode !== "bridge" && + networkMode !== "host" && + networkMode !== "none" + ); +} + +export const ensureDockerNetwork = Effect.fnUntraced(function* ( + networkMode: string, + projectId: string, +) { + if (!isUserDefinedDockerNetwork(networkMode)) { + return; + } + + const inspect = yield* runChildProcess("docker", ["network", "inspect", networkMode], { + stdout: "ignore", + stderr: "ignore", + }).pipe(Effect.catch(() => Effect.succeed({ exitCode: 1, stdout: "", stderr: "" }))); + if (inspect.exitCode === 0) { + return; + } + + const labels = dockerProjectLabels(projectId); + const create = yield* runChildProcess( + "docker", + [ + "network", + "create", + "--label", + `${dockerCliProjectLabel}=${labels[dockerCliProjectLabel]}`, + "--label", + `${dockerComposeProjectLabel}=${labels[dockerComposeProjectLabel]}`, + networkMode, + ], + { + stdout: "ignore", + stderr: "pipe", + }, + ); + if (create.exitCode !== 0 && !create.stderr.includes("already exists")) { + return yield* Effect.fail(new Error(`failed to create docker network: ${networkMode}`)); + } +}); + +export const ensureDockerNamedVolume = Effect.fnUntraced(function* ( + volumeName: string, + projectId: string, +) { + if (process.env["BITBUCKET_CLONE_DIR"] !== undefined) { + return; + } + + const labels = dockerProjectLabels(projectId); + const create = yield* runChildProcess( + "docker", + [ + "volume", + "create", + "--label", + `${dockerCliProjectLabel}=${labels[dockerCliProjectLabel]}`, + "--label", + `${dockerComposeProjectLabel}=${labels[dockerComposeProjectLabel]}`, + volumeName, + ], + { + stdout: "ignore", + stderr: "pipe", + }, + ); + if (create.exitCode !== 0 && !create.stderr.includes("already exists")) { + return yield* Effect.fail(new Error(`failed to create docker volume: ${volumeName}`)); + } +}); + +export const isDockerRunning = Effect.fnUntraced(function* () { + const result = yield* runChildProcess("docker", ["info"], { + stdout: "ignore", + stderr: "ignore", + }).pipe(Effect.catch(() => Effect.succeed({ exitCode: 1, stdout: "", stderr: "" }))); + return result.exitCode === 0; +}); + +export function resolveEdgeRuntimeVersion( + denoVersion: number | undefined, + defaultVersion: string, +): Effect.Effect { + if (denoVersion === undefined || denoVersion === 2) { + return Effect.succeed(defaultVersion); + } + if (denoVersion === 1) { + return Effect.succeed(DENO1_EDGE_RUNTIME_VERSION); + } + return Effect.fail( + new Error(`Failed reading config: Invalid edge_runtime.deno_version: ${denoVersion}.`), + ); +} diff --git a/apps/cli/src/shared/functions/functions.shared.ts b/apps/cli/src/shared/functions/functions.shared.ts index 8c961e867d..731785e7d6 100644 --- a/apps/cli/src/shared/functions/functions.shared.ts +++ b/apps/cli/src/shared/functions/functions.shared.ts @@ -1,3 +1,8 @@ +import { DEFAULT_VERSIONS } from "@supabase/stack/effect"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { Effect } from "effect"; + const functionSlugPattern = /^[A-Za-z][A-Za-z0-9_-]*$/; export const invalidFunctionSlugDetail = @@ -16,3 +21,20 @@ export const FUNCTIONS_PROJECT_REF_SAFE_FLAGS = ["project-ref"] as const; // `MarkFlagsMutuallyExclusive("use-api", "use-docker", "legacy-bundle")` // (`cmd/functions.go:158,182`). export const FUNCTIONS_BUNDLER_MUTEX_GROUP = ["use-api", "use-docker", "legacy-bundle"] as const; + +/** + * Go: `Config.EdgeRuntime.Image` reflects `supabase/.temp/edge-runtime-version` + * when present (`pkg/config/config.go:847-849`) — shared by every `functions` + * command that resolves a Docker edge-runtime image (`deploy`, `download`) in + * both shells, so this is the single home for the file-read rather than four + * copies of the same `readFile` -> `trim` -> fallback pipeline. + */ +export const resolveEdgeRuntimeVersionPin = Effect.fnUntraced(function* (supabaseDir: string) { + return yield* Effect.tryPromise(() => + readFile(join(supabaseDir, ".temp", "edge-runtime-version"), "utf8"), + ).pipe( + Effect.map((version) => version.trim()), + Effect.catch(() => Effect.succeed("")), + Effect.map((version) => version || DEFAULT_VERSIONS["edge-runtime"]), + ); +}); diff --git a/apps/cli/src/shared/functions/serve.ts b/apps/cli/src/shared/functions/serve.ts index a4aab1f19f..2970651a19 100644 --- a/apps/cli/src/shared/functions/serve.ts +++ b/apps/cli/src/shared/functions/serve.ts @@ -51,19 +51,21 @@ import { discoverFunctionSlugs, dockerBindContainerPath, dockerBindHostPath, - dockerProjectLabels, dockerWorkdirLabel, + rawFunctionConfigRecord, + resolveFunctionConfigs, + type ResolvedDeployFunctionConfig, +} from "./deploy.ts"; +import { + dockerProjectLabels, ensureDockerNamedVolume, ensureDockerNetwork, localDockerId, normalizeProjectId, - rawFunctionConfigRecord, resolveEdgeRuntimeVersion, - resolveFunctionConfigs, runChildProcess, toDockerPath, - type ResolvedDeployFunctionConfig, -} from "./deploy.ts"; +} from "./functions-docker.ts"; const decodeProjectConfig = Schema.decodeUnknownSync(ProjectConfigSchema); const defaultProjectConfig = decodeProjectConfig({}); diff --git a/apps/cli/src/shared/output/normalize-error.ts b/apps/cli/src/shared/output/normalize-error.ts index 7d0976dd05..c40f72a34e 100644 --- a/apps/cli/src/shared/output/normalize-error.ts +++ b/apps/cli/src/shared/output/normalize-error.ts @@ -194,12 +194,18 @@ export function normalizeCliError( const code = readString(error, "_tag") ?? "UnknownError"; const message = readString(error, "message") ?? readString(error, "detail") ?? code; const detail = readString(error, "detail"); - const suggestion = readString(error, "suggestion"); + // Raw read: some producers' suggestion text is meaningful leading/trailing + // whitespace, not incidental — e.g. `suggestLegacyBundle`'s Go-parity + // string (`shared/functions/download.ts`) starts with `\n` to reproduce + // Go's blank separator line before the hint (`cmd/root.go:301-302`, + // `Fprintln(os.Stderr, CmdSuggestion)`). `readString` would trim exactly + // that away. + const suggestion = readRawString(error, "suggestion"); return { code, message, ...(detail && detail !== message ? { detail } : {}), - ...(suggestion ? { suggestion } : {}), + ...(suggestion !== undefined && suggestion.length > 0 ? { suggestion } : {}), }; } From 265a39ae79a9adf4bb2ccfba22319ec94770877a Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Wed, 5 Aug 2026 14:56:30 +0100 Subject: [PATCH 02/22] fix(functions): treat empty --network-id as the generated network (review: CLI-1963) Go's DockerStart only overrides the Docker network when len(viper.GetString("network-id")) > 0 (internal/utils/docker.go:379-382). The native functions download/deploy Docker paths used explicitStringFlag(...) ?? localDockerId(...), which returns "" (not undefined) for --network-id=, so an explicit empty override was invoked verbatim instead of falling back to the generated network. Adds explicitNonEmptyStringFlag (cobra-flag-groups.ts), which folds in Go's len(value) > 0 gate, and switches both download.ts and deploy.ts's docker network resolution to it. --- .../download/download.integration.test.ts | 50 ++++++++++++++++++- apps/cli/src/shared/cli/cobra-flag-groups.ts | 22 +++++++- apps/cli/src/shared/functions/deploy.ts | 10 +++- apps/cli/src/shared/functions/download.ts | 11 ++-- 4 files changed, 84 insertions(+), 9 deletions(-) diff --git a/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts b/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts index b58604ab77..d82731f0eb 100644 --- a/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts +++ b/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts @@ -641,8 +641,8 @@ describe("legacy functions download", () => { return Effect.gen(function* () { // `--network-id` is a persistent root flag (`cmd/root.go:328`), not - // registered on `functions download` itself — `explicitStringFlag` - // scans the whole argv unscoped. + // registered on `functions download` itself — + // `explicitNonEmptyStringFlag` scans the whole argv unscoped. yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }); expect(child.spawned.find((spawned) => spawned.args[0] === "network")).toEqual({ @@ -655,6 +655,52 @@ describe("legacy functions download", () => { }).pipe(Effect.provide(layer)); }); + it.live( + "falls back to the generated network name when --network-id is passed with an empty value", + () => { + // Go only overrides the network when `len(viper.GetString("network-id")) > 0` + // (`internal/utils/docker.go:379-382`) — an explicit-but-empty + // `--network-id=` must fall through to the generated network name just + // like an omitted flag (review round on CLI-1963's `functions download` + // port). + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi(); + const proxy = mockProxy(); + const child = mockChildProcessSpawner({ exitCode: 0 }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + }), + proxy.layer, + child.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "hello-world", + "--use-docker", + "--project-ref", + PROJECT_ID, + "--network-id=", + ]), + }), + ); + + return Effect.gen(function* () { + yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }); + + expect(child.spawned.find((spawned) => spawned.args[0] === "network")).toEqual({ + command: "docker", + args: ["network", "inspect", `supabase_network_${PROJECT_ID}`], + }); + const runCommand = child.spawned.find((spawned) => spawned.args[0] === "run"); + expect(runCommand?.args).toContain(`supabase_network_${PROJECT_ID}`); + }).pipe(Effect.provide(layer)); + }, + ); + it.live("keeps the temporary eszip file when --debug is passed", () => { const out = mockOutput({ format: "text" }); const api = mockLegacyPlatformApi(); diff --git a/apps/cli/src/shared/cli/cobra-flag-groups.ts b/apps/cli/src/shared/cli/cobra-flag-groups.ts index 2ec3d23d3d..c07d1b4592 100644 --- a/apps/cli/src/shared/cli/cobra-flag-groups.ts +++ b/apps/cli/src/shared/cli/cobra-flag-groups.ts @@ -30,9 +30,12 @@ export function hasExplicitLongFlag( /** * Raw value of `--`/`--=value` anywhere in argv - * (unscoped — no command-path anchoring), or `undefined` if absent. + * (unscoped — no command-path anchoring), or `undefined` if absent. Not + * exported — every current call site needs Go's `len(value) > 0` gate too + * (see {@link explicitNonEmptyStringFlag}); re-export this directly if a + * future caller genuinely needs presence-only semantics. */ -export function explicitStringFlag(rawArgs: ReadonlyArray, flagName: string) { +function explicitStringFlag(rawArgs: ReadonlyArray, flagName: string) { for (let index = 0; index < rawArgs.length; index += 1) { const token = rawArgs[index]; if (token === `--${flagName}`) { @@ -45,6 +48,21 @@ export function explicitStringFlag(rawArgs: ReadonlyArray, flagName: str return undefined; } +/** + * Same as {@link explicitStringFlag}, but treats an explicit empty value + * (`--=`) as unset — matching Go call sites that gate on + * `len(viper.GetString(flagName)) > 0` rather than mere presence (e.g. + * `--network-id`, `apps/cli-go/internal/utils/docker.go:379-382`). pflag + * still marks the flag `Changed` for `--network-id=`, but Go's own + * `if networkId := viper.GetString("network-id"); len(networkId) > 0` + * falls through to the generated network name for that value just like an + * omitted flag would (review round on CLI-1963's `functions download` port). + */ +export function explicitNonEmptyStringFlag(rawArgs: ReadonlyArray, flagName: string) { + const value = explicitStringFlag(rawArgs, flagName); + return value !== undefined && value.length > 0 ? value : undefined; +} + /** * Whether `--` (or `--=`) appears anywhere in argv, * unscoped. diff --git a/apps/cli/src/shared/functions/deploy.ts b/apps/cli/src/shared/functions/deploy.ts index 7d59849035..3295813801 100644 --- a/apps/cli/src/shared/functions/deploy.ts +++ b/apps/cli/src/shared/functions/deploy.ts @@ -18,7 +18,7 @@ import { legacyGetRegistryImageUrl } from "../../legacy/shared/legacy-docker-reg import { findGitRootPath } from "../git/git-root.ts"; import { cobraMutuallyExclusiveErrorMessage, - explicitStringFlag, + explicitNonEmptyStringFlag, hasExplicitLongFlag, hasGlobalLongFlag, } from "../cli/cobra-flag-groups.ts"; @@ -2201,7 +2201,13 @@ export function deployFunctions( join(dependencies.projectRoot, SUPABASE_FUNCTIONS_DIR), configs, dependencies.api, - explicitStringFlag(dependencies.rawArgs, "network-id"), + // Go only treats `--network-id` as an override when + // `len(viper.GetString("network-id")) > 0` + // (`internal/utils/docker.go:379-382`) — an explicit-but-empty + // `--network-id=` must fall through to the generated network + // name (`dockerNetworkId?: string` → `undefined`) just like an + // omitted flag. + explicitNonEmptyStringFlag(dependencies.rawArgs, "network-id"), debugEnabled, styleEmphasis, ); diff --git a/apps/cli/src/shared/functions/download.ts b/apps/cli/src/shared/functions/download.ts index bff0926d2c..be624872d5 100644 --- a/apps/cli/src/shared/functions/download.ts +++ b/apps/cli/src/shared/functions/download.ts @@ -10,7 +10,7 @@ import type * as HttpClientResponse from "effect/unstable/http/HttpClientRespons import { Output } from "../output/output.service.ts"; import { cobraMutuallyExclusiveErrorMessage, - explicitStringFlag, + explicitNonEmptyStringFlag, hasExplicitLongFlag, hasGlobalLongFlag, } from "../cli/cobra-flag-groups.ts"; @@ -927,9 +927,14 @@ const downloadWithDockerUnbundle = Effect.fnUntraced(function* ( // Go: `viper.GetString("network-id")` else `NetId` (`docker.go:379-383`) — // `--network-id` is a persistent root flag (`cmd/root.go:328`), not - // registered on `functions download` itself. + // registered on `functions download` itself. Go only treats the override as + // set when `len(networkId) > 0`, so an explicit-but-empty `--network-id=` + // must fall through to the generated network name too, not just an omitted + // flag — `explicitNonEmptyStringFlag` (unlike the unexported + // `explicitStringFlag`) treats that case as unset for exactly this reason. const networkMode = - explicitStringFlag(dependencies.rawArgs, "network-id") ?? localDockerId("network", projectId); + explicitNonEmptyStringFlag(dependencies.rawArgs, "network-id") ?? + localDockerId("network", projectId); const extract = Effect.gen(function* () { yield* ensureDockerNetwork(networkMode, projectId).pipe( From 810323314797d6c8e1ee57933358c9cdf9b92b0c Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Wed, 5 Aug 2026 14:59:21 +0100 Subject: [PATCH 03/22] fix(functions): preserve v-prefixed edge-runtime-version pins (review: CLI-1963) Go's replaceImageTag (pkg/config/utils.go:81-84) appends the raw content of supabase/.temp/edge-runtime-version verbatim after the image's `:`, so a pin can legitimately already carry its own `v` prefix (both forms are exercised elsewhere in this codebase, e.g. legacy-edge-runtime-image.unit.test.ts's "v9.9.9" fixture vs. deploy.integration.test.ts's bare "9.9.9"). The native download Docker path always prepended `v` to the resolved version, so a v-prefixed pin produced `supabase/edge-runtime:vv9.9.9`, which Docker fails to pull. Hoists serve.ts's existing edgeRuntimeImageTag helper (which already handled this correctly) into the shared functions-docker.ts, and applies it in download.ts and deploy.ts, which had the same unprefixed-vs-prefixed bug in their own inline `v${version}` construction. --- .../download/download.integration.test.ts | 46 +++++++++++++++++++ apps/cli/src/shared/functions/deploy.ts | 7 ++- apps/cli/src/shared/functions/download.ts | 8 +++- .../src/shared/functions/functions-docker.ts | 18 ++++++++ apps/cli/src/shared/functions/serve.ts | 5 +- 5 files changed, 78 insertions(+), 6 deletions(-) diff --git a/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts b/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts index d82731f0eb..71278992f8 100644 --- a/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts +++ b/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts @@ -701,6 +701,52 @@ describe("legacy functions download", () => { }, ); + it.live("does not double-prefix an already v-prefixed edge-runtime-version pin", () => { + // Go's `replaceImageTag` (`pkg/config/utils.go:81-84`) appends the pin + // file's raw content verbatim after the image's `:`, so a pin already + // carrying its own `v` prefix (a legitimate form — see + // `legacy-edge-runtime-image.unit.test.ts`'s own `"v9.9.9"` fixture) must + // not be prepended with a second `v` (review round on CLI-1963's + // `functions download` port). + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi(); + const proxy = mockProxy(); + const child = mockChildProcessSpawner({ exitCode: 0 }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + }), + proxy.layer, + child.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "hello-world", + "--use-docker", + "--project-ref", + PROJECT_ID, + ]), + }), + ); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + mkdir(join(tempRoot.current, "supabase", ".temp"), { recursive: true }), + ); + yield* Effect.tryPromise(() => + writeFile(join(tempRoot.current, "supabase", ".temp", "edge-runtime-version"), "v9.9.9\n"), + ); + + yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }); + + const runCommand = child.spawned.find((spawned) => spawned.args[0] === "run"); + expect(runCommand?.args.slice(-6)[0]).toBe("public.ecr.aws/supabase/edge-runtime:v9.9.9"); + }).pipe(Effect.provide(layer)); + }); + it.live("keeps the temporary eszip file when --debug is passed", () => { const out = mockOutput({ format: "text" }); const api = mockLegacyPlatformApi(); diff --git a/apps/cli/src/shared/functions/deploy.ts b/apps/cli/src/shared/functions/deploy.ts index 3295813801..9ad0320e8e 100644 --- a/apps/cli/src/shared/functions/deploy.ts +++ b/apps/cli/src/shared/functions/deploy.ts @@ -34,6 +34,7 @@ import { NoFunctionsToDeployError, } from "./deploy.errors.ts"; import { + edgeRuntimeImageTag, ensureDockerNamedVolume, ensureDockerNetwork, isDockerRunning, @@ -1279,7 +1280,11 @@ const bundleFunctionWithDocker = Effect.fnUntraced(function* ( } command.push( - legacyGetRegistryImageUrl(`supabase/edge-runtime:v${edgeRuntimeVersion}`), + // `edgeRuntimeImageTag`, not a bare `v${edgeRuntimeVersion}` prepend — + // `edgeRuntimeVersion` can come from a `.temp/edge-runtime-version` pin + // that's already `v`-prefixed (see the helper's doc in + // `functions-docker.ts`); blindly prepending `v` double-prefixes it. + legacyGetRegistryImageUrl(`supabase/edge-runtime:${edgeRuntimeImageTag(edgeRuntimeVersion)}`), "bundle", "--entrypoint", toDockerPath(config.entrypoint), diff --git a/apps/cli/src/shared/functions/download.ts b/apps/cli/src/shared/functions/download.ts index be624872d5..d767497a3b 100644 --- a/apps/cli/src/shared/functions/download.ts +++ b/apps/cli/src/shared/functions/download.ts @@ -17,6 +17,7 @@ import { import { legacyDescribeContainerCliFailure } from "../../legacy/shared/legacy-container-cli.ts"; import { legacyGetRegistryImageUrl } from "../../legacy/shared/legacy-docker-registry.ts"; import { + edgeRuntimeImageTag, ensureDockerNamedVolume, ensureDockerNetwork, isDockerRunning, @@ -857,7 +858,12 @@ const resolveEdgeRuntimeImage = Effect.fnUntraced(function* ( return { projectId, denoVersion, - image: legacyGetRegistryImageUrl(`supabase/edge-runtime:v${edgeRuntimeVersion}`), + // `edgeRuntimeImageTag` (not a bare `v${edgeRuntimeVersion}` prepend) — + // `dependencies.edgeRuntimeVersion` comes from a `.temp/edge-runtime-version` + // pin that may already carry its own `v` prefix (see the helper's doc). + image: legacyGetRegistryImageUrl( + `supabase/edge-runtime:${edgeRuntimeImageTag(edgeRuntimeVersion)}`, + ), }; }); diff --git a/apps/cli/src/shared/functions/functions-docker.ts b/apps/cli/src/shared/functions/functions-docker.ts index 19712a790f..8862964ac0 100644 --- a/apps/cli/src/shared/functions/functions-docker.ts +++ b/apps/cli/src/shared/functions/functions-docker.ts @@ -186,3 +186,21 @@ export function resolveEdgeRuntimeVersion( new Error(`Failed reading config: Invalid edge_runtime.deno_version: ${denoVersion}.`), ); } + +/** + * Formats a resolved edge-runtime version as a Docker tag, tolerating a + * pin that's already `v`-prefixed. `resolveEdgeRuntimeVersion`'s own + * defaults are bare (`"1.74.2"`, `DENO1_EDGE_RUNTIME_VERSION`), but a value + * sourced from `supabase/.temp/edge-runtime-version` can legitimately be + * either form — Go's `replaceImageTag` (`pkg/config/utils.go:81-84`) appends + * the pin file's raw content verbatim after the image's `:`, and both forms + * are exercised elsewhere in this codebase (`legacy-edge-runtime-image.ts`'s + * own `replaceImageTag` port, and its and `services.integration.test.ts`'s + * `"v9.9.9"` fixtures alongside `deploy.integration.test.ts`'s bare + * `"9.9.9"`). Blindly prepending `v` — as every caller below did before this + * helper existed — double-prefixes an already-`v`-prefixed pin + * (`supabase/edge-runtime:vv9.9.9`), which docker then simply fails to pull. + */ +export function edgeRuntimeImageTag(version: string): string { + return version.startsWith("v") ? version : `v${version}`; +} diff --git a/apps/cli/src/shared/functions/serve.ts b/apps/cli/src/shared/functions/serve.ts index 2970651a19..725e27c70f 100644 --- a/apps/cli/src/shared/functions/serve.ts +++ b/apps/cli/src/shared/functions/serve.ts @@ -58,6 +58,7 @@ import { } from "./deploy.ts"; import { dockerProjectLabels, + edgeRuntimeImageTag, ensureDockerNamedVolume, ensureDockerNetwork, localDockerId, @@ -1382,10 +1383,6 @@ async function writeServeMainTemplateFile(template: string, dir: string) { return { bind: `${pathname}:${serveMainContainerPath}:ro,Z` } as const; } -function edgeRuntimeImageTag(version: string) { - return version.startsWith("v") ? version : `v${version}`; -} - const resolveServeFunctionConfigs = Effect.fnUntraced(function* ( projectRoot: string, supabaseDir: string, From 5583c93b83cf4b12081cbc977aa784b44e1942dc Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Wed, 5 Aug 2026 15:00:44 +0100 Subject: [PATCH 04/22] fix(functions): request the raw eszip body instead of a negotiated JSON response (review: CLI-1963) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v1GetAFunctionBody's generated contract marks its response kind: "json", so executeRaw() defaults to Accept: application/json for it (buildRequest's unconditional acceptJson for json-kind operations). Go's own downloadOne (the Docker-unbundle path this mirrors) sends no Accept header at all, unlike the server-side path's explicit multipart/form-data override, so the default JSON negotiation here could receive a negotiated JSON response instead of the raw eszip bytes and fail downstream in edge-runtime unbundle. Overrides the request's Accept header to */* (no preference) — the closest equivalent this API surface has to Go sending no header. --- .../download/download.integration.test.ts | 39 +++++++++++++++ apps/cli/src/shared/functions/download.ts | 47 ++++++++++++------- 2 files changed, 68 insertions(+), 18 deletions(-) diff --git a/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts b/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts index 71278992f8..5e7542f130 100644 --- a/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts +++ b/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts @@ -612,6 +612,45 @@ describe("legacy functions download", () => { }).pipe(Effect.provide(layer)); }); + it.live("requests the raw eszip body instead of a negotiated JSON response", () => { + // `v1GetAFunctionBody`'s generated contract marks its response + // `kind: "json"`, so `executeRaw` would otherwise default to + // `Accept: application/json` (`buildRequest`'s unconditional `acceptJson` + // for json-kind operations) and risk a negotiated JSON response instead + // of the raw eszip body Go's un-overridden request receives (review round + // on CLI-1963's `functions download` port). + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi(); + const proxy = mockProxy(); + const child = mockChildProcessSpawner({ exitCode: 0 }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + }), + proxy.layer, + child.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "hello-world", + "--use-docker", + "--project-ref", + PROJECT_ID, + ]), + }), + ); + + return Effect.gen(function* () { + yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }); + + const bodyRequest = api.requests.find((request) => request.url.endsWith("/hello-world/body")); + expect(bodyRequest?.headers["accept"]).toBe("*/*"); + }).pipe(Effect.provide(layer)); + }); + it.live("uses an explicit --network-id override instead of the derived network name", () => { const out = mockOutput({ format: "text" }); const api = mockLegacyPlatformApi(); diff --git a/apps/cli/src/shared/functions/download.ts b/apps/cli/src/shared/functions/download.ts index d767497a3b..05179145bd 100644 --- a/apps/cli/src/shared/functions/download.ts +++ b/apps/cli/src/shared/functions/download.ts @@ -744,30 +744,41 @@ const downloadBody = Effect.fnUntraced(function* ( }); // Go: `downloadOne` (`apps/cli-go/internal/functions/download/download.go:218-245`) -// — no `Accept` override (contrast `downloadBody` above, which requests -// `multipart/form-data` for the server-side path). Go explicitly decodes a -// brotli `Content-Encoding` itself because Go's `http.Transport` only -// auto-decodes `gzip`; this TS CLI's transport (`effect/unstable/http`'s -// `FetchHttpClient`, backed by the platform `fetch`) already transparently -// decodes `br` per the Fetch spec — while still reporting -// `Content-Encoding: br` on the exposed `Response.headers` (confirmed -// empirically: a `fetch()` against a real `Content-Encoding: br` response -// returns already-decompressed bytes from `arrayBuffer()`). Re-running -// `brotliDecompressSync` here would therefore throw on already-decoded -// bytes, so this reads the body as-is and does not re-implement Go's manual -// decode step. Error prefix ("failed to get function body") is deliberately -// distinct from `downloadBody`'s ("failed to download function") — the two -// Go call sites use different wording. +// sends this request with no `Accept` header set at all (contrast +// `downloadBody` above, which requests `multipart/form-data` for the +// server-side path). This operation's generated contract marks its response +// `kind: "json"` (`packages/api/src/generated/contracts.ts`), so +// `executeRaw` would otherwise default to `Accept: application/json` here +// (`buildRequest`'s unconditional `acceptJson` for json-kind operations, +// `packages/api/src/internal/client.ts`) and risk a negotiated JSON response +// instead of the raw eszip body — overriding to `*/*` (no preference) is the +// closest equivalent this API surface has to Go sending no header at all. +// Go explicitly decodes a brotli `Content-Encoding` itself because Go's +// `http.Transport` only auto-decodes `gzip`; this TS CLI's transport +// (`effect/unstable/http`'s `FetchHttpClient`, backed by the platform +// `fetch`) already transparently decodes `br` per the Fetch spec — while +// still reporting `Content-Encoding: br` on the exposed `Response.headers` +// (confirmed empirically: a `fetch()` against a real `Content-Encoding: br` +// response returns already-decompressed bytes from `arrayBuffer()`). +// Re-running `brotliDecompressSync` here would therefore throw on +// already-decoded bytes, so this reads the body as-is and does not +// re-implement Go's manual decode step. Error prefix ("failed to get +// function body") is deliberately distinct from `downloadBody`'s ("failed to +// download function") — the two Go call sites use different wording. const downloadEszipBody = Effect.fnUntraced(function* ( api: ApiClient, projectRef: string, slug: string, ) { const response = yield* api - .executeRaw(operationDefinitions.v1GetAFunctionBody, { - ref: projectRef, - function_slug: slug, - }) + .executeRaw( + operationDefinitions.v1GetAFunctionBody, + { + ref: projectRef, + function_slug: slug, + }, + { Accept: "*/*" }, + ) .pipe(Effect.mapError((error) => mapTransportError("failed to get function body", error))); if (response.status !== 200) { From bc765ee4234916edf7cec9598b61ebcf415f97ab Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Wed, 5 Aug 2026 15:02:16 +0100 Subject: [PATCH 05/22] fix(functions): validate project config before falling back from Docker (review: CLI-1963) Go's Run calls flags.LoadConfig(fsys) unconditionally at the very top, before checking useDocker or whether Docker itself is running (download.go:135-138). The native download path only resolved/validated the project config (via resolveEdgeRuntimeImage) inside the isDockerRunning() branch, so a default `functions download` with an invalid edge_runtime.deno_version proceeded straight to the API/filesystem side-effecting server-side path whenever Docker was down or --use-api was passed, instead of failing up front like Go. Resolves resolveEdgeRuntimeImage unconditionally before branching on --use-api/--use-docker/Docker's running state. --- .../download/download.integration.test.ts | 57 +++++++++++++++++++ apps/cli/src/shared/functions/download.ts | 21 +++++-- 2 files changed, 72 insertions(+), 6 deletions(-) diff --git a/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts b/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts index 5e7542f130..23ded13dae 100644 --- a/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts +++ b/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts @@ -821,6 +821,63 @@ describe("legacy functions download", () => { }).pipe(Effect.provide(layer)); }); + it.live( + "fails on an invalid project config before falling back when Docker is not running", + () => { + // Go's `Run` calls `flags.LoadConfig(fsys)` unconditionally at the very + // top, before checking whether Docker is running (`download.go:135-138`) + // — an invalid `supabase/config.toml` must fail up front instead of + // silently falling through to the server-side path's API/filesystem + // side effects (review round on CLI-1963's `functions download` port). + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi(); + const proxy = mockProxy(); + // Every docker command (including the `docker info` probe) fails, + // modeling Docker not running. + const child = mockChildProcessSpawner({ exitCode: 1 }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + }), + proxy.layer, + child.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "hello-world", + "--project-ref", + PROJECT_ID, + ]), + }), + ); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + mkdir(join(tempRoot.current, "supabase"), { recursive: true }), + ); + yield* Effect.tryPromise(() => + writeFile( + join(tempRoot.current, "supabase", "config.toml"), + ["[edge_runtime]", "deno_version = 3", ""].join("\n"), + ), + ); + + const error = yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }).pipe( + Effect.flip, + ); + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe( + "Failed reading config: Invalid edge_runtime.deno_version: 3.", + ); + expect(api.requests).toEqual([]); + }).pipe(Effect.provide(layer)); + }, + ); + describe("docker unbundle container failures", () => { it.live("fails with the legacy-bundle suggestion when the container exits non-zero", () => { const out = mockOutput({ format: "text" }); diff --git a/apps/cli/src/shared/functions/download.ts b/apps/cli/src/shared/functions/download.ts index 05179145bd..1ea6e10a08 100644 --- a/apps/cli/src/shared/functions/download.ts +++ b/apps/cli/src/shared/functions/download.ts @@ -1174,6 +1174,19 @@ export function downloadFunctions Date: Wed, 5 Aug 2026 15:03:43 +0100 Subject: [PATCH 06/22] fix(functions): skip the named Deno cache volume bind on Bitbucket (review: CLI-1963) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Go's DockerStart drops the named-volume bind entirely on Bitbucket (internal/utils/docker.go:400-405) rather than just skipping its explicit creation — `docker run -v :...` would otherwise still implicitly create the named volume, which Bitbucket's restricted Docker environment doesn't allow. The native Docker-unbundle path's ensureDockerNamedVolume already skipped the explicit `docker volume create` under BITBUCKET_CLONE_DIR, but the manually-built `docker run -v ...` bind list still unconditionally included the named-volume bind, so the container run itself could still fail in Bitbucket's restricted environment. Applies the same BITBUCKET_CLONE_DIR carve-out deploy.ts's buildDockerBinds already uses. --- .../download/download.integration.test.ts | 66 +++++++++++++++++++ apps/cli/src/shared/functions/download.ts | 12 +++- 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts b/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts index 23ded13dae..e73d81ab06 100644 --- a/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts +++ b/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts @@ -612,6 +612,72 @@ describe("legacy functions download", () => { }).pipe(Effect.provide(layer)); }); + it.live("omits the named Deno cache volume bind on Bitbucket", () => { + // Go's `DockerStart` drops the named-volume bind entirely on Bitbucket + // (`internal/utils/docker.go:400-405`) rather than just skipping its + // explicit creation — `docker run -v :...` would otherwise still + // implicitly create the named volume, which Bitbucket's restricted Docker + // environment doesn't allow (review round on CLI-1963's `functions + // download` port; `deploy.ts`'s `buildDockerBinds` already applies this + // same carve-out). + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi(); + const proxy = mockProxy(); + const child = mockChildProcessSpawner({ exitCode: 0 }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + }), + proxy.layer, + child.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "hello-world", + "--use-docker", + "--project-ref", + PROJECT_ID, + ]), + }), + ); + + const previousBitbucketCloneDir = process.env["BITBUCKET_CLONE_DIR"]; + process.env["BITBUCKET_CLONE_DIR"] = "/opt/atlassian/pipelines/agent/build"; + + return Effect.gen(function* () { + yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }); + + const runCommand = child.spawned.find((spawned) => spawned.args[0] === "run"); + expect(runCommand?.args).not.toContain( + `supabase_edge_runtime_${PROJECT_ID}:/root/.cache/deno:rw`, + ); + const hostEszipPath = resolve( + tempRoot.current, + "supabase", + ".temp", + "output_hello-world.eszip", + ); + expect(runCommand?.args).toContain( + `${hostEszipPath}:/root/eszips/output_hello-world.eszip:ro`, + ); + }) + .pipe(Effect.provide(layer)) + .pipe( + Effect.ensuring( + Effect.sync(() => { + if (previousBitbucketCloneDir === undefined) { + delete process.env["BITBUCKET_CLONE_DIR"]; + } else { + process.env["BITBUCKET_CLONE_DIR"] = previousBitbucketCloneDir; + } + }), + ), + ); + }); + it.live("requests the raw eszip body instead of a negotiated JSON response", () => { // `v1GetAFunctionBody`'s generated contract marks its response // `kind: "json"`, so `executeRaw` would otherwise default to diff --git a/apps/cli/src/shared/functions/download.ts b/apps/cli/src/shared/functions/download.ts index 1ea6e10a08..da35f8f3e9 100644 --- a/apps/cli/src/shared/functions/download.ts +++ b/apps/cli/src/shared/functions/download.ts @@ -961,9 +961,17 @@ const downloadWithDockerUnbundle = Effect.fnUntraced(function* ( Effect.mapError(withLegacyBundleSuggestion(slug)), ); - // Bind order matches `extractOne` (`download.go:260-266`) exactly. + // Bind order matches `extractOne` (`download.go:260-266`) exactly. Go's + // `DockerStart` drops the named-volume bind entirely on Bitbucket + // (`internal/utils/docker.go:400-405`) rather than just skipping its + // explicit creation — `docker run -v :...` would otherwise still + // implicitly create the named volume, which Bitbucket's restricted Docker + // environment doesn't allow, same carve-out as `deploy.ts`'s + // `buildDockerBinds`. const binds = [ - `${localDockerId("edge_runtime", projectId)}:/root/.cache/deno:rw`, + ...(process.env["BITBUCKET_CLONE_DIR"] === undefined + ? [`${localDockerId("edge_runtime", projectId)}:/root/.cache/deno:rw`] + : []), `${hostEszipPath}:${dockerEszipPath}:ro`, `${functionsDir}:${DOCKER_DENO_DIR}:rw`, ]; From 51524c620657e7b715b0204b9d74e27d0f213ef5 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Wed, 5 Aug 2026 15:05:49 +0100 Subject: [PATCH 07/22] fix(functions): honor pflag boolean value for --debug in eszip cleanup (review: CLI-1963) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Go gates the Docker-unbundle path's temp-eszip cleanup on viper.GetBool("DEBUG") (download.go:203), so an explicit --debug=false resolves to false (cleanup runs). The native path used hasGlobalLongFlag(rawArgs, "debug"), a presence-only check, so --debug=false was treated the same as --debug and skipped cleanup — the opposite of Go. Adds explicitBooleanLongFlag (cobra-flag-groups.ts), which reads the last explicit occurrence's pflag-parsed boolean value instead of mere presence, and switches this call site to it. SUPABASE_DEBUG env-var fallback remains a separate, pre-existing gap shared by every other hasGlobalLongFlag(rawArgs, "debug") site (e.g. deploy.ts) and the legacy debug logger, left open rather than fixed piecemeal here. --- .../download/download.integration.test.ts | 42 +++++++++++++++++++ apps/cli/src/shared/cli/cobra-flag-groups.ts | 36 ++++++++++++++++ apps/cli/src/shared/functions/download.ts | 14 ++++++- 3 files changed, 90 insertions(+), 2 deletions(-) diff --git a/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts b/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts index e73d81ab06..6bbdbd3f6d 100644 --- a/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts +++ b/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts @@ -887,6 +887,48 @@ describe("legacy functions download", () => { }).pipe(Effect.provide(layer)); }); + it.live( + "removes the temporary eszip file when --debug=false overrides the flag's own presence", + () => { + // Go gates this on `viper.GetBool("DEBUG")`, so an explicit + // `--debug=false` resolves to `false` (cleanup runs) — a presence-only + // check would get this backwards and treat `--debug=false` like + // `--debug` (review round on CLI-1963's `functions download` port). + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi(); + const proxy = mockProxy(); + const child = mockChildProcessSpawner({ exitCode: 0 }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + }), + proxy.layer, + child.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "hello-world", + "--use-docker", + "--project-ref", + PROJECT_ID, + "--debug=false", + ]), + }), + ); + + return Effect.gen(function* () { + yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }); + + expect( + existsSync(join(tempRoot.current, "supabase", ".temp", "output_hello-world.eszip")), + ).toBe(false); + }).pipe(Effect.provide(layer)); + }, + ); + it.live( "fails on an invalid project config before falling back when Docker is not running", () => { diff --git a/apps/cli/src/shared/cli/cobra-flag-groups.ts b/apps/cli/src/shared/cli/cobra-flag-groups.ts index c07d1b4592..46975b2fa6 100644 --- a/apps/cli/src/shared/cli/cobra-flag-groups.ts +++ b/apps/cli/src/shared/cli/cobra-flag-groups.ts @@ -71,6 +71,42 @@ export function hasGlobalLongFlag(rawArgs: ReadonlyArray, flagName: stri return rawArgs.some((token) => token === `--${flagName}` || token.startsWith(`--${flagName}=`)); } +const PFLAG_BOOLEAN_FALSE_VALUES: ReadonlySet = new Set([ + "0", + "f", + "F", + "false", + "FALSE", + "False", +]); + +/** + * Last explicit `--`/`--=` boolean occurrence in + * argv, or `undefined` when the flag never appears — matching pflag/viper's + * shared-variable last-`Set()`-wins semantics (mirrors + * `legacyExperimentalFlagFromArgs`, `shared/legacy/global-flags.ts`). A bare + * `--` records pflag's bool `NoOptDefVal` (`true`); an inline value + * is parsed through pflag's `strconv.ParseBool` false set — anything else + * (including garbage) is truthy, same as `cast.ToBool`'s permissive default. + * Unlike {@link hasGlobalLongFlag}, this distinguishes `--=false` + * from presence alone, which matters for Go call sites gated on + * `viper.GetBool` rather than "was the flag passed at all". + */ +export function explicitBooleanLongFlag( + rawArgs: ReadonlyArray, + flagName: string, +): boolean | undefined { + let result: boolean | undefined; + for (const token of rawArgs) { + if (token === `--${flagName}`) { + result = true; + } else if (token.startsWith(`--${flagName}=`)) { + result = !PFLAG_BOOLEAN_FALSE_VALUES.has(token.slice(flagName.length + 3)); + } + } + return result; +} + /** * Value-taking long flags registered persistently on the Go root command * (`apps/cli-go/cmd/root.go:324-333`: `--workdir`, `--network-id`, diff --git a/apps/cli/src/shared/functions/download.ts b/apps/cli/src/shared/functions/download.ts index da35f8f3e9..d4b18e84f5 100644 --- a/apps/cli/src/shared/functions/download.ts +++ b/apps/cli/src/shared/functions/download.ts @@ -10,9 +10,9 @@ import type * as HttpClientResponse from "effect/unstable/http/HttpClientRespons import { Output } from "../output/output.service.ts"; import { cobraMutuallyExclusiveErrorMessage, + explicitBooleanLongFlag, explicitNonEmptyStringFlag, hasExplicitLongFlag, - hasGlobalLongFlag, } from "../cli/cobra-flag-groups.ts"; import { legacyDescribeContainerCliFailure } from "../../legacy/shared/legacy-container-cli.ts"; import { legacyGetRegistryImageUrl } from "../../legacy/shared/legacy-docker-registry.ts"; @@ -928,7 +928,17 @@ const downloadWithDockerUnbundle = Effect.fnUntraced(function* ( // wraps every step from here on so a failure resolving the network/volume, // spawning Docker, or a non-zero container exit all still clean up the // temp eszip, matching Go instead of only doing so on the happy path. - const debugEnabled = hasGlobalLongFlag(dependencies.rawArgs, "debug"); + // + // Go gates this on `viper.GetBool("DEBUG")` (`download.go:203`), which + // resolves an explicit `--debug=false` to `false` (cleanup runs) — a plain + // presence check would get that backwards, so this reads the last explicit + // occurrence's boolean value instead (`explicitBooleanLongFlag`), falling + // back to `false` (cleanup runs) when `--debug` never appears. `SUPABASE_DEBUG` + // env-var fallback is a separate, pre-existing gap shared with every other + // `hasGlobalLongFlag(rawArgs, "debug")` call site in this file family + // (e.g. `deploy.ts`) and the legacy debug logger itself, none of which + // currently honor it either — left open rather than fixed piecemeal here. + const debugEnabled = explicitBooleanLongFlag(dependencies.rawArgs, "debug") ?? false; const cleanupEszip = debugEnabled ? Effect.void : Effect.tryPromise({ From b88cf6a1302b55f90b8a24e7edc1872b369b0d9e Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Wed, 5 Aug 2026 16:57:00 +0100 Subject: [PATCH 08/22] fix(functions): treat container: as a non-user-defined docker network mode (review: CLI-1963) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Go's container.NetworkMode.IsUserDefined() explicitly excludes IsContainer() (docker/api/types/container/hostconfig_unix.go:23-25), so DockerNetworkCreateIfNotExists never inspects or creates a network for --network-id container: — the mode attaches to another container's stack and is passed straight through to `docker run --network`. The shared isUserDefinedDockerNetwork predicate (used by deploy.ts, serve.ts, download.ts, and start's container lifecycle) didn't exclude this case, so the Docker download path's preflight would have run `docker network inspect`/`create container:redis` before `docker run`. Fixed once in the shared predicate so every consumer gets the same fix. --- .../download/download.integration.test.ts | 43 +++++++++++++++++++ .../lib/container-lifecycle.unit.test.ts | 16 +++++++ .../src/shared/functions/functions-docker.ts | 18 +++++++- 3 files changed, 76 insertions(+), 1 deletion(-) diff --git a/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts b/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts index 6bbdbd3f6d..861784cf37 100644 --- a/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts +++ b/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts @@ -806,6 +806,49 @@ describe("legacy functions download", () => { }, ); + it.live("skips network creation for a container: network mode", () => { + // Go's `container.NetworkMode.IsUserDefined()` + // (`docker/api/types/container/hostconfig_unix.go:23-25`) explicitly + // excludes `IsContainer()` — `--network-id container:redis` attaches to + // another container's network stack, so `DockerNetworkCreateIfNotExists` + // never inspects or creates a network for it, and the mode is passed + // straight through to `docker run --network` (review round on + // CLI-1963's `functions download` port). + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi(); + const proxy = mockProxy(); + const child = mockChildProcessSpawner({ exitCode: 0 }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + }), + proxy.layer, + child.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "hello-world", + "--use-docker", + "--project-ref", + PROJECT_ID, + "--network-id", + "container:redis", + ]), + }), + ); + + return Effect.gen(function* () { + yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }); + + expect(child.spawned.find((spawned) => spawned.args[0] === "network")).toBeUndefined(); + const runCommand = child.spawned.find((spawned) => spawned.args[0] === "run"); + expect(runCommand?.args).toContain("container:redis"); + }).pipe(Effect.provide(layer)); + }); + it.live("does not double-prefix an already v-prefixed edge-runtime-version pin", () => { // Go's `replaceImageTag` (`pkg/config/utils.go:81-84`) appends the pin // file's raw content verbatim after the image's `:`, so a pin already diff --git a/apps/cli/src/legacy/commands/start/lib/container-lifecycle.unit.test.ts b/apps/cli/src/legacy/commands/start/lib/container-lifecycle.unit.test.ts index cd77a460b4..66cb6084ed 100644 --- a/apps/cli/src/legacy/commands/start/lib/container-lifecycle.unit.test.ts +++ b/apps/cli/src/legacy/commands/start/lib/container-lifecycle.unit.test.ts @@ -747,6 +747,22 @@ describe("legacyEnsureStartNetwork", () => { ); }, ); + + it.live("skips docker network create for a container: network mode", () => { + // Go's `container.NetworkMode.IsUserDefined()` + // (`docker/api/types/container/hostconfig_unix.go:23-25`) explicitly + // excludes `IsContainer()` — `--network-id container:redis` attaches to + // another container's network stack, not a name `docker network create` + // could ever act on (review round on CLI-1963's `functions download` + // port, which surfaced the same gap in the shared + // `isUserDefinedDockerNetwork` predicate this helper reuses). + const mock = mockSpawner(() => ({ exitCode: 1, stderr: "some failure" })); + return legacyEnsureStartNetwork(mock.spawner, "container:redis", {}).pipe( + Effect.map(() => { + expect(mock.spawned).toEqual([]); + }), + ); + }); }); describe("legacyEnsureStartVolume", () => { diff --git a/apps/cli/src/shared/functions/functions-docker.ts b/apps/cli/src/shared/functions/functions-docker.ts index 8862964ac0..485eca85c3 100644 --- a/apps/cli/src/shared/functions/functions-docker.ts +++ b/apps/cli/src/shared/functions/functions-docker.ts @@ -86,13 +86,29 @@ export const runChildProcess = Effect.fnUntraced(function* ( return { exitCode, stdout, stderr }; }); +// Go: `container.NetworkMode.IsContainer()` (`docker/api/types/container/hostconfig.go:152-155`, +// via the unexported `containerID` helper, same file:493-499) — `--network container:` +// (Docker's syntax for attaching to another container's network stack) is recognized by a bare +// `"container:"` prefix before the first `:`, regardless of what (if anything) follows it. +function isContainerDockerNetworkMode(networkMode: string) { + const separatorIndex = networkMode.indexOf(":"); + return separatorIndex !== -1 && networkMode.slice(0, separatorIndex) === "container"; +} + +// Go: `container.NetworkMode.IsUserDefined()` (`docker/api/types/container/hostconfig_unix.go:23-25`) +// — `!IsDefault() && !IsBridge() && !IsHost() && !IsNone() && !IsContainer()`. Omitting the +// `IsContainer()` exclusion would make `DockerNetworkCreateIfNotExists` +// (`internal/utils/docker.go:63`) run `docker network inspect`/`create` against a +// `container:` mode, which isn't a network name at all — Go passes that mode straight +// through to the container's `NetworkMode` without ever touching the network subsystem. export function isUserDefinedDockerNetwork(networkMode: string) { return ( networkMode.length > 0 && networkMode !== "default" && networkMode !== "bridge" && networkMode !== "host" && - networkMode !== "none" + networkMode !== "none" && + !isContainerDockerNetworkMode(networkMode) ); } From f0d3361e323180e054753196a59c0270b43067cf Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Wed, 5 Aug 2026 16:58:56 +0100 Subject: [PATCH 09/22] fix(functions): resolve legacy Docker download config from the exact workdir, toml-only (review: CLI-1963) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Go's flags.LoadConfig only ever resolves supabase/config.toml from the already-resolved workdir, with no ancestor climb and no concept of a JSON project config (pkg/config/utils.go:43-48). resolveEdgeRuntimeImage's loadProjectConfig call omitted search: false/tomlOnly: true, so the legacy shell's Docker download path could pick up an unrelated ancestor project's config.toml, or prefer a stray supabase/config.json over config.toml — both diverging from Go. Gated on goViperCompat so the next shell keeps the package's existing (non-Go-parity) defaults, matching legacy-local-project-context.ts and start.handler.ts's established pattern for the same options. Also documents (not fixed here) a separate, pre-existing gap the same review round surfaced: resolveEdgeRuntimeImage resolves a single registry URL with no ECR/GHCR/Docker Hub retry, unlike Go's DockerResolveImageIfNotCached — shared with deploy.ts/serve.ts's own already-shipped native Docker paths, so it's a cross-cutting follow-up rather than a download-only fix. --- .../download/download.integration.test.ts | 118 ++++++++++++++++++ apps/cli/src/shared/functions/download.ts | 26 ++++ 2 files changed, 144 insertions(+) diff --git a/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts b/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts index 861784cf37..e161425c04 100644 --- a/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts +++ b/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts @@ -806,6 +806,124 @@ describe("legacy functions download", () => { }, ); + it.live( + "does not climb to an ancestor project's config.toml for the Docker download path", + () => { + // Go's `flags.LoadConfig` only ever resolves `supabase/config.toml` from + // the already-resolved workdir, with no ancestor climb + // (`NewPathBuilder`, `pkg/config/utils.go:43-48`) — mirrored here by + // `resolveEdgeRuntimeImage`'s `search: false` (review round on + // CLI-1963's `functions download` port). A nested workdir with no + // `supabase/config.toml` of its own must fall back to `--project-ref` + // for network/volume naming, not an ancestor project's configured + // `project_id`, even though `cliConfig.workdir` sits right inside one. + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi(); + const proxy = mockProxy(); + const child = mockChildProcessSpawner({ exitCode: 0 }); + const nestedWorkdir = join(tempRoot.current, "nested"); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: nestedWorkdir }), + }), + proxy.layer, + child.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "hello-world", + "--use-docker", + "--project-ref", + PROJECT_ID, + ]), + }), + ); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => mkdir(nestedWorkdir, { recursive: true })); + yield* Effect.tryPromise(() => + mkdir(join(tempRoot.current, "supabase"), { recursive: true }), + ); + yield* Effect.tryPromise(() => + writeFile( + join(tempRoot.current, "supabase", "config.toml"), + ['project_id = "ancestor-project"', ""].join("\n"), + ), + ); + + yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }); + + expect(child.spawned.find((spawned) => spawned.args[0] === "network")).toEqual({ + command: "docker", + args: ["network", "inspect", `supabase_network_${PROJECT_ID}`], + }); + const runCommand = child.spawned.find((spawned) => spawned.args[0] === "run"); + expect(runCommand?.args).toContain(`supabase_network_${PROJECT_ID}`); + expect(runCommand?.args).not.toContain("supabase_network_ancestor-project"); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("prefers config.toml over a stray config.json for the Docker download path", () => { + // Go's `NewPathBuilder`/`Config.Load` (`pkg/config/utils.go:43-48`) has + // no concept of a JSON project config file — it always resolves + // `supabase/config.toml`, mirrored here by `resolveEdgeRuntimeImage`'s + // `tomlOnly: true` (review round on CLI-1963's `functions download` + // port). A workdir with both files must resolve `project_id` from + // `config.toml`, not prefer the JSON file as the package loader + // otherwise would. + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi(); + const proxy = mockProxy(); + const child = mockChildProcessSpawner({ exitCode: 0 }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + }), + proxy.layer, + child.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "hello-world", + "--use-docker", + "--project-ref", + PROJECT_ID, + ]), + }), + ); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + mkdir(join(tempRoot.current, "supabase"), { recursive: true }), + ); + yield* Effect.tryPromise(() => + writeFile( + join(tempRoot.current, "supabase", "config.toml"), + ['project_id = "toml-project"', ""].join("\n"), + ), + ); + yield* Effect.tryPromise(() => + writeFile( + join(tempRoot.current, "supabase", "config.json"), + JSON.stringify({ project_id: "json-project" }), + ), + ); + + yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }); + + const runCommand = child.spawned.find((spawned) => spawned.args[0] === "run"); + expect(runCommand?.args).toContain("supabase_network_toml-project"); + expect(runCommand?.args).not.toContain("supabase_network_json-project"); + }).pipe(Effect.provide(layer)); + }); + it.live("skips network creation for a container: network mode", () => { // Go's `container.NetworkMode.IsUserDefined()` // (`docker/api/types/container/hostconfig_unix.go:23-25`) explicitly diff --git a/apps/cli/src/shared/functions/download.ts b/apps/cli/src/shared/functions/download.ts index d4b18e84f5..3b961f441f 100644 --- a/apps/cli/src/shared/functions/download.ts +++ b/apps/cli/src/shared/functions/download.ts @@ -859,6 +859,17 @@ const resolveEdgeRuntimeImage = Effect.fnUntraced(function* ( const loadedConfig = yield* loadProjectConfig(dependencies.projectRoot, { projectRef, goViperCompat: dependencies.goViperCompat, + // `search: false`/`tomlOnly: true` only under `goViperCompat` (the legacy caller, whose + // `dependencies.projectRoot` is `cliConfig.workdir` — already Go's fully-resolved chdir + // target, same reasoning as `legacy-local-project-context.ts`/`start.handler.ts`). Go's + // `flags.LoadConfig` (`pkg/config/utils.go:43-48`) only ever resolves `supabase/config.toml` + // from that exact workdir, with no ancestor climb and no concept of a JSON project config — + // leaving these unset here would let an unrelated ancestor project's config win, or a stray + // `supabase/config.json` be preferred over `config.toml`, for the legacy shell's Docker + // download path specifically. The `next` shell keeps the package defaults (ancestor search, + // JSON preferred), matching its other non-Go-parity `loadProjectConfig` callers. + search: dependencies.goViperCompat ? false : undefined, + tomlOnly: dependencies.goViperCompat, }); const denoVersion = loadedConfig?.config?.edge_runtime.deno_version; const projectId = loadedConfig?.config?.project_id ?? projectRef; @@ -872,6 +883,21 @@ const resolveEdgeRuntimeImage = Effect.fnUntraced(function* ( // `edgeRuntimeImageTag` (not a bare `v${edgeRuntimeVersion}` prepend) — // `dependencies.edgeRuntimeVersion` comes from a `.temp/edge-runtime-version` // pin that may already carry its own `v` prefix (see the helper's doc). + // + // Single `legacyGetRegistryImageUrl` value, not the ECR→GHCR→Docker-Hub + // retry `legacyGetRegistryImageUrlCandidates` gives `start` (review round + // on CLI-1963's `functions download` port). Go's `DockerStart` resolves + // `config.Image` through `DockerResolveImageIfNotCached` + // (`internal/utils/docker.go:326-348,363-365`), which tries every + // registry candidate — including for this exact edge-runtime unbundle + // container — so an ECR outage/throttle that the previous Go-delegated + // default path would have survived can now fail this native path outright. + // Pre-existing, not introduced by this PR: `deploy.ts`'s and `serve.ts`'s + // own already-shipped native Docker paths resolve this identically + // (single-URL, no retry) — `legacyGetRegistryImageUrlCandidates` has only + // ever been wired up for `start` (see its own doc comment). Extending the + // retry to all three `functions` Docker paths is a shared, cross-cutting + // follow-up, not something to fix piecemeal for `download` alone. image: legacyGetRegistryImageUrl( `supabase/edge-runtime:${edgeRuntimeImageTag(edgeRuntimeVersion)}`, ), From d4530b1fcb9ffc14ef69773fd8ff925e78b47ee5 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Wed, 5 Aug 2026 18:41:11 +0100 Subject: [PATCH 10/22] docs(functions): document deno-version-default and network-id env-var gaps (review: CLI-1963) Codex flagged that resolveEdgeRuntimeImage falls back to the v2 default when config.toml is absent (ignoring SUPABASE_EDGE_RUNTIME_DENO_VERSION), and that networkMode resolution never checks SUPABASE_NETWORK_ID the way Go's viper AutomaticEnv does for the --network-id persistent flag. Both are confirmed real gaps, but pre-existing and cross-cutting rather than introduced here: deploy.ts has the identical deno_version fallback today (config.toml present or not, since @supabase/config has no generic env-var struct binding at all), and start.handler.ts/deploy.ts/serve.ts's own network-id resolution don't check SUPABASE_NETWORK_ID either. Fixing either belongs in one shared place, not duplicated per Docker-path call site in download.ts alone -- left open, matching this PR's existing precedent for the registry-fallback gap. Documented inline and in the PR description's "Judgement calls left open" section instead of silently resolving the review threads. --- apps/cli/src/shared/functions/download.ts | 33 +++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/apps/cli/src/shared/functions/download.ts b/apps/cli/src/shared/functions/download.ts index 3b961f441f..c0fef4e858 100644 --- a/apps/cli/src/shared/functions/download.ts +++ b/apps/cli/src/shared/functions/download.ts @@ -871,6 +871,26 @@ const resolveEdgeRuntimeImage = Effect.fnUntraced(function* ( search: dependencies.goViperCompat ? false : undefined, tomlOnly: dependencies.goViperCompat, }); + // A project with no `supabase/config.toml`/`config.json` makes + // `loadProjectConfig` return `null` outright, so `denoVersion` below falls + // through to `undefined` and this always resolves the v2 default. Go's + // `flags.LoadConfig` never short-circuits like that: `Config.Load` → + // `loadFromFile` (`pkg/config/config.go:579-611`) merges the template + // defaults and enables `viper.AutomaticEnv()` with `SetEnvPrefix("SUPABASE")` + // *before* attempting to read the file — `mergeFileConfig` (`config.go:701-716`) + // simply no-ops on `os.ErrNotExist` — so `SUPABASE_EDGE_RUNTIME_DENO_VERSION=1` + // (or the same key in `supabase/.env`, via `loadNestedEnv`) still pins the + // deno-v1 image even with no config.toml on disk. Pre-existing, not + // introduced by this PR: `@supabase/config`'s `loadProjectConfig` has no + // equivalent of Go's generic `ExperimentalBindStruct`+`AutomaticEnv` field + // binding at all (it only expands literal `env(...)` references already + // written inside the TOML), so `deploy.ts`'s identical + // `resolveEdgeRuntimeVersion(deployConfig?.edge_runtime.deno_version, ...)` + // call has the same gap whether or not config.toml exists. A fix belongs in + // the shared config-loading layer every native caller goes through (`gen + // types`, `next start`, `functions dev/serve/deploy`, …), not duplicated + // per call site here — left open (review round on CLI-1963's `functions + // download` port). const denoVersion = loadedConfig?.config?.edge_runtime.deno_version; const projectId = loadedConfig?.config?.project_id ?? projectRef; const edgeRuntimeVersion = yield* resolveEdgeRuntimeVersion( @@ -985,6 +1005,19 @@ const downloadWithDockerUnbundle = Effect.fnUntraced(function* ( // must fall through to the generated network name too, not just an omitted // flag — `explicitNonEmptyStringFlag` (unlike the unexported // `explicitStringFlag`) treats that case as unset for exactly this reason. + // + // Go's root `init()` also binds every persistent flag (including + // `network-id`) through `viper.BindPFlags` after enabling + // `viper.AutomaticEnv()` with `SetEnvPrefix("SUPABASE")` and a `-`→`_` + // replacer (`cmd/root.go:316-334`), so `SUPABASE_NETWORK_ID` overrides the + // flag's empty default whenever `--network-id` itself is never passed — + // this raw-argv-only lookup has no equivalent env-var fallback. Pre-existing, + // not introduced by this PR: `start.handler.ts`'s `LegacyNetworkIdFlag` and + // `deploy.ts`'s/`serve.ts`'s own network-id resolution don't check + // `SUPABASE_NETWORK_ID` either — no native command does today. A fix + // belongs in one shared place for the global `--network-id` resolution, + // not duplicated per Docker-path call site here — left open (review round + // on CLI-1963's `functions download` port). const networkMode = explicitNonEmptyStringFlag(dependencies.rawArgs, "network-id") ?? localDockerId("network", projectId); From 695afaaf6edd498e9cc7c4bc49a5015927eb4d19 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Wed, 5 Aug 2026 19:44:32 +0100 Subject: [PATCH 11/22] fix(functions): honor the final occurrence of a repeated --network-id flag (review: CLI-1963) pflag/viper string flags are shared-variable, last-Set()-wins (confirmed empirically with a scratch pflag.FlagSet.Parse probe: --network-id old --network-id ci-net resolves to ci-net; a trailing --network-id= clears an earlier non-empty value). explicitStringFlag returned on the first argv match instead of scanning for the last, unlike this file's own explicitBooleanLongFlag and the legacy shell's legacyPflagStringValue, which already implement last-wins. Fixed to keep scanning, plus regression tests covering the repeated-override and repeated-then-cleared cases. --- .../download/download.integration.test.ts | 141 ++++++++++++++++++ apps/cli/src/shared/cli/cobra-flag-groups.ts | 23 +-- 2 files changed, 155 insertions(+), 9 deletions(-) diff --git a/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts b/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts index e161425c04..a5c10c6f7a 100644 --- a/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts +++ b/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts @@ -806,6 +806,94 @@ describe("legacy functions download", () => { }, ); + it.live("honors the final occurrence of a repeated --network-id flag", () => { + // pflag/viper string flags are shared-variable, last-`Set()`-wins + // (confirmed empirically: `pflag.FlagSet.Parse` on + // `--network-id old --network-id custom-network` resolves to + // `custom-network`) — `explicitStringFlag` must keep scanning past the + // first match instead of returning early (review round on CLI-1963's + // `functions download` port). + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi(); + const proxy = mockProxy(); + const child = mockChildProcessSpawner({ exitCode: 0 }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + }), + proxy.layer, + child.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "hello-world", + "--use-docker", + "--project-ref", + PROJECT_ID, + "--network-id", + "old-network", + "--network-id", + "custom-network", + ]), + }), + ); + + return Effect.gen(function* () { + yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }); + + const runCommand = child.spawned.find((spawned) => spawned.args[0] === "run"); + expect(runCommand?.args).toContain("custom-network"); + expect(runCommand?.args).not.toContain("old-network"); + }).pipe(Effect.provide(layer)); + }); + + it.live( + "falls back to the generated network name when the final --network-id occurrence is empty", + () => { + // Same last-wins rule as above, applied to Go's `len(networkId) > 0` + // gate: a non-empty default followed by an explicit-but-empty override + // must fall through to the generated network name, not the earlier + // non-empty value. + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi(); + const proxy = mockProxy(); + const child = mockChildProcessSpawner({ exitCode: 0 }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + }), + proxy.layer, + child.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "hello-world", + "--use-docker", + "--project-ref", + PROJECT_ID, + "--network-id", + "custom-network", + "--network-id=", + ]), + }), + ); + + return Effect.gen(function* () { + yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }); + + const runCommand = child.spawned.find((spawned) => spawned.args[0] === "run"); + expect(runCommand?.args).toContain(`supabase_network_${PROJECT_ID}`); + expect(runCommand?.args).not.toContain("custom-network"); + }).pipe(Effect.provide(layer)); + }, + ); + it.live( "does not climb to an ancestor project's config.toml for the Docker download path", () => { @@ -1509,6 +1597,59 @@ describe("legacy functions download", () => { }).pipe(Effect.provide(layer)); }); + it.live("fails loudly instead of silently dropping a malformed function-list entry", () => { + // Go: `FunctionResponse.Slug` (`pkg/api/types.gen.go:6465`) is a + // required, non-pointer `string` — a list entry with no "slug" key + // decodes to the zero value "" and then fails `ValidateFunctionSlug` + // in `downloadAll` (`download.go:182-188`), rather than being dropped + // from the list. A malicious/compromised API response returning + // `[{}]` must surface an error here too, never "No functions found." + // nor a silent partial download (review round on CLI-1963's + // `functions download` port). + const out = mockOutput({ format: "json" }); + const api = mockLegacyPlatformApi({ + handler: (request) => + request.url.endsWith("/functions") + ? Effect.succeed(legacyJsonResponse(request, 200, [{}])) + : Effect.succeed(legacyJsonResponse(request, 200, {})), + }); + const proxy = mockProxy(); + const child = mockChildProcessSpawner({ exitCode: 0 }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + }), + proxy.layer, + child.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "--project-ref", + "abcdefghijklmnopqrst", + "--output-format", + "json", + ]), + }), + ); + + return Effect.gen(function* () { + const exit = yield* legacyFunctionsDownload({ + ...baseFlags, + functionName: Option.none(), + useDocker: true, + }).pipe(Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + expect(proxy.calls).toEqual([]); + expect(out.messages).not.toContainEqual( + expect.objectContaining({ type: "success", message: "No functions found." }), + ); + }).pipe(Effect.provide(layer)); + }); + it.live("forwards only --legacy-bundle to the Go proxy, not the --use-docker default too", () => { const out = mockOutput({ format: "text" }); const api = mockLegacyPlatformApi(); diff --git a/apps/cli/src/shared/cli/cobra-flag-groups.ts b/apps/cli/src/shared/cli/cobra-flag-groups.ts index 46975b2fa6..88d80c350c 100644 --- a/apps/cli/src/shared/cli/cobra-flag-groups.ts +++ b/apps/cli/src/shared/cli/cobra-flag-groups.ts @@ -30,22 +30,27 @@ export function hasExplicitLongFlag( /** * Raw value of `--`/`--=value` anywhere in argv - * (unscoped — no command-path anchoring), or `undefined` if absent. Not - * exported — every current call site needs Go's `len(value) > 0` gate too - * (see {@link explicitNonEmptyStringFlag}); re-export this directly if a - * future caller genuinely needs presence-only semantics. + * (unscoped — no command-path anchoring), or `undefined` if absent. + * pflag string flags are shared-variable, last-`Set()`-wins (same rule + * {@link explicitBooleanLongFlag} and `legacyPflagStringValue` already + * follow) — a repeated `-- old -- new` must resolve to + * `new`, so this keeps scanning after a match instead of returning early + * (review round on CLI-1963's `functions download` port). Not exported — + * every current call site needs Go's `len(value) > 0` gate too (see + * {@link explicitNonEmptyStringFlag}); re-export this directly if a future + * caller genuinely needs presence-only semantics. */ function explicitStringFlag(rawArgs: ReadonlyArray, flagName: string) { + let result: string | undefined; for (let index = 0; index < rawArgs.length; index += 1) { const token = rawArgs[index]; if (token === `--${flagName}`) { - return rawArgs[index + 1]; - } - if (token?.startsWith(`--${flagName}=`)) { - return token.slice(flagName.length + 3); + result = rawArgs[index + 1]; + } else if (token?.startsWith(`--${flagName}=`)) { + result = token.slice(flagName.length + 3); } } - return undefined; + return result; } /** From d556716a6ea8c730198537ad720035c45af9d0d6 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Wed, 5 Aug 2026 19:45:25 +0100 Subject: [PATCH 12/22] fix(functions): surface malformed function-list entries instead of dropping them (review: CLI-1963) Go's FunctionResponse.Slug (apps/cli-go/pkg/api/types.gen.go:6465) is a required, non-pointer string: a list entry with a missing or null "slug" decodes to the zero value "" rather than erroring, and that empty slug then fails ValidateFunctionSlug loudly in downloadAll (download.go:182-188) instead of vanishing from the list. listRemoteFunctionSlugs's flatMap filtered such entries out entirely, defeating part of the CLI-1891 validation this PR added for exactly this "compromised/malformed API response" threat model. Preserve the entry (coerced to "") so the existing validateRemoteSlug/validateSlug check catches it, matching Go instead of reporting "No functions found." or a silent partial download. --- apps/cli/src/shared/functions/download.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/apps/cli/src/shared/functions/download.ts b/apps/cli/src/shared/functions/download.ts index c0fef4e858..a860cc56da 100644 --- a/apps/cli/src/shared/functions/download.ts +++ b/apps/cli/src/shared/functions/download.ts @@ -664,9 +664,21 @@ const listRemoteFunctionSlugs = Effect.fnUntraced(function* (api: ApiClient, pro if (!Array.isArray(parsed)) { throw new Error("expected functions list response to be an array"); } - return parsed.flatMap((value) => { + // Go: `FunctionResponse.Slug` (`apps/cli-go/pkg/api/types.gen.go:6465`) + // is a required, non-pointer `string` — a list entry with a missing or + // `null` "slug" decodes to the zero value `""` rather than erroring + // (`encoding/json`'s documented null-into-non-pointer no-op), and that + // empty slug then fails loudly downstream (`validateRemoteSlug`, + // matching Go's own per-item `ValidateFunctionSlug` in `downloadAll`, + // `download.go:182-188`) instead of silently vanishing from the list. + // Coercing here (rather than filtering the entry out, as before) + // preserves that "always surface an unexpected API response, never + // silently download fewer functions than requested" invariant — the + // exact CLI-1891 threat model `validateRemoteSlug` exists for (review + // round on CLI-1963's `functions download` port). + return parsed.map((value) => { const slug = getObjectProperty(value, "slug"); - return typeof slug === "string" ? [slug] : []; + return typeof slug === "string" ? slug : ""; }); }, catch: (cause) => From 78342387681cc5908e4bcd7f3f6bc223c9ef5244 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Wed, 5 Aug 2026 19:45:44 +0100 Subject: [PATCH 13/22] docs(functions): document project_id-validation gap for Docker download configs (review: CLI-1963) Go's Config.Validate (pkg/config/config.go:990-991) rejects a config.toml with project_id = "" up front, inside flags.LoadConfig, before any Docker/API work. resolveEdgeRuntimeImage's `?? projectRef` fallback only substitutes on null/undefined, so an explicit empty project_id sails through instead. Pre-existing and cross-cutting, not specific to this PR: deploy.ts's identical deployConfig?.project_id ?? projectRef fallback (deploy.ts:2201) has the same gap, and no native functions Docker path (deploy/serve/download) routes its config through Config.Validate parity checks at all -- that port has one home today (legacy-config-validate.ts's legacyValidateResolvedConfig), wired up only for the db/migration loader and status/stop resolver. Left open, same treatment as the registry-fallback/config-defaults/network-id-env gaps already documented above -- belongs in the shared config-loading layer every native caller goes through, not duplicated per call site. --- apps/cli/src/shared/functions/download.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/apps/cli/src/shared/functions/download.ts b/apps/cli/src/shared/functions/download.ts index a860cc56da..049845a534 100644 --- a/apps/cli/src/shared/functions/download.ts +++ b/apps/cli/src/shared/functions/download.ts @@ -904,6 +904,23 @@ const resolveEdgeRuntimeImage = Effect.fnUntraced(function* ( // per call site here — left open (review round on CLI-1963's `functions // download` port). const denoVersion = loadedConfig?.config?.edge_runtime.deno_version; + // `?? projectRef` only substitutes on `null`/`undefined`, so a config.toml + // with an explicit `project_id = ""` still resolves to the empty string + // here (`supabase_network_`/`supabase_edge_runtime_`) instead of failing + // up front. Go's `Config.Validate` rejects that same config with "Missing + // required field in config: project_id" (`pkg/config/config.go:990-991`) + // before `flags.LoadConfig` ever returns to `Run` — before any Docker/API + // work. Pre-existing and cross-cutting, not introduced by this PR: + // `deploy.ts`'s identical `deployConfig?.project_id ?? projectRef` + // fallback (`deploy.ts:2201`) has the same gap, and no native `functions` + // Docker path (`deploy`, `serve`, `download`) routes a loaded config + // through `Config.Validate` parity checks at all — that port has one home + // today, `legacy-config-validate.ts`'s `legacyValidateResolvedConfig`, + // wired up only for the db/migration loader and the status/stop resolver. + // Wiring `Config.Validate` into every native config-consuming command + // belongs in the shared config-loading layer, not duplicated per + // Docker-path call site here — left open, same as the config-defaults gap + // above (review round on CLI-1963's `functions download` port). const projectId = loadedConfig?.config?.project_id ?? projectRef; const edgeRuntimeVersion = yield* resolveEdgeRuntimeVersion( denoVersion, From 8b7dad19bb0af76f7d03103ec3ff84f6d36522c6 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Wed, 5 Aug 2026 20:38:18 +0100 Subject: [PATCH 14/22] docs(functions): fix SIDE_EFFECTS.md config-read scope for download (review: CLI-1963) resolveEdgeRuntimeImage() (and its config.toml/config.json read) runs unconditionally after resolving the project ref, before the --use-api check -- matching Go's flags.LoadConfig running unconditionally at the top of Run. The doc previously claimed --use-api reads no project config at all, which is now stale. Also documents BITBUCKET_CLONE_DIR: the new Docker-unbundle path skips creating the named Deno-cache volume and its bind mount when set, mirroring deploy.ts's existing carve-out; the Environment Variables table omitted it entirely. --- .../functions/download/SIDE_EFFECTS.md | 35 ++++++++++--------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/apps/cli/src/legacy/commands/functions/download/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/functions/download/SIDE_EFFECTS.md index a32ee01293..4fe5177c4b 100644 --- a/apps/cli/src/legacy/commands/functions/download/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/functions/download/SIDE_EFFECTS.md @@ -2,15 +2,15 @@ ## Files Read -| Path | Format | When | -| --------------------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/profile` | plain text | when `--profile` and `SUPABASE_PROFILE` are both unset | -| `.yaml` | YAML | when `SUPABASE_PROFILE` or `--profile` points to a file | -| `/supabase/.temp/project-ref` | plain text | when `--project-ref` and `SUPABASE_PROJECT_ID` are both unset | -| `/supabase/.temp/edge-runtime-version` | plain text | Docker-unbundle path: overrides the default edge-runtime image tag when present | -| `/supabase/config.toml` (or `config.json`) | TOML/JSON | Docker-unbundle path: resolves `edge_runtime.deno_version` and `project_id` (`loadProjectConfig`) — a new file-read surface versus the `--use-api` path, which reads no project config | -| `/telemetry.json` | JSON | when present, before post-run telemetry state is refreshed | +| Path | Format | When | +| --------------------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/profile` | plain text | when `--profile` and `SUPABASE_PROFILE` are both unset | +| `.yaml` | YAML | when `SUPABASE_PROFILE` or `--profile` points to a file | +| `/supabase/.temp/project-ref` | plain text | when `--project-ref` and `SUPABASE_PROJECT_ID` are both unset | +| `/supabase/.temp/edge-runtime-version` | plain text | Docker-unbundle path: overrides the default edge-runtime image tag when present | +| `/supabase/config.toml` (or `config.json`) | TOML/JSON | Read unconditionally after resolving the project ref, before checking `--use-api`/`--use-docker` or whether Docker is running — resolves `edge_runtime.deno_version` and `project_id` (`loadProjectConfig`) for the Docker-unbundle path. Matches Go's `flags.LoadConfig` running unconditionally at the top of `Run` (`download.go:131-138`): a malformed config now fails here even on the `--use-api` invocation. | +| `/telemetry.json` | JSON | when present, before post-run telemetry state is refreshed | ## Files Written @@ -52,14 +52,15 @@ to stderr in machine-output modes (CLI-1546). ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `/access-token`) | -| `SUPABASE_HOME` | overrides where `telemetry.json` and `profile` are read/written | no (defaults to `~/.supabase`) | -| `SUPABASE_NO_KEYRING` | disables the OS keyring, forcing the access-token file fallback | no | -| `SUPABASE_PROFILE` | select a built-in profile or YAML profile file with `api_url:` | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_PROJECT_ID` | provides the project ref when `--project-ref` is unset | no (falls back to `/supabase/.temp/project-ref`) | -| `SUPABASE_WORKDIR` | sets `` for local Supabase temp files | no (falls back to `--workdir` -> nearest ancestor with `supabase/config.toml` -> cwd) | +| Variable | Purpose | Required? | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `/access-token`) | +| `SUPABASE_HOME` | overrides where `telemetry.json` and `profile` are read/written | no (defaults to `~/.supabase`) | +| `SUPABASE_NO_KEYRING` | disables the OS keyring, forcing the access-token file fallback | no | +| `SUPABASE_PROFILE` | select a built-in profile or YAML profile file with `api_url:` | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | provides the project ref when `--project-ref` is unset | no (falls back to `/supabase/.temp/project-ref`) | +| `SUPABASE_WORKDIR` | sets `` for local Supabase temp files | no (falls back to `--workdir` -> nearest ancestor with `supabase/config.toml` -> cwd) | +| `BITBUCKET_CLONE_DIR` | Docker-unbundle path: when set, skips creating the named Deno-cache volume and omits its bind mount from the `docker run` command (Bitbucket's restricted Docker environment rejects both) | no | ## Exit Codes From b856a5318c7201b90726b9d1368d4b9ffca9fea9 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Wed, 5 Aug 2026 20:38:28 +0100 Subject: [PATCH 15/22] fix(functions): style the Docker-unbundle progress slug in legacy shell (review: CLI-1963) Go's downloadOne bolds the slug on the "Downloading function:" progress line (utils.Bold, download.go:219); the new native Docker-unbundle path wrote the plain slug with no styling. Adds an optional styleEmphasis hook to DownloadDockerRuntimeDependencies (defaulting to identity, mirroring deploy.ts's DeployFunctionsDependencies.styleEmphasis) and wires the legacy handler to inject legacyBold, keeping next isolated from legacy/-specific rendering. downloadSingle's server-side path has the identical unstyled-slug gap, but it predates this PR (#5527) rather than being introduced here, so it's left as-is. --- .../functions/download/download.handler.ts | 4 ++++ apps/cli/src/shared/functions/download.ts | 16 ++++++++++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/apps/cli/src/legacy/commands/functions/download/download.handler.ts b/apps/cli/src/legacy/commands/functions/download/download.handler.ts index 53886acf72..2b89b6aa75 100644 --- a/apps/cli/src/legacy/commands/functions/download/download.handler.ts +++ b/apps/cli/src/legacy/commands/functions/download/download.handler.ts @@ -6,6 +6,7 @@ import { } from "../../../../shared/functions/download.ts"; import { resolveEdgeRuntimeVersionPin } from "../../../../shared/functions/functions.shared.ts"; import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { legacyBold } from "../../../shared/legacy-colors.ts"; import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; @@ -35,6 +36,9 @@ export const legacyFunctionsDownload = Effect.fn("legacy.functions.download")(fu rawArgs, goViperCompat: true, edgeRuntimeVersion, + // Go: `utils.Bold` on the `Downloading function:` slug (`downloadOne`, + // `download.go:219`, stderr) — matches `legacyBold`'s default TTY gate. + styleEmphasis: (text) => legacyBold(text), resolveProjectRef: (projectRef) => resolver.resolve(projectRef).pipe( Effect.tap((ref) => diff --git a/apps/cli/src/shared/functions/download.ts b/apps/cli/src/shared/functions/download.ts index 049845a534..b0cfb10531 100644 --- a/apps/cli/src/shared/functions/download.ts +++ b/apps/cli/src/shared/functions/download.ts @@ -61,6 +61,14 @@ interface DownloadRuntimeDependencies { /** Adds what the Docker-unbundle path needs beyond the server-side path. */ interface DownloadDockerRuntimeDependencies extends DownloadRuntimeDependencies { readonly rawArgs: ReadonlyArray; + /** + * Optional shell-specific styling hook for the `Downloading function:` + * progress line — mirrors `deploy.ts`'s `DeployFunctionsDependencies.styleEmphasis`. + * Defaults to identity (plain text); the legacy shell injects Go's bold + * styling here so the next shell stays isolated from `legacy/`-specific + * rendering. Go: `utils.Bold(slug)` (`downloadOne`, `download.go:219`). + */ + readonly styleEmphasis?: (text: string) => string; } /** @@ -972,11 +980,15 @@ const downloadWithDockerUnbundle = Effect.fnUntraced(function* ( slug: string, ) { const output = yield* Output; + const styleEmphasis = dependencies.styleEmphasis ?? ((text: string) => text); // Go: `downloadOne` (`download.go:219`) — lowercase "function", distinct // from the server-side path's "Downloading Function:" (capital F, - // `downloadWithServerSideUnbundle`, `download.go:329`). - yield* output.raw(`Downloading function: ${slug}\n`, "stderr"); + // `downloadWithServerSideUnbundle`, `download.go:329`). Both Go call sites + // bold the slug (`utils.Bold`); this path is new in CLI-1963, so it picks + // up the styling hook now. `downloadSingle`'s server-side path below has + // the identical gap, but predates this PR (#5527) — left as-is here. + yield* output.raw(`Downloading function: ${styleEmphasis(slug)}\n`, "stderr"); const eszip = yield* downloadEszipBody(dependencies.api, projectRef, slug); From 118787458438fe22b9eb1d1458d0c9cd08129cf7 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Wed, 5 Aug 2026 21:42:56 +0100 Subject: [PATCH 16/22] fix(functions): fail the whole function list on a typed non-string slug (review: CLI-1963) Go's generated client unmarshals the entire []FunctionResponse array in one json.Unmarshal call (apps/cli-go/pkg/api/client.gen.go:22186-22208) -- a type mismatch on any single element's slug (a required string field) fails that call outright, and ParseV1ListAllFunctionsResponse returns before ever assigning response.JSON200, so downloadAll fails with "failed to list functions: ..." before downloading anything. listRemoteFunctionSlugs instead coerced a present-but-non-string slug (e.g. 123) to "", so an earlier well-formed entry in the same list would already be downloaded before the later entry's validation error surfaced. Throw immediately on a present, non-string slug (still zero-valuing missing/null, matching Go's null-into-non-pointer no-op) to preserve Go's fail-before-any-download ordering. Confirmed empirically with a scratch json.Unmarshal probe. --- .../download/download.integration.test.ts | 43 +++++++++++++++++++ apps/cli/src/shared/functions/download.ts | 23 +++++++++- 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/apps/cli/src/next/commands/functions/download/download.integration.test.ts b/apps/cli/src/next/commands/functions/download/download.integration.test.ts index 72f23a0413..cfe88deb0d 100644 --- a/apps/cli/src/next/commands/functions/download/download.integration.test.ts +++ b/apps/cli/src/next/commands/functions/download/download.integration.test.ts @@ -766,6 +766,49 @@ describe("functions download", () => { ); }); + it.live( + "fails the whole list before downloading anything when a slug is typed as a non-string", + () => { + const tempDir = makeTempDir(); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); + // Go's generated client unmarshals the whole `[]FunctionResponse` + // array in one `json.Unmarshal` call + // (`apps/cli-go/pkg/api/client.gen.go:22186-22208`); a type mismatch + // on any single element's `slug` (a required `string` field) fails + // that call outright, so `downloadAll` fails with "failed to list + // functions: ..." before downloading anything — including the + // earlier, well-formed "ok" entry. Confirmed empirically: + // `json.Unmarshal([]byte(`[{"slug":"ok"},{"slug":123}]`), &dest)` + // returns a `*json.UnmarshalTypeError`, and the generated parser + // returns before ever assigning `response.JSON200`. + const { api, layer } = setup(tempDir, { + listBody: [{ slug: "ok" }, { slug: 123 }], + }); + + const error = yield* functionsDownload({ + ...BASE_FLAGS, + functionName: Option.none(), + }).pipe(Effect.provide(layer), Effect.flip); + + expect(error).toBeInstanceOf(InvalidFunctionDownloadResponseError); + expect((error as Error).message).toBe( + "failed to read functions list: expected function slug to be a string, got number", + ); + // Only the list call happened — "ok" was never downloaded, matching + // Go's atomic list-decode failure instead of downloading it before + // hitting the later entry's error. + expect(api.requests).toEqual([ + `https://api.supabase.com/v1/projects/${PROJECT_REF}/functions`, + ]); + expect(existsSync(join(tempDir, "supabase", "functions"))).toBe(false); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }, + ); + it.live("prints the download-all success line when the project has one function", () => { const tempDir = makeTempDir(); const multipart = multipartBody([ diff --git a/apps/cli/src/shared/functions/download.ts b/apps/cli/src/shared/functions/download.ts index b0cfb10531..c8490ad885 100644 --- a/apps/cli/src/shared/functions/download.ts +++ b/apps/cli/src/shared/functions/download.ts @@ -684,9 +684,30 @@ const listRemoteFunctionSlugs = Effect.fnUntraced(function* (api: ApiClient, pro // silently download fewer functions than requested" invariant — the // exact CLI-1891 threat model `validateRemoteSlug` exists for (review // round on CLI-1963's `functions download` port). + // + // A "slug" present but typed as something other than string/null is a + // different case: Go's generated client decodes the *entire* array in + // one `json.Unmarshal` call (`ParseV1ListAllFunctionsResponse`, + // `apps/cli-go/pkg/api/client.gen.go:22186-22208`), and a type mismatch + // on any single element fails that whole call — confirmed empirically + // (`json.Unmarshal([]byte(`+"`"+`[{"slug":"ok"},{"slug":123}]`+"`"+`), &dest)` + // returns a `*json.UnmarshalTypeError`; `dest` is partially populated in + // memory, but `ParseV1ListAllFunctionsResponse` returns before ever + // assigning `response.JSON200`, discarding it), so `V1ListAllFunctionsWithResponse` + // returns an error and `downloadAll` fails with "failed to list + // functions: ..." before downloading anything — not after downloading + // the earlier, well-formed entries. Throwing here (rather than + // coercing to `""` like the missing/null case above) preserves that + // same fail-before-any-download ordering. return parsed.map((value) => { const slug = getObjectProperty(value, "slug"); - return typeof slug === "string" ? slug : ""; + if (slug === null || slug === undefined) { + return ""; + } + if (typeof slug !== "string") { + throw new Error(`expected function slug to be a string, got ${typeof slug}`); + } + return slug; }); }, catch: (cause) => From 29f2f28f9bc8a4a3935997a93b8d3db0dbc6c3a9 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Wed, 5 Aug 2026 21:43:49 +0100 Subject: [PATCH 17/22] docs(functions): document project-dotenv registry gap as cross-cutting (review: CLI-1963) resolveEdgeRuntimeImage calls legacyGetRegistryImageUrl with no projectEnvValues, so a SUPABASE_INTERNAL_IMAGE_REGISTRY set only in supabase/.env (not the ambient shell) is invisible here, unlike Go's flags.LoadConfig -> loadNestedEnv, which os.Setenvs every project dotenv key into the process env before GetRegistry() ever reads it. Confirmed real, but pre-existing and cross-cutting, not specific to this PR: deploy.ts and serve.ts call the same helper the same way -- the only caller that resolves and threads project dotenv today is start, via legacyLoadLocalProjectContext. Belongs in the shared config-loading layer every native functions Docker path goes through, not duplicated per call site -- left open, same treatment already applied to the registry-fallback/config-defaults/network-id-env/ Config.Validate gaps in this same function. --- apps/cli/src/shared/functions/download.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/apps/cli/src/shared/functions/download.ts b/apps/cli/src/shared/functions/download.ts index c8490ad885..20afd845f9 100644 --- a/apps/cli/src/shared/functions/download.ts +++ b/apps/cli/src/shared/functions/download.ts @@ -976,6 +976,28 @@ const resolveEdgeRuntimeImage = Effect.fnUntraced(function* ( // ever been wired up for `start` (see its own doc comment). Extending the // retry to all three `functions` Docker paths is a shared, cross-cutting // follow-up, not something to fix piecemeal for `download` alone. + // + // No `projectEnvValues` argument either (review round on CLI-1963's + // `functions download` port): Go's `flags.LoadConfig` → `loadNestedEnv` + // (`pkg/config/config.go:1220-1258`) calls `godotenv.Load` on every + // project dotenv file, which `os.Setenv`s each key into the process env + // — ambient-wins, but a `SUPABASE_INTERNAL_IMAGE_REGISTRY` set only in + // `supabase/.env` (not the ambient shell) is visible to `GetRegistry()`'s + // later `viper.GetString("INTERNAL_IMAGE_REGISTRY")` read + // (`internal/utils/docker.go:221-227`) regardless. `loadProjectConfig` + // above only uses its own dotenv read internally, for `env(...)` + // interpolation — it doesn't return the values, so this call falls back + // to `legacyGetRegistryOverride`'s ambient-only `process.env` read and + // misses a project-local registry mirror configured only via dotenv. + // Pre-existing and cross-cutting, not introduced by this PR: `deploy.ts` + // (`deploy.ts:1287`) and `serve.ts` (`serve.ts:1756`) call the same + // `legacyGetRegistryImageUrl` with no `projectEnvValues` either — the + // only caller that resolves and threads it today is `start`, via + // `legacyLoadLocalProjectContext`/`legacyGetRegistryImageUrlCandidates`. + // Loading project dotenv for every native `functions` Docker path belongs + // in the shared config-loading layer, not duplicated per call site here + // — left open, same treatment as the config-defaults/network-id-env/ + // Config.Validate gaps above. image: legacyGetRegistryImageUrl( `supabase/edge-runtime:${edgeRuntimeImageTag(edgeRuntimeVersion)}`, ), From 42b5c371f645760b44f2b2d00359e5930ce9f26b Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Wed, 5 Aug 2026 22:47:36 +0100 Subject: [PATCH 18/22] docs(functions): fix legacy download SIDE_EFFECTS.md file-read matrix (review: CLI-1963) - edge-runtime-version pin is read unconditionally by resolveEdgeRuntimeVersionPin() before the --use-api/Docker choice, not only on the Docker-unbundle path. - goViperCompat's tomlOnly:true means config.json is never a legacy read path; drop the "(or config.json)" implication from config.toml's row. - list the SUPABASE_INTERNAL_IMAGE_REGISTRY env var, read unconditionally while resolving the edge-runtime image (even on --use-api invocations). --- .../functions/download/SIDE_EFFECTS.md | 37 ++++++++++--------- 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/apps/cli/src/legacy/commands/functions/download/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/functions/download/SIDE_EFFECTS.md index 4fe5177c4b..11f217cb53 100644 --- a/apps/cli/src/legacy/commands/functions/download/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/functions/download/SIDE_EFFECTS.md @@ -2,15 +2,15 @@ ## Files Read -| Path | Format | When | -| --------------------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/profile` | plain text | when `--profile` and `SUPABASE_PROFILE` are both unset | -| `.yaml` | YAML | when `SUPABASE_PROFILE` or `--profile` points to a file | -| `/supabase/.temp/project-ref` | plain text | when `--project-ref` and `SUPABASE_PROJECT_ID` are both unset | -| `/supabase/.temp/edge-runtime-version` | plain text | Docker-unbundle path: overrides the default edge-runtime image tag when present | -| `/supabase/config.toml` (or `config.json`) | TOML/JSON | Read unconditionally after resolving the project ref, before checking `--use-api`/`--use-docker` or whether Docker is running — resolves `edge_runtime.deno_version` and `project_id` (`loadProjectConfig`) for the Docker-unbundle path. Matches Go's `flags.LoadConfig` running unconditionally at the top of `Run` (`download.go:131-138`): a malformed config now fails here even on the `--use-api` invocation. | -| `/telemetry.json` | JSON | when present, before post-run telemetry state is refreshed | +| Path | Format | When | +| ----------------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/profile` | plain text | when `--profile` and `SUPABASE_PROFILE` are both unset | +| `.yaml` | YAML | when `SUPABASE_PROFILE` or `--profile` points to a file | +| `/supabase/.temp/project-ref` | plain text | when `--project-ref` and `SUPABASE_PROJECT_ID` are both unset | +| `/supabase/.temp/edge-runtime-version` | plain text | Read unconditionally by `resolveEdgeRuntimeVersionPin()` in the handler, before the shared downloader chooses `--use-api` vs Docker — only affects the resolved edge-runtime image tag on the Docker-unbundle path | +| `/supabase/config.toml` | TOML | Read unconditionally after resolving the project ref, before checking `--use-api`/`--use-docker` or whether Docker is running — resolves `edge_runtime.deno_version` and `project_id` (`loadProjectConfig`) for the Docker-unbundle path. `goViperCompat`'s `tomlOnly: true` means `config.json` is never read here, unlike other `loadProjectConfig` callers. Matches Go's `flags.LoadConfig` running unconditionally at the top of `Run` (`download.go:131-138`): a malformed config now fails here even on the `--use-api` invocation. | +| `/telemetry.json` | JSON | when present, before post-run telemetry state is refreshed | ## Files Written @@ -52,15 +52,16 @@ to stderr in machine-output modes (CLI-1546). ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `/access-token`) | -| `SUPABASE_HOME` | overrides where `telemetry.json` and `profile` are read/written | no (defaults to `~/.supabase`) | -| `SUPABASE_NO_KEYRING` | disables the OS keyring, forcing the access-token file fallback | no | -| `SUPABASE_PROFILE` | select a built-in profile or YAML profile file with `api_url:` | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_PROJECT_ID` | provides the project ref when `--project-ref` is unset | no (falls back to `/supabase/.temp/project-ref`) | -| `SUPABASE_WORKDIR` | sets `` for local Supabase temp files | no (falls back to `--workdir` -> nearest ancestor with `supabase/config.toml` -> cwd) | -| `BITBUCKET_CLONE_DIR` | Docker-unbundle path: when set, skips creating the named Deno-cache volume and omits its bind mount from the `docker run` command (Bitbucket's restricted Docker environment rejects both) | no | +| Variable | Purpose | Required? | +| ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `/access-token`) | +| `SUPABASE_HOME` | overrides where `telemetry.json` and `profile` are read/written | no (defaults to `~/.supabase`) | +| `SUPABASE_NO_KEYRING` | disables the OS keyring, forcing the access-token file fallback | no | +| `SUPABASE_PROFILE` | select a built-in profile or YAML profile file with `api_url:` | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | provides the project ref when `--project-ref` is unset | no (falls back to `/supabase/.temp/project-ref`) | +| `SUPABASE_WORKDIR` | sets `` for local Supabase temp files | no (falls back to `--workdir` -> nearest ancestor with `supabase/config.toml` -> cwd) | +| `BITBUCKET_CLONE_DIR` | Docker-unbundle path: when set, skips creating the named Deno-cache volume and omits its bind mount from the `docker run` command (Bitbucket's restricted Docker environment rejects both) | no | +| `SUPABASE_INTERNAL_IMAGE_REGISTRY` | selects the registry the edge-runtime unbundle image is pulled from (`legacyGetRegistryImageUrl`); read unconditionally while resolving the image, before the `--use-api`/Docker choice is finalized — also consumed on the `--use-api` invocation even though it never pulls an image | no (defaults to `public.ecr.aws`) | ## Exit Codes From 39b98d0ebbcf75ceb24d2f24d1440edf1c33f000 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Wed, 5 Aug 2026 22:47:47 +0100 Subject: [PATCH 19/22] fix(functions): style the --legacy-bundle suggestion aqua in legacy shell (review: CLI-1963) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Go wraps the suggested `--legacy-bundle` command in utils.Aqua (suggestLegacyBundle, download.go:315); the Docker-unbundle port hard-coded plain text even though this same file already threads a styleEmphasis hook for the sibling "Downloading function:" line. Add a matching styleAqua dependency, injected as legacyAqua from the legacy handler (next stays plain, same isolation rationale as styleEmphasis). Also documents three confirmed-but-left-open cross-cutting gaps found in the same review round (buffered instead of streamed unbundle container output, missing container labels, unstyled Docker-down warning) — each already present unmodified in deploy.ts's Docker bundler, so fixing them only here would create asymmetry between the two commands. See the PR description's "Judgement calls left open" section. --- .../functions/download/download.handler.ts | 6 +- apps/cli/src/shared/functions/download.ts | 80 ++++++++++++++++--- 2 files changed, 74 insertions(+), 12 deletions(-) diff --git a/apps/cli/src/legacy/commands/functions/download/download.handler.ts b/apps/cli/src/legacy/commands/functions/download/download.handler.ts index 2b89b6aa75..f53170995b 100644 --- a/apps/cli/src/legacy/commands/functions/download/download.handler.ts +++ b/apps/cli/src/legacy/commands/functions/download/download.handler.ts @@ -6,7 +6,7 @@ import { } from "../../../../shared/functions/download.ts"; import { resolveEdgeRuntimeVersionPin } from "../../../../shared/functions/functions.shared.ts"; import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; -import { legacyBold } from "../../../shared/legacy-colors.ts"; +import { legacyAqua, legacyBold } from "../../../shared/legacy-colors.ts"; import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; @@ -39,6 +39,10 @@ export const legacyFunctionsDownload = Effect.fn("legacy.functions.download")(fu // Go: `utils.Bold` on the `Downloading function:` slug (`downloadOne`, // `download.go:219`, stderr) — matches `legacyBold`'s default TTY gate. styleEmphasis: (text) => legacyBold(text), + // Go: `utils.Aqua` on the suggested `--legacy-bundle` command + // (`suggestLegacyBundle`, `download.go:315`, stderr) — matches + // `legacyAqua`'s default TTY gate. + styleAqua: (text) => legacyAqua(text), resolveProjectRef: (projectRef) => resolver.resolve(projectRef).pipe( Effect.tap((ref) => diff --git a/apps/cli/src/shared/functions/download.ts b/apps/cli/src/shared/functions/download.ts index 20afd845f9..2d849fd4bd 100644 --- a/apps/cli/src/shared/functions/download.ts +++ b/apps/cli/src/shared/functions/download.ts @@ -69,6 +69,14 @@ interface DownloadDockerRuntimeDependencies extends DownloadRuntimeDependencies * rendering. Go: `utils.Bold(slug)` (`downloadOne`, `download.go:219`). */ readonly styleEmphasis?: (text: string) => string; + /** + * Optional shell-specific styling hook for the `--legacy-bundle` command + * suggested inside {@link suggestLegacyBundle} — same isolation rationale + * as {@link styleEmphasis}, just a different Go colour. Go: + * `utils.Aqua("supabase functions download --legacy-bundle "+slug)` + * (`suggestLegacyBundle`, `download.go:315`). + */ + readonly styleAqua?: (text: string) => string; } /** @@ -839,10 +847,15 @@ const downloadEszipBody = Effect.fnUntraced(function* ( ); }); -function suggestLegacyBundle(slug: string): string { +function suggestLegacyBundle( + slug: string, + styleAqua: (text: string) => string = (text) => text, +): string { // Go: `suggestLegacyBundle` (`download.go:314-316`) — verbatim, including - // the source's own "trying running" wording and its leading newline. - return `\nIf your function is deployed using CLI < 1.120.0, trying running supabase functions download --legacy-bundle ${slug} instead.`; + // the source's own "trying running" wording and its leading newline. Go + // wraps only the suggested command itself in `utils.Aqua`, not the whole + // sentence — `styleAqua` mirrors that scope exactly. + return `\nIf your function is deployed using CLI < 1.120.0, trying running ${styleAqua(`supabase functions download --legacy-bundle ${slug}`)} instead.`; } function suggestDenoV2(): string { @@ -862,10 +875,10 @@ function suggestDenoV2(): string { * they raise themselves (`functions-docker.ts`), so this only normalizes * (never re-prefixes) whatever `legacyDescribeContainerCliFailure` reports. */ -function withLegacyBundleSuggestion(slug: string) { +function withLegacyBundleSuggestion(slug: string, styleAqua?: (text: string) => string) { return (cause: unknown): Error => Object.assign(new Error(legacyDescribeContainerCliFailure(cause)), { - suggestion: suggestLegacyBundle(slug), + suggestion: suggestLegacyBundle(slug, styleAqua), }); } @@ -877,10 +890,10 @@ function withLegacyBundleSuggestion(slug: string) { * running, unlike `ensureDockerNetwork`/`ensureDockerNamedVolume`'s * self-describing errors. */ -function withDockerStepFailure(step: string, slug: string) { +function withDockerStepFailure(step: string, slug: string, styleAqua?: (text: string) => string) { return (cause: unknown): Error => Object.assign(new Error(`${step}: ${legacyDescribeContainerCliFailure(cause)}`), { - suggestion: suggestLegacyBundle(slug), + suggestion: suggestLegacyBundle(slug, styleAqua), }); } @@ -1024,6 +1037,7 @@ const downloadWithDockerUnbundle = Effect.fnUntraced(function* ( ) { const output = yield* Output; const styleEmphasis = dependencies.styleEmphasis ?? ((text: string) => text); + const styleAqua = dependencies.styleAqua ?? ((text: string) => text); // Go: `downloadOne` (`download.go:219`) — lowercase "function", distinct // from the server-side path's "Downloading Function:" (capital F, @@ -1108,10 +1122,10 @@ const downloadWithDockerUnbundle = Effect.fnUntraced(function* ( const extract = Effect.gen(function* () { yield* ensureDockerNetwork(networkMode, projectId).pipe( - Effect.mapError(withLegacyBundleSuggestion(slug)), + Effect.mapError(withLegacyBundleSuggestion(slug, styleAqua)), ); yield* ensureDockerNamedVolume(localDockerId("edge_runtime", projectId), projectId).pipe( - Effect.mapError(withLegacyBundleSuggestion(slug)), + Effect.mapError(withLegacyBundleSuggestion(slug, styleAqua)), ); // Bind order matches `extractOne` (`download.go:260-266`) exactly. Go's @@ -1128,6 +1142,22 @@ const downloadWithDockerUnbundle = Effect.fnUntraced(function* ( `${hostEszipPath}:${dockerEszipPath}:ro`, `${functionsDir}:${DOCKER_DENO_DIR}:rw`, ]; + // No `com.supabase.cli.project`/`com.docker.compose.project` labels on + // this container itself — Go's `DockerStart` (`internal/utils/docker.go:372-376`) + // sets both unconditionally on `config.Labels` for every container it + // starts via the Engine API, including this exact unbundle container + // (`DockerRunOnceWithConfig` → `DockerStart`, `download.go:268`), so + // label-based cleanup/inspection can't associate an orphaned one-shot + // container with the project if the CLI is interrupted mid-run. Pre-existing + // and cross-cutting, not introduced by this PR: `deploy.ts`'s own + // `bundleFunctionWithDocker` builds an equally raw `docker run` command + // (its own `command.push(image, "bundle", ...)`) with the identical gap — + // only `ensureDockerNetwork`/`ensureDockerNamedVolume` (`functions-docker.ts`) + // thread `dockerProjectLabels` today, for the network/volume they create, + // not for the one-shot containers either Docker path runs. Adding `--label` + // to every `functions` Docker `run` invocation belongs in a shared + // container-build helper both call sites use, not duplicated per call site + // here — left open (review round on CLI-1963's `functions download` port). const command = [ "run", "--rm", @@ -1140,12 +1170,26 @@ const downloadWithDockerUnbundle = Effect.fnUntraced(function* ( } command.push(image, "unbundle", "--eszip", dockerEszipPath, "--output", dockerOutputPath); + // Go pipes the container's stdout/stderr straight to `os.Stdout`/`getErrorLogger()` + // while the container runs (`DockerRunOnceWithConfig`, copied live via the + // log stream) — this awaits `runChildProcess`, which buffers the whole + // run via `collectByteStream`'s `Stream.runFold` and only writes below + // once the process exits, so live progress/error output is hidden and + // stdout/stderr ordering can't be preserved relative to each other while + // the container is still running. Pre-existing and cross-cutting, not + // introduced by this PR: `deploy.ts`'s `bundleFunctionWithDocker` (added + // in #5561, before `functions-docker.ts` existed as its own file) calls + // the exact same `runChildProcess` helper the exact same way for its own + // bundler container. A real fix needs a streaming variant of + // `runChildProcess` used by both `functions` Docker paths, not a + // one-off change here — left open (review round on CLI-1963's `functions + // download` port). const result = yield* runChildProcess("docker", command, { stdout: "pipe", stderr: "pipe", }).pipe( Effect.mapError( - withDockerStepFailure("failed to run the edge-runtime unbundle container", slug), + withDockerStepFailure("failed to run the edge-runtime unbundle container", slug, styleAqua), ), ); @@ -1174,7 +1218,8 @@ const downloadWithDockerUnbundle = Effect.fnUntraced(function* ( result.stderr .split(/\r?\n/) .some((line) => line.trim().toLowerCase() === "invalid eszip v2"); - const suggestion = (invalidEszipV2 ? suggestDenoV2() : "") + suggestLegacyBundle(slug); + const suggestion = + (invalidEszipV2 ? suggestDenoV2() : "") + suggestLegacyBundle(slug, styleAqua); return yield* Effect.fail( Object.assign(new Error(`error running container: exit ${result.exitCode}`), { suggestion, @@ -1361,6 +1406,19 @@ export function downloadFunctions Date: Mon, 10 Aug 2026 16:23:59 +0100 Subject: [PATCH 20/22] fix(functions): close deferred Docker-path parity gaps across deploy/download/serve (review: CLI-1963) - extract shared one-shot docker-run builder (binds/network/env/labels) used by deploy's bundler and download's unbundler; both containers now carry Go's com.supabase.cli.project/com.docker.compose.project labels - stream container stdout/stderr live via runChildProcess onStdout/onStderr tees instead of buffering until exit (Go DockerStreamLogs parity) - resolve edge-runtime images through the ECR->GHCR->Docker-Hub retry resolver (legacyMakeDockerImageResolver) in deploy, download, and serve - fix v-prefix double-tagging via shared edgeRuntimeImageTag helper - fold serve's own edge-runtime version-pin lookup into the shared resolveEdgeRuntimeVersionPin/resolveEdgeRuntimeVersion helpers - add loadFunctionsProjectConfig + legacyFunctionsGoConfigCompat: legacy-shell functions Docker paths now run the same dotenv/Config.Validate pipeline as start/stop/status (template defaults + env with no config.toml, project_id validation, project dotenv threaded into registry resolution) - honor SUPABASE_NETWORK_ID (ambient env + project dotenv) for network selection via resolveDockerNetworkMode, preserving viper's changed-flag precedence - style the 'Docker is not running' WARNING: prefix yellow via injected styleWarning hook in both shells' deploy/download - refresh the stale next/ functions section in go-cli-porting-status.md --- apps/cli/docs/go-cli-porting-status.md | 23 +- .../commands/functions/deploy/SIDE_EFFECTS.md | 49 +- .../functions/deploy/deploy.handler.ts | 9 +- .../deploy/deploy.integration.test.ts | 560 +++++++++++++++++- .../functions/download/SIDE_EFFECTS.md | 57 +- .../functions/download/download.handler.ts | 9 +- .../download/download.integration.test.ts | 501 +++++++++++++++- .../commands/functions/serve/SIDE_EFFECTS.md | 44 +- .../commands/functions/serve/serve.handler.ts | 2 + .../functions/serve/serve.integration.test.ts | 249 ++++++++ .../src/legacy/shared/legacy-docker-ids.ts | 11 +- .../shared/legacy-docker-image-resolve.ts | 10 +- .../shared/legacy-functions-go-config.ts | 48 ++ .../shared/legacy-local-config-values.ts | 36 +- .../shared/legacy-local-project-context.ts | 13 + .../functions/deploy/deploy.handler.ts | 2 +- .../deploy/deploy.integration.test.ts | 132 ++++- .../functions/download/download.handler.ts | 2 +- .../download/download.integration.test.ts | 116 ++++ apps/cli/src/shared/cli/cobra-flag-groups.ts | 27 +- apps/cli/src/shared/functions/deploy.ts | 234 +++++--- apps/cli/src/shared/functions/download.ts | 301 ++++------ .../src/shared/functions/functions-config.ts | 91 +++ .../src/shared/functions/functions-docker.ts | 142 ++++- .../functions/functions-docker.unit.test.ts | 199 +++++++ .../src/shared/functions/functions.shared.ts | 7 +- apps/cli/src/shared/functions/serve.ts | 125 +++- .../cli/src/shared/legacy/legacy-viper-env.ts | 13 + .../legacy/legacy-viper-env.unit.test.ts | 36 +- 29 files changed, 2620 insertions(+), 428 deletions(-) create mode 100644 apps/cli/src/legacy/shared/legacy-functions-go-config.ts create mode 100644 apps/cli/src/shared/functions/functions-config.ts create mode 100644 apps/cli/src/shared/functions/functions-docker.unit.test.ts diff --git a/apps/cli/docs/go-cli-porting-status.md b/apps/cli/docs/go-cli-porting-status.md index 017693f2de..ee994b0cbe 100644 --- a/apps/cli/docs/go-cli-porting-status.md +++ b/apps/cli/docs/go-cli-porting-status.md @@ -138,27 +138,20 @@ These commands exist in the TS CLI today but have no direct top-level equivalent The old Go `functions` family mixed linked-project operations (`list`, `deploy`, `download`, `delete`) with local-development workflows (`new`, `serve`). -**This section is stale beyond the scope of this pass and needs its own dedicated -audit:** `next/` now has a registered `functions` command tree +`next/` has a registered `functions` command tree ([`next/commands/functions/`](../src/next/commands/functions/functions.command.ts), wired in [`next/cli/root.ts`](../src/next/cli/root.ts)) with `list`, `delete`, -`deploy`, `download`, `new`, and `dev` subcommands — the "still no dedicated -`functions` CLI surface in `next/`" premise below and the blanket `missing` status -on every row predate that and are not accurate as written. Fixing this properly -needs a flag-by-flag comparison against the old Go CLI per subcommand (this pass -only confirmed the command paths exist, not their flag-parity level), so the rows -below are marked `partial` rather than `ported`: the whole `next/` root already -diverges from Go's global flag surface (see -[Global Flags Overview](#global-flags-overview)), so none of these can be called -materially aligned yet, but `missing` would wrongly claim no TS surface exists at -all now that the command paths resolve. Treat `partial` here as "exists, -leaf-flag parity unaudited," not as a confirmed parity gap. +`deploy`, `download`, `new`, and `dev` subcommands, so these rows are `partial`, +not `missing`. `partial` here means "TS command surface exists, leaf-flag parity +against the Go CLI not yet audited" — the whole `next/` root already diverges +from Go's global flag surface (see [Global Flags Overview](#global-flags-overview)), +so none of these can be called materially aligned yet. | Old command | TS status | New TS counterpart(s) | Notes | | -------------------- | --------- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `functions delete` | `partial` | [`../src/next/commands/functions/delete/`](../src/next/commands/functions/delete/delete.command.ts) | Command surface exists in `next/`; flag-parity against Go not yet audited (see section note above). Natively ported in the legacy shell. | | `functions deploy` | `partial` | [`../src/next/commands/functions/deploy/`](../src/next/commands/functions/deploy/deploy.command.ts) | Command surface exists in `next/`; flag-parity against Go not yet audited (see section note above). Natively ported in the legacy shell. | -| `functions download` | `partial` | [`../src/next/commands/functions/download/`](../src/next/commands/functions/download/download.command.ts) | Command surface exists in `next/`; flag-parity against Go not yet audited (see section note above). Native for `--use-api` and the default Docker-unbundle path (`--use-docker`, CLI-1963) in both shells; hidden `--legacy-bundle` still delegates to Go — see [Legacy Shell Command Status](#legacy-shell-command-status) below. | +| `functions download` | `partial` | [`../src/next/commands/functions/download/`](../src/next/commands/functions/download/download.command.ts) | Command surface exists in `next/`; flag-parity against Go not yet audited (see section note above). Native for `--use-api` and the default Docker-unbundle path (`--use-docker`, CLI-1963) in both shells; hidden `--legacy-bundle` still delegates to Go — see [Legacy Shell Command Status](#legacy-shell-command-status) below. | | `functions list` | `partial` | [`../src/next/commands/functions/list/`](../src/next/commands/functions/list/list.command.ts) | Command surface exists in `next/`; flag-parity against Go not yet audited (see section note above). Natively ported in the legacy shell. | | `functions new` | `partial` | [`../src/next/commands/functions/new/`](../src/next/commands/functions/new/new.command.ts) | Command surface exists in `next/`; flag-parity against Go not yet audited (see section note above). Natively ported in the legacy shell. | | `functions serve` | `partial` | [`../src/next/commands/functions/dev/`](../src/next/commands/functions/dev/dev.command.ts) | `next/`'s `functions dev` is a TS-native local Functions workflow (`--stack`, `--env-file`, `--no-verify-jwt`) rather than a flag-parity port of Go's `serve` — kept `partial` here pending a decision on whether it counts as this row's counterpart or belongs in [TS-only Commands](#ts-only-commands) instead. Natively ported in the legacy shell. | @@ -308,7 +301,7 @@ Legend: | `gen keys` | `wrapped` | [`../src/legacy/commands/gen/keys/keys.command.ts`](../src/legacy/commands/gen/keys/keys.command.ts) | | `functions list` | `ported` | [`../src/legacy/commands/functions/list/list.command.ts`](../src/legacy/commands/functions/list/list.command.ts) | | `functions delete` | `ported` | [`../src/legacy/commands/functions/delete/delete.command.ts`](../src/legacy/commands/functions/delete/delete.command.ts) | -| `functions download` | `ported` | [`../src/legacy/commands/functions/download/download.command.ts`](../src/legacy/commands/functions/download/download.command.ts) — native for `--use-api` (lists, downloads, and extracts via the Management API directly) and the default Docker-unbundle path (`--use-docker`, CLI-1963); hidden `--legacy-bundle` still delegates to the Go binary (pre-1.120.0 fallback requiring a host Deno-binary install with no precedent elsewhere in this codebase — tracked separately, see CLI-1963) | +| `functions download` | `ported` | [`../src/legacy/commands/functions/download/download.command.ts`](../src/legacy/commands/functions/download/download.command.ts) — native for `--use-api` (lists, downloads, and extracts via the Management API directly) and the default Docker-unbundle path (`--use-docker`, CLI-1963); hidden `--legacy-bundle` still delegates to the Go binary (pre-1.120.0 fallback requiring a host Deno-binary install with no precedent elsewhere in this codebase — tracked separately, see CLI-1963) | | `functions deploy` | `ported` | [`../src/legacy/commands/functions/deploy/deploy.command.ts`](../src/legacy/commands/functions/deploy/deploy.command.ts) | | `functions new` | `ported` | [`../src/legacy/commands/functions/new/new.command.ts`](../src/legacy/commands/functions/new/new.command.ts) | | `functions serve` | `ported` | [`../src/legacy/commands/functions/serve/serve.command.ts`](../src/legacy/commands/functions/serve/serve.command.ts) | diff --git a/apps/cli/src/legacy/commands/functions/deploy/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/functions/deploy/SIDE_EFFECTS.md index 88402efeed..b6a2886015 100644 --- a/apps/cli/src/legacy/commands/functions/deploy/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/functions/deploy/SIDE_EFFECTS.md @@ -2,16 +2,18 @@ ## Files Read -| Path | Format | When | -| ---------------------------------------------- | ---------- | ----------------------------------------------------------- | -| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/supabase/config.toml` | TOML | to resolve function config, project id, and local Functions | -| `/supabase/functions//index.ts` | TypeScript | function source to deploy | -| `/supabase/functions/**/deno.json*` | JSON/JSONC | when resolving import maps | -| imported modules | TypeScript | when walking local import graphs for deploy uploads/bundles | -| configured static files | any | when `static_files` patterns match local files | -| `package.json` next to function entrypoint | JSON | Docker bundling package discovery | -| `/supabase/functions/import_map.json` | JSON | deprecated fallback import map discovery | +| Path | Format | When | +| -------------------------------------------------------------------------------------------------------------------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/{.env,.env.local,.env.,.env..local}` and the same four at the project root | dotenv | Go-parity project dotenv (`legacyResolveProjectEnvironmentValues`), merged into the `SUPABASE_*` overrides below and threaded into registry resolution | +| `/supabase/config.toml` | TOML | to resolve function config, project id, and local Functions — via `goConfigCompat`'s `tomlOnly: true`/`search: false` (same resolver `start`/`stop`/`status` use), so `config.json` is never read here and no ancestor directory is searched past ``; also runs the full `Config.Validate` pipeline (`legacyResolveLocalConfigValues`), so an invalid config fails up front even for fields this command never otherwise reads | +| `/supabase/`, `[api.tls]` cert/key paths, email template `content_path` — when configured | varies | as part of the `Config.Validate` pipeline above, unconditionally, matching Go's `Config.Load` | +| `/supabase/functions//index.ts` | TypeScript | function source to deploy | +| `/supabase/functions/**/deno.json*` | JSON/JSONC | when resolving import maps | +| imported modules | TypeScript | when walking local import graphs for deploy uploads/bundles | +| configured static files | any | when `static_files` patterns match local files | +| `package.json` next to function entrypoint | JSON | Docker bundling package discovery | +| `/supabase/functions/import_map.json` | JSON | deprecated fallback import map discovery | ## Files Written @@ -22,10 +24,12 @@ ## Subprocesses -| Command | When | -| ------------- | ------------------------------------------------------------------- | -| `docker info` | to detect whether explicitly selected local Docker bundling can run | -| `docker run` | when Docker bundling is selected/available | +| Command | When | +| ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| `docker info` | to detect whether explicitly selected local Docker bundling can run | +| `docker image inspect ` (ECR, then GHCR, then Docker Hub) | Docker bundling: check whether the edge-runtime image is already cached locally, tried in registry order, before the network/volume ensure | +| `docker pull ` | Docker bundling, cache miss on a candidate: pull with 2 retries (4s/8s backoff) before falling through to the next registry candidate | +| `docker run --rm ... --label com.supabase.cli.project= --label com.docker.compose.project= ...` | when Docker bundling is selected/available; labeled so orphaned containers can be associated with the project (Go: `DockerStart`) | Docker bundling may pull or run the configured edge-runtime image and uses the `supabase_edge_runtime_` Deno cache volume. @@ -43,13 +47,16 @@ Docker bundling may pull or run the configured edge-runtime image and uses the ## Environment Variables -| Variable | Purpose | Required? | -| ---------------------------------- | --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_PROJECT_ID` | optional project ref fallback | no | -| `SUPABASE_INTERNAL_IMAGE_REGISTRY` | selects the Functions bundler image registry | no | -| `NPM_CONFIG_REGISTRY` | forwarded into Docker bundling when set (the only npm variable forwarded, matching Go; `NPM_AUTH_TOKEN` is not) | no | -| `DEBUG` | enables verbose Docker bundle output when `true` | no | +| Variable | Purpose | Required? | +| ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROJECT_ID` | optional project ref fallback; also read from project dotenv now (previously ambient-shell-only) | no | +| `SUPABASE_ENV` | selects environment-specific dotenv files (`.env..local`, `.env.`) | no (defaults to `development`) | +| `SUPABASE_INTERNAL_IMAGE_REGISTRY` | selects the Functions bundler image registry; read from the ambient shell **or** project dotenv; unset resolves ECR->GHCR->Docker-Hub candidates in order instead of a single URL | no | +| `SUPABASE_NETWORK_ID` | overrides the generated `supabase_network_` Docker network name when `--network-id` isn't passed; read from the ambient shell or project dotenv | no | +| `SUPABASE_EDGE_RUNTIME_DENO_VERSION` | overrides `edge_runtime.deno_version` (which bundler image tag to use) when set, from the ambient shell or project dotenv — takes effect even with no `config.toml` on disk | no | +| `NPM_CONFIG_REGISTRY` | forwarded into Docker bundling when set (the only npm variable forwarded, matching Go; `NPM_AUTH_TOKEN` is not) | no | +| `DEBUG` | enables verbose Docker bundle output when `true` | no | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/functions/deploy/deploy.handler.ts b/apps/cli/src/legacy/commands/functions/deploy/deploy.handler.ts index 59bec4fd78..7ff6481ad3 100644 --- a/apps/cli/src/legacy/commands/functions/deploy/deploy.handler.ts +++ b/apps/cli/src/legacy/commands/functions/deploy/deploy.handler.ts @@ -2,7 +2,8 @@ import { join } from "node:path"; import { Effect, Option, Stdio } from "effect"; import { deployFunctions } from "../../../../shared/functions/deploy.ts"; import { resolveEdgeRuntimeVersionPin } from "../../../../shared/functions/functions.shared.ts"; -import { legacyAqua, legacyBold } from "../../../shared/legacy-colors.ts"; +import { legacyAqua, legacyBold, legacyYellow } from "../../../shared/legacy-colors.ts"; +import { legacyFunctionsGoConfigCompat } from "../../../shared/legacy-functions-go-config.ts"; import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; @@ -40,7 +41,7 @@ export const legacyFunctionsDeploy = Effect.fn("legacy.functions.deploy")(functi projectRoot: cliConfig.workdir, supabaseDir: join(cliConfig.workdir, "supabase"), dashboardUrl: legacyDashboardUrl(cliConfig.profile), - goViperCompat: true, + goConfigCompat: legacyFunctionsGoConfigCompat, yes, rawArgs, edgeRuntimeVersion, @@ -60,6 +61,10 @@ export const legacyFunctionsDeploy = Effect.fn("legacy.functions.deploy")(functi // and the no-functions error dir (`deploy.go:35`, rendered on stderr) — // both stderr-bound, matching `legacyBold`'s default TTY gate. styleEmphasis: (text) => legacyBold(text), + // Go: `utils.Yellow` on the `WARNING:` token before "Docker is not + // running" (`deploy.go:60`, stderr) — matches `legacyYellow`'s default + // TTY gate. + styleWarning: (text) => legacyYellow(text), }).pipe( Effect.ensuring( Effect.suspend(() => diff --git a/apps/cli/src/legacy/commands/functions/deploy/deploy.integration.test.ts b/apps/cli/src/legacy/commands/functions/deploy/deploy.integration.test.ts index 67725800fc..1b46629d76 100644 --- a/apps/cli/src/legacy/commands/functions/deploy/deploy.integration.test.ts +++ b/apps/cli/src/legacy/commands/functions/deploy/deploy.integration.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; +import { mkdirSync, writeFileSync } from "node:fs"; import { mkdir, rm, writeFile } from "node:fs/promises"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { Effect, Exit, Layer, Option, Stdio } from "effect"; import { LegacyYesFlag } from "../../../../shared/legacy/global-flags.ts"; @@ -16,8 +17,10 @@ import { import { mockOutput, mockRuntimeInfo } from "../../../../../tests/helpers/mocks.ts"; import { mockChildProcessSpawner } from "../../../../../../../packages/process-compose/tests/helpers/mocks.ts"; import { deployFunctions } from "../../../../shared/functions/deploy.ts"; +import { legacyFunctionsGoConfigCompat } from "../../../shared/legacy-functions-go-config.ts"; import { ConflictingFunctionDeployFlagsError, + InvalidFunctionDeploySlugError, NoFunctionsToDeployError, } from "../../../../shared/functions/deploy.errors.ts"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; @@ -60,6 +63,37 @@ async function writeLocalFunction( // eslint-disable-next-line no-control-regex const stripSgr = (text: string) => text.replace(/\x1b\[[0-9;]*m/gu, ""); +function resolveDockerOutputPath(args: ReadonlyArray): string { + const outputIndex = args.indexOf("--output"); + if (outputIndex < 0 || args[outputIndex + 1] === undefined) { + throw new Error("missing docker bundle output flag"); + } + return args[outputIndex + 1]!; +} + +/** + * Every `docker image inspect` call is a cache hit (exit 0) — no real pull, + * no real registry candidate fallback (that path has its own coverage in + * `functions/download`'s integration tests) — and every `docker run` + * synthesizes the eszip the bundler container would otherwise have produced, + * so `bundleFunctionWithDocker` can read it back and complete the deploy. + */ +function mockDockerBundleSpawner() { + const spawnerOpts: { + exitCode?: number; + onSpawn?: (record: { command: string; args: ReadonlyArray }) => void; + } = { exitCode: 0 }; + spawnerOpts.onSpawn = (record) => { + if (record.command !== "docker" || record.args[0] !== "run") { + return; + } + const outputPath = resolveDockerOutputPath(record.args); + mkdirSync(dirname(outputPath), { recursive: true }); + writeFileSync(outputPath, "eszip-test-output"); + }; + return mockChildProcessSpawner(spawnerOpts); +} + describe("legacy functions deploy", () => { it.live("deploys a function natively through the Management API", () => { const out = mockOutput({ format: "text" }); @@ -1154,7 +1188,7 @@ describe("legacy functions deploy", () => { projectRoot: tempRoot.current, supabaseDir: join(tempRoot.current, "supabase"), dashboardUrl: "https://supabase.com/dashboard", - goViperCompat: true, + goConfigCompat: legacyFunctionsGoConfigCompat, yes: false, rawArgs: ["functions", "deploy"], edgeRuntimeVersion: "1.69.12", @@ -1207,4 +1241,526 @@ describe("legacy functions deploy", () => { ); }); }); + + describe("Config.Validate parity (CLI-1963)", () => { + it.live( + "fails before any Docker/API work when config.toml has an explicit empty project_id", + () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi(); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), + }), + Layer.succeed(LegacyYesFlag, false), + Stdio.layerTest({ + args: Effect.succeed(["functions", "deploy", "hello-world"]), + }), + ); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => writeProjectConfig(tempRoot.current, 'project_id = ""\n')); + yield* Effect.tryPromise(() => writeLocalFunction(tempRoot.current, "hello-world")); + + const error = yield* legacyFunctionsDeploy(baseFlags).pipe(Effect.flip); + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe("Missing required field in config: project_id"); + expect(api.requests).toEqual([]); + }).pipe( + Effect.provide(layer), + Effect.ensuring( + Effect.tryPromise(() => rm(tempRoot.current, { recursive: true, force: true })), + ), + ); + }, + ); + + it.live( + "fails before any Docker/API work on an unrelated Config.Validate branch (unsupported Postgres major version)", + () => { + // Proves the WHOLE resolved config is validated, not just `project_id` + // — `db.major_version = 12` is a genuinely unrelated Go `Config.Validate` + // branch (`config.go:1034-1062`). + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi(); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), + }), + Layer.succeed(LegacyYesFlag, false), + Stdio.layerTest({ + args: Effect.succeed(["functions", "deploy", "hello-world"]), + }), + ); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeProjectConfig( + tempRoot.current, + ['project_id = "test-project"', "", "[db]", "major_version = 12", ""].join("\n"), + ), + ); + yield* Effect.tryPromise(() => writeLocalFunction(tempRoot.current, "hello-world")); + + const error = yield* legacyFunctionsDeploy(baseFlags).pipe(Effect.flip); + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe( + "Postgres version 12.x is unsupported. To use the CLI, either start a new project or follow project migration steps here: https://supabase.com/docs/guides/database#migrating-between-projects.", + ); + expect(api.requests).toEqual([]); + }).pipe( + Effect.provide(layer), + Effect.ensuring( + Effect.tryPromise(() => rm(tempRoot.current, { recursive: true, force: true })), + ), + ); + }, + ); + + it.live( + "reports a Config.Validate failure before an invalid slug's format error, matching Go's flags.LoadConfig-before-slug-validation order (deploy.go:22-28)", + () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi(); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), + }), + Layer.succeed(LegacyYesFlag, false), + Stdio.layerTest({ + args: Effect.succeed(["functions", "deploy", "1-invalid-slug"]), + }), + ); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => writeProjectConfig(tempRoot.current, 'project_id = ""\n')); + + const error = yield* legacyFunctionsDeploy({ + ...baseFlags, + functionNames: ["1-invalid-slug"], + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe("Missing required field in config: project_id"); + expect(api.requests).toEqual([]); + }).pipe( + Effect.provide(layer), + Effect.ensuring( + Effect.tryPromise(() => rm(tempRoot.current, { recursive: true, force: true })), + ), + ); + }, + ); + + it.live("still rejects an invalid slug once the config itself is valid", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi(); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), + }), + Layer.succeed(LegacyYesFlag, false), + Stdio.layerTest({ + args: Effect.succeed(["functions", "deploy", "1-invalid-slug"]), + }), + ); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => writeProjectConfig(tempRoot.current)); + + const error = yield* legacyFunctionsDeploy({ + ...baseFlags, + functionNames: ["1-invalid-slug"], + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(InvalidFunctionDeploySlugError); + expect(api.requests).toEqual([]); + }).pipe( + Effect.provide(layer), + Effect.ensuring( + Effect.tryPromise(() => rm(tempRoot.current, { recursive: true, force: true })), + ), + ); + }); + }); + + describe("Docker bundling path Go-parity config/env wiring (CLI-1963)", () => { + function mockFunctionCreateApi() { + return mockLegacyPlatformApi({ + handler: (request) => { + if (request.method === "GET") { + return Effect.succeed(legacyJsonResponse(request, 200, [])); + } + return Effect.succeed( + legacyJsonResponse(request, 201, { + id: "function-id", + slug: "hello-world", + name: "hello-world", + status: "ACTIVE", + version: 1, + created_at: 1_687_423_025_152, + updated_at: 1_687_423_025_152, + verify_jwt: true, + import_map: false, + entrypoint_path: "functions/hello-world/index.ts", + }), + ); + }, + }); + } + + it.live( + "resolves the deno v1 edge-runtime image tag when SUPABASE_EDGE_RUNTIME_DENO_VERSION=1 overrides an unset config value", + () => { + const out = mockOutput({ format: "text" }); + const api = mockFunctionCreateApi(); + const child = mockDockerBundleSpawner(); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), + }), + Layer.succeed(LegacyYesFlag, false), + child.layer, + Stdio.layerTest({ + args: Effect.succeed(["functions", "deploy", "hello-world", "--use-api=false"]), + }), + ); + + const previous = process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"]; + process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = "1"; + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => writeProjectConfig(tempRoot.current)); + yield* Effect.tryPromise(() => writeLocalFunction(tempRoot.current, "hello-world")); + + yield* legacyFunctionsDeploy({ ...baseFlags, useApi: false, useDocker: true }); + + // `docker info` is spawned[0]; the bundler's first image-inspect + // candidate (a cache hit here) is spawned[1]. + expect(child.spawned[1]).toEqual({ + command: "docker", + args: ["image", "inspect", "public.ecr.aws/supabase/edge-runtime:v1.68.4"], + }); + }).pipe( + Effect.provide(layer), + Effect.ensuring( + Effect.tryPromise(() => rm(tempRoot.current, { recursive: true, force: true })), + ), + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) { + delete process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"]; + } else { + process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = previous; + } + }), + ), + ); + }, + ); + + it.live( + "uses SUPABASE_NETWORK_ID as the bundler's docker network when no --network-id flag is passed", + () => { + const out = mockOutput({ format: "text" }); + const api = mockFunctionCreateApi(); + const child = mockDockerBundleSpawner(); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), + }), + Layer.succeed(LegacyYesFlag, false), + child.layer, + Stdio.layerTest({ + args: Effect.succeed(["functions", "deploy", "hello-world", "--use-api=false"]), + }), + ); + + const previous = process.env["SUPABASE_NETWORK_ID"]; + process.env["SUPABASE_NETWORK_ID"] = "env-network"; + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => writeProjectConfig(tempRoot.current)); + yield* Effect.tryPromise(() => writeLocalFunction(tempRoot.current, "hello-world")); + + yield* legacyFunctionsDeploy({ ...baseFlags, useApi: false, useDocker: true }); + + expect(child.spawned[2]).toEqual({ + command: "docker", + args: ["network", "inspect", "env-network"], + }); + const runCommand = child.spawned.find((spawned) => spawned.args[0] === "run"); + expect(runCommand?.args).toContain("env-network"); + }).pipe( + Effect.provide(layer), + Effect.ensuring( + Effect.tryPromise(() => rm(tempRoot.current, { recursive: true, force: true })), + ), + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) { + delete process.env["SUPABASE_NETWORK_ID"]; + } else { + process.env["SUPABASE_NETWORK_ID"] = previous; + } + }), + ), + ); + }, + ); + + it.live( + "prefers an explicit --network-id flag over SUPABASE_NETWORK_ID for the bundler container", + () => { + const out = mockOutput({ format: "text" }); + const api = mockFunctionCreateApi(); + const child = mockDockerBundleSpawner(); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), + }), + Layer.succeed(LegacyYesFlag, false), + child.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "deploy", + "hello-world", + "--use-api=false", + "--network-id", + "flag-network", + ]), + }), + ); + + const previous = process.env["SUPABASE_NETWORK_ID"]; + process.env["SUPABASE_NETWORK_ID"] = "env-network"; + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => writeProjectConfig(tempRoot.current)); + yield* Effect.tryPromise(() => writeLocalFunction(tempRoot.current, "hello-world")); + + yield* legacyFunctionsDeploy({ ...baseFlags, useApi: false, useDocker: true }); + + expect(child.spawned[2]).toEqual({ + command: "docker", + args: ["network", "inspect", "flag-network"], + }); + const runCommand = child.spawned.find((spawned) => spawned.args[0] === "run"); + expect(runCommand?.args).toContain("flag-network"); + expect(runCommand?.args).not.toContain("env-network"); + }).pipe( + Effect.provide(layer), + Effect.ensuring( + Effect.tryPromise(() => rm(tempRoot.current, { recursive: true, force: true })), + ), + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) { + delete process.env["SUPABASE_NETWORK_ID"]; + } else { + process.env["SUPABASE_NETWORK_ID"] = previous; + } + }), + ), + ); + }, + ); + + it.live( + "labels the bundler container with the resolved project id (Go parity: docker.go:349-386)", + () => { + const out = mockOutput({ format: "text" }); + const api = mockFunctionCreateApi(); + const child = mockDockerBundleSpawner(); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), + }), + Layer.succeed(LegacyYesFlag, false), + child.layer, + Stdio.layerTest({ + args: Effect.succeed(["functions", "deploy", "hello-world", "--use-api=false"]), + }), + ); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeProjectConfig(tempRoot.current, 'project_id = "test-project"\n'), + ); + yield* Effect.tryPromise(() => writeLocalFunction(tempRoot.current, "hello-world")); + + yield* legacyFunctionsDeploy({ ...baseFlags, useApi: false, useDocker: true }); + + const runCommand = child.spawned.find((spawned) => spawned.args[0] === "run"); + expect(runCommand?.args).toEqual( + expect.arrayContaining([ + "--label", + "com.supabase.cli.project=test-project", + "--label", + "com.docker.compose.project=test-project", + ]), + ); + }).pipe( + Effect.provide(layer), + Effect.ensuring( + Effect.tryPromise(() => rm(tempRoot.current, { recursive: true, force: true })), + ), + ); + }, + ); + + it.live( + "does not climb to an ancestor project's config.toml for the Docker bundling path", + () => { + // Go's `flags.LoadConfig` only ever resolves `supabase/config.toml` + // from the already-resolved workdir, with no ancestor climb + // (`NewPathBuilder`, `pkg/config/utils.go:43-48`) — mirrored by + // `loadFunctionsProjectConfig`'s `search: false` (a real behavior + // change: deploy did NOT have this before CLI-1963, unlike download). + const nestedWorkdir = join(tempRoot.current, "nested"); + const out = mockOutput({ format: "text" }); + const api = mockFunctionCreateApi(); + const child = mockDockerBundleSpawner(); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: nestedWorkdir }), + runtimeInfo: mockRuntimeInfo({ cwd: nestedWorkdir }), + }), + Layer.succeed(LegacyYesFlag, false), + child.layer, + Stdio.layerTest({ + args: Effect.succeed(["functions", "deploy", "hello-world", "--use-api=false"]), + }), + ); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeProjectConfig(tempRoot.current, 'project_id = "ancestor-project"\n'), + ); + yield* Effect.tryPromise(() => writeLocalFunction(nestedWorkdir, "hello-world")); + + yield* legacyFunctionsDeploy({ ...baseFlags, useApi: false, useDocker: true }); + + expect(child.spawned[2]).toEqual({ + command: "docker", + args: ["network", "inspect", "supabase_network_abcdefghijklmnopqrst"], + }); + const runCommand = child.spawned.find((spawned) => spawned.args[0] === "run"); + expect(runCommand?.args).toContain("supabase_network_abcdefghijklmnopqrst"); + expect(runCommand?.args).not.toContain("supabase_network_ancestor-project"); + }).pipe( + Effect.provide(layer), + Effect.ensuring( + Effect.tryPromise(() => rm(tempRoot.current, { recursive: true, force: true })), + ), + ); + }, + ); + }); + + describe("docker-not-running warning styling (Go parity: deploy.go:60; only WARNING: is styled)", () => { + it.live("wraps only the WARNING token, not the rest of the fallback line", () => { + // Calls the shared `deployFunctions` with a marker `styleWarning` instead + // of going through `legacyFunctionsDeploy`: the real hook (`legacyYellow`) + // is TTY-gated and therefore inert under vitest, so only an injected + // marker can deterministically observe styling scope — same pattern as + // the "no-functions error styling" block above. + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ + handler: (request) => { + if (request.method === "GET") { + return Effect.succeed(legacyJsonResponse(request, 200, [])); + } + return Effect.succeed( + legacyJsonResponse(request, 201, { + id: "function-id", + slug: "hello-world", + name: "hello-world", + status: "ACTIVE", + version: 1, + created_at: 1_687_423_025_152, + updated_at: 1_687_423_025_152, + verify_jwt: true, + import_map: false, + entrypoint_path: "functions/hello-world/index.ts", + }), + ); + }, + }); + const child = mockChildProcessSpawner({ exitCode: 1 }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), + }), + Layer.succeed(LegacyYesFlag, false), + child.layer, + Stdio.layerTest({ + args: Effect.succeed(["functions", "deploy", "hello-world", "--use-api=false"]), + }), + ); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => writeProjectConfig(tempRoot.current)); + yield* Effect.tryPromise(() => writeLocalFunction(tempRoot.current, "hello-world")); + + const platformApi = yield* LegacyPlatformApi; + yield* deployFunctions( + { ...baseFlags, useApi: false, useDocker: true }, + { + api: platformApi, + cwd: tempRoot.current, + flagCwd: tempRoot.current, + projectRoot: tempRoot.current, + supabaseDir: join(tempRoot.current, "supabase"), + dashboardUrl: "https://supabase.com/dashboard", + goConfigCompat: legacyFunctionsGoConfigCompat, + yes: false, + rawArgs: ["functions", "deploy", "hello-world", "--use-api=false"], + edgeRuntimeVersion: "1.69.12", + resolveProjectRef: () => Effect.succeed("abcdefghijklmnopqrst"), + styleWarning: (text) => `${text}`, + }, + ); + + expect(out.stderrText).toContain("WARNING: Docker is not running\n"); + }).pipe( + Effect.provide(layer), + Effect.ensuring( + Effect.tryPromise(() => rm(tempRoot.current, { recursive: true, force: true })), + ), + ); + }); + }); }); diff --git a/apps/cli/src/legacy/commands/functions/download/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/functions/download/SIDE_EFFECTS.md index 11f217cb53..84465d7676 100644 --- a/apps/cli/src/legacy/commands/functions/download/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/functions/download/SIDE_EFFECTS.md @@ -2,15 +2,17 @@ ## Files Read -| Path | Format | When | -| ----------------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/profile` | plain text | when `--profile` and `SUPABASE_PROFILE` are both unset | -| `.yaml` | YAML | when `SUPABASE_PROFILE` or `--profile` points to a file | -| `/supabase/.temp/project-ref` | plain text | when `--project-ref` and `SUPABASE_PROJECT_ID` are both unset | -| `/supabase/.temp/edge-runtime-version` | plain text | Read unconditionally by `resolveEdgeRuntimeVersionPin()` in the handler, before the shared downloader chooses `--use-api` vs Docker — only affects the resolved edge-runtime image tag on the Docker-unbundle path | -| `/supabase/config.toml` | TOML | Read unconditionally after resolving the project ref, before checking `--use-api`/`--use-docker` or whether Docker is running — resolves `edge_runtime.deno_version` and `project_id` (`loadProjectConfig`) for the Docker-unbundle path. `goViperCompat`'s `tomlOnly: true` means `config.json` is never read here, unlike other `loadProjectConfig` callers. Matches Go's `flags.LoadConfig` running unconditionally at the top of `Run` (`download.go:131-138`): a malformed config now fails here even on the `--use-api` invocation. | -| `/telemetry.json` | JSON | when present, before post-run telemetry state is refreshed | +| Path | Format | When | +| -------------------------------------------------------------------------------------------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/profile` | plain text | when `--profile` and `SUPABASE_PROFILE` are both unset | +| `.yaml` | YAML | when `SUPABASE_PROFILE` or `--profile` points to a file | +| `/supabase/.temp/project-ref` | plain text | when `--project-ref` and `SUPABASE_PROJECT_ID` are both unset | +| `/supabase/.temp/edge-runtime-version` | plain text | Read unconditionally by `resolveEdgeRuntimeVersionPin()` in the handler, before the shared downloader chooses `--use-api` vs Docker — only affects the resolved edge-runtime image tag on the Docker-unbundle path | +| `/supabase/{.env,.env.local,.env.,.env..local}` and the same four at the project root | dotenv | Docker-unbundle path only, before resolving config.toml — Go-parity project dotenv (`legacyResolveProjectEnvironmentValues`), merged into the `SUPABASE_*` overrides below and threaded into registry resolution | +| `/supabase/config.toml` | TOML | Read unconditionally after resolving the project ref, before checking `--use-api`/`--use-docker` or whether Docker is running — resolves `edge_runtime.deno_version` and `project_id` (`loadProjectConfig`) for the Docker-unbundle path. `goViperCompat`'s `tomlOnly: true` means `config.json` is never read here, unlike other `loadProjectConfig` callers. Matches Go's `flags.LoadConfig` running unconditionally at the top of `Run` (`download.go:131-138`): a malformed config now fails here even on the `--use-api` invocation. Also runs the full `Config.Validate` pipeline (`legacyResolveLocalConfigValues`, same one `start`/`stop`/`status` already use) — an invalid config (bad `db.major_version`, malformed auth hook, etc.) now fails the Docker-unbundle path up front, even for fields this command never otherwise reads, matching Go's `flags.LoadConfig` -> `Config.Validate`. | +| `/supabase/`, `[api.tls]` cert/key paths, email template `content_path` — when configured | varies | Docker-unbundle path only, as part of the `Config.Validate` pipeline above — read even though this command never uses their contents, matching Go's `Config.Load` doing the same I/O unconditionally | +| `/telemetry.json` | JSON | when present, before post-run telemetry state is refreshed | ## Files Written @@ -33,12 +35,14 @@ ## Subprocesses -| Command | When | Purpose | -| ---------------------------------------------------------------------------- | -------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | -| `docker info` | `--use-docker` (default), unless `--use-api` | check whether Docker is running before choosing the Docker-unbundle downloader | -| `docker network inspect` / `network create` / `volume create` | Docker-unbundle path, when Docker is running | ensure the shared per-project network/named volume exist (same primitives as `functions deploy`'s Docker bundler) | -| `docker run --rm ... unbundle --eszip ... --output ...` | Docker-unbundle path, when Docker is running | extract the downloaded eszip into `supabase/functions//...` | -| `supabase-go functions download ... --legacy-bundle` | `--legacy-bundle` only | preserve the hidden, deprecated pre-1.120.0 bundling fallback (native TS port tracked separately, CLI-1963) | +| Command | When | Purpose | +| ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `docker info` | `--use-docker` (default), unless `--use-api` | check whether Docker is running before choosing the Docker-unbundle downloader | +| `docker image inspect ` (ECR, then GHCR, then Docker Hub) | Docker-unbundle path, when Docker is running | check whether the edge-runtime image is already cached locally, tried in registry order, before the network/volume ensure | +| `docker pull ` | Docker-unbundle path, cache miss on a candidate | pull with 2 retries (4s/8s backoff) before falling through to the next registry candidate | +| `docker network inspect` / `network create` / `volume create` | Docker-unbundle path, when Docker is running | ensure the shared per-project network/named volume exist (same primitives as `functions deploy`'s Docker bundler) | +| `docker run --rm ... --label com.supabase.cli.project= --label com.docker.compose.project= unbundle --eszip ... --output ...` | Docker-unbundle path, when Docker is running | extract the downloaded eszip into `supabase/functions//...`; labeled so orphaned containers can be associated with the project (Go: `DockerStart`) | +| `supabase-go functions download ... --legacy-bundle` | `--legacy-bundle` only | preserve the hidden, deprecated pre-1.120.0 bundling fallback (native TS port tracked separately, CLI-1963) | The `--legacy-bundle` delegated call runs with `SUPABASE_TELEMETRY_DISABLED=1` so the Go child's own `cli_command_executed` doesn't double-count on top of @@ -52,16 +56,19 @@ to stderr in machine-output modes (CLI-1546). ## Environment Variables -| Variable | Purpose | Required? | -| ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `/access-token`) | -| `SUPABASE_HOME` | overrides where `telemetry.json` and `profile` are read/written | no (defaults to `~/.supabase`) | -| `SUPABASE_NO_KEYRING` | disables the OS keyring, forcing the access-token file fallback | no | -| `SUPABASE_PROFILE` | select a built-in profile or YAML profile file with `api_url:` | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_PROJECT_ID` | provides the project ref when `--project-ref` is unset | no (falls back to `/supabase/.temp/project-ref`) | -| `SUPABASE_WORKDIR` | sets `` for local Supabase temp files | no (falls back to `--workdir` -> nearest ancestor with `supabase/config.toml` -> cwd) | -| `BITBUCKET_CLONE_DIR` | Docker-unbundle path: when set, skips creating the named Deno-cache volume and omits its bind mount from the `docker run` command (Bitbucket's restricted Docker environment rejects both) | no | -| `SUPABASE_INTERNAL_IMAGE_REGISTRY` | selects the registry the edge-runtime unbundle image is pulled from (`legacyGetRegistryImageUrl`); read unconditionally while resolving the image, before the `--use-api`/Docker choice is finalized — also consumed on the `--use-api` invocation even though it never pulls an image | no (defaults to `public.ecr.aws`) | +| Variable | Purpose | Required? | +| ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `/access-token`) | +| `SUPABASE_HOME` | overrides where `telemetry.json` and `profile` are read/written | no (defaults to `~/.supabase`) | +| `SUPABASE_NO_KEYRING` | disables the OS keyring, forcing the access-token file fallback | no | +| `SUPABASE_PROFILE` | select a built-in profile or YAML profile file with `api_url:` | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | provides the project ref when `--project-ref` is unset; Docker-unbundle path: also read from project dotenv now (previously ambient-shell-only), overriding `project_id` for Docker network/volume/label naming and `Config.Validate` | no (falls back to `/supabase/.temp/project-ref`) | +| `SUPABASE_WORKDIR` | sets `` for local Supabase temp files | no (falls back to `--workdir` -> nearest ancestor with `supabase/config.toml` -> cwd) | +| `SUPABASE_ENV` | Docker-unbundle path: selects environment-specific dotenv files (`.env..local`, `.env.`) | no (defaults to `development`) | +| `BITBUCKET_CLONE_DIR` | Docker-unbundle path: when set, skips creating the named Deno-cache volume and omits its bind mount from the `docker run` command (Bitbucket's restricted Docker environment rejects both) | no | +| `SUPABASE_INTERNAL_IMAGE_REGISTRY` | selects the registry the edge-runtime unbundle image is pulled from (`legacyGetRegistryImageUrl`); read from the ambient shell **or** project dotenv (Docker-unbundle path); unset resolves ECR->GHCR->Docker-Hub candidates in order instead of a single URL — also consumed on the `--use-api` invocation even though it never pulls an image | no (defaults to `public.ecr.aws`) | +| `SUPABASE_NETWORK_ID` | Docker-unbundle path: overrides the generated `supabase_network_` Docker network name when `--network-id` isn't passed; read from the ambient shell or project dotenv | no | +| `SUPABASE_EDGE_RUNTIME_DENO_VERSION` | Docker-unbundle path: overrides `edge_runtime.deno_version` (which image tag to pull) when set, from the ambient shell or project dotenv — takes effect even with no `config.toml` on disk | no | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/functions/download/download.handler.ts b/apps/cli/src/legacy/commands/functions/download/download.handler.ts index f53170995b..dbe4bab220 100644 --- a/apps/cli/src/legacy/commands/functions/download/download.handler.ts +++ b/apps/cli/src/legacy/commands/functions/download/download.handler.ts @@ -6,7 +6,8 @@ import { } from "../../../../shared/functions/download.ts"; import { resolveEdgeRuntimeVersionPin } from "../../../../shared/functions/functions.shared.ts"; import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; -import { legacyAqua, legacyBold } from "../../../shared/legacy-colors.ts"; +import { legacyAqua, legacyBold, legacyYellow } from "../../../shared/legacy-colors.ts"; +import { legacyFunctionsGoConfigCompat } from "../../../shared/legacy-functions-go-config.ts"; import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; @@ -34,7 +35,7 @@ export const legacyFunctionsDownload = Effect.fn("legacy.functions.download")(fu api, projectRoot: cliConfig.workdir, rawArgs, - goViperCompat: true, + goConfigCompat: legacyFunctionsGoConfigCompat, edgeRuntimeVersion, // Go: `utils.Bold` on the `Downloading function:` slug (`downloadOne`, // `download.go:219`, stderr) — matches `legacyBold`'s default TTY gate. @@ -43,6 +44,10 @@ export const legacyFunctionsDownload = Effect.fn("legacy.functions.download")(fu // (`suggestLegacyBundle`, `download.go:315`, stderr) — matches // `legacyAqua`'s default TTY gate. styleAqua: (text) => legacyAqua(text), + // Go: `utils.Yellow` on the `WARNING:` token before "Docker is not + // running" (`download.go:146`, stderr) — matches `legacyYellow`'s default + // TTY gate. + styleWarning: (text) => legacyYellow(text), resolveProjectRef: (projectRef) => resolver.resolve(projectRef).pipe( Effect.tap((ref) => diff --git a/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts b/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts index a5c10c6f7a..a0a888b268 100644 --- a/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts +++ b/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts @@ -23,6 +23,9 @@ import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; import { mockChildProcessSpawner } from "../../../../../../../packages/process-compose/tests/helpers/mocks.ts"; import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; import { legacyContainerRuntimeNotFoundMessage } from "../../../shared/legacy-container-cli.ts"; +import { downloadFunctions } from "../../../../shared/functions/download.ts"; +import { legacyFunctionsGoConfigCompat } from "../../../shared/legacy-functions-go-config.ts"; +import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; import { ConflictingFunctionDownloadFlagsError } from "../../../../shared/functions/download.errors.ts"; import { legacyFunctionsDownloadHandler } from "./download.command.ts"; import type { LegacyFunctionsDownloadFlags } from "./download.command.ts"; @@ -522,6 +525,17 @@ describe("legacy functions download", () => { (spawned) => spawned.command === "docker" && spawned.args[0] === "run", ), ).toHaveLength(2); + // The edge-runtime image is resolved/pulled once for the whole + // invocation, not once per function — see `PulledEdgeRuntimeImage`'s + // doc comment in `download.ts`. + expect( + child.spawned.filter( + (spawned) => + spawned.command === "docker" && + spawned.args[0] === "image" && + spawned.args[1] === "inspect", + ), + ).toHaveLength(1); expect(out.messages).toContainEqual( expect.objectContaining({ type: "success", @@ -746,8 +760,8 @@ describe("legacy functions download", () => { return Effect.gen(function* () { // `--network-id` is a persistent root flag (`cmd/root.go:328`), not - // registered on `functions download` itself — - // `explicitNonEmptyStringFlag` scans the whole argv unscoped. + // registered on `functions download` itself — `explicitStringFlag` + // scans the whole argv unscoped. yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }); expect(child.spawned.find((spawned) => spawned.args[0] === "network")).toEqual({ @@ -1808,4 +1822,487 @@ describe("legacy functions download", () => { expect(proxy.calls).toEqual([]); }).pipe(Effect.provide(layer)); }); + + describe("Config.Validate / dotenv / env-override parity (CLI-1963)", () => { + it.live( + "fails before any Docker/API work when config.toml has an explicit empty project_id", + () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi(); + const proxy = mockProxy(); + const child = mockChildProcessSpawner({ exitCode: 0 }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + }), + proxy.layer, + child.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "hello-world", + "--project-ref", + PROJECT_ID, + ]), + }), + ); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + mkdir(join(tempRoot.current, "supabase"), { recursive: true }), + ); + yield* Effect.tryPromise(() => + writeFile(join(tempRoot.current, "supabase", "config.toml"), 'project_id = ""\n'), + ); + + const error = yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }).pipe( + Effect.flip, + ); + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe("Missing required field in config: project_id"); + expect(api.requests).toEqual([]); + expect(child.spawned).toEqual([]); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "fails before any Docker/API work on an unrelated Config.Validate branch (unsupported Postgres major version)", + () => { + // Proves the WHOLE resolved config is validated, not just `project_id` + // — `db.major_version = 12` is a genuinely unrelated Go `Config.Validate` + // branch (`config.go:1034-1062`). + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi(); + const proxy = mockProxy(); + const child = mockChildProcessSpawner({ exitCode: 0 }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + }), + proxy.layer, + child.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "hello-world", + "--project-ref", + PROJECT_ID, + ]), + }), + ); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + mkdir(join(tempRoot.current, "supabase"), { recursive: true }), + ); + yield* Effect.tryPromise(() => + writeFile( + join(tempRoot.current, "supabase", "config.toml"), + ['project_id = "test-project"', "", "[db]", "major_version = 12", ""].join("\n"), + ), + ); + + const error = yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }).pipe( + Effect.flip, + ); + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe( + "Postgres version 12.x is unsupported. To use the CLI, either start a new project or follow project migration steps here: https://supabase.com/docs/guides/database#migrating-between-projects.", + ); + expect(api.requests).toEqual([]); + expect(child.spawned).toEqual([]); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "resolves the deno v1 edge-runtime image tag when SUPABASE_EDGE_RUNTIME_DENO_VERSION=1 overrides an unset config value", + () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi(); + const proxy = mockProxy(); + const child = mockChildProcessSpawner({ exitCode: 0 }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + }), + proxy.layer, + child.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "hello-world", + "--use-docker", + "--project-ref", + PROJECT_ID, + ]), + }), + ); + + const previous = process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"]; + process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = "1"; + + return Effect.gen(function* () { + yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }); + + const runCommand = child.spawned.find((spawned) => spawned.args[0] === "run"); + expect(runCommand?.args.slice(-6)[0]).toBe( + "public.ecr.aws/supabase/edge-runtime:v1.68.4", + ); + }) + .pipe(Effect.provide(layer)) + .pipe( + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) { + delete process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"]; + } else { + process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = previous; + } + }), + ), + ); + }, + ); + + it.live( + "uses SUPABASE_NETWORK_ID as the docker network when no --network-id flag is passed", + () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi(); + const proxy = mockProxy(); + const child = mockChildProcessSpawner({ exitCode: 0 }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + }), + proxy.layer, + child.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "hello-world", + "--use-docker", + "--project-ref", + PROJECT_ID, + ]), + }), + ); + + const previous = process.env["SUPABASE_NETWORK_ID"]; + process.env["SUPABASE_NETWORK_ID"] = "env-network"; + + return Effect.gen(function* () { + yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }); + + expect(child.spawned.find((spawned) => spawned.args[0] === "network")).toEqual({ + command: "docker", + args: ["network", "inspect", "env-network"], + }); + const runCommand = child.spawned.find((spawned) => spawned.args[0] === "run"); + expect(runCommand?.args).toContain("env-network"); + }) + .pipe(Effect.provide(layer)) + .pipe( + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) { + delete process.env["SUPABASE_NETWORK_ID"]; + } else { + process.env["SUPABASE_NETWORK_ID"] = previous; + } + }), + ), + ); + }, + ); + + it.live("prefers an explicit --network-id flag over SUPABASE_NETWORK_ID", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi(); + const proxy = mockProxy(); + const child = mockChildProcessSpawner({ exitCode: 0 }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + }), + proxy.layer, + child.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "hello-world", + "--use-docker", + "--project-ref", + PROJECT_ID, + "--network-id", + "flag-network", + ]), + }), + ); + + const previous = process.env["SUPABASE_NETWORK_ID"]; + process.env["SUPABASE_NETWORK_ID"] = "env-network"; + + return Effect.gen(function* () { + yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }); + + expect(child.spawned.find((spawned) => spawned.args[0] === "network")).toEqual({ + command: "docker", + args: ["network", "inspect", "flag-network"], + }); + const runCommand = child.spawned.find((spawned) => spawned.args[0] === "run"); + expect(runCommand?.args).toContain("flag-network"); + expect(runCommand?.args).not.toContain("env-network"); + }) + .pipe(Effect.provide(layer)) + .pipe( + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) { + delete process.env["SUPABASE_NETWORK_ID"]; + } else { + process.env["SUPABASE_NETWORK_ID"] = previous; + } + }), + ), + ); + }); + + it.live( + "resolves a registry override configured only via project dotenv, not the ambient shell", + () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi(); + const proxy = mockProxy(); + const child = mockChildProcessSpawner({ exitCode: 0 }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + }), + proxy.layer, + child.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "hello-world", + "--use-docker", + "--project-ref", + PROJECT_ID, + ]), + }), + ); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + mkdir(join(tempRoot.current, "supabase"), { recursive: true }), + ); + yield* Effect.tryPromise(() => + writeFile( + join(tempRoot.current, "supabase", ".env"), + "SUPABASE_INTERNAL_IMAGE_REGISTRY=ghcr.io\n", + ), + ); + + yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }); + + const runCommand = child.spawned.find((spawned) => spawned.args[0] === "run"); + expect(runCommand?.args.slice(-6)[0]).toBe( + `ghcr.io/supabase/edge-runtime:v${DEFAULT_VERSIONS["edge-runtime"]}`, + ); + expect( + child.spawned.filter( + (spawned) => spawned.args[0] === "image" && spawned.args[1] === "inspect", + ), + ).toHaveLength(1); + // Proves the registry came from the project dotenv file, not the + // ambient shell environment. + expect(process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]).toBeUndefined(); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("falls back to the GHCR candidate when the ECR image cannot be inspected", () => { + // Simulates a registry-candidate fallback with no real pull/retry: the + // ECR `docker image inspect` MISSes cleanly (non-zero exit, "not found" + // stderr), so `hasLocalImage` moves straight to the next candidate + // instead of ever entering the sleeping pull-retry loop. + const spawnerOpts: { + exitCode?: number; + stderr?: string[]; + onSpawn?: (record: { command: string; args: ReadonlyArray }) => void; + } = { exitCode: 0 }; + spawnerOpts.onSpawn = (record) => { + if ( + record.command === "docker" && + record.args[0] === "image" && + record.args[1] === "inspect" + ) { + const image = record.args[2] ?? ""; + const isEcrCandidate = image.startsWith("public.ecr.aws/"); + spawnerOpts.exitCode = isEcrCandidate ? 1 : 0; + spawnerOpts.stderr = isEcrCandidate ? [`Error: No such image: ${image}`] : []; + return; + } + spawnerOpts.exitCode = 0; + spawnerOpts.stderr = []; + }; + const child = mockChildProcessSpawner(spawnerOpts); + + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi(); + const proxy = mockProxy(); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + }), + proxy.layer, + child.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "hello-world", + "--use-docker", + "--project-ref", + PROJECT_ID, + ]), + }), + ); + + return Effect.gen(function* () { + yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }); + + expect( + child.spawned.filter( + (spawned) => spawned.args[0] === "image" && spawned.args[1] === "inspect", + ), + ).toHaveLength(2); + const runCommand = child.spawned.find((spawned) => spawned.args[0] === "run"); + expect(runCommand?.args.slice(-6)[0]).toBe( + `ghcr.io/supabase/edge-runtime:v${DEFAULT_VERSIONS["edge-runtime"]}`, + ); + }).pipe(Effect.provide(layer)); + }); + + it.live( + "labels the unbundle container with the resolved project id (Go parity: docker.go:349-386)", + () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi(); + const proxy = mockProxy(); + const child = mockChildProcessSpawner({ exitCode: 0 }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + }), + proxy.layer, + child.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "hello-world", + "--use-docker", + "--project-ref", + PROJECT_ID, + ]), + }), + ); + + return Effect.gen(function* () { + yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }); + + const runCommand = child.spawned.find((spawned) => spawned.args[0] === "run"); + expect(runCommand?.args).toEqual( + expect.arrayContaining([ + "--label", + `com.supabase.cli.project=${PROJECT_ID}`, + "--label", + `com.docker.compose.project=${PROJECT_ID}`, + ]), + ); + }).pipe(Effect.provide(layer)); + }, + ); + }); + + describe("docker-not-running warning styling (Go parity: download.go:146; only WARNING: is styled)", () => { + it.live("wraps only the WARNING token, not the rest of the fallback line", () => { + // Calls the shared `downloadFunctions` with a marker `styleWarning` + // instead of going through `legacyFunctionsDownload`: the real hook + // (`legacyYellow`) is TTY-gated and therefore inert under vitest, so + // only an injected marker can deterministically observe styling scope. + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ + handler: (request) => + request.url.endsWith("/body") + ? Effect.succeed(multipartResponse(request)) + : Effect.succeed(legacyJsonResponse(request, 200, {})), + }); + const child = mockChildProcessSpawner({ exitCode: 1 }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + }), + child.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "hello-world", + "--project-ref", + PROJECT_ID, + ]), + }), + ); + + return Effect.gen(function* () { + const platformApi = yield* LegacyPlatformApi; + + yield* downloadFunctions( + { ...baseFlags, useDocker: true }, + { + api: platformApi, + projectRoot: tempRoot.current, + rawArgs: ["functions", "download", "hello-world", "--project-ref", PROJECT_ID], + goConfigCompat: legacyFunctionsGoConfigCompat, + edgeRuntimeVersion: "1.69.12", + resolveProjectRef: () => Effect.succeed(PROJECT_ID), + proxyDownload: () => Effect.die("unexpected proxy invocation"), + styleWarning: (text) => `${text}`, + }, + ); + + expect(out.stderrText).toContain("WARNING: Docker is not running\n"); + }).pipe(Effect.provide(layer)); + }); + }); }); diff --git a/apps/cli/src/legacy/commands/functions/serve/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/functions/serve/SIDE_EFFECTS.md index e7382f532d..8e0149be32 100644 --- a/apps/cli/src/legacy/commands/functions/serve/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/functions/serve/SIDE_EFFECTS.md @@ -2,16 +2,18 @@ ## Files Read -| Path | Format | When | -| ---------------------------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------- | -| `/supabase/config.toml` | TOML | on every startup / restart when the project config exists | -| `/supabase/.temp/edge-runtime-version` | plain text | when present, to override the bundled edge-runtime image tag | -| `/supabase/functions/.env` | dotenv | when `--env-file` is unset and the fallback env file exists | -| `` | dotenv | when `--env-file` is set; relative paths resolve from the caller cwd | -| `/supabase/functions/*/index.ts` | TypeScript | to discover filesystem-backed functions | -| config-declared entrypoints / import maps / static files and imports | mixed | for each enabled function while resolving Docker bind mounts | -| `` | JSON | when `auth.signing_keys_path` is configured | -| `apps/cli/src/shared/functions/serve.main.ts` (+ `serve-main-deps.ts`) | TypeScript | only when running from source (`bun src/supabase.ts`), bundled on demand; compiled binaries embed the pre-bundled template and read nothing | +| Path | Format | When | +| -------------------------------------------------------------------------------------------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | on every startup / restart when the project config exists | +| `/supabase/{.env,.env.local,.env.,.env..local}` and the same four at the project root | dotenv | on every startup / restart, a SECOND, independent read from the `env()`-interpolation one below — Go-parity project dotenv (`legacyResolveProjectEnvironmentValues`) feeding the `SUPABASE_*` overrides (network-id, deno-version, registry) and the `Config.Validate` pipeline, same one `start`/`stop`/`status` already use | +| `/supabase/`, `[api.tls]` cert/key paths, email template `content_path` — when configured | varies | on every startup / restart, as part of the `Config.Validate` pipeline above, unconditionally, matching Go's `Config.Load` — read even though `serve` doesn't otherwise use their contents | +| `/supabase/.temp/edge-runtime-version` | plain text | when present, to override the bundled edge-runtime image tag | +| `/supabase/functions/.env` | dotenv | when `--env-file` is unset and the fallback env file exists | +| `` | dotenv | when `--env-file` is set; relative paths resolve from the caller cwd | +| `/supabase/functions/*/index.ts` | TypeScript | to discover filesystem-backed functions | +| config-declared entrypoints / import maps / static files and imports | mixed | for each enabled function while resolving Docker bind mounts | +| `` | JSON | when `auth.signing_keys_path` is configured | +| `apps/cli/src/shared/functions/serve.main.ts` (+ `serve-main-deps.ts`) | TypeScript | only when running from source (`bun src/supabase.ts`), bundled on demand; compiled binaries embed the pre-bundled template and read nothing | ## Files Written @@ -42,14 +44,16 @@ validation is performed on the discovered URLs, also matching the Go CLI. ## Environment Variables -| Variable | Purpose | Required? | -| --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ | -| `SUPABASE_PROFILE` | resolves the legacy profile / API base URL | no (defaults to `supabase`) | -| `SUPABASE_WORKDIR` | overrides the project workdir | no (falls back to CLI cwd discovery) | -| `SUPABASE_PROJECT_ID` | legacy config-service override for project identity | no | -| `SUPABASE_ENV` | selects environment-specific dotenv files (`.env..local`, `.env.`) | no (defaults to `development`) | -| env vars referenced by `supabase/config.toml` | config interpolation; the full ambient `process.env` is layered under the project `.env*` files and passed to config loading | no | -| `SUPABASE_INTERNAL_IMAGE_REGISTRY` | overrides the edge-runtime Docker registry mirror | no (defaults to `public.ecr.aws`) | +| Variable | Purpose | Required? | +| --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ | +| `SUPABASE_PROFILE` | resolves the legacy profile / API base URL | no (defaults to `supabase`) | +| `SUPABASE_WORKDIR` | overrides the project workdir | no (falls back to CLI cwd discovery) | +| `SUPABASE_PROJECT_ID` | legacy config-service override for project identity | no | +| `SUPABASE_ENV` | selects environment-specific dotenv files (`.env..local`, `.env.`) | no (defaults to `development`) | +| env vars referenced by `supabase/config.toml` | config interpolation; the full ambient `process.env` is layered under the project `.env*` files and passed to config loading | no | +| `SUPABASE_INTERNAL_IMAGE_REGISTRY` | overrides the edge-runtime Docker registry mirror; read from the ambient shell **or** project dotenv; unset resolves ECR->GHCR->Docker-Hub candidates in order instead of a single URL | no (defaults to `public.ecr.aws`) | +| `SUPABASE_NETWORK_ID` | overrides the generated `supabase_network_` Docker network name when `--network-id` isn't passed; read from the ambient shell or project dotenv | no | +| `SUPABASE_EDGE_RUNTIME_DENO_VERSION` | overrides `edge_runtime.deno_version` (which image tag to pull) when set, from the ambient shell or project dotenv — takes effect even with no `config.toml` on disk | no | ## Exit Codes @@ -57,7 +61,7 @@ validation is performed on the discovered URLs, also matching the Go CLI. | ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `0` | clean shutdown after `SIGINT`, `SIGTERM`, or stdin close | | `1` | local DB container is not running, or the Docker daemon is unreachable (surfaces from the DB inspect as `failed to inspect service: …` plus the Docker Desktop install suggestion) | -| `1` | invalid inspect flag combination or invalid project/auth config | +| `1` | invalid inspect flag combination, or a `Config.Validate` failure anywhere in `config.toml` (not just project/auth config) | | `1` | env file, signing key, import map, or function bind resolution failure | | `1` | edge-runtime container startup, log streaming, or restart loop failure | @@ -97,5 +101,7 @@ Long-running raw log / error events only; there is no terminal `result` event on - network: `supabase_network_` unless `--network-id` overrides it - Inspector mode exposes the configured `edge_runtime.inspector_port` on the host and sets `SUPABASE_INTERNAL_WALLCLOCK_LIMIT_SEC=0`, matching the Go serve path. - Config `env()` interpolation uses a project environment resolved by the command itself (ambient `process.env` layered under `.env..local` / `.env.local` / `.env.` / `.env`, matching Go) and passed into `loadProjectConfig`. The command does not mutate `process.env` or move/hide any project files. +- Before each container (re)start, resolves the edge-runtime image through the same registry-candidate pull-with-retry every native `functions` Docker path uses: `docker image inspect ` (ECR, then GHCR, then Docker Hub) to check the local cache, then `docker pull ` with 2 retries (4s/8s backoff) on a miss, after `assertLocalDbRunning` — resolving it earlier would hijack the down-daemon error message that DB-inspect step is responsible for producing. +- Runs the full `Config.Validate` pipeline (`legacyResolveLocalConfigValues`, same one `start`/`stop`/`status` use) on every startup/restart, before `assertLocalDbRunning` — an invalid config now fails `serve` up front even for fields this command never otherwise reads (e.g. a bad `db.major_version` or malformed auth hook), matching Go's `flags.LoadConfig` -> `Config.Validate`. - A container crash terminates the command with a non-zero exit; only a watched-file change restarts the container. The Go CLI never auto-restarts a crashed container. - The worker bootstrap template (`serve.main.ts`) is bundled into a single self-contained module with `jose` and the local path/status helpers inlined, so the edge-runtime worker boots without any network access (supabase/supabase#45570). The bundle is embedded at build time for shipped binaries and produced on demand (esbuild) when running from source. diff --git a/apps/cli/src/legacy/commands/functions/serve/serve.handler.ts b/apps/cli/src/legacy/commands/functions/serve/serve.handler.ts index 86b51f387b..9f66fe7a34 100644 --- a/apps/cli/src/legacy/commands/functions/serve/serve.handler.ts +++ b/apps/cli/src/legacy/commands/functions/serve/serve.handler.ts @@ -1,6 +1,7 @@ import { Effect } from "effect"; import { join } from "node:path"; import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; +import { legacyFunctionsGoConfigCompat } from "../../../shared/legacy-functions-go-config.ts"; import { LegacyDebugFlag, LegacyNetworkIdFlag } from "../../../../shared/legacy/global-flags.ts"; import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; @@ -34,5 +35,6 @@ export const legacyFunctionsServe = Effect.fn("legacy.functions.serve")(function networkId, projectIdOverride: cliConfig.projectId, goViperCompat: true, + goConfigCompat: legacyFunctionsGoConfigCompat, }).pipe(Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts b/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts index a0c9dcd02e..6a241aa470 100644 --- a/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts +++ b/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts @@ -77,6 +77,7 @@ vi.mock("../../../../shared/functions/functions-docker.ts", async () => { typeof import("../../../../shared/functions/functions-docker.ts") >("../../../../shared/functions/functions-docker.ts"); const { Effect } = await import("effect"); + const { legacyGetRegistryImageUrl } = await import("../../../shared/legacy-docker-registry.ts"); return { ...actual, @@ -88,6 +89,18 @@ vi.mock("../../../../shared/functions/functions-docker.ts", async () => { Effect.sync(() => { deployMockState.volumeCalls.push({ volumeName, projectId }); }), + // Stubbed to the pure registry-mapping step only, skipping the actual + // cache-check/pull: the real implementation + // (`legacyMakeDockerImageResolver`) does `docker image inspect`/`docker + // pull` via the real `ChildProcessSpawner` directly (not through this + // file's mocked `runChildProcess` below), so leaving it real here would + // insert un-mocked spawns — and real 4s/8s retry backoffs on a miss — + // into every test that reaches container start. Registry + // resolution/retry has its own coverage in `functions-docker.unit.test.ts`. + resolveFunctionsDockerImage: ( + image: string, + projectEnvValues?: Readonly>, + ) => Effect.sync(() => legacyGetRegistryImageUrl(image, projectEnvValues)), runChildProcess: (command: string, args: ReadonlyArray, options?: unknown) => Effect.suspend(() => { const envFile = args.flatMap((value, index) => @@ -2739,4 +2752,240 @@ describe("legacy functions serve integration", () => { ).toHaveLength(0); }); }); + + describe("Config.Validate / dotenv / env-override parity (CLI-1963)", () => { + it.live( + "fails before any Docker work when config.toml has an explicit empty project_id", + () => { + return Effect.gen(function* () { + yield* Effect.promise(() => writeProjectConfig('project_id = ""\n')); + yield* Effect.promise(() => + writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), + ); + + const { layer } = setupServe(); + const error = yield* legacyFunctionsServe(baseFlags()).pipe( + Effect.provide(layer), + Effect.flip, + ); + + expect(error).toBeInstanceOf(Error); + if (error instanceof Error) { + expect(error.message).toBe("Missing required field in config: project_id"); + } + expect(deployMockState.runCalls).toHaveLength(0); + expect(deployMockState.networkCalls).toHaveLength(0); + expect(deployMockState.volumeCalls).toHaveLength(0); + }); + }, + ); + + it.live( + "fails before any Docker work on an unrelated Config.Validate branch (unsupported Postgres major version)", + () => { + // Proves the WHOLE resolved config is validated, not just `project_id` + // — `db.major_version = 12` is a genuinely unrelated Go `Config.Validate` + // branch (`config.go:1034-1062`). + return Effect.gen(function* () { + yield* Effect.promise(() => + writeProjectConfig( + ['project_id = "test-project"', "", "[db]", "major_version = 12", ""].join("\n"), + ), + ); + yield* Effect.promise(() => + writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), + ); + + const { layer } = setupServe(); + const error = yield* legacyFunctionsServe(baseFlags()).pipe( + Effect.provide(layer), + Effect.flip, + ); + + expect(error).toBeInstanceOf(Error); + if (error instanceof Error) { + expect(error.message).toBe( + "Postgres version 12.x is unsupported. To use the CLI, either start a new project or follow project migration steps here: https://supabase.com/docs/guides/database#migrating-between-projects.", + ); + } + expect(deployMockState.runCalls).toHaveLength(0); + expect(deployMockState.networkCalls).toHaveLength(0); + expect(deployMockState.volumeCalls).toHaveLength(0); + }); + }, + ); + + it.live( + "resolves the deno v1 edge-runtime image tag when SUPABASE_EDGE_RUNTIME_DENO_VERSION=1 overrides an unset config value", + () => { + deployMockState.runHandler = (command, args) => { + if (command !== "docker") { + throw new Error(`unexpected process: ${command}`); + } + if (args[0] === "container" && args[1] === "inspect") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "container" && args[1] === "rm") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "run") { + return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; + } + if (args[0] === "exec") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + throw new Error(`unexpected docker args: ${args.join(" ")}`); + }; + const childSpawner = mockDockerLogSpawner([{ exitCode: 1, stderr: "serve logs failed" }]); + + return Effect.gen(function* () { + const previous = process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"]; + process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = "1"; + yield* Effect.addFinalizer(() => + Effect.sync(() => { + if (previous === undefined) { + delete process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"]; + } else { + process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = previous; + } + }), + ); + + yield* Effect.promise(() => + writeProjectConfig(['project_id = "test-project"', ""].join("\n")), + ); + yield* Effect.promise(() => + writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), + ); + yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); + + const { layer } = setupServe({ childSpawner }); + yield* legacyFunctionsServe(baseFlags()).pipe(Effect.provide(layer), Effect.flip); + + const dockerRun = deployMockState.runCalls.find( + (call) => call.command === "docker" && call.args[0] === "run", + ); + expect(dockerRun).toBeDefined(); + if (dockerRun === undefined) { + throw new Error("expected docker run call"); + } + expect(dockerRun.args).toContain("public.ecr.aws/supabase/edge-runtime:v1.68.4"); + }); + }, + ); + + it.live( + "uses SUPABASE_NETWORK_ID as the docker network when no --network-id flag is passed", + () => { + deployMockState.runHandler = (command, args) => { + if (command !== "docker") { + throw new Error(`unexpected process: ${command}`); + } + if (args[0] === "container" && args[1] === "inspect") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "container" && args[1] === "rm") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "run") { + return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; + } + if (args[0] === "exec") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + throw new Error(`unexpected docker args: ${args.join(" ")}`); + }; + const childSpawner = mockDockerLogSpawner([{ exitCode: 1, stderr: "serve logs failed" }]); + + return Effect.gen(function* () { + const previous = process.env["SUPABASE_NETWORK_ID"]; + process.env["SUPABASE_NETWORK_ID"] = "env-network"; + yield* Effect.addFinalizer(() => + Effect.sync(() => { + if (previous === undefined) { + delete process.env["SUPABASE_NETWORK_ID"]; + } else { + process.env["SUPABASE_NETWORK_ID"] = previous; + } + }), + ); + + yield* Effect.promise(() => + writeProjectConfig(['project_id = "test-project"', ""].join("\n")), + ); + yield* Effect.promise(() => + writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), + ); + yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); + + const { layer } = setupServe({ childSpawner }); + yield* legacyFunctionsServe(baseFlags()).pipe(Effect.provide(layer), Effect.flip); + + expect(deployMockState.networkCalls).toEqual([ + { networkMode: "env-network", projectId: "test-project" }, + ]); + const dockerRun = deployMockState.runCalls.find( + (call) => call.command === "docker" && call.args[0] === "run", + ); + expect(dockerRun?.args).toContain("env-network"); + }); + }, + ); + + it.live("prefers an explicit --network-id flag over SUPABASE_NETWORK_ID", () => { + deployMockState.runHandler = (command, args) => { + if (command !== "docker") { + throw new Error(`unexpected process: ${command}`); + } + if (args[0] === "container" && args[1] === "inspect") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "container" && args[1] === "rm") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "run") { + return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; + } + if (args[0] === "exec") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + throw new Error(`unexpected docker args: ${args.join(" ")}`); + }; + const childSpawner = mockDockerLogSpawner([{ exitCode: 1, stderr: "serve logs failed" }]); + + return Effect.gen(function* () { + const previous = process.env["SUPABASE_NETWORK_ID"]; + process.env["SUPABASE_NETWORK_ID"] = "env-network"; + yield* Effect.addFinalizer(() => + Effect.sync(() => { + if (previous === undefined) { + delete process.env["SUPABASE_NETWORK_ID"]; + } else { + process.env["SUPABASE_NETWORK_ID"] = previous; + } + }), + ); + + yield* Effect.promise(() => + writeProjectConfig(['project_id = "test-project"', ""].join("\n")), + ); + yield* Effect.promise(() => + writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), + ); + yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); + + const { layer } = setupServe({ childSpawner, networkId: Option.some("flag-network") }); + yield* legacyFunctionsServe(baseFlags()).pipe(Effect.provide(layer), Effect.flip); + + expect(deployMockState.networkCalls).toEqual([ + { networkMode: "flag-network", projectId: "test-project" }, + ]); + const dockerRun = deployMockState.runCalls.find( + (call) => call.command === "docker" && call.args[0] === "run", + ); + expect(dockerRun?.args).toContain("flag-network"); + expect(dockerRun?.args).not.toContain("env-network"); + }); + }); + }); }); diff --git a/apps/cli/src/legacy/shared/legacy-docker-ids.ts b/apps/cli/src/legacy/shared/legacy-docker-ids.ts index 5334a5ee30..f5fbd017ed 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-ids.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-ids.ts @@ -12,16 +12,23 @@ import { basename } from "node:path"; * Resolve the project id Go feeds into `utils.DbId`/`utils.NetId`. viper sets * `Config.ProjectId` from config.toml's `project_id`, then `AutomaticEnv` overrides it * with `SUPABASE_PROJECT_ID`; when both are absent Go falls back to the working - * directory basename (`utils.Config.ProjectId` default). So the precedence is - * `SUPABASE_PROJECT_ID` → config.toml `project_id` → workdir basename. + * directory basename (`utils.Config.ProjectId` default) — UNLESS a `--project-ref` + * was resolved for this invocation, in which case `flags.LoadConfig` pre-sets + * `Config.ProjectId = ProjectRef` before ever merging the file + * (`pkg/config/config.go:561-570`), so `Eject` only reaches the basename fallback + * when that default is itself empty. `projectRefDefault` is `undefined` for + * `start`/`stop`/`status`, which have no such flag. So the full precedence is + * `SUPABASE_PROJECT_ID` → config.toml `project_id` → `--project-ref` → workdir basename. */ export function legacyResolveLocalProjectId( envProjectId: string | undefined, tomlProjectId: string | undefined, workdir: string, + projectRefDefault?: string, ): string { if (envProjectId !== undefined && envProjectId.length > 0) return envProjectId; if (tomlProjectId !== undefined && tomlProjectId.length > 0) return tomlProjectId; + if (projectRefDefault !== undefined && projectRefDefault.length > 0) return projectRefDefault; return basename(workdir); } diff --git a/apps/cli/src/legacy/shared/legacy-docker-image-resolve.ts b/apps/cli/src/legacy/shared/legacy-docker-image-resolve.ts index 5458eab746..54fe6421fc 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-image-resolve.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-image-resolve.ts @@ -62,10 +62,12 @@ const concat = (chunks: ReadonlyArray): Uint8Array => { * the caller's process lifecycle differs. * * `projectEnvValues` is optional (see `legacy-docker-registry.ts`'s doc - * comment) — only `start` currently threads it through, since its caller - * already has the project's dotenv-merged values in scope; `legacy-docker-run.layer.ts` - * is a statically-composed `Layer` built before any `projectEnvValues` is - * known, so its own callers stay ambient-only for now. + * comment) — `start` and the `functions` Docker paths (`deploy`, `download`, + * `serve`, via `resolveFunctionsDockerImage`) all thread it through, since + * each already has the project's dotenv-merged values in scope by the time + * it resolves an image; `legacy-docker-run.layer.ts` is a statically-composed + * `Layer` built before any `projectEnvValues` is known, so its own callers + * stay ambient-only for now. */ export function legacyMakeDockerImageResolver( spawner: Spawner, diff --git a/apps/cli/src/legacy/shared/legacy-functions-go-config.ts b/apps/cli/src/legacy/shared/legacy-functions-go-config.ts new file mode 100644 index 0000000000..1b0e766bfa --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-functions-go-config.ts @@ -0,0 +1,48 @@ +import { Effect } from "effect"; +import type { FunctionsGoConfigCompat } from "../../shared/functions/functions-config.ts"; +import { legacyLoadLocalProjectContext } from "./legacy-local-project-context.ts"; +import { legacyResolveLocalConfigValues } from "./legacy-local-config-values.ts"; + +function toError(cause: unknown): Error { + return cause instanceof Error ? cause : new Error(String(cause)); +} + +/** + * Go-parity config resolution for the native `functions` Docker paths + * (`deploy`/`download`/`serve`), injected into `functions-config.ts`'s + * `loadFunctionsProjectConfig` so `shared/functions/` never imports + * `legacy/`-specific validation directly (same isolation rationale as + * `styleEmphasis`/`styleAqua`). + * + * Delegates entirely to the SAME two functions `start`/`stop`/`status` + * already share — `legacyLoadLocalProjectContext` (dotenv + config load) and + * `legacyResolveLocalConfigValues` (`Config.Validate`, one home per + * `apps/cli/CLAUDE.md`) — rather than re-implementing either. Their + * derived local-dev values (JWTs, URLs) are discarded here; only + * `projectId`/`edgeRuntimeDenoVersion` and the validation side effect + * (throws on the first Go-parity failure) matter to these three commands. + */ +export const legacyFunctionsGoConfigCompat: FunctionsGoConfigCompat = { + load: ({ projectRoot, projectRef }) => + Effect.gen(function* () { + const context = yield* legacyLoadLocalProjectContext(projectRoot, toError, projectRef); + const validated = yield* Effect.try({ + try: () => + legacyResolveLocalConfigValues( + context.config, + context.hostname, + projectRoot, + context.projectEnvValues, + context.loaded?.document, + projectRef, + ), + catch: toError, + }); + return { + loaded: context.loaded, + projectEnvValues: context.projectEnvValues, + projectId: validated.projectId, + denoVersion: validated.edgeRuntimeDenoVersion, + }; + }), +}; diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.ts index d519c85cd6..ce31164bda 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.ts @@ -144,6 +144,17 @@ export interface LegacyLocalConfigValues { readonly gcpProjectNumber: string; /** Already env-overridden `analytics.gcp_jwt_path` (`SUPABASE_ANALYTICS_GCP_JWT_PATH`). */ readonly gcpJwtPath: string; + /** + * Go's `Config.ProjectId`, sanitized (`config.go:938-944`) — the SAME + * env-overridden, `projectIdFallback`-aware value this function already + * validates internally (see `resolvedProjectId` above), just also + * returned so callers that need it for Docker resource naming (`functions` + * deploy`/`download`/`serve`) don't re-derive it with a second + * implementation that could drift from this one. + */ + readonly projectId: string; + /** Already env-overridden `edge_runtime.deno_version` (`SUPABASE_EDGE_RUNTIME_DENO_VERSION`). */ + readonly edgeRuntimeDenoVersion: number; } /** @@ -2169,6 +2180,15 @@ export function legacyResolveLocalConfigValues( * guessed at. */ document: Readonly> | undefined = undefined, + /** + * Go's `Eject` default (`pkg/config/config.go:561-570`): `flags.LoadConfig` + * pre-sets `Config.ProjectId` to the resolved `--project-ref`/linked project + * ref BEFORE merging the file, so `Eject`'s own basename fallback only + * triggers when that default is itself empty. `undefined` for `status`/ + * `stop`, which have no such flag and fall straight to the basename, same + * as before this parameter existed. + */ + projectIdFallback?: string, ): LegacyLocalConfigValues { // Go's `Config.Validate` checks `ProjectId` FIRST, before every other field // (`pkg/config/config.go:990-991`) — see this function's `@throws` doc above @@ -2176,15 +2196,19 @@ export function legacyResolveLocalConfigValues( // `project_id` is absent from the file entirely. `config.project_id` is // `undefined` only when the key is genuinely absent (`optionalKey`, see // `packages/config/src/base.ts`) — that's the ONE case where Go's own - // sanitized-basename viper default shows through instead of a file value, - // so the fallback belongs here, not as a third branch after `legacyEnvOverride`. + // sanitized-basename-or-`projectIdFallback` viper default shows through + // instead of a file value, so the fallback belongs here, not as a third + // branch after `legacyEnvOverride`. // `SUPABASE_PROJECT_ID` is checked via the same `legacyEnvOverride` precedence // every other field here uses, since Viper's `AutomaticEnv` binds it too // (`config.go:529-535`) and it can turn an explicit-empty file value (or an // unsanitizable basename fallback) back into a valid override. const resolvedProjectId = legacyEnvOverride( "SUPABASE_PROJECT_ID", - config.project_id ?? legacySanitizeProjectId(basename(workdir)), + config.project_id ?? + (projectIdFallback !== undefined && projectIdFallback.length > 0 + ? projectIdFallback + : legacySanitizeProjectId(basename(workdir))), projectEnvValues, ); @@ -2971,6 +2995,12 @@ export function legacyResolveLocalConfigValues( gcpProjectId: gcpProjectId ?? "", gcpProjectNumber: gcpProjectNumber ?? "", gcpJwtPath: gcpJwtPath ?? "", + // Sanitized here (not above, in `input.projectId`) — `legacyValidateResolvedConfig`'s check is + // presence-only and must see the raw value to reject an explicit `project_id = ""` before any + // fallback; every OTHER reader of `Config.ProjectId` (Docker resource naming, labels) needs Go's + // post-`Validate` sanitized singleton (`config.go:938-944`). + projectId: legacySanitizeProjectId(resolvedProjectId ?? ""), + edgeRuntimeDenoVersion: denoVersion, }; } diff --git a/apps/cli/src/legacy/shared/legacy-local-project-context.ts b/apps/cli/src/legacy/shared/legacy-local-project-context.ts index 62341fc56a..947d8f5c8c 100644 --- a/apps/cli/src/legacy/shared/legacy-local-project-context.ts +++ b/apps/cli/src/legacy/shared/legacy-local-project-context.ts @@ -53,6 +53,17 @@ export interface LegacyLocalProjectContext { export const legacyLoadLocalProjectContext = ( workdir: string, mapConfigLoadError: (message: string) => E, + /** + * Go's `Eject` default (`pkg/config/config.go:561-570`): `flags.LoadConfig` + * pre-sets `Config.ProjectId = ProjectRef` before merging the file, so + * `Eject`'s own basename fallback only triggers when that default is + * itself empty. `undefined` for `start`/`stop`/`status`, which have no + * such flag — unchanged behavior for those callers. Also threaded into + * `loadProjectConfig`'s `projectRef` option, so a `[remotes.]` block + * merges over the base config, matching Go's `loadFromFile` with + * `Config.ProjectId` already set. + */ + projectRef?: string, ): Effect.Effect => Effect.gen(function* () { // `search: false`: `workdir` already IS Go's fully-resolved chdir target (`legacy-cli-config. @@ -96,6 +107,7 @@ export const legacyLoadLocalProjectContext = ( // via the workdir basename default. Only a malformed file (`loadProjectConfig` failing rather // than returning `null`) is a hard error. const loaded = yield* loadProjectConfig(workdir, { + ...(projectRef === undefined ? {} : { projectRef }), projectEnv: projectEnv !== null ? { ...projectEnv, values: projectEnvValues } : undefined, search: false, // Go's `NewPathBuilder`/`Config.Load` (`pkg/config/utils.go:43-48`) only ever resolves @@ -114,6 +126,7 @@ export const legacyLoadLocalProjectContext = ( projectEnvValues["SUPABASE_PROJECT_ID"] ?? process.env["SUPABASE_PROJECT_ID"], config.project_id, workdir, + projectRef, ), ); diff --git a/apps/cli/src/next/commands/functions/deploy/deploy.handler.ts b/apps/cli/src/next/commands/functions/deploy/deploy.handler.ts index 8d31bfaaf6..300403b634 100644 --- a/apps/cli/src/next/commands/functions/deploy/deploy.handler.ts +++ b/apps/cli/src/next/commands/functions/deploy/deploy.handler.ts @@ -26,7 +26,7 @@ export const functionsDeploy = Effect.fn("functions.deploy")(function* ( projectRoot: projectHome.projectRoot, supabaseDir: projectHome.supabaseDir, dashboardUrl: cliConfig.dashboardUrl, - goViperCompat: false, + goConfigCompat: undefined, yes: flags.yes, rawArgs, edgeRuntimeVersion, diff --git a/apps/cli/src/next/commands/functions/deploy/deploy.integration.test.ts b/apps/cli/src/next/commands/functions/deploy/deploy.integration.test.ts index ea8fe70489..29e841dd70 100644 --- a/apps/cli/src/next/commands/functions/deploy/deploy.integration.test.ts +++ b/apps/cli/src/next/commands/functions/deploy/deploy.integration.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; import { makeApiClient, FunctionResponse } from "@supabase/api/effect"; +import { DEFAULT_VERSIONS } from "@supabase/stack/effect"; import { BunServices } from "@effect/platform-bun"; import { createHash } from "node:crypto"; import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; @@ -1669,16 +1670,23 @@ describe("functions deploy", () => { useDocker: true, }).pipe(Effect.provide(layer)); - expect(child.spawned).toHaveLength(4); + expect(child.spawned).toHaveLength(5); expect(child.spawned[0]).toEqual({ command: "docker", args: ["info"], }); + // Go: `DockerStart` -> `DockerResolveImageIfNotCached` — resolved + // before the network/volume ensure; `deno_version = 1` pins + // `DENO1_EDGE_RUNTIME_VERSION` ("1.68.4"). expect(child.spawned[1]).toEqual({ command: "docker", - args: ["network", "inspect", "supabase_network_test-project"], + args: ["image", "inspect", "public.ecr.aws/supabase/edge-runtime:v1.68.4"], }); expect(child.spawned[2]).toEqual({ + command: "docker", + args: ["network", "inspect", "supabase_network_test-project"], + }); + expect(child.spawned[3]).toEqual({ command: "docker", args: [ "volume", @@ -1957,7 +1965,7 @@ describe("functions deploy", () => { path: `/v1/projects/${PROJECT_REF}/functions/hello-world`, }); expect(api.requests[1]?.urlParams).not.toContain("name="); - expect(child.spawned).toHaveLength(4); + expect(child.spawned).toHaveLength(5); }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); }, ); @@ -2174,7 +2182,7 @@ describe("functions deploy", () => { useDocker: true, }).pipe(Effect.provide(layer)); - expect(child.spawned).toHaveLength(4); + expect(child.spawned).toHaveLength(5); expect(child.spawned.at(-1)?.args).toContain( yield* Effect.promise(() => expectedDockerBind(staticFile)), ); @@ -2569,4 +2577,120 @@ describe("functions deploy", () => { }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); }); }); + + describe("Go's Config.Validate/env-override parity is legacy-only (CLI-1963)", () => { + it.live( + "does not fail on an explicit empty project_id, unlike the legacy shell's Config.Validate", + () => { + const tempDir = makeTempDir(); + + return Effect.gen(function* () { + yield* Effect.promise(() => writeProjectConfig(tempDir, 'project_id = ""\n')); + yield* Effect.promise(() => writeLocalFunction(tempDir, "hello-world")); + + const { out, layer } = setup(tempDir, { + rawArgs: ["functions", "deploy", "hello-world"], + }); + + yield* functionsDeploy({ + ...BASE_FLAGS, + functionNames: ["hello-world"], + }).pipe(Effect.provide(layer)); + + expect(out.stdoutText).toContain( + `Deployed Functions on project ${PROJECT_REF}: hello-world\n`, + ); + }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); + }, + ); + + it.live( + "does not fail on an unrelated Config.Validate branch (unsupported Postgres major version)", + () => { + const tempDir = makeTempDir(); + + return Effect.gen(function* () { + yield* Effect.promise(() => + writeProjectConfig( + tempDir, + ['project_id = "test-project"', "", "[db]", "major_version = 12", ""].join("\n"), + ), + ); + yield* Effect.promise(() => writeLocalFunction(tempDir, "hello-world")); + + const { out, layer } = setup(tempDir, { + rawArgs: ["functions", "deploy", "hello-world"], + }); + + yield* functionsDeploy({ + ...BASE_FLAGS, + functionNames: ["hello-world"], + }).pipe(Effect.provide(layer)); + + expect(out.stdoutText).toContain( + `Deployed Functions on project ${PROJECT_REF}: hello-world\n`, + ); + }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); + }, + ); + + it.live( + "ignores SUPABASE_EDGE_RUNTIME_DENO_VERSION and resolves the default edge-runtime image tag", + () => { + const tempDir = makeTempDir(); + const child = mockChildProcessSpawner({ + exitCode: 0, + onSpawn: (record) => { + if (record.command !== "docker" || record.args[0] !== "run") { + return; + } + const outputPath = resolveDockerOutputPath(record.args); + mkdirSync(dirname(outputPath), { recursive: true }); + writeFileSync(outputPath, "eszip-test-output"); + }, + }); + + const previous = process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"]; + process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = "1"; + + return Effect.gen(function* () { + yield* Effect.promise(() => writeProjectConfig(tempDir)); + yield* Effect.promise(() => writeLocalFunction(tempDir, "hello-world")); + + const { layer } = setup(tempDir, { + rawArgs: ["functions", "deploy", "hello-world", "--use-docker"], + childLayer: child.layer, + }); + + yield* functionsDeploy({ + ...BASE_FLAGS, + functionNames: ["hello-world"], + useDocker: true, + }).pipe(Effect.provide(layer)); + + // `docker info` is spawned[0]; the bundler's first image-inspect + // candidate (a cache hit here) is spawned[1]. + expect(child.spawned[1]).toEqual({ + command: "docker", + args: [ + "image", + "inspect", + `public.ecr.aws/supabase/edge-runtime:v${DEFAULT_VERSIONS["edge-runtime"]}`, + ], + }); + }).pipe( + Effect.ensuring(cleanupTempDir(tempDir)), + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) { + delete process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"]; + } else { + process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = previous; + } + }), + ), + ); + }, + ); + }); }); diff --git a/apps/cli/src/next/commands/functions/download/download.handler.ts b/apps/cli/src/next/commands/functions/download/download.handler.ts index bb63eaf913..e1385f4cf4 100644 --- a/apps/cli/src/next/commands/functions/download/download.handler.ts +++ b/apps/cli/src/next/commands/functions/download/download.handler.ts @@ -22,7 +22,7 @@ export const functionsDownload = Effect.fnUntraced(function* (flags: FunctionsDo api, projectRoot: projectHome.projectRoot, rawArgs, - goViperCompat: false, + goConfigCompat: undefined, edgeRuntimeVersion, resolveProjectRef, // In machine-output mode the child's stdout is captured and discarded diff --git a/apps/cli/src/next/commands/functions/download/download.integration.test.ts b/apps/cli/src/next/commands/functions/download/download.integration.test.ts index cfe88deb0d..f740edf061 100644 --- a/apps/cli/src/next/commands/functions/download/download.integration.test.ts +++ b/apps/cli/src/next/commands/functions/download/download.integration.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; import { FunctionResponse, makeApiClient } from "@supabase/api/effect"; +import { DEFAULT_VERSIONS } from "@supabase/stack/effect"; import { existsSync, mkdtempSync } from "node:fs"; import { mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -1903,4 +1904,119 @@ describe("functions download", () => { Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), ); }); + + describe("Go's Config.Validate/env-override parity is legacy-only (CLI-1963)", () => { + it.live( + "does not fail on an explicit empty project_id, unlike the legacy shell's Config.Validate", + () => { + const tempDir = makeTempDir(); + const child = mockChildProcessSpawner({ exitCode: 0 }); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => mkdir(join(tempDir, "supabase"), { recursive: true })); + yield* Effect.tryPromise(() => + writeFile(join(tempDir, "supabase", "config.toml"), 'project_id = ""\n'), + ); + const { out, layer, proxy } = setup(tempDir, { + bodyBySlug: { + "hello-world": { body: "fake-eszip-bytes", contentType: "application/octet-stream" }, + }, + rawArgs: ["functions", "download", "hello-world", "--use-docker"], + childLayer: child.layer, + }); + + yield* functionsDownload({ ...BASE_FLAGS, useDocker: true }).pipe(Effect.provide(layer)); + + expect(proxy.calls).toEqual([]); + expect( + child.spawned.some( + (spawned) => spawned.command === "docker" && spawned.args[0] === "run", + ), + ).toBe(true); + expect(out.stderrText).toContain("Downloading function: hello-world\n"); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }, + ); + + it.live( + "does not fail on an unrelated Config.Validate branch (unsupported Postgres major version)", + () => { + const tempDir = makeTempDir(); + const child = mockChildProcessSpawner({ exitCode: 0 }); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => mkdir(join(tempDir, "supabase"), { recursive: true })); + yield* Effect.tryPromise(() => + writeFile( + join(tempDir, "supabase", "config.toml"), + ['project_id = "test-project"', "", "[db]", "major_version = 12", ""].join("\n"), + ), + ); + const { out, layer, proxy } = setup(tempDir, { + bodyBySlug: { + "hello-world": { body: "fake-eszip-bytes", contentType: "application/octet-stream" }, + }, + rawArgs: ["functions", "download", "hello-world", "--use-docker"], + childLayer: child.layer, + }); + + yield* functionsDownload({ ...BASE_FLAGS, useDocker: true }).pipe(Effect.provide(layer)); + + expect(proxy.calls).toEqual([]); + expect( + child.spawned.some( + (spawned) => spawned.command === "docker" && spawned.args[0] === "run", + ), + ).toBe(true); + expect(out.stderrText).toContain("Downloading function: hello-world\n"); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }, + ); + + it.live( + "ignores SUPABASE_EDGE_RUNTIME_DENO_VERSION and resolves the default edge-runtime image tag", + () => { + const tempDir = makeTempDir(); + const child = mockChildProcessSpawner({ exitCode: 0 }); + const previous = process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"]; + process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = "1"; + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); + const { layer, proxy } = setup(tempDir, { + bodyBySlug: { + "hello-world": { body: "fake-eszip-bytes", contentType: "application/octet-stream" }, + }, + rawArgs: ["functions", "download", "hello-world", "--use-docker"], + childLayer: child.layer, + }); + + yield* functionsDownload({ ...BASE_FLAGS, useDocker: true }).pipe(Effect.provide(layer)); + + expect(proxy.calls).toEqual([]); + const runCommand = child.spawned.find( + (spawned) => spawned.command === "docker" && spawned.args[0] === "run", + ); + expect(runCommand?.args).toContain( + `public.ecr.aws/supabase/edge-runtime:v${DEFAULT_VERSIONS["edge-runtime"]}`, + ); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) { + delete process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"]; + } else { + process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = previous; + } + }), + ), + ); + }, + ); + }); }); diff --git a/apps/cli/src/shared/cli/cobra-flag-groups.ts b/apps/cli/src/shared/cli/cobra-flag-groups.ts index 88d80c350c..1829f16869 100644 --- a/apps/cli/src/shared/cli/cobra-flag-groups.ts +++ b/apps/cli/src/shared/cli/cobra-flag-groups.ts @@ -35,12 +35,14 @@ export function hasExplicitLongFlag( * {@link explicitBooleanLongFlag} and `legacyPflagStringValue` already * follow) — a repeated `-- old -- new` must resolve to * `new`, so this keeps scanning after a match instead of returning early - * (review round on CLI-1963's `functions download` port). Not exported — - * every current call site needs Go's `len(value) > 0` gate too (see - * {@link explicitNonEmptyStringFlag}); re-export this directly if a future - * caller genuinely needs presence-only semantics. + * (review round on CLI-1963's `functions download` port). Preserves the + * 3-way distinction `undefined` (flag never passed) / `""` (explicit + * `--=`) / non-empty value — callers that need to distinguish + * "flag explicitly cleared" from "flag never touched" (e.g. + * `resolveDockerNetworkMode`'s env-fallback precedence — see its doc + * comment) need this rather than collapsing both to `undefined`. */ -function explicitStringFlag(rawArgs: ReadonlyArray, flagName: string) { +export function explicitStringFlag(rawArgs: ReadonlyArray, flagName: string) { let result: string | undefined; for (let index = 0; index < rawArgs.length; index += 1) { const token = rawArgs[index]; @@ -53,21 +55,6 @@ function explicitStringFlag(rawArgs: ReadonlyArray, flagName: string) { return result; } -/** - * Same as {@link explicitStringFlag}, but treats an explicit empty value - * (`--=`) as unset — matching Go call sites that gate on - * `len(viper.GetString(flagName)) > 0` rather than mere presence (e.g. - * `--network-id`, `apps/cli-go/internal/utils/docker.go:379-382`). pflag - * still marks the flag `Changed` for `--network-id=`, but Go's own - * `if networkId := viper.GetString("network-id"); len(networkId) > 0` - * falls through to the generated network name for that value just like an - * omitted flag would (review round on CLI-1963's `functions download` port). - */ -export function explicitNonEmptyStringFlag(rawArgs: ReadonlyArray, flagName: string) { - const value = explicitStringFlag(rawArgs, flagName); - return value !== undefined && value.length > 0 ? value : undefined; -} - /** * Whether `--` (or `--=`) appears anywhere in argv, * unscoped. diff --git a/apps/cli/src/shared/functions/deploy.ts b/apps/cli/src/shared/functions/deploy.ts index 9ad0320e8e..739b62cb27 100644 --- a/apps/cli/src/shared/functions/deploy.ts +++ b/apps/cli/src/shared/functions/deploy.ts @@ -5,7 +5,6 @@ import { URL } from "node:url"; import { FunctionResponse, operationDefinitions, type ApiClient } from "@supabase/api/effect"; import { inferFunctionsManifest, - loadProjectConfig, type ResolvedFunctionConfig as ManifestFunctionConfig, } from "@supabase/config"; import { Duration, Effect, Option, Schema } from "effect"; @@ -14,11 +13,11 @@ import { legacyPromptYesNo } from "../legacy/legacy-prompt-yes-no.ts"; import { CONTEXT_CANCELED_MESSAGE } from "../output/errors.ts"; import { Output } from "../output/output.service.ts"; import { legacyBold } from "../../legacy/shared/legacy-colors.ts"; -import { legacyGetRegistryImageUrl } from "../../legacy/shared/legacy-docker-registry.ts"; +import { legacyViperEnvStringWithProjectFallback } from "../legacy/legacy-viper-env.ts"; import { findGitRootPath } from "../git/git-root.ts"; import { cobraMutuallyExclusiveErrorMessage, - explicitNonEmptyStringFlag, + explicitStringFlag, hasExplicitLongFlag, hasGlobalLongFlag, } from "../cli/cobra-flag-groups.ts"; @@ -34,16 +33,20 @@ import { NoFunctionsToDeployError, } from "./deploy.errors.ts"; import { + buildFunctionsDockerRunArgs, edgeRuntimeImageTag, ensureDockerNamedVolume, ensureDockerNetwork, isDockerRunning, localDockerId, + resolveDockerNetworkMode, resolveEdgeRuntimeVersion, + resolveFunctionsDockerImage, runChildProcess, toDockerPath, toSlash, } from "./functions-docker.ts"; +import { loadFunctionsProjectConfig, type FunctionsGoConfigCompat } from "./functions-config.ts"; const COMPRESSED_ESZIP_MAGIC = "EZBR"; const DEPLOY_RATE_LIMIT_MAX_RETRIES = 8; @@ -72,7 +75,12 @@ interface DeployFunctionsDependencies { readonly projectRoot: string; readonly supabaseDir: string; readonly dashboardUrl: string; - readonly goViperCompat: boolean; + /** + * `undefined` in `next`; the legacy shell injects + * `legacyFunctionsGoConfigCompat` so this file never imports `legacy/` + * directly — see {@link FunctionsGoConfigCompat}. + */ + readonly goConfigCompat: FunctionsGoConfigCompat | undefined; readonly yes?: boolean; readonly rawArgs: ReadonlyArray; readonly edgeRuntimeVersion: string; @@ -86,9 +94,12 @@ interface DeployFunctionsDependencies { * - `styleIdentifier`: the project ref in the stdout success line. * - `styleEmphasis`: the slug in the stderr `Bundling Function:` line and * the functions dir in the no-functions error. + * - `styleWarning`: the `WARNING:` token on the "Docker is not running" + * fallback line. Go: `utils.Yellow("WARNING:")` (`deploy.go:60`). */ readonly styleIdentifier?: (text: string) => string; readonly styleEmphasis?: (text: string) => string; + readonly styleWarning?: (text: string) => string; } export interface ResolvedDeployFunctionConfig { @@ -1233,15 +1244,31 @@ async function shouldUsePackageJsonDiscovery(entrypoint: string, importMap: stri } } +interface BundleFunctionWithDockerOptions { + readonly projectId: string; + readonly edgeRuntimeVersion: string; + readonly functionsDir: string; + readonly config: ResolvedDeployFunctionConfig; + /** Already resolved (explicit flag > `SUPABASE_NETWORK_ID` > generated) — see the caller. */ + readonly networkMode: string; + readonly verbose?: boolean; + readonly styleEmphasis?: (text: string) => string; + readonly projectEnvValues?: Readonly>; +} + const bundleFunctionWithDocker = Effect.fnUntraced(function* ( - projectId: string, - edgeRuntimeVersion: string, - functionsDir: string, - config: ResolvedDeployFunctionConfig, - dockerNetworkId?: string, - verbose = false, - styleEmphasis: (text: string) => string = (text) => text, + options: BundleFunctionWithDockerOptions, ) { + const { + projectId, + edgeRuntimeVersion, + functionsDir, + config, + networkMode, + verbose = false, + styleEmphasis = (text: string) => text, + projectEnvValues, + } = options; const output = yield* Output; // Go: `fmt.Fprintln(os.Stderr, "Bundling Function:", utils.Bold(slug))` // (`internal/functions/deploy/bundle.go:30`) — the legacy handler injects @@ -1259,58 +1286,68 @@ const bundleFunctionWithDocker = Effect.fnUntraced(function* ( const binds = yield* Effect.promise(() => buildDockerBinds(projectId, functionsDir, outputDir, config), ); - const networkMode = dockerNetworkId ?? localDockerId("network", projectId); + // Go: `DockerStart` -> `DockerResolveImageIfNotCached` (`internal/utils/docker.go:326-386`) + // — resolves ECR->GHCR->Docker-Hub candidates and pulls with retry, per + // container, before ever touching the network/volume. + const image = yield* resolveFunctionsDockerImage( + // `edgeRuntimeImageTag`, not a bare `v${edgeRuntimeVersion}` prepend — + // `edgeRuntimeVersion` can come from a `.temp/edge-runtime-version` pin + // that's already `v`-prefixed (see the helper's doc in + // `functions-docker.ts`); blindly prepending `v` double-prefixes it. + `supabase/edge-runtime:${edgeRuntimeImageTag(edgeRuntimeVersion)}`, + projectEnvValues, + ); yield* ensureDockerNetwork(networkMode, projectId); yield* ensureDockerNamedVolume(localDockerId("edge_runtime", projectId), projectId); - const command = ["run", "--rm", ...binds.flatMap((bind) => ["-v", bind])]; - command.push("--network", networkMode); - if (process.platform === "linux") { - command.push("--add-host", "host.docker.internal:host-gateway"); - } + const env: Array = []; if ( !(yield* Effect.promise(() => shouldUsePackageJsonDiscovery(config.entrypoint, config.importMap), )) ) { - command.push("-e", "DENO_NO_PACKAGE_JSON=1"); - } - for (const env of dockerNpmEnv()) { - command.push("-e", env); + env.push("DENO_NO_PACKAGE_JSON=1"); } + env.push(...dockerNpmEnv()); - command.push( - // `edgeRuntimeImageTag`, not a bare `v${edgeRuntimeVersion}` prepend — - // `edgeRuntimeVersion` can come from a `.temp/edge-runtime-version` pin - // that's already `v`-prefixed (see the helper's doc in - // `functions-docker.ts`); blindly prepending `v` double-prefixes it. - legacyGetRegistryImageUrl(`supabase/edge-runtime:${edgeRuntimeImageTag(edgeRuntimeVersion)}`), + const containerArgs = [ "bundle", "--entrypoint", toDockerPath(config.entrypoint), "--output", toDockerPath(outputPath), - ); + ]; if ( config.importMap.length > 0 && !shouldUseDenoJsonDiscovery(config.entrypoint, config.importMap) ) { - command.push("--import-map", toDockerPath(config.importMap)); + containerArgs.push("--import-map", toDockerPath(config.importMap)); } for (const staticFile of config.staticFiles) { - command.push("--static", toDockerPath(staticFile)); + containerArgs.push("--static", toDockerPath(staticFile)); } if (verbose || process.env["DEBUG"] === "true") { - command.push("--verbose"); + containerArgs.push("--verbose"); } - const result = yield* runChildProcess("docker", command, { stdout: "pipe", stderr: "pipe" }); - if (result.stdout.length > 0) { - yield* output.raw(result.stdout, output.format === "text" ? "stdout" : "stderr"); - } - if (result.stderr.length > 0) { - yield* output.raw(result.stderr, "stderr"); - } + const command = buildFunctionsDockerRunArgs({ + image, + projectId, + networkMode, + binds, + env, + containerArgs, + }); + + // Live-tees each chunk to `output.raw` as it arrives (Go's + // `DockerRunOnceWithConfig` copies the container's log stream live) + // rather than buffering the whole run until exit. + const result = yield* runChildProcess("docker", command, { + stdout: "pipe", + stderr: "pipe", + onStdout: (chunk) => output.raw(chunk, output.format === "text" ? "stdout" : "stderr"), + onStderr: (chunk) => output.raw(chunk, "stderr"), + }); if (result.exitCode !== 0) { return yield* Effect.fail(new Error(`failed to bundle function: exit ${result.exitCode}`)); } @@ -1948,17 +1985,33 @@ const deployViaApi = Effect.fnUntraced(function* ( } }); -const deployViaDocker = Effect.fnUntraced(function* ( - projectId: string, - projectRef: string, - edgeRuntimeVersion: string, - functionsDir: string, - configs: ReadonlyArray, - api: ApiClient, - dockerNetworkId?: string, - verbose = false, - styleEmphasis: (text: string) => string = (text) => text, -) { +interface DeployViaDockerOptions { + readonly projectId: string; + readonly projectRef: string; + readonly edgeRuntimeVersion: string; + readonly functionsDir: string; + readonly configs: ReadonlyArray; + readonly api: ApiClient; + /** Already resolved (explicit flag > `SUPABASE_NETWORK_ID` > generated) — see the caller. */ + readonly networkMode: string; + readonly verbose?: boolean; + readonly styleEmphasis?: (text: string) => string; + readonly projectEnvValues?: Readonly>; +} + +const deployViaDocker = Effect.fnUntraced(function* (options: DeployViaDockerOptions) { + const { + projectId, + projectRef, + edgeRuntimeVersion, + functionsDir, + configs, + api, + networkMode, + verbose = false, + styleEmphasis = (text: string) => text, + projectEnvValues, + } = options; const output = yield* Output; const remoteFunctions = yield* listRemoteFunctions(api, projectRef); const remoteBySlug = new Map(remoteFunctions.map((fn) => [fn.slug, fn])); @@ -1970,15 +2023,16 @@ const deployViaDocker = Effect.fnUntraced(function* ( continue; } - const bundled = yield* bundleFunctionWithDocker( + const bundled = yield* bundleFunctionWithDocker({ projectId, edgeRuntimeVersion, functionsDir, config, - dockerNetworkId, + networkMode, verbose, styleEmphasis, - ); + projectEnvValues, + }); const current = remoteBySlug.get(config.slug); if ( current?.ezbr_sha256 === bundled.metadata.sha256 && @@ -2094,10 +2148,21 @@ export function deployFunctions( return yield* Effect.fail(new Error("--jobs must be used together with --use-api")); } - const preResolvedProjectRef = - flags.functionNames.length > 0 - ? yield* dependencies.resolveProjectRef(flags.projectRef) - : undefined; + const projectRef = yield* dependencies.resolveProjectRef(flags.projectRef); + // `@supabase/config` merges the matching `[remotes.*]` block over the base + // config (Go's `loadFromFile` with `Config.ProjectId` set), so the resolved + // config already reflects any remote function/edge_runtime overrides. + // In the legacy shell this also runs the same `Config.Validate`/dotenv/ + // env-override pipeline `start`/`stop`/`status` already go through — see + // `functions-config.ts`. Go: `flags.LoadConfig` runs before validating any + // slug (`deploy.go:22-28`), so this must precede the loop below too — an + // invalid `config.toml` is reported ahead of a malformed slug when both + // are wrong (review round on CLI-1963). + const context = yield* loadFunctionsProjectConfig({ + projectRoot: dependencies.projectRoot, + projectRef, + goConfigCompat: dependencies.goConfigCompat, + }); if (flags.functionNames.length > 0) { for (const slug of flags.functionNames) { @@ -2112,18 +2177,9 @@ export function deployFunctions( flags.noVerifyJwt, ); const debugEnabled = hasGlobalLongFlag(dependencies.rawArgs, "debug"); - const projectRef = - preResolvedProjectRef ?? (yield* dependencies.resolveProjectRef(flags.projectRef)); - // `@supabase/config` merges the matching `[remotes.*]` block over the base - // config (Go's `loadFromFile` with `Config.ProjectId` set), so the resolved - // config already reflects any remote function/edge_runtime overrides. - const loadedConfig = yield* loadProjectConfig(dependencies.projectRoot, { - projectRef, - goViperCompat: dependencies.goViperCompat, - }); - const deployConfig = loadedConfig?.config; + const deployConfig = context.loaded?.config; const edgeRuntimeVersion = yield* resolveEdgeRuntimeVersion( - deployConfig?.edge_runtime.deno_version, + context.denoVersion, dependencies.edgeRuntimeVersion, ); const configFunctions = yield* inferFunctionsManifest({ @@ -2131,7 +2187,7 @@ export function deployFunctions( config: deployConfig, }); const configDeclaredFunctions = deployConfig?.functions ?? {}; - const rawConfigFunctions = rawFunctionConfigRecord(loadedConfig?.document); + const rawConfigFunctions = rawFunctionConfigRecord(context.loaded?.document); yield* validateConfigFunctionSlugs(configDeclaredFunctions); const slugs = flags.functionNames.length > 0 @@ -2191,31 +2247,43 @@ export function deployFunctions( ), ); + const styleWarning = dependencies.styleWarning ?? ((text: string) => text); const deployed = useLocalBundler ? yield* Effect.gen(function* () { if (!(yield* isDockerRunning())) { - yield* output.raw("WARNING: Docker is not running\n", "stderr"); + yield* output.raw(`${styleWarning("WARNING:")} Docker is not running\n`, "stderr"); return yield* deployWithApi; } - const projectId = deployConfig?.project_id ?? projectRef; - yield* deployViaDocker( - projectId, + // `explicitStringFlag` preserves the "explicitly cleared" vs + // "never touched" distinction `resolveDockerNetworkMode` needs to + // decide whether `SUPABASE_NETWORK_ID` applies — see that + // function's own doc comment. `SUPABASE_NETWORK_ID` (env or + // project dotenv) is legacy-shell-only — same Go-viper-parity gate + // as `context.projectEnvValues` itself (`undefined` in `next`). + const networkMode = resolveDockerNetworkMode({ + explicit: explicitStringFlag(dependencies.rawArgs, "network-id"), + envOverride: + context.projectEnvValues === undefined + ? undefined + : legacyViperEnvStringWithProjectFallback( + "SUPABASE_NETWORK_ID", + context.projectEnvValues, + ), + projectId: context.projectId, + }); + yield* deployViaDocker({ + projectId: context.projectId, projectRef, edgeRuntimeVersion, - join(dependencies.projectRoot, SUPABASE_FUNCTIONS_DIR), + functionsDir: join(dependencies.projectRoot, SUPABASE_FUNCTIONS_DIR), configs, - dependencies.api, - // Go only treats `--network-id` as an override when - // `len(viper.GetString("network-id")) > 0` - // (`internal/utils/docker.go:379-382`) — an explicit-but-empty - // `--network-id=` must fall through to the generated network - // name (`dockerNetworkId?: string` → `undefined`) just like an - // omitted flag. - explicitNonEmptyStringFlag(dependencies.rawArgs, "network-id"), - debugEnabled, + api: dependencies.api, + networkMode, + verbose: debugEnabled, styleEmphasis, - ); + projectEnvValues: context.projectEnvValues, + }); return true; }) : yield* deployWithApi; diff --git a/apps/cli/src/shared/functions/download.ts b/apps/cli/src/shared/functions/download.ts index 2d849fd4bd..88e6624b0a 100644 --- a/apps/cli/src/shared/functions/download.ts +++ b/apps/cli/src/shared/functions/download.ts @@ -1,5 +1,4 @@ import { operationDefinitions, type ApiClient } from "@supabase/api/effect"; -import { loadProjectConfig } from "@supabase/config"; import { randomUUID } from "node:crypto"; import { mkdir, open, rename, rm, writeFile } from "node:fs/promises"; import { dirname, isAbsolute, join, posix, relative, resolve, sep } from "node:path"; @@ -11,20 +10,24 @@ import { Output } from "../output/output.service.ts"; import { cobraMutuallyExclusiveErrorMessage, explicitBooleanLongFlag, - explicitNonEmptyStringFlag, + explicitStringFlag, hasExplicitLongFlag, } from "../cli/cobra-flag-groups.ts"; import { legacyDescribeContainerCliFailure } from "../../legacy/shared/legacy-container-cli.ts"; -import { legacyGetRegistryImageUrl } from "../../legacy/shared/legacy-docker-registry.ts"; +import { legacyViperEnvStringWithProjectFallback } from "../legacy/legacy-viper-env.ts"; import { + buildFunctionsDockerRunArgs, edgeRuntimeImageTag, ensureDockerNamedVolume, ensureDockerNetwork, isDockerRunning, localDockerId, + resolveDockerNetworkMode, resolveEdgeRuntimeVersion, + resolveFunctionsDockerImage, runChildProcess, } from "./functions-docker.ts"; +import { loadFunctionsProjectConfig, type FunctionsGoConfigCompat } from "./functions-config.ts"; import { FUNCTIONS_BUNDLER_MUTEX_GROUP, invalidFunctionSlugDetail, @@ -77,6 +80,12 @@ interface DownloadDockerRuntimeDependencies extends DownloadRuntimeDependencies * (`suggestLegacyBundle`, `download.go:315`). */ readonly styleAqua?: (text: string) => string; + /** + * Optional shell-specific styling hook for the `WARNING:` token on the + * "Docker is not running" fallback line — same isolation rationale as + * {@link styleEmphasis}. Go: `utils.Yellow("WARNING:")` (`download.go:146`). + */ + readonly styleWarning?: (text: string) => string; } /** @@ -87,11 +96,11 @@ interface DownloadDockerRuntimeDependencies extends DownloadRuntimeDependencies interface EdgeRuntimeImageDependencies { readonly projectRoot: string; /** - * `true` in the legacy shell, `false` in `next` — forwarded verbatim to - * `loadProjectConfig`'s `goViperCompat` option (matches every other - * `functions`-family command, e.g. `deploy.ts`'s own `DeployFunctionsDependencies`). + * `undefined` in `next`; the legacy shell injects + * `legacyFunctionsGoConfigCompat` so this file never imports `legacy/` + * directly — see {@link FunctionsGoConfigCompat}. */ - readonly goViperCompat: boolean; + readonly goConfigCompat: FunctionsGoConfigCompat | undefined; /** * Fallback edge-runtime image tag used when the project config doesn't pin * `edge_runtime.deno_version` to `1` (which forces the older @@ -901,125 +910,57 @@ function withDockerStepFailure(step: string, slug: string, styleAqua?: (text: st // from `edge_runtime.deno_version` — `1` pins the older // `DENO1_EDGE_RUNTIME_VERSION`, anything else (including unset) uses the // project's configured/default tag (`resolveEdgeRuntimeVersion`, shared with -// `deploy.ts`). `project_id` mirrors `deploy.ts`'s own -// `deployConfig?.project_id ?? projectRef` fallback for Docker network/volume -// naming (`GetId`, `internal/utils/config.go:57-58`). Resolved once per -// invocation by the caller (`downloadFunctions`), not once per slug — Go's -// `Config` is likewise loaded once, before any per-function work. +// `deploy.ts`). Resolved once per invocation by the caller +// (`downloadFunctions`), not once per slug — Go's `Config` is likewise loaded +// once, before any per-function work. `loadFunctionsProjectConfig` (legacy +// shell only) runs the same `Config.Validate`/dotenv/env-override pipeline +// `start`/`stop`/`status` already go through — see `functions-config.ts`. const resolveEdgeRuntimeImage = Effect.fnUntraced(function* ( dependencies: EdgeRuntimeImageDependencies, projectRef: string, ) { - const loadedConfig = yield* loadProjectConfig(dependencies.projectRoot, { + const context = yield* loadFunctionsProjectConfig({ + projectRoot: dependencies.projectRoot, projectRef, - goViperCompat: dependencies.goViperCompat, - // `search: false`/`tomlOnly: true` only under `goViperCompat` (the legacy caller, whose - // `dependencies.projectRoot` is `cliConfig.workdir` — already Go's fully-resolved chdir - // target, same reasoning as `legacy-local-project-context.ts`/`start.handler.ts`). Go's - // `flags.LoadConfig` (`pkg/config/utils.go:43-48`) only ever resolves `supabase/config.toml` - // from that exact workdir, with no ancestor climb and no concept of a JSON project config — - // leaving these unset here would let an unrelated ancestor project's config win, or a stray - // `supabase/config.json` be preferred over `config.toml`, for the legacy shell's Docker - // download path specifically. The `next` shell keeps the package defaults (ancestor search, - // JSON preferred), matching its other non-Go-parity `loadProjectConfig` callers. - search: dependencies.goViperCompat ? false : undefined, - tomlOnly: dependencies.goViperCompat, + goConfigCompat: dependencies.goConfigCompat, }); - // A project with no `supabase/config.toml`/`config.json` makes - // `loadProjectConfig` return `null` outright, so `denoVersion` below falls - // through to `undefined` and this always resolves the v2 default. Go's - // `flags.LoadConfig` never short-circuits like that: `Config.Load` → - // `loadFromFile` (`pkg/config/config.go:579-611`) merges the template - // defaults and enables `viper.AutomaticEnv()` with `SetEnvPrefix("SUPABASE")` - // *before* attempting to read the file — `mergeFileConfig` (`config.go:701-716`) - // simply no-ops on `os.ErrNotExist` — so `SUPABASE_EDGE_RUNTIME_DENO_VERSION=1` - // (or the same key in `supabase/.env`, via `loadNestedEnv`) still pins the - // deno-v1 image even with no config.toml on disk. Pre-existing, not - // introduced by this PR: `@supabase/config`'s `loadProjectConfig` has no - // equivalent of Go's generic `ExperimentalBindStruct`+`AutomaticEnv` field - // binding at all (it only expands literal `env(...)` references already - // written inside the TOML), so `deploy.ts`'s identical - // `resolveEdgeRuntimeVersion(deployConfig?.edge_runtime.deno_version, ...)` - // call has the same gap whether or not config.toml exists. A fix belongs in - // the shared config-loading layer every native caller goes through (`gen - // types`, `next start`, `functions dev/serve/deploy`, …), not duplicated - // per call site here — left open (review round on CLI-1963's `functions - // download` port). - const denoVersion = loadedConfig?.config?.edge_runtime.deno_version; - // `?? projectRef` only substitutes on `null`/`undefined`, so a config.toml - // with an explicit `project_id = ""` still resolves to the empty string - // here (`supabase_network_`/`supabase_edge_runtime_`) instead of failing - // up front. Go's `Config.Validate` rejects that same config with "Missing - // required field in config: project_id" (`pkg/config/config.go:990-991`) - // before `flags.LoadConfig` ever returns to `Run` — before any Docker/API - // work. Pre-existing and cross-cutting, not introduced by this PR: - // `deploy.ts`'s identical `deployConfig?.project_id ?? projectRef` - // fallback (`deploy.ts:2201`) has the same gap, and no native `functions` - // Docker path (`deploy`, `serve`, `download`) routes a loaded config - // through `Config.Validate` parity checks at all — that port has one home - // today, `legacy-config-validate.ts`'s `legacyValidateResolvedConfig`, - // wired up only for the db/migration loader and the status/stop resolver. - // Wiring `Config.Validate` into every native config-consuming command - // belongs in the shared config-loading layer, not duplicated per - // Docker-path call site here — left open, same as the config-defaults gap - // above (review round on CLI-1963's `functions download` port). - const projectId = loadedConfig?.config?.project_id ?? projectRef; const edgeRuntimeVersion = yield* resolveEdgeRuntimeVersion( - denoVersion, + context.denoVersion, dependencies.edgeRuntimeVersion, ); return { - projectId, - denoVersion, + projectId: context.projectId, + denoVersion: context.denoVersion, // `edgeRuntimeImageTag` (not a bare `v${edgeRuntimeVersion}` prepend) — // `dependencies.edgeRuntimeVersion` comes from a `.temp/edge-runtime-version` // pin that may already carry its own `v` prefix (see the helper's doc). - // - // Single `legacyGetRegistryImageUrl` value, not the ECR→GHCR→Docker-Hub - // retry `legacyGetRegistryImageUrlCandidates` gives `start` (review round - // on CLI-1963's `functions download` port). Go's `DockerStart` resolves - // `config.Image` through `DockerResolveImageIfNotCached` - // (`internal/utils/docker.go:326-348,363-365`), which tries every - // registry candidate — including for this exact edge-runtime unbundle - // container — so an ECR outage/throttle that the previous Go-delegated - // default path would have survived can now fail this native path outright. - // Pre-existing, not introduced by this PR: `deploy.ts`'s and `serve.ts`'s - // own already-shipped native Docker paths resolve this identically - // (single-URL, no retry) — `legacyGetRegistryImageUrlCandidates` has only - // ever been wired up for `start` (see its own doc comment). Extending the - // retry to all three `functions` Docker paths is a shared, cross-cutting - // follow-up, not something to fix piecemeal for `download` alone. - // - // No `projectEnvValues` argument either (review round on CLI-1963's - // `functions download` port): Go's `flags.LoadConfig` → `loadNestedEnv` - // (`pkg/config/config.go:1220-1258`) calls `godotenv.Load` on every - // project dotenv file, which `os.Setenv`s each key into the process env - // — ambient-wins, but a `SUPABASE_INTERNAL_IMAGE_REGISTRY` set only in - // `supabase/.env` (not the ambient shell) is visible to `GetRegistry()`'s - // later `viper.GetString("INTERNAL_IMAGE_REGISTRY")` read - // (`internal/utils/docker.go:221-227`) regardless. `loadProjectConfig` - // above only uses its own dotenv read internally, for `env(...)` - // interpolation — it doesn't return the values, so this call falls back - // to `legacyGetRegistryOverride`'s ambient-only `process.env` read and - // misses a project-local registry mirror configured only via dotenv. - // Pre-existing and cross-cutting, not introduced by this PR: `deploy.ts` - // (`deploy.ts:1287`) and `serve.ts` (`serve.ts:1756`) call the same - // `legacyGetRegistryImageUrl` with no `projectEnvValues` either — the - // only caller that resolves and threads it today is `start`, via - // `legacyLoadLocalProjectContext`/`legacyGetRegistryImageUrlCandidates`. - // Loading project dotenv for every native `functions` Docker path belongs - // in the shared config-loading layer, not duplicated per call site here - // — left open, same treatment as the config-defaults/network-id-env/ - // Config.Validate gaps above. - image: legacyGetRegistryImageUrl( - `supabase/edge-runtime:${edgeRuntimeImageTag(edgeRuntimeVersion)}`, - ), + // Registry mapping + pull-with-retry happens per-container, right before + // `ensureDockerNetwork`, matching Go's `DockerStart` (see the caller). + rawImage: `supabase/edge-runtime:${edgeRuntimeImageTag(edgeRuntimeVersion)}`, + projectEnvValues: context.projectEnvValues, }; }); interface EdgeRuntimeImage { readonly projectId: string; readonly denoVersion: number | undefined; + /** Not yet registry-mapped/pull-resolved — see {@link resolveFunctionsDockerImage}. */ + readonly rawImage: string; + readonly projectEnvValues: Readonly> | undefined; +} + +/** + * `EdgeRuntimeImage` plus the pull-resolved reference, once per invocation + * (not once per slug — see {@link downloadFunctions}'s own resolve site): + * the image is identical for every function being downloaded, so resolving + * it inside the per-slug loop would multiply both the cache-check subprocess + * count and, on a registry outage, the retry-backoff sleep (up to ~36s) by + * the function count. Go's own `DockerStart` DOES run per-container (once + * per `extractOne`), but its image-cache check is an in-process Engine API + * call, not a fork+exec — the per-slug cost that justifies hoisting here has + * no Go equivalent to stay faithful to. + */ +interface PulledEdgeRuntimeImage extends EdgeRuntimeImage { readonly image: string; } @@ -1031,7 +972,7 @@ interface EdgeRuntimeImage { // asserts this explicitly). const downloadWithDockerUnbundle = Effect.fnUntraced(function* ( dependencies: DownloadDockerRuntimeDependencies, - edgeRuntimeImage: EdgeRuntimeImage, + edgeRuntimeImage: PulledEdgeRuntimeImage, projectRef: string, slug: string, ) { @@ -1090,7 +1031,7 @@ const downloadWithDockerUnbundle = Effect.fnUntraced(function* ( catch: (cause) => (cause instanceof Error ? cause.message : String(cause)), }).pipe(Effect.catch((message) => output.raw(`${message}\n`, "stderr"))); - const { projectId, denoVersion, image } = edgeRuntimeImage; + const { projectId, denoVersion, image, projectEnvValues } = edgeRuntimeImage; const functionsDir = resolve(dependencies.projectRoot, "supabase", "functions"); const hostEszipPath = resolve(eszipPath); const dockerEszipPath = posix.join(DOCKER_ESZIP_DIR, eszipFileName); @@ -1098,29 +1039,24 @@ const downloadWithDockerUnbundle = Effect.fnUntraced(function* ( // Go: `viper.GetString("network-id")` else `NetId` (`docker.go:379-383`) — // `--network-id` is a persistent root flag (`cmd/root.go:328`), not - // registered on `functions download` itself. Go only treats the override as - // set when `len(networkId) > 0`, so an explicit-but-empty `--network-id=` - // must fall through to the generated network name too, not just an omitted - // flag — `explicitNonEmptyStringFlag` (unlike the unexported - // `explicitStringFlag`) treats that case as unset for exactly this reason. - // - // Go's root `init()` also binds every persistent flag (including - // `network-id`) through `viper.BindPFlags` after enabling - // `viper.AutomaticEnv()` with `SetEnvPrefix("SUPABASE")` and a `-`→`_` - // replacer (`cmd/root.go:316-334`), so `SUPABASE_NETWORK_ID` overrides the - // flag's empty default whenever `--network-id` itself is never passed — - // this raw-argv-only lookup has no equivalent env-var fallback. Pre-existing, - // not introduced by this PR: `start.handler.ts`'s `LegacyNetworkIdFlag` and - // `deploy.ts`'s/`serve.ts`'s own network-id resolution don't check - // `SUPABASE_NETWORK_ID` either — no native command does today. A fix - // belongs in one shared place for the global `--network-id` resolution, - // not duplicated per Docker-path call site here — left open (review round - // on CLI-1963's `functions download` port). - const networkMode = - explicitNonEmptyStringFlag(dependencies.rawArgs, "network-id") ?? - localDockerId("network", projectId); + // registered on `functions download` itself. `explicitStringFlag` + // preserves the "explicitly cleared" vs "never touched" distinction + // `resolveDockerNetworkMode` needs to decide whether `SUPABASE_NETWORK_ID` + // applies — see that function's own doc comment. `SUPABASE_NETWORK_ID` + // (env or project dotenv) is legacy-shell-only — same Go-viper-parity gate + // as `projectEnvValues` itself (`undefined` in `next`). + const networkMode = resolveDockerNetworkMode({ + explicit: explicitStringFlag(dependencies.rawArgs, "network-id"), + envOverride: + projectEnvValues === undefined + ? undefined + : legacyViperEnvStringWithProjectFallback("SUPABASE_NETWORK_ID", projectEnvValues), + projectId, + }); const extract = Effect.gen(function* () { + // `image` is already pull-resolved once for the whole invocation — see + // `downloadFunctions`'s own resolve site — not re-resolved per slug. yield* ensureDockerNetwork(networkMode, projectId).pipe( Effect.mapError(withLegacyBundleSuggestion(slug, styleAqua)), ); @@ -1142,67 +1078,33 @@ const downloadWithDockerUnbundle = Effect.fnUntraced(function* ( `${hostEszipPath}:${dockerEszipPath}:ro`, `${functionsDir}:${DOCKER_DENO_DIR}:rw`, ]; - // No `com.supabase.cli.project`/`com.docker.compose.project` labels on - // this container itself — Go's `DockerStart` (`internal/utils/docker.go:372-376`) - // sets both unconditionally on `config.Labels` for every container it - // starts via the Engine API, including this exact unbundle container - // (`DockerRunOnceWithConfig` → `DockerStart`, `download.go:268`), so - // label-based cleanup/inspection can't associate an orphaned one-shot - // container with the project if the CLI is interrupted mid-run. Pre-existing - // and cross-cutting, not introduced by this PR: `deploy.ts`'s own - // `bundleFunctionWithDocker` builds an equally raw `docker run` command - // (its own `command.push(image, "bundle", ...)`) with the identical gap — - // only `ensureDockerNetwork`/`ensureDockerNamedVolume` (`functions-docker.ts`) - // thread `dockerProjectLabels` today, for the network/volume they create, - // not for the one-shot containers either Docker path runs. Adding `--label` - // to every `functions` Docker `run` invocation belongs in a shared - // container-build helper both call sites use, not duplicated per call site - // here — left open (review round on CLI-1963's `functions download` port). - const command = [ - "run", - "--rm", - ...binds.flatMap((bind) => ["-v", bind]), - "--network", + const command = buildFunctionsDockerRunArgs({ + image, + projectId, networkMode, - ]; - if (process.platform === "linux") { - command.push("--add-host", "host.docker.internal:host-gateway"); - } - command.push(image, "unbundle", "--eszip", dockerEszipPath, "--output", dockerOutputPath); + binds, + containerArgs: ["unbundle", "--eszip", dockerEszipPath, "--output", dockerOutputPath], + }); // Go pipes the container's stdout/stderr straight to `os.Stdout`/`getErrorLogger()` // while the container runs (`DockerRunOnceWithConfig`, copied live via the - // log stream) — this awaits `runChildProcess`, which buffers the whole - // run via `collectByteStream`'s `Stream.runFold` and only writes below - // once the process exits, so live progress/error output is hidden and - // stdout/stderr ordering can't be preserved relative to each other while - // the container is still running. Pre-existing and cross-cutting, not - // introduced by this PR: `deploy.ts`'s `bundleFunctionWithDocker` (added - // in #5561, before `functions-docker.ts` existed as its own file) calls - // the exact same `runChildProcess` helper the exact same way for its own - // bundler container. A real fix needs a streaming variant of - // `runChildProcess` used by both `functions` Docker paths, not a - // one-off change here — left open (review round on CLI-1963's `functions - // download` port). + // log stream) — `runChildProcess`'s `onStdout`/`onStderr` tee each chunk + // to `output.raw` as it arrives instead of buffering the whole run. + // Go pipes the container's stdout straight to `os.Stdout` + // (`download.go:279`); machine-output modes must keep stdout + // payload-only (CLI-1546), so this mirrors `deploy.ts`'s own + // `bundleFunctionWithDocker` routing. const result = yield* runChildProcess("docker", command, { stdout: "pipe", stderr: "pipe", + onStdout: (chunk) => output.raw(chunk, output.format === "text" ? "stdout" : "stderr"), + onStderr: (chunk) => output.raw(chunk, "stderr"), }).pipe( Effect.mapError( withDockerStepFailure("failed to run the edge-runtime unbundle container", slug, styleAqua), ), ); - // Go pipes the container's stdout straight to `os.Stdout` (`download.go:279`); - // machine-output modes must keep stdout payload-only (CLI-1546), so this - // mirrors `deploy.ts`'s own `bundleFunctionWithDocker` routing. - if (result.stdout.length > 0) { - yield* output.raw(result.stdout, output.format === "text" ? "stdout" : "stderr"); - } - if (result.stderr.length > 0) { - yield* output.raw(result.stderr, "stderr"); - } - if (result.exitCode !== 0) { // Go's `getErrorLogger` (deno-v1 only) sets `CmdSuggestion = // suggestDenoV2()` (assignment) as soon as a full stderr line reads @@ -1406,25 +1308,13 @@ export function downloadFunctions text); const edgeRuntimeImage: EdgeRuntimeImage | undefined = !flags.useApi && flags.useDocker ? (yield* isDockerRunning()) ? resolvedEdgeRuntimeImage : yield* output - .raw("WARNING: Docker is not running\n", "stderr") + .raw(`${styleWarning("WARNING:")} Docker is not running\n`, "stderr") .pipe(Effect.as(undefined)) : undefined; @@ -1445,6 +1335,29 @@ export function downloadFunctions `DockerResolveImageIfNotCached` (`internal/utils/docker.go:326-386`) + // — resolved ONCE here, for the whole invocation, not once per slug + // inside the loop below: the image is identical for every function, so + // per-slug resolution would multiply both the cache-check subprocess + // count and, on a registry outage, the retry-backoff sleep (up to ~36s) + // by the function count — see `PulledEdgeRuntimeImage`'s own doc comment + // for why this diverges from Go's per-container `DockerStart` without + // losing parity (Go's cache check is in-process, not a fork+exec). The + // `--legacy-bundle` suggestion on a resolve failure uses the first slug + // as a representative example, since no single slug is "the" one being + // processed yet at this point. + const styleAqua = dependencies.styleAqua ?? ((text: string) => text); + const pulledEdgeRuntimeImage: PulledEdgeRuntimeImage | undefined = + edgeRuntimeImage === undefined + ? undefined + : { + ...edgeRuntimeImage, + image: yield* resolveFunctionsDockerImage( + edgeRuntimeImage.rawImage, + edgeRuntimeImage.projectEnvValues, + ).pipe(Effect.mapError(withLegacyBundleSuggestion(slugs[0] ?? "", styleAqua))), + }; + const downloaded: string[] = []; for (const slug of slugs) { // Go: CLI-1891, `downloadAll`'s per-item validation runs before any @@ -1456,9 +1369,9 @@ export function downloadFunctions> | undefined; + /** Go's `Config.ProjectId`, sanitized, after `Config.Validate` in the legacy shell. */ + readonly projectId: string; + readonly denoVersion: number | undefined; +} + +/** + * Legacy-shell-only Go-parity hook, injected so this file (used by both + * shells) never imports `legacy/`-specific validation/dotenv machinery + * directly — same isolation rationale as `download.ts`'s `styleEmphasis`/ + * `styleAqua`. `undefined` marks the `next` shell. + * + * A single method (not one hook per step) so the legacy implementation can + * delegate its dotenv/config-load work to `legacy-local-project-context.ts`'s + * `legacyLoadLocalProjectContext` end to end — the same pipeline `start`/ + * `stop`/`status` already share — rather than re-implementing it here. + */ +export interface FunctionsGoConfigCompat { + readonly load: (input: { + readonly projectRoot: string; + readonly projectRef: string | undefined; + }) => Effect.Effect< + { + readonly loaded: LoadedProjectConfig | null; + readonly projectEnvValues: Readonly>; + readonly projectId: string; + readonly denoVersion: number; + }, + Error, + FileSystem.FileSystem | Path.Path + >; +} + +/** + * Go: `flags.LoadConfig` (`internal/utils/flags/config_path.go:10-14` -> + * `pkg/config/config.go:579-611,878`) — loads dotenv, decodes config.toml + * (merging template defaults + env even when the file is absent), and ends in + * `Config.Validate`, unconditionally, before any Docker/API work. Only the + * legacy shell (`goConfigCompat` set) runs that Go-parity dotenv/validate + * pipeline; `next` keeps today's plain `loadProjectConfig` behavior exactly. + */ +export const loadFunctionsProjectConfig = Effect.fnUntraced(function* (input: { + readonly projectRoot: string; + readonly projectRef: string | undefined; + readonly goConfigCompat: FunctionsGoConfigCompat | undefined; +}) { + if (input.goConfigCompat === undefined) { + const loaded = yield* loadProjectConfig(input.projectRoot, { + ...(input.projectRef === undefined ? {} : { projectRef: input.projectRef }), + goViperCompat: false, + }); + return { + loaded, + projectEnvValues: undefined, + // `input.projectRef` is a definite string for every current caller + // (`deploy`/`download` always resolve one first); the `basename` + // fallback only matters if this ever runs with `projectRef` + // `undefined` and no `project_id` in the file — matching Go's `Eject` + // basename default (`pkg/config/config.go:561-570`) and the legacy + // branch's own `legacyResolveLocalProjectId` fallback below. + projectId: loaded?.config.project_id ?? input.projectRef ?? basename(input.projectRoot), + denoVersion: loaded?.config.edge_runtime.deno_version, + } satisfies FunctionsProjectConfigContext; + } + + const context = yield* input.goConfigCompat.load({ + projectRoot: input.projectRoot, + projectRef: input.projectRef, + }); + return { + loaded: context.loaded, + projectEnvValues: context.projectEnvValues, + projectId: context.projectId, + denoVersion: context.denoVersion, + } satisfies FunctionsProjectConfigContext; +}); diff --git a/apps/cli/src/shared/functions/functions-docker.ts b/apps/cli/src/shared/functions/functions-docker.ts index 485eca85c3..0e4a7620ab 100644 --- a/apps/cli/src/shared/functions/functions-docker.ts +++ b/apps/cli/src/shared/functions/functions-docker.ts @@ -8,6 +8,7 @@ import { resolve } from "node:path"; import { Effect, Stream } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import { spawnContainerCli } from "../../legacy/shared/legacy-container-cli.ts"; +import { legacyMakeDockerImageResolver } from "../../legacy/shared/legacy-docker-image-resolve.ts"; const INVALID_PROJECT_ID = /[^a-zA-Z0-9_.-]+/g; const MAX_PROJECT_ID_LENGTH = 40; @@ -28,6 +29,41 @@ export function localDockerId(name: string, projectId: string) { return `supabase_${name}_${normalizeProjectId(projectId)}`; } +/** + * Go: `DockerStart`'s network selection (`internal/utils/docker.go:379-383`) + * combined with root's `viper.BindPFlags`/`AutomaticEnv` for the persistent + * `--network-id` flag (`cmd/root.go:316-334`). viper's `find()` resolves a + * `Changed` pflag *before* it ever consults a bound env var (`viper.go`'s + * flag-override branch precedes its env-override branch) — so an explicit + * `--network-id=` (empty, but still marks the flag `Changed`) makes + * `viper.GetString("network-id")` return `""` and stop there, WITHOUT + * falling through to `SUPABASE_NETWORK_ID`; only THEN does the consuming + * `len(networkId) > 0` check fall through, straight to the generated + * default. An explicit-but-empty *env* value, by contrast, genuinely means + * unset (viper never enables `AllowEmptyEnv`) and falls through to the + * default the normal way. Net effect: `explicit === undefined` (flag never + * touched) is the ONLY case that consults `envOverride` — `explicit === ""` + * (flag explicitly cleared) skips straight to the generated default, same + * as a non-empty `explicit` skips it by using the flag's own value. Callers + * MUST pass a flag reader that preserves this 3-way distinction — see + * `explicitStringFlag`. + * `envOverride` is `undefined` in `next` (no Go-viper env-binding claim + * there) — see `resolveDockerNetworkMode`'s callers. + */ +export function resolveDockerNetworkMode(input: { + readonly explicit: string | undefined; + readonly envOverride: string | undefined; + readonly projectId: string; +}): string { + if (input.explicit !== undefined) { + return input.explicit.length > 0 ? input.explicit : localDockerId("network", input.projectId); + } + if (input.envOverride !== undefined && input.envOverride.length > 0) { + return input.envOverride; + } + return localDockerId("network", input.projectId); +} + const dockerCliProjectLabel = "com.supabase.cli.project"; const dockerComposeProjectLabel = "com.docker.compose.project"; @@ -43,13 +79,75 @@ export function toDockerPath(hostPath: string) { return normalized.replace(/^[A-Za-z]:/, ""); } -function collectByteStream(stream: Stream.Stream) { - const decoder = new TextDecoder(); - return Stream.runFold( - stream, - () => "", - (text, chunk) => text + decoder.decode(chunk, { stream: true }), - ).pipe(Effect.map((text) => text + decoder.decode())); +export interface FunctionsDockerRunSpec { + /** Already registry/pull-resolved image reference. */ + readonly image: string; + /** Go's `Config.ProjectId` — the label value (`docker.go:374-376`). */ + readonly projectId: string; + readonly networkMode: string; + readonly binds: ReadonlyArray; + /** `KEY=VALUE` entries, each emitted as `-e KEY=VALUE`. */ + readonly env?: ReadonlyArray; + /** argv after the image, e.g. `["bundle", "--entrypoint", …]`. */ + readonly containerArgs: ReadonlyArray; + readonly platform?: NodeJS.Platform; +} + +/** + * Assembles the one-shot `docker run` invocation shared by `deploy.ts`'s + * bundler and `download.ts`'s unbundler containers: binds, network, the + * linux `host.docker.internal` workaround, env, and Go's unconditional + * `com.supabase.cli.project`/`com.docker.compose.project` labels + * (`DockerStart`, `internal/utils/docker.go:349-386`) — previously applied + * only to the network/volume these containers depend on + * (`ensureDockerNetwork`/`ensureDockerNamedVolume` above), never to the + * one-shot containers themselves, so label-based cleanup/inspection couldn't + * associate an orphaned container with the project. + */ +export function buildFunctionsDockerRunArgs(spec: FunctionsDockerRunSpec): Array { + const command = ["run", "--rm", ...spec.binds.flatMap((bind) => ["-v", bind])]; + command.push("--network", spec.networkMode); + if ((spec.platform ?? process.platform) === "linux") { + command.push("--add-host", "host.docker.internal:host-gateway"); + } + for (const env of spec.env ?? []) { + command.push("-e", env); + } + const labels = dockerProjectLabels(spec.projectId); + command.push( + "--label", + `${dockerCliProjectLabel}=${labels[dockerCliProjectLabel]}`, + "--label", + `${dockerComposeProjectLabel}=${labels[dockerComposeProjectLabel]}`, + ); + command.push(spec.image, ...spec.containerArgs); + return command; +} + +// Decodes a byte stream to text, both accumulating the full text (returned, +// for callers that need to post-process it, e.g. scanning stderr for +// "invalid eszip v2") AND tee-ing each decoded chunk to `onChunk` as it +// arrives — Go's `DockerStreamLogs`/`DockerRunOnceWithConfig` copy a +// container's log stream live while it runs, rather than buffering the whole +// thing until exit. +function collectByteStream( + stream: Stream.Stream, + onChunk?: (chunk: string) => Effect.Effect, +): Effect.Effect { + return Effect.suspend(() => { + const decoder = new TextDecoder(); + let text = ""; + const append = (chunk: string) => { + text += chunk; + return chunk.length > 0 && onChunk !== undefined ? onChunk(chunk) : Effect.void; + }; + return Stream.runForEach(stream, (bytes) => + append(decoder.decode(bytes, { stream: true })), + ).pipe( + Effect.flatMap(() => append(decoder.decode())), + Effect.map(() => text), + ); + }); } // Runs a container CLI command and collects its output. Every caller runs @@ -64,6 +162,10 @@ export const runChildProcess = Effect.fnUntraced(function* ( readonly stderr?: "pipe" | "ignore"; readonly env?: Readonly>; readonly extendEnv?: boolean; + /** Tees each decoded stdout chunk as it arrives, live — see {@link collectByteStream}. */ + readonly onStdout?: (chunk: string) => Effect.Effect; + /** Tees each decoded stderr chunk as it arrives, live — see {@link collectByteStream}. */ + readonly onStderr?: (chunk: string) => Effect.Effect; } = {}, ) { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; @@ -77,8 +179,12 @@ export const runChildProcess = Effect.fnUntraced(function* ( const [stdout, stderr, exitCode] = yield* Effect.all( [ - opts.stdout === "ignore" ? Effect.succeed("") : collectByteStream(child.stdout), - opts.stderr === "ignore" ? Effect.succeed("") : collectByteStream(child.stderr), + opts.stdout === "ignore" + ? Effect.succeed("") + : collectByteStream(child.stdout, opts.onStdout), + opts.stderr === "ignore" + ? Effect.succeed("") + : collectByteStream(child.stderr, opts.onStderr), child.exitCode.pipe(Effect.map(Number)), ], { concurrency: "unbounded" }, @@ -220,3 +326,21 @@ export function resolveEdgeRuntimeVersion( export function edgeRuntimeImageTag(version: string): string { return version.startsWith("v") ? version : `v${version}`; } + +/** + * Go: `DockerStart` -> `DockerResolveImageIfNotCached`/`DockerImagePullWithRetry` + * (`internal/utils/docker.go:304-348,366-370`) — checks every registry + * candidate (ECR/GHCR/Docker Hub) for a local cache hit first, then pulls + * with 2 retries per candidate (4s/8s backoff), returning whichever + * candidate answered. Shared by both shells' `functions` Docker paths + * (`deploy`/`download`/`serve`) — `legacyGetRegistryImageUrl`'s single-URL + * mapping is already called unconditionally by both today, so the retry is + * strictly-better resilience, not a Go-only quirk. + */ +export const resolveFunctionsDockerImage = Effect.fnUntraced(function* ( + image: string, + projectEnvValues?: Readonly>, +) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + return yield* legacyMakeDockerImageResolver(spawner, projectEnvValues)(image); +}); diff --git a/apps/cli/src/shared/functions/functions-docker.unit.test.ts b/apps/cli/src/shared/functions/functions-docker.unit.test.ts new file mode 100644 index 0000000000..a784511b5a --- /dev/null +++ b/apps/cli/src/shared/functions/functions-docker.unit.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from "vitest"; + +import { + buildFunctionsDockerRunArgs, + localDockerId, + resolveDockerNetworkMode, +} from "./functions-docker.ts"; + +describe("buildFunctionsDockerRunArgs", () => { + it("assembles run/--rm, binds, network, env, labels, image, and container args in order", () => { + const args = buildFunctionsDockerRunArgs({ + image: "supabase/edge-runtime:v1.2.3", + projectId: "my-project", + networkMode: "supabase_network_my-project", + binds: ["/host/a:/container/a", "/host/b:/container/b"], + env: ["FOO=bar", "BAZ=qux"], + containerArgs: ["bundle", "--entrypoint", "index.ts"], + platform: "darwin", + }); + + expect(args).toEqual([ + "run", + "--rm", + "-v", + "/host/a:/container/a", + "-v", + "/host/b:/container/b", + "--network", + "supabase_network_my-project", + "-e", + "FOO=bar", + "-e", + "BAZ=qux", + "--label", + "com.supabase.cli.project=my-project", + "--label", + "com.docker.compose.project=my-project", + "supabase/edge-runtime:v1.2.3", + "bundle", + "--entrypoint", + "index.ts", + ]); + }); + + it("omits --add-host on a non-linux platform", () => { + const args = buildFunctionsDockerRunArgs({ + image: "img", + projectId: "p", + networkMode: "bridge", + binds: [], + containerArgs: [], + platform: "darwin", + }); + + expect(args).not.toContain("--add-host"); + }); + + it("inserts --add-host host.docker.internal:host-gateway between --network and the -e entries on linux", () => { + const args = buildFunctionsDockerRunArgs({ + image: "img", + projectId: "p", + networkMode: "bridge", + binds: [], + env: ["FOO=bar"], + containerArgs: [], + platform: "linux", + }); + + const networkIndex = args.indexOf("--network"); + expect(args.slice(networkIndex, networkIndex + 6)).toEqual([ + "--network", + "bridge", + "--add-host", + "host.docker.internal:host-gateway", + "-e", + "FOO=bar", + ]); + }); + + it("produces no -v flags for an empty binds array", () => { + const args = buildFunctionsDockerRunArgs({ + image: "img", + projectId: "p", + networkMode: "bridge", + binds: [], + containerArgs: [], + platform: "darwin", + }); + + expect(args).not.toContain("-v"); + }); + + it("produces no -e flags when env is omitted", () => { + const args = buildFunctionsDockerRunArgs({ + image: "img", + projectId: "p", + networkMode: "bridge", + binds: [], + containerArgs: [], + platform: "darwin", + }); + + expect(args).not.toContain("-e"); + }); + + it("preserves the input order of multiple binds and env entries", () => { + const args = buildFunctionsDockerRunArgs({ + image: "img", + projectId: "p", + networkMode: "bridge", + binds: ["/c:/c", "/a:/a", "/b:/b"], + env: ["C=3", "A=1", "B=2"], + containerArgs: [], + platform: "darwin", + }); + + expect(args.slice(2, 8)).toEqual(["-v", "/c:/c", "-v", "/a:/a", "-v", "/b:/b"]); + const networkIndex = args.indexOf("--network"); + expect(args.slice(networkIndex + 2, networkIndex + 8)).toEqual([ + "-e", + "C=3", + "-e", + "A=1", + "-e", + "B=2", + ]); + }); + + it("uses the exact projectId value in both labels, unsanitized", () => { + const args = buildFunctionsDockerRunArgs({ + image: "img", + projectId: "My Weird/Project!!", + networkMode: "bridge", + binds: [], + containerArgs: [], + platform: "darwin", + }); + + expect(args).toContain("--label"); + expect(args).toContain("com.supabase.cli.project=My Weird/Project!!"); + expect(args).toContain("com.docker.compose.project=My Weird/Project!!"); + }); +}); + +describe("resolveDockerNetworkMode", () => { + it("prefers the explicit flag over the env override when both are set", () => { + expect( + resolveDockerNetworkMode({ + explicit: "explicit-network", + envOverride: "env-network", + projectId: "my-project", + }), + ).toBe("explicit-network"); + }); + + it("falls back to the env override when explicit is undefined", () => { + expect( + resolveDockerNetworkMode({ + explicit: undefined, + envOverride: "env-network", + projectId: "my-project", + }), + ).toBe("env-network"); + }); + + it("treats an explicit empty flag (--network-id=) as skipping straight to the generated default, not the env override", () => { + // Go parity: viper's Changed pflag wins over AutomaticEnv outright — an + // explicit `--network-id=` never falls back to SUPABASE_NETWORK_ID, only + // an OMITTED flag does. + expect( + resolveDockerNetworkMode({ + explicit: "", + envOverride: "env-network", + projectId: "my-project", + }), + ).toBe(localDockerId("network", "my-project")); + }); + + it("treats an empty env override as unset and falls through to the generated default", () => { + expect( + resolveDockerNetworkMode({ + explicit: undefined, + envOverride: "", + projectId: "my-project", + }), + ).toBe(localDockerId("network", "my-project")); + }); + + it("generates supabase_network_ when both are unset", () => { + const result = resolveDockerNetworkMode({ + explicit: undefined, + envOverride: undefined, + projectId: "my-project", + }); + + expect(result).toBe(localDockerId("network", "my-project")); + expect(result).toBe("supabase_network_my-project"); + }); +}); diff --git a/apps/cli/src/shared/functions/functions.shared.ts b/apps/cli/src/shared/functions/functions.shared.ts index 731785e7d6..2a1013aaab 100644 --- a/apps/cli/src/shared/functions/functions.shared.ts +++ b/apps/cli/src/shared/functions/functions.shared.ts @@ -25,9 +25,10 @@ export const FUNCTIONS_BUNDLER_MUTEX_GROUP = ["use-api", "use-docker", "legacy-b /** * Go: `Config.EdgeRuntime.Image` reflects `supabase/.temp/edge-runtime-version` * when present (`pkg/config/config.go:847-849`) — shared by every `functions` - * command that resolves a Docker edge-runtime image (`deploy`, `download`) in - * both shells, so this is the single home for the file-read rather than four - * copies of the same `readFile` -> `trim` -> fallback pipeline. + * command that resolves a Docker edge-runtime image: `deploy`/`download` in + * both shells, plus `serve` (legacy-only — `next` has no native `serve`). + * Single home for the file-read rather than several copies of the same + * `readFile` -> `trim` -> fallback pipeline. */ export const resolveEdgeRuntimeVersionPin = Effect.fnUntraced(function* (supabaseDir: string) { return yield* Effect.tryPromise(() => diff --git a/apps/cli/src/shared/functions/serve.ts b/apps/cli/src/shared/functions/serve.ts index 725e27c70f..664e605434 100644 --- a/apps/cli/src/shared/functions/serve.ts +++ b/apps/cli/src/shared/functions/serve.ts @@ -27,12 +27,12 @@ import { legacyDescribeContainerCliFailure, spawnContainerCli, } from "../../legacy/shared/legacy-container-cli.ts"; -import { legacyGetRegistryImageUrl } from "../../legacy/shared/legacy-docker-registry.ts"; import { LEGACY_SUGGEST_DOCKER_INSTALL, legacyIsDockerDaemonUnreachable, } from "../../legacy/shared/legacy-docker-suggest.ts"; import { parseDotEnv } from "../../legacy/shared/legacy-dotenv.ts"; +import { legacyViperEnvStringWithProjectFallback } from "../legacy/legacy-viper-env.ts"; import { resolveRemoteJwks, resolveThirdPartyIssuerUrl, @@ -63,10 +63,14 @@ import { ensureDockerNetwork, localDockerId, normalizeProjectId, + resolveDockerNetworkMode, resolveEdgeRuntimeVersion, + resolveFunctionsDockerImage, runChildProcess, toDockerPath, } from "./functions-docker.ts"; +import { loadFunctionsProjectConfig, type FunctionsGoConfigCompat } from "./functions-config.ts"; +import { resolveEdgeRuntimeVersionPin } from "./functions.shared.ts"; const decodeProjectConfig = Schema.decodeUnknownSync(ProjectConfigSchema); const defaultProjectConfig = decodeProjectConfig({}); @@ -98,7 +102,6 @@ const ignoredDirNames = new Set([ ]); const dockerLogRetryDelay = Duration.millis(400); const dockerLogDiagnosticTailLength = 4_096; -const legacyDefaultEdgeRuntimeVersion = "v1.74.2"; const defaultSupabaseEnv = "development"; const serveMainContainerPath = "/root/index.ts"; const shellVariableNamePattern = /^[A-Za-z_][A-Za-z0-9_]*$/; @@ -142,6 +145,13 @@ export interface FunctionsServeDependencies { readonly networkId: Option.Option; readonly projectIdOverride: Option.Option; readonly goViperCompat: boolean; + /** + * `undefined` in `next`; the legacy shell injects + * `legacyFunctionsGoConfigCompat` so this file never imports `legacy/` + * directly — see {@link FunctionsGoConfigCompat}. Distinct from + * `goViperCompat` above, which only gates `env(...)` interpolation. + */ + readonly goConfigCompat: FunctionsGoConfigCompat | undefined; } interface PlainServeAuthConfig { @@ -171,6 +181,8 @@ interface ServeResolvedConfig { readonly configFunctions: Readonly>; readonly rawConfigFunctions: Readonly>>>; readonly configPath?: string; + /** Go's post-`loadNestedEnv` merged env (ambient-wins). `undefined` in `next`. */ + readonly projectEnvValues: Readonly> | undefined; } interface ServeFunctionContainerConfig { @@ -678,6 +690,7 @@ const resolveServeConfig = Effect.fnUntraced(function* ( projectRoot: string, projectIdOverride: Option.Option, goViperCompat: boolean, + goConfigCompat: FunctionsGoConfigCompat | undefined, ) { const projectEnv = yield* loadServeProjectEnvironment(projectRoot); const projectRef = Option.match(projectIdOverride, { @@ -691,10 +704,20 @@ const resolveServeConfig = Effect.fnUntraced(function* ( // environment. We resolve that environment ourselves (Go-accurate, layering // `.env.`/`.env.local`/`.env` over the ambient env) and pass it // in, so loading neither re-reads those files nor mutates `process.env`. + // + // `search: false`/`tomlOnly: true` when `goConfigCompat` is set (legacy + // shell): this MUST match `loadFunctionsProjectConfig`'s own options below + // exactly, or the two loads can resolve two different files (an ancestor's + // config.toml vs this dir's; a stray config.json vs config.toml) — one + // supplying `auth`/`edgeRuntime`/`apiPort` here, the other supplying + // `denoVersion`/`Config.Validate` below, silently mixing fields from two + // different projects. `next` (`goConfigCompat === undefined`) keeps the + // package defaults (ancestor search, JSON preferred), unchanged. const loadedConfig = yield* loadProjectConfig(projectRoot, { ...(projectRef === undefined ? {} : { projectRef }), ...(projectEnv === null ? {} : { projectEnv }), goViperCompat, + ...(goConfigCompat === undefined ? {} : { search: false, tomlOnly: true }), }); const baseConfig = loadedConfig?.config ?? defaultProjectConfig; @@ -743,15 +766,55 @@ const resolveServeConfig = Effect.fnUntraced(function* ( const rawProjectId = Option.getOrElse(projectIdOverride, () => configProjectId).trim(); const fallbackProjectId = basename(resolve(projectRoot)); + // Go: `flags.LoadConfig` -> `Config.Validate` (`pkg/config/config.go:878,989-1192`) + // — `restartEdgeRuntime` runs this FIRST, before `AssertSupabaseDbIsRunning` + // (see this function's own caller for that ordering) — so an invalid + // config must fail here too, before any Docker check. Legacy shell only; + // `next` keeps its own package-default config resolution above unchanged. + // A second, independent config/dotenv load (rather than reusing this + // function's own `loadedConfig`/`projectEnv` above) — that pipeline's + // `env(...)`-interpolation purpose is unrelated to Go's `SUPABASE_*` + // `AutomaticEnv` override system this one provides, and the two shouldn't + // be entangled for a shipped, long-running command's config path. + // `search`/`tomlOnly` are aligned with this file's own `loadedConfig` call + // above (see its comment) so the two loads can never disagree about which + // file is "the" project config. `projectEnvValues` (for registry/network-id + // env lookups, this file's own caller) and the env-overridden + // `deno_version` are consumed from it; `auth`/`apiPort`/functions above + // keep their existing derivation. `projectId` also keeps its existing + // derivation — a known, narrow gap: unlike `deploy`/`download` (which use + // `context.projectId` outright), `rawProjectId` below only ever sees + // `SUPABASE_PROJECT_ID` from the *ambient* shell (`projectIdOverride`, from + // `LegacyCliConfig`), not from project dotenv, so a project that sets it + // only in `supabase/.env` gets a different container/network/volume name + // than `deploy`/`download` would resolve for the same project. Folding + // `goContext.projectEnvValues` in here would also require reconciling this + // function's `projectIdOverride`-wins-unconditionally precedence with + // `legacyResolveLocalProjectId`'s config-file-wins-over-`projectRef` + // precedence (they're not the same order) — left open rather than risking + // that regression under time pressure (review round on CLI-1963). + const goContext = + goConfigCompat === undefined + ? undefined + : yield* loadFunctionsProjectConfig({ + projectRoot, + projectRef, + goConfigCompat, + }); + return { projectId: normalizeProjectId(rawProjectId.length > 0 ? rawProjectId : fallbackProjectId), apiPort, auth, - edgeRuntime, + edgeRuntime: + goContext === undefined + ? edgeRuntime + : { ...edgeRuntime, deno_version: goContext.denoVersion }, configDeclaredFunctions, configFunctions, rawConfigFunctions: rawFunctionConfigRecord(loadedConfig?.document), configPath: loadedConfig?.path, + projectEnvValues: goContext?.projectEnvValues, } satisfies ServeResolvedConfig; }); @@ -1732,30 +1795,35 @@ const startEdgeRuntime = Effect.fnUntraced(function* (input: { input.dependencies.projectRoot, input.dependencies.projectIdOverride, input.dependencies.goViperCompat, + input.dependencies.goConfigCompat, ); const projectId = resolved.projectId; const containerId = localDockerId("edge_runtime", projectId); let ownsRuntime = false; let startedRuntime: StartedRuntime | undefined; return yield* Effect.gen(function* () { - const networkMode = Option.getOrElse(input.networkId, () => - localDockerId("network", projectId), - ); + // `SUPABASE_NETWORK_ID` (env or project dotenv) is legacy-shell-only — + // same Go-viper-parity gate as `resolved.projectEnvValues` itself + // (`undefined` in `next`). + const networkMode = resolveDockerNetworkMode({ + explicit: Option.getOrUndefined(input.networkId), + envOverride: + resolved.projectEnvValues === undefined + ? undefined + : legacyViperEnvStringWithProjectFallback( + "SUPABASE_NETWORK_ID", + resolved.projectEnvValues, + ), + projectId, + }); const localAuthArtifacts = yield* resolveLocalAuthArtifacts(resolved.auth, resolved.configPath); - const edgeRuntimeVersionOverride = yield* Effect.tryPromise(() => - readFile(join(input.dependencies.supabaseDir, ".temp", "edge-runtime-version"), "utf8"), - ).pipe( - Effect.map((value) => value.trim()), - Effect.catch(() => Effect.succeed("")), - Effect.map((value) => value || legacyDefaultEdgeRuntimeVersion), + const edgeRuntimeVersionOverride = yield* resolveEdgeRuntimeVersionPin( + input.dependencies.supabaseDir, ); const edgeRuntimeVersion = yield* resolveEdgeRuntimeVersion( resolved.edgeRuntime.deno_version, edgeRuntimeVersionOverride, ); - const image = legacyGetRegistryImageUrl( - `supabase/edge-runtime:${edgeRuntimeImageTag(edgeRuntimeVersion)}`, - ); yield* assertLocalDbRunning(projectId); yield* bestEffortRemoveContainer(containerId); @@ -1775,6 +1843,33 @@ const startEdgeRuntime = Effect.fnUntraced(function* (input: { // (`edge-runtime.service.ts`), which resolves its own JWKS. const authArtifacts = yield* finalizeAuthArtifacts(localAuthArtifacts); + // Go: `DockerStart` -> `DockerResolveImageIfNotCached` (`internal/utils/docker.go:326-386`) + // — resolved here, not earlier: `hasLocalImage` fails fast on an + // unreachable daemon, which would otherwise hijack the down-daemon + // message `assertLocalDbRunning` above is responsible for producing. + // + // Known ordering divergence (not fixed here — see below): Go's own + // `ServeFunctions` (`serve.go:134-167`) parses `--env-file` and every + // per-function config BEFORE ever calling `DockerStart` + // (`serve.go:218`), so a broken env file or function config fails fast, + // before any pull. This port's `startEdgeRuntimeContainer` (below) does + // that same parsing internally, but AFTER receiving an already-resolved + // `image` — so on a cold image cache, a broken `--env-file` now surfaces + // after a potentially slow `docker pull` instead of immediately. Fixing + // this properly means splitting `startEdgeRuntimeContainer` into a + // "build container config" phase and a "run it" phase so this resolve + // can move between them — but that function is also `start`'s bring-up + // core (`edge-runtime.service.ts`), which already passes in a + // pre-resolved image via `legacyEnsureImagesCached`, so restructuring it + // risks that shipped, more critical path. Left as a documented + // UX-only regression (the command still fails with the right error, + // just later) rather than a hasty change to shared, `start`-critical + // code (review round on CLI-1963). + const image = yield* resolveFunctionsDockerImage( + `supabase/edge-runtime:${edgeRuntimeImageTag(edgeRuntimeVersion)}`, + resolved.projectEnvValues, + ); + startedRuntime = yield* startEdgeRuntimeContainer({ config: { projectId, diff --git a/apps/cli/src/shared/legacy/legacy-viper-env.ts b/apps/cli/src/shared/legacy/legacy-viper-env.ts index f787ab97d8..110702fdc5 100644 --- a/apps/cli/src/shared/legacy/legacy-viper-env.ts +++ b/apps/cli/src/shared/legacy/legacy-viper-env.ts @@ -55,3 +55,16 @@ export function legacyViperEnvBoolWithProjectFallback( ): boolean { return legacyViperBool(process.env[name] ?? projectEnv[name]); } + +/** + * `viper.GetString` for a `SUPABASE_*` key where a project `supabase/.env` + * value may also apply — same shell-presence-suppresses-file-value semantics + * as {@link legacyViperEnvBoolWithProjectFallback} (see its doc comment), + * just without the bool cast. + */ +export function legacyViperEnvStringWithProjectFallback( + name: string, + projectEnv: Readonly>, +): string | undefined { + return process.env[name] ?? projectEnv[name]; +} diff --git a/apps/cli/src/shared/legacy/legacy-viper-env.unit.test.ts b/apps/cli/src/shared/legacy/legacy-viper-env.unit.test.ts index 7dc63d33d9..c366992222 100644 --- a/apps/cli/src/shared/legacy/legacy-viper-env.unit.test.ts +++ b/apps/cli/src/shared/legacy/legacy-viper-env.unit.test.ts @@ -1,6 +1,10 @@ import { afterEach, describe, expect, it } from "vitest"; -import { legacyViperEnvBool, legacyViperEnvBoolWithProjectFallback } from "./legacy-viper-env.ts"; +import { + legacyViperEnvBool, + legacyViperEnvBoolWithProjectFallback, + legacyViperEnvStringWithProjectFallback, +} from "./legacy-viper-env.ts"; const KEY = "SUPABASE_TEST_VIPER_BOOL"; @@ -70,3 +74,33 @@ describe("legacyViperEnvBoolWithProjectFallback", () => { expect(legacyViperEnvBoolWithProjectFallback(KEY, { [KEY]: "false" })).toBe(true); }); }); + +describe("legacyViperEnvStringWithProjectFallback", () => { + afterEach(() => { + delete process.env[KEY]; + }); + + it("returns the shell value and ignores the project value when both are set", () => { + process.env[KEY] = "shell-value"; + expect(legacyViperEnvStringWithProjectFallback(KEY, { [KEY]: "project-value" })).toBe( + "shell-value", + ); + }); + + it("treats an empty shell value as present (godotenv never overwrites an existing key)", () => { + process.env[KEY] = ""; + expect(legacyViperEnvStringWithProjectFallback(KEY, { [KEY]: "project-value" })).toBe(""); + }); + + it("falls back to the project value when the shell var is absent", () => { + delete process.env[KEY]; + expect(legacyViperEnvStringWithProjectFallback(KEY, { [KEY]: "project-value" })).toBe( + "project-value", + ); + }); + + it("returns undefined when the key is absent from both the shell and the project env", () => { + delete process.env[KEY]; + expect(legacyViperEnvStringWithProjectFallback(KEY, {})).toBeUndefined(); + }); +}); From f102b669701736d37d215895ea09a463180ee3e0 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Mon, 10 Aug 2026 17:54:54 +0100 Subject: [PATCH 21/22] fix(functions): apply parity-audit and engineering-review findings (review: CLI-1963) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - use context.projectId (remote-override-gated, --project-ref defaulted, sanitized) for the functions Docker paths instead of the validation-only projectId, restoring the [remotes.] OVERRIDE-tier guard for SUPABASE_PROJECT_ID - apply edge-runtime version pins VERBATIM as image tags (Go replaceImageTag semantics) via a single edgeRuntimeImage helper sourced from the Go Dockerfile — no more v-prefix synthesis that broke bare pins like 'latest' and disagreed with legacy-edge-runtime-image.ts over the same pin file - consolidate --network-id resolution into resolveDockerNetworkMode: delete legacyResolveNetworkId, whose explicit-empty-flag handling wrongly fell through to SUPABASE_NETWORK_ID (viper resolves a Changed pflag before env); start/db start now use the shared helper - replace explicitStringFlag with the stronger existing lastExplicitLongFlagValue (handles '--' terminator, consumed value tokens, trailing valueless occurrences); drop hasGlobalLongFlag and gate deploy's bundler --verbose on explicitBooleanLongFlag so --debug=false disables it - pass Go's bundler WorkingDir (-w) through the shared docker-run builder; sanitize next-shell project ids before they reach container labels - scope runChildProcess so per-invocation spawn finalizers don't accumulate across functions serve restarts - decouple integration tests from @supabase/stack's DEFAULT_VERSIONS (assert against the Go Dockerfile image); make hidden-flag's --use-docker probe fail pre-Docker so a live CI daemon can't trigger a real image pull - add streaming-tee unit tests (split multi-byte UTF-8, no empty chunks), explicitBooleanLongFlag cases, and bundler label/workdir assertions - document the BITBUCKET_CLONE_DIR process.env install in deploy/serve SIDE_EFFECTS.md and correct serve's no-env-mutation claim --- .../commands/functions/deploy/SIDE_EFFECTS.md | 21 ++-- .../deploy/deploy.integration.test.ts | 20 ++++ .../download/download.integration.test.ts | 12 +-- .../commands/functions/serve/SIDE_EFFECTS.md | 23 ++--- .../functions/serve/serve.integration.test.ts | 5 +- .../legacy/commands/start/start.handler.ts | 19 ++-- .../db-bootstrap/local-container-inputs.ts | 19 ++-- .../src/legacy/shared/legacy-docker-ids.ts | 38 ++----- .../shared/legacy-docker-ids.unit.test.ts | 44 +++++---- .../shared/legacy-functions-go-config.ts | 11 ++- .../shared/legacy-local-project-context.ts | 1 - .../deploy/deploy.integration.test.ts | 13 ++- .../download/download.integration.test.ts | 4 +- apps/cli/src/shared/cli/cobra-flag-groups.ts | 37 +------ .../shared/cli/cobra-flag-groups.unit.test.ts | 35 +++++++ .../src/shared/cli/hidden-flag.unit.test.ts | 12 ++- apps/cli/src/shared/functions/deploy.ts | 37 ++++--- apps/cli/src/shared/functions/download.ts | 20 ++-- .../src/shared/functions/functions-config.ts | 11 ++- .../src/shared/functions/functions-docker.ts | 94 ++++++++++-------- .../functions/functions-docker.unit.test.ts | 99 ++++++++++++++++++- .../src/shared/functions/functions.shared.ts | 31 ++++-- apps/cli/src/shared/functions/serve.ts | 21 ++-- 23 files changed, 403 insertions(+), 224 deletions(-) diff --git a/apps/cli/src/legacy/commands/functions/deploy/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/functions/deploy/SIDE_EFFECTS.md index b6a2886015..7146b1613f 100644 --- a/apps/cli/src/legacy/commands/functions/deploy/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/functions/deploy/SIDE_EFFECTS.md @@ -47,16 +47,17 @@ Docker bundling may pull or run the configured edge-runtime image and uses the ## Environment Variables -| Variable | Purpose | Required? | -| ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_PROJECT_ID` | optional project ref fallback; also read from project dotenv now (previously ambient-shell-only) | no | -| `SUPABASE_ENV` | selects environment-specific dotenv files (`.env..local`, `.env.`) | no (defaults to `development`) | -| `SUPABASE_INTERNAL_IMAGE_REGISTRY` | selects the Functions bundler image registry; read from the ambient shell **or** project dotenv; unset resolves ECR->GHCR->Docker-Hub candidates in order instead of a single URL | no | -| `SUPABASE_NETWORK_ID` | overrides the generated `supabase_network_` Docker network name when `--network-id` isn't passed; read from the ambient shell or project dotenv | no | -| `SUPABASE_EDGE_RUNTIME_DENO_VERSION` | overrides `edge_runtime.deno_version` (which bundler image tag to use) when set, from the ambient shell or project dotenv — takes effect even with no `config.toml` on disk | no | -| `NPM_CONFIG_REGISTRY` | forwarded into Docker bundling when set (the only npm variable forwarded, matching Go; `NPM_AUTH_TOKEN` is not) | no | -| `DEBUG` | enables verbose Docker bundle output when `true` | no | +| Variable | Purpose | Required? | +| ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROJECT_ID` | optional project ref fallback; also read from project dotenv now (previously ambient-shell-only) | no | +| `SUPABASE_ENV` | selects environment-specific dotenv files (`.env..local`, `.env.`) | no (defaults to `development`) | +| `SUPABASE_INTERNAL_IMAGE_REGISTRY` | selects the Functions bundler image registry; read from the ambient shell **or** project dotenv; unset resolves ECR->GHCR->Docker-Hub candidates in order instead of a single URL | no | +| `SUPABASE_NETWORK_ID` | overrides the generated `supabase_network_` Docker network name when `--network-id` isn't passed; read from the ambient shell or project dotenv | no | +| `BITBUCKET_CLONE_DIR` | when set, skips creating the named Deno-cache volume and omits its bind mount from the bundler `docker run` (Bitbucket's restricted Docker environment rejects both); a project-dotenv-only value is installed into `process.env` by config loading, matching Go's `loadNestedEnv` | no | +| `SUPABASE_EDGE_RUNTIME_DENO_VERSION` | overrides `edge_runtime.deno_version` (which bundler image tag to use) when set, from the ambient shell or project dotenv — takes effect even with no `config.toml` on disk | no | +| `NPM_CONFIG_REGISTRY` | forwarded into Docker bundling when set (the only npm variable forwarded, matching Go; `NPM_AUTH_TOKEN` is not) | no | +| `DEBUG` | enables verbose Docker bundle output when `true` | no | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/functions/deploy/deploy.integration.test.ts b/apps/cli/src/legacy/commands/functions/deploy/deploy.integration.test.ts index 2d8d3e86bc..6dbb187af6 100644 --- a/apps/cli/src/legacy/commands/functions/deploy/deploy.integration.test.ts +++ b/apps/cli/src/legacy/commands/functions/deploy/deploy.integration.test.ts @@ -20,6 +20,7 @@ import { deployFunctions, shouldChmodBundleOutputDirectory, } from "../../../../shared/functions/deploy.ts"; +import { toDockerPath } from "../../../../shared/functions/functions-docker.ts"; import { legacyFunctionsGoConfigCompat } from "../../../shared/legacy-functions-go-config.ts"; import { ConflictingFunctionDeployFlagsError, @@ -1640,6 +1641,25 @@ describe("legacy functions deploy", () => { "com.docker.compose.project=test-project", ]), ); + // Adjacent pairs, not merely present anywhere in argv — + // `buildFunctionsDockerRunArgs` emits the two `--label KEY=VALUE` + // pairs back-to-back, immediately before the image. + const cliLabelIndex = runCommand?.args.indexOf("--label") ?? -1; + expect(runCommand?.args.slice(cliLabelIndex, cliLabelIndex + 4)).toEqual([ + "--label", + "com.supabase.cli.project=test-project", + "--label", + "com.docker.compose.project=test-project", + ]); + // `-w ` — Go's bundler sets WorkingDir to + // the post-ChangeWorkDir cwd (`bundle.go:79`), which + // `deploy.ts`/`deploy.handler.ts` resolve to `cliConfig.workdir`, + // i.e. `tempRoot.current` in this test. + const workingDirIndex = runCommand?.args.indexOf("-w") ?? -1; + expect(runCommand?.args.slice(workingDirIndex, workingDirIndex + 2)).toEqual([ + "-w", + toDockerPath(tempRoot.current), + ]); }).pipe( Effect.provide(layer), Effect.ensuring( diff --git a/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts b/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts index a0a888b268..a5e5546b32 100644 --- a/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts +++ b/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { DEFAULT_VERSIONS } from "@supabase/stack/effect"; +import { dockerfileServiceImage } from "../../../../shared/services/dockerfile-images.ts"; import { existsSync } from "node:fs"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import { join, resolve } from "node:path"; @@ -616,7 +616,7 @@ describe("legacy functions download", () => { // The unbundle tail is always the LAST 6 args regardless of whether // `--add-host` (Linux-only) was inserted before it. expect(runCommand?.args.slice(-6)).toEqual([ - `public.ecr.aws/supabase/edge-runtime:v${DEFAULT_VERSIONS["edge-runtime"]}`, + `public.ecr.aws/${dockerfileServiceImage("edgeruntime")}`, "unbundle", "--eszip", "/root/eszips/output_hello-world.eszip", @@ -760,7 +760,7 @@ describe("legacy functions download", () => { return Effect.gen(function* () { // `--network-id` is a persistent root flag (`cmd/root.go:328`), not - // registered on `functions download` itself — `explicitStringFlag` + // registered on `functions download` itself — `lastExplicitLongFlagValue` // scans the whole argv unscoped. yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }); @@ -824,7 +824,7 @@ describe("legacy functions download", () => { // pflag/viper string flags are shared-variable, last-`Set()`-wins // (confirmed empirically: `pflag.FlagSet.Parse` on // `--network-id old --network-id custom-network` resolves to - // `custom-network`) — `explicitStringFlag` must keep scanning past the + // `custom-network`) — `lastExplicitLongFlagValue` must keep scanning past the // first match instead of returning early (review round on CLI-1963's // `functions download` port). const out = mockOutput({ format: "text" }); @@ -2129,7 +2129,7 @@ describe("legacy functions download", () => { const runCommand = child.spawned.find((spawned) => spawned.args[0] === "run"); expect(runCommand?.args.slice(-6)[0]).toBe( - `ghcr.io/supabase/edge-runtime:v${DEFAULT_VERSIONS["edge-runtime"]}`, + `ghcr.io/${dockerfileServiceImage("edgeruntime")}`, ); expect( child.spawned.filter( @@ -2203,7 +2203,7 @@ describe("legacy functions download", () => { ).toHaveLength(2); const runCommand = child.spawned.find((spawned) => spawned.args[0] === "run"); expect(runCommand?.args.slice(-6)[0]).toBe( - `ghcr.io/supabase/edge-runtime:v${DEFAULT_VERSIONS["edge-runtime"]}`, + `ghcr.io/${dockerfileServiceImage("edgeruntime")}`, ); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/legacy/commands/functions/serve/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/functions/serve/SIDE_EFFECTS.md index 8e0149be32..18ace3c1ef 100644 --- a/apps/cli/src/legacy/commands/functions/serve/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/functions/serve/SIDE_EFFECTS.md @@ -44,16 +44,17 @@ validation is performed on the discovered URLs, also matching the Go CLI. ## Environment Variables -| Variable | Purpose | Required? | -| --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ | -| `SUPABASE_PROFILE` | resolves the legacy profile / API base URL | no (defaults to `supabase`) | -| `SUPABASE_WORKDIR` | overrides the project workdir | no (falls back to CLI cwd discovery) | -| `SUPABASE_PROJECT_ID` | legacy config-service override for project identity | no | -| `SUPABASE_ENV` | selects environment-specific dotenv files (`.env..local`, `.env.`) | no (defaults to `development`) | -| env vars referenced by `supabase/config.toml` | config interpolation; the full ambient `process.env` is layered under the project `.env*` files and passed to config loading | no | -| `SUPABASE_INTERNAL_IMAGE_REGISTRY` | overrides the edge-runtime Docker registry mirror; read from the ambient shell **or** project dotenv; unset resolves ECR->GHCR->Docker-Hub candidates in order instead of a single URL | no (defaults to `public.ecr.aws`) | -| `SUPABASE_NETWORK_ID` | overrides the generated `supabase_network_` Docker network name when `--network-id` isn't passed; read from the ambient shell or project dotenv | no | -| `SUPABASE_EDGE_RUNTIME_DENO_VERSION` | overrides `edge_runtime.deno_version` (which image tag to pull) when set, from the ambient shell or project dotenv — takes effect even with no `config.toml` on disk | no | +| Variable | Purpose | Required? | +| --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ | +| `SUPABASE_PROFILE` | resolves the legacy profile / API base URL | no (defaults to `supabase`) | +| `SUPABASE_WORKDIR` | overrides the project workdir | no (falls back to CLI cwd discovery) | +| `SUPABASE_PROJECT_ID` | legacy config-service override for project identity | no | +| `SUPABASE_ENV` | selects environment-specific dotenv files (`.env..local`, `.env.`) | no (defaults to `development`) | +| env vars referenced by `supabase/config.toml` | config interpolation; the full ambient `process.env` is layered under the project `.env*` files and passed to config loading | no | +| `SUPABASE_INTERNAL_IMAGE_REGISTRY` | overrides the edge-runtime Docker registry mirror; read from the ambient shell **or** project dotenv; unset resolves ECR->GHCR->Docker-Hub candidates in order instead of a single URL | no (defaults to `public.ecr.aws`) | +| `SUPABASE_NETWORK_ID` | overrides the generated `supabase_network_` Docker network name when `--network-id` isn't passed; read from the ambient shell or project dotenv | no | +| `SUPABASE_EDGE_RUNTIME_DENO_VERSION` | overrides `edge_runtime.deno_version` (which image tag to pull) when set, from the ambient shell or project dotenv — takes effect even with no `config.toml` on disk | no | +| `BITBUCKET_CLONE_DIR` | when set, skips creating the named Deno-cache volume and omits its bind mount from the edge-runtime `docker run` (Bitbucket's restricted Docker environment rejects both); a project-dotenv-only value is installed into `process.env` by config loading, matching Go's `loadNestedEnv` | no | ## Exit Codes @@ -100,7 +101,7 @@ Long-running raw log / error events only; there is no terminal `result` event on - named volume: `supabase_edge_runtime_` - network: `supabase_network_` unless `--network-id` overrides it - Inspector mode exposes the configured `edge_runtime.inspector_port` on the host and sets `SUPABASE_INTERNAL_WALLCLOCK_LIMIT_SEC=0`, matching the Go serve path. -- Config `env()` interpolation uses a project environment resolved by the command itself (ambient `process.env` layered under `.env..local` / `.env.local` / `.env.` / `.env`, matching Go) and passed into `loadProjectConfig`. The command does not mutate `process.env` or move/hide any project files. +- Config `env()` interpolation uses a project environment resolved by the command itself (ambient `process.env` layered under `.env..local` / `.env.local` / `.env.` / `.env`, matching Go) and passed into `loadProjectConfig`. The command does not move/hide any project files. One `process.env` mutation exists: the Go-parity config pipeline (`legacyLoadLocalProjectContext`, shared with `deploy`/`download`/`start`) installs a project-dotenv-only `BITBUCKET_CLONE_DIR` into `process.env`, matching Go's `loadNestedEnv` `os.Setenv` behavior. - Before each container (re)start, resolves the edge-runtime image through the same registry-candidate pull-with-retry every native `functions` Docker path uses: `docker image inspect ` (ECR, then GHCR, then Docker Hub) to check the local cache, then `docker pull ` with 2 retries (4s/8s backoff) on a miss, after `assertLocalDbRunning` — resolving it earlier would hijack the down-daemon error message that DB-inspect step is responsible for producing. - Runs the full `Config.Validate` pipeline (`legacyResolveLocalConfigValues`, same one `start`/`stop`/`status` use) on every startup/restart, before `assertLocalDbRunning` — an invalid config now fails `serve` up front even for fields this command never otherwise reads (e.g. a bad `db.major_version` or malformed auth hook), matching Go's `flags.LoadConfig` -> `Config.Validate`. - A container crash terminates the command with a non-zero exit; only a watched-file change restarts the container. The Go CLI never auto-restarts a crashed container. diff --git a/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts b/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts index b6b77e13d3..4198b91fdb 100644 --- a/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts +++ b/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts @@ -501,7 +501,10 @@ describe("legacy functions serve integration", () => { expect(dockerRun.args).toContain("supabase_network_test-project"); expect(dockerRun.args).toContain("--add-host"); expect(dockerRun.args).toContain("host.docker.internal:host-gateway"); - expect(dockerRun.args).toContain("public.ecr.aws/supabase/edge-runtime:v1.73.13"); + // The pin's content is applied VERBATIM as the tag (Go's + // `replaceImageTag`, `pkg/config/utils.go:81-84`) — a bare pin stays + // bare, no `v` synthesized. + expect(dockerRun.args).toContain("public.ecr.aws/supabase/edge-runtime:1.73.13"); expect( extractFlagValues(dockerRun.args, "-v").some((value) => value.endsWith(":/root/index.ts:ro,Z"), diff --git a/apps/cli/src/legacy/commands/start/start.handler.ts b/apps/cli/src/legacy/commands/start/start.handler.ts index b7399f37a0..cc8a7b8073 100644 --- a/apps/cli/src/legacy/commands/start/start.handler.ts +++ b/apps/cli/src/legacy/commands/start/start.handler.ts @@ -55,11 +55,12 @@ import { import { legacyParseGoDuration } from "../../shared/legacy-go-duration.ts"; import { legacyCliProjectFilterValue, - legacyResolveNetworkId, legacyServiceContainerIds, legacyServiceContainerName, localDbContainerId, } from "../../shared/legacy-docker-ids.ts"; +import { resolveDockerNetworkMode } from "../../../shared/functions/functions-docker.ts"; +import { legacyViperEnvStringWithProjectFallback } from "../../../shared/legacy/legacy-viper-env.ts"; import { legacyInspectContainerState, legacyListContainersByLabel, @@ -920,17 +921,17 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta // Go's `DockerStart` forces every container's network mode (and the // network it creates) to `--network-id` when set, ahead of the generated // `supabase_network_` fallback (`docker.go:379-383`) — and `--network-id` falls - // back to the `SUPABASE_NETWORK_ID` shell/project-dotenv env var when the flag itself is - // omitted, via the same `viper`/`AutomaticEnv` mechanism as `SUPABASE_YES`/ + // back to the `SUPABASE_NETWORK_ID` shell/project-dotenv env var ONLY when the flag was + // never passed, via the same `viper`/`AutomaticEnv` mechanism as `SUPABASE_YES`/ // `SUPABASE_EXPERIMENTAL` (review: PRRT_kwDOErm0O86VlqIL). See - // {@link legacyResolveNetworkId}'s doc comment (shared with `db start`, which computes this - // identically). + // {@link resolveDockerNetworkMode}'s doc comment for the full 3-way flag/env + // precedence (shared with `db start` and the `functions` Docker paths). const networkIdFlag = yield* LegacyNetworkIdFlag; - const networkId = legacyResolveNetworkId( - Option.getOrUndefined(networkIdFlag), + const networkId = resolveDockerNetworkMode({ + explicit: Option.getOrUndefined(networkIdFlag), + envOverride: legacyViperEnvStringWithProjectFallback("SUPABASE_NETWORK_ID", projectEnvValues), projectId, - projectEnvValues, - ); + }); // Go's `DockerStart` unconditionally appends the Linux-only // `host.docker.internal:host-gateway` extra host for every container it // starts (`docker_linux.go`; empty on darwin/windows, where Docker diff --git a/apps/cli/src/legacy/shared/db-bootstrap/local-container-inputs.ts b/apps/cli/src/legacy/shared/db-bootstrap/local-container-inputs.ts index 2b2a8a00f0..320d2c6bad 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/local-container-inputs.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/local-container-inputs.ts @@ -33,7 +33,9 @@ import type { GlobalFlag } from "effect/unstable/cli"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; import { legacyResolveExperimentalWithProjectEnv } from "../../../shared/legacy/global-flags.ts"; import { LegacyDbConfigLoadError } from "../legacy-db-config.errors.ts"; -import { localDbContainerId, legacyResolveNetworkId } from "../legacy-docker-ids.ts"; +import { localDbContainerId } from "../legacy-docker-ids.ts"; +import { resolveDockerNetworkMode } from "../../../shared/functions/functions-docker.ts"; +import { legacyViperEnvStringWithProjectFallback } from "../../../shared/legacy/legacy-viper-env.ts"; import { legacyIsBitbucketPipeline } from "../legacy-bitbucket-pipeline.ts"; import { legacyResolveAuthExternalUrl, @@ -190,15 +192,16 @@ export const legacyBuildLocalDbContainerInputs = ( // Go's `DockerStart` forces every container's network mode (and the network it creates) to // `--network-id` when set, ahead of the generated `supabase_network_` fallback // (`docker.go:379-383`) — and `--network-id` falls back to the `SUPABASE_NETWORK_ID` - // shell/project-dotenv env var when the flag itself is omitted, via the same + // shell/project-dotenv env var ONLY when the flag was never passed, via the same // `viper`/`AutomaticEnv` mechanism as `SUPABASE_YES`/`SUPABASE_EXPERIMENTAL` (review: - // PRRT_kwDOErm0O86VlqIL; see {@link legacyResolveNetworkId}'s doc comment for why this is NOT - // the same freeze-at-package-init shape as `utils.Config.Hostname`). - const networkId = legacyResolveNetworkId( - Option.getOrUndefined(networkIdFlag), + // PRRT_kwDOErm0O86VlqIL; unlike `utils.Config.Hostname`, viper re-reads the dotenv-merged + // env fresh at `DockerStart`'s own call site, not at package init). See + // {@link resolveDockerNetworkMode}'s doc comment for the full 3-way flag/env precedence. + const networkId = resolveDockerNetworkMode({ + explicit: Option.getOrUndefined(networkIdFlag), + envOverride: legacyViperEnvStringWithProjectFallback("SUPABASE_NETWORK_ID", projectEnvValues), projectId, - projectEnvValues, - ); + }); // Go's `DockerStart` unconditionally appends the Linux-only `host.docker.internal:host-gateway` // extra host for every container it starts (`docker_linux.go`; empty on darwin/windows, where // Docker Desktop already resolves that hostname). diff --git a/apps/cli/src/legacy/shared/legacy-docker-ids.ts b/apps/cli/src/legacy/shared/legacy-docker-ids.ts index b2f66ef2ff..4ac08af9cd 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-ids.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-ids.ts @@ -8,8 +8,6 @@ import { basename } from "node:path"; -import { legacyViperEnvStringWithProjectFallback } from "../../shared/legacy/legacy-viper-env.ts"; - /** * Resolve the project id Go feeds into `utils.DbId`/`utils.NetId`. viper sets * `Config.ProjectId` from config.toml's `project_id`, then `AutomaticEnv` overrides it @@ -84,35 +82,13 @@ export function localNetworkId(projectId: string) { return legacyServiceContainerName("network", projectId); } -/** - * `utils.NetId`/`DockerStart`'s network-mode resolution (`apps/cli-go/internal/utils/docker.go: - * 379-383`, `internal/utils/config.go:62`): an explicit `--network-id` flag wins, then - * `SUPABASE_NETWORK_ID` — `network-id` is one of the persistent flags Go binds to viper under - * `SetEnvPrefix("SUPABASE")` + `AutomaticEnv()` (`cmd/root.go:318-334`, same mechanism as - * `SUPABASE_YES`/`SUPABASE_EXPERIMENTAL`), and `viper.GetString("network-id")` reads the - * (dotenv-merged) process env fresh at `DockerStart`'s own call site — deep inside container - * bring-up, well after `Config.Load`'s dotenv pass already ran — unlike `utils.Config.Hostname`, - * which is fixed once via `GetHostname()` at the `utils` package's `var` init, before `main()` - * ever runs a command's `Config.Load` (see {@link legacyGetHostname}'s own doc comment for why a - * project-dotenv-only override does NOT reach that field). Only when both the flag and the env - * are absent does Go fall back to the generated `supabase_network_` name. - * - * `db start` and `start` both compute this identically — hoisted here (rather than duplicated in - * each handler) per the "hoist before you duplicate" rule (`apps/cli/CLAUDE.md`). - */ -export function legacyResolveNetworkId( - flagValue: string | undefined, - projectId: string, - projectEnvValues: Readonly>, -): string { - if (flagValue !== undefined && flagValue.length > 0) return flagValue; - const envNetworkId = legacyViperEnvStringWithProjectFallback( - "SUPABASE_NETWORK_ID", - projectEnvValues, - ); - if (envNetworkId.length > 0) return envNetworkId; - return localNetworkId(projectId); -} +// `utils.NetId`/`DockerStart`'s network-mode resolution has ONE home: +// `resolveDockerNetworkMode` (`shared/functions/functions-docker.ts`). An +// earlier `legacyResolveNetworkId` here fell through to `SUPABASE_NETWORK_ID` +// on an explicit-but-empty `--network-id=`, which viper's `find()` never does +// (a `Changed` pflag resolves BEFORE the env branch — see the shared helper's +// doc comment); `start`/`db start` now call the shared helper directly +// (review round on CLI-1963). /** Go's `utils.CliProjectLabel` (`apps/cli-go/internal/utils/docker.go:59`) — the * Docker label every container/volume/network created by `supabase start` carries. */ diff --git a/apps/cli/src/legacy/shared/legacy-docker-ids.unit.test.ts b/apps/cli/src/legacy/shared/legacy-docker-ids.unit.test.ts index adc825453f..9019cd3aa9 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-ids.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-ids.unit.test.ts @@ -4,12 +4,13 @@ import { LEGACY_CLI_PROJECT_LABEL, legacyCliProjectFilterValue, legacyResolveLocalProjectId, - legacyResolveNetworkId, legacySanitizeProjectId, legacyServiceContainerIds, localDbContainerId, localNetworkId, } from "./legacy-docker-ids.ts"; +import { resolveDockerNetworkMode } from "../../shared/functions/functions-docker.ts"; +import { legacyViperEnvStringWithProjectFallback } from "../../shared/legacy/legacy-viper-env.ts"; describe("legacyResolveLocalProjectId", () => { it("prefers SUPABASE_PROJECT_ID (env) over config.toml and the basename", () => { @@ -83,50 +84,59 @@ describe("legacyCliProjectFilterValue", () => { }); }); -describe("legacyResolveNetworkId", () => { +describe("resolveDockerNetworkMode composed with legacyViperEnvStringWithProjectFallback (start/db start call shape)", () => { const KEY = "SUPABASE_NETWORK_ID"; afterEach(() => { delete process.env[KEY]; }); + // `start`/`db start` resolve the network exactly like the `functions` + // Docker paths: the shared 3-way resolver fed by the viper-shaped + // shell/project-dotenv env read — one home, per the review round on + // CLI-1963 that deleted `legacyResolveNetworkId`'s divergent copy. + function resolve(flagValue: string | undefined, projectEnv: Record) { + return resolveDockerNetworkMode({ + explicit: flagValue, + envOverride: legacyViperEnvStringWithProjectFallback(KEY, projectEnv), + projectId: "my-app", + }); + } + it("prefers an explicit --network-id flag over everything else", () => { process.env[KEY] = "env-network"; - expect(legacyResolveNetworkId("flag-network", "my-app", { [KEY]: "toml-network" })).toBe( - "flag-network", - ); + expect(resolve("flag-network", { [KEY]: "toml-network" })).toBe("flag-network"); }); it("falls back to SUPABASE_NETWORK_ID (shell) when the flag is absent", () => { process.env[KEY] = "shell-network"; - expect(legacyResolveNetworkId(undefined, "my-app", {})).toBe("shell-network"); + expect(resolve(undefined, {})).toBe("shell-network"); }); it("falls back to SUPABASE_NETWORK_ID (project .env) when both the flag and shell are absent", () => { delete process.env[KEY]; - expect(legacyResolveNetworkId(undefined, "my-app", { [KEY]: "project-network" })).toBe( - "project-network", - ); + expect(resolve(undefined, { [KEY]: "project-network" })).toBe("project-network"); }); it("prefers the shell value over the project .env value (presence wins, matching godotenv.Load)", () => { process.env[KEY] = "shell-network"; - expect(legacyResolveNetworkId(undefined, "my-app", { [KEY]: "project-network" })).toBe( - "shell-network", - ); + expect(resolve(undefined, { [KEY]: "project-network" })).toBe("shell-network"); }); it("falls back to the generated network name when the flag and env are all absent/empty", () => { delete process.env[KEY]; - expect(legacyResolveNetworkId(undefined, "my-app", {})).toBe(localNetworkId("my-app")); - expect(legacyResolveNetworkId("", "my-app", {})).toBe(localNetworkId("my-app")); + expect(resolve(undefined, {})).toBe(localNetworkId("my-app")); + expect(resolve("", {})).toBe(localNetworkId("my-app")); + }); + + it("an explicit-but-empty --network-id= skips the env var entirely (viper: a Changed pflag resolves before AutomaticEnv)", () => { + process.env[KEY] = "env-network"; + expect(resolve("", { [KEY]: "project-network" })).toBe(localNetworkId("my-app")); }); it("treats an empty shell value as present (blocks the project value) and falls to generated", () => { process.env[KEY] = ""; - expect(legacyResolveNetworkId(undefined, "my-app", { [KEY]: "project-network" })).toBe( - localNetworkId("my-app"), - ); + expect(resolve(undefined, { [KEY]: "project-network" })).toBe(localNetworkId("my-app")); }); }); diff --git a/apps/cli/src/legacy/shared/legacy-functions-go-config.ts b/apps/cli/src/legacy/shared/legacy-functions-go-config.ts index aad8c51ec1..14c52a698b 100644 --- a/apps/cli/src/legacy/shared/legacy-functions-go-config.ts +++ b/apps/cli/src/legacy/shared/legacy-functions-go-config.ts @@ -52,7 +52,16 @@ export const legacyFunctionsGoConfigCompat: FunctionsGoConfigCompat = { return { loaded: context.loaded, projectEnvValues: context.projectEnvValues, - projectId: validated.projectId, + // `context.projectId`, NOT `validated.projectId`: the context's id is + // the one built for Docker naming/labels — sanitized, `--project-ref` + // defaulted, and `SUPABASE_PROJECT_ID`-gated when a `[remotes.]` + // block matched (Go installs the remote's own `project_id` at viper's + // OVERRIDE tier, above `AutomaticEnv` — `pkg/config/config.go:718-724`; + // see `legacy-local-project-context.ts`'s gate, review + // PRRT_kwDOErm0O86XHGDL). `validated.projectId` exists only to feed + // `legacyValidateResolvedConfig`'s emptiness check and deliberately + // skips that gate — see its own doc comment. + projectId: context.projectId, denoVersion: validated.edgeRuntimeDenoVersion, }; }), diff --git a/apps/cli/src/legacy/shared/legacy-local-project-context.ts b/apps/cli/src/legacy/shared/legacy-local-project-context.ts index 6f9dddaf85..08f87ea4c8 100644 --- a/apps/cli/src/legacy/shared/legacy-local-project-context.ts +++ b/apps/cli/src/legacy/shared/legacy-local-project-context.ts @@ -169,7 +169,6 @@ export const legacyLoadLocalProjectContext = ( // via the workdir basename default. Only a malformed file (`loadProjectConfig` failing rather // than returning `null`) is a hard error. const loaded = yield* loadProjectConfig(workdir, { - ...(projectRef === undefined ? {} : { projectRef }), projectEnv: projectEnv !== null ? { ...projectEnv, values: projectEnvValues } : undefined, search: false, // Go's `NewPathBuilder`/`Config.Load` (`pkg/config/utils.go:43-48`) only ever resolves diff --git a/apps/cli/src/next/commands/functions/deploy/deploy.integration.test.ts b/apps/cli/src/next/commands/functions/deploy/deploy.integration.test.ts index 29e841dd70..2447031ce7 100644 --- a/apps/cli/src/next/commands/functions/deploy/deploy.integration.test.ts +++ b/apps/cli/src/next/commands/functions/deploy/deploy.integration.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; import { makeApiClient, FunctionResponse } from "@supabase/api/effect"; -import { DEFAULT_VERSIONS } from "@supabase/stack/effect"; +import { dockerfileServiceImage } from "../../../../shared/services/dockerfile-images.ts"; import { BunServices } from "@effect/platform-bun"; import { createHash } from "node:crypto"; import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; @@ -2136,7 +2136,10 @@ describe("functions deploy", () => { useDocker: true, }).pipe(Effect.provide(layer)); - expect(child.spawned.at(-1)?.args).toContain("public.ecr.aws/supabase/edge-runtime:v9.9.9"); + // The pin's content is applied VERBATIM as the tag (Go's + // `replaceImageTag`, `pkg/config/utils.go:81-84`) — a bare `9.9.9` pin + // stays bare, with no `v` synthesized. + expect(child.spawned.at(-1)?.args).toContain("public.ecr.aws/supabase/edge-runtime:9.9.9"); }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); }); @@ -2672,11 +2675,7 @@ describe("functions deploy", () => { // candidate (a cache hit here) is spawned[1]. expect(child.spawned[1]).toEqual({ command: "docker", - args: [ - "image", - "inspect", - `public.ecr.aws/supabase/edge-runtime:v${DEFAULT_VERSIONS["edge-runtime"]}`, - ], + args: ["image", "inspect", `public.ecr.aws/${dockerfileServiceImage("edgeruntime")}`], }); }).pipe( Effect.ensuring(cleanupTempDir(tempDir)), diff --git a/apps/cli/src/next/commands/functions/download/download.integration.test.ts b/apps/cli/src/next/commands/functions/download/download.integration.test.ts index f740edf061..e43f3cdd15 100644 --- a/apps/cli/src/next/commands/functions/download/download.integration.test.ts +++ b/apps/cli/src/next/commands/functions/download/download.integration.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; import { FunctionResponse, makeApiClient } from "@supabase/api/effect"; -import { DEFAULT_VERSIONS } from "@supabase/stack/effect"; +import { dockerfileServiceImage } from "../../../../shared/services/dockerfile-images.ts"; import { existsSync, mkdtempSync } from "node:fs"; import { mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -2002,7 +2002,7 @@ describe("functions download", () => { (spawned) => spawned.command === "docker" && spawned.args[0] === "run", ); expect(runCommand?.args).toContain( - `public.ecr.aws/supabase/edge-runtime:v${DEFAULT_VERSIONS["edge-runtime"]}`, + `public.ecr.aws/${dockerfileServiceImage("edgeruntime")}`, ); }).pipe( Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), diff --git a/apps/cli/src/shared/cli/cobra-flag-groups.ts b/apps/cli/src/shared/cli/cobra-flag-groups.ts index 05081d4435..0d305fcec1 100644 --- a/apps/cli/src/shared/cli/cobra-flag-groups.ts +++ b/apps/cli/src/shared/cli/cobra-flag-groups.ts @@ -28,41 +28,6 @@ export function hasExplicitLongFlag( return false; } -/** - * Raw value of `--`/`--=value` anywhere in argv - * (unscoped — no command-path anchoring), or `undefined` if absent. - * pflag string flags are shared-variable, last-`Set()`-wins (same rule - * {@link explicitBooleanLongFlag} and `legacyPflagStringValue` already - * follow) — a repeated `-- old -- new` must resolve to - * `new`, so this keeps scanning after a match instead of returning early - * (review round on CLI-1963's `functions download` port). Preserves the - * 3-way distinction `undefined` (flag never passed) / `""` (explicit - * `--=`) / non-empty value — callers that need to distinguish - * "flag explicitly cleared" from "flag never touched" (e.g. - * `resolveDockerNetworkMode`'s env-fallback precedence — see its doc - * comment) need this rather than collapsing both to `undefined`. - */ -export function explicitStringFlag(rawArgs: ReadonlyArray, flagName: string) { - let result: string | undefined; - for (let index = 0; index < rawArgs.length; index += 1) { - const token = rawArgs[index]; - if (token === `--${flagName}`) { - result = rawArgs[index + 1]; - } else if (token?.startsWith(`--${flagName}=`)) { - result = token.slice(flagName.length + 3); - } - } - return result; -} - -/** - * Whether `--` (or `--=`) appears anywhere in argv, - * unscoped. - */ -export function hasGlobalLongFlag(rawArgs: ReadonlyArray, flagName: string) { - return rawArgs.some((token) => token === `--${flagName}` || token.startsWith(`--${flagName}=`)); -} - const PFLAG_BOOLEAN_FALSE_VALUES: ReadonlySet = new Set([ "0", "f", @@ -80,7 +45,7 @@ const PFLAG_BOOLEAN_FALSE_VALUES: ReadonlySet = new Set([ * `--` records pflag's bool `NoOptDefVal` (`true`); an inline value * is parsed through pflag's `strconv.ParseBool` false set — anything else * (including garbage) is truthy, same as `cast.ToBool`'s permissive default. - * Unlike {@link hasGlobalLongFlag}, this distinguishes `--=false` + * Unlike a bare presence scan, this distinguishes `--=false` * from presence alone, which matters for Go call sites gated on * `viper.GetBool` rather than "was the flag passed at all". */ diff --git a/apps/cli/src/shared/cli/cobra-flag-groups.unit.test.ts b/apps/cli/src/shared/cli/cobra-flag-groups.unit.test.ts index 03e5e08dd6..a502e5df3d 100644 --- a/apps/cli/src/shared/cli/cobra-flag-groups.unit.test.ts +++ b/apps/cli/src/shared/cli/cobra-flag-groups.unit.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "vitest"; import { cobraMutuallyExclusiveErrorMessage, + explicitBooleanLongFlag, hasExplicitLongFlag, lastExplicitLongFlagValue, PERSISTENT_VALUE_FLAG_NAMES, @@ -70,6 +71,40 @@ describe("lastExplicitLongFlagValue", () => { }); }); +describe("explicitBooleanLongFlag", () => { + test("a bare flag records pflag's NoOptDefVal true", () => { + expect(explicitBooleanLongFlag(["--debug"], "debug")).toBe(true); + }); + + test("an inline =false records false", () => { + expect(explicitBooleanLongFlag(["--debug=false"], "debug")).toBe(false); + }); + + test("an inline =0 records false, matching pflag's ParseBool false set", () => { + expect(explicitBooleanLongFlag(["--debug=0"], "debug")).toBe(false); + }); + + test("an inline =F records false, matching pflag's ParseBool false set", () => { + expect(explicitBooleanLongFlag(["--debug=F"], "debug")).toBe(false); + }); + + test("an inline =true records true", () => { + expect(explicitBooleanLongFlag(["--debug=true"], "debug")).toBe(true); + }); + + test("a garbage inline value is truthy, matching pflag's permissive cast", () => { + expect(explicitBooleanLongFlag(["--debug=yes"], "debug")).toBe(true); + }); + + test("repeated occurrences resolve last-wins", () => { + expect(explicitBooleanLongFlag(["--debug", "--debug=false"], "debug")).toBe(false); + }); + + test("returns undefined when the flag never appears", () => { + expect(explicitBooleanLongFlag(["--other"], "debug")).toBeUndefined(); + }); +}); + describe("pflagArgvScan", () => { const SSO_UPDATE_PATH = ["sso", "update"] as const; const SPEC = { diff --git a/apps/cli/src/shared/cli/hidden-flag.unit.test.ts b/apps/cli/src/shared/cli/hidden-flag.unit.test.ts index 3c1e03e6bc..13d81e6ae5 100644 --- a/apps/cli/src/shared/cli/hidden-flag.unit.test.ts +++ b/apps/cli/src/shared/cli/hidden-flag.unit.test.ts @@ -138,9 +138,13 @@ describe("native hidden flags", () => { ]).pipe(Effect.exit); expect(JSON.stringify(stopExit)).not.toContain("UnrecognizedFlag"); // `functions download --use-docker` now runs the native Docker-unbundle - // path (CLI-1963) instead of forwarding to `LegacyGoProxy` — it can fail - // for Docker-related reasons in this proxy-only test layer, same as - // `start`/`stop` above, so this only proves the hidden flag still parses. + // path (CLI-1963) instead of forwarding to `LegacyGoProxy` — the + // deliberately-invalid slug makes it fail at `validateSlug` + // (`download.ts`, checked BEFORE `isDockerRunning`/any image pull), + // so the invocation stays fast and side-effect-free even on a CI + // runner with a live Docker daemon (a valid slug here triggered a + // real multi-second `docker pull` and timed this test out), while + // still proving the hidden flag parses by exact name. // `--legacy-bundle` is the one remaining case that still forwards to the // proxy, asserted below. const downloadUseDockerExit = yield* Command.runWith(legacyTestRoot, { @@ -148,7 +152,7 @@ describe("native hidden flags", () => { })([ "functions", "download", - "hello", + "Not_A_Valid-Slug!", "--project-ref", "abcdefghijklmnopqrst", "--use-docker", diff --git a/apps/cli/src/shared/functions/deploy.ts b/apps/cli/src/shared/functions/deploy.ts index ef3d8a9f7a..54a0b25911 100644 --- a/apps/cli/src/shared/functions/deploy.ts +++ b/apps/cli/src/shared/functions/deploy.ts @@ -23,11 +23,12 @@ import { legacyViperEnvStringWithProjectFallback } from "../legacy/legacy-viper- import { findGitRootPath } from "../git/git-root.ts"; import { cobraMutuallyExclusiveErrorMessage, - explicitStringFlag, + explicitBooleanLongFlag, hasExplicitLongFlag, - hasGlobalLongFlag, + lastExplicitLongFlagValue, } from "../cli/cobra-flag-groups.ts"; import { + edgeRuntimeImage, FUNCTIONS_BUNDLER_MUTEX_GROUP, invalidFunctionSlugDetail, validateFunctionSlugMessage, @@ -40,7 +41,6 @@ import { } from "./deploy.errors.ts"; import { buildFunctionsDockerRunArgs, - edgeRuntimeImageTag, ensureDockerNamedVolume, ensureDockerNetwork, isDockerRunning, @@ -1328,13 +1328,16 @@ const bundleFunctionWithDocker = Effect.fnUntraced(function* ( ); // Go: `DockerStart` -> `DockerResolveImageIfNotCached` (`internal/utils/docker.go:326-386`) // — resolves ECR->GHCR->Docker-Hub candidates and pulls with retry, per - // container, before ever touching the network/volume. + // container, before ever touching the network/volume. Deliberately NOT + // hoisted out of the per-function loop the way `download.ts`'s + // `PulledEdgeRuntimeImage` is: per-slug matches Go's per-container + // `DockerStart` exactly, and the first resolve failure aborts the loop, + // so the only cost is one cached `docker image inspect` per function. const image = yield* resolveFunctionsDockerImage( - // `edgeRuntimeImageTag`, not a bare `v${edgeRuntimeVersion}` prepend — - // `edgeRuntimeVersion` can come from a `.temp/edge-runtime-version` pin - // that's already `v`-prefixed (see the helper's doc in - // `functions-docker.ts`); blindly prepending `v` double-prefixes it. - `supabase/edge-runtime:${edgeRuntimeImageTag(edgeRuntimeVersion)}`, + // `edgeRuntimeImage` applies the tag VERBATIM (Go's `replaceImageTag`) + // — a `.temp/edge-runtime-version` pin flows through unmodified, `v` + // prefix or not (see the helper's doc in `functions.shared.ts`). + edgeRuntimeImage(edgeRuntimeVersion), projectEnvValues, ); yield* ensureDockerNetwork(networkMode, projectId); @@ -1376,6 +1379,11 @@ const bundleFunctionWithDocker = Effect.fnUntraced(function* ( networkMode, binds, env, + // Go: `WorkingDir: utils.ToDockerPath(cwd)` (`bundle.go:79`), where + // `cwd` is the post-`ChangeWorkDir` workdir — `functionsDir` is + // `/supabase/functions`, same derivation as `deployViaApi`'s + // own `projectRoot`. + workingDir: toDockerPath(resolve(functionsDir, "..", "..")), containerArgs, }); @@ -2251,7 +2259,12 @@ export function deployFunctions( "no-verify-jwt", flags.noVerifyJwt, ); - const debugEnabled = hasGlobalLongFlag(dependencies.rawArgs, "debug"); + // Go gates the bundler's `--verbose` on `viper.GetBool("DEBUG")` + // (`bundle.go:59`), so `--debug=false` must resolve to `false` — a plain + // presence check would get that backwards (same rule as `download.ts`'s + // own `--debug` read; the `SUPABASE_DEBUG` env fallback is deferred + // there too). + const debugEnabled = explicitBooleanLongFlag(dependencies.rawArgs, "debug") ?? false; const deployConfig = context.loaded?.config; const edgeRuntimeVersion = yield* resolveEdgeRuntimeVersion( context.denoVersion, @@ -2330,14 +2343,14 @@ export function deployFunctions( return yield* deployWithApi; } - // `explicitStringFlag` preserves the "explicitly cleared" vs + // `lastExplicitLongFlagValue` preserves the "explicitly cleared" vs // "never touched" distinction `resolveDockerNetworkMode` needs to // decide whether `SUPABASE_NETWORK_ID` applies — see that // function's own doc comment. `SUPABASE_NETWORK_ID` (env or // project dotenv) is legacy-shell-only — same Go-viper-parity gate // as `context.projectEnvValues` itself (`undefined` in `next`). const networkMode = resolveDockerNetworkMode({ - explicit: explicitStringFlag(dependencies.rawArgs, "network-id"), + explicit: lastExplicitLongFlagValue(dependencies.rawArgs, [], "network-id"), envOverride: context.projectEnvValues === undefined ? undefined diff --git a/apps/cli/src/shared/functions/download.ts b/apps/cli/src/shared/functions/download.ts index aa08d98d1f..5f15a577c5 100644 --- a/apps/cli/src/shared/functions/download.ts +++ b/apps/cli/src/shared/functions/download.ts @@ -11,14 +11,13 @@ import { Output } from "../output/output.service.ts"; import { cobraMutuallyExclusiveErrorMessage, explicitBooleanLongFlag, - explicitStringFlag, + lastExplicitLongFlagValue, hasExplicitLongFlag, } from "../cli/cobra-flag-groups.ts"; import { legacyDescribeContainerCliFailure } from "../../legacy/shared/legacy-container-cli.ts"; import { legacyViperEnvStringWithProjectFallback } from "../legacy/legacy-viper-env.ts"; import { buildFunctionsDockerRunArgs, - edgeRuntimeImageTag, ensureDockerNamedVolume, ensureDockerNetwork, isDockerRunning, @@ -30,6 +29,7 @@ import { } from "./functions-docker.ts"; import { loadFunctionsProjectConfig, type FunctionsGoConfigCompat } from "./functions-config.ts"; import { + edgeRuntimeImage, FUNCTIONS_BUNDLER_MUTEX_GROUP, invalidFunctionSlugDetail, validateFunctionSlugMessage, @@ -956,12 +956,12 @@ const resolveEdgeRuntimeImage = Effect.fnUntraced(function* ( return { projectId: context.projectId, denoVersion: context.denoVersion, - // `edgeRuntimeImageTag` (not a bare `v${edgeRuntimeVersion}` prepend) — - // `dependencies.edgeRuntimeVersion` comes from a `.temp/edge-runtime-version` - // pin that may already carry its own `v` prefix (see the helper's doc). - // Registry mapping + pull-with-retry happens per-container, right before + // `edgeRuntimeImage` applies the tag VERBATIM (Go's `replaceImageTag`) — + // a `.temp/edge-runtime-version` pin flows through unmodified, `v` prefix + // or not (see the helper's doc in `functions.shared.ts`). Registry + // mapping + pull-with-retry happens per-container, right before // `ensureDockerNetwork`, matching Go's `DockerStart` (see the caller). - rawImage: `supabase/edge-runtime:${edgeRuntimeImageTag(edgeRuntimeVersion)}`, + rawImage: edgeRuntimeImage(edgeRuntimeVersion), projectEnvValues: context.projectEnvValues, }; }); @@ -1045,7 +1045,7 @@ const downloadWithDockerUnbundle = Effect.fnUntraced(function* ( // occurrence's boolean value instead (`explicitBooleanLongFlag`), falling // back to `false` (cleanup runs) when `--debug` never appears. `SUPABASE_DEBUG` // env-var fallback is a separate, pre-existing gap shared with every other - // `hasGlobalLongFlag(rawArgs, "debug")` call site in this file family + // presence-only `--debug` read this file family used to have // (e.g. `deploy.ts`) and the legacy debug logger itself, none of which // currently honor it either — left open rather than fixed piecemeal here. const debugEnabled = explicitBooleanLongFlag(dependencies.rawArgs, "debug") ?? false; @@ -1064,14 +1064,14 @@ const downloadWithDockerUnbundle = Effect.fnUntraced(function* ( // Go: `viper.GetString("network-id")` else `NetId` (`docker.go:379-383`) — // `--network-id` is a persistent root flag (`cmd/root.go:328`), not - // registered on `functions download` itself. `explicitStringFlag` + // registered on `functions download` itself. `lastExplicitLongFlagValue` // preserves the "explicitly cleared" vs "never touched" distinction // `resolveDockerNetworkMode` needs to decide whether `SUPABASE_NETWORK_ID` // applies — see that function's own doc comment. `SUPABASE_NETWORK_ID` // (env or project dotenv) is legacy-shell-only — same Go-viper-parity gate // as `projectEnvValues` itself (`undefined` in `next`). const networkMode = resolveDockerNetworkMode({ - explicit: explicitStringFlag(dependencies.rawArgs, "network-id"), + explicit: lastExplicitLongFlagValue(dependencies.rawArgs, [], "network-id"), envOverride: projectEnvValues === undefined ? undefined diff --git a/apps/cli/src/shared/functions/functions-config.ts b/apps/cli/src/shared/functions/functions-config.ts index 0c1d2f4b2a..7202e6af6a 100644 --- a/apps/cli/src/shared/functions/functions-config.ts +++ b/apps/cli/src/shared/functions/functions-config.ts @@ -1,6 +1,7 @@ import { basename } from "node:path"; import { Effect, type FileSystem, type Path } from "effect"; import { loadProjectConfig, type LoadedProjectConfig } from "@supabase/config"; +import { normalizeProjectId } from "./functions-docker.ts"; /** * Everything the native `functions` Docker paths (`deploy`/`download`/`serve`) @@ -73,7 +74,15 @@ export const loadFunctionsProjectConfig = Effect.fnUntraced(function* (input: { // `undefined` and no `project_id` in the file — matching Go's `Eject` // basename default (`pkg/config/config.go:561-570`) and the legacy // branch's own `legacyResolveLocalProjectId` fallback below. - projectId: loaded?.config.project_id ?? input.projectRef ?? basename(input.projectRoot), + // Sanitized like the legacy branch's (`legacySanitizeProjectId`, run + // inside its validate pipeline): this id feeds `dockerProjectLabels`' + // raw label values as well as `localDockerId`'s (self-sanitizing) + // resource names, and an unsanitized `project_id = "My Project"` would + // label the container `My Project` while its network/volume are named + // `..._My_Project` — breaking label-based cleanup filters. + projectId: normalizeProjectId( + loaded?.config.project_id ?? input.projectRef ?? basename(input.projectRoot), + ), denoVersion: loaded?.config.edge_runtime.deno_version, } satisfies FunctionsProjectConfigContext; } diff --git a/apps/cli/src/shared/functions/functions-docker.ts b/apps/cli/src/shared/functions/functions-docker.ts index 0e4a7620ab..d5872ea2c2 100644 --- a/apps/cli/src/shared/functions/functions-docker.ts +++ b/apps/cli/src/shared/functions/functions-docker.ts @@ -1,6 +1,6 @@ // Docker orchestration primitives shared by `deploy.ts` and `download.ts` // (the `functions` command family root, `src/shared/functions/`) — plus -// `serve.ts` (same family) and `legacy/commands/start/lib/container-lifecycle.ts` +// `serve.ts` (same family) and `legacy/shared/db-bootstrap/container-lifecycle.ts` // (a different family, reaching in for the generic `isUserDefinedDockerNetwork` // predicate), both of which already imported these primitives from `deploy.ts` // before this file existed. @@ -12,7 +12,10 @@ import { legacyMakeDockerImageResolver } from "../../legacy/shared/legacy-docker const INVALID_PROJECT_ID = /[^a-zA-Z0-9_.-]+/g; const MAX_PROJECT_ID_LENGTH = 40; -const DENO1_EDGE_RUNTIME_VERSION = "1.68.4"; +// Go's `deno1` image tag (`pkg/config/constants.go:15`, +// `supabase/edge-runtime:v1.68.4`) — a full tag, since tags flow verbatim +// into `edgeRuntimeImage` (`functions.shared.ts`) with no `v` synthesis. +const DENO1_EDGE_RUNTIME_VERSION = "v1.68.4"; export function toSlash(pathname: string) { return pathname.replaceAll("\\", "/"); @@ -46,7 +49,7 @@ export function localDockerId(name: string, projectId: string) { * (flag explicitly cleared) skips straight to the generated default, same * as a non-empty `explicit` skips it by using the flag's own value. Callers * MUST pass a flag reader that preserves this 3-way distinction — see - * `explicitStringFlag`. + * `lastExplicitLongFlagValue` (`shared/cli/cobra-flag-groups.ts`). * `envOverride` is `undefined` in `next` (no Go-viper env-binding claim * there) — see `resolveDockerNetworkMode`'s callers. */ @@ -88,6 +91,12 @@ export interface FunctionsDockerRunSpec { readonly binds: ReadonlyArray; /** `KEY=VALUE` entries, each emitted as `-e KEY=VALUE`. */ readonly env?: ReadonlyArray; + /** + * Emitted as `-w ` — Go's bundler sets `WorkingDir: + * utils.ToDockerPath(cwd)` (`bundle.go:79`); the unbundler sets none + * (`download.go:268-281`), so this is optional. + */ + readonly workingDir?: string; /** argv after the image, e.g. `["bundle", "--entrypoint", …]`. */ readonly containerArgs: ReadonlyArray; readonly platform?: NodeJS.Platform; @@ -113,6 +122,9 @@ export function buildFunctionsDockerRunArgs(spec: FunctionsDockerRunSpec): Array for (const env of spec.env ?? []) { command.push("-e", env); } + if (spec.workingDir !== undefined) { + command.push("-w", spec.workingDir); + } const labels = dockerProjectLabels(spec.projectId); command.push( "--label", @@ -154,6 +166,11 @@ function collectByteStream( // `docker`, so the spawn goes through `spawnContainerCli` to fall back to // `podman` on Docker-less hosts. `command` is retained for the extendEnv // default and the `functions serve` dependency-injection seam. +// `Effect.scoped` closes the spawn's own acquireRelease scope as soon as the +// process has exited and both streams are drained — without it, every call +// parks a release finalizer in the CALLER's scope, and `functions serve`'s +// session-long restart loop (one `Effect.scoped` around an infinite loop) +// would accumulate one per docker invocation per file-change restart. export const runChildProcess = Effect.fnUntraced(function* ( command: string, args: ReadonlyArray, @@ -168,28 +185,32 @@ export const runChildProcess = Effect.fnUntraced(function* ( readonly onStderr?: (chunk: string) => Effect.Effect; } = {}, ) { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const child = yield* spawnContainerCli(spawner, [...args], { - stdin: "ignore", - stdout: opts.stdout ?? "pipe", - stderr: opts.stderr ?? "pipe", - env: opts.env, - extendEnv: opts.extendEnv ?? command === "docker", - }); + return yield* Effect.scoped( + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const child = yield* spawnContainerCli(spawner, [...args], { + stdin: "ignore", + stdout: opts.stdout ?? "pipe", + stderr: opts.stderr ?? "pipe", + env: opts.env, + extendEnv: opts.extendEnv ?? command === "docker", + }); - const [stdout, stderr, exitCode] = yield* Effect.all( - [ - opts.stdout === "ignore" - ? Effect.succeed("") - : collectByteStream(child.stdout, opts.onStdout), - opts.stderr === "ignore" - ? Effect.succeed("") - : collectByteStream(child.stderr, opts.onStderr), - child.exitCode.pipe(Effect.map(Number)), - ], - { concurrency: "unbounded" }, + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + opts.stdout === "ignore" + ? Effect.succeed("") + : collectByteStream(child.stdout, opts.onStdout), + opts.stderr === "ignore" + ? Effect.succeed("") + : collectByteStream(child.stderr, opts.onStderr), + child.exitCode.pipe(Effect.map(Number)), + ], + { concurrency: "unbounded" }, + ); + return { exitCode, stdout, stderr }; + }), ); - return { exitCode, stdout, stderr }; }); // Go: `container.NetworkMode.IsContainer()` (`docker/api/types/container/hostconfig.go:152-155`, @@ -294,6 +315,15 @@ export const isDockerRunning = Effect.fnUntraced(function* () { return result.exitCode === 0; }); +/** + * Resolves the edge-runtime image TAG (fed verbatim into + * `edgeRuntimeImage`, `functions.shared.ts` — Go's `replaceImageTag` + * semantics, no `v` synthesis). `defaultVersion` is the + * `supabase/.temp/edge-runtime-version` pin when present, else the + * Dockerfile default tag (`resolveEdgeRuntimeVersionPin`); `deno_version = 1` + * overrides EITHER with Go's `deno1` image tag, matching `Config.Validate` + * running after the pin was applied (`pkg/config/config.go:847-849,1164-1169`). + */ export function resolveEdgeRuntimeVersion( denoVersion: number | undefined, defaultVersion: string, @@ -309,24 +339,6 @@ export function resolveEdgeRuntimeVersion( ); } -/** - * Formats a resolved edge-runtime version as a Docker tag, tolerating a - * pin that's already `v`-prefixed. `resolveEdgeRuntimeVersion`'s own - * defaults are bare (`"1.74.2"`, `DENO1_EDGE_RUNTIME_VERSION`), but a value - * sourced from `supabase/.temp/edge-runtime-version` can legitimately be - * either form — Go's `replaceImageTag` (`pkg/config/utils.go:81-84`) appends - * the pin file's raw content verbatim after the image's `:`, and both forms - * are exercised elsewhere in this codebase (`legacy-edge-runtime-image.ts`'s - * own `replaceImageTag` port, and its and `services.integration.test.ts`'s - * `"v9.9.9"` fixtures alongside `deploy.integration.test.ts`'s bare - * `"9.9.9"`). Blindly prepending `v` — as every caller below did before this - * helper existed — double-prefixes an already-`v`-prefixed pin - * (`supabase/edge-runtime:vv9.9.9`), which docker then simply fails to pull. - */ -export function edgeRuntimeImageTag(version: string): string { - return version.startsWith("v") ? version : `v${version}`; -} - /** * Go: `DockerStart` -> `DockerResolveImageIfNotCached`/`DockerImagePullWithRetry` * (`internal/utils/docker.go:304-348,366-370`) — checks every registry diff --git a/apps/cli/src/shared/functions/functions-docker.unit.test.ts b/apps/cli/src/shared/functions/functions-docker.unit.test.ts index a784511b5a..60eaef8ea2 100644 --- a/apps/cli/src/shared/functions/functions-docker.unit.test.ts +++ b/apps/cli/src/shared/functions/functions-docker.unit.test.ts @@ -1,11 +1,50 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from "@effect/vitest"; +import { Deferred, Effect, Layer, Sink, Stream } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; import { buildFunctionsDockerRunArgs, localDockerId, resolveDockerNetworkMode, + runChildProcess, } from "./functions-docker.ts"; +/** + * A `ChildProcessSpawner` layer whose handle emits exactly the given raw + * `Uint8Array` chunks on stdout/stderr — unlike the shared + * `mockChildProcessSpawner` (`packages/process-compose/tests/helpers/mocks.ts`), + * which encodes one full line per chunk, this lets a test place an arbitrary + * byte boundary mid-codepoint to exercise `collectByteStream`'s per-stream + * `TextDecoder` buffering. + */ +function mockStreamingChildProcessLayer( + opts: { + readonly stdout?: ReadonlyArray; + readonly stderr?: ReadonlyArray; + } = {}, +) { + const spawner = ChildProcessSpawner.make(() => + Effect.gen(function* () { + const exitDeferred = yield* Deferred.make(); + yield* Deferred.succeed(exitDeferred, ChildProcessSpawner.ExitCode(0)); + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + stdout: Stream.fromIterable(opts.stdout ?? []), + stderr: Stream.fromIterable(opts.stderr ?? []), + all: Stream.empty, + exitCode: Deferred.await(exitDeferred), + isRunning: Effect.succeed(false), + stdin: Sink.drain, + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + }), + ); + return Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner); +} + describe("buildFunctionsDockerRunArgs", () => { it("assembles run/--rm, binds, network, env, labels, image, and container args in order", () => { const args = buildFunctionsDockerRunArgs({ @@ -197,3 +236,61 @@ describe("resolveDockerNetworkMode", () => { expect(result).toBe("supabase_network_my-project"); }); }); + +describe("runChildProcess", () => { + it.effect( + "tees a multi-byte UTF-8 character split across a chunk boundary, decoding it correctly in both the live tee and the accumulated stdout, and never tees an empty string", + () => + Effect.gen(function* () { + // "café"'s bytes are [c, a, f, 0xC3, 0xA9] — "é" is the 2-byte sequence + // 0xC3 0xA9. Chunk 1 ends right after the leading byte (incomplete on + // its own); chunk 2 is a genuinely empty chunk (decodes to "", must + // never be teed); chunk 3 carries only the trailing byte, completing + // "é" once joined with the decoder's buffered leading byte. + const full = new TextEncoder().encode("café"); + const chunk1 = full.slice(0, 4); + const chunk2 = new Uint8Array(0); + const chunk3 = full.slice(4); + + const stdoutTee: Array = []; + const result = yield* runChildProcess("docker", ["logs"], { + onStdout: (chunk) => Effect.sync(() => stdoutTee.push(chunk)), + }).pipe( + Effect.provide(mockStreamingChildProcessLayer({ stdout: [chunk1, chunk2, chunk3] })), + ); + + expect(result.stdout).toBe("café"); + // The teed chunks, concatenated, must equal the returned stdout exactly. + expect(stdoutTee.join("")).toBe(result.stdout); + expect(stdoutTee).not.toContain(""); + expect(stdoutTee.every((chunk) => chunk.length > 0)).toBe(true); + }), + ); + + it.effect("tees stderr independently of stdout, both live and in the returned strings", () => + Effect.gen(function* () { + const encoder = new TextEncoder(); + const stdoutChunks = [encoder.encode("stdout-"), encoder.encode("chunk")]; + const stderrChunks = [encoder.encode("stderr-"), encoder.encode("chunk")]; + + const stdoutTee: Array = []; + const stderrTee: Array = []; + const result = yield* runChildProcess("docker", ["logs"], { + onStdout: (chunk) => Effect.sync(() => stdoutTee.push(chunk)), + onStderr: (chunk) => Effect.sync(() => stderrTee.push(chunk)), + }).pipe( + Effect.provide( + mockStreamingChildProcessLayer({ stdout: stdoutChunks, stderr: stderrChunks }), + ), + ); + + expect(result.stdout).toBe("stdout-chunk"); + expect(result.stderr).toBe("stderr-chunk"); + expect(stdoutTee.join("")).toBe(result.stdout); + expect(stderrTee.join("")).toBe(result.stderr); + // Neither stream's tee ever observes so much as a fragment of the other. + expect(stdoutTee.some((chunk) => chunk.includes("stderr"))).toBe(false); + expect(stderrTee.some((chunk) => chunk.includes("stdout"))).toBe(false); + }), + ); +}); diff --git a/apps/cli/src/shared/functions/functions.shared.ts b/apps/cli/src/shared/functions/functions.shared.ts index 7f7b108e2a..63f740c849 100644 --- a/apps/cli/src/shared/functions/functions.shared.ts +++ b/apps/cli/src/shared/functions/functions.shared.ts @@ -22,12 +22,31 @@ export const FUNCTIONS_PROJECT_REF_SAFE_FLAGS = ["project-ref"] as const; // (`cmd/functions.go:158,182`). export const FUNCTIONS_BUNDLER_MUTEX_GROUP = ["use-api", "use-docker", "legacy-bundle"] as const; -// Go: `Images.EdgeRuntime`'s default tag is baked into the binary via the -// embedded Dockerfile (`legacy-edge-runtime-image.ts` reads the same source) -// — sourced from there rather than `@supabase/stack`'s independently- -// maintained catalog, so a Dockerfile pin bump can never drift from what the -// `functions` Docker paths resolve. -const DEFAULT_EDGE_RUNTIME_TAG = dockerfileServiceImage("edgeruntime").split(":")[1] ?? ""; +// Go: `Images.EdgeRuntime` is baked into the binary via the embedded +// Dockerfile (`pkg/config/constants.go:40-58`; `legacy-edge-runtime-image.ts` +// reads the same source) — sourced from there rather than `@supabase/stack`'s +// independently-maintained catalog, so a Dockerfile pin bump can never drift +// from what the `functions` Docker paths resolve. +const DEFAULT_EDGE_RUNTIME_IMAGE = dockerfileServiceImage("edgeruntime"); +const DEFAULT_EDGE_RUNTIME_TAG = DEFAULT_EDGE_RUNTIME_IMAGE.split(":")[1] ?? ""; + +/** + * Go: `replaceImageTag(Images.EdgeRuntime, tag)` (`pkg/config/utils.go:81-84`) + * — everything after the image's first `:` is replaced with `tag` VERBATIM, + * no `v` synthesis. A bare pin like `latest` or `9.9.9` therefore produces + * `supabase/edge-runtime:latest`/`:9.9.9`, exactly as Go does (an earlier + * revision `v`-prefixed bare pins here, which broke pins that work in Go and + * made this path disagree with `legacy-edge-runtime-image.ts`'s faithful + * `replaceImageTag` port reading the SAME pin file — review round on + * CLI-1963). Both non-pin sources are already full tags: the Dockerfile + * default above and `resolveEdgeRuntimeVersion`'s deno-1 constant. + * Single home for the repository too — only the tag half is parameterized, + * so a `supabase/edge-runtime` rename in the Dockerfile propagates whole. + */ +export function edgeRuntimeImage(tag: string): string { + const index = DEFAULT_EDGE_RUNTIME_IMAGE.indexOf(":"); + return DEFAULT_EDGE_RUNTIME_IMAGE.slice(0, index + 1) + tag.trim(); +} /** * Go: `Config.EdgeRuntime.Image` reflects `supabase/.temp/edge-runtime-version` diff --git a/apps/cli/src/shared/functions/serve.ts b/apps/cli/src/shared/functions/serve.ts index 4a91a08b50..2ef8eecfea 100644 --- a/apps/cli/src/shared/functions/serve.ts +++ b/apps/cli/src/shared/functions/serve.ts @@ -58,7 +58,6 @@ import { } from "./deploy.ts"; import { dockerProjectLabels, - edgeRuntimeImageTag, ensureDockerNamedVolume, ensureDockerNetwork, localDockerId, @@ -70,7 +69,7 @@ import { toDockerPath, } from "./functions-docker.ts"; import { loadFunctionsProjectConfig, type FunctionsGoConfigCompat } from "./functions-config.ts"; -import { resolveEdgeRuntimeVersionPin } from "./functions.shared.ts"; +import { edgeRuntimeImage, resolveEdgeRuntimeVersionPin } from "./functions.shared.ts"; const decodeProjectConfig = Schema.decodeUnknownSync(ProjectConfigSchema); const defaultProjectConfig = decodeProjectConfig({}); @@ -782,12 +781,16 @@ const resolveServeConfig = Effect.fnUntraced(function* ( // env lookups, this file's own caller) and the env-overridden // `deno_version` are consumed from it; `auth`/`apiPort`/functions above // keep their existing derivation. `projectId` also keeps its existing - // derivation — a known, narrow gap: unlike `deploy`/`download` (which use - // `context.projectId` outright), `rawProjectId` below only ever sees - // `SUPABASE_PROJECT_ID` from the *ambient* shell (`projectIdOverride`, from - // `LegacyCliConfig`), not from project dotenv, so a project that sets it - // only in `supabase/.env` gets a different container/network/volume name - // than `deploy`/`download` would resolve for the same project. Folding + // derivation — a known gap, narrow to trigger but NOT cosmetic when hit: + // unlike `deploy`/`download` (which use `context.projectId` outright), + // `rawProjectId` below only ever sees `SUPABASE_PROJECT_ID` from the + // *ambient* shell (`projectIdOverride`, from `LegacyCliConfig`), not from + // project dotenv. A project that sets it only in `supabase/.env` therefore + // gets a different `supabase_edge_runtime_`/`supabase_network_` + // here than `deploy`/`download`/`start` resolve for the SAME project — so + // `serve` creates a second network and a container `reloadKong(projectId)`'s + // Kong (named off the other id) can't route to: a silently non-functional + // `serve`, where Go reads one `Config.ProjectId` for everything. Folding // `goContext.projectEnvValues` in here would also require reconciling this // function's `projectIdOverride`-wins-unconditionally precedence with // `legacyResolveLocalProjectId`'s config-file-wins-over-`projectRef` @@ -1874,7 +1877,7 @@ const startEdgeRuntime = Effect.fnUntraced(function* (input: { // just later) rather than a hasty change to shared, `start`-critical // code (review round on CLI-1963). const image = yield* resolveFunctionsDockerImage( - `supabase/edge-runtime:${edgeRuntimeImageTag(edgeRuntimeVersion)}`, + edgeRuntimeImage(edgeRuntimeVersion), resolved.projectEnvValues, ); From 396e25c5367e9a8dcd1a19631dde4ef9f91e3f95 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Tue, 11 Aug 2026 14:55:29 +0100 Subject: [PATCH 22/22] fix(functions): close remaining styling-parity gaps in download suggestions (review: CLI-1963) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Go's suggestDenoV2 bolds the config path (utils.Bold(utils.ConfigPath)) and downloadAll's slug-validation suggestion wraps the slug in utils.Aqua — both rendered plain in the TS port. Thread the existing styleEmphasis/styleAqua hooks through so the legacy shell matches byte for byte; next stays plain via the existing identity-fallback. --- apps/cli/src/shared/functions/download.ts | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/apps/cli/src/shared/functions/download.ts b/apps/cli/src/shared/functions/download.ts index 5f15a577c5..d6beca0b38 100644 --- a/apps/cli/src/shared/functions/download.ts +++ b/apps/cli/src/shared/functions/download.ts @@ -207,14 +207,18 @@ function validateSlug(slug: string): Effect.Effect` argument), which fails with a plain `InvalidFunctionSlugError` and no * "failed to download function" prefix or suggestion. */ -function validateRemoteSlug(slug: string): Effect.Effect { +function validateRemoteSlug( + slug: string, + styleAqua: (text: string) => string = (text) => text, +): Effect.Effect { if (validateFunctionSlugMessage(slug) === undefined) { return Effect.void; } return Effect.fail( Object.assign(new Error(`failed to download function ${slug}: ${invalidFunctionSlugDetail}`), { - suggestion: `The Supabase API returned an unexpected function slug (${slug}). Retry the command, and if this keeps happening, verify your network connection is not being intercepted before contacting Supabase support.`, + // Go: `utils.Aqua(f.Slug)` (`download.go:185`). + suggestion: `The Supabase API returned an unexpected function slug (${styleAqua(slug)}). Retry the command, and if this keeps happening, verify your network connection is not being intercepted before contacting Supabase support.`, }), ); } @@ -892,10 +896,12 @@ function suggestLegacyBundle( return `\nIf your function is deployed using CLI < 1.120.0, trying running ${styleAqua(`supabase functions download --legacy-bundle ${slug}`)} instead.`; } -function suggestDenoV2(): string { +function suggestDenoV2(styleEmphasis: (text: string) => string = (text) => text): string { // Go: `suggestDenoV2` (`download.go:306-312`), verbatim including its - // trailing newline. - return "Please use deno v2 in supabase/config.toml to download this Function:\n\n[edge_runtime]\ndeno_version = 2\n"; + // trailing newline. Go bolds `utils.ConfigPath` via `utils.Bold` — the + // same hook `styleEmphasis` already covers for the slug above + // (`downloadOne`, `download.go:219`). + return `Please use deno v2 in ${styleEmphasis("supabase/config.toml")} to download this Function:\n\n[edge_runtime]\ndeno_version = 2\n`; } /** @@ -1146,7 +1152,7 @@ const downloadWithDockerUnbundle = Effect.fnUntraced(function* ( .split(/\r?\n/) .some((line) => line.trim().toLowerCase() === "invalid eszip v2"); const suggestion = - (invalidEszipV2 ? suggestDenoV2() : "") + suggestLegacyBundle(slug, styleAqua); + (invalidEszipV2 ? suggestDenoV2(styleEmphasis) : "") + suggestLegacyBundle(slug, styleAqua); return yield* Effect.fail( Object.assign(new Error(`error running container: exit ${result.exitCode}`), { suggestion, @@ -1392,7 +1398,7 @@ export function downloadFunctions