diff --git a/apps/cli/docs/stack-commands.md b/apps/cli/docs/stack-commands.md index b333547721..3ddf1f803c 100644 --- a/apps/cli/docs/stack-commands.md +++ b/apps/cli/docs/stack-commands.md @@ -89,6 +89,16 @@ For temporary selection, set `SUPABASE_EXPERIMENTAL_STACK=1` to select the new b precedence over `experimental.stack`; an unset or empty value falls back to the file setting. Other values are rejected. The override is applied before reading the project configuration. +`supabase services` follows the same backend selection. In stack mode it lists image versions and +canonical `ghcr.io/supabase/cli/...` names from the installed CLI's artifact catalog, including +Mailpit and Vector. PostgreSQL uses the selected major version (15 or 17); invalid configuration +or an unsupported PostgreSQL major warns with the cause and uses catalog defaults. This inventory describes the +CLI catalog, not running containers, downloaded images, or service health. The Docker and native +stack runtimes use the same catalog versions, though a running stack launched by another CLI +version or using mirrored images may differ. Legacy version pins and slim-image overrides do not +apply in stack mode. Linked versions remain best-effort; catalog mismatches cannot be changed with +`supabase link`. + `SUPABASE_EXPERIMENTAL_STACK=1 supabase init` writes `[experimental] stack = true` into the new project config and omits the Docker-era default ports so the stack is not pinned to them. Without the environment variable, `init` still writes the established template with those ports diff --git a/apps/cli/src/command-internal/stack-backend.ts b/apps/cli/src/command-internal/stack-backend.ts index 1e07708224..9c12d7023a 100644 --- a/apps/cli/src/command-internal/stack-backend.ts +++ b/apps/cli/src/command-internal/stack-backend.ts @@ -25,6 +25,7 @@ const STACK_BACKEND_COMMANDS = new Set([ "pull", "storage", "seed", + "services", ]); const isFunctionsServePath = (path: ReadonlyArray): boolean => diff --git a/apps/cli/src/commands/experimental/stack/stack-backend.integration.test.ts b/apps/cli/src/commands/experimental/stack/stack-backend.integration.test.ts index 7a659649c6..2dfb685dd2 100644 --- a/apps/cli/src/commands/experimental/stack/stack-backend.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/stack-backend.integration.test.ts @@ -65,6 +65,7 @@ stack = true expect(yield* resolve({ args: ["inspect", "db"], cwd: root, env: {} })).toBe("stack"); expect(yield* resolve({ args: ["storage", "ls"], cwd: root, env: {} })).toBe("stack"); expect(yield* resolve({ args: ["seed", "buckets"], cwd: root, env: {} })).toBe("stack"); + expect(yield* resolve({ args: ["services"], cwd: root, env: {} })).toBe("stack"); }), ); }); diff --git a/apps/cli/src/commands/services/SIDE_EFFECTS.md b/apps/cli/src/commands/services/SIDE_EFFECTS.md index 63e6390cdc..646d88e098 100644 --- a/apps/cli/src/commands/services/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/services/SIDE_EFFECTS.md @@ -2,10 +2,12 @@ ## Files Read -| Path | Format | When | -| ---------------------------- | ---------- | ------------------------------------------------------------------------------------------ | -| `supabase/.temp/project-ref` | plain text | when the checkout is linked and no explicit ref is already loaded | -| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` is unset and keyring access falls back to the home token file | +| Path | Format | When | +| ---------------------------------------------------- | ------------- | ------------------------------------------------------------------------------------------------ | +| `supabase/.temp/project-ref` | plain text | when the checkout is linked and no explicit ref is already loaded | +| `supabase/config.toml` and project environment files | TOML / dotenv | legacy service overrides, linked remote config, or stack PostgreSQL major selection | +| Docker client context/config metadata | JSON | when the shared local project context resolves the Docker hostname; no daemon connection is made | +| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` is unset and keyring access falls back to the home token file | ## Files Written @@ -40,18 +42,20 @@ Tenant calls send `apikey: ` and additionally ## Environment Variables -| Variable | Purpose | Required? | -| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token for Management API linked-version checks | no (falls back to keyring, then `~/.supabase/access-token`) | -| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_USE_SLIM_IMAGES` | Ambient `process.env` only (`true`/`1` enable). Rewrites current-pin `SERVICE IMAGE`/`name` fields to `ghcr.io/supabase/cli/`. Kong stays on docker.io. Majors 13/15 list the slim `15.14.1.167` pin when the flag is on; flag-off keeps `15.8.1.085`. | no | +| Variable | Purpose | Required? | +| ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token for Management API linked-version checks | no (falls back to keyring, then `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_USE_SLIM_IMAGES` | Ambient `process.env` only (`true`/`1` enable). Rewrites legacy current-pin `SERVICE IMAGE`/`name` fields to `ghcr.io/supabase/cli/`; Kong stays on docker.io. Majors 13/15 list the slim `15.14.1.167` pin when enabled; flag-off keeps `15.8.1.085`. Ignored by the stack backend. | no | +| `SUPABASE_EXPERIMENTAL_STACK` | Selects the stack backend (`1`) or legacy backend (`0`); unset or empty uses `experimental.stack`. Other values fail routing. | no | +| `SUPABASE_DB_MAJOR_VERSION` | Stack backend PostgreSQL major override; supported values are 15 and 17. | no | ## Exit Codes -| Code | Condition | -| ---- | ------------------------------------------------------------------------------ | -| `0` | success; always prints the local service matrix and optionally linked versions | -| `1` | `--output env` is requested; explicitly unsupported | +| Code | Condition | +| ---- | ------------------------------------------------------------------------------------ | +| `0` | success; always prints the local service matrix and optionally linked versions | +| `1` | `--output env` is unsupported, or `SUPABASE_EXPERIMENTAL_STACK` has an invalid value | ## Output @@ -81,8 +85,10 @@ TS-only NDJSON success event with the same `{ services: [...] }` payload. ## Notes -- Local versions come from the command's baked-in service matrix; the command does not inspect Docker state or local config files. +- Backend selection follows canonical experimental-feature routing: `SUPABASE_EXPERIMENTAL_STACK=1|0` takes precedence over `experimental.stack`; unset or empty uses project config, and an invalid environment value fails. Output fields and serializers stay the same. +- The legacy backend uses its baked-in service matrix and honors its existing config/version overrides. The stack backend lists services from the installed CLI artifact catalog using canonical `ghcr.io/supabase/cli/...` image names and catalog versions. Native and Docker runtimes use the same catalog versions. Since the catalog belongs to the installed CLI, an older launched CLI or a newer/mirrored stack image may differ from this inventory. The command does not inspect running containers, image pulls, service health, or live stack state. +- For stack mode, PostgreSQL uses the configured major version or `SUPABASE_DB_MAJOR_VERSION` (15 or 17). Invalid configuration or an unsupported PostgreSQL major warns with the cause and falls back to default catalog versions; absent config uses defaults. Legacy image pins, slim-image rewriting, and remote image overrides do not affect stack results. - Linked-version checks are best-effort. Remote lookup failures do not change the exit code; they only leave the `LINKED` column empty for unavailable services. - A malformed linked ref is the one lookup failure that prints an explicit stderr warning (see API Routes above); every other remote failure (network error, expired token, etc.) still fails silently and just leaves `LINKED` empty. Most real-world malformed refs come from an untrimmed `SUPABASE_PROJECT_ID` env var (e.g. a trailing newline from a secrets manager or `.env` file) rather than actual file tampering — the env var is read raw and unlike the on-disk `project-ref` file is never trimmed. -- Version mismatches are reported to stderr as a warning. +- Version mismatches are reported to stderr as a warning. In stack mode, the warning explains that catalog versions cannot be changed with `supabase link`. - `telemetry.json` is written on every invocation, including `--output env` failures. diff --git a/apps/cli/src/commands/services/services-local-stack.ts b/apps/cli/src/commands/services/services-local-stack.ts new file mode 100644 index 0000000000..85907a4470 --- /dev/null +++ b/apps/cli/src/commands/services/services-local-stack.ts @@ -0,0 +1,60 @@ +import { + artifactServiceKinds, + postgresVersion, + resolveArtifact, +} from "@supabase/stack/internal/service-catalog"; +import { Effect, Result } from "effect"; +import { loadLocalProjectContext } from "../../command-internal/local-project-context.ts"; +import { envOverrideMajorVersion } from "../../command-internal/local-config-values.ts"; +import type { ServiceVersionRow } from "../../shared/services/services.shared.ts"; +import type { RemoteServiceName } from "../../shared/services/services.shared.ts"; + +const remoteNames: Readonly> = { + database: "postgres", + auth: "auth", + rest: "postgrest", + storage: "storage", +}; + +export const stackServiceVersions = Effect.fn("services.stackServiceVersions")(function* ( + workdir: string, + remote: Partial> = {}, +) { + const context = yield* loadLocalProjectContext(workdir, (message) => message).pipe(Effect.result); + let configError: string | undefined; + let major: number | undefined; + if (Result.isFailure(context)) configError = context.failure; + else { + const resolvedMajor = yield* Effect.try({ + try: () => { + const value = envOverrideMajorVersion( + context.success.config.db.major_version, + context.success.projectEnvValues, + ); + if (value !== 15 && value !== 17) + throw new Error(`unsupported PostgreSQL major version: ${value}`); + return value; + }, + catch: (cause) => (cause instanceof Error ? cause.message : String(cause)), + }).pipe(Effect.result); + if (Result.isFailure(resolvedMajor)) configError = resolvedMajor.failure; + else major = resolvedMajor.success; + } + return yield* Effect.forEach(artifactServiceKinds(), (service) => + Effect.gen(function* () { + const artifact = yield* resolveArtifact({ + service, + ...(service === "database" && major !== undefined + ? { version: postgresVersion(String(major)) } + : {}), + }); + const name = artifact.image.split("@")[0]?.replace(/:[^/:]+$/, "") ?? artifact.image; + const remoteName = remoteNames[service]; + return { + name, + local: artifact.version, + remote: remoteName === undefined ? "" : (remote[remoteName] ?? ""), + } satisfies ServiceVersionRow; + }), + ).pipe(Effect.map((rows) => ({ rows, configError }))); +}); diff --git a/apps/cli/src/commands/services/services.handler.ts b/apps/cli/src/commands/services/services.handler.ts index 97a0d83be3..dbbb151ed6 100644 --- a/apps/cli/src/commands/services/services.handler.ts +++ b/apps/cli/src/commands/services/services.handler.ts @@ -11,6 +11,7 @@ import { readDbToml } from "../../command-internal/db-config.toml-read.ts"; import { resolveDbImage } from "../../command-internal/db-image.ts"; import { resolveEdgeRuntimeImage } from "../../command-internal/edge-runtime-image.ts"; import { readServiceVersionOverrides } from "../../command-internal/service-version-overrides.ts"; +import { currentStackBackend } from "../../command-internal/stack-backend.ts"; import { OutputFlag } from "../../command-internal/global-flags.ts"; import { Output } from "../../shared/output/output.service.ts"; import { encodeGoJson } from "../../command-internal/go-output.encoders.ts"; @@ -30,9 +31,11 @@ import { mergeRemoteServiceVersions, renderServicesTable, renderServicesWarning, + type ServiceVersionRow, } from "../../shared/services/services.shared.ts"; import type { ServicesFlags } from "./services.command.ts"; import { ServicesEnvNotSupportedError } from "./services.errors.ts"; +import { stackServiceVersions } from "./services-local-stack.ts"; /** * Struct shape for `imageVersion`: field order is name, local, remote (not @@ -101,6 +104,7 @@ export const services = Effect.fn("services")(function* (_flags: ServicesFlags) ); const validLinkedRef = Option.filter(linkedProjectRef, (ref) => PROJECT_REF_PATTERN.test(ref)); + const backend = yield* currentStackBackend; if (Option.isSome(linkedProjectRef) && Option.isNone(validLinkedRef)) { // A malformed linked ref still warns, but the remote call is skipped: // `fetchLinkedServiceVersions` embeds the ref unescaped into the tenant @@ -110,66 +114,97 @@ export const services = Effect.fn("services")(function* (_flags: ServicesFlags) yield* output.raw(`${INVALID_PROJECT_REF_MESSAGE}\n`, "stderr"); } - const tomlValues = yield* readDbToml( - fs, - path, - cliSettings.workdir, - Option.getOrUndefined(linkedProjectRef), - ).pipe( - Effect.catch((error) => - output.raw(`${formatConfigLoadError(error)}\n`, "stderr").pipe(Effect.as(null)), - ), - ); - const serviceVersions = - tomlValues === null - ? {} - : yield* readServiceVersionOverrides( - fs, - path, - cliSettings.workdir, - tomlValues.majorVersion, - ); - const postgresImage = - tomlValues === null - ? undefined - : (yield* resolveDbImage( - fs, - path, - cliSettings.workdir, - tomlValues.majorVersion, - Option.getOrUndefined(tomlValues.orioledbVersion), - )).image; - const edgeRuntimeImage = - tomlValues === null - ? undefined - : yield* resolveEdgeRuntimeImage(fs, path, cliSettings.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, - slimCurrentPinOnly: true, - }; - - let rows = listLocalServiceVersions(localImageOptions); - if (Option.isSome(validLinkedRef) && Option.isSome(accessToken)) { - const remote = yield* fetchLinkedServiceVersions({ - apiUrl: cliSettings.apiUrl, - projectHost: cliSettings.projectHost, - projectRef: validLinkedRef.value, - accessToken: accessToken.value, - userAgent: cliSettings.userAgent, - }); - rows = mergeRemoteServiceVersions(remote, localImageOptions); + let rows: ReadonlyArray; + if (backend.kind === "stack") { + const remote = + Option.isSome(validLinkedRef) && Option.isSome(accessToken) + ? yield* fetchLinkedServiceVersions({ + apiUrl: cliSettings.apiUrl, + projectHost: cliSettings.projectHost, + projectRef: validLinkedRef.value, + accessToken: accessToken.value, + userAgent: cliSettings.userAgent, + }) + : {}; + const result = yield* stackServiceVersions(cliSettings.workdir, remote); + if (result.configError !== undefined) { + yield* output.raw( + `${result.configError}; using default stack catalog versions\n`, + "stderr", + ); + } + rows = result.rows; + } else { + const tomlValues = yield* readDbToml( + fs, + path, + cliSettings.workdir, + Option.getOrUndefined(linkedProjectRef), + ).pipe( + Effect.catch((error) => + output.raw(`${formatConfigLoadError(error)}\n`, "stderr").pipe(Effect.as(null)), + ), + ); + const serviceVersions = + tomlValues === null + ? {} + : yield* readServiceVersionOverrides( + fs, + path, + cliSettings.workdir, + tomlValues.majorVersion, + ); + const postgresImage = + tomlValues === null + ? undefined + : (yield* resolveDbImage( + fs, + path, + cliSettings.workdir, + tomlValues.majorVersion, + Option.getOrUndefined(tomlValues.orioledbVersion), + )).image; + const edgeRuntimeImage = + tomlValues === null + ? undefined + : yield* resolveEdgeRuntimeImage(fs, path, cliSettings.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, + slimCurrentPinOnly: true, + }; + + rows = listLocalServiceVersions(localImageOptions); + if (Option.isSome(validLinkedRef) && Option.isSome(accessToken)) { + const remote = yield* fetchLinkedServiceVersions({ + apiUrl: cliSettings.apiUrl, + projectHost: cliSettings.projectHost, + projectRef: validLinkedRef.value, + accessToken: accessToken.value, + userAgent: cliSettings.userAgent, + }); + rows = mergeRemoteServiceVersions(remote, localImageOptions); + } } - const warning = renderServicesWarning(rows); + const warning = renderServicesWarning( + rows, + backend.kind === "stack" + ? { + heading: "The CLI stack catalog versions differ from your linked project:", + recommendation: + "These versions come from the CLI stack catalog and cannot be changed with supabase link.", + } + : {}, + ); if (warning !== undefined) { yield* output.raw(formatServicesWarning(warning, output.format === "text"), "stderr"); } diff --git a/apps/cli/src/commands/services/services.integration.test.ts b/apps/cli/src/commands/services/services.integration.test.ts index 54d53e4ce8..c22e86f41a 100644 --- a/apps/cli/src/commands/services/services.integration.test.ts +++ b/apps/cli/src/commands/services/services.integration.test.ts @@ -22,6 +22,7 @@ import { CommandSettings } from "../../config/command-settings.service.ts"; import { INVALID_PROJECT_REF_MESSAGE } from "../../config/project-ref.service.ts"; import { LinkedProjectCache } from "../../telemetry/linked-project-cache.service.ts"; import { GLOBAL_FLAGS, OutputFlag } from "../../command-internal/global-flags.ts"; +import { stackBackendLayer } from "../../command-internal/stack-backend.ts"; import { mockAnalytics, mockOutput, @@ -81,6 +82,10 @@ function setup( layer: Layer.mergeAll( BunServices.layer, FetchHttpClient.layer, + mockRuntimeInfo({ + cwd: opts.workdir ?? defaultWorkdir.current, + homeDir: opts.workdir ?? defaultWorkdir.current, + }), out.layer, telemetry.layer, Layer.succeed(OutputFlag, opts.goOutput ?? Option.none()), @@ -317,6 +322,87 @@ describe("services", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); + it.live("uses the stack artifact catalog when the stack backend is selected", () => + Effect.gen(function* () { + const workdir = yield* makeProjectWithDbMajorVersion(15); + const { layer, out } = setup({ goOutput: Option.some("json"), workdir }); + + yield* services({}).pipe(Effect.provide(Layer.mergeAll(layer, stackBackendLayer("stack")))); + + const rows = yield* decodeServiceRows(out.stdoutText); + expect(out.stderrText).toBe(""); + expect(rows).toHaveLength(13); + expect(rows).toContainEqual( + expect.objectContaining({ name: "ghcr.io/supabase/cli/postgres", local: "15.14.1.173" }), + ); + expect(rows).toContainEqual( + expect.objectContaining({ name: "ghcr.io/supabase/cli/mailpit", local: "v1.30.2" }), + ); + expect(rows).toContainEqual( + expect.objectContaining({ name: "ghcr.io/supabase/cli/vector", local: "0.53.0" }), + ); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + it.live("applies the stack PostgreSQL environment override", () => + Effect.gen(function* () { + const workdir = yield* makeProjectWithDbMajorVersion(17); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.writeFileString( + path.join(workdir, "supabase", ".env"), + "SUPABASE_DB_MAJOR_VERSION=15\n", + ); + const { layer, out } = setup({ goOutput: Option.some("json"), workdir }); + + yield* services({}).pipe(Effect.provide(Layer.mergeAll(layer, stackBackendLayer("stack")))); + + const rows = yield* decodeServiceRows(out.stdoutText); + expect(rows).toContainEqual( + expect.objectContaining({ name: "ghcr.io/supabase/cli/postgres", local: "15.14.1.173" }), + ); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + it.live( + "falls back to stack catalog defaults for unsupported database config and ignores legacy pins", + () => + Effect.gen(function* () { + const workdir = yield* makeProjectWithDbMajorVersion(16); + yield* writeTempFile(workdir, "postgres-version", "legacy-postgres-tag\n"); + yield* writeTempFile(workdir, "gotrue-version", "legacy-auth-tag\n"); + const { layer, out } = setup({ goOutput: Option.some("json"), workdir }); + + yield* services({}).pipe(Effect.provide(Layer.mergeAll(layer, stackBackendLayer("stack")))); + + const rows = yield* decodeServiceRows(out.stdoutText); + expect(rows).toContainEqual( + expect.objectContaining({ name: "ghcr.io/supabase/cli/postgres", local: "17.6.1.173" }), + ); + expect(rows).toContainEqual( + expect.objectContaining({ name: "ghcr.io/supabase/cli/auth", local: "v2.196.0" }), + ); + expect(out.stderrText).toContain("unsupported PostgreSQL major version: 16"); + expect(out.stderrText).toContain("using default stack catalog versions"); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + it.live("reports malformed config causes before falling back to the stack catalog", () => + Effect.gen(function* () { + const workdir = yield* makeProjectWithConfig("[db]\nmajor_version = "); + const { layer, out } = setup({ goOutput: Option.some("json"), workdir }); + + yield* services({}).pipe(Effect.provide(Layer.mergeAll(layer, stackBackendLayer("stack")))); + + const rows = yield* decodeServiceRows(out.stdoutText); + expect(rows).toContainEqual( + expect.objectContaining({ name: "ghcr.io/supabase/cli/postgres", local: "17.6.1.173" }), + ); + expect(out.stderrText).toMatch(/^failed to read config:/); + expect(out.stderrText).toContain("using default stack catalog versions"); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + it.live("ignores config.json and reads legacy config.toml for local image selection", () => Effect.gen(function* () { const workdir = yield* makeProjectWithConfigFiles({ @@ -396,6 +482,13 @@ major_version = 15 it.live("fetches and merges remote versions for a valid ref when logged in", () => Effect.gen(function* () { const workdir = yield* makeWorkdir(); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.join(workdir, "supabase"), { recursive: true }); + yield* fs.writeFileString( + path.join(workdir, "supabase", "config.toml"), + '[db]\nmajor_version = 17\n\n[remotes.linked]\nproject_id = "abcdefghijklmnopqrst"\n\n[remotes.linked.db]\nmajor_version = 15\n', + ); yield* writeTempFile(workdir, "project-ref", "abcdefghijklmnopqrst"); const server = yield* Effect.acquireRelease( @@ -467,8 +560,32 @@ major_version = 15 expect(out.stderrText).not.toContain(INVALID_PROJECT_REF_MESSAGE); const rows = yield* decodeServiceRows(out.stdoutText); expect(rows).toContainEqual( - expect.objectContaining({ name: "supabase/postgres", remote: "17.6.1.200" }), + expect.objectContaining({ + name: "supabase/postgres", + local: postgresVersionForDbMajorVersion(15), + remote: "17.6.1.200", + }), + ); + + const stack = setup({ + workdir, + accessToken: "sbp_test-token", + apiUrl: server.url.origin, + goOutput: Option.some("json"), + }); + yield* services({}).pipe( + Effect.provide(Layer.mergeAll(stack.layer, stackBackendLayer("stack"))), + ); + const stackRows = yield* decodeServiceRows(stack.out.stdoutText); + expect(stackRows).toContainEqual( + expect.objectContaining({ + name: "ghcr.io/supabase/cli/postgres", + local: "17.6.1.173", + remote: "17.6.1.200", + }), ); + expect(stack.out.stderrText).toContain("cannot be changed with supabase link"); + expect(stack.out.stderrText).not.toContain("You are running different service versions"); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); diff --git a/apps/cli/src/shared/services/services.shared.ts b/apps/cli/src/shared/services/services.shared.ts index 97a6bc0c7c..6a49a27005 100644 --- a/apps/cli/src/shared/services/services.shared.ts +++ b/apps/cli/src/shared/services/services.shared.ts @@ -425,16 +425,23 @@ export function renderServicesTable(rows: ReadonlyArray): str ); } -export function renderServicesWarning(rows: ReadonlyArray): string | undefined { +export function renderServicesWarning( + rows: ReadonlyArray, + options: { + readonly heading?: string; + readonly recommendation?: string; + } = {}, +): string | undefined { const mismatches = rows.filter((row) => row.remote.length > 0 && row.remote !== row.local); if (mismatches.length === 0) { return undefined; } return [ - "You are running different service versions locally than your linked project:", + options.heading ?? + "You are running different service versions locally than your linked project:", ...mismatches.map((row) => `${row.name}:${row.local} => ${row.remote}`), - "Run supabase link to update them.", + options.recommendation ?? "Run supabase link to update them.", ].join("\n"); } diff --git a/packages/stack/package.json b/packages/stack/package.json index ef95e90bae..044c47737f 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -10,7 +10,8 @@ "./internal/dispatch": "./src/internal/dispatch.ts", "./internal/identity": "./src/identity/Identity.ts", "./internal/functions/serve-main": "./src/functions/serve.main.ts", - "./internal/postgres-artifact": "./src/internal/postgres-artifact.ts" + "./internal/postgres-artifact": "./src/internal/postgres-artifact.ts", + "./internal/service-catalog": "./src/internal/service-catalog.ts" }, "scripts": { "test": "pnpm run test:unit && pnpm run test:integration", diff --git a/packages/stack/src/Artifacts.ts b/packages/stack/src/Artifacts.ts index c28f4bc3b4..78b6d5032c 100644 --- a/packages/stack/src/Artifacts.ts +++ b/packages/stack/src/Artifacts.ts @@ -1,4 +1,4 @@ -import { Data, Effect, Path } from "effect"; +import { Data, Effect, Path, Record } from "effect"; import { makeArtifactStore } from "./preparation/ArtifactStore.ts"; import { makeSlimServicesSource, @@ -250,6 +250,9 @@ export const postgresVersion = (version: string): string => (candidate) => candidate.split(".")[0] === version, ) ?? version; +/** Service kinds in artifact catalog order. */ +export const artifactServiceKinds = (): ReadonlyArray => Record.keys(definitions); + const artifactKey = (artifact: SlimServicesArtifact): string => `slim-services/${artifact.service}/${artifact.version}/${artifact.target}`; diff --git a/packages/stack/src/internal/service-catalog.ts b/packages/stack/src/internal/service-catalog.ts new file mode 100644 index 0000000000..3392a3c9f4 --- /dev/null +++ b/packages/stack/src/internal/service-catalog.ts @@ -0,0 +1 @@ +export { artifactServiceKinds, postgresVersion, resolveArtifact } from "../Artifacts.ts";