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..195eb7328b 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,36 @@ ## 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 | -| ---- | ------ | ---- | -| — | — | — | +| 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 -| 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, ...}]` | +| `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 -| 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 +41,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..ea06a775ec 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,379 @@ -import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { operationDefinitions } 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, 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"; +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; + +const INVALID_FIELD = Symbol("invalid function field"); +type InvalidField = typeof INVALID_FIELD; + +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), + ]), + ); +} + +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]; + if (value === undefined || value === null) return undefined; + return typeof value === "boolean" ? value : INVALID_FIELD; +} + +function readOptionalString( + record: Record, + key: string, +): string | undefined | InvalidField { + const value = record[key]; + if (value === undefined || value === null) return undefined; + return typeof value === "string" ? value : INVALID_FIELD; +} + +function readOptionalNullableString( + record: Record, + key: string, +): string | null | undefined | InvalidField { + const value = record[key]; + if (value === undefined) return undefined; + return value === null || typeof value === "string" ? value : INVALID_FIELD; +} + +function readGoString(record: Record, key: string): string | InvalidField { + const value = record[key]; + if (value === undefined || value === null) return ""; + return typeof value === "string" ? value : INVALID_FIELD; +} + +function readGoInteger(record: Record, key: string): number | InvalidField { + const value = record[key]; + if (value === undefined || value === null) return 0; + return typeof value === "number" && Number.isSafeInteger(value) ? value : INVALID_FIELD; +} + +function readRequiredFunctionFields( + record: Record, +): + | Omit< + LegacyFunctionRecord, + "verify_jwt" | "import_map" | "entrypoint_path" | "import_map_path" | "ezbr_sha256" + > + | undefined { + 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 === INVALID_FIELD || + slug === INVALID_FIELD || + name === INVALID_FIELD || + status === INVALID_FIELD || + version === INVALID_FIELD || + createdAt === INVALID_FIELD || + updatedAt === INVALID_FIELD + ) { + 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; + } + const functions: LegacyFunctionRecord[] = []; + for (const item of value) { + if (!isRecord(item)) { + return undefined; + } + 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: verifyJwt, + import_map: importMap, + entrypoint_path: entrypointPath, + import_map_path: importMapPath, + ezbr_sha256: ezbrSha256, + }); + } + return functions; +} + +function decodeFunctionsResponse( + rawBody: string, +): Effect.Effect { + return Effect.gen(function* () { + const parse = (): unknown => JSON.parse(rawBody); + const parsed = yield* Effect.try({ + try: parse, + 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, + name: function_.name, + slug: function_.slug, + status: function_.status, + version: function_.version, + created_at: function_.created_at, + updated_at: function_.updated_at, + }; +} + +function optionalGoJsonFields(function_: Functions[number]) { + return { + ...(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 } : {}), + }; +} + +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: base.created_at, + ...(function_.entrypoint_path != null ? { EntrypointPath: function_.entrypoint_path } : {}), + ...(function_.ezbr_sha256 != null ? { EzbrSha256: function_.ezbr_sha256 } : {}), + Id: base.id, + ...(function_.import_map != null ? { ImportMap: function_.import_map } : {}), + ...(function_.import_map_path != null ? { ImportMapPath: function_.import_map_path } : {}), + Name: base.name, + Slug: base.slug, + Status: base.status, + UpdatedAt: base.updated_at, + ...(function_.verify_jwt != null ? { VerifyJwt: function_.verify_jwt } : {}), + Version: base.version, + }; +} + 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 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 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), + ); + 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(escapeGoJsonHtmlChars(encodeGoJson(functions.map(toGoJsonFunction)))); + return; + } + if (goFmt === "yaml") { + yield* output.raw(encodeYaml(functions.map(toGoYamlFunction))); + return; + } + if (goFmt === "toml") { + 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 }); + 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..b8e9052037 --- /dev/null +++ b/apps/cli/src/legacy/commands/functions/list/list.integration.test.ts @@ -0,0 +1,406 @@ +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, + 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 HTML_FUNCTION: Functions[number] = { + ...SAMPLE_FUNCTION, + 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", +}; + +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 { + readonly format?: "text" | "json" | "stream-json"; + readonly goOutput?: "env" | "pretty" | "json" | "toml" | "yaml"; + readonly response?: unknown; + 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'); + expect(out.stdoutText).not.toContain('"import_map_path": null'); + }).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("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* () { + yield* legacyFunctionsList({ projectRef: Option.none() }); + 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)); + }); + + 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* () { + yield* legacyFunctionsList({ projectRef: Option.none() }); + 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)); + }); + + 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("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* () { + 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("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* () { + 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("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("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* () { + 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)); + }); +});