diff --git a/apps/cli/docs/go-cli-porting-status.md b/apps/cli/docs/go-cli-porting-status.md index 6286a74e55..719b66144a 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). Hybrid in the legacy shell: native for `--use-api`, delegates wholesale to Go for the default (`--use-docker`) and `--legacy-bundle` paths — 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); default (`--use-docker`) and `--legacy-bundle` delegate wholesale to Go | +| `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..7146b1613f 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,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 | 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 | +| `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.handler.ts b/apps/cli/src/legacy/commands/functions/deploy/deploy.handler.ts index 00bb20b7ba..7ff6481ad3 100644 --- a/apps/cli/src/legacy/commands/functions/deploy/deploy.handler.ts +++ b/apps/cli/src/legacy/commands/functions/deploy/deploy.handler.ts @@ -1,9 +1,9 @@ -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 { legacyAqua, legacyBold } from "../../../shared/legacy-colors.ts"; +import { resolveEdgeRuntimeVersionPin } from "../../../../shared/functions/functions.shared.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"; @@ -29,12 +29,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(); @@ -45,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, @@ -65,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 2f1952f292..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 @@ -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"; @@ -19,8 +20,11 @@ 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, + InvalidFunctionDeploySlugError, NoFunctionsToDeployError, } from "../../../../shared/functions/deploy.errors.ts"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; @@ -63,6 +67,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" }); @@ -1167,7 +1202,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", @@ -1220,4 +1255,545 @@ 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", + ]), + ); + // 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( + 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 9c9c6a1845..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,66 +2,84 @@ ## 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 | 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 -| 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 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 +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 -| 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; 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 -| 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 +91,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 75dbff9094..27f6ca2330 100644 --- a/apps/cli/src/legacy/commands/functions/download/download.handler.ts +++ b/apps/cli/src/legacy/commands/functions/download/download.handler.ts @@ -1,9 +1,13 @@ +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 { 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"; @@ -22,12 +26,28 @@ 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, + goConfigCompat: legacyFunctionsGoConfigCompat, + edgeRuntimeVersion, + // 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), + // 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) => @@ -47,7 +67,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( 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..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,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 { 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"; +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,110 @@ 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 { 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"; 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 +258,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 +373,186 @@ describe("legacy functions download", () => { }, ); - it.live("still proxies to Docker when --use-api=false is passed explicitly", () => { + 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, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + }), + proxy.layer, + child.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "--project-ref", + PROJECT_ID, + "--output-format", + "json", + ]), + }), + ); + + return Effect.gen(function* () { + yield* legacyFunctionsDownload({ + ...baseFlags, + functionName: Option.none(), + useDocker: true, + }); + + expect(proxy.calls).toEqual([]); + expect(proxy.captureCalls).toEqual([]); + expect( + child.spawned.filter( + (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", + 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, @@ -262,44 +560,857 @@ 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", + "--use-docker", "--project-ref", - "abcdefghijklmnopqrst", + 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/${dockerfileServiceImage("edgeruntime")}`, + "unbundle", + "--eszip", + "/root/eszips/output_hello-world.eszip", + "--output", + "/home/deno/hello-world", + ]); + }).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, ]), }), ); - 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 }); + 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 + // `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(); + 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", + ]), + }), + ); + + return Effect.gen(function* () { + // `--network-id` is a persistent root flag (`cmd/root.go:328`), not + // registered on `functions download` itself — `lastExplicitLongFlagValue` + // scans the whole argv unscoped. + yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }); + + 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( + "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("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`) — `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" }); + 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", + () => { + // 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 + // 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 + // 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(); + 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", + ]), + }), + ); + + 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)); + }); + + 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", + () => { + // 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" }); + 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, + ]), + }), + ); - expect(api.requests).toEqual([]); - expect(proxy.calls).toEqual([ - [ - "functions", - "download", - "hello-world", - "--project-ref", - "abcdefghijklmnopqrst", - "--use-docker", - ], - ]); - expect(proxy.envs).toEqual([{ SUPABASE_TELEMETRY_DISABLED: "1" }]); - }).pipe(Effect.provide(layer)); + 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("emits a JSON success envelope when proxying to Docker in machine-output mode", () => { - const out = mockOutput({ format: "json" }); + 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, @@ -307,63 +1418,47 @@ 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* () { - // 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. - yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }); + const error = yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }).pipe( + Effect.flip, + ); - expect(proxy.calls).toEqual([]); - expect(proxy.captureCalls).toEqual([ - [ - "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" }, - }), + 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 +1466,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 +1515,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 +1527,7 @@ describe("legacy functions download", () => { cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), }), proxy.layer, + child.layer, Stdio.layerTest({ args: Effect.succeed([ "functions", @@ -470,6 +1573,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 +1581,7 @@ describe("legacy functions download", () => { cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), }), proxy.layer, + child.layer, Stdio.layerTest({ args: Effect.succeed([ "functions", @@ -506,6 +1611,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(); @@ -664,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/${dockerfileServiceImage("edgeruntime")}`, + ); + 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/${dockerfileServiceImage("edgeruntime")}`, + ); + }).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..18ace3c1ef 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,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 | 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 | +| `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 @@ -57,7 +62,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 | @@ -96,6 +101,8 @@ 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. - 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 f7820c3313..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 @@ -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, @@ -74,11 +74,12 @@ 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"); + const { legacyGetRegistryImageUrl } = await import("../../../shared/legacy-docker-registry.ts"); return { ...actual, @@ -90,6 +91,18 @@ vi.mock("../../../../shared/functions/deploy.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) => @@ -488,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"), @@ -2752,6 +2768,242 @@ describe("legacy functions serve integration", () => { }); }); + 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"); + }); + }); + }); + it.live("surfaces the real filesystem error when the fallback env file is unreadable", () => { return Effect.gen(function* () { yield* Effect.promise(() => 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/container-lifecycle.ts b/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.ts index 6d49d80523..fde651aa10 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.ts @@ -38,7 +38,7 @@ import { } from "../legacy-docker-bind-classify.ts"; import { LEGACY_CLI_PROJECT_LABEL, LEGACY_CLI_WORKDIR_LABEL } from "../legacy-docker-ids.ts"; import { legacyIsDockerDaemonUnreachable } from "../legacy-docker-suggest.ts"; -import { isUserDefinedDockerNetwork } from "../../../shared/functions/deploy.ts"; +import { isUserDefinedDockerNetwork } from "../../../shared/functions/functions-docker.ts"; import { legacyBuildStartContainerCreateArgs, legacyApplyBitbucketStartContainerFilter, @@ -62,9 +62,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"; @@ -269,8 +270,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 legacyEnsureNetwork( spawner: Spawner, diff --git a/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.unit.test.ts b/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.unit.test.ts index ccc6cd59c5..39e224f6a8 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.unit.test.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.unit.test.ts @@ -774,6 +774,22 @@ describe("legacyEnsureNetwork", () => { ); }, ); + + 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 legacyEnsureNetwork(mock.spawner, "container:redis", {}).pipe( + Effect.map(() => { + expect(mock.spawned).toEqual([]); + }), + ); + }); }); describe("legacyEnsureVolume", () => { 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 1a145feceb..4ac08af9cd 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-ids.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-ids.ts @@ -8,22 +8,27 @@ 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 * 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); } @@ -77,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-docker-image-resolve.ts b/apps/cli/src/legacy/shared/legacy-docker-image-resolve.ts index 57e96af0a8..9351040caa 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-image-resolve.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-image-resolve.ts @@ -78,10 +78,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..14c52a698b --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-functions-go-config.ts @@ -0,0 +1,68 @@ +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, + // No `[remotes.]` override-tier gating (empty set, the + // parameter default): the remote block itself already merged over + // the base config at file level via `legacyLoadLocalProjectContext`'s + // `projectRef` threading above. Known narrow divergence: without + // the key set, an ambient `SUPABASE_EDGE_RUNTIME_DENO_VERSION` + // still beats a matched remote block's own `deno_version`, where + // Go's OVERRIDE-tier `v.Set` would win — computing the keys here + // needs `legacy-db-config.toml-read.ts`'s remote-resolution + // pipeline, which this `loadProjectConfig`-based path doesn't run + // (review round on CLI-1963). + undefined, + projectRef, + ), + catch: toError, + }); + return { + loaded: context.loaded, + projectEnvValues: context.projectEnvValues, + // `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-config-values.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.ts index e00760520b..9bcce7c616 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.ts @@ -154,6 +154,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; } /** @@ -3156,6 +3167,15 @@ export function legacyResolveLocalConfigValues( * own doc comments). */ remoteOverrideKeys: ReadonlySet = new Set(), + /** + * 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 { const remoteWins = legacyMakeRemoteWins(remoteOverrideKeys); // Go's `Config.Validate` checks `ProjectId` FIRST, before every other field @@ -3164,8 +3184,9 @@ 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 @@ -3181,7 +3202,10 @@ export function legacyResolveLocalConfigValues( // doc comment — review: PRRT_kwDOErm0O86XHGDL), not this validation-only field. 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, ); @@ -4069,6 +4093,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 88f55c094f..08f87ea4c8 100644 --- a/apps/cli/src/legacy/shared/legacy-local-project-context.ts +++ b/apps/cli/src/legacy/shared/legacy-local-project-context.ts @@ -54,13 +54,16 @@ export interface LegacyLocalProjectContext { export const legacyLoadLocalProjectContext = ( workdir: string, mapConfigLoadError: (message: string) => E, - // The resolved `--linked` ref, when the caller already has one in scope (`db diff`/`db - // pull`'s shadow-provisioning prelude — CLI-1956) — threaded straight into + // The resolved `--linked`/`--project-ref` ref, when the caller already has one in scope + // (`db diff`/`db pull`'s shadow-provisioning prelude — CLI-1956 — and the `functions` + // Docker paths' Go-config pipeline — CLI-1963) — threaded straight into // `loadProjectConfig`'s own `projectRef` option so the matching `[remotes.]` block // merges over the base config, exactly like `legacyReadDbToml(..., ref)` already does for - // those same commands' OTHER config read. `db start`/`db reset` never pass this (neither - // operates against a linked target), so it defaults to `undefined` — no remote merge, - // unchanged from before. + // those same commands' OTHER config read. It also supplies 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. `db start`/`db reset`/`start`/`stop`/`status` never + // pass this, so it defaults to `undefined` — no remote merge, unchanged from before. projectRef?: string, ): Effect.Effect => Effect.gen(function* () { @@ -198,6 +201,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 acd17b7a1b..300403b634 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, @@ -34,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..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,5 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; import { makeApiClient, FunctionResponse } from "@supabase/api/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"; @@ -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))); }, ); @@ -2128,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))); }); @@ -2174,7 +2185,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 +2580,116 @@ 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/${dockerfileServiceImage("edgeruntime")}`], + }); + }).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.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 26293b2413..129f1e2609 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, + goConfigCompat: undefined, + 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( 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..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,5 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; import { FunctionResponse, makeApiClient } from "@supabase/api/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"; @@ -23,6 +24,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 +32,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 +77,7 @@ function textResponse( status: number, body: ResponseBody = "", contentType = "text/plain", + extraHeaders: Readonly> = {}, ): HttpClientResponse.HttpClientResponse { return HttpClientResponse.fromWeb( request, @@ -81,6 +85,7 @@ function textResponse( status, headers: { "content-type": contentType, + ...extraHeaders, }, }), ); @@ -182,7 +187,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 +246,7 @@ function mockDownloadApi(opts: { response?.status ?? 200, response?.body ?? "", response?.contentType ?? "multipart/form-data; boundary=missing", + response?.headers ?? {}, ), ); } @@ -277,6 +291,7 @@ function setup( linked?: boolean; projectRoot?: string; rawArgs?: ReadonlyArray; + childLayer?: ReturnType["layer"]; } = {}, ) { const out = mockOutput({ format: opts.format ?? "text", interactive: false }); @@ -293,6 +308,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,48 +728,88 @@ 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 }))), ); }); + 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([ @@ -851,56 +910,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 +1039,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 +1566,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([ @@ -1625,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/${dockerfileServiceImage("edgeruntime")}`, + ); + }).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 19b7da1b2d..0d305fcec1 100644 --- a/apps/cli/src/shared/cli/cobra-flag-groups.ts +++ b/apps/cli/src/shared/cli/cobra-flag-groups.ts @@ -28,6 +28,42 @@ export function hasExplicitLongFlag( return false; } +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 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". + */ +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; +} + /** * The LAST explicit `--` occurrence's value in raw argv, matching * pflag's last-wins resolution (`--profile a --profile b` → `b`), or 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 9171d54aee..13d81e6ae5 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,34 @@ describe("native hidden flags", () => { "--backup=false", ]).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` — 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, { + version: "0.0.0-test", + })([ + "functions", + "download", + "Not_A_Valid-Slug!", + "--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", - "--use-docker", + "--legacy-bundle", ]); const useDockerExit = yield* Command.runWith(legacyTestRoot, { version: "0.0.0-test", @@ -171,7 +192,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 813029a3f3..54a0b25911 100644 --- a/apps/cli/src/shared/functions/deploy.ts +++ b/apps/cli/src/shared/functions/deploy.ts @@ -10,25 +10,25 @@ import { } from "@supabase/api/effect"; import { inferFunctionsManifest, - loadProjectConfig, type ResolvedFunctionConfig as ManifestFunctionConfig, } from "@supabase/config"; -import { Duration, Effect, Option, Schema, Stream } from "effect"; +import { Duration, Effect, Option, Schema } from "effect"; import * as HttpBody from "effect/unstable/http/HttpBody"; import * as HttpClientError from "effect/unstable/http/HttpClientError"; -import { ChildProcessSpawner } from "effect/unstable/process"; 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 { legacyViperEnvStringWithProjectFallback } from "../legacy/legacy-viper-env.ts"; import { findGitRootPath } from "../git/git-root.ts"; import { cobraMutuallyExclusiveErrorMessage, + explicitBooleanLongFlag, hasExplicitLongFlag, + lastExplicitLongFlagValue, } from "../cli/cobra-flag-groups.ts"; import { + edgeRuntimeImage, FUNCTIONS_BUNDLER_MUTEX_GROUP, invalidFunctionSlugDetail, validateFunctionSlugMessage, @@ -39,15 +39,26 @@ import { InvalidFunctionDeploySlugError, NoFunctionsToDeployError, } from "./deploy.errors.ts"; +import { + buildFunctionsDockerRunArgs, + ensureDockerNamedVolume, + ensureDockerNetwork, + isDockerRunning, + localDockerId, + resolveDockerNetworkMode, + resolveEdgeRuntimeVersion, + resolveFunctionsDockerImage, + runChildProcess, + toDockerPath, + toSlash, +} from "./functions-docker.ts"; +import { loadFunctionsProjectConfig, type FunctionsGoConfigCompat } from "./functions-config.ts"; import { FunctionsApiStatusError, FunctionsApiTransportError } from "./functions-api.errors.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; @@ -75,7 +86,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; @@ -89,9 +105,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 { @@ -246,6 +265,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, @@ -255,45 +286,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 @@ -313,18 +305,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"); @@ -1140,15 +1120,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, @@ -1294,84 +1265,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; @@ -1384,57 +1277,31 @@ 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; -}); +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 @@ -1459,54 +1326,76 @@ 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. 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( + // `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); 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( - legacyGetRegistryImageUrl(`supabase/edge-runtime:v${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, + // 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, + }); + + // 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}`)); } @@ -2179,17 +2068,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])); @@ -2201,15 +2106,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 && @@ -2236,21 +2142,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, @@ -2340,10 +2231,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) { @@ -2357,19 +2259,15 @@ export function deployFunctions( "no-verify-jwt", 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; + // 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( - deployConfig?.edge_runtime.deno_version, + context.denoVersion, dependencies.edgeRuntimeVersion, ); const configFunctions = yield* inferFunctionsManifest({ @@ -2377,7 +2275,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 @@ -2437,25 +2335,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, + // `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: lastExplicitLongFlagValue(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, - explicitStringFlag(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 becf6bf94d..d6beca0b38 100644 --- a/apps/cli/src/shared/functions/download.ts +++ b/apps/cli/src/shared/functions/download.ts @@ -1,6 +1,6 @@ import { operationDefinitions, SupabaseApiInputError, type ApiClient } from "@supabase/api/effect"; 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"; @@ -10,9 +10,26 @@ import type * as HttpClientResponse from "effect/unstable/http/HttpClientRespons import { Output } from "../output/output.service.ts"; import { cobraMutuallyExclusiveErrorMessage, + explicitBooleanLongFlag, + 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, + ensureDockerNamedVolume, + ensureDockerNetwork, + isDockerRunning, + localDockerId, + resolveDockerNetworkMode, + resolveEdgeRuntimeVersion, + resolveFunctionsDockerImage, + runChildProcess, +} from "./functions-docker.ts"; +import { loadFunctionsProjectConfig, type FunctionsGoConfigCompat } from "./functions-config.ts"; +import { + edgeRuntimeImage, FUNCTIONS_BUNDLER_MUTEX_GROUP, invalidFunctionSlugDetail, validateFunctionSlugMessage, @@ -27,6 +44,11 @@ import { import { FunctionsApiStatusError, FunctionsApiTransportError } from "./functions-api.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; @@ -36,15 +58,68 @@ 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; + /** + * 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; + /** + * 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; + /** + * 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; +} + +/** + * 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; + /** + * `undefined` in `next`; the legacy shell injects + * `legacyFunctionsGoConfigCompat` so this file never imports `legacy/` + * directly — see {@link FunctionsGoConfigCompat}. + */ + 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 + * `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; @@ -53,7 +128,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, @@ -62,29 +138,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; } @@ -129,6 +194,35 @@ 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, + 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}`), { + // 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.`, + }), + ); +} + const downloadCommandPath = ["functions", "download"] as const; function validateDownloadFlags( @@ -615,9 +709,42 @@ 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). + // + // 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) => @@ -703,6 +830,346 @@ const downloadBody = Effect.fnUntraced(function* ( ); }); +// Go: `downloadOne` (`apps/cli-go/internal/functions/download/download.go:218-245`) +// 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, + }, + { Accept: "*/*" }, + ) + .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, + 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. 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(styleEmphasis: (text: string) => string = (text) => text): string { + // Go: `suggestDenoV2` (`download.go:306-312`), verbatim including its + // 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`; +} + +/** + * 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, styleAqua?: (text: string) => string) { + return (cause: unknown): Error => + Object.assign(new Error(legacyDescribeContainerCliFailure(cause)), { + suggestion: suggestLegacyBundle(slug, styleAqua), + }); +} + +/** + * 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, styleAqua?: (text: string) => string) { + return (cause: unknown): Error => + Object.assign(new Error(`${step}: ${legacyDescribeContainerCliFailure(cause)}`), { + suggestion: suggestLegacyBundle(slug, styleAqua), + }); +} + +// 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`). 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 context = yield* loadFunctionsProjectConfig({ + projectRoot: dependencies.projectRoot, + projectRef, + goConfigCompat: dependencies.goConfigCompat, + }); + const edgeRuntimeVersion = yield* resolveEdgeRuntimeVersion( + context.denoVersion, + dependencies.edgeRuntimeVersion, + ); + return { + projectId: context.projectId, + denoVersion: context.denoVersion, + // `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: edgeRuntimeImage(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; +} + +// 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: PulledEdgeRuntimeImage, + projectRef: string, + slug: string, +) { + 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, + // `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); + + 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. + // + // 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 + // 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; + 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, projectEnvValues } = 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. `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: lastExplicitLongFlagValue(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)), + ); + yield* ensureDockerNamedVolume(localDockerId("edge_runtime", projectId), projectId).pipe( + Effect.mapError(withLegacyBundleSuggestion(slug, styleAqua)), + ); + + // 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 = [ + ...(process.env["BITBUCKET_CLONE_DIR"] === undefined + ? [`${localDockerId("edge_runtime", projectId)}:/root/.cache/deno:rw`] + : []), + `${hostEszipPath}:${dockerEszipPath}:ro`, + `${functionsDir}:${DOCKER_DENO_DIR}:rw`, + ]; + const command = buildFunctionsDockerRunArgs({ + image, + projectId, + networkMode, + 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) — `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), + ), + ); + + 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(styleEmphasis) : "") + suggestLegacyBundle(slug, styleAqua); + 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, @@ -789,11 +1256,17 @@ export function downloadFunctions text); + const edgeRuntimeImage: EdgeRuntimeImage | undefined = + !flags.useApi && flags.useDocker + ? (yield* isDockerRunning()) + ? resolvedEdgeRuntimeImage + : yield* output + .raw(`${styleWarning("WARNING:")} Docker is not running\n`, "stderr") + .pipe(Effect.as(undefined)) + : undefined; + const slugs = Option.isSome(flags.functionName) ? [flags.functionName.value] : yield* listRemoteFunctionSlugs(dependencies.api, projectRef); @@ -856,9 +1366,47 @@ 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) { - downloaded.push(yield* downloadSingle(dependencies, projectRef, slug)); + // Go: CLI-1891, `downloadAll`'s per-item validation runs before any + // per-slug network/filesystem work (`download.go:182-188`). A + // user-supplied slug is already validated above (`validateSlug`); this + // covers slugs sourced from the Management API's function list, which + // this threat model treats as untrusted (a malicious/compromised + // response, or a MITM). + if (Option.isNone(flags.functionName)) { + yield* validateRemoteSlug(slug, styleAqua); + } + if (pulledEdgeRuntimeImage !== undefined) { + downloaded.push( + yield* downloadWithDockerUnbundle(dependencies, pulledEdgeRuntimeImage, projectRef, slug), + ); + } else { + downloaded.push(yield* downloadSingle(dependencies, projectRef, slug)); + } } if (output.format !== "text") { diff --git a/apps/cli/src/shared/functions/functions-config.ts b/apps/cli/src/shared/functions/functions-config.ts new file mode 100644 index 0000000000..7202e6af6a --- /dev/null +++ b/apps/cli/src/shared/functions/functions-config.ts @@ -0,0 +1,100 @@ +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`) + * need from project config resolution, unified across both shells. In the + * legacy shell this also runs the same `Config.Validate`/dotenv pipeline + * `start`/`stop`/`status` already go through — see {@link FunctionsGoConfigCompat}. + * `next` keeps its existing plain `loadProjectConfig` behavior exactly (no + * Go-parity claim there). + */ +interface FunctionsProjectConfigContext { + readonly loaded: LoadedProjectConfig | null; + /** Go's post-`loadNestedEnv` merged env (ambient-wins). `undefined` in `next`. */ + readonly projectEnvValues: Readonly> | 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. + // 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; + } + + 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 new file mode 100644 index 0000000000..d5872ea2c2 --- /dev/null +++ b/apps/cli/src/shared/functions/functions-docker.ts @@ -0,0 +1,358 @@ +// 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/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. +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; +// 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("\\", "/"); +} + +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)}`; +} + +/** + * 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 + * `lastExplicitLongFlagValue` (`shared/cli/cobra-flag-groups.ts`). + * `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"; + +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]:/, ""); +} + +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; + /** + * 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; +} + +/** + * 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); + } + if (spec.workingDir !== undefined) { + command.push("-w", spec.workingDir); + } + 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 +// `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, + opts: { + readonly stdout?: "pipe" | "ignore"; + 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; + } = {}, +) { + 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" }, + ); + 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" && + !isContainerDockerNetworkMode(networkMode) + ); +} + +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; +}); + +/** + * 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, +): 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}.`), + ); +} + +/** + * 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..60eaef8ea2 --- /dev/null +++ b/apps/cli/src/shared/functions/functions-docker.unit.test.ts @@ -0,0 +1,296 @@ +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({ + 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"); + }); +}); + +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 8c961e867d..63f740c849 100644 --- a/apps/cli/src/shared/functions/functions.shared.ts +++ b/apps/cli/src/shared/functions/functions.shared.ts @@ -1,3 +1,8 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { Effect } from "effect"; +import { dockerfileServiceImage } from "../services/dockerfile-images.ts"; + const functionSlugPattern = /^[A-Za-z][A-Za-z0-9_-]*$/; export const invalidFunctionSlugDetail = @@ -16,3 +21,47 @@ 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: `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` + * 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, 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(() => + readFile(join(supabaseDir, ".temp", "edge-runtime-version"), "utf8"), + ).pipe( + Effect.map((version) => version.trim()), + Effect.catch(() => Effect.succeed("")), + Effect.map((version) => version || DEFAULT_EDGE_RUNTIME_TAG), + ); +}); diff --git a/apps/cli/src/shared/functions/serve.ts b/apps/cli/src/shared/functions/serve.ts index b19dd06d2e..2ef8eecfea 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, @@ -46,25 +46,30 @@ import { type FileWatchEvent, } from "../runtime/file-watcher.service.ts"; import { ProcessControl } from "../runtime/process-control.service.ts"; -import { dockerfileServiceImage } from "../services/dockerfile-images.ts"; import { buildDockerBinds, discoverFunctionSlugs, dockerBindContainerPath, dockerBindHostPath, - dockerProjectLabels, dockerWorkdirLabel, + rawFunctionConfigRecord, + resolveFunctionConfigs, + type ResolvedDeployFunctionConfig, +} from "./deploy.ts"; +import { + dockerProjectLabels, ensureDockerNamedVolume, ensureDockerNetwork, localDockerId, normalizeProjectId, - rawFunctionConfigRecord, + resolveDockerNetworkMode, resolveEdgeRuntimeVersion, - resolveFunctionConfigs, + resolveFunctionsDockerImage, runChildProcess, toDockerPath, - type ResolvedDeployFunctionConfig, -} from "./deploy.ts"; +} from "./functions-docker.ts"; +import { loadFunctionsProjectConfig, type FunctionsGoConfigCompat } from "./functions-config.ts"; +import { edgeRuntimeImage, resolveEdgeRuntimeVersionPin } from "./functions.shared.ts"; const decodeProjectConfig = Schema.decodeUnknownSync(ProjectConfigSchema); const defaultProjectConfig = decodeProjectConfig({}); @@ -96,7 +101,6 @@ const ignoredDirNames = new Set([ ]); const dockerLogRetryDelay = Duration.millis(400); const dockerLogDiagnosticTailLength = 4_096; -const legacyDefaultEdgeRuntimeImage = dockerfileServiceImage("edgeruntime"); const defaultSupabaseEnv = "development"; const serveMainContainerPath = "/root/index.ts"; const shellVariableNamePattern = /^[A-Za-z_][A-Za-z0-9_]*$/; @@ -140,6 +144,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 { @@ -169,6 +180,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 { @@ -676,6 +689,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, { @@ -689,10 +703,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; @@ -741,15 +765,59 @@ 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 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` + // 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; }); @@ -1385,10 +1453,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, @@ -1742,31 +1806,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("")), + const edgeRuntimeVersionOverride = yield* resolveEdgeRuntimeVersionPin( + input.dependencies.supabaseDir, ); const edgeRuntimeVersion = yield* resolveEdgeRuntimeVersion( resolved.edgeRuntime.deno_version, edgeRuntimeVersionOverride, ); - const image = legacyGetRegistryImageUrl( - edgeRuntimeVersion.length === 0 - ? legacyDefaultEdgeRuntimeImage - : `supabase/edge-runtime:${edgeRuntimeImageTag(edgeRuntimeVersion)}`, - ); yield* assertLocalDbRunning(projectId); yield* bestEffortRemoveContainer(containerId); @@ -1786,6 +1854,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( + edgeRuntimeImage(edgeRuntimeVersion), + resolved.projectEnvValues, + ); + startedRuntime = yield* startEdgeRuntimeContainer({ config: { projectId, diff --git a/apps/cli/src/shared/output/normalize-error.ts b/apps/cli/src/shared/output/normalize-error.ts index e0930473d2..da68d1e74b 100644 --- a/apps/cli/src/shared/output/normalize-error.ts +++ b/apps/cli/src/shared/output/normalize-error.ts @@ -200,12 +200,18 @@ export function normalizeCliError( const code = rawCode === "UnknownSubcomand" ? "UnknownSubcommand" : rawCode; 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 } : {}), }; }