From 60c2ccab8548e040c42b72d9730fe5e046012746 Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Mon, 22 Jun 2026 20:09:57 +0530 Subject: [PATCH 1/8] feat(cli): port list to native ts --- apps/cli/docs/go-cli-porting-status.md | 2 +- .../commands/functions/list/SIDE_EFFECTS.md | 49 ++-- .../commands/functions/list/list.command.ts | 21 +- .../commands/functions/list/list.handler.ts | 118 +++++++- .../functions/list/list.integration.test.ts | 264 ++++++++++++++++++ 5 files changed, 430 insertions(+), 24 deletions(-) create mode 100644 apps/cli/src/legacy/commands/functions/list/list.integration.test.ts diff --git a/apps/cli/docs/go-cli-porting-status.md b/apps/cli/docs/go-cli-porting-status.md index fb8beae0a9..76df38fc6e 100644 --- a/apps/cli/docs/go-cli-porting-status.md +++ b/apps/cli/docs/go-cli-porting-status.md @@ -285,7 +285,7 @@ Legend: | `gen signing-key` | `ported` | [`../src/legacy/commands/gen/signing-key/signing-key.command.ts`](../src/legacy/commands/gen/signing-key/signing-key.command.ts) | | `gen bearer-jwt` | `wrapped` | [`../src/legacy/commands/gen/bearer-jwt/bearer-jwt.command.ts`](../src/legacy/commands/gen/bearer-jwt/bearer-jwt.command.ts) | | `gen keys` | `wrapped` | [`../src/legacy/commands/gen/keys/keys.command.ts`](../src/legacy/commands/gen/keys/keys.command.ts) | -| `functions list` | `wrapped` | [`../src/legacy/commands/functions/list/list.command.ts`](../src/legacy/commands/functions/list/list.command.ts) | +| `functions list` | `ported` | [`../src/legacy/commands/functions/list/list.command.ts`](../src/legacy/commands/functions/list/list.command.ts) | | `functions delete` | `ported` | [`../src/legacy/commands/functions/delete/delete.command.ts`](../src/legacy/commands/functions/delete/delete.command.ts) | | `functions download` | `ported` | [`../src/legacy/commands/functions/download/download.command.ts`](../src/legacy/commands/functions/download/download.command.ts) | | `functions deploy` | `ported` | [`../src/legacy/commands/functions/deploy/deploy.command.ts`](../src/legacy/commands/functions/deploy/deploy.command.ts) | diff --git a/apps/cli/src/legacy/commands/functions/list/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/functions/list/SIDE_EFFECTS.md index e725372ecc..572ebc01b1 100644 --- a/apps/cli/src/legacy/commands/functions/list/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/functions/list/SIDE_EFFECTS.md @@ -2,28 +2,33 @@ ## Files Read -| Path | Format | When | -| -------------------------- | ---------- | ---------------------------------------------------------- | -| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| Path | Format | When | +| ------------------------------ | ---------- | ------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `~/.supabase/profile` | plain text | when `--profile` and `SUPABASE_PROFILE` are both unset | +| `.yaml` | YAML | when `SUPABASE_PROFILE` or `--profile` points to a file | +| `./supabase/.temp/project-ref` | plain text | when `--project-ref` and `SUPABASE_PROJECT_ID` are both unset | ## Files Written | Path | Format | When | | ---- | ------ | ---- | -| — | — | — | +| - | - | - | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ------------------------------ | ------------ | ------------ | --------------------------------- | -| `GET` | `/v1/projects/{ref}/functions` | Bearer token | none | `[{id, slug, name, status, ...}]` | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ------------------------------ | ------------ | ------------ | ------------------------------------------------------ | +| `GET` | `/v1/projects/{ref}/functions` | Bearer token | none | `[{id, name, slug, status, version, updated_at, ...}]` | ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | 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 (bypasses credential file/keyring lookup) | no (falls back to keyring -> `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | select a built-in profile or YAML profile file with `api_url:` | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | provides the project ref when `--project-ref` is unset | no (falls back to `./supabase/.temp/project-ref`) | +| ~~`SUPABASE_API_URL`~~ | **not honored** - Go parity. Use `SUPABASE_PROFILE` instead. | - | ## Exit Codes @@ -33,22 +38,34 @@ | `1` | API error (non-2xx response) | | `1` | authentication error (no token found) | | `1` | network / connection failure | +| `1` | unsupported Go output mode (`env`) | + +## Telemetry Events Fired + +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | ----------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` | ## Output ### `--output-format text` (Go CLI compatible) -Prints a table of Edge Functions with columns for slug, status, version, and region. +Prints a Glamour-style ASCII table with columns `ID`, `NAME`, `SLUG`, `STATUS`, `VERSION`, and `UPDATED_AT (UTC)`. ### `--output-format json` -Not applicable (proxied to Go binary). +Prints a structured success result shaped as `{ "functions": [...] }`. ### `--output-format stream-json` -Not applicable (proxied to Go binary). +Prints a structured success result shaped as `{ "functions": [...] }`. ## Notes -- Requires a linked project (`--project-ref` or linked project config). -- Phase 0 proxy: all invocations are forwarded to the bundled Go binary. +- Requires a linked project (`--project-ref`, `SUPABASE_PROJECT_ID`, or `./supabase/.temp/project-ref`). +- Native TypeScript port using the Management API. +- Go `--output` parity: + - `json` emits the raw array. + - `yaml` emits the raw array. + - `toml` emits `{ functions = [...] }`. + - `env` fails with `--output env flag is not supported`. diff --git a/apps/cli/src/legacy/commands/functions/list/list.command.ts b/apps/cli/src/legacy/commands/functions/list/list.command.ts index 6c496deb07..f411518741 100644 --- a/apps/cli/src/legacy/commands/functions/list/list.command.ts +++ b/apps/cli/src/legacy/commands/functions/list/list.command.ts @@ -1,5 +1,8 @@ import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; +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 { legacyFunctionsList } from "./list.handler.ts"; const config = { @@ -14,5 +17,21 @@ export type LegacyFunctionsListFlags = CliCommand.Command.Config.Infer legacyFunctionsList(flags)), + Command.withExamples([ + { + command: "supabase functions list", + description: "List all deployed functions in the linked project", + }, + { + command: "supabase functions list --project-ref abcdefghijklmnopqrst", + description: "List all deployed functions in a specific project", + }, + ]), + Command.withHandler((flags) => + legacyFunctionsList(flags).pipe( + withLegacyCommandInstrumentation({ flags, safeFlags: ["project-ref"] }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyManagementApiRuntimeLayer(["functions", "list"])), ); diff --git a/apps/cli/src/legacy/commands/functions/list/list.handler.ts b/apps/cli/src/legacy/commands/functions/list/list.handler.ts index a09b251996..71648eed47 100644 --- a/apps/cli/src/legacy/commands/functions/list/list.handler.ts +++ b/apps/cli/src/legacy/commands/functions/list/list.handler.ts @@ -1,12 +1,118 @@ -import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import type { V1ListAllFunctionsOutput } from "@supabase/api/effect"; +import { Data, Effect, Option } from "effect"; + +import { LegacyOutputFlag } from "../../../../shared/legacy/global-flags.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; +import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { renderGlamourTable } from "../../../output/legacy-glamour-table.ts"; +import { encodeGoJson, encodeToml, encodeYaml } from "../../../shared/legacy-go-output.encoders.ts"; +import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; +import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; import type { LegacyFunctionsListFlags } from "./list.command.ts"; +type Functions = typeof V1ListAllFunctionsOutput.Type; + +class LegacyFunctionsListNetworkError extends Data.TaggedError("LegacyFunctionsListNetworkError")<{ + readonly message: string; +}> {} + +class LegacyFunctionsListUnexpectedStatusError extends Data.TaggedError( + "LegacyFunctionsListUnexpectedStatusError", +)<{ + readonly status: number; + readonly body: string; + readonly message: string; +}> {} + +class LegacyFunctionsEnvNotSupportedError extends Data.TaggedError( + "LegacyFunctionsEnvNotSupportedError", +)<{ + readonly message: string; +}> {} + +const mapListError = mapLegacyHttpError({ + networkError: LegacyFunctionsListNetworkError, + statusError: LegacyFunctionsListUnexpectedStatusError, + networkMessage: (cause) => `failed to list functions: ${cause}`, + statusMessage: (status, body) => `unexpected list functions status ${status}: ${body}`, +}); + +function formatUnixMilliTimestamp(value: number): string { + const date = new Date(value); + const parts = [ + date.getUTCFullYear(), + date.getUTCMonth() + 1, + date.getUTCDate(), + date.getUTCHours(), + date.getUTCMinutes(), + date.getUTCSeconds(), + ]; + const [year, ...rest] = parts.map((part) => part.toString().padStart(2, "0")); + return `${year}-${rest[0]}-${rest[1]} ${rest[2]}:${rest[3]}:${rest[4]}`; +} + +function renderFunctionsTable(functions: Functions): string { + return renderGlamourTable( + ["ID", "NAME", "SLUG", "STATUS", "VERSION", "UPDATED_AT (UTC)"], + functions.map((fn) => [ + fn.id, + fn.name, + fn.slug, + fn.status, + String(fn.version), + formatUnixMilliTimestamp(fn.updated_at), + ]), + ); +} + export const legacyFunctionsList = Effect.fn("legacy.functions.list")(function* ( flags: LegacyFunctionsListFlags, ) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["functions", "list"]; - if (Option.isSome(flags.projectRef)) args.push("--project-ref", flags.projectRef.value); - yield* proxy.exec(args); + const output = yield* Output; + const goOutputFlag = yield* LegacyOutputFlag; + const api = yield* LegacyPlatformApi; + const resolver = yield* LegacyProjectRefResolver; + const linkedProjectCache = yield* LegacyLinkedProjectCache; + const telemetryState = yield* LegacyTelemetryState; + + const ref = yield* resolver.resolve(flags.projectRef); + + yield* Effect.gen(function* () { + const fetching = + output.format === "text" ? yield* output.task("Fetching functions...") : undefined; + const functions: Functions = yield* api.v1.listAllFunctions({ ref }).pipe( + Effect.tapError(() => fetching?.fail() ?? Effect.void), + Effect.catch(mapListError), + ); + yield* fetching?.clear() ?? Effect.void; + + const goFmt = Option.getOrUndefined(goOutputFlag); + + if (goFmt === "env") { + return yield* new LegacyFunctionsEnvNotSupportedError({ + message: "--output env flag is not supported", + }); + } + if (goFmt === "json") { + yield* output.raw(encodeGoJson(functions)); + return; + } + if (goFmt === "yaml") { + yield* output.raw(encodeYaml(functions)); + return; + } + if (goFmt === "toml") { + yield* output.raw(encodeToml({ functions }) + "\n"); + return; + } + + if (output.format === "json" || output.format === "stream-json") { + yield* output.success("", { functions }); + return; + } + + yield* output.raw(renderFunctionsTable(functions)); + }).pipe(Effect.ensuring(linkedProjectCache.cache(ref)), Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/functions/list/list.integration.test.ts b/apps/cli/src/legacy/commands/functions/list/list.integration.test.ts new file mode 100644 index 0000000000..3d93317bfe --- /dev/null +++ b/apps/cli/src/legacy/commands/functions/list/list.integration.test.ts @@ -0,0 +1,264 @@ +import type { V1ListAllFunctionsOutput } from "@supabase/api/effect"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, Option } from "effect"; + +import { + LEGACY_VALID_REF, + buildLegacyTestRuntime, + mockLegacyCliConfig, + mockLegacyLinkedProjectCacheTracked, + mockLegacyPlatformApi, + mockLegacyTelemetryStateTracked, + useLegacyTempWorkdir, +} from "../../../../../tests/helpers/legacy-mocks.ts"; +import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { legacyFunctionsList } from "./list.handler.ts"; + +type Functions = typeof V1ListAllFunctionsOutput.Type; + +const SAMPLE_FUNCTION: Functions[number] = { + id: "11111111-2222-3333-4444-555555555555", + slug: "hello-world", + name: "Hello World", + status: "ACTIVE", + version: 2, + created_at: 1_687_423_025_152, + updated_at: 1_687_423_025_152, + verify_jwt: true, + import_map: false, + entrypoint_path: "functions/hello-world/index.ts", + import_map_path: null, +}; + +const PIPE_FUNCTION: Functions[number] = { + ...SAMPLE_FUNCTION, + name: "Hello|World", + slug: "hello|world", +}; + +const tempRoot = useLegacyTempWorkdir("supabase-functions-list-int-"); + +interface SetupOpts { + readonly format?: "text" | "json" | "stream-json"; + readonly goOutput?: "env" | "pretty" | "json" | "toml" | "yaml"; + readonly response?: Functions; + readonly status?: number; + readonly network?: "fail"; +} + +function setup(opts: SetupOpts = {}) { + const out = mockOutput({ format: opts.format ?? "text" }); + const api = mockLegacyPlatformApi({ + response: { status: opts.status ?? 200, body: opts.response ?? [SAMPLE_FUNCTION] }, + network: opts.network, + }); + const cliConfig = mockLegacyCliConfig({ workdir: tempRoot.current }); + const layer = buildLegacyTestRuntime({ + out, + api, + cliConfig, + goOutput: opts.goOutput === undefined ? Option.none() : Option.some(opts.goOutput), + }); + return { layer, out, api }; +} + +function setupTracked(opts: SetupOpts = {}) { + const out = mockOutput({ format: opts.format ?? "text" }); + const api = mockLegacyPlatformApi({ + response: { status: opts.status ?? 200, body: opts.response ?? [SAMPLE_FUNCTION] }, + network: opts.network, + }); + const cliConfig = mockLegacyCliConfig({ workdir: tempRoot.current }); + const telemetry = mockLegacyTelemetryStateTracked(); + const cache = mockLegacyLinkedProjectCacheTracked(); + const layer = buildLegacyTestRuntime({ + out, + api, + cliConfig, + telemetry: telemetry.layer, + linkedProjectCache: cache.layer, + }); + return { layer, out, api, telemetry, cache }; +} + +describe("legacy functions list integration", () => { + it.live("renders a Glamour table with all 6 columns in text mode", () => { + const { layer, out } = setup(); + return Effect.gen(function* () { + yield* legacyFunctionsList({ projectRef: Option.none() }); + expect(out.stdoutText).toContain("ID"); + expect(out.stdoutText).toContain("NAME"); + expect(out.stdoutText).toContain("SLUG"); + expect(out.stdoutText).toContain("STATUS"); + expect(out.stdoutText).toContain("VERSION"); + expect(out.stdoutText).toContain("UPDATED_AT (UTC)"); + expect(out.stdoutText).toContain("Hello World"); + expect(out.stdoutText).toContain("2023-06-22 08:37:05"); + }).pipe(Effect.provide(layer)); + }); + + it.live("renders literal `|` characters in table cells (Go parity)", () => { + const { layer, out } = setup({ response: [PIPE_FUNCTION] }); + return Effect.gen(function* () { + yield* legacyFunctionsList({ projectRef: Option.none() }); + expect(out.stdoutText).toContain("Hello|World"); + expect(out.stdoutText).toContain("hello|world"); + }).pipe(Effect.provide(layer)); + }); + + it.live("renders an empty table when the API returns []", () => { + const { layer, out } = setup({ response: [] }); + return Effect.gen(function* () { + yield* legacyFunctionsList({ projectRef: Option.none() }); + expect(out.stdoutText).toContain("UPDATED_AT (UTC)"); + expect(out.stdoutText).not.toContain("Hello World"); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a success event with { functions } for --output-format=json", () => { + const { layer, out } = setup({ format: "json" }); + return Effect.gen(function* () { + yield* legacyFunctionsList({ projectRef: Option.none() }); + const success = out.messages.find((message) => message.type === "success"); + expect(success).toBeDefined(); + expect(success?.data).toMatchObject({ functions: [SAMPLE_FUNCTION] }); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a success event for --output-format=stream-json", () => { + const { layer, out } = setup({ format: "stream-json" }); + return Effect.gen(function* () { + yield* legacyFunctionsList({ projectRef: Option.none() }); + expect(out.messages.find((message) => message.type === "success")).toBeDefined(); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits Go-byte-exact indented JSON for --output json", () => { + const { layer, out } = setup({ goOutput: "json" }); + return Effect.gen(function* () { + yield* legacyFunctionsList({ projectRef: Option.none() }); + expect(out.stdoutText.startsWith("[\n {\n")).toBe(true); + expect(out.stdoutText.endsWith("]\n")).toBe(true); + expect(out.stdoutText).toContain('"created_at": 1687423025152'); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a YAML array for --output yaml", () => { + const { layer, out } = setup({ goOutput: "yaml" }); + return Effect.gen(function* () { + yield* legacyFunctionsList({ projectRef: Option.none() }); + expect(out.stdoutText).toContain("name: Hello World"); + expect(out.stdoutText).toContain("slug: hello-world"); + }).pipe(Effect.provide(layer)); + }); + + it.live("wraps the result as { functions = [...] } for --output toml", () => { + const { layer, out } = setup({ goOutput: "toml" }); + return Effect.gen(function* () { + yield* legacyFunctionsList({ projectRef: Option.none() }); + expect(out.stdoutText).toContain("[[functions]]"); + expect(out.stdoutText).toContain('name = "Hello World"'); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with LegacyFunctionsEnvNotSupportedError for --output env", () => { + const { layer } = setup({ goOutput: "env" }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyFunctionsList({ projectRef: Option.none() })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyFunctionsEnvNotSupportedError"); + expect(json).toContain("--output env flag is not supported"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("treats --output pretty as identical to text mode (table render)", () => { + const { layer, out } = setup({ goOutput: "pretty" }); + return Effect.gen(function* () { + yield* legacyFunctionsList({ projectRef: Option.none() }); + expect(out.stdoutText).toContain("Hello World"); + expect(out.stdoutText).toContain("UPDATED_AT (UTC)"); + }).pipe(Effect.provide(layer)); + }); + + it.live("--output flag wins over --output-format", () => { + const { layer, out } = setup({ format: "json", goOutput: "yaml" }); + return Effect.gen(function* () { + yield* legacyFunctionsList({ projectRef: Option.none() }); + expect(out.stdoutText).toContain("name: Hello World"); + expect(out.stdoutText.startsWith("{")).toBe(false); + }).pipe(Effect.provide(layer)); + }); + + it.live("passes the resolved project ref to listAllFunctions", () => { + const { layer, api } = setup(); + return Effect.gen(function* () { + yield* legacyFunctionsList({ projectRef: Option.none() }); + expect(api.requests).toHaveLength(1); + expect(api.requests[0]?.url).toContain(`/v1/projects/${LEGACY_VALID_REF}/functions`); + }).pipe(Effect.provide(layer)); + }); + + it.live("uses --project-ref over the linked project default", () => { + const { layer, api } = setup(); + return Effect.gen(function* () { + yield* legacyFunctionsList({ projectRef: Option.some("qrstuvwxyzabcdefghij") }); + expect(api.requests[0]?.url).toContain("/v1/projects/qrstuvwxyzabcdefghij/functions"); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with LegacyFunctionsListUnexpectedStatusError on HTTP 503", () => { + const { layer } = setup({ status: 503, response: [] }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyFunctionsList({ projectRef: Option.none() })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyFunctionsListUnexpectedStatusError"); + expect(json).toContain("unexpected list functions status 503"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with LegacyFunctionsListNetworkError on transport failure", () => { + const { layer } = setup({ network: "fail" }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyFunctionsList({ projectRef: Option.none() })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyFunctionsListNetworkError"); + expect(json).toContain("failed to list functions"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("writes linked-project cache + telemetry state on success", () => { + const { layer, telemetry, cache } = setupTracked(); + return Effect.gen(function* () { + yield* legacyFunctionsList({ projectRef: Option.none() }); + expect(telemetry.flushed).toBe(true); + expect(cache.cached).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("writes linked-project cache + telemetry state on failure", () => { + const { layer, telemetry, cache } = setupTracked({ status: 503, response: [] }); + return Effect.gen(function* () { + yield* Effect.exit(legacyFunctionsList({ projectRef: Option.none() })); + expect(telemetry.flushed).toBe(true); + expect(cache.cached).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a fail event when withJsonErrorHandling wraps a JSON-mode error", () => { + const { layer, out } = setup({ format: "json", status: 503, response: [] }); + return Effect.gen(function* () { + yield* legacyFunctionsList({ projectRef: Option.none() }).pipe(withJsonErrorHandling); + expect(out.messages.some((message) => message.type === "fail")).toBe(true); + }).pipe(Effect.provide(layer)); + }); +}); From c14b71cbf8729d0e679807e5660a11e3e0e3b535 Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Tue, 23 Jun 2026 00:22:43 +0530 Subject: [PATCH 2/8] fix(cli): match keys --- .../commands/functions/list/SIDE_EFFECTS.md | 7 +-- .../commands/functions/list/list.handler.ts | 49 ++++++++++++++++++- .../functions/list/list.integration.test.ts | 13 +++-- 3 files changed, 61 insertions(+), 8 deletions(-) diff --git a/apps/cli/src/legacy/commands/functions/list/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/functions/list/SIDE_EFFECTS.md index 572ebc01b1..d62db7fb9f 100644 --- a/apps/cli/src/legacy/commands/functions/list/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/functions/list/SIDE_EFFECTS.md @@ -11,9 +11,10 @@ ## Files Written -| Path | Format | When | -| ---- | ------ | ---- | -| - | - | - | +| Path | Format | When | +| ----------------------------------------------- | ------ | ----------------------------------------------------------------------- | +| `./supabase/.temp/linked-project.json` | JSON | after resolving a project ref, cached on both success and failure paths | +| `/telemetry.json` | JSON | after command completion, flushed on both success and failure paths | ## API Routes diff --git a/apps/cli/src/legacy/commands/functions/list/list.handler.ts b/apps/cli/src/legacy/commands/functions/list/list.handler.ts index 71648eed47..53eaee9737 100644 --- a/apps/cli/src/legacy/commands/functions/list/list.handler.ts +++ b/apps/cli/src/legacy/commands/functions/list/list.handler.ts @@ -67,6 +67,51 @@ function renderFunctionsTable(functions: Functions): string { ); } +function toGoYamlFunction(function_: Functions[number]) { + return { + createdat: function_.created_at, + entrypointpath: function_.entrypoint_path, + ezbrsha256: function_.ezbr_sha256 ?? null, + id: function_.id, + importmap: function_.import_map, + importmappath: function_.import_map_path, + name: function_.name, + slug: function_.slug, + status: function_.status, + updatedat: function_.updated_at, + verifyjwt: function_.verify_jwt, + version: function_.version, + }; +} + +function toGoTomlFunction(function_: Functions[number]) { + const goFunction: Record = { + CreatedAt: function_.created_at, + Id: function_.id, + Name: function_.name, + Slug: function_.slug, + Status: function_.status, + UpdatedAt: function_.updated_at, + Version: function_.version, + }; + if (function_.entrypoint_path != null) { + goFunction.EntrypointPath = function_.entrypoint_path; + } + if (function_.ezbr_sha256 != null) { + goFunction.EzbrSha256 = function_.ezbr_sha256; + } + if (function_.import_map != null) { + goFunction.ImportMap = function_.import_map; + } + if (function_.import_map_path != null) { + goFunction.ImportMapPath = function_.import_map_path; + } + if (function_.verify_jwt != null) { + goFunction.VerifyJwt = function_.verify_jwt; + } + return goFunction; +} + export const legacyFunctionsList = Effect.fn("legacy.functions.list")(function* ( flags: LegacyFunctionsListFlags, ) { @@ -100,11 +145,11 @@ export const legacyFunctionsList = Effect.fn("legacy.functions.list")(function* return; } if (goFmt === "yaml") { - yield* output.raw(encodeYaml(functions)); + yield* output.raw(encodeYaml(functions.map(toGoYamlFunction))); return; } if (goFmt === "toml") { - yield* output.raw(encodeToml({ functions }) + "\n"); + yield* output.raw(encodeToml({ functions: functions.map(toGoTomlFunction) }) + "\n"); return; } diff --git a/apps/cli/src/legacy/commands/functions/list/list.integration.test.ts b/apps/cli/src/legacy/commands/functions/list/list.integration.test.ts index 3d93317bfe..f4ab6e032e 100644 --- a/apps/cli/src/legacy/commands/functions/list/list.integration.test.ts +++ b/apps/cli/src/legacy/commands/functions/list/list.integration.test.ts @@ -148,8 +148,11 @@ describe("legacy functions list integration", () => { const { layer, out } = setup({ goOutput: "yaml" }); return Effect.gen(function* () { yield* legacyFunctionsList({ projectRef: Option.none() }); - expect(out.stdoutText).toContain("name: Hello World"); - expect(out.stdoutText).toContain("slug: hello-world"); + expect(out.stdoutText).toContain("createdat: 1687423025152"); + expect(out.stdoutText).toContain("entrypointpath: functions/hello-world/index.ts"); + expect(out.stdoutText).toContain("verifyjwt: true"); + expect(out.stdoutText).not.toContain("created_at:"); + expect(out.stdoutText).not.toContain("entrypoint_path:"); }).pipe(Effect.provide(layer)); }); @@ -158,7 +161,11 @@ describe("legacy functions list integration", () => { return Effect.gen(function* () { yield* legacyFunctionsList({ projectRef: Option.none() }); expect(out.stdoutText).toContain("[[functions]]"); - expect(out.stdoutText).toContain('name = "Hello World"'); + expect(out.stdoutText).toContain("CreatedAt = 1687423025152"); + expect(out.stdoutText).toContain('EntrypointPath = "functions/hello-world/index.ts"'); + expect(out.stdoutText).toContain('Name = "Hello World"'); + expect(out.stdoutText).not.toContain("created_at"); + expect(out.stdoutText).not.toContain("entrypoint_path"); }).pipe(Effect.provide(layer)); }); From 14b882a2736d19af50441b2785f9f24f4693cd7b Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Tue, 23 Jun 2026 00:38:56 +0530 Subject: [PATCH 3/8] fix(cli): preserve nil --- .../commands/functions/list/list.handler.ts | 42 ++++++++++++++++--- .../functions/list/list.integration.test.ts | 32 ++++++++++++++ 2 files changed, 69 insertions(+), 5 deletions(-) diff --git a/apps/cli/src/legacy/commands/functions/list/list.handler.ts b/apps/cli/src/legacy/commands/functions/list/list.handler.ts index 53eaee9737..c069d22777 100644 --- a/apps/cli/src/legacy/commands/functions/list/list.handler.ts +++ b/apps/cli/src/legacy/commands/functions/list/list.handler.ts @@ -70,20 +70,48 @@ function renderFunctionsTable(functions: Functions): string { function toGoYamlFunction(function_: Functions[number]) { return { createdat: function_.created_at, - entrypointpath: function_.entrypoint_path, + entrypointpath: function_.entrypoint_path ?? null, ezbrsha256: function_.ezbr_sha256 ?? null, id: function_.id, - importmap: function_.import_map, - importmappath: function_.import_map_path, + importmap: function_.import_map ?? null, + importmappath: function_.import_map_path ?? null, name: function_.name, slug: function_.slug, status: function_.status, updatedat: function_.updated_at, - verifyjwt: function_.verify_jwt, + verifyjwt: function_.verify_jwt ?? null, version: function_.version, }; } +function toGoJsonFunction(function_: Functions[number]) { + const goFunction: Record = { + created_at: function_.created_at, + id: function_.id, + name: function_.name, + slug: function_.slug, + status: function_.status, + updated_at: function_.updated_at, + version: function_.version, + }; + if (function_.entrypoint_path != null) { + goFunction.entrypoint_path = function_.entrypoint_path; + } + if (function_.ezbr_sha256 != null) { + goFunction.ezbr_sha256 = function_.ezbr_sha256; + } + if (function_.import_map != null) { + goFunction.import_map = function_.import_map; + } + if (function_.import_map_path != null) { + goFunction.import_map_path = function_.import_map_path; + } + if (function_.verify_jwt != null) { + goFunction.verify_jwt = function_.verify_jwt; + } + return goFunction; +} + function toGoTomlFunction(function_: Functions[number]) { const goFunction: Record = { CreatedAt: function_.created_at, @@ -141,7 +169,7 @@ export const legacyFunctionsList = Effect.fn("legacy.functions.list")(function* }); } if (goFmt === "json") { - yield* output.raw(encodeGoJson(functions)); + yield* output.raw(encodeGoJson(functions.map(toGoJsonFunction))); return; } if (goFmt === "yaml") { @@ -152,6 +180,10 @@ export const legacyFunctionsList = Effect.fn("legacy.functions.list")(function* yield* output.raw(encodeToml({ functions: functions.map(toGoTomlFunction) }) + "\n"); return; } + if (goFmt === "pretty") { + yield* output.raw(renderFunctionsTable(functions)); + return; + } if (output.format === "json" || output.format === "stream-json") { yield* output.success("", { functions }); diff --git a/apps/cli/src/legacy/commands/functions/list/list.integration.test.ts b/apps/cli/src/legacy/commands/functions/list/list.integration.test.ts index f4ab6e032e..36e7dc0e26 100644 --- a/apps/cli/src/legacy/commands/functions/list/list.integration.test.ts +++ b/apps/cli/src/legacy/commands/functions/list/list.integration.test.ts @@ -37,6 +37,15 @@ const PIPE_FUNCTION: Functions[number] = { slug: "hello|world", }; +const NIL_OPTIONALS_FUNCTION: Functions[number] = { + ...SAMPLE_FUNCTION, + entrypoint_path: undefined, + ezbr_sha256: undefined, + import_map: undefined, + import_map_path: null, + verify_jwt: undefined, +}; + const tempRoot = useLegacyTempWorkdir("supabase-functions-list-int-"); interface SetupOpts { @@ -141,6 +150,7 @@ describe("legacy functions list integration", () => { expect(out.stdoutText.startsWith("[\n {\n")).toBe(true); expect(out.stdoutText.endsWith("]\n")).toBe(true); expect(out.stdoutText).toContain('"created_at": 1687423025152'); + expect(out.stdoutText).not.toContain('"import_map_path": null'); }).pipe(Effect.provide(layer)); }); @@ -156,6 +166,18 @@ describe("legacy functions list integration", () => { }).pipe(Effect.provide(layer)); }); + it.live("preserves nil optional fields as null in YAML output", () => { + const { layer, out } = setup({ goOutput: "yaml", response: [NIL_OPTIONALS_FUNCTION] }); + return Effect.gen(function* () { + yield* legacyFunctionsList({ projectRef: Option.none() }); + expect(out.stdoutText).toContain("entrypointpath: null"); + expect(out.stdoutText).toContain("ezbrsha256: null"); + expect(out.stdoutText).toContain("importmap: null"); + expect(out.stdoutText).toContain("importmappath: null"); + expect(out.stdoutText).toContain("verifyjwt: null"); + }).pipe(Effect.provide(layer)); + }); + it.live("wraps the result as { functions = [...] } for --output toml", () => { const { layer, out } = setup({ goOutput: "toml" }); return Effect.gen(function* () { @@ -191,6 +213,16 @@ describe("legacy functions list integration", () => { }).pipe(Effect.provide(layer)); }); + it.live("lets --output pretty win over --output-format json", () => { + const { layer, out } = setup({ format: "json", goOutput: "pretty" }); + return Effect.gen(function* () { + yield* legacyFunctionsList({ projectRef: Option.none() }); + expect(out.stdoutText).toContain("Hello World"); + expect(out.stdoutText).toContain("UPDATED_AT (UTC)"); + expect(out.messages.find((message) => message.type === "success")).toBeUndefined(); + }).pipe(Effect.provide(layer)); + }); + it.live("--output flag wins over --output-format", () => { const { layer, out } = setup({ format: "json", goOutput: "yaml" }); return Effect.gen(function* () { From 1992e9a3b3326dadc68d998f7710456ab300b7c5 Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Tue, 23 Jun 2026 00:58:21 +0530 Subject: [PATCH 4/8] nit --- .../commands/functions/list/list.handler.ts | 157 +++++++++++++----- .../functions/list/list.integration.test.ts | 23 ++- 2 files changed, 138 insertions(+), 42 deletions(-) diff --git a/apps/cli/src/legacy/commands/functions/list/list.handler.ts b/apps/cli/src/legacy/commands/functions/list/list.handler.ts index c069d22777..811b0ed5c5 100644 --- a/apps/cli/src/legacy/commands/functions/list/list.handler.ts +++ b/apps/cli/src/legacy/commands/functions/list/list.handler.ts @@ -1,4 +1,4 @@ -import type { V1ListAllFunctionsOutput } from "@supabase/api/effect"; +import { operationDefinitions } from "@supabase/api/effect"; import { Data, Effect, Option } from "effect"; import { LegacyOutputFlag } from "../../../../shared/legacy/global-flags.ts"; @@ -7,12 +7,27 @@ import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts" import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; import { renderGlamourTable } from "../../../output/legacy-glamour-table.ts"; import { encodeGoJson, encodeToml, encodeYaml } from "../../../shared/legacy-go-output.encoders.ts"; -import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; +import { mapLegacyHttpError, sanitizeLegacyErrorBody } from "../../../shared/legacy-http-errors.ts"; import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; import type { LegacyFunctionsListFlags } from "./list.command.ts"; -type Functions = typeof V1ListAllFunctionsOutput.Type; +interface LegacyFunctionRecord { + readonly id: string; + readonly slug: string; + readonly name: string; + readonly status: string; + readonly version: number; + readonly created_at: number; + readonly updated_at: number; + readonly verify_jwt?: boolean; + readonly import_map?: boolean; + readonly entrypoint_path?: string; + readonly import_map_path?: string | null; + readonly ezbr_sha256?: string; +} + +type Functions = ReadonlyArray; class LegacyFunctionsListNetworkError extends Data.TaggedError("LegacyFunctionsListNetworkError")<{ readonly message: string; @@ -67,6 +82,70 @@ function renderFunctionsTable(functions: Functions): string { ); } +function readOptionalBoolean(record: Record, key: string): boolean | undefined { + const value = record[key]; + return typeof value === "boolean" ? value : undefined; +} + +function readOptionalString(record: Record, key: string): string | undefined { + const value = record[key]; + return typeof value === "string" ? value : undefined; +} + +function readOptionalNullableString( + record: Record, + key: string, +): string | null | undefined { + const value = record[key]; + return value === null || typeof value === "string" ? value : undefined; +} + +function parseFunctionsResponse(value: unknown): Functions | undefined { + if (!Array.isArray(value)) { + return undefined; + } + const functions: LegacyFunctionRecord[] = []; + for (const item of value) { + if (typeof item !== "object" || item === null) { + return undefined; + } + const record = item as Record; + const id = record.id; + const slug = record.slug; + const name = record.name; + const status = record.status; + const version = record.version; + const createdAt = record.created_at; + const updatedAt = record.updated_at; + if ( + typeof id !== "string" || + typeof slug !== "string" || + typeof name !== "string" || + typeof status !== "string" || + typeof version !== "number" || + typeof createdAt !== "number" || + typeof updatedAt !== "number" + ) { + return undefined; + } + functions.push({ + id, + slug, + name, + status, + version, + created_at: createdAt, + updated_at: updatedAt, + verify_jwt: readOptionalBoolean(record, "verify_jwt"), + import_map: readOptionalBoolean(record, "import_map"), + entrypoint_path: readOptionalString(record, "entrypoint_path"), + import_map_path: readOptionalNullableString(record, "import_map_path"), + ezbr_sha256: readOptionalString(record, "ezbr_sha256"), + }); + } + return functions; +} + function toGoYamlFunction(function_: Functions[number]) { return { createdat: function_.created_at, @@ -85,7 +164,7 @@ function toGoYamlFunction(function_: Functions[number]) { } function toGoJsonFunction(function_: Functions[number]) { - const goFunction: Record = { + return { created_at: function_.created_at, id: function_.id, name: function_.name, @@ -93,51 +172,29 @@ function toGoJsonFunction(function_: Functions[number]) { status: function_.status, updated_at: function_.updated_at, version: function_.version, + ...(function_.entrypoint_path != null ? { entrypoint_path: function_.entrypoint_path } : {}), + ...(function_.ezbr_sha256 != null ? { ezbr_sha256: function_.ezbr_sha256 } : {}), + ...(function_.import_map != null ? { import_map: function_.import_map } : {}), + ...(function_.import_map_path != null ? { import_map_path: function_.import_map_path } : {}), + ...(function_.verify_jwt != null ? { verify_jwt: function_.verify_jwt } : {}), }; - if (function_.entrypoint_path != null) { - goFunction.entrypoint_path = function_.entrypoint_path; - } - if (function_.ezbr_sha256 != null) { - goFunction.ezbr_sha256 = function_.ezbr_sha256; - } - if (function_.import_map != null) { - goFunction.import_map = function_.import_map; - } - if (function_.import_map_path != null) { - goFunction.import_map_path = function_.import_map_path; - } - if (function_.verify_jwt != null) { - goFunction.verify_jwt = function_.verify_jwt; - } - return goFunction; } function toGoTomlFunction(function_: Functions[number]) { - const goFunction: Record = { + return { CreatedAt: function_.created_at, + ...(function_.entrypoint_path != null ? { EntrypointPath: function_.entrypoint_path } : {}), + ...(function_.ezbr_sha256 != null ? { EzbrSha256: function_.ezbr_sha256 } : {}), Id: function_.id, + ...(function_.import_map != null ? { ImportMap: function_.import_map } : {}), + ...(function_.import_map_path != null ? { ImportMapPath: function_.import_map_path } : {}), Name: function_.name, Slug: function_.slug, Status: function_.status, UpdatedAt: function_.updated_at, + ...(function_.verify_jwt != null ? { VerifyJwt: function_.verify_jwt } : {}), Version: function_.version, }; - if (function_.entrypoint_path != null) { - goFunction.EntrypointPath = function_.entrypoint_path; - } - if (function_.ezbr_sha256 != null) { - goFunction.EzbrSha256 = function_.ezbr_sha256; - } - if (function_.import_map != null) { - goFunction.ImportMap = function_.import_map; - } - if (function_.import_map_path != null) { - goFunction.ImportMapPath = function_.import_map_path; - } - if (function_.verify_jwt != null) { - goFunction.VerifyJwt = function_.verify_jwt; - } - return goFunction; } export const legacyFunctionsList = Effect.fn("legacy.functions.list")(function* ( @@ -155,10 +212,34 @@ export const legacyFunctionsList = Effect.fn("legacy.functions.list")(function* yield* Effect.gen(function* () { const fetching = output.format === "text" ? yield* output.task("Fetching functions...") : undefined; - const functions: Functions = yield* api.v1.listAllFunctions({ ref }).pipe( + const response = yield* api.executeRaw(operationDefinitions.v1ListAllFunctions, { ref }).pipe( Effect.tapError(() => fetching?.fail() ?? Effect.void), Effect.catch(mapListError), ); + if (response.status !== 200) { + const body = sanitizeLegacyErrorBody( + yield* response.text.pipe(Effect.orElseSucceed(() => "")), + ); + yield* fetching?.fail() ?? Effect.void; + return yield* new LegacyFunctionsListUnexpectedStatusError({ + status: response.status, + body, + message: `unexpected list functions status ${response.status}: ${body}`, + }); + } + const parsed = yield* response.json.pipe( + Effect.tapError(() => fetching?.fail() ?? Effect.void), + Effect.orElseSucceed(() => undefined), + ); + const functions = parseFunctionsResponse(parsed); + if (functions === undefined) { + yield* fetching?.fail() ?? Effect.void; + return yield* new LegacyFunctionsListUnexpectedStatusError({ + status: response.status, + body: "", + message: "unexpected list functions status 200: failed to decode response body", + }); + } yield* fetching?.clear() ?? Effect.void; const goFmt = Option.getOrUndefined(goOutputFlag); diff --git a/apps/cli/src/legacy/commands/functions/list/list.integration.test.ts b/apps/cli/src/legacy/commands/functions/list/list.integration.test.ts index 36e7dc0e26..0ec11cd6f4 100644 --- a/apps/cli/src/legacy/commands/functions/list/list.integration.test.ts +++ b/apps/cli/src/legacy/commands/functions/list/list.integration.test.ts @@ -37,6 +37,11 @@ const PIPE_FUNCTION: Functions[number] = { slug: "hello|world", }; +const UNKNOWN_STATUS_FUNCTION: Functions[number] = { + ...SAMPLE_FUNCTION, + status: "PAUSED_FOR_REBALANCE", +}; + const NIL_OPTIONALS_FUNCTION: Functions[number] = { ...SAMPLE_FUNCTION, entrypoint_path: undefined, @@ -182,10 +187,12 @@ describe("legacy functions list integration", () => { const { layer, out } = setup({ goOutput: "toml" }); return Effect.gen(function* () { yield* legacyFunctionsList({ projectRef: Option.none() }); - expect(out.stdoutText).toContain("[[functions]]"); - expect(out.stdoutText).toContain("CreatedAt = 1687423025152"); - expect(out.stdoutText).toContain('EntrypointPath = "functions/hello-world/index.ts"'); - expect(out.stdoutText).toContain('Name = "Hello World"'); + expect(out.stdoutText).toContain(`[[functions]] +CreatedAt = 1687423025152 +EntrypointPath = "functions/hello-world/index.ts" +Id = "11111111-2222-3333-4444-555555555555" +ImportMap = false +Name = "Hello World"`); expect(out.stdoutText).not.toContain("created_at"); expect(out.stdoutText).not.toContain("entrypoint_path"); }).pipe(Effect.provide(layer)); @@ -241,6 +248,14 @@ describe("legacy functions list integration", () => { }).pipe(Effect.provide(layer)); }); + it.live("accepts unknown future function status strings", () => { + const { layer, out } = setup({ response: [UNKNOWN_STATUS_FUNCTION] }); + return Effect.gen(function* () { + yield* legacyFunctionsList({ projectRef: Option.none() }); + expect(out.stdoutText).toContain("PAUSED_FOR_REBALANCE"); + }).pipe(Effect.provide(layer)); + }); + it.live("uses --project-ref over the linked project default", () => { const { layer, api } = setup(); return Effect.gen(function* () { From dee8f548a3deb68b108c18335b795e9c05af6454 Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Tue, 23 Jun 2026 01:04:57 +0530 Subject: [PATCH 5/8] fix: types --- .../legacy/commands/functions/list/list.integration.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/cli/src/legacy/commands/functions/list/list.integration.test.ts b/apps/cli/src/legacy/commands/functions/list/list.integration.test.ts index 0ec11cd6f4..d1c317799c 100644 --- a/apps/cli/src/legacy/commands/functions/list/list.integration.test.ts +++ b/apps/cli/src/legacy/commands/functions/list/list.integration.test.ts @@ -37,7 +37,7 @@ const PIPE_FUNCTION: Functions[number] = { slug: "hello|world", }; -const UNKNOWN_STATUS_FUNCTION: Functions[number] = { +const UNKNOWN_STATUS_FUNCTION = { ...SAMPLE_FUNCTION, status: "PAUSED_FOR_REBALANCE", }; @@ -56,7 +56,7 @@ const tempRoot = useLegacyTempWorkdir("supabase-functions-list-int-"); interface SetupOpts { readonly format?: "text" | "json" | "stream-json"; readonly goOutput?: "env" | "pretty" | "json" | "toml" | "yaml"; - readonly response?: Functions; + readonly response?: unknown; readonly status?: number; readonly network?: "fail"; } From d02f1b7d9638f5953031d7cf5e0f45c8ac2544cd Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Tue, 23 Jun 2026 01:13:47 +0530 Subject: [PATCH 6/8] smol refactor --- .../commands/functions/list/list.handler.ts | 140 ++++++++++++------ 1 file changed, 94 insertions(+), 46 deletions(-) diff --git a/apps/cli/src/legacy/commands/functions/list/list.handler.ts b/apps/cli/src/legacy/commands/functions/list/list.handler.ts index 811b0ed5c5..af54e6f080 100644 --- a/apps/cli/src/legacy/commands/functions/list/list.handler.ts +++ b/apps/cli/src/legacy/commands/functions/list/list.handler.ts @@ -100,6 +100,53 @@ function readOptionalNullableString( return value === null || typeof value === "string" ? value : undefined; } +function readRequiredString(record: Record, key: string): string | undefined { + const value = record[key]; + return typeof value === "string" ? value : undefined; +} + +function readRequiredNumber(record: Record, key: string): number | undefined { + const value = record[key]; + return typeof value === "number" ? value : undefined; +} + +function readRequiredFunctionFields( + record: Record, +): + | Omit< + LegacyFunctionRecord, + "verify_jwt" | "import_map" | "entrypoint_path" | "import_map_path" | "ezbr_sha256" + > + | undefined { + const id = readRequiredString(record, "id"); + const slug = readRequiredString(record, "slug"); + const name = readRequiredString(record, "name"); + const status = readRequiredString(record, "status"); + const version = readRequiredNumber(record, "version"); + const createdAt = readRequiredNumber(record, "created_at"); + const updatedAt = readRequiredNumber(record, "updated_at"); + if ( + id === undefined || + slug === undefined || + name === undefined || + status === undefined || + version === undefined || + createdAt === undefined || + updatedAt === undefined + ) { + return undefined; + } + return { + id, + slug, + name, + status, + version, + created_at: createdAt, + updated_at: updatedAt, + }; +} + function parseFunctionsResponse(value: unknown): Functions | undefined { if (!Array.isArray(value)) { return undefined; @@ -110,32 +157,12 @@ function parseFunctionsResponse(value: unknown): Functions | undefined { return undefined; } const record = item as Record; - const id = record.id; - const slug = record.slug; - const name = record.name; - const status = record.status; - const version = record.version; - const createdAt = record.created_at; - const updatedAt = record.updated_at; - if ( - typeof id !== "string" || - typeof slug !== "string" || - typeof name !== "string" || - typeof status !== "string" || - typeof version !== "number" || - typeof createdAt !== "number" || - typeof updatedAt !== "number" - ) { + const required = readRequiredFunctionFields(record); + if (required === undefined) { return undefined; } functions.push({ - id, - slug, - name, - status, - version, - created_at: createdAt, - updated_at: updatedAt, + ...required, verify_jwt: readOptionalBoolean(record, "verify_jwt"), import_map: readOptionalBoolean(record, "import_map"), entrypoint_path: readOptionalString(record, "entrypoint_path"), @@ -146,32 +173,20 @@ function parseFunctionsResponse(value: unknown): Functions | undefined { return functions; } -function toGoYamlFunction(function_: Functions[number]) { +function baseFunctionFields(function_: Functions[number]) { return { - createdat: function_.created_at, - entrypointpath: function_.entrypoint_path ?? null, - ezbrsha256: function_.ezbr_sha256 ?? null, id: function_.id, - importmap: function_.import_map ?? null, - importmappath: function_.import_map_path ?? null, name: function_.name, slug: function_.slug, status: function_.status, - updatedat: function_.updated_at, - verifyjwt: function_.verify_jwt ?? null, version: function_.version, + created_at: function_.created_at, + updated_at: function_.updated_at, }; } -function toGoJsonFunction(function_: Functions[number]) { +function optionalGoJsonFields(function_: Functions[number]) { return { - created_at: function_.created_at, - id: function_.id, - name: function_.name, - slug: function_.slug, - status: function_.status, - updated_at: function_.updated_at, - version: function_.version, ...(function_.entrypoint_path != null ? { entrypoint_path: function_.entrypoint_path } : {}), ...(function_.ezbr_sha256 != null ? { ezbr_sha256: function_.ezbr_sha256 } : {}), ...(function_.import_map != null ? { import_map: function_.import_map } : {}), @@ -180,20 +195,53 @@ function toGoJsonFunction(function_: Functions[number]) { }; } +function toGoYamlFunction(function_: Functions[number]) { + const base = baseFunctionFields(function_); + return { + createdat: base.created_at, + entrypointpath: function_.entrypoint_path ?? null, + ezbrsha256: function_.ezbr_sha256 ?? null, + id: base.id, + importmap: function_.import_map ?? null, + importmappath: function_.import_map_path ?? null, + name: base.name, + slug: base.slug, + status: base.status, + updatedat: base.updated_at, + verifyjwt: function_.verify_jwt ?? null, + version: base.version, + }; +} + +function toGoJsonFunction(function_: Functions[number]) { + const base = baseFunctionFields(function_); + return { + created_at: base.created_at, + id: base.id, + name: base.name, + slug: base.slug, + status: base.status, + updated_at: base.updated_at, + version: base.version, + ...optionalGoJsonFields(function_), + }; +} + function toGoTomlFunction(function_: Functions[number]) { + const base = baseFunctionFields(function_); return { - CreatedAt: function_.created_at, + CreatedAt: base.created_at, ...(function_.entrypoint_path != null ? { EntrypointPath: function_.entrypoint_path } : {}), ...(function_.ezbr_sha256 != null ? { EzbrSha256: function_.ezbr_sha256 } : {}), - Id: function_.id, + Id: base.id, ...(function_.import_map != null ? { ImportMap: function_.import_map } : {}), ...(function_.import_map_path != null ? { ImportMapPath: function_.import_map_path } : {}), - Name: function_.name, - Slug: function_.slug, - Status: function_.status, - UpdatedAt: function_.updated_at, + Name: base.name, + Slug: base.slug, + Status: base.status, + UpdatedAt: base.updated_at, ...(function_.verify_jwt != null ? { VerifyJwt: function_.verify_jwt } : {}), - Version: function_.version, + Version: base.version, }; } From c7a4460bd1402d85e3bc07e8bb5cc99e66c8fdc9 Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Tue, 23 Jun 2026 01:23:36 +0530 Subject: [PATCH 7/8] fix(cli): escape strings --- .../commands/functions/list/SIDE_EFFECTS.md | 1 + .../commands/functions/list/list.handler.ts | 47 ++++++++++++++----- .../functions/list/list.integration.test.ts | 41 ++++++++++++++++ 3 files changed, 77 insertions(+), 12 deletions(-) diff --git a/apps/cli/src/legacy/commands/functions/list/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/functions/list/SIDE_EFFECTS.md index d62db7fb9f..4b1198764c 100644 --- a/apps/cli/src/legacy/commands/functions/list/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/functions/list/SIDE_EFFECTS.md @@ -21,6 +21,7 @@ | Method | Path | Auth | Request body | Response (used fields) | | ------ | ------------------------------ | ------------ | ------------ | ------------------------------------------------------ | | `GET` | `/v1/projects/{ref}/functions` | Bearer token | none | `[{id, name, slug, status, version, updated_at, ...}]` | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | linked project metadata used by the post-run cache | ## Environment Variables diff --git a/apps/cli/src/legacy/commands/functions/list/list.handler.ts b/apps/cli/src/legacy/commands/functions/list/list.handler.ts index af54e6f080..a0ea118dad 100644 --- a/apps/cli/src/legacy/commands/functions/list/list.handler.ts +++ b/apps/cli/src/legacy/commands/functions/list/list.handler.ts @@ -173,6 +173,32 @@ function parseFunctionsResponse(value: unknown): Functions | undefined { return functions; } +function decodeFunctionsResponse( + rawBody: string, +): Effect.Effect { + return Effect.gen(function* () { + const parsed = yield* Effect.try({ + try: () => JSON.parse(rawBody) as unknown, + catch: (cause) => + new LegacyFunctionsListNetworkError({ + message: `failed to list functions: ${String(cause)}`, + }), + }); + const functions = parseFunctionsResponse(parsed); + if (functions === undefined) { + return yield* new LegacyFunctionsListNetworkError({ + message: + "failed to list functions: response body did not match the expected function array shape", + }); + } + return functions; + }); +} + +function escapeGoJsonHtmlChars(text: string): string { + return text.replaceAll("<", "\\u003c").replaceAll(">", "\\u003e").replaceAll("&", "\\u0026"); +} + function baseFunctionFields(function_: Functions[number]) { return { id: function_.id, @@ -275,19 +301,16 @@ export const legacyFunctionsList = Effect.fn("legacy.functions.list")(function* message: `unexpected list functions status ${response.status}: ${body}`, }); } - const parsed = yield* response.json.pipe( + const rawBody = yield* response.text.pipe( + Effect.tapError(() => fetching?.fail() ?? Effect.void), + Effect.catch( + (cause) => + new LegacyFunctionsListNetworkError({ message: `failed to list functions: ${cause}` }), + ), + ); + const functions = yield* decodeFunctionsResponse(rawBody).pipe( Effect.tapError(() => fetching?.fail() ?? Effect.void), - Effect.orElseSucceed(() => undefined), ); - const functions = parseFunctionsResponse(parsed); - if (functions === undefined) { - yield* fetching?.fail() ?? Effect.void; - return yield* new LegacyFunctionsListUnexpectedStatusError({ - status: response.status, - body: "", - message: "unexpected list functions status 200: failed to decode response body", - }); - } yield* fetching?.clear() ?? Effect.void; const goFmt = Option.getOrUndefined(goOutputFlag); @@ -298,7 +321,7 @@ export const legacyFunctionsList = Effect.fn("legacy.functions.list")(function* }); } if (goFmt === "json") { - yield* output.raw(encodeGoJson(functions.map(toGoJsonFunction))); + yield* output.raw(escapeGoJsonHtmlChars(encodeGoJson(functions.map(toGoJsonFunction)))); return; } if (goFmt === "yaml") { diff --git a/apps/cli/src/legacy/commands/functions/list/list.integration.test.ts b/apps/cli/src/legacy/commands/functions/list/list.integration.test.ts index d1c317799c..b8f7def7c1 100644 --- a/apps/cli/src/legacy/commands/functions/list/list.integration.test.ts +++ b/apps/cli/src/legacy/commands/functions/list/list.integration.test.ts @@ -1,6 +1,7 @@ import type { V1ListAllFunctionsOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; import { Effect, Exit, Option } from "effect"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import { LEGACY_VALID_REF, @@ -37,6 +38,11 @@ const PIPE_FUNCTION: Functions[number] = { slug: "hello|world", }; +const HTML_FUNCTION: Functions[number] = { + ...SAMPLE_FUNCTION, + name: "&World>", +}; + const UNKNOWN_STATUS_FUNCTION = { ...SAMPLE_FUNCTION, status: "PAUSED_FOR_REBALANCE", @@ -159,6 +165,14 @@ describe("legacy functions list integration", () => { }).pipe(Effect.provide(layer)); }); + it.live("escapes html-sensitive characters in Go JSON output", () => { + const { layer, out } = setup({ goOutput: "json", response: [HTML_FUNCTION] }); + return Effect.gen(function* () { + yield* legacyFunctionsList({ projectRef: Option.none() }); + expect(out.stdoutText).toContain('"name": "\\u003cHello\\u003e\\u0026World\\u003e"'); + }).pipe(Effect.provide(layer)); + }); + it.live("emits a YAML array for --output yaml", () => { const { layer, out } = setup({ goOutput: "yaml" }); return Effect.gen(function* () { @@ -290,6 +304,33 @@ Name = "Hello World"`); }).pipe(Effect.provide(layer)); }); + it.live("surfaces malformed 200 JSON bodies as failed to list functions", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ + handler: (request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response("{", { + status: 200, + headers: { "content-type": "application/json" }, + }), + ), + ), + }); + const cliConfig = mockLegacyCliConfig({ workdir: tempRoot.current }); + const layer = buildLegacyTestRuntime({ out, api, cliConfig }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyFunctionsList({ projectRef: Option.none() })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyFunctionsListNetworkError"); + expect(json).toContain("failed to list functions:"); + } + }).pipe(Effect.provide(layer)); + }); + it.live("writes linked-project cache + telemetry state on success", () => { const { layer, telemetry, cache } = setupTracked(); return Effect.gen(function* () { From b99d43d2853c70138b94804ebe74dce327798a47 Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Tue, 23 Jun 2026 02:11:13 +0530 Subject: [PATCH 8/8] nitpicks --- .../commands/functions/list/SIDE_EFFECTS.md | 1 + .../commands/functions/list/list.handler.ts | 98 ++++++++++++------- .../functions/list/list.integration.test.ts | 47 +++++++++ 3 files changed, 113 insertions(+), 33 deletions(-) diff --git a/apps/cli/src/legacy/commands/functions/list/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/functions/list/SIDE_EFFECTS.md index 4b1198764c..195eb7328b 100644 --- a/apps/cli/src/legacy/commands/functions/list/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/functions/list/SIDE_EFFECTS.md @@ -21,6 +21,7 @@ | Method | Path | Auth | Request body | Response (used fields) | | ------ | ------------------------------ | ------------ | ------------ | ------------------------------------------------------ | | `GET` | `/v1/projects/{ref}/functions` | Bearer token | none | `[{id, name, slug, status, version, updated_at, ...}]` | +| `GET` | `/v1/projects` | Bearer token | none | project picker options when no ref is supplied in TTY | | `GET` | `/v1/projects/{ref}` | Bearer token | none | linked project metadata used by the post-run cache | ## Environment Variables diff --git a/apps/cli/src/legacy/commands/functions/list/list.handler.ts b/apps/cli/src/legacy/commands/functions/list/list.handler.ts index a0ea118dad..ea06a775ec 100644 --- a/apps/cli/src/legacy/commands/functions/list/list.handler.ts +++ b/apps/cli/src/legacy/commands/functions/list/list.handler.ts @@ -29,6 +29,9 @@ interface LegacyFunctionRecord { type Functions = ReadonlyArray; +const INVALID_FIELD = Symbol("invalid function field"); +type InvalidField = typeof INVALID_FIELD; + class LegacyFunctionsListNetworkError extends Data.TaggedError("LegacyFunctionsListNetworkError")<{ readonly message: string; }> {} @@ -82,32 +85,47 @@ function renderFunctionsTable(functions: Functions): string { ); } -function readOptionalBoolean(record: Record, key: string): boolean | undefined { +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function readOptionalBoolean( + record: Record, + key: string, +): boolean | undefined | InvalidField { const value = record[key]; - return typeof value === "boolean" ? value : undefined; + if (value === undefined || value === null) return undefined; + return typeof value === "boolean" ? value : INVALID_FIELD; } -function readOptionalString(record: Record, key: string): string | undefined { +function readOptionalString( + record: Record, + key: string, +): string | undefined | InvalidField { const value = record[key]; - return typeof value === "string" ? value : undefined; + if (value === undefined || value === null) return undefined; + return typeof value === "string" ? value : INVALID_FIELD; } function readOptionalNullableString( record: Record, key: string, -): string | null | undefined { +): string | null | undefined | InvalidField { const value = record[key]; - return value === null || typeof value === "string" ? value : undefined; + if (value === undefined) return undefined; + return value === null || typeof value === "string" ? value : INVALID_FIELD; } -function readRequiredString(record: Record, key: string): string | undefined { +function readGoString(record: Record, key: string): string | InvalidField { const value = record[key]; - return typeof value === "string" ? value : undefined; + if (value === undefined || value === null) return ""; + return typeof value === "string" ? value : INVALID_FIELD; } -function readRequiredNumber(record: Record, key: string): number | undefined { +function readGoInteger(record: Record, key: string): number | InvalidField { const value = record[key]; - return typeof value === "number" ? value : undefined; + if (value === undefined || value === null) return 0; + return typeof value === "number" && Number.isSafeInteger(value) ? value : INVALID_FIELD; } function readRequiredFunctionFields( @@ -118,21 +136,21 @@ function readRequiredFunctionFields( "verify_jwt" | "import_map" | "entrypoint_path" | "import_map_path" | "ezbr_sha256" > | undefined { - const id = readRequiredString(record, "id"); - const slug = readRequiredString(record, "slug"); - const name = readRequiredString(record, "name"); - const status = readRequiredString(record, "status"); - const version = readRequiredNumber(record, "version"); - const createdAt = readRequiredNumber(record, "created_at"); - const updatedAt = readRequiredNumber(record, "updated_at"); + const id = readGoString(record, "id"); + const slug = readGoString(record, "slug"); + const name = readGoString(record, "name"); + const status = readGoString(record, "status"); + const version = readGoInteger(record, "version"); + const createdAt = readGoInteger(record, "created_at"); + const updatedAt = readGoInteger(record, "updated_at"); if ( - id === undefined || - slug === undefined || - name === undefined || - status === undefined || - version === undefined || - createdAt === undefined || - updatedAt === undefined + id === INVALID_FIELD || + slug === INVALID_FIELD || + name === INVALID_FIELD || + status === INVALID_FIELD || + version === INVALID_FIELD || + createdAt === INVALID_FIELD || + updatedAt === INVALID_FIELD ) { return undefined; } @@ -153,21 +171,34 @@ function parseFunctionsResponse(value: unknown): Functions | undefined { } const functions: LegacyFunctionRecord[] = []; for (const item of value) { - if (typeof item !== "object" || item === null) { + if (!isRecord(item)) { return undefined; } - const record = item as Record; - const required = readRequiredFunctionFields(record); + const required = readRequiredFunctionFields(item); if (required === undefined) { return undefined; } + const verifyJwt = readOptionalBoolean(item, "verify_jwt"); + const importMap = readOptionalBoolean(item, "import_map"); + const entrypointPath = readOptionalString(item, "entrypoint_path"); + const importMapPath = readOptionalNullableString(item, "import_map_path"); + const ezbrSha256 = readOptionalString(item, "ezbr_sha256"); + if ( + verifyJwt === INVALID_FIELD || + importMap === INVALID_FIELD || + entrypointPath === INVALID_FIELD || + importMapPath === INVALID_FIELD || + ezbrSha256 === INVALID_FIELD + ) { + return undefined; + } functions.push({ ...required, - verify_jwt: readOptionalBoolean(record, "verify_jwt"), - import_map: readOptionalBoolean(record, "import_map"), - entrypoint_path: readOptionalString(record, "entrypoint_path"), - import_map_path: readOptionalNullableString(record, "import_map_path"), - ezbr_sha256: readOptionalString(record, "ezbr_sha256"), + verify_jwt: verifyJwt, + import_map: importMap, + entrypoint_path: entrypointPath, + import_map_path: importMapPath, + ezbr_sha256: ezbrSha256, }); } return functions; @@ -177,8 +208,9 @@ function decodeFunctionsResponse( rawBody: string, ): Effect.Effect { return Effect.gen(function* () { + const parse = (): unknown => JSON.parse(rawBody); const parsed = yield* Effect.try({ - try: () => JSON.parse(rawBody) as unknown, + try: parse, catch: (cause) => new LegacyFunctionsListNetworkError({ message: `failed to list functions: ${String(cause)}`, diff --git a/apps/cli/src/legacy/commands/functions/list/list.integration.test.ts b/apps/cli/src/legacy/commands/functions/list/list.integration.test.ts index b8f7def7c1..b8e9052037 100644 --- a/apps/cli/src/legacy/commands/functions/list/list.integration.test.ts +++ b/apps/cli/src/legacy/commands/functions/list/list.integration.test.ts @@ -43,6 +43,16 @@ const HTML_FUNCTION: Functions[number] = { name: "&World>", }; +const INVALID_OPTIONAL_FUNCTION = { + ...SAMPLE_FUNCTION, + verify_jwt: "true", +}; + +const NON_INTEGER_FUNCTION = { + ...SAMPLE_FUNCTION, + version: 1.5, +}; + const UNKNOWN_STATUS_FUNCTION = { ...SAMPLE_FUNCTION, status: "PAUSED_FOR_REBALANCE", @@ -173,6 +183,17 @@ describe("legacy functions list integration", () => { }).pipe(Effect.provide(layer)); }); + it.live("preserves Go zero values for omitted non-pointer fields", () => { + const { layer, out } = setup({ goOutput: "json", response: [{}] }); + return Effect.gen(function* () { + yield* legacyFunctionsList({ projectRef: Option.none() }); + expect(out.stdoutText).toContain('"created_at": 0'); + expect(out.stdoutText).toContain('"id": ""'); + expect(out.stdoutText).toContain('"status": ""'); + expect(out.stdoutText).toContain('"version": 0'); + }).pipe(Effect.provide(layer)); + }); + it.live("emits a YAML array for --output yaml", () => { const { layer, out } = setup({ goOutput: "yaml" }); return Effect.gen(function* () { @@ -331,6 +352,32 @@ Name = "Hello World"`); }).pipe(Effect.provide(layer)); }); + it.live("fails on invalid optional field types", () => { + const { layer } = setup({ response: [INVALID_OPTIONAL_FUNCTION] }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyFunctionsList({ projectRef: Option.none() })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyFunctionsListNetworkError"); + expect(json).toContain("failed to list functions"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails on non-integer numeric fields", () => { + const { layer } = setup({ response: [NON_INTEGER_FUNCTION] }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyFunctionsList({ projectRef: Option.none() })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyFunctionsListNetworkError"); + expect(json).toContain("failed to list functions"); + } + }).pipe(Effect.provide(layer)); + }); + it.live("writes linked-project cache + telemetry state on success", () => { const { layer, telemetry, cache } = setupTracked(); return Effect.gen(function* () {