Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/cli-e2e/src/tests/stack.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down
4 changes: 2 additions & 2 deletions apps/cli/docs/go-cli-porting-status.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ These commands exist in the TS CLI today but have no direct top-level equivalent

<!-- Note: start, stop, and status are also wrapped in the legacy shell — see Legacy Shell Wrapping Status below. -->

| `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

Expand Down Expand Up @@ -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) |
Expand Down
88 changes: 49 additions & 39 deletions apps/cli/src/legacy/commands/services/SIDE_EFFECTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <serviceKey>`
and additionally `Authorization: Bearer <serviceKey>` 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.
8 changes: 7 additions & 1 deletion apps/cli/src/legacy/commands/services/services.command.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -8,5 +11,8 @@ export type LegacyServicesFlags = CliCommand.Command.Config.Infer<typeof config>
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"])),
);
7 changes: 7 additions & 0 deletions apps/cli/src/legacy/commands/services/services.errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { Data } from "effect";

export class LegacyServicesEnvNotSupportedError extends Data.TaggedError(
"LegacyServicesEnvNotSupportedError",
)<{
readonly message: string;
}> {}
112 changes: 108 additions & 4 deletions apps/cli/src/legacy/commands/services/services.handler.ts
Original file line number Diff line number Diff line change
@@ -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");
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion(non-blocking): use LegacyProjectRefResolver.resolveOptional here instead of re-reading .temp/project-ref inline. It already does this projectId/ref-file chain via legacyTempPaths() and mirrors Go's flags.LoadProjectRef.

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<string>();
}

const content = yield* fs.readFileString(projectRefPath).pipe(Effect.orElseSucceed(() => ""));
const trimmed = content.trim();
return trimmed.length === 0 ? Option.none<string>() : 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));
});
Loading
Loading