From 3c786808097d43f2f4ebf8b9184ab00502238454 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 5 Jun 2026 00:09:56 +0000 Subject: [PATCH 01/11] fix(deps): bump github.com/posthog/posthog-go from 1.13.0 to 1.13.1 in /apps/cli-go in the go-minor group across 1 directory (#5482) Bumps the go-minor group with 1 update in the /apps/cli-go directory: [github.com/posthog/posthog-go](https://github.com/posthog/posthog-go). Updates `github.com/posthog/posthog-go` from 1.13.0 to 1.13.1
Release notes

Sourced from github.com/posthog/posthog-go's releases.

1.13.1

Unreleased

Changelog

Sourced from github.com/posthog/posthog-go's changelog.

1.13.1

Patch Changes

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=github.com/posthog/posthog-go&package-manager=go_modules&previous-version=1.13.0&new-version=1.13.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- apps/cli-go/go.mod | 2 +- apps/cli-go/go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/cli-go/go.mod b/apps/cli-go/go.mod index 7c9ee6dff7..643c8caf86 100644 --- a/apps/cli-go/go.mod +++ b/apps/cli-go/go.mod @@ -42,7 +42,7 @@ require ( github.com/multigres/multigres v0.0.0-20260126223308-f5a52171bbc4 github.com/oapi-codegen/nullable v1.1.0 github.com/olekukonko/tablewriter v1.1.4 - github.com/posthog/posthog-go v1.13.0 + github.com/posthog/posthog-go v1.13.1 github.com/spf13/afero v1.15.0 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 diff --git a/apps/cli-go/go.sum b/apps/cli-go/go.sum index caa2f47bdb..9707d0e595 100644 --- a/apps/cli-go/go.sum +++ b/apps/cli-go/go.sum @@ -941,8 +941,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/polyfloyd/go-errorlint v1.8.0 h1:DL4RestQqRLr8U4LygLw8g2DX6RN1eBJOpa2mzsrl1Q= github.com/polyfloyd/go-errorlint v1.8.0/go.mod h1:G2W0Q5roxbLCt0ZQbdoxQxXktTjwNyDbEaj3n7jvl4s= -github.com/posthog/posthog-go v1.13.0 h1:+i+t6txCczJcGZj7ME2ry4sLhPYvq3q7RYuUZ0z6NpQ= -github.com/posthog/posthog-go v1.13.0/go.mod h1:xsVOW9YImilUcazwPNEq4PJDqEZf2KeCS758zXjwkPg= +github.com/posthog/posthog-go v1.13.1 h1:7OtfgOEM9fC2n4Hbs4e5mK1uaLuNguoYWFEI4kEnTUY= +github.com/posthog/posthog-go v1.13.1/go.mod h1:xsVOW9YImilUcazwPNEq4PJDqEZf2KeCS758zXjwkPg= github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= github.com/prometheus/client_golang v0.9.0-pre1.0.20180209125602-c332b6f63c06/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= From efa6df7f7679eca1d888278084744d6d42b9d76d Mon Sep 17 00:00:00 2001 From: Vaibhav <117663341+7ttp@users.noreply.github.com> Date: Fri, 5 Jun 2026 14:31:38 +0530 Subject: [PATCH 02/11] feat(cli): port services (#5468) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## TL;DR ports `supabase services` to native ts ## What’s introduced adds native TS implementations for services moves the local service version rendering and linked version lookup into shared command logic, adds integration coverage for text/json output and linked version fallback behavior ## Ref - closes CLI-1307 --------- Co-authored-by: Colum Ferry --- apps/cli-e2e/src/tests/stack.e2e.test.ts | 4 +- apps/cli/docs/go-cli-porting-status.md | 4 +- .../legacy/commands/services/SIDE_EFFECTS.md | 88 ++-- .../commands/services/services.command.ts | 8 +- .../commands/services/services.errors.ts | 7 + .../commands/services/services.handler.ts | 112 ++++- .../services/services.integration.test.ts | 184 +++++++++ apps/cli/src/next/cli/root.ts | 2 + .../commands/services/services.command.ts | 30 ++ .../commands/services/services.handler.ts | 57 +++ .../services/services.integration.test.ts | 211 ++++++++++ .../src/shared/services/services.shared.ts | 383 ++++++++++++++++++ .../services/services.shared.unit.test.ts | 355 ++++++++++++++++ 13 files changed, 1397 insertions(+), 48 deletions(-) create mode 100644 apps/cli/src/legacy/commands/services/services.errors.ts create mode 100644 apps/cli/src/legacy/commands/services/services.integration.test.ts create mode 100644 apps/cli/src/next/commands/services/services.command.ts create mode 100644 apps/cli/src/next/commands/services/services.handler.ts create mode 100644 apps/cli/src/next/commands/services/services.integration.test.ts create mode 100644 apps/cli/src/shared/services/services.shared.ts create mode 100644 apps/cli/src/shared/services/services.shared.unit.test.ts diff --git a/apps/cli-e2e/src/tests/stack.e2e.test.ts b/apps/cli-e2e/src/tests/stack.e2e.test.ts index 5ba87adf1d..c07d620b23 100644 --- a/apps/cli-e2e/src/tests/stack.e2e.test.ts +++ b/apps/cli-e2e/src/tests/stack.e2e.test.ts @@ -60,8 +60,8 @@ function testParityStack(cmd: string[], opts?: { workspaceSetup?: (dir: string) // --------------------------------------------------------------------------- // services // --------------------------------------------------------------------------- -// `services` reads service image names from config (not Docker) so DOCKER_HOST -// is not needed. +// `services` prints a baked-in Go-parity service matrix, so DOCKER_HOST is not +// needed. describe("services", () => { testBehaviour("lists known service images", async ({ run }) => { diff --git a/apps/cli/docs/go-cli-porting-status.md b/apps/cli/docs/go-cli-porting-status.md index ff70e62590..a96b086da0 100644 --- a/apps/cli/docs/go-cli-porting-status.md +++ b/apps/cli/docs/go-cli-porting-status.md @@ -76,7 +76,7 @@ These commands exist in the TS CLI today but have no direct top-level equivalent -| `services` | `partial` | `supabase status` + `supabase stack update` | Go-style dedicated `services` command shape | `--stack` | The old version-reporting and linked-version drift behavior exists in TS, but it is split across `status` for per-service versions and `stack update` for refreshing pinned versions instead of a single `services` command. | +| `services` | `ported` | [`../src/next/commands/services/services.command.ts`](../src/next/commands/services/services.command.ts) | `--output` remains a global legacy-shell concern rather than a next-only command flag | `--output-format` | TS restores the dedicated `services` command, prints the bundled local service image matrix, and best-effort compares linked remote versions without proxying to Go. | ## Database @@ -267,7 +267,7 @@ Legend: | `unlink` | `ported` | [`../src/legacy/commands/unlink/unlink.command.ts`](../src/legacy/commands/unlink/unlink.command.ts) | | `bootstrap` | `wrapped` | [`../src/legacy/commands/bootstrap/bootstrap.command.ts`](../src/legacy/commands/bootstrap/bootstrap.command.ts) | | `init` | `ported` | [`../src/legacy/commands/init/init.command.ts`](../src/legacy/commands/init/init.command.ts) | -| `services` | `wrapped` | [`../src/legacy/commands/services/services.command.ts`](../src/legacy/commands/services/services.command.ts) | +| `services` | `ported` | [`../src/legacy/commands/services/services.command.ts`](../src/legacy/commands/services/services.command.ts) | | `start` | `wrapped` | [`../src/legacy/commands/start/start.command.ts`](../src/legacy/commands/start/start.command.ts) | | `stop` | `wrapped` | [`../src/legacy/commands/stop/stop.command.ts`](../src/legacy/commands/stop/stop.command.ts) | | `status` | `wrapped` | [`../src/legacy/commands/status/status.command.ts`](../src/legacy/commands/status/status.command.ts) | diff --git a/apps/cli/src/legacy/commands/services/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/services/SIDE_EFFECTS.md index f8dd90075e..07092ca913 100644 --- a/apps/cli/src/legacy/commands/services/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/services/SIDE_EFFECTS.md @@ -2,68 +2,78 @@ ## Files Read -| Path | Format | When | -| -------------------------- | ---------- | ----------------------------------------------------------------------------- | -| `.supabase/project.json` | JSON | to resolve linked project ref for remote version check | -| `supabase/config.toml` | TOML | to read local service image versions | -| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable (for linked check) | +| 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 | ## Files Written -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | +| Path | Format | When | +| ------------------------------------ | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| `supabase/.temp/linked-project.json` | JSON | when a project ref resolves and no cache exists yet (`Effect.ensuring(linkedProjectCache.cache(ref))`, mirrors Go's `ensureProjectGroupsCached`) | +| `~/.supabase/telemetry.json` | JSON | always (`Effect.ensuring(telemetryState.flush)`) at end of the command | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ---------------------------------------------- | ------------ | ------------ | ------------------------------------------------------- | -| `GET` | `/v1/projects/{ref}/api-keys` | Bearer token | none | `[{name, api_key}]` (used to authenticate tenant calls) | -| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{database.version}` (postgres image version) | -| `GET` | `https://{ref}.supabase.co/auth/v1/health` | service key | none | `{version}` (auth service version) | -| `GET` | `https://{ref}.supabase.co/rest/v1/` | service key | none | `{info.version}` (postgrest version) | -| `GET` | `https://{ref}.supabase.co/storage/v1/version` | service key | none | body as plain text (storage version) | +The resolved project ref must match `^[a-z]{20}$` (Go's `utils.ProjectRefPattern`) +before any remote lookup runs; a malformed ref skips the linked-version checks +and only the local matrix is printed. Tenant calls send `apikey: ` +and additionally `Authorization: Bearer ` unless the key is a +new-style `sb_…` key (which authenticates via the `apikey` header alone), +matching `apps/cli-go/pkg/fetcher/gateway.go`. + +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ---------------------------------------------- | ------------------------------ | ------------ | ------------------------------------------------------------------ | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, region, status, organization_slug, database.version}` | +| `GET` | `/v1/projects/{ref}/api-keys?reveal=true` | Bearer token | none | `[{name, type, api_key, secret_jwt_template}]` | +| `GET` | `https://{ref}.supabase.co/auth/v1/health` | apikey (+ Bearer if non-`sb_`) | none | `{version}` | +| `GET` | `https://{ref}.supabase.co/rest/v1/` | apikey (+ Bearer if non-`sb_`) | none | `{info.version}` | +| `GET` | `https://{ref}.supabase.co/storage/v1/version` | apikey (+ Bearer if non-`sb_`) | none | plain text version body | ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token for Management API (linked version check) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| Variable | Purpose | Required? | +| ----------------------- | --------------------------------------------------- | ----------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token for Management API linked-version checks | no (falls back to keyring, then `~/.supabase/access-token`) | +| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | ## Exit Codes -| Code | Condition | -| ---- | ------------------------------------------------------------------------- | -| `0` | success — local service versions printed; remote versions shown if linked | -| `0` | not linked — local versions still printed, remote column shows `-` | -| `1` | `--output env` format — explicitly not supported (`ErrEnvNotSupported`) | +| Code | Condition | +| ---- | ------------------------------------------------------------------------------ | +| `0` | success; always prints the local service matrix and optionally linked versions | +| `1` | `--output env` is requested; Go explicitly treats it as unsupported | ## Output -### `--output-format text` (Go CLI compatible) +### Default / text -Prints a Markdown-style table to stdout with local and linked (remote) service image versions: +Prints a Markdown table with `SERVICE IMAGE`, `LOCAL`, and `LINKED` columns. -``` -|SERVICE IMAGE|LOCAL|LINKED| -|-|-|-| -|`supabase/postgres:15.1.0.117`|`15.1.0.117`|`15.1.0.117`| -|`supabase/gotrue:v2.74.2`|`v2.74.2`|`-`| -``` +### `--output json` + +Prints the JSON array of service rows. + +### `--output toml` + +Prints a TOML object with a top-level `services = [...]` array. + +### `--output yaml` + +Prints the YAML array of service rows. ### `--output-format json` -Not defined in Go CLI. Emits structured service version data as JSON. +TS-only structured success event: `{ services: [...] }`. ### `--output-format stream-json` -Not defined in Go CLI. Emits NDJSON result event. +TS-only NDJSON success event with the same `{ services: [...] }` payload. ## Notes -- The remote version check is best-effort: failures are printed to stderr but do not cause a non-zero exit. -- If the project is not linked, the LINKED column shows `-` for all services. -- Uses concurrent requests to check remote service versions via a work queue. -- The Go CLI also supports `--output toml` (TOML format) and `--output json` via `utils.OutputFormat`. -- The `--output env` format is explicitly unsupported and returns `ErrEnvNotSupported`. +- Local versions come from the command's baked-in service matrix; the command does not inspect Docker state or local config files. +- 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. +- Version mismatches are reported to stderr as a warning. +- `telemetry.json` is written on every invocation, including `--output env` failures, to match the legacy Go command lifecycle. diff --git a/apps/cli/src/legacy/commands/services/services.command.ts b/apps/cli/src/legacy/commands/services/services.command.ts index d9228248f2..3084983d67 100644 --- a/apps/cli/src/legacy/commands/services/services.command.ts +++ b/apps/cli/src/legacy/commands/services/services.command.ts @@ -1,4 +1,7 @@ import { Command } from "effect/unstable/cli"; +import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; +import { legacyManagementApiRuntimeLayer } from "../../shared/legacy-management-api-runtime.layer.ts"; +import { withLegacyCommandInstrumentation } from "../../telemetry/legacy-command-instrumentation.ts"; import type * as CliCommand from "effect/unstable/cli/Command"; import { legacyServices } from "./services.handler.ts"; @@ -8,5 +11,8 @@ export type LegacyServicesFlags = CliCommand.Command.Config.Infer export const legacyServicesCommand = Command.make("services", config).pipe( Command.withDescription("Show versions of all Supabase services."), Command.withShortDescription("Show versions of all Supabase services"), - Command.withHandler((_flags) => legacyServices(_flags)), + Command.withHandler((flags) => + legacyServices(flags).pipe(withLegacyCommandInstrumentation({ flags }), withJsonErrorHandling), + ), + Command.provide(legacyManagementApiRuntimeLayer(["services"])), ); diff --git a/apps/cli/src/legacy/commands/services/services.errors.ts b/apps/cli/src/legacy/commands/services/services.errors.ts new file mode 100644 index 0000000000..7450caafb2 --- /dev/null +++ b/apps/cli/src/legacy/commands/services/services.errors.ts @@ -0,0 +1,7 @@ +import { Data } from "effect"; + +export class LegacyServicesEnvNotSupportedError extends Data.TaggedError( + "LegacyServicesEnvNotSupportedError", +)<{ + readonly message: string; +}> {} diff --git a/apps/cli/src/legacy/commands/services/services.handler.ts b/apps/cli/src/legacy/commands/services/services.handler.ts index 1dd2a06b46..28b4dc2461 100644 --- a/apps/cli/src/legacy/commands/services/services.handler.ts +++ b/apps/cli/src/legacy/commands/services/services.handler.ts @@ -1,8 +1,112 @@ -import { Effect } from "effect"; -import { LegacyGoProxy } from "../../../shared/legacy/go-proxy.service.ts"; +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 { 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"; +import { + encodeLegacyTomlRows, + fetchLinkedServiceVersions, + formatServicesWarning, + listLocalServiceVersions, + mergeRemoteServiceVersions, + renderServicesTable, + renderServicesWarning, +} from "../../../shared/services/services.shared.ts"; import type { LegacyServicesFlags } from "./services.command.ts"; +import { LegacyServicesEnvNotSupportedError } from "./services.errors.ts"; export const legacyServices = Effect.fn("legacy.services")(function* (_flags: LegacyServicesFlags) { - const proxy = yield* LegacyGoProxy; - yield* proxy.exec(["services"]); + const output = yield* Output; + const legacyOutput = yield* LegacyOutputFlag; + const cliConfig = yield* LegacyCliConfig; + const credentials = yield* LegacyCredentials; + const linkedProjectCache = yield* LegacyLinkedProjectCache; + const telemetryState = yield* LegacyTelemetryState; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const projectRefPath = path.join(cliConfig.workdir, "supabase", ".temp", "project-ref"); + const linkedProjectRef = yield* Effect.gen(function* () { + if (Option.isSome(cliConfig.projectId)) { + return cliConfig.projectId; + } + + const exists = yield* fs.exists(projectRefPath).pipe(Effect.orElseSucceed(() => false)); + if (!exists) { + return Option.none(); + } + + const content = yield* fs.readFileString(projectRefPath).pipe(Effect.orElseSucceed(() => "")); + const trimmed = content.trim(); + return trimmed.length === 0 ? Option.none() : Option.some(trimmed); + }); + + // Mirror Go's PersistentPostRun (`apps/cli-go/cmd/root.go:176`): when a project + // ref is resolved, refresh the linked-project cache on success and failure so + // PostHog org/project groups stay attached. Persist the telemetry state too. + const cacheLinkedProject = Option.match(linkedProjectRef, { + onNone: () => Effect.void, + onSome: (ref) => linkedProjectCache.cache(ref), + }); + + yield* Effect.gen(function* () { + const accessTokenExit = yield* credentials.getAccessToken.pipe(Effect.exit); + const accessToken = Exit.isSuccess(accessTokenExit) ? accessTokenExit.value : Option.none(); + + let rows = listLocalServiceVersions(); + if (Option.isSome(linkedProjectRef) && Option.isSome(accessToken)) { + const remote = yield* fetchLinkedServiceVersions({ + apiUrl: cliConfig.apiUrl, + projectHost: cliConfig.projectHost, + projectRef: linkedProjectRef.value, + accessToken: accessToken.value, + userAgent: cliConfig.userAgent, + }); + rows = mergeRemoteServiceVersions(remote); + } + + const warning = renderServicesWarning(rows); + if (warning !== undefined) { + yield* output.raw(formatServicesWarning(warning, output.format === "text"), "stderr"); + } + + const goOutput = Option.getOrUndefined(legacyOutput); + + if (goOutput === "env") { + return yield* Effect.fail( + new LegacyServicesEnvNotSupportedError({ + message: "--output env flag is not supported", + }), + ); + } + + if (goOutput === "json") { + yield* output.raw(encodeGoJson(rows)); + return; + } + + if (goOutput === "yaml") { + yield* output.raw(encodeYaml(rows)); + return; + } + + if (goOutput === "toml") { + yield* output.raw(encodeToml(encodeLegacyTomlRows(rows))); + return; + } + + // goOutput is undefined or "pretty" — defer to the TS --output-format flag for + // machine output, otherwise render the Go `--output pretty` table. Guarding the + // table behind this (rather than treating "pretty" as force-table) keeps + // `--output pretty --output-format json` emitting JSON, per CLI-1546. + if (output.format === "json" || output.format === "stream-json") { + yield* output.success("", { services: rows }); + return; + } + + yield* output.raw(renderServicesTable(rows)); + }).pipe(Effect.ensuring(cacheLinkedProject), Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/services/services.integration.test.ts b/apps/cli/src/legacy/commands/services/services.integration.test.ts new file mode 100644 index 0000000000..1c866a2b42 --- /dev/null +++ b/apps/cli/src/legacy/commands/services/services.integration.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it } from "@effect/vitest"; +import { BunServices } from "@effect/platform-bun"; +import { Cause, Effect, Exit, Layer, Option } from "effect"; +import { FetchHttpClient } from "effect/unstable/http"; +import { LegacyCredentials } from "../../auth/legacy-credentials.service.ts"; +import { LegacyCliConfig } from "../../config/legacy-cli-config.service.ts"; +import { LegacyLinkedProjectCache } from "../../telemetry/legacy-linked-project-cache.service.ts"; +import { LegacyOutputFlag } from "../../../shared/legacy/global-flags.ts"; +import { mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { mockLegacyTelemetryStateTracked } from "../../../../tests/helpers/legacy-mocks.ts"; +import { legacyServices } from "./services.handler.ts"; + +function setup( + opts: { + format?: "text" | "json" | "stream-json"; + goOutput?: Option.Option<"env" | "pretty" | "json" | "toml" | "yaml">; + } = {}, +) { + const out = mockOutput({ + format: opts.format ?? "text", + interactive: (opts.format ?? "text") === "text", + }); + const telemetry = mockLegacyTelemetryStateTracked(); + const cachedRefs: string[] = []; + + return { + out, + telemetry, + cachedRefs, + layer: Layer.mergeAll( + BunServices.layer, + FetchHttpClient.layer, + out.layer, + telemetry.layer, + Layer.succeed(LegacyOutputFlag, opts.goOutput ?? Option.none()), + Layer.succeed( + LegacyCliConfig, + LegacyCliConfig.of({ + profile: "supabase", + apiUrl: "https://api.supabase.com", + projectHost: "supabase.co", + accessToken: Option.none(), + projectId: Option.none(), + workdir: process.cwd(), + userAgent: "SupabaseCLI/test", + }), + ), + Layer.succeed(LegacyCredentials, LegacyCredentials.of(legacyCredentialsMock)), + Layer.succeed( + LegacyLinkedProjectCache, + LegacyLinkedProjectCache.of({ + cache: (ref) => + Effect.sync(() => { + cachedRefs.push(ref); + }), + }), + ), + ), + }; +} + +const legacyCredentialsMock = { + getAccessToken: Effect.succeed(Option.none()), + saveAccessToken: () => Effect.die("unexpected saveAccessToken"), + deleteAccessToken: Effect.die("unexpected deleteAccessToken"), + deleteAllProjectCredentials: Effect.void, + deleteProjectCredential: () => Effect.succeed(false), +}; + +function expectFailureTag(exit: Exit.Exit, tag: string) { + expect(Exit.isFailure(exit)).toBe(true); + if (!Exit.isFailure(exit)) { + return; + } + + const failure = Cause.findErrorOption(exit.cause); + expect(Option.isSome(failure)).toBe(true); + if (Option.isSome(failure)) { + expect((failure.value as { _tag: string })._tag).toBe(tag); + } +} + +describe("legacy services", () => { + it.live("prints the services table by default", () => { + const { layer, out } = setup(); + + return Effect.gen(function* () { + yield* legacyServices({}).pipe(Effect.provide(layer)); + + expect(out.stdoutText).toContain("supabase/postgres"); + expect(out.stdoutText).toContain("supabase/gotrue"); + expect(out.stdoutText).toContain("supabase/storage-api"); + expect(out.stderrText).toBe(""); + }); + }); + + it.live("emits a services JSON array for --output json", () => { + const { layer, out } = setup({ goOutput: Option.some("json") }); + + 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).toHaveLength(10); + expect(rows[0]).toMatchObject({ name: "supabase/postgres", local: "17.6.1.132" }); + }); + }); + + 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. + const { layer, out } = setup({ format: "json", goOutput: Option.some("pretty") }); + + return Effect.gen(function* () { + yield* legacyServices({}).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.132" }), + ]), + }); + }); + }); + + it.live("emits structured JSON for --output-format stream-json", () => { + const { layer, out } = setup({ format: "stream-json" }); + + return Effect.gen(function* () { + yield* legacyServices({}).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.132" }), + ]), + }); + }); + }); + + it.live("emits a TOML services array for --output toml", () => { + const { layer, out } = setup({ goOutput: Option.some("toml") }); + + return Effect.gen(function* () { + yield* legacyServices({}).pipe(Effect.provide(layer)); + + expect(out.stdoutText).toContain("[[services]]"); + expect(out.stdoutText).toContain('name = "supabase/postgres"'); + }); + }); + + it.live("emits a YAML services array for --output yaml", () => { + const { layer, out } = setup({ goOutput: Option.some("yaml") }); + + return Effect.gen(function* () { + yield* legacyServices({}).pipe(Effect.provide(layer)); + + expect(out.stdoutText).toContain("- name: supabase/postgres"); + expect(out.stdoutText).toContain("local: 17.6.1.132"); + }); + }); + + it.live("rejects --output env", () => { + const { layer } = setup({ goOutput: Option.some("env") }); + + return Effect.gen(function* () { + const exit = yield* legacyServices({}).pipe(Effect.provide(layer), Effect.exit); + expectFailureTag(exit, "LegacyServicesEnvNotSupportedError"); + }); + }); + + it.live("flushes telemetry state after the command finishes", () => { + const { layer, telemetry } = setup(); + + return Effect.gen(function* () { + yield* legacyServices({}).pipe(Effect.provide(layer)); + expect(telemetry.flushed).toBe(true); + }); + }); +}); diff --git a/apps/cli/src/next/cli/root.ts b/apps/cli/src/next/cli/root.ts index af65205e66..332507a772 100644 --- a/apps/cli/src/next/cli/root.ts +++ b/apps/cli/src/next/cli/root.ts @@ -10,6 +10,7 @@ import { loginCommand } from "../commands/login/login.command.ts"; import { logoutCommand } from "../commands/logout/logout.command.ts"; import { logsCommand } from "../commands/logs/logs.command.ts"; import { apiCommand } from "../commands/platform/api.command.ts"; +import { servicesCommand } from "../commands/services/services.command.ts"; import { startCommand } from "../commands/start/start.command.ts"; import { statusCommand } from "../commands/status/status.command.ts"; import { stopCommand } from "../commands/stop/stop.command.ts"; @@ -35,6 +36,7 @@ export const nextRoot = Command.make("supabase").pipe( branchesCommand, linkCommand, unlinkCommand, + servicesCommand, stackCommand, startCommand, stopCommand, diff --git a/apps/cli/src/next/commands/services/services.command.ts b/apps/cli/src/next/commands/services/services.command.ts new file mode 100644 index 0000000000..9716a5685c --- /dev/null +++ b/apps/cli/src/next/commands/services/services.command.ts @@ -0,0 +1,30 @@ +import { Layer } from "effect"; +import { Command } from "effect/unstable/cli"; +import { FetchHttpClient } from "effect/unstable/http"; +import { credentialsLayer } from "../../auth/credentials.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"; +import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; +import { withCommandInstrumentation } from "../../../shared/telemetry/command-instrumentation.ts"; +import { services } from "./services.handler.ts"; + +const servicesRuntimeLayer = provideProjectCommandRuntime( + Layer.mergeAll( + credentialsLayer, + projectLinkStateLayer, + commandRuntimeLayer(["services"]), + // `fetchLinkedServiceVersions` builds its management/tenant API clients from + // the ambient HttpClient rather than self-provisioning one. + FetchHttpClient.layer, + ), +); + +export const servicesCommand = Command.make("services").pipe( + Command.withDescription( + "Show versions of local Supabase services.\n\nPrints the local image matrix and, when this checkout is linked and authenticated, best-effort linked service versions for comparison.", + ), + Command.withShortDescription("Show versions of all Supabase services"), + Command.withHandler(() => services().pipe(withCommandInstrumentation(), withJsonErrorHandling)), + Command.provide(servicesRuntimeLayer), +); diff --git a/apps/cli/src/next/commands/services/services.handler.ts b/apps/cli/src/next/commands/services/services.handler.ts new file mode 100644 index 0000000000..5b7fc61c94 --- /dev/null +++ b/apps/cli/src/next/commands/services/services.handler.ts @@ -0,0 +1,57 @@ +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 { Output } from "../../../shared/output/output.service.ts"; +import { + CommandRuntime, + getCommandRuntimeCommand, +} from "../../../shared/runtime/command-runtime.service.ts"; +import { + fetchLinkedServiceVersions, + formatServicesWarning, + listLocalServiceVersions, + mergeRemoteServiceVersions, + renderServicesTable, + renderServicesWarning, +} from "../../../shared/services/services.shared.ts"; + +export const services = Effect.fnUntraced(function* () { + const output = yield* Output; + const cliConfig = yield* CliConfig; + const credentials = yield* Credentials; + 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; + + let rows = listLocalServiceVersions(); + if (Option.isSome(linkedState) && Option.isSome(accessToken)) { + const remote = yield* fetchLinkedServiceVersions({ + apiUrl: cliConfig.apiUrl, + projectHost: cliConfig.projectHost, + projectRef: linkedState.value.project.ref, + accessToken: accessToken.value, + userAgent: "@supabase/cli", + headers: { + "X-Supabase-Command": getCommandRuntimeCommand(commandRuntime), + "X-Supabase-Command-Run-ID": commandRuntime.commandRunId, + }, + }); + rows = mergeRemoteServiceVersions(remote); + } + + const warning = renderServicesWarning(rows); + if (warning !== undefined) { + yield* output.raw(formatServicesWarning(warning, output.format === "text"), "stderr"); + } + + if (output.format === "text") { + yield* output.raw(renderServicesTable(rows)); + return; + } + + yield* output.success("", { services: 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 new file mode 100644 index 0000000000..317db800db --- /dev/null +++ b/apps/cli/src/next/commands/services/services.integration.test.ts @@ -0,0 +1,211 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Layer, Option, Redacted } from "effect"; +import { FetchHttpClient } from "effect/unstable/http"; +import { CliConfig } from "../../config/cli-config.service.ts"; +import { + ProjectLinkState, + type ProjectLinkStateValue, +} from "../../config/project-link-state.service.ts"; +import { InvalidProjectLinkStateError } from "../../config/project-link-state.service.ts"; +import { Credentials } from "../../auth/credentials.service.ts"; +import { mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { CommandRuntime } from "../../../shared/runtime/command-runtime.service.ts"; +import { services } from "./services.handler.ts"; + +const LINKED_REF = "abcdefghijklmnopqrst"; + +function linkedStateFixture(): ProjectLinkStateValue { + return { + project: { + ref: LINKED_REF, + name: "Linked Project", + organization_id: "org-id", + organization_slug: "org", + }, + active_branch: { ref: "branch-ref", name: "main", is_default: true }, + fetchedAt: "2026-03-13T12:00:00.000Z", + versions: {}, + }; +} + +function setup( + opts: { + format?: "text" | "json" | "stream-json"; + linkedState?: Option.Option; + invalidLinkedState?: boolean; + accessToken?: string; + apiUrl?: string; + } = {}, +) { + const out = mockOutput({ + format: opts.format ?? "text", + interactive: (opts.format ?? "text") === "text", + }); + const linkedState = opts.linkedState ?? Option.none(); + + return { + out, + layer: Layer.mergeAll( + out.layer, + FetchHttpClient.layer, + Layer.succeed( + CliConfig, + CliConfig.of({ + apiUrl: opts.apiUrl ?? "https://api.supabase.com", + dashboardUrl: "https://supabase.com/dashboard", + projectHost: "supabase.co", + telemetryPosthogHost: "https://ph.supabase.com", + telemetryPosthogKey: Option.none(), + accessToken: Option.none(), + noKeyring: Option.none(), + supabaseHome: "/tmp/supabase-home", + debug: Option.none(), + telemetryDebug: Option.none(), + telemetryDisabled: Option.none(), + doNotTrack: Option.none(), + }), + ), + Layer.succeed( + Credentials, + Credentials.of({ + getAccessToken: Effect.succeed( + opts.accessToken === undefined + ? Option.none() + : Option.some(Redacted.make(opts.accessToken)), + ), + saveAccessToken: () => Effect.die("unexpected saveAccessToken"), + deleteAccessToken: Effect.die("unexpected deleteAccessToken"), + }), + ), + Layer.succeed( + ProjectLinkState, + ProjectLinkState.of({ + load: opts.invalidLinkedState + ? Effect.fail( + new InvalidProjectLinkStateError({ + detail: "broken project link state", + suggestion: "fix it", + }), + ) + : Effect.succeed(linkedState), + save: () => Effect.die("unexpected save"), + clear: Effect.die("unexpected clear"), + getActiveBranch: Effect.succeed(Option.none()), + setActiveBranch: () => Effect.die("unexpected setActiveBranch"), + }), + ), + Layer.succeed( + CommandRuntime, + CommandRuntime.of({ + commandPath: ["services"], + commandRunId: "run-services-test", + }), + ), + ), + }; +} + +describe("next services", () => { + it.live("prints the services table in text mode", () => { + const { layer, out } = setup(); + + return Effect.gen(function* () { + yield* services().pipe(Effect.provide(layer)); + + expect(out.stdoutText).toContain("supabase/postgres"); + expect(out.stdoutText).toContain("supabase/gotrue"); + expect(out.stdoutText).toContain("supabase/storage-api"); + expect(out.stderrText).toBe(""); + }); + }); + + it.live("emits structured services data in json mode", () => { + const { layer, out } = setup({ format: "json" }); + + 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.132" }), + ]), + }); + }); + }); + + it.live("falls back to local output when linked state is invalid", () => { + const { layer, out } = setup({ invalidLinkedState: true }); + + return Effect.gen(function* () { + yield* services().pipe(Effect.provide(layer)); + + expect(out.stdoutText).toContain("supabase/postgres"); + expect(out.stderrText).toBe(""); + }); + }); + + it.live("merges linked service versions and warns on a version mismatch", () => { + const server = Bun.serve({ + port: 0, + fetch(request) { + const url = new URL(request.url); + if (url.pathname === `/v1/projects/${LINKED_REF}/api-keys`) { + return Response.json([ + { + name: "anon", + id: "publishable-id", + type: "publishable", + api_key: "publishable-key", + description: null, + }, + ]); + } + + if (url.pathname === `/v1/projects/${LINKED_REF}`) { + return Response.json({ + id: LINKED_REF, + ref: LINKED_REF, + organization_id: "org-id", + organization_slug: "org", + name: "Linked Project", + region: "us-east-1", + created_at: "2026-03-13T12:00:00.000Z", + status: "ACTIVE_HEALTHY", + database: { + host: "db.supabase.internal", + version: "17.6.1.200", + postgres_engine: "17", + release_channel: "ga", + }, + }); + } + + return new Response("not found", { status: 404 }); + }, + }); + + const { layer, out } = setup({ + format: "json", + linkedState: Option.some(linkedStateFixture()), + accessToken: "sbp_token", + apiUrl: server.url.origin, + }); + + 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.132", + remote: "17.6.1.200", + }), + ]), + }); + expect(out.stderrText).toContain("WARNING:"); + }).pipe(Effect.ensuring(Effect.promise(() => server.stop(true)))); + }); +}); diff --git a/apps/cli/src/shared/services/services.shared.ts b/apps/cli/src/shared/services/services.shared.ts new file mode 100644 index 0000000000..8475c5360e --- /dev/null +++ b/apps/cli/src/shared/services/services.shared.ts @@ -0,0 +1,383 @@ +import { styleText } from "node:util"; +import { makeApiClient, type ApiClient } from "@supabase/api/effect"; +import { Data, Duration, Effect, Exit, Redacted } from "effect"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import { renderGlamourTable } from "../../legacy/output/legacy-glamour-table.ts"; + +export type RemoteServiceName = "postgres" | "auth" | "postgrest" | "storage"; +export type OptionalRemoteServiceName = Exclude; + +// 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 +// tenant gateway hostname keeps a tampered/malformed value from redirecting the +// service-role key to an attacker-controlled host. +const PROJECT_REF_PATTERN = /^[a-z]{20}$/; + +interface ServiceImageSpec { + readonly image: string; + readonly remoteService: RemoteServiceName | undefined; +} + +// Mirrors the legacy `services` image matrix: +// - source versions: `apps/cli-go/pkg/config/templates/Dockerfile` +// - source order: `apps/cli-go/pkg/config/config.go` `GetServiceImages()` +// +// We keep this compiled into the TS CLI because the published package does not +// ship the Go source tree at runtime, but the user-visible `services` output +// still needs to match the bundled image manifest. +export const LOCAL_SERVICE_IMAGES = [ + { image: "supabase/postgres:17.6.1.132", remoteService: "postgres" }, + { image: "supabase/gotrue:v2.189.0", remoteService: "auth" }, + { image: "postgrest/postgrest:v14.12", remoteService: "postgrest" }, + { image: "supabase/realtime:v2.103.2", remoteService: undefined }, + { image: "supabase/storage-api:v1.60.4", remoteService: "storage" }, + { image: "supabase/edge-runtime:v1.74.0", remoteService: undefined }, + { + image: "supabase/studio:2026.06.03-sha-0bca601", + remoteService: undefined, + }, + { image: "supabase/postgres-meta:v0.96.6", remoteService: undefined }, + { image: "supabase/logflare:1.43.3", remoteService: undefined }, + { image: "supabase/supavisor:2.9.7", remoteService: undefined }, +] as const satisfies ReadonlyArray; + +const TABLE_HEADERS = ["SERVICE IMAGE", "LOCAL", "LINKED"] as const; + +type ProjectApiKey = { + readonly name: string; + readonly type?: "legacy" | "publishable" | "secret" | null; + readonly api_key?: string | null; + readonly secret_jwt_template?: Record | null; +}; + +export interface ServiceVersionRow { + readonly name: string; + readonly local: string; + readonly remote: string; +} + +function toServiceVersionRow( + service: ServiceImageSpec, + remote: Partial> = {}, +): ServiceVersionRow { + const parts = service.image.split(":"); + const name = parts[0]; + const local = parts[1]; + + if (name === undefined || local === undefined) { + throw new Error(`Invalid service image entry: ${service.image}`); + } + + return { + name, + local, + remote: service.remoteService === undefined ? "" : (remote[service.remoteService] ?? ""), + }; +} + +export interface ServiceFetchConfig { + readonly apiUrl: string; + readonly projectHost: string; + readonly projectRef: string; + readonly accessToken?: Redacted.Redacted; + readonly userAgent: string; + readonly headers?: Readonly>; + readonly api?: ApiClient; + readonly tenantBaseUrlOverride?: string; +} + +class ServiceVersionNotFoundError extends Data.TaggedError("ServiceVersionNotFoundError")<{ + readonly service: string; +}> {} + +function fieldValue(value: unknown, key: string): unknown { + if (typeof value !== "object" || value === null) { + return undefined; + } + return Reflect.get(value, key); +} + +function stringField(value: unknown, key: string): string | undefined { + const field = fieldValue(value, key); + return typeof field === "string" && field.length > 0 ? field : undefined; +} + +function selectTenantAccessKey( + keys: ReadonlyArray, +): Redacted.Redacted | undefined { + for (const key of keys) { + const template = key.secret_jwt_template; + if ( + key.type === "secret" && + typeof key.api_key === "string" && + template != null && + typeof template === "object" && + !Array.isArray(template) && + typeof template.role === "string" && + template.role.toLowerCase() === "service_role" + ) { + return Redacted.make(key.api_key); + } + } + + for (const key of keys) { + if (key.name === "service_role" && typeof key.api_key === "string") { + return Redacted.make(key.api_key); + } + } +} + +function hasProjectAccessKey(keys: ReadonlyArray): boolean { + return keys.some((key) => { + if (typeof key.api_key !== "string") { + return false; + } + + if (key.type === "publishable") { + return true; + } + + if (key.name === "anon") { + return true; + } + + if (key.name === "service_role") { + return true; + } + + return ( + key.type === "secret" && + key.secret_jwt_template != null && + typeof key.secret_jwt_template === "object" && + !Array.isArray(key.secret_jwt_template) && + typeof key.secret_jwt_template.role === "string" && + key.secret_jwt_template.role.toLowerCase() === "service_role" + ); + }); +} + +const authenticatedRequest = (url: string, accessKey: Redacted.Redacted) => { + const key = Redacted.value(accessKey); + const request = HttpClientRequest.get(url).pipe(HttpClientRequest.setHeader("apikey", key)); + // New-style `sb_…` keys authenticate via the `apikey` header alone; older JWT + // keys additionally require a bearer token. Mirrors the conditional auth in + // `apps/cli-go/pkg/fetcher/gateway.go` and `legacy/shared/legacy-tenant-versions.ts`. + return key.startsWith("sb_") + ? request + : request.pipe(HttpClientRequest.setHeader("Authorization", `Bearer ${key}`)); +}; + +const fetchJson = Effect.fnUntraced(function* ( + client: HttpClient.HttpClient, + url: string, + accessKey: Redacted.Redacted, +) { + const request = authenticatedRequest(url, accessKey).pipe(HttpClientRequest.acceptJson); + const response = yield* client.execute(request); + return yield* response.json; +}); + +const fetchText = Effect.fnUntraced(function* ( + client: HttpClient.HttpClient, + url: string, + accessKey: Redacted.Redacted, +) { + const response = yield* client.execute(authenticatedRequest(url, accessKey)); + return yield* response.text; +}); + +const fetchPostgrestVersion = Effect.fnUntraced(function* ( + client: HttpClient.HttpClient, + baseUrl: string, + accessKey: Redacted.Redacted, +) { + const body = yield* fetchJson(client, `${baseUrl}/rest/v1/`, accessKey); + const version = + typeof body === "object" && + body !== null && + "info" in body && + typeof body.info === "object" && + body.info !== null && + "version" in body.info && + typeof body.info.version === "string" + ? body.info.version + : undefined; + + const normalized = version?.trim().split(/\s+/)[0]; + if (normalized === undefined || normalized.length === 0) { + return yield* Effect.fail(new ServiceVersionNotFoundError({ service: "postgrest" })); + } + + return normalized.startsWith("v") ? normalized : `v${normalized}`; +}); + +const fetchAuthVersion = Effect.fnUntraced(function* ( + client: HttpClient.HttpClient, + baseUrl: string, + accessKey: Redacted.Redacted, +) { + const body = yield* fetchJson(client, `${baseUrl}/auth/v1/health`, accessKey); + const version = stringField(body, "version")?.trim(); + + if (version === undefined || version.length === 0) { + return yield* Effect.fail(new ServiceVersionNotFoundError({ service: "auth" })); + } + + return version; +}); + +const fetchStorageVersion = Effect.fnUntraced(function* ( + client: HttpClient.HttpClient, + baseUrl: string, + accessKey: Redacted.Redacted, +) { + const version = (yield* fetchText(client, `${baseUrl}/storage/v1/version`, accessKey)).trim(); + if (version.length === 0 || version === "0.0.0") { + return yield* Effect.fail(new ServiceVersionNotFoundError({ service: "storage" })); + } + + return version.startsWith("v") ? version : `v${version}`; +}); + +const fetchOptionalVersion = ( + service: OptionalRemoteServiceName, + effect: Effect.Effect, +) => + effect.pipe( + Effect.exit, + Effect.map((exit) => ({ service, exit }) as const), + ); + +const makeConfiguredApiClient = Effect.fnUntraced(function* (input: ServiceFetchConfig) { + return ( + input.api ?? + (yield* makeApiClient({ + baseUrl: input.apiUrl, + accessToken: input.accessToken, + userAgent: input.userAgent, + headers: input.headers, + })) + ); +}); + +export function listLocalServiceVersions(): ReadonlyArray { + return LOCAL_SERVICE_IMAGES.map((service) => toServiceVersionRow(service)); +} + +export function mergeRemoteServiceVersions( + remote: Partial>, +): ReadonlyArray { + return LOCAL_SERVICE_IMAGES.map((service) => toServiceVersionRow(service, remote)); +} + +export function renderServicesTable(rows: ReadonlyArray): string { + return renderGlamourTable( + TABLE_HEADERS, + rows.map((row) => [row.name, row.local, row.remote.length === 0 ? "-" : row.remote]), + ); +} + +export function renderServicesWarning(rows: ReadonlyArray): 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:", + ...mismatches.map((row) => `${row.name}:${row.local} => ${row.remote}`), + "Run supabase link to update them.", + ].join("\n"); +} + +/** + * Renders the linked-version mismatch warning for stderr. In text mode the + * `WARNING:` prefix is colorized (matching Go's `utils.Yellow`); machine modes + * keep it plain so the stderr line stays parseable. + */ +export function formatServicesWarning(message: string, textMode: boolean): string { + const lines = message.split("\n"); + const prefix = textMode ? styleText("yellow", "WARNING:") : "WARNING:"; + const [first, ...rest] = lines; + return `${prefix} ${first}\n${rest.join("\n")}\n`; +} + +export function encodeLegacyTomlRows(rows: ReadonlyArray) { + return { services: rows } as const; +} + +export function fetchLinkedServiceVersions(input: ServiceFetchConfig) { + return Effect.gen(function* () { + const exit = yield* Effect.gen(function* () { + // Reject malformed refs before they reach the management API path param or + // the tenant gateway hostname (`https://.`). The override is + // test-only, so it bypasses the check. + if ( + input.tenantBaseUrlOverride === undefined && + !PROJECT_REF_PATTERN.test(input.projectRef) + ) { + return {} as Partial>; + } + + const client = yield* makeConfiguredApiClient(input); + const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.filterStatusOk); + + const apiKeysExit = yield* client.v1 + .getProjectApiKeys({ ref: input.projectRef, reveal: true }) + .pipe(Effect.exit); + if (!Exit.isSuccess(apiKeysExit) || !hasProjectAccessKey(apiKeysExit.value)) { + return {} as Partial>; + } + + let versions: Partial> = {}; + const postgresExit = yield* client.v1.getProject({ ref: input.projectRef }).pipe( + Effect.map((project) => project.database.version), + Effect.exit, + ); + if (Exit.isSuccess(postgresExit)) { + versions = { ...versions, postgres: postgresExit.value }; + } + + const accessKey = selectTenantAccessKey(apiKeysExit.value); + if (accessKey === undefined) { + return versions; + } + + const baseUrl = + input.tenantBaseUrlOverride ?? `https://${input.projectRef}.${input.projectHost}`; + const results = yield* Effect.all( + [ + fetchOptionalVersion( + "postgrest", + fetchPostgrestVersion(httpClient, baseUrl, accessKey).pipe( + Effect.timeout(Duration.seconds(10)), + ), + ), + fetchOptionalVersion( + "auth", + fetchAuthVersion(httpClient, baseUrl, accessKey).pipe( + Effect.timeout(Duration.seconds(10)), + ), + ), + fetchOptionalVersion( + "storage", + fetchStorageVersion(httpClient, baseUrl, accessKey).pipe( + Effect.timeout(Duration.seconds(10)), + ), + ), + ], + { concurrency: "unbounded" }, + ); + + for (const result of results) { + if (Exit.isSuccess(result.exit)) { + versions = { ...versions, [result.service]: result.exit.value }; + } + } + + return versions; + }).pipe(Effect.exit); + return Exit.isSuccess(exit) ? exit.value : ({} as Partial>); + }); +} diff --git a/apps/cli/src/shared/services/services.shared.unit.test.ts b/apps/cli/src/shared/services/services.shared.unit.test.ts new file mode 100644 index 0000000000..be777f9231 --- /dev/null +++ b/apps/cli/src/shared/services/services.shared.unit.test.ts @@ -0,0 +1,355 @@ +import { describe, expect, test } from "vitest"; +import { Effect, Redacted } from "effect"; +import { FetchHttpClient } from "effect/unstable/http"; +import { readFileSync } from "node:fs"; +import { + fetchLinkedServiceVersions, + LOCAL_SERVICE_IMAGES, + listLocalServiceVersions, + renderServicesTable, + renderServicesWarning, +} from "./services.shared.ts"; + +const ACCESS_TOKEN = Redacted.make(`sbp_${"a".repeat(40)}`); +const PROJECT_REF = "abcdefghijklmnopqrst"; + +// `fetchLinkedServiceVersions` reads the ambient HttpClient from context instead +// of self-provisioning one, so each invocation needs a concrete transport. +const runLinkedFetch = (input: Parameters[0]) => + Effect.runPromise(fetchLinkedServiceVersions(input).pipe(Effect.provide(FetchHttpClient.layer))); + +function parseDockerfileServiceImages() { + const dockerfile = readFileSync( + new URL("../../../../cli-go/pkg/config/templates/Dockerfile", import.meta.url), + "utf8", + ); + + const imagesByAlias = new Map(); + + for (const line of dockerfile.split(/\r?\n/)) { + const match = /^FROM\s+([^:]+):(\S+)\s+AS\s+(\S+)$/.exec(line.trim()); + if (match === null) { + continue; + } + + const [, imageName, version, alias] = match; + if (imageName === undefined || version === undefined || alias === undefined) { + throw new Error("Dockerfile service image parse failed"); + } + imagesByAlias.set(alias, { name: imageName, version }); + } + + return [ + imagesByAlias.get("pg"), + imagesByAlias.get("gotrue"), + imagesByAlias.get("postgrest"), + imagesByAlias.get("realtime"), + imagesByAlias.get("storage"), + imagesByAlias.get("edgeruntime"), + imagesByAlias.get("studio"), + imagesByAlias.get("pgmeta"), + imagesByAlias.get("logflare"), + imagesByAlias.get("supavisor"), + ].map((image) => { + if (image === undefined) { + throw new Error("Dockerfile service image alias missing"); + } + return image; + }); +} + +describe("services shared", () => { + test("keeps the local image matrix aligned with the bundled Dockerfile manifest", () => { + expect(parseDockerfileServiceImages()).toEqual( + LOCAL_SERVICE_IMAGES.map((service) => { + const [name, version] = service.image.split(":"); + return { name, version }; + }), + ); + }); + + test("returns postgres only when no service-role key is available", async () => { + const server = Bun.serve({ + port: 0, + fetch(request) { + const url = new URL(request.url); + if (url.pathname === `/v1/projects/${PROJECT_REF}`) { + return Response.json({ + id: PROJECT_REF, + ref: PROJECT_REF, + organization_id: "org-id", + organization_slug: "org", + name: "Linked Project", + region: "us-east-1", + created_at: "2026-03-13T12:00:00.000Z", + status: "ACTIVE_HEALTHY", + database: { + host: "db.supabase.internal", + version: "17.6.1.200", + postgres_engine: "17", + release_channel: "ga", + }, + }); + } + + if (url.pathname === `/v1/projects/${PROJECT_REF}/api-keys`) { + return Response.json([ + { + name: "anon", + id: "publishable-id", + type: "publishable", + api_key: "publishable-key", + description: null, + }, + ]); + } + + if ( + url.pathname === "/auth/v1/health" || + url.pathname === "/rest/v1/" || + url.pathname === "/storage/v1/version" + ) { + throw new Error( + `tenant endpoint should not be called without a service-role key: ${url.pathname}`, + ); + } + + return new Response("not found", { status: 404 }); + }, + }); + + try { + const result = await runLinkedFetch({ + apiUrl: server.url.origin, + projectHost: "supabase.co", + projectRef: PROJECT_REF, + accessToken: ACCESS_TOKEN, + userAgent: "supabase", + tenantBaseUrlOverride: server.url.origin, + }); + + expect(result).toEqual({ postgres: "17.6.1.200" }); + } finally { + await server.stop(true); + } + }); + + test("returns no linked versions when project api keys cannot be loaded", async () => { + const server = Bun.serve({ + port: 0, + fetch(request) { + const url = new URL(request.url); + if (url.pathname === `/v1/projects/${PROJECT_REF}/api-keys`) { + return new Response("boom", { status: 500 }); + } + + if (url.pathname === `/v1/projects/${PROJECT_REF}`) { + return Response.json({ + id: PROJECT_REF, + ref: PROJECT_REF, + organization_id: "org-id", + organization_slug: "org", + name: "Linked Project", + region: "us-east-1", + created_at: "2026-03-13T12:00:00.000Z", + status: "ACTIVE_HEALTHY", + database: { + host: "db.supabase.internal", + version: "17.6.1.200", + postgres_engine: "17", + release_channel: "ga", + }, + }); + } + + return new Response("not found", { status: 404 }); + }, + }); + + try { + const result = await runLinkedFetch({ + apiUrl: server.url.origin, + projectHost: "supabase.co", + projectRef: PROJECT_REF, + accessToken: ACCESS_TOKEN, + userAgent: "supabase", + tenantBaseUrlOverride: server.url.origin, + }); + + expect(result).toEqual({}); + } finally { + await server.stop(true); + } + }); + + test("still returns tenant service versions when project version lookup fails", async () => { + const server = Bun.serve({ + port: 0, + fetch(request) { + const url = new URL(request.url); + if (url.pathname === `/v1/projects/${PROJECT_REF}`) { + return new Response("boom", { status: 500 }); + } + + if (url.pathname === `/v1/projects/${PROJECT_REF}/api-keys`) { + return Response.json([ + { + name: "service_role", + id: "key-id", + type: "secret", + api_key: "service-role-key", + description: null, + secret_jwt_template: { role: "service_role" }, + }, + ]); + } + + if (url.pathname === "/auth/v1/health") { + return Response.json({ version: "v2.190.0" }); + } + + if (url.pathname === "/rest/v1/") { + return Response.json({ info: { version: "14.13" } }); + } + + if (url.pathname === "/storage/v1/version") { + return new Response("1.61.0"); + } + + return new Response("not found", { status: 404 }); + }, + }); + + try { + const result = await runLinkedFetch({ + apiUrl: server.url.origin, + projectHost: "supabase.co", + projectRef: PROJECT_REF, + accessToken: ACCESS_TOKEN, + userAgent: "supabase", + tenantBaseUrlOverride: server.url.origin, + }); + + expect(result).toEqual({ + auth: "v2.190.0", + postgrest: "v14.13", + storage: "v1.61.0", + }); + } finally { + await server.stop(true); + } + }); + + test("falls back to empty linked versions when the linked fetch fails", async () => { + const result = await runLinkedFetch({ + apiUrl: "http://127.0.0.1:1", + projectHost: "supabase.co", + projectRef: PROJECT_REF, + accessToken: ACCESS_TOKEN, + userAgent: "supabase", + }); + + expect(result).toEqual({}); + }); + + test("authenticates tenant probes with apikey only for sb_ keys", async () => { + const authHeaders: Record = {}; + const server = Bun.serve({ + port: 0, + fetch(request) { + const url = new URL(request.url); + if (url.pathname === `/v1/projects/${PROJECT_REF}/api-keys`) { + return Response.json([ + { + name: "service_role", + id: "key-id", + type: "secret", + api_key: "sb_secret_servicerolekey", + description: null, + secret_jwt_template: { role: "service_role" }, + }, + ]); + } + + if (url.pathname === `/v1/projects/${PROJECT_REF}`) { + return new Response("boom", { status: 500 }); + } + + if (url.pathname === "/auth/v1/health") { + authHeaders.apikey = request.headers.get("apikey"); + authHeaders.authorization = request.headers.get("authorization"); + return Response.json({ version: "v2.190.0" }); + } + + if (url.pathname === "/rest/v1/" || url.pathname === "/storage/v1/version") { + return new Response("not found", { status: 404 }); + } + + return new Response("not found", { status: 404 }); + }, + }); + + try { + const result = await runLinkedFetch({ + apiUrl: server.url.origin, + projectHost: "supabase.co", + projectRef: PROJECT_REF, + accessToken: ACCESS_TOKEN, + userAgent: "supabase", + tenantBaseUrlOverride: server.url.origin, + }); + + expect(result).toEqual({ auth: "v2.190.0" }); + expect(authHeaders.apikey).toBe("sb_secret_servicerolekey"); + expect(authHeaders.authorization).toBeNull(); + } finally { + await server.stop(true); + } + }); + + test("skips remote lookups for a malformed project ref", async () => { + const server = Bun.serve({ + port: 0, + fetch() { + throw new Error("no request should be made for a malformed project ref"); + }, + }); + + try { + const result = await runLinkedFetch({ + apiUrl: server.url.origin, + projectHost: "supabase.co", + projectRef: "not-a-valid-ref", + accessToken: ACCESS_TOKEN, + userAgent: "supabase", + }); + + expect(result).toEqual({}); + } finally { + await server.stop(true); + } + }); + + test("renders the local services table with expected headers and rows", () => { + const rows = listLocalServiceVersions(); + const table = renderServicesTable(rows); + + expect(table).toContain("SERVICE IMAGE"); + expect(table).toContain("LOCAL"); + expect(table).toContain("LINKED"); + + for (const row of rows) { + expect(table).toContain(row.name); + expect(table).toContain(row.local); + } + }); + + test("renders update warning only for mismatched linked versions", () => { + expect( + renderServicesWarning([ + { name: "supabase/postgres", local: "17.6.1.132", remote: "17.6.1.200" }, + { name: "supabase/gotrue", local: "v2.189.0", remote: "v2.189.0" }, + ]), + ).toContain("supabase/postgres:17.6.1.132 => 17.6.1.200"); + }); +}); From 94b0db1bc9502d1517a14db460ea988482b62780 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 5 Jun 2026 10:47:34 +0100 Subject: [PATCH 03/11] feat(cli): port bootstrap command to native TypeScript (#5470) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What changed Replaces the Phase-0 Go proxy for `supabase bootstrap` with a native TypeScript implementation in the legacy shell. `bootstrap` is a meta-orchestrator that chains: workdir resolve/prompt → template list/download → blank `init` → ensure-login → `projects create` → `projects api-keys` (backoff) → `link` services → health poll (backoff) → write `.env` → `db push` → start suggestion. Per the "hoist before you duplicate" policy, the shared orchestration is extracted from the already-ported `login` / `projects create` / `projects api-keys` / `link` handlers into `legacy/shared/`, and those handlers are refactored to delegate (no behavior change — their existing tests still pass): - `legacy-ensure-login` (browser login flow + post-login telemetry) - `legacy-project-create-core` - `legacy-get-api-keys` - `legacy-link-services-core` - `legacy-tenant-keys` (`legacyExtractServiceKeys`) - the login api/crypto layers move to `legacy/shared/` so `bootstrap` can compose them without a cross-command import ## Why Phase 1+ native port of `bootstrap` (the last Quick-Start command still proxied), unblocking native machine output (`--output-format json|stream-json`) and removing the Go dependency for everything except the migration push. ## Reviewer-relevant context - **`db push` is delegated to the bundled Go binary (interim).** It is the only non-native step, pending a separate native `db push` port. `LegacyGoProxy.exec` exits the process on a non-zero exit rather than returning a failure, so Go's push backoff cannot be reproduced from the proxy (single attempt). Documented in `SIDE_EFFECTS.md`. - **Go-parity decisions worth a look:** - No `cli_project_linked` telemetry — Go's `bootstrap` calls `link.LinkServices` (services-only), **not** `link.Run`, so it deliberately skips the project-linked event, status check, and `linked-project.json` temp write. - API keys are fetched **without** `reveal` (Go's `RunGetApiKeys` uses empty params). - Workdir / DB password env vars are read **prefixed** (`SUPABASE_WORKDIR`, `SUPABASE_DB_PASSWORD`) to match Go's `viper.SetEnvPrefix("SUPABASE")`. - `Using workdir …` prints only when the resolved workdir differs from the cwd (matches Go's `ChangeWorkDir` guard). - **Hardening:** template download rejects entries that would escape the target directory; GitHub and health error bodies are sanitized before surfacing. - `docs/go-cli-porting-status.md` flips `bootstrap` `wrapped` → `ported` with the interim-db-push note. ## Follow-ups (out of scope) - Native `db push` port will remove the proxy delegation (eliminating the single-attempt and transient `--password`-in-process-table limitations). - `bootstrap.e2e.test.ts` deferred — integration coverage is comprehensive; e2e needs replayed API fixtures plus handling of the real Go `db push` subprocess. --- apps/cli/docs/go-cli-porting-status.md | 8 +- .../legacy/commands/bootstrap/SIDE_EFFECTS.md | 136 ++++-- .../commands/bootstrap/bootstrap.command.ts | 10 +- .../commands/bootstrap/bootstrap.dotenv.ts | 174 +++++++ .../bootstrap/bootstrap.dotenv.unit.test.ts | 121 +++++ .../commands/bootstrap/bootstrap.errors.ts | 54 +++ .../commands/bootstrap/bootstrap.handler.ts | 304 +++++++++++- .../bootstrap/bootstrap.integration.test.ts | 452 ++++++++++++++++++ .../commands/bootstrap/bootstrap.layers.ts | 64 +++ .../commands/bootstrap/bootstrap.pgconfig.ts | 74 +++ .../bootstrap/bootstrap.pgconfig.unit.test.ts | 68 +++ .../commands/bootstrap/bootstrap.retry.ts | 77 +++ .../bootstrap/bootstrap.retry.unit.test.ts | 125 +++++ .../commands/bootstrap/bootstrap.suggest.ts | 34 ++ .../bootstrap/bootstrap.suggest.unit.test.ts | 36 ++ .../commands/bootstrap/bootstrap.templates.ts | 246 ++++++++++ ...ootstrap.workdir-cache.integration.test.ts | 184 +++++++ .../src/legacy/commands/link/link.handler.ts | 121 +---- .../legacy/commands/login/login.handler.ts | 137 +----- .../src/legacy/commands/login/login.layers.ts | 4 +- .../projects/api-keys/api-keys.handler.ts | 18 +- .../projects/create/create.handler.ts | 148 +----- apps/cli/src/legacy/shared/legacy-colors.ts | 22 + .../src/legacy/shared/legacy-ensure-login.ts | 181 +++++++ .../src/legacy/shared/legacy-get-api-keys.ts | 33 ++ .../shared/legacy-link-services-core.ts | 126 +++++ .../legacy-login-api.layer.ts} | 9 +- .../legacy-login-crypto.layer.ts} | 7 +- .../shared/legacy-project-create-core.ts | 167 +++++++ .../src/legacy/shared/legacy-tenant-keys.ts | 43 ++ .../legacy-linked-project-cache.layer.ts | 4 +- .../legacy-linked-project-cache.service.ts | 8 +- 32 files changed, 2739 insertions(+), 456 deletions(-) create mode 100644 apps/cli/src/legacy/commands/bootstrap/bootstrap.dotenv.ts create mode 100644 apps/cli/src/legacy/commands/bootstrap/bootstrap.dotenv.unit.test.ts create mode 100644 apps/cli/src/legacy/commands/bootstrap/bootstrap.errors.ts create mode 100644 apps/cli/src/legacy/commands/bootstrap/bootstrap.integration.test.ts create mode 100644 apps/cli/src/legacy/commands/bootstrap/bootstrap.layers.ts create mode 100644 apps/cli/src/legacy/commands/bootstrap/bootstrap.pgconfig.ts create mode 100644 apps/cli/src/legacy/commands/bootstrap/bootstrap.pgconfig.unit.test.ts create mode 100644 apps/cli/src/legacy/commands/bootstrap/bootstrap.retry.ts create mode 100644 apps/cli/src/legacy/commands/bootstrap/bootstrap.retry.unit.test.ts create mode 100644 apps/cli/src/legacy/commands/bootstrap/bootstrap.suggest.ts create mode 100644 apps/cli/src/legacy/commands/bootstrap/bootstrap.suggest.unit.test.ts create mode 100644 apps/cli/src/legacy/commands/bootstrap/bootstrap.templates.ts create mode 100644 apps/cli/src/legacy/commands/bootstrap/bootstrap.workdir-cache.integration.test.ts create mode 100644 apps/cli/src/legacy/shared/legacy-colors.ts create mode 100644 apps/cli/src/legacy/shared/legacy-ensure-login.ts create mode 100644 apps/cli/src/legacy/shared/legacy-get-api-keys.ts create mode 100644 apps/cli/src/legacy/shared/legacy-link-services-core.ts rename apps/cli/src/legacy/{commands/login/login-api.layer.ts => shared/legacy-login-api.layer.ts} (92%) rename apps/cli/src/legacy/{commands/login/login-crypto.layer.ts => shared/legacy-login-crypto.layer.ts} (93%) create mode 100644 apps/cli/src/legacy/shared/legacy-project-create-core.ts create mode 100644 apps/cli/src/legacy/shared/legacy-tenant-keys.ts diff --git a/apps/cli/docs/go-cli-porting-status.md b/apps/cli/docs/go-cli-porting-status.md index a96b086da0..c202ce3684 100644 --- a/apps/cli/docs/go-cli-porting-status.md +++ b/apps/cli/docs/go-cli-porting-status.md @@ -57,9 +57,9 @@ These commands exist in the TS CLI today but have no direct top-level equivalent ## Quick Start -| Old command | TS status | TS command path or `missing` | Missing flags/params | Extra TS flags/params | Notes | -| ----------- | --------- | ---------------------------- | -------------------- | --------------------- | ------------------------------------------- | -| `bootstrap` | `missing` | `missing` | `n/a` | `n/a` | No TS command yet. Wrapped in legacy shell. | +| Old command | TS status | TS command path or `missing` | Missing flags/params | Extra TS flags/params | Notes | +| ----------- | --------- | ---------------------------- | -------------------- | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `bootstrap` | `missing` | `missing` | `n/a` | `n/a` | No `next/` command yet. Ported to native TS in the legacy shell; the migration-push sub-step is delegated to the Go binary as a documented interim until `db push` is natively ported. | ## Project / Stack Lifecycle @@ -265,7 +265,7 @@ Legend: | `logout` | `ported` | [`../src/legacy/commands/logout/logout.command.ts`](../src/legacy/commands/logout/logout.command.ts) | | `link` | `ported` | [`../src/legacy/commands/link/link.command.ts`](../src/legacy/commands/link/link.command.ts) | | `unlink` | `ported` | [`../src/legacy/commands/unlink/unlink.command.ts`](../src/legacy/commands/unlink/unlink.command.ts) | -| `bootstrap` | `wrapped` | [`../src/legacy/commands/bootstrap/bootstrap.command.ts`](../src/legacy/commands/bootstrap/bootstrap.command.ts) | +| `bootstrap` | `ported` | [`../src/legacy/commands/bootstrap/bootstrap.command.ts`](../src/legacy/commands/bootstrap/bootstrap.command.ts) (native; `db push` step delegated to the Go binary — interim) | | `init` | `ported` | [`../src/legacy/commands/init/init.command.ts`](../src/legacy/commands/init/init.command.ts) | | `services` | `ported` | [`../src/legacy/commands/services/services.command.ts`](../src/legacy/commands/services/services.command.ts) | | `start` | `wrapped` | [`../src/legacy/commands/start/start.command.ts`](../src/legacy/commands/start/start.command.ts) | diff --git a/apps/cli/src/legacy/commands/bootstrap/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/bootstrap/SIDE_EFFECTS.md index 720d4096b6..7a4ff00269 100644 --- a/apps/cli/src/legacy/commands/bootstrap/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/bootstrap/SIDE_EFFECTS.md @@ -1,71 +1,119 @@ # `supabase bootstrap [template]` +`bootstrap` is a meta-orchestrator: it chains a workdir prompt → template fetch/download → +blank `init` → ensure-login → `projects create` → `projects api-keys` → `link` services → +health poll → write `.env` → `db push` → start suggestion. Every step is native TypeScript +**except** the migration push, which is delegated to the bundled Go binary (interim — see Notes). + ## Files Read -| Path | Format | When | -| -------------------------- | ------------------------- | ---------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| Path | Format | When | +| -------------------------------------- | ---------- | ----------------------------------------------------------- | +| `~/.supabase/access-token` | plain text | ensure-login token miss (env unset and keyring unavailable) | +| `/.env.example` | dotenv | optional; merged into the generated `.env` | +| `/supabase/.temp/project-ref` | plain text | read by the delegated `db push` subprocess (post-`chdir`) | ## Files Written -| Path | Format | When | -| -------------------------------- | ------ | ------------------------------------------------------------- | -| `/supabase/config.toml` | TOML | always on success; created from selected template | -| `/.env` | env | always on success; populated with API keys and project config | -| `/