From c9ccb0db02f60b70c60287d89573b049531f4729 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 1 Jul 2026 16:26:28 +0200 Subject: [PATCH 1/4] fix(cli): report configured postgres service version --- .../commands/services/services.handler.ts | 9 +++- .../services/services.integration.test.ts | 34 +++++++++++- .../commands/services/services.handler.ts | 11 +++- .../services/services.integration.test.ts | 52 +++++++++++++++++++ .../src/shared/services/services.shared.ts | 43 +++++++++++++-- 5 files changed, 140 insertions(+), 9 deletions(-) diff --git a/apps/cli/src/legacy/commands/services/services.handler.ts b/apps/cli/src/legacy/commands/services/services.handler.ts index 28b4dc2461..6839deb3d3 100644 --- a/apps/cli/src/legacy/commands/services/services.handler.ts +++ b/apps/cli/src/legacy/commands/services/services.handler.ts @@ -1,3 +1,4 @@ +import { loadProjectConfig } from "@supabase/config"; import { Effect, Exit, FileSystem, Option, Path } from "effect"; import { LegacyCliConfig } from "../../config/legacy-cli-config.service.ts"; import { LegacyCredentials } from "../../auth/legacy-credentials.service.ts"; @@ -55,8 +56,12 @@ 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 loadedProjectConfig = yield* loadProjectConfig(cliConfig.workdir).pipe( + Effect.catch(() => Effect.succeed(null)), + ); + const projectConfig = loadedProjectConfig?.config ?? null; - let rows = listLocalServiceVersions(); + let rows = listLocalServiceVersions(projectConfig); if (Option.isSome(linkedProjectRef) && Option.isSome(accessToken)) { const remote = yield* fetchLinkedServiceVersions({ apiUrl: cliConfig.apiUrl, @@ -65,7 +70,7 @@ export const legacyServices = Effect.fn("legacy.services")(function* (_flags: Le accessToken: accessToken.value, userAgent: cliConfig.userAgent, }); - rows = mergeRemoteServiceVersions(remote); + rows = mergeRemoteServiceVersions(remote, projectConfig); } const warning = renderServicesWarning(rows); 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..ae062410b2 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"; @@ -41,6 +41,7 @@ function setup( opts: { format?: "text" | "json" | "stream-json"; goOutput?: Option.Option<"env" | "pretty" | "json" | "toml" | "yaml">; + workdir?: string; } = {}, ) { const out = mockOutput({ @@ -69,7 +70,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 +101,14 @@ const legacyTestRoot = Command.make("supabase").pipe( Command.withSubcommands([legacyServicesCommand]), ); +function makeProjectWithDbMajorVersion(majorVersion: number) { + const workdir = mkdtempSync(join(tmpdir(), "supabase-services-config-")); + const configDir = join(workdir, "supabase"); + mkdirSync(configDir, { recursive: true }); + writeFileSync(join(configDir, "config.toml"), `[db]\nmajor_version = ${majorVersion}\n`); + return workdir; +} + function expectFailureTag(exit: Exit.Exit, tag: string) { expect(Exit.isFailure(exit)).toBe(true); if (!Exit.isFailure(exit)) { @@ -197,6 +206,27 @@ 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: "15.8.1.085", + }), + ); + }).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/next/commands/services/services.handler.ts b/apps/cli/src/next/commands/services/services.handler.ts index 5b7fc61c94..e028368243 100644 --- a/apps/cli/src/next/commands/services/services.handler.ts +++ b/apps/cli/src/next/commands/services/services.handler.ts @@ -1,7 +1,9 @@ +import { loadProjectConfig } from "@supabase/config"; import { Effect, Exit, Option } from "effect"; import { Credentials } from "../../auth/credentials.service.ts"; import { CliConfig } from "../../config/cli-config.service.ts"; import { ProjectLinkState } from "../../config/project-link-state.service.ts"; +import { ProjectHome } from "../../config/project-home.service.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { CommandRuntime, @@ -20,14 +22,19 @@ export const services = Effect.fnUntraced(function* () { const output = yield* Output; const cliConfig = yield* CliConfig; const credentials = yield* Credentials; + const projectHome = yield* ProjectHome; const projectLinkState = yield* ProjectLinkState; 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 loadedProjectConfig = yield* loadProjectConfig(projectHome.projectRoot).pipe( + Effect.catch(() => Effect.succeed(null)), + ); + const projectConfig = loadedProjectConfig?.config ?? null; - let rows = listLocalServiceVersions(); + let rows = listLocalServiceVersions(projectConfig); if (Option.isSome(linkedState) && Option.isSome(accessToken)) { const remote = yield* fetchLinkedServiceVersions({ apiUrl: cliConfig.apiUrl, @@ -40,7 +47,7 @@ export const services = Effect.fnUntraced(function* () { "X-Supabase-Command-Run-ID": commandRuntime.commandRunId, }, }); - rows = mergeRemoteServiceVersions(remote); + rows = mergeRemoteServiceVersions(remote, projectConfig); } 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..66379fcf40 100644 --- a/apps/cli/src/next/commands/services/services.integration.test.ts +++ b/apps/cli/src/next/commands/services/services.integration.test.ts @@ -1,7 +1,12 @@ +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 { Effect, Layer, Option, Redacted } from "effect"; import { FetchHttpClient } from "effect/unstable/http"; import { CliConfig } from "../../config/cli-config.service.ts"; +import { ProjectHome } from "../../config/project-home.service.ts"; import { ProjectLinkState, type ProjectLinkStateValue, @@ -45,6 +50,7 @@ function setup( invalidLinkedState?: boolean; accessToken?: string; apiUrl?: string; + workdir?: string; } = {}, ) { const out = mockOutput({ @@ -52,10 +58,13 @@ 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, FetchHttpClient.layer, Layer.succeed( @@ -87,6 +96,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 +140,14 @@ function setup( }; } +function makeProjectWithDbMajorVersion(majorVersion: number) { + const workdir = mkdtempSync(join(tmpdir(), "supabase-services-config-")); + const configDir = join(workdir, "supabase"); + mkdirSync(configDir, { recursive: true }); + writeFileSync(join(configDir, "config.toml"), `[db]\nmajor_version = ${majorVersion}\n`); + return workdir; +} + describe("next services", () => { it.live("prints the services table in text mode", () => { const { layer, out } = setup(); @@ -147,6 +180,25 @@ describe("next services", () => { }); }); + it.live("reports the configured Postgres version for local projects", () => { + 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: "15.8.1.085", + }), + ]), + }); + }).pipe(Effect.ensuring(Effect.sync(() => rmSync(workdir, { recursive: true, force: true })))); + }); + 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/services/services.shared.ts b/apps/cli/src/shared/services/services.shared.ts index 96f36cb10d..52d863eeda 100644 --- a/apps/cli/src/shared/services/services.shared.ts +++ b/apps/cli/src/shared/services/services.shared.ts @@ -1,5 +1,6 @@ import { styleText } from "node:util"; import { makeApiClient, type ApiClient } from "@supabase/api/effect"; +import type { ProjectConfig } from "@supabase/config"; import { Data, Duration, Effect, Exit, Redacted } from "effect"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; @@ -69,6 +70,37 @@ 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. +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 localServiceImagesForConfig( + projectConfig: ProjectConfig | null = null, +): ReadonlyArray { + const postgresImage = + projectConfig === null + ? undefined + : postgresImageForDbMajorVersion(projectConfig.db.major_version); + + if (postgresImage === undefined) { + return LOCAL_SERVICE_IMAGES; + } + + return LOCAL_SERVICE_IMAGES.map((service) => + service.remoteService === "postgres" ? { ...service, image: postgresImage } : service, + ); +} + const TABLE_HEADERS = ["SERVICE IMAGE", "LOCAL", "LINKED"] as const; type ProjectApiKey = { @@ -288,14 +320,19 @@ const makeConfiguredApiClient = Effect.fnUntraced(function* (input: ServiceFetch ); }); -export function listLocalServiceVersions(): ReadonlyArray { - return LOCAL_SERVICE_IMAGES.map((service) => toServiceVersionRow(service)); +export function listLocalServiceVersions( + projectConfig: ProjectConfig | null = null, +): ReadonlyArray { + return localServiceImagesForConfig(projectConfig).map((service) => toServiceVersionRow(service)); } export function mergeRemoteServiceVersions( remote: Partial>, + projectConfig: ProjectConfig | null = null, ): ReadonlyArray { - return LOCAL_SERVICE_IMAGES.map((service) => toServiceVersionRow(service, remote)); + return localServiceImagesForConfig(projectConfig).map((service) => + toServiceVersionRow(service, remote), + ); } export function renderServicesTable(rows: ReadonlyArray): string { From 63307062a83a48441248ad10259bc6cfc73c5c40 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 1 Jul 2026 16:53:17 +0200 Subject: [PATCH 2/4] fix(cli): honor pinned service versions --- .../commands/services/services.handler.ts | 77 +++++++++++- .../services/services.integration.test.ts | 97 ++++++++++++++- .../commands/services/services.command.ts | 2 + .../commands/services/services.handler.ts | 29 ++++- .../services/services.integration.test.ts | 87 +++++++++++++- .../src/shared/services/services.shared.ts | 111 +++++++++++++----- 6 files changed, 359 insertions(+), 44 deletions(-) diff --git a/apps/cli/src/legacy/commands/services/services.handler.ts b/apps/cli/src/legacy/commands/services/services.handler.ts index 6839deb3d3..0747bcda8b 100644 --- a/apps/cli/src/legacy/commands/services/services.handler.ts +++ b/apps/cli/src/legacy/commands/services/services.handler.ts @@ -4,6 +4,8 @@ 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 { legacyResolveDbImage } from "../../shared/legacy-db-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"; @@ -12,6 +14,8 @@ import { fetchLinkedServiceVersions, formatServicesWarning, listLocalServiceVersions, + type LocalServiceVersionName, + type LocalServiceVersionOverrides, mergeRemoteServiceVersions, renderServicesTable, renderServicesWarning, @@ -56,12 +60,33 @@ 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 loadedProjectConfig = yield* loadProjectConfig(cliConfig.workdir).pipe( - Effect.catch(() => Effect.succeed(null)), + const loadedProjectConfig = yield* loadProjectConfig(cliConfig.workdir, { + projectRef: Option.getOrUndefined(linkedProjectRef), + }).pipe( + Effect.catch((error) => + output.raw(`${formatConfigLoadError(error)}\n`, "stderr").pipe(Effect.as(null)), + ), ); const projectConfig = loadedProjectConfig?.config ?? null; + const serviceVersions = yield* readLegacyServiceVersionOverrides( + fs, + path, + cliConfig.workdir, + projectConfig?.db.major_version, + ); + const postgresImage = + projectConfig === null + ? undefined + : yield* legacyResolveDbImage( + fs, + path, + cliConfig.workdir, + projectConfig.db.major_version, + projectConfig.experimental.orioledb_version, + ); + const localImageOptions = { projectConfig, postgresImage, serviceVersions }; - let rows = listLocalServiceVersions(projectConfig); + let rows = listLocalServiceVersions(localImageOptions); if (Option.isSome(linkedProjectRef) && Option.isSome(accessToken)) { const remote = yield* fetchLinkedServiceVersions({ apiUrl: cliConfig.apiUrl, @@ -70,7 +95,7 @@ export const legacyServices = Effect.fn("legacy.services")(function* (_flags: Le accessToken: accessToken.value, userAgent: cliConfig.userAgent, }); - rows = mergeRemoteServiceVersions(remote, projectConfig); + rows = mergeRemoteServiceVersions(remote, localImageOptions); } const warning = renderServicesWarning(rows); @@ -115,3 +140,47 @@ 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"], + ["edge-runtime", "edge-runtime-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 ae062410b2..c20fd3c291 100644 --- a/apps/cli/src/legacy/commands/services/services.integration.test.ts +++ b/apps/cli/src/legacy/commands/services/services.integration.test.ts @@ -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"; @@ -101,14 +104,32 @@ const legacyTestRoot = Command.make("supabase").pipe( Command.withSubcommands([legacyServicesCommand]), ); -function makeProjectWithDbMajorVersion(majorVersion: number) { +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"), `[db]\nmajor_version = ${majorVersion}\n`); + writeFileSync(join(configDir, "config.toml"), config); 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)) { @@ -221,12 +242,80 @@ describe("legacy services", () => { expect(rows).toContainEqual( expect.objectContaining({ name: "supabase/postgres", - local: "15.8.1.085", + 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", "v2.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: "v2.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("prints config load errors and falls back to the default matrix", () => { + const workdir = makeProjectWithConfig("[db]\nmajor_version = "); + const { layer, out } = setup({ workdir }); + + return Effect.gen(function* () { + yield* legacyServices({}).pipe(Effect.provide(layer)); + + expect(out.stdoutText).toContain("supabase/postgres"); + 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/next/commands/services/services.command.ts b/apps/cli/src/next/commands/services/services.command.ts index 9716a5685c..2b5525ead8 100644 --- a/apps/cli/src/next/commands/services/services.command.ts +++ b/apps/cli/src/next/commands/services/services.command.ts @@ -2,6 +2,7 @@ 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 { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; @@ -13,6 +14,7 @@ const servicesRuntimeLayer = provideProjectCommandRuntime( Layer.mergeAll( credentialsLayer, projectLinkStateLayer, + projectLocalServiceVersionsLayer, 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 e028368243..5800b70e41 100644 --- a/apps/cli/src/next/commands/services/services.handler.ts +++ b/apps/cli/src/next/commands/services/services.handler.ts @@ -2,6 +2,7 @@ import { loadProjectConfig } from "@supabase/config"; 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 { ProjectHome } from "../../config/project-home.service.ts"; import { Output } from "../../../shared/output/output.service.ts"; @@ -23,18 +24,34 @@ export const services = Effect.fnUntraced(function* () { const cliConfig = yield* CliConfig; const credentials = yield* Credentials; const projectHome = yield* ProjectHome; + const projectLocalServiceVersions = yield* ProjectLocalServiceVersions; const projectLinkState = yield* ProjectLinkState; 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 loadedProjectConfig = yield* loadProjectConfig(projectHome.projectRoot).pipe( - Effect.catch(() => Effect.succeed(null)), + const loadedProjectConfig = yield* loadProjectConfig(projectHome.projectRoot, { + projectRef: Option.match(linkedState, { + onNone: () => undefined, + onSome: (state) => state.project.ref, + }), + }).pipe( + Effect.catch((error) => + output.raw(`${formatConfigLoadError(error)}\n`, "stderr").pipe(Effect.as(null)), + ), ); const projectConfig = loadedProjectConfig?.config ?? null; + const localServiceVersions = yield* projectLocalServiceVersions.load; + const localImageOptions = { + projectConfig, + serviceVersions: Option.match(localServiceVersions, { + onNone: () => undefined, + onSome: (state) => state.versions, + }), + }; - let rows = listLocalServiceVersions(projectConfig); + let rows = listLocalServiceVersions(localImageOptions); if (Option.isSome(linkedState) && Option.isSome(accessToken)) { const remote = yield* fetchLinkedServiceVersions({ apiUrl: cliConfig.apiUrl, @@ -47,7 +64,7 @@ export const services = Effect.fnUntraced(function* () { "X-Supabase-Command-Run-ID": commandRuntime.commandRunId, }, }); - rows = mergeRemoteServiceVersions(remote, projectConfig); + rows = mergeRemoteServiceVersions(remote, localImageOptions); } const warning = renderServicesWarning(rows); @@ -62,3 +79,7 @@ export const services = Effect.fnUntraced(function* () { yield* output.success("", { services: rows }); }); + +function formatConfigLoadError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} 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 66379fcf40..2a258a0a26 100644 --- a/apps/cli/src/next/commands/services/services.integration.test.ts +++ b/apps/cli/src/next/commands/services/services.integration.test.ts @@ -6,6 +6,7 @@ import { BunServices } from "@effect/platform-bun"; 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, @@ -13,9 +14,12 @@ import { } 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 } from "../../../../tests/helpers/mocks.ts"; import { CommandRuntime } from "../../../shared/runtime/command-runtime.service.ts"; -import { listLocalServiceVersions } from "../../../shared/services/services.shared.ts"; +import { + listLocalServiceVersions, + postgresImageForDbMajorVersion, +} from "../../../shared/services/services.shared.ts"; import { services } from "./services.handler.ts"; const LINKED_REF = "abcdefghijklmnopqrst"; @@ -51,6 +55,7 @@ function setup( accessToken?: string; apiUrl?: string; workdir?: string; + localServiceVersions?: LocalServiceVersionsState; } = {}, ) { const out = mockOutput({ @@ -66,6 +71,7 @@ function setup( layer: Layer.mergeAll( BunServices.layer, out.layer, + mockProjectLocalServiceVersions(opts.localServiceVersions), FetchHttpClient.layer, Layer.succeed( CliConfig, @@ -140,14 +146,26 @@ function setup( }; } -function makeProjectWithDbMajorVersion(majorVersion: number) { +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"), `[db]\nmajor_version = ${majorVersion}\n`); + writeFileSync(join(configDir, "config.toml"), config); return workdir; } +function makeProjectWithDbMajorVersion(majorVersion: number): string { + return makeProjectWithConfig(`[db]\nmajor_version = ${majorVersion}\n`); +} + +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); +} + describe("next services", () => { it.live("prints the services table in text mode", () => { const { layer, out } = setup(); @@ -192,13 +210,72 @@ describe("next services", () => { services: expect.arrayContaining([ expect.objectContaining({ name: "supabase/postgres", - local: "15.8.1.085", + 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 = "${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: postgresVersionForDbMajorVersion(15), }), ]), }); }).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("falls back to local output when linked state is invalid", () => { const { layer, out } = setup({ invalidLinkedState: true }); diff --git a/apps/cli/src/shared/services/services.shared.ts b/apps/cli/src/shared/services/services.shared.ts index 52d863eeda..62c8e350ec 100644 --- a/apps/cli/src/shared/services/services.shared.ts +++ b/apps/cli/src/shared/services/services.shared.ts @@ -15,6 +15,25 @@ 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 interface LocalServiceImageOptions { + readonly projectConfig?: ProjectConfig | null; + readonly postgresImage?: string; + 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 @@ -25,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 { @@ -58,6 +88,7 @@ function localServiceImagesFromSpecs( return { image, remoteService: service.remoteService, + localService: service.localService, }; }); } @@ -72,7 +103,7 @@ 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. -function postgresImageForDbMajorVersion(majorVersion: number): string | undefined { +export function postgresImageForDbMajorVersion(majorVersion: number): string | undefined { switch (majorVersion) { case 13: case 15: @@ -84,21 +115,47 @@ function postgresImageForDbMajorVersion(majorVersion: number): string | undefine } } -function localServiceImagesForConfig( - projectConfig: ProjectConfig | null = null, -): ReadonlyArray { - const postgresImage = - projectConfig === null - ? undefined - : postgresImageForDbMajorVersion(projectConfig.db.major_version); +function replaceImageTag(image: string, tag: string): string { + const index = image.lastIndexOf(":"); + if (index === -1) { + return image; + } + return `${image.slice(0, index + 1)}${tag.trim()}`; +} - if (postgresImage === undefined) { - return LOCAL_SERVICE_IMAGES; +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; +} - return LOCAL_SERVICE_IMAGES.map((service) => - service.remoteService === "postgres" ? { ...service, image: postgresImage } : service, - ); +function localServiceImagesForOptions( + options: LocalServiceImageOptions = {}, +): ReadonlyArray { + const projectConfig = options.projectConfig ?? null; + const postgresImage = + options.postgresImage ?? + (projectConfig === null + ? undefined + : postgresImageForDbMajorVersion(projectConfig.db.major_version)); + + return LOCAL_SERVICE_IMAGES.map((service) => { + const baseImage = + service.localService === "postgres" && postgresImage !== undefined + ? postgresImage + : 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, tagForServiceVersion(service.localService, version)), + }; + }); } const TABLE_HEADERS = ["SERVICE IMAGE", "LOCAL", "LINKED"] as const; @@ -321,16 +378,16 @@ const makeConfiguredApiClient = Effect.fnUntraced(function* (input: ServiceFetch }); export function listLocalServiceVersions( - projectConfig: ProjectConfig | null = null, + options: LocalServiceImageOptions = {}, ): ReadonlyArray { - return localServiceImagesForConfig(projectConfig).map((service) => toServiceVersionRow(service)); + return localServiceImagesForOptions(options).map((service) => toServiceVersionRow(service)); } export function mergeRemoteServiceVersions( remote: Partial>, - projectConfig: ProjectConfig | null = null, + options: LocalServiceImageOptions = {}, ): ReadonlyArray { - return localServiceImagesForConfig(projectConfig).map((service) => + return localServiceImagesForOptions(options).map((service) => toServiceVersionRow(service, remote), ); } From 97fa1fbabd9ad9edcabb04add98027e8067d9b23 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 1 Jul 2026 17:44:13 +0200 Subject: [PATCH 3/4] fix(cli): align services version sources --- .../commands/services/services.handler.ts | 39 ++++++++++---- .../services/services.integration.test.ts | 52 +++++++++++++++++++ .../commands/services/services.handler.ts | 31 +++-------- .../services/services.integration.test.ts | 22 +++----- .../src/shared/services/services.shared.ts | 17 ++---- 5 files changed, 97 insertions(+), 64 deletions(-) diff --git a/apps/cli/src/legacy/commands/services/services.handler.ts b/apps/cli/src/legacy/commands/services/services.handler.ts index 0747bcda8b..35c55600aa 100644 --- a/apps/cli/src/legacy/commands/services/services.handler.ts +++ b/apps/cli/src/legacy/commands/services/services.handler.ts @@ -1,10 +1,11 @@ -import { loadProjectConfig } from "@supabase/config"; import { Effect, Exit, FileSystem, Option, Path } from "effect"; 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"; @@ -14,6 +15,7 @@ import { fetchLinkedServiceVersions, formatServicesWarning, listLocalServiceVersions, + type LocalServiceImageOverrides, type LocalServiceVersionName, type LocalServiceVersionOverrides, mergeRemoteServiceVersions, @@ -60,31 +62,47 @@ 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 loadedProjectConfig = yield* loadProjectConfig(cliConfig.workdir, { - projectRef: Option.getOrUndefined(linkedProjectRef), - }).pipe( + 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 projectConfig = loadedProjectConfig?.config ?? null; const serviceVersions = yield* readLegacyServiceVersionOverrides( fs, path, cliConfig.workdir, - projectConfig?.db.major_version, + tomlValues?.majorVersion, ); const postgresImage = - projectConfig === null + tomlValues === null ? undefined : yield* legacyResolveDbImage( fs, path, cliConfig.workdir, - projectConfig.db.major_version, - projectConfig.experimental.orioledb_version, + tomlValues.majorVersion, + Option.getOrUndefined(tomlValues.orioledbVersion), ); - const localImageOptions = { projectConfig, postgresImage, serviceVersions }; + 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, + serviceVersions, + }; let rows = listLocalServiceVersions(localImageOptions); if (Option.isSome(linkedProjectRef) && Option.isSome(accessToken)) { @@ -149,7 +167,6 @@ 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"], - ["edge-runtime", "edge-runtime-version"], ["realtime", "realtime-version"], ["studio", "studio-version"], ["pgmeta", "pgmeta-version"], 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 c20fd3c291..d797cf161b 100644 --- a/apps/cli/src/legacy/commands/services/services.integration.test.ts +++ b/apps/cli/src/legacy/commands/services/services.integration.test.ts @@ -112,6 +112,12 @@ function makeProjectWithConfig(config: string): string { 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`); } @@ -248,6 +254,30 @@ describe("legacy services", () => { }).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] @@ -304,6 +334,28 @@ major_version = 15 }).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 = "); const { layer, out } = setup({ workdir }); diff --git a/apps/cli/src/next/commands/services/services.handler.ts b/apps/cli/src/next/commands/services/services.handler.ts index 5800b70e41..1096fdba54 100644 --- a/apps/cli/src/next/commands/services/services.handler.ts +++ b/apps/cli/src/next/commands/services/services.handler.ts @@ -1,10 +1,9 @@ -import { loadProjectConfig } from "@supabase/config"; +import { planStackVersions } 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 { ProjectHome } from "../../config/project-home.service.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { CommandRuntime, @@ -23,7 +22,6 @@ export const services = Effect.fnUntraced(function* () { const output = yield* Output; const cliConfig = yield* CliConfig; const credentials = yield* Credentials; - const projectHome = yield* ProjectHome; const projectLocalServiceVersions = yield* ProjectLocalServiceVersions; const projectLinkState = yield* ProjectLinkState; const commandRuntime = yield* CommandRuntime; @@ -31,24 +29,15 @@ export const services = Effect.fnUntraced(function* () { const linkedStateExit = yield* projectLinkState.load.pipe(Effect.exit); const linkedState = Exit.isSuccess(linkedStateExit) ? linkedStateExit.value : Option.none(); const accessToken = yield* credentials.getAccessToken; - const loadedProjectConfig = yield* loadProjectConfig(projectHome.projectRoot, { - projectRef: Option.match(linkedState, { - onNone: () => undefined, - onSome: (state) => state.project.ref, - }), - }).pipe( - Effect.catch((error) => - output.raw(`${formatConfigLoadError(error)}\n`, "stderr").pipe(Effect.as(null)), - ), - ); - const projectConfig = loadedProjectConfig?.config ?? null; const localServiceVersions = yield* projectLocalServiceVersions.load; + const serviceVersionContext = planStackVersions({ + ...(Option.isSome(linkedState) ? { candidateBaseline: linkedState.value.versions } : {}), + ...(Option.isSome(localServiceVersions) + ? { localOverrides: localServiceVersions.value.versions } + : {}), + }); const localImageOptions = { - projectConfig, - serviceVersions: Option.match(localServiceVersions, { - onNone: () => undefined, - onSome: (state) => state.versions, - }), + serviceVersions: serviceVersionContext.runtimeVersions, }; let rows = listLocalServiceVersions(localImageOptions); @@ -79,7 +68,3 @@ export const services = Effect.fnUntraced(function* () { yield* output.success("", { services: rows }); }); - -function formatConfigLoadError(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} 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 2a258a0a26..f5f475d740 100644 --- a/apps/cli/src/next/commands/services/services.integration.test.ts +++ b/apps/cli/src/next/commands/services/services.integration.test.ts @@ -3,6 +3,7 @@ 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 } 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"; @@ -16,10 +17,7 @@ import { InvalidProjectLinkStateError } from "../../config/project-link-state.se import { Credentials } from "../../auth/credentials.service.ts"; import { mockOutput, mockProjectLocalServiceVersions } from "../../../../tests/helpers/mocks.ts"; import { CommandRuntime } from "../../../shared/runtime/command-runtime.service.ts"; -import { - listLocalServiceVersions, - postgresImageForDbMajorVersion, -} from "../../../shared/services/services.shared.ts"; +import { listLocalServiceVersions } from "../../../shared/services/services.shared.ts"; import { services } from "./services.handler.ts"; const LINKED_REF = "abcdefghijklmnopqrst"; @@ -158,14 +156,6 @@ function makeProjectWithDbMajorVersion(majorVersion: number): string { return makeProjectWithConfig(`[db]\nmajor_version = ${majorVersion}\n`); } -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); -} - describe("next services", () => { it.live("prints the services table in text mode", () => { const { layer, out } = setup(); @@ -198,7 +188,7 @@ describe("next services", () => { }); }); - it.live("reports the configured Postgres version for local projects", () => { + 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 }); @@ -210,14 +200,14 @@ describe("next services", () => { services: expect.arrayContaining([ expect.objectContaining({ name: "supabase/postgres", - local: postgresVersionForDbMajorVersion(15), + local: DEFAULT_VERSIONS.postgres, }), ]), }); }).pipe(Effect.ensuring(Effect.sync(() => rmSync(workdir, { recursive: true, force: true })))); }); - it.live("applies linked-project remote config overrides when choosing the local image", () => { + it.live("reports the stack runtime version instead of linked remote config db overrides", () => { const workdir = makeProjectWithConfig(` [db] major_version = 17 @@ -242,7 +232,7 @@ major_version = 15 services: expect.arrayContaining([ expect.objectContaining({ name: "supabase/postgres", - local: postgresVersionForDbMajorVersion(15), + local: DEFAULT_VERSIONS.postgres, }), ]), }); diff --git a/apps/cli/src/shared/services/services.shared.ts b/apps/cli/src/shared/services/services.shared.ts index 62c8e350ec..fde3b93eef 100644 --- a/apps/cli/src/shared/services/services.shared.ts +++ b/apps/cli/src/shared/services/services.shared.ts @@ -1,6 +1,5 @@ import { styleText } from "node:util"; import { makeApiClient, type ApiClient } from "@supabase/api/effect"; -import type { ProjectConfig } from "@supabase/config"; import { Data, Duration, Effect, Exit, Redacted } from "effect"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; @@ -28,10 +27,10 @@ export type LocalServiceVersionName = | "pooler"; export type LocalServiceVersionOverrides = Partial>; +export type LocalServiceImageOverrides = Partial>; export interface LocalServiceImageOptions { - readonly projectConfig?: ProjectConfig | null; - readonly postgresImage?: string; + readonly imageOverrides?: LocalServiceImageOverrides; readonly serviceVersions?: LocalServiceVersionOverrides; } @@ -135,18 +134,8 @@ function tagForServiceVersion(service: LocalServiceVersionName, version: string) function localServiceImagesForOptions( options: LocalServiceImageOptions = {}, ): ReadonlyArray { - const projectConfig = options.projectConfig ?? null; - const postgresImage = - options.postgresImage ?? - (projectConfig === null - ? undefined - : postgresImageForDbMajorVersion(projectConfig.db.major_version)); - return LOCAL_SERVICE_IMAGES.map((service) => { - const baseImage = - service.localService === "postgres" && postgresImage !== undefined - ? postgresImage - : service.image; + 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 }; From 8be96a4147673c0daed14a87814d830fb3ecf927 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 1 Jul 2026 22:58:40 +0200 Subject: [PATCH 4/4] fix(cli): align services with runtime pins --- .../commands/services/services.handler.ts | 16 +++-- .../services/services.integration.test.ts | 6 +- .../shared/legacy-db-config.toml-read.ts | 2 +- .../shared/legacy-edge-runtime-image.ts | 10 +-- .../legacy-edge-runtime-image.unit.test.ts | 4 +- .../commands/services/services.command.ts | 2 + .../commands/services/services.handler.ts | 8 ++- .../services/services.integration.test.ts | 71 ++++++++++++++++++- apps/cli/src/shared/functions/serve.ts | 2 +- .../src/shared/services/services.shared.ts | 7 +- .../services/services.shared.unit.test.ts | 16 +++++ 11 files changed, 123 insertions(+), 21 deletions(-) diff --git a/apps/cli/src/legacy/commands/services/services.handler.ts b/apps/cli/src/legacy/commands/services/services.handler.ts index 35c55600aa..f6dd731220 100644 --- a/apps/cli/src/legacy/commands/services/services.handler.ts +++ b/apps/cli/src/legacy/commands/services/services.handler.ts @@ -72,12 +72,15 @@ export const legacyServices = Effect.fn("legacy.services")(function* (_flags: Le output.raw(`${formatConfigLoadError(error)}\n`, "stderr").pipe(Effect.as(null)), ), ); - const serviceVersions = yield* readLegacyServiceVersionOverrides( - fs, - path, - cliConfig.workdir, - tomlValues?.majorVersion, - ); + const serviceVersions = + tomlValues === null + ? {} + : yield* readLegacyServiceVersionOverrides( + fs, + path, + cliConfig.workdir, + tomlValues.majorVersion, + ); const postgresImage = tomlValues === null ? undefined @@ -101,6 +104,7 @@ export const legacyServices = Effect.fn("legacy.services")(function* (_flags: Le } const localImageOptions = { imageOverrides, + normalizeVersionTags: false, serviceVersions, }; 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 d797cf161b..f3c8ce3e64 100644 --- a/apps/cli/src/legacy/commands/services/services.integration.test.ts +++ b/apps/cli/src/legacy/commands/services/services.integration.test.ts @@ -312,7 +312,7 @@ major_version = 15 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", "v2.74.2\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 }); @@ -327,7 +327,7 @@ major_version = 15 expect(rows).toEqual( 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/gotrue", local: "2.74.2" }), expect.objectContaining({ name: "supabase/storage-api", local: "v1.28.0" }), ]), ); @@ -358,12 +358,14 @@ major_version = 15 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 })))); }); 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 2b5525ead8..2404d750b6 100644 --- a/apps/cli/src/next/commands/services/services.command.ts +++ b/apps/cli/src/next/commands/services/services.command.ts @@ -5,6 +5,7 @@ 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"; @@ -15,6 +16,7 @@ const servicesRuntimeLayer = provideProjectCommandRuntime( 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 1096fdba54..176495e638 100644 --- a/apps/cli/src/next/commands/services/services.handler.ts +++ b/apps/cli/src/next/commands/services/services.handler.ts @@ -1,4 +1,4 @@ -import { planStackVersions } from "@supabase/stack/effect"; +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"; @@ -24,14 +24,20 @@ export const services = Effect.fnUntraced(function* () { 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 } : {}), 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 f5f475d740..72648c8210 100644 --- a/apps/cli/src/next/commands/services/services.integration.test.ts +++ b/apps/cli/src/next/commands/services/services.integration.test.ts @@ -3,7 +3,7 @@ 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 } from "@supabase/stack/effect"; +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"; @@ -15,7 +15,11 @@ import { } 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, mockProjectLocalServiceVersions } 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"; @@ -54,6 +58,7 @@ function setup( apiUrl?: string; workdir?: string; localServiceVersions?: LocalServiceVersionsState; + pinnedStackVersions?: VersionManifest; } = {}, ) { const out = mockOutput({ @@ -69,6 +74,40 @@ function setup( 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( @@ -266,6 +305,34 @@ major_version = 15 }); }); + 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 fde3b93eef..05840cb893 100644 --- a/apps/cli/src/shared/services/services.shared.ts +++ b/apps/cli/src/shared/services/services.shared.ts @@ -31,6 +31,7 @@ export type LocalServiceImageOverrides = Partial { + 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]; @@ -142,7 +144,10 @@ function localServiceImagesForOptions( } return { ...service, - image: replaceImageTag(baseImage, tagForServiceVersion(service.localService, version)), + image: replaceImageTag( + baseImage, + normalizeVersionTags ? tagForServiceVersion(service.localService, version) : version, + ), }; }); } 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,