diff --git a/apps/cli/src/legacy/commands/services/services.handler.ts b/apps/cli/src/legacy/commands/services/services.handler.ts index 28b4dc2461..f6dd731220 100644 --- a/apps/cli/src/legacy/commands/services/services.handler.ts +++ b/apps/cli/src/legacy/commands/services/services.handler.ts @@ -3,6 +3,10 @@ import { LegacyCliConfig } from "../../config/legacy-cli-config.service.ts"; import { LegacyCredentials } from "../../auth/legacy-credentials.service.ts"; import { LegacyLinkedProjectCache } from "../../telemetry/legacy-linked-project-cache.service.ts"; import { LegacyTelemetryState } from "../../telemetry/legacy-telemetry-state.service.ts"; +import { legacyReadDbToml } from "../../shared/legacy-db-config.toml-read.ts"; +import { legacyResolveDbImage } from "../../shared/legacy-db-image.ts"; +import { legacyResolveEdgeRuntimeImage } from "../../shared/legacy-edge-runtime-image.ts"; +import { legacyTempPaths } from "../../shared/legacy-temp-paths.ts"; import { LegacyOutputFlag } from "../../../shared/legacy/global-flags.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { encodeGoJson, encodeToml, encodeYaml } from "../../shared/legacy-go-output.encoders.ts"; @@ -11,6 +15,9 @@ import { fetchLinkedServiceVersions, formatServicesWarning, listLocalServiceVersions, + type LocalServiceImageOverrides, + type LocalServiceVersionName, + type LocalServiceVersionOverrides, mergeRemoteServiceVersions, renderServicesTable, renderServicesWarning, @@ -55,8 +62,53 @@ export const legacyServices = Effect.fn("legacy.services")(function* (_flags: Le yield* Effect.gen(function* () { const accessTokenExit = yield* credentials.getAccessToken.pipe(Effect.exit); const accessToken = Exit.isSuccess(accessTokenExit) ? accessTokenExit.value : Option.none(); + const tomlValues = yield* legacyReadDbToml( + fs, + path, + cliConfig.workdir, + Option.getOrUndefined(linkedProjectRef), + ).pipe( + Effect.catch((error) => + output.raw(`${formatConfigLoadError(error)}\n`, "stderr").pipe(Effect.as(null)), + ), + ); + const serviceVersions = + tomlValues === null + ? {} + : yield* readLegacyServiceVersionOverrides( + fs, + path, + cliConfig.workdir, + tomlValues.majorVersion, + ); + const postgresImage = + tomlValues === null + ? undefined + : yield* legacyResolveDbImage( + fs, + path, + cliConfig.workdir, + tomlValues.majorVersion, + Option.getOrUndefined(tomlValues.orioledbVersion), + ); + const edgeRuntimeImage = + tomlValues === null + ? undefined + : yield* legacyResolveEdgeRuntimeImage(fs, path, cliConfig.workdir, tomlValues.denoVersion); + const imageOverrides: LocalServiceImageOverrides = {}; + if (postgresImage !== undefined) { + imageOverrides.postgres = postgresImage; + } + if (edgeRuntimeImage !== undefined) { + imageOverrides["edge-runtime"] = edgeRuntimeImage; + } + const localImageOptions = { + imageOverrides, + normalizeVersionTags: false, + serviceVersions, + }; - let rows = listLocalServiceVersions(); + let rows = listLocalServiceVersions(localImageOptions); if (Option.isSome(linkedProjectRef) && Option.isSome(accessToken)) { const remote = yield* fetchLinkedServiceVersions({ apiUrl: cliConfig.apiUrl, @@ -65,7 +117,7 @@ export const legacyServices = Effect.fn("legacy.services")(function* (_flags: Le accessToken: accessToken.value, userAgent: cliConfig.userAgent, }); - rows = mergeRemoteServiceVersions(remote); + rows = mergeRemoteServiceVersions(remote, localImageOptions); } const warning = renderServicesWarning(rows); @@ -110,3 +162,46 @@ export const legacyServices = Effect.fn("legacy.services")(function* (_flags: Le yield* output.raw(renderServicesTable(rows)); }).pipe(Effect.ensuring(cacheLinkedProject), Effect.ensuring(telemetryState.flush)); }); + +function formatConfigLoadError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +const LEGACY_VERSION_FILES = [ + ["auth", "gotrue-version", (majorVersion: number | undefined) => (majorVersion ?? 17) > 14], + ["postgrest", "rest-version", (majorVersion: number | undefined) => (majorVersion ?? 17) > 14], + ["storage", "storage-version"], + ["realtime", "realtime-version"], + ["studio", "studio-version"], + ["pgmeta", "pgmeta-version"], + ["analytics", "logflare-version"], + ["pooler", "pooler-version"], +] as const satisfies ReadonlyArray< + readonly [LocalServiceVersionName, string, ((majorVersion: number | undefined) => boolean)?] +>; + +const readLegacyServiceVersionOverrides = Effect.fnUntraced(function* ( + fs: FileSystem.FileSystem, + path: Path.Path, + workdir: string, + majorVersion: number | undefined, +) { + const paths = legacyTempPaths(path, workdir); + const versions: LocalServiceVersionOverrides = {}; + + for (const [service, fileName, shouldRead] of LEGACY_VERSION_FILES) { + if (shouldRead !== undefined && !shouldRead(majorVersion)) { + continue; + } + + const version = yield* fs.readFileString(path.join(paths.tempDir, fileName)).pipe( + Effect.map((content) => content.trim()), + Effect.orElseSucceed(() => ""), + ); + if (version.length > 0) { + versions[service] = version; + } + } + + return versions; +}); diff --git a/apps/cli/src/legacy/commands/services/services.integration.test.ts b/apps/cli/src/legacy/commands/services/services.integration.test.ts index 564e8f6e11..f3c8ce3e64 100644 --- a/apps/cli/src/legacy/commands/services/services.integration.test.ts +++ b/apps/cli/src/legacy/commands/services/services.integration.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "@effect/vitest"; @@ -19,7 +19,10 @@ import { processEnvLayer, } from "../../../../tests/helpers/mocks.ts"; import { mockLegacyTelemetryStateTracked } from "../../../../tests/helpers/legacy-mocks.ts"; -import { listLocalServiceVersions } from "../../../shared/services/services.shared.ts"; +import { + listLocalServiceVersions, + postgresImageForDbMajorVersion, +} from "../../../shared/services/services.shared.ts"; import { textCliOutputFormatter } from "../../../shared/output/text-formatter.ts"; import { processControlLayer } from "../../../shared/runtime/process-control.layer.ts"; import { TelemetryRuntime } from "../../../shared/telemetry/runtime.service.ts"; @@ -41,6 +44,7 @@ function setup( opts: { format?: "text" | "json" | "stream-json"; goOutput?: Option.Option<"env" | "pretty" | "json" | "toml" | "yaml">; + workdir?: string; } = {}, ) { const out = mockOutput({ @@ -69,7 +73,7 @@ function setup( poolerHost: "supabase.com", accessToken: Option.none(), projectId: Option.none(), - workdir: process.cwd(), + workdir: opts.workdir ?? process.cwd(), userAgent: "SupabaseCLI/test", }), ), @@ -100,6 +104,38 @@ const legacyTestRoot = Command.make("supabase").pipe( Command.withSubcommands([legacyServicesCommand]), ); +function makeProjectWithConfig(config: string): string { + const workdir = mkdtempSync(join(tmpdir(), "supabase-services-config-")); + const configDir = join(workdir, "supabase"); + mkdirSync(configDir, { recursive: true }); + writeFileSync(join(configDir, "config.toml"), config); + return workdir; +} + +function makeProjectWithConfigFiles(opts: { toml: string; json: string }): string { + const workdir = makeProjectWithConfig(opts.toml); + writeFileSync(join(workdir, "supabase", "config.json"), opts.json); + return workdir; +} + +function makeProjectWithDbMajorVersion(majorVersion: number): string { + return makeProjectWithConfig(`[db]\nmajor_version = ${majorVersion}\n`); +} + +function writeTempFile(workdir: string, name: string, content: string): void { + const tempDir = join(workdir, "supabase", ".temp"); + mkdirSync(tempDir, { recursive: true }); + writeFileSync(join(tempDir, name), content); +} + +function postgresVersionForDbMajorVersion(majorVersion: number): string { + const image = postgresImageForDbMajorVersion(majorVersion); + if (image === undefined) { + throw new Error(`Missing Postgres image for db major ${majorVersion}.`); + } + return image.slice(image.lastIndexOf(":") + 1); +} + function expectFailureTag(exit: Exit.Exit, tag: string) { expect(Exit.isFailure(exit)).toBe(true); if (!Exit.isFailure(exit)) { @@ -197,6 +233,143 @@ describe("legacy services", () => { }); }); + it.live("reports the configured Postgres version for local projects", () => { + const workdir = makeProjectWithDbMajorVersion(15); + const { layer, out } = setup({ goOutput: Option.some("json"), workdir }); + + return Effect.gen(function* () { + yield* legacyServices({}).pipe(Effect.provide(layer)); + + const rows = JSON.parse(out.stdoutText) as Array<{ + name: string; + local: string; + remote: string; + }>; + expect(rows).toContainEqual( + expect.objectContaining({ + name: "supabase/postgres", + local: postgresVersionForDbMajorVersion(15), + }), + ); + }).pipe(Effect.ensuring(Effect.sync(() => rmSync(workdir, { recursive: true, force: true })))); + }); + + it.live("ignores config.json and reads legacy config.toml for local image selection", () => { + const workdir = makeProjectWithConfigFiles({ + toml: "[db]\nmajor_version = 15\n", + json: JSON.stringify({ db: { major_version: 14 } }), + }); + const { layer, out } = setup({ goOutput: Option.some("json"), workdir }); + + return Effect.gen(function* () { + yield* legacyServices({}).pipe(Effect.provide(layer)); + + const rows = JSON.parse(out.stdoutText) as Array<{ + name: string; + local: string; + remote: string; + }>; + expect(rows).toContainEqual( + expect.objectContaining({ + name: "supabase/postgres", + local: postgresVersionForDbMajorVersion(15), + }), + ); + }).pipe(Effect.ensuring(Effect.sync(() => rmSync(workdir, { recursive: true, force: true })))); + }); + + it.live("applies linked-project remote config overrides when choosing the local image", () => { + const workdir = makeProjectWithConfig(` +[db] +major_version = 17 + +[remotes.linked] +project_id = "abcdefghijklmnopqrst" + +[remotes.linked.db] +major_version = 15 +`); + writeTempFile(workdir, "project-ref", "abcdefghijklmnopqrst"); + const { layer, out } = setup({ goOutput: Option.some("json"), workdir }); + + return Effect.gen(function* () { + yield* legacyServices({}).pipe(Effect.provide(layer)); + + const rows = JSON.parse(out.stdoutText) as Array<{ + name: string; + local: string; + remote: string; + }>; + expect(rows).toContainEqual( + expect.objectContaining({ + name: "supabase/postgres", + local: postgresVersionForDbMajorVersion(15), + }), + ); + }).pipe(Effect.ensuring(Effect.sync(() => rmSync(workdir, { recursive: true, force: true })))); + }); + + it.live("reports pinned legacy temp service versions", () => { + const workdir = makeProjectWithDbMajorVersion(15); + writeTempFile(workdir, "postgres-version", "15.1.0.117\n"); + writeTempFile(workdir, "gotrue-version", "2.74.2\n"); + writeTempFile(workdir, "storage-version", "v1.28.0\n"); + const { layer, out } = setup({ goOutput: Option.some("json"), workdir }); + + return Effect.gen(function* () { + yield* legacyServices({}).pipe(Effect.provide(layer)); + + const rows = JSON.parse(out.stdoutText) as Array<{ + name: string; + local: string; + remote: string; + }>; + expect(rows).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "supabase/postgres", local: "15.1.0.117" }), + expect.objectContaining({ name: "supabase/gotrue", local: "2.74.2" }), + expect.objectContaining({ name: "supabase/storage-api", local: "v1.28.0" }), + ]), + ); + }).pipe(Effect.ensuring(Effect.sync(() => rmSync(workdir, { recursive: true, force: true })))); + }); + + it.live("reports the Deno 1 edge-runtime image instead of the temp pin", () => { + const workdir = makeProjectWithConfig("[edge_runtime]\ndeno_version = 1\n"); + writeTempFile(workdir, "edge-runtime-version", "v9.9.9\n"); + const { layer, out } = setup({ goOutput: Option.some("json"), workdir }); + + return Effect.gen(function* () { + yield* legacyServices({}).pipe(Effect.provide(layer)); + + const rows = JSON.parse(out.stdoutText) as Array<{ + name: string; + local: string; + remote: string; + }>; + expect(rows).toContainEqual( + expect.objectContaining({ + name: "supabase/edge-runtime", + local: "v1.68.4", + }), + ); + }).pipe(Effect.ensuring(Effect.sync(() => rmSync(workdir, { recursive: true, force: true })))); + }); + + it.live("prints config load errors and falls back to the default matrix", () => { + const workdir = makeProjectWithConfig("[db]\nmajor_version = "); + writeTempFile(workdir, "storage-version", "v9.9.9\n"); + const { layer, out } = setup({ workdir }); + + return Effect.gen(function* () { + yield* legacyServices({}).pipe(Effect.provide(layer)); + + expect(out.stdoutText).toContain("supabase/postgres"); + expect(out.stdoutText).not.toContain("v9.9.9"); + expect(out.stderrText).not.toBe(""); + }).pipe(Effect.ensuring(Effect.sync(() => rmSync(workdir, { recursive: true, force: true })))); + }); + it.live("emits structured JSON for --output pretty combined with --output-format json", () => { // Regression guard (CLI-1546): a Go `--output pretty` must defer to the TS // `--output-format json` flag instead of forcing the human-readable table. diff --git a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts index ac8e8e573a..b9a89e6577 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts @@ -143,7 +143,7 @@ const DEFAULT_PORT = 54322; const DEFAULT_SHADOW_PORT = 54320; const DEFAULT_MAJOR_VERSION = 17; const DEFAULT_PASSWORD = "postgres"; -/** `[edge_runtime] deno_version` default (`config.toml` template). 2 → v1.74.1. */ +/** `[edge_runtime] deno_version` default (`config.toml` template). 2 → the current edge-runtime image. */ const DEFAULT_DENO_VERSION = 2; /** Default declarative schema dir (`utils.DeclarativeDir`, `misc.go:102`). */ diff --git a/apps/cli/src/legacy/shared/legacy-edge-runtime-image.ts b/apps/cli/src/legacy/shared/legacy-edge-runtime-image.ts index da146970ba..6bc21cbfb1 100644 --- a/apps/cli/src/legacy/shared/legacy-edge-runtime-image.ts +++ b/apps/cli/src/legacy/shared/legacy-edge-runtime-image.ts @@ -6,14 +6,14 @@ import { Effect, type FileSystem, type Path } from "effect"; * declarative pg-delta scripts that run inside the edge-runtime container. * * The default tag is baked into the Go binary via the embedded Dockerfile - * (`FROM supabase/edge-runtime:v1.74.1 AS edgeruntime`), mirrored here as a + * (`FROM supabase/edge-runtime:v1.74.2 AS edgeruntime`), mirrored here as a * constant. A pinned tag in `supabase/.temp/edge-runtime-version` overrides it * (written by `supabase start`). `edge_runtime.deno_version = 1` selects the - * legacy `deno1` image instead (default `deno_version = 2` keeps v1.74.1). + * legacy `deno1` image instead (default `deno_version = 2` keeps v1.74.2). */ -// `FROM supabase/edge-runtime:v1.74.1 AS edgeruntime` (embedded Dockerfile). -export const LEGACY_EDGE_RUNTIME_IMAGE = "supabase/edge-runtime:v1.74.1"; +// `FROM supabase/edge-runtime:v1.74.2 AS edgeruntime` (embedded Dockerfile). +export const LEGACY_EDGE_RUNTIME_IMAGE = "supabase/edge-runtime:v1.74.2"; // `deno1` (`pkg/config/constants.go:15`) — used when `deno_version = 1`. const LEGACY_EDGE_RUNTIME_DENO1_IMAGE = "supabase/edge-runtime:v1.68.4"; @@ -26,7 +26,7 @@ function replaceImageTag(image: string, tag: string): string { /** * Resolve the edge-runtime image, honoring the pinned tag in * `supabase/.temp/edge-runtime-version` and the `deno_version` selector - * (default 2 → v1.74.1; 1 → `deno1`). The version pin is applied first (Go's + * (default 2 → v1.74.2; 1 → `deno1`). The version pin is applied first (Go's * `Load`), then `deno_version = 1` overrides to `deno1` (Go's validate pass). */ export const legacyResolveEdgeRuntimeImage = Effect.fnUntraced(function* ( diff --git a/apps/cli/src/legacy/shared/legacy-edge-runtime-image.unit.test.ts b/apps/cli/src/legacy/shared/legacy-edge-runtime-image.unit.test.ts index 5565da4153..9b15eecf24 100644 --- a/apps/cli/src/legacy/shared/legacy-edge-runtime-image.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-edge-runtime-image.unit.test.ts @@ -15,12 +15,12 @@ const resolve = (workdir: string, denoVersion: number) => }).pipe(Effect.provide(BunServices.layer)); describe("legacyResolveEdgeRuntimeImage", () => { - it.effect("returns the default v1.74.1 image when nothing is pinned", () => { + it.effect("returns the default v1.74.2 image when nothing is pinned", () => { const dir = mkdtempSync(join(tmpdir(), "legacy-edge-img-")); return resolve(dir, 2).pipe( Effect.tap((image) => Effect.sync(() => { - expect(image).toBe("supabase/edge-runtime:v1.74.1"); + expect(image).toBe("supabase/edge-runtime:v1.74.2"); rmSync(dir, { recursive: true, force: true }); }), ), diff --git a/apps/cli/src/next/commands/services/services.command.ts b/apps/cli/src/next/commands/services/services.command.ts index 9716a5685c..2404d750b6 100644 --- a/apps/cli/src/next/commands/services/services.command.ts +++ b/apps/cli/src/next/commands/services/services.command.ts @@ -2,8 +2,10 @@ import { Layer } from "effect"; import { Command } from "effect/unstable/cli"; import { FetchHttpClient } from "effect/unstable/http"; import { credentialsLayer } from "../../auth/credentials.layer.ts"; +import { projectLocalServiceVersionsLayer } from "../../config/project-local-service-versions.layer.ts"; import { projectLinkStateLayer } from "../../config/project-link-state.layer.ts"; import { provideProjectCommandRuntime } from "../../config/project-runtime.layer.ts"; +import { projectStackStateManagerLayer } from "../../config/project-stack-state-manager.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; import { withCommandInstrumentation } from "../../../shared/telemetry/command-instrumentation.ts"; @@ -13,6 +15,8 @@ const servicesRuntimeLayer = provideProjectCommandRuntime( Layer.mergeAll( credentialsLayer, projectLinkStateLayer, + projectLocalServiceVersionsLayer, + projectStackStateManagerLayer, commandRuntimeLayer(["services"]), // `fetchLinkedServiceVersions` builds its management/tenant API clients from // the ambient HttpClient rather than self-provisioning one. diff --git a/apps/cli/src/next/commands/services/services.handler.ts b/apps/cli/src/next/commands/services/services.handler.ts index 5b7fc61c94..176495e638 100644 --- a/apps/cli/src/next/commands/services/services.handler.ts +++ b/apps/cli/src/next/commands/services/services.handler.ts @@ -1,6 +1,8 @@ +import { planStackVersions, StateManager } from "@supabase/stack/effect"; import { Effect, Exit, Option } from "effect"; import { Credentials } from "../../auth/credentials.service.ts"; import { CliConfig } from "../../config/cli-config.service.ts"; +import { ProjectLocalServiceVersions } from "../../config/project-local-service-versions.service.ts"; import { ProjectLinkState } from "../../config/project-link-state.service.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { @@ -20,14 +22,31 @@ export const services = Effect.fnUntraced(function* () { const output = yield* Output; const cliConfig = yield* CliConfig; const credentials = yield* Credentials; + const projectLocalServiceVersions = yield* ProjectLocalServiceVersions; const projectLinkState = yield* ProjectLinkState; + const stateManager = yield* StateManager; const commandRuntime = yield* CommandRuntime; const linkedStateExit = yield* projectLinkState.load.pipe(Effect.exit); const linkedState = Exit.isSuccess(linkedStateExit) ? linkedStateExit.value : Option.none(); const accessToken = yield* credentials.getAccessToken; + const localServiceVersions = yield* projectLocalServiceVersions.load; + const existingMetadata = yield* stateManager.readMetadata("default").pipe( + Effect.map(Option.some), + Effect.catchTag("StackMetadataNotFoundError", () => Effect.succeed(Option.none())), + ); + const serviceVersionContext = planStackVersions({ + ...(Option.isSome(linkedState) ? { candidateBaseline: linkedState.value.versions } : {}), + ...(Option.isSome(existingMetadata) ? { pinnedBaseline: existingMetadata.value.services } : {}), + ...(Option.isSome(localServiceVersions) + ? { localOverrides: localServiceVersions.value.versions } + : {}), + }); + const localImageOptions = { + serviceVersions: serviceVersionContext.runtimeVersions, + }; - let rows = listLocalServiceVersions(); + let rows = listLocalServiceVersions(localImageOptions); if (Option.isSome(linkedState) && Option.isSome(accessToken)) { const remote = yield* fetchLinkedServiceVersions({ apiUrl: cliConfig.apiUrl, @@ -40,7 +59,7 @@ export const services = Effect.fnUntraced(function* () { "X-Supabase-Command-Run-ID": commandRuntime.commandRunId, }, }); - rows = mergeRemoteServiceVersions(remote); + rows = mergeRemoteServiceVersions(remote, localImageOptions); } const warning = renderServicesWarning(rows); diff --git a/apps/cli/src/next/commands/services/services.integration.test.ts b/apps/cli/src/next/commands/services/services.integration.test.ts index b3abe533aa..72648c8210 100644 --- a/apps/cli/src/next/commands/services/services.integration.test.ts +++ b/apps/cli/src/next/commands/services/services.integration.test.ts @@ -1,14 +1,25 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { describe, expect, it } from "@effect/vitest"; +import { BunServices } from "@effect/platform-bun"; +import { DEFAULT_VERSIONS, stackMetadata, type VersionManifest } from "@supabase/stack/effect"; import { Effect, Layer, Option, Redacted } from "effect"; import { FetchHttpClient } from "effect/unstable/http"; import { CliConfig } from "../../config/cli-config.service.ts"; +import type { LocalServiceVersionsState } from "../../config/project-local-service-versions.service.ts"; +import { ProjectHome } from "../../config/project-home.service.ts"; import { ProjectLinkState, type ProjectLinkStateValue, } from "../../config/project-link-state.service.ts"; import { InvalidProjectLinkStateError } from "../../config/project-link-state.service.ts"; import { Credentials } from "../../auth/credentials.service.ts"; -import { mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { + mockOutput, + mockProjectLocalServiceVersions, + mockStateManager, +} from "../../../../tests/helpers/mocks.ts"; import { CommandRuntime } from "../../../shared/runtime/command-runtime.service.ts"; import { listLocalServiceVersions } from "../../../shared/services/services.shared.ts"; import { services } from "./services.handler.ts"; @@ -45,6 +56,9 @@ function setup( invalidLinkedState?: boolean; accessToken?: string; apiUrl?: string; + workdir?: string; + localServiceVersions?: LocalServiceVersionsState; + pinnedStackVersions?: VersionManifest; } = {}, ) { const out = mockOutput({ @@ -52,11 +66,49 @@ function setup( interactive: (opts.format ?? "text") === "text", }); const linkedState = opts.linkedState ?? Option.none(); + const projectRoot = opts.workdir ?? process.cwd(); + const supabaseDir = join(projectRoot, "supabase"); return { out, layer: Layer.mergeAll( + BunServices.layer, out.layer, + mockStateManager({ + metadata: + opts.pinnedStackVersions === undefined + ? [] + : [ + { + name: "default", + metadata: stackMetadata({ + ports: { + apiPort: 54321, + dbPort: 54322, + authPort: 54323, + postgrestPort: 54324, + postgrestAdminPort: 54325, + edgeRuntimePort: 54337, + edgeRuntimeInspectorPort: 54338, + realtimePort: 54326, + storagePort: 54327, + imgproxyPort: 54328, + mailpitPort: 54329, + mailpitSmtpPort: 54330, + mailpitPop3Port: 54331, + pgmetaPort: 54332, + studioPort: 54333, + analyticsPort: 54334, + poolerPort: 54335, + poolerApiPort: 54336, + }, + services: opts.pinnedStackVersions, + launch: { mode: "auto", excludedServices: [] }, + }), + }, + ], + }), + mockProjectLocalServiceVersions(opts.localServiceVersions), FetchHttpClient.layer, Layer.succeed( CliConfig, @@ -87,6 +139,22 @@ function setup( deleteAccessToken: Effect.die("unexpected deleteAccessToken"), }), ), + Layer.succeed( + ProjectHome, + ProjectHome.of({ + projectRoot, + supabaseDir, + projectHomeDir: join(supabaseDir, ".temp"), + projectLinkPath: join(supabaseDir, ".temp", "project-ref"), + projectLocalVersionsPath: join(supabaseDir, ".temp", "local-versions"), + ensureProjectHomeDir: Effect.void, + stackDir: (name) => join(supabaseDir, ".branches", name), + stackStatePath: (name) => join(supabaseDir, ".branches", name, "stack-state.json"), + stackMetadataPath: (name) => join(supabaseDir, ".branches", name, "stack.json"), + stackDataDir: (name) => join(supabaseDir, ".branches", name, "data"), + stackLogsDir: (name) => join(supabaseDir, ".branches", name, "logs"), + }), + ), Layer.succeed( ProjectLinkState, ProjectLinkState.of({ @@ -115,6 +183,18 @@ function setup( }; } +function makeProjectWithConfig(config: string): string { + const workdir = mkdtempSync(join(tmpdir(), "supabase-services-config-")); + const configDir = join(workdir, "supabase"); + mkdirSync(configDir, { recursive: true }); + writeFileSync(join(configDir, "config.toml"), config); + return workdir; +} + +function makeProjectWithDbMajorVersion(majorVersion: number): string { + return makeProjectWithConfig(`[db]\nmajor_version = ${majorVersion}\n`); +} + describe("next services", () => { it.live("prints the services table in text mode", () => { const { layer, out } = setup(); @@ -147,6 +227,112 @@ describe("next services", () => { }); }); + it.live("reports the stack runtime Postgres version instead of config db.major_version", () => { + const workdir = makeProjectWithDbMajorVersion(15); + const { layer, out } = setup({ format: "json", workdir }); + + return Effect.gen(function* () { + yield* services().pipe(Effect.provide(layer)); + + const success = out.messages.find((message) => message.type === "success"); + expect(success?.data).toMatchObject({ + services: expect.arrayContaining([ + expect.objectContaining({ + name: "supabase/postgres", + local: DEFAULT_VERSIONS.postgres, + }), + ]), + }); + }).pipe(Effect.ensuring(Effect.sync(() => rmSync(workdir, { recursive: true, force: true })))); + }); + + it.live("reports the stack runtime version instead of linked remote config db overrides", () => { + const workdir = makeProjectWithConfig(` +[db] +major_version = 17 + +[remotes.linked] +project_id = "${LINKED_REF}" + +[remotes.linked.db] +major_version = 15 +`); + const { layer, out } = setup({ + format: "json", + linkedState: Option.some(linkedStateFixture()), + workdir, + }); + + return Effect.gen(function* () { + yield* services().pipe(Effect.provide(layer)); + + const success = out.messages.find((message) => message.type === "success"); + expect(success?.data).toMatchObject({ + services: expect.arrayContaining([ + expect.objectContaining({ + name: "supabase/postgres", + local: DEFAULT_VERSIONS.postgres, + }), + ]), + }); + }).pipe(Effect.ensuring(Effect.sync(() => rmSync(workdir, { recursive: true, force: true })))); + }); + + it.live("reports pinned local service versions", () => { + const { layer, out } = setup({ + format: "json", + localServiceVersions: { + updatedAt: "2026-07-01T14:00:00.000Z", + versions: { + postgres: "15.1.0.117", + auth: "2.74.2", + storage: "1.28.0", + }, + }, + }); + + return Effect.gen(function* () { + yield* services().pipe(Effect.provide(layer)); + + const success = out.messages.find((message) => message.type === "success"); + expect(success?.data).toMatchObject({ + services: expect.arrayContaining([ + expect.objectContaining({ name: "supabase/postgres", local: "15.1.0.117" }), + expect.objectContaining({ name: "supabase/gotrue", local: "v2.74.2" }), + expect.objectContaining({ name: "supabase/storage-api", local: "v1.28.0" }), + ]), + }); + }); + }); + + it.live("reports pinned stack metadata before newer linked baseline versions", () => { + const { layer, out } = setup({ + format: "json", + linkedState: Option.some({ + ...linkedStateFixture(), + versions: { postgres: "17.6.1.200" }, + }), + pinnedStackVersions: { + ...DEFAULT_VERSIONS, + postgres: "17.6.1.100", + }, + }); + + return Effect.gen(function* () { + yield* services().pipe(Effect.provide(layer)); + + const success = out.messages.find((message) => message.type === "success"); + expect(success?.data).toMatchObject({ + services: expect.arrayContaining([ + expect.objectContaining({ + name: "supabase/postgres", + local: "17.6.1.100", + }), + ]), + }); + }); + }); + it.live("falls back to local output when linked state is invalid", () => { const { layer, out } = setup({ invalidLinkedState: true }); diff --git a/apps/cli/src/shared/functions/serve.ts b/apps/cli/src/shared/functions/serve.ts index 91874fb603..e11a1c4736 100644 --- a/apps/cli/src/shared/functions/serve.ts +++ b/apps/cli/src/shared/functions/serve.ts @@ -83,7 +83,7 @@ const ignoredDirNames = new Set([ const dockerLogRetryDelay = Duration.millis(400); const dockerLogDiagnosticTailLength = 4_096; const remoteJwksTimeoutMs = 10_000; -const legacyDefaultEdgeRuntimeVersion = "v1.74.1"; +const legacyDefaultEdgeRuntimeVersion = "v1.74.2"; const defaultSupabaseEnv = "development"; const serveMainContainerPath = "/root/index.ts"; const clerkDomainPattern = /^(clerk([.][a-z0-9-]+){2,}|([a-z0-9-]+[.])+clerk[.]accounts[.]dev)$/; diff --git a/apps/cli/src/shared/services/services.shared.ts b/apps/cli/src/shared/services/services.shared.ts index 96f36cb10d..05840cb893 100644 --- a/apps/cli/src/shared/services/services.shared.ts +++ b/apps/cli/src/shared/services/services.shared.ts @@ -14,6 +14,26 @@ export { parseDockerfileServiceImages } from "./dockerfile-images.ts"; export type RemoteServiceName = "postgres" | "auth" | "postgrest" | "storage"; export type OptionalRemoteServiceName = Exclude; +export type LocalServiceVersionName = + | "postgres" + | "auth" + | "postgrest" + | "realtime" + | "storage" + | "edge-runtime" + | "studio" + | "pgmeta" + | "analytics" + | "pooler"; + +export type LocalServiceVersionOverrides = Partial>; +export type LocalServiceImageOverrides = Partial>; + +export interface LocalServiceImageOptions { + readonly imageOverrides?: LocalServiceImageOverrides; + readonly normalizeVersionTags?: boolean; + readonly serviceVersions?: LocalServiceVersionOverrides; +} // Mirrors Go's `utils.ProjectRefPattern` (`apps/cli-go/internal/utils/misc.go`). // Validating the ref before it reaches the management API path param or the @@ -24,26 +44,37 @@ const PROJECT_REF_PATTERN = /^[a-z]{20}$/; interface ServiceImageSpec { readonly image: string; readonly remoteService: RemoteServiceName | undefined; + readonly localService: LocalServiceVersionName; } interface ServiceImageAliasSpec { readonly alias: string; readonly remoteService: RemoteServiceName | undefined; + readonly localService: LocalServiceVersionName; } const SERVICE_IMAGE_ALIASES: ReadonlyArray = [ - { alias: "pg", remoteService: "postgres" }, - { alias: "gotrue", remoteService: "auth" }, - { alias: "postgrest", remoteService: "postgrest" }, - { alias: "realtime", remoteService: undefined }, - { alias: "storage", remoteService: "storage" }, - { alias: "edgeruntime", remoteService: undefined }, - { alias: "studio", remoteService: undefined }, - { alias: "pgmeta", remoteService: undefined }, - { alias: "logflare", remoteService: undefined }, - { alias: "supavisor", remoteService: undefined }, + { alias: "pg", remoteService: "postgres", localService: "postgres" }, + { alias: "gotrue", remoteService: "auth", localService: "auth" }, + { alias: "postgrest", remoteService: "postgrest", localService: "postgrest" }, + { alias: "realtime", remoteService: undefined, localService: "realtime" }, + { alias: "storage", remoteService: "storage", localService: "storage" }, + { alias: "edgeruntime", remoteService: undefined, localService: "edge-runtime" }, + { alias: "studio", remoteService: undefined, localService: "studio" }, + { alias: "pgmeta", remoteService: undefined, localService: "pgmeta" }, + { alias: "logflare", remoteService: undefined, localService: "analytics" }, + { alias: "supavisor", remoteService: undefined, localService: "pooler" }, ]; +const SERVICE_VERSION_TAG_PREFIX: Partial> = { + auth: "v", + postgrest: "v", + realtime: "v", + storage: "v", + "edge-runtime": "v", + pgmeta: "v", +}; + function localServiceImagesFromSpecs( specs: ReadonlyArray, ): ReadonlyArray { @@ -57,6 +88,7 @@ function localServiceImagesFromSpecs( return { image, remoteService: service.remoteService, + localService: service.localService, }; }); } @@ -69,6 +101,57 @@ export function localServiceImagesFromDockerfile( const LOCAL_SERVICE_IMAGES = localServiceImagesFromSpecs(dockerfileServiceImages); +// Mirrors Go's config image rewrite in `apps/cli-go/pkg/config/config.go`. +// Major version 13 intentionally falls through to the pg15 image there. +export function postgresImageForDbMajorVersion(majorVersion: number): string | undefined { + switch (majorVersion) { + case 13: + case 15: + return "supabase/postgres:15.8.1.085"; + case 14: + return "supabase/postgres:14.1.0.89"; + default: + return undefined; + } +} + +function replaceImageTag(image: string, tag: string): string { + const index = image.lastIndexOf(":"); + if (index === -1) { + return image; + } + return `${image.slice(0, index + 1)}${tag.trim()}`; +} + +function tagForServiceVersion(service: LocalServiceVersionName, version: string): string { + const trimmed = version.trim(); + const prefix = SERVICE_VERSION_TAG_PREFIX[service]; + if (prefix === "v" && !trimmed.toLowerCase().startsWith("v")) { + return `v${trimmed}`; + } + return trimmed; +} + +function localServiceImagesForOptions( + options: LocalServiceImageOptions = {}, +): ReadonlyArray { + const normalizeVersionTags = options.normalizeVersionTags ?? true; + return LOCAL_SERVICE_IMAGES.map((service) => { + const baseImage = options.imageOverrides?.[service.localService] ?? service.image; + const version = options.serviceVersions?.[service.localService]; + if (version === undefined || version.trim().length === 0) { + return baseImage === service.image ? service : { ...service, image: baseImage }; + } + return { + ...service, + image: replaceImageTag( + baseImage, + normalizeVersionTags ? tagForServiceVersion(service.localService, version) : version, + ), + }; + }); +} + const TABLE_HEADERS = ["SERVICE IMAGE", "LOCAL", "LINKED"] as const; type ProjectApiKey = { @@ -288,14 +371,19 @@ const makeConfiguredApiClient = Effect.fnUntraced(function* (input: ServiceFetch ); }); -export function listLocalServiceVersions(): ReadonlyArray { - return LOCAL_SERVICE_IMAGES.map((service) => toServiceVersionRow(service)); +export function listLocalServiceVersions( + options: LocalServiceImageOptions = {}, +): ReadonlyArray { + return localServiceImagesForOptions(options).map((service) => toServiceVersionRow(service)); } export function mergeRemoteServiceVersions( remote: Partial>, + options: LocalServiceImageOptions = {}, ): ReadonlyArray { - return LOCAL_SERVICE_IMAGES.map((service) => toServiceVersionRow(service, remote)); + return localServiceImagesForOptions(options).map((service) => + toServiceVersionRow(service, remote), + ); } export function renderServicesTable(rows: ReadonlyArray): string { diff --git a/apps/cli/src/shared/services/services.shared.unit.test.ts b/apps/cli/src/shared/services/services.shared.unit.test.ts index 27be161714..bb343ee103 100644 --- a/apps/cli/src/shared/services/services.shared.unit.test.ts +++ b/apps/cli/src/shared/services/services.shared.unit.test.ts @@ -68,6 +68,22 @@ describe("services shared", () => { ]); }); + test("can preserve raw local service version overrides", () => { + expect( + listLocalServiceVersions({ + normalizeVersionTags: false, + serviceVersions: { + auth: "2.151.0", + }, + }), + ).toContainEqual( + expect.objectContaining({ + name: "supabase/gotrue", + local: "2.151.0", + }), + ); + }); + test("returns postgres only when no service-role key is available", async () => { const server = Bun.serve({ port: 0,