From b0007e3b46b56789e917ab24e0d5f54d0c57e828 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 28 May 2026 15:46:53 +0100 Subject: [PATCH 1/3] feat(cli): port snippets commands to native TypeScript Replaces the Phase 0 Go-binary proxies for `supabase snippets list` and `supabase snippets download` with native Effect handlers. Mirrors Go's output bytes, error messages, exit codes, and PersistentPostRun/Execute lifecycle (linked-project cache + telemetry flush on every exit). Adds TS-only `--output-format json|stream-json` events on top, exposing the full Management-API payload to scripted callers. Hoists `formatBackupTimestamp` to `legacy/shared/legacy-timestamp.format.ts` as `formatLegacyTimestamp` (second consumer triggered the hoist rule). Adds `urlParams` + `urlWithParams` to `LegacyRecordedRequest` so tests can assert on GET-style query parameters. Fixes CLI-1299 --- apps/cli/docs/go-cli-porting-status.md | 4 +- .../legacy/commands/backups/backups.format.ts | 27 -- .../backups/backups.format.unit.test.ts | 25 +- .../commands/backups/list/list.handler.ts | 5 +- .../snippets/download/SIDE_EFFECTS.md | 75 +++-- .../snippets/download/download.command.ts | 15 +- .../snippets/download/download.handler.ts | 91 ++++- .../download/download.integration.test.ts | 277 +++++++++++++++ .../commands/snippets/list/SIDE_EFFECTS.md | 88 +++-- .../commands/snippets/list/list.command.ts | 15 +- .../commands/snippets/list/list.handler.ts | 90 ++++- .../snippets/list/list.integration.test.ts | 314 ++++++++++++++++++ .../commands/snippets/snippets.e2e.test.ts | 27 ++ .../commands/snippets/snippets.errors.ts | 43 +++ .../commands/snippets/snippets.format.ts | 40 +++ .../snippets/snippets.format.unit.test.ts | 63 ++++ .../legacy/shared/legacy-timestamp.format.ts | 26 ++ .../legacy-timestamp.format.unit.test.ts | 26 ++ apps/cli/tests/helpers/legacy-mocks.ts | 14 + 19 files changed, 1146 insertions(+), 119 deletions(-) create mode 100644 apps/cli/src/legacy/commands/snippets/download/download.integration.test.ts create mode 100644 apps/cli/src/legacy/commands/snippets/list/list.integration.test.ts create mode 100644 apps/cli/src/legacy/commands/snippets/snippets.e2e.test.ts create mode 100644 apps/cli/src/legacy/commands/snippets/snippets.errors.ts create mode 100644 apps/cli/src/legacy/commands/snippets/snippets.format.ts create mode 100644 apps/cli/src/legacy/commands/snippets/snippets.format.unit.test.ts create mode 100644 apps/cli/src/legacy/shared/legacy-timestamp.format.ts create mode 100644 apps/cli/src/legacy/shared/legacy-timestamp.format.unit.test.ts diff --git a/apps/cli/docs/go-cli-porting-status.md b/apps/cli/docs/go-cli-porting-status.md index 1a29409300..7259433c46 100644 --- a/apps/cli/docs/go-cli-porting-status.md +++ b/apps/cli/docs/go-cli-porting-status.md @@ -233,8 +233,8 @@ Legend: | `config push` | `wrapped` | [`../src/legacy/commands/config/push/push.command.ts`](../src/legacy/commands/config/push/push.command.ts) | | `backups list` | `ported` | [`../src/legacy/commands/backups/list/list.command.ts`](../src/legacy/commands/backups/list/list.command.ts) | | `backups restore` | `ported` | [`../src/legacy/commands/backups/restore/restore.command.ts`](../src/legacy/commands/backups/restore/restore.command.ts) | -| `snippets list` | `wrapped` | [`../src/legacy/commands/snippets/list/list.command.ts`](../src/legacy/commands/snippets/list/list.command.ts) | -| `snippets download` | `wrapped` | [`../src/legacy/commands/snippets/download/download.command.ts`](../src/legacy/commands/snippets/download/download.command.ts) | +| `snippets list` | `ported` | [`../src/legacy/commands/snippets/list/list.command.ts`](../src/legacy/commands/snippets/list/list.command.ts) | +| `snippets download` | `ported` | [`../src/legacy/commands/snippets/download/download.command.ts`](../src/legacy/commands/snippets/download/download.command.ts) | | `sso list` | `ported` | [`../src/legacy/commands/sso/list/list.command.ts`](../src/legacy/commands/sso/list/list.command.ts) | | `sso add` | `ported` | [`../src/legacy/commands/sso/add/add.command.ts`](../src/legacy/commands/sso/add/add.command.ts) | | `sso remove` | `ported` | [`../src/legacy/commands/sso/remove/remove.command.ts`](../src/legacy/commands/sso/remove/remove.command.ts) | diff --git a/apps/cli/src/legacy/commands/backups/backups.format.ts b/apps/cli/src/legacy/commands/backups/backups.format.ts index a04ce811d7..61beb4c00e 100644 --- a/apps/cli/src/legacy/commands/backups/backups.format.ts +++ b/apps/cli/src/legacy/commands/backups/backups.format.ts @@ -22,30 +22,3 @@ const REGION_NAMES: Readonly> = { export function formatRegion(region: string): string { return REGION_NAMES[region] ?? region; } - -function pad2(value: number): string { - return value.toString().padStart(2, "0"); -} - -/** - * Reproduces `utils.FormatTimestamp` from `apps/cli-go/internal/utils/render.go:17`: - * parse RFC3339; on success format as UTC "YYYY-MM-DD HH:MM:SS"; on failure - * return the input verbatim. - */ -export function formatBackupTimestamp(value: string): string { - if (value.length === 0) return value; - // Go uses time.Parse(time.RFC3339, value). Date.parse accepts a broader format - // surface, so we additionally require the year-month-day prefix to weed out - // values like "2026-02-08 16:44:07" (already-formatted) that Date.parse would - // happily accept but Go's strict RFC3339 parser would reject. - if (!/^\d{4}-\d{2}-\d{2}T/.test(value)) { - return value; - } - const parsed = Date.parse(value); - if (Number.isNaN(parsed)) return value; - const date = new Date(parsed); - return ( - `${date.getUTCFullYear()}-${pad2(date.getUTCMonth() + 1)}-${pad2(date.getUTCDate())} ` + - `${pad2(date.getUTCHours())}:${pad2(date.getUTCMinutes())}:${pad2(date.getUTCSeconds())}` - ); -} diff --git a/apps/cli/src/legacy/commands/backups/backups.format.unit.test.ts b/apps/cli/src/legacy/commands/backups/backups.format.unit.test.ts index 4d270cdc21..a0f13e9e1f 100644 --- a/apps/cli/src/legacy/commands/backups/backups.format.unit.test.ts +++ b/apps/cli/src/legacy/commands/backups/backups.format.unit.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { formatBackupTimestamp, formatRegion } from "./backups.format.ts"; +import { formatRegion } from "./backups.format.ts"; describe("formatRegion", () => { it.each([ @@ -30,26 +30,3 @@ describe("formatRegion", () => { expect(formatRegion("xx-unknown-9")).toBe("xx-unknown-9"); }); }); - -describe("formatBackupTimestamp", () => { - it("formats valid RFC3339 to YYYY-MM-DD HH:MM:SS UTC", () => { - expect(formatBackupTimestamp("2026-02-08T16:44:07Z")).toBe("2026-02-08 16:44:07"); - }); - - it("handles offsets by normalizing to UTC", () => { - expect(formatBackupTimestamp("2026-02-08T18:44:07+02:00")).toBe("2026-02-08 16:44:07"); - }); - - it("falls back to the original value for already-formatted timestamps", () => { - // Go's time.Parse(time.RFC3339, ...) rejects "2026-02-08 16:44:07" (space, not T). - expect(formatBackupTimestamp("2026-02-08 16:44:07")).toBe("2026-02-08 16:44:07"); - }); - - it("falls back for malformed input", () => { - expect(formatBackupTimestamp("not-a-timestamp")).toBe("not-a-timestamp"); - }); - - it("returns empty string unchanged", () => { - expect(formatBackupTimestamp("")).toBe(""); - }); -}); diff --git a/apps/cli/src/legacy/commands/backups/list/list.handler.ts b/apps/cli/src/legacy/commands/backups/list/list.handler.ts index 85892779e7..496fa0f5b8 100644 --- a/apps/cli/src/legacy/commands/backups/list/list.handler.ts +++ b/apps/cli/src/legacy/commands/backups/list/list.handler.ts @@ -19,7 +19,8 @@ import { encodeYaml, } from "../../../shared/legacy-go-output.encoders.ts"; import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; -import { formatBackupTimestamp, formatRegion } from "../backups.format.ts"; +import { formatLegacyTimestamp } from "../../../shared/legacy-timestamp.format.ts"; +import { formatRegion } from "../backups.format.ts"; import type { LegacyBackupsListFlags } from "./list.command.ts"; type BackupsResponse = typeof V1ListAllBackupsOutput.Type; @@ -56,7 +57,7 @@ function renderLogicalTable(response: BackupsResponse): string { region, backup.is_physical_backup ? "PHYSICAL" : "LOGICAL", backup.status, - formatBackupTimestamp(backup.inserted_at), + formatLegacyTimestamp(backup.inserted_at), ]); return renderGlamourTable(LOGICAL_HEADERS, rows); } diff --git a/apps/cli/src/legacy/commands/snippets/download/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/snippets/download/SIDE_EFFECTS.md index 1226c2938b..19f3323f9e 100644 --- a/apps/cli/src/legacy/commands/snippets/download/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/snippets/download/SIDE_EFFECTS.md @@ -2,59 +2,84 @@ ## Files Read -| Path | Format | When | -| -------------------------- | ------------------------- | ---------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| Path | Format | When | +| ---------------------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------- | +| keyring `"Supabase CLI"` / `` | OS keychain | when `SUPABASE_ACCESS_TOKEN` unset and keyring available; account = `LegacyCliConfig.profile` | +| keyring `"Supabase CLI"` / `access-token` | OS keychain | legacy-key fallback when the profile-keyed lookup misses | +| `~/.supabase/access-token` | plain text (token string) | last-resort fallback after env + keyring miss | +| `/supabase/.temp/project-ref` | plain text | when `--project-ref` flag and `PROJECT_ID` env are unset | +| `/supabase/.temp/linked-project.json` | JSON | always — `linkedProjectCache` reads to decide whether to write | ## Files Written -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | +| Path | Format | When | +| ---------------------------------------------- | ------ | ------------------------------------------------------------------- | +| `~/.supabase/telemetry.json` | JSON | always (`Effect.ensuring(telemetryState.flush)`) | +| `/supabase/.temp/linked-project.json` | JSON | best-effort after `--project-ref` resolves (Go `PersistentPostRun`) | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ------------------- | ------------ | ------------ | -------------------------- | -| `GET` | `/v1/snippets/{id}` | Bearer token | none | `{content: {sql: string}}` | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ------------------- | ------------ | ------------ | ------------------------------------------------------------------------------------------------------------ | +| `GET` | `/v1/snippets/{id}` | Bearer token | none | `{content: {sql, schema_version, favorite?}, id, name, visibility, owner, project, inserted_at, updated_at}` | + +Only `content.sql` is rendered in text mode. The full payload is exposed via `--output-format json`. ## Environment Variables | Variable | Purpose | Required? | | ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`) | | `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| `SUPABASE_PROFILE` | profile selector (built-in name or YAML file path) | no (defaults to `supabase`) | ## Exit Codes -| Code | Condition | -| ---- | ------------------------------------------------------- | -| `0` | success — SQL content printed to stdout | -| `1` | invalid snippet ID argument (empty or not a valid UUID) | -| `1` | authentication error — no valid token found | -| `1` | API error — non-2xx response from `/v1/snippets/{id}` | -| `1` | network / connection failure | +| Code | Condition | +| ---- | ------------------------------------------------------------------- | +| `0` | success — SQL written to stdout | +| `1` | `LegacySnippetsInvalidIdError` — `` is not a valid UUID | +| `1` | `LegacyInvalidProjectRefError` / `LegacyProjectNotLinkedError` | +| `1` | `LegacySnippetsDownloadUnexpectedStatusError` — non-2xx response | +| `1` | `LegacySnippetsDownloadNetworkError` — transport-level failure | + +## Telemetry Events Fired + +| Event | When | Notable properties | +| ---------------------- | ------------------------------------------ | ---------------------------------------------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--project-ref` allowed verbatim) | ## Output ### `--output-format text` (Go CLI compatible) -Prints the raw SQL content of the snippet to stdout, followed by a newline. +The raw SQL `content.sql` followed by a trailing `\n`. ``` -select 1 +select 1; ``` -### `--output-format json` +### `--output-format json` (TS extension) -Not applicable — download writes SQL directly to stdout. +Single `success` event with the full `V1GetASnippetOutput` payload as `data`. This includes `id`, `name`, `visibility`, `owner`, `project`, `inserted_at`, `updated_at`, `favorite`, and `content` (with `sql`, `schema_version`, and optional `favorite`). Agents that only need the SQL can read `data.content.sql`; agents reconstructing a snippet in a new project have everything they need. + +```json +{ + "id": "0b0d48f6-…", + "name": "Create table", + "visibility": "user", + "owner": { "id": 7, "username": "supaseed" }, + "content": { "schema_version": "1.0.0", "sql": "select 1;" } +} +``` -### `--output-format stream-json` +### `--output-format stream-json` (TS extension) -Not applicable — download writes SQL directly to stdout. +NDJSON `success` event with the same full payload as `--output-format json`. ## Notes -- Requires a `` positional argument (UUID). -- Requires `--project-ref` or a linked project. -- Phase 0 proxy: all invocations are forwarded to the bundled Go binary via `LegacyGoProxy`. +- Go's `--output` flag is **ignored** by `download.Run` — `fmt.Println(resp.JSON200.Content.Sql)` runs regardless of `pretty|json|yaml|toml|env`. The TS port mirrors this exactly: Go-style `--output` values do not change text-mode rendering. Only the TS-extension `--output-format json|stream-json` produces a structured payload. +- UUID validation runs **after** project-ref resolution but **before** the API call, matching Go's lifecycle: `PersistentPreRunE` resolves the ref first, then `download.Run` validates via `uuid.Parse`. Error messages mirror google/uuid v1.6.0: `invalid snippet ID: invalid UUID length: N` for malformed lengths, `invalid snippet ID: invalid UUID format` for length-36 inputs with wrong dash positions or hex chars. +- The linked-project cache fires after project-ref resolves (Go `PersistentPostRun`); the telemetry state always flushes (Go `Execute`). Both run on success and on every error path — including invalid-UUID early-exit — via the two `Effect.ensuring` blocks in the handler. diff --git a/apps/cli/src/legacy/commands/snippets/download/download.command.ts b/apps/cli/src/legacy/commands/snippets/download/download.command.ts index c26691bd70..724d5afd68 100644 --- a/apps/cli/src/legacy/commands/snippets/download/download.command.ts +++ b/apps/cli/src/legacy/commands/snippets/download/download.command.ts @@ -1,5 +1,9 @@ import { Argument, 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 { legacySnippetsDownload } from "./download.handler.ts"; const config = { @@ -22,5 +26,14 @@ export const legacySnippetsDownloadCommand = Command.make("download", config).pi description: "Download the SQL contents of the given snippet", }, ]), - Command.withHandler((flags) => legacySnippetsDownload(flags)), + Command.withHandler((flags) => + legacySnippetsDownload(flags).pipe( + // No `safeFlags` — Go's `cmd/snippets.go` does not call + // `markFlagTelemetrySafe` for `--project-ref`, so the telemetry payload + // redacts the value. + withLegacyCommandInstrumentation({ flags }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyManagementApiRuntimeLayer(["snippets", "download"])), ); diff --git a/apps/cli/src/legacy/commands/snippets/download/download.handler.ts b/apps/cli/src/legacy/commands/snippets/download/download.handler.ts index bffb5ce890..f43b6b8469 100644 --- a/apps/cli/src/legacy/commands/snippets/download/download.handler.ts +++ b/apps/cli/src/legacy/commands/snippets/download/download.handler.ts @@ -1,12 +1,91 @@ -import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { Effect } from "effect"; + +import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; +import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { Output } from "../../../../shared/output/output.service.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 { + LegacySnippetsDownloadNetworkError, + LegacySnippetsDownloadUnexpectedStatusError, + LegacySnippetsInvalidIdError, +} from "../snippets.errors.ts"; import type { LegacySnippetsDownloadFlags } from "./download.command.ts"; +// Load-bearing for error-message parity. The generated `V1GetASnippetInput` +// schema (contracts.ts:1539-1545) already pattern-checks UUIDs, so if this +// pre-check is removed, a non-UUID input would surface as a `SchemaError` +// routed through `mapDownloadError` to `LegacySnippetsDownloadNetworkError` +// with a `failed to download snippet:` prefix — losing the Go-canonical +// `invalid snippet ID:` prefix from `apps/cli-go/internal/snippets/download/download.go:17`. +const UUID_RE = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/; + +const mapDownloadError = mapLegacyHttpError({ + networkError: LegacySnippetsDownloadNetworkError, + statusError: LegacySnippetsDownloadUnexpectedStatusError, + networkMessage: (cause) => `failed to download snippet: ${cause}`, + statusMessage: (status, body) => `unexpected download snippet status ${status}: ${body}`, +}); + +// Mirrors Go's `uuid.Parse` (google/uuid v1.6.0) error surface: +// - len(s) not in {32, 36, 38, 41} → `invalid UUID length: N` +// - len(s) == 36 but dashes/hex chars wrong → `invalid UUID format` +// We accept only the canonical 36-char form (`8-4-4-4-12`), so the two +// branches collapse to length-vs-format. The outer wrap mirrors +// `fmt.Errorf("invalid snippet ID: %w", err)` from download.go:17. +function uuidErrorMessage(value: string): string { + if (value.length !== 36) { + return `invalid snippet ID: invalid UUID length: ${value.length}`; + } + return "invalid snippet ID: invalid UUID format"; +} + export const legacySnippetsDownload = Effect.fn("legacy.snippets.download")(function* ( flags: LegacySnippetsDownloadFlags, ) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["snippets", "download", flags.snippetId]; - if (Option.isSome(flags.projectRef)) args.push("--project-ref", flags.projectRef.value); - yield* proxy.exec(args); + const output = yield* Output; + const api = yield* LegacyPlatformApi; + const resolver = yield* LegacyProjectRefResolver; + const linkedProjectCache = yield* LegacyLinkedProjectCache; + const telemetryState = yield* LegacyTelemetryState; + + // Same lifecycle as `list` — see that handler for the Go cross-reference. + // The UUID short-circuit lives inside the inner block so the linked-project + // cache still fires (Go's PersistentPostRun runs after the failing Run). + yield* Effect.gen(function* () { + const ref = yield* resolver.resolve(flags.projectRef); + + yield* Effect.gen(function* () { + if (!UUID_RE.test(flags.snippetId)) { + return yield* Effect.fail( + new LegacySnippetsInvalidIdError({ + message: uuidErrorMessage(flags.snippetId), + }), + ); + } + + const fetching = + output.format === "text" ? yield* output.task("Downloading snippet...") : undefined; + const response = yield* api.v1.getASnippet({ id: flags.snippetId }).pipe( + Effect.tapError(() => fetching?.fail() ?? Effect.void), + Effect.catch(mapDownloadError), + ); + yield* fetching?.clear() ?? Effect.void; + + // TS-only structured output. Expose the full payload so scripted callers + // and agents can read snippet identity (`id`, `name`, `owner`, …) + // alongside `content.sql`, matching the SIDE_EFFECTS.md contract and the + // shape `snippets list --output-format json` uses for its response. + if (output.format === "json" || output.format === "stream-json") { + yield* output.success("", response); + return; + } + + // Go's `download.Run` ignores `--output` entirely and always runs + // `fmt.Println(resp.JSON200.Content.Sql)` (download.go:25). Mirror that: + // no branching on `LegacyOutputFlag`. + yield* output.raw(response.content.sql + "\n"); + }).pipe(Effect.ensuring(linkedProjectCache.cache(ref))); + }).pipe(Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/snippets/download/download.integration.test.ts b/apps/cli/src/legacy/commands/snippets/download/download.integration.test.ts new file mode 100644 index 0000000000..05db086767 --- /dev/null +++ b/apps/cli/src/legacy/commands/snippets/download/download.integration.test.ts @@ -0,0 +1,277 @@ +import { type V1GetASnippetOutput } from "@supabase/api/effect"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, Option } from "effect"; + +import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { + buildLegacyTestRuntime, + mockLegacyCliConfig, + mockLegacyLinkedProjectCacheTracked, + mockLegacyPlatformApi, + mockLegacyTelemetryStateTracked, + useLegacyTempWorkdir, +} from "../../../../../tests/helpers/legacy-mocks.ts"; +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { legacySnippetsDownload } from "./download.handler.ts"; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const VALID_ID = "0b0d48f6-878b-4190-88d7-2ca33ed800bc"; +const INVALID_ID = "not-a-uuid"; // length 10 → "invalid UUID length: 10" +const TOO_LONG_ID = "0b0d48f6-878b-4190-88d7-2ca33ed800bc-extra"; // length 42 (3 ungrouped: 32, 36, 38, 41) +const WRONG_FORMAT_ID = "0b0d48f6.878b.4190.88d7.2ca33ed800bc"; // length 36, no dashes in canonical positions +const SQL = "select 1;"; + +type SnippetResponse = typeof V1GetASnippetOutput.Type; + +const SNIPPET_RESPONSE: SnippetResponse = { + id: VALID_ID, + inserted_at: "2023-10-13T17:48:58.491Z", + updated_at: "2023-10-13T17:48:58.491Z", + type: "sql", + visibility: "user", + name: "Create table", + description: null, + project: { id: 1, name: "Proj" }, + owner: { id: 7, username: "supaseed" }, + updated_by: { id: 7, username: "supaseed" }, + favorite: false, + content: { schema_version: "1.0.0", sql: SQL }, +}; + +// --------------------------------------------------------------------------- +// Setup +// --------------------------------------------------------------------------- + +// `goOutput` is intentionally absent: the download handler does not consume +// `LegacyOutputFlag` at all (matches Go's `download.Run`, which calls +// `fmt.Println(resp.JSON200.Content.Sql)` unconditionally). Threading a value +// through here would suggest a behaviour difference that does not exist. +interface SetupOpts { + format?: "text" | "json" | "stream-json"; + status?: number; + network?: "fail"; + response?: SnippetResponse; +} + +const tempRoot = useLegacyTempWorkdir("supabase-snippets-download-int-"); + +function setup(opts: SetupOpts = {}) { + const out = mockOutput({ format: opts.format ?? "text" }); + const telemetry = mockLegacyTelemetryStateTracked(); + const cache = mockLegacyLinkedProjectCacheTracked(); + const api = mockLegacyPlatformApi({ + response: { status: opts.status ?? 200, body: opts.response ?? SNIPPET_RESPONSE }, + network: opts.network, + }); + const cliConfig = mockLegacyCliConfig({ workdir: tempRoot.current }); + const layer = buildLegacyTestRuntime({ + out, + api, + cliConfig, + telemetry: telemetry.layer, + linkedProjectCache: cache.layer, + }); + return { layer, out, api, telemetry, cache }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("legacy snippets download integration", () => { + it.live("prints raw SQL with a trailing newline in text mode", () => { + const { layer, out } = setup(); + return Effect.gen(function* () { + yield* legacySnippetsDownload({ snippetId: VALID_ID, projectRef: Option.none() }); + expect(out.stdoutText).toBe(`${SQL}\n`); + }).pipe(Effect.provide(layer)); + }); + + // Go's `download.Run` ignores `--output` entirely (download.go:25). The TS + // handler must reproduce that: no read of `LegacyOutputFlag`, no branching. + // This regression guards against a future refactor that adds branch-on-goOutput + // logic by mistake — if the flag is consumed, this assertion will diverge. + it.live("text mode is unaffected by any Go `--output` value (Go parity)", () => { + const out = mockOutput({ format: "text" }); + const telemetry = mockLegacyTelemetryStateTracked(); + const cache = mockLegacyLinkedProjectCacheTracked(); + const api = mockLegacyPlatformApi({ response: { status: 200, body: SNIPPET_RESPONSE } }); + const cliConfig = mockLegacyCliConfig({ workdir: tempRoot.current }); + const layer = buildLegacyTestRuntime({ + out, + api, + cliConfig, + telemetry: telemetry.layer, + linkedProjectCache: cache.layer, + goOutput: Option.some("json"), + }); + return Effect.gen(function* () { + yield* legacySnippetsDownload({ snippetId: VALID_ID, projectRef: Option.none() }); + expect(out.stdoutText).toBe(`${SQL}\n`); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a success event with the full response under --output-format=json", () => { + const { layer, out } = setup({ format: "json" }); + return Effect.gen(function* () { + yield* legacySnippetsDownload({ snippetId: VALID_ID, projectRef: Option.none() }); + const success = out.messages.find((m) => m.type === "success"); + expect(success).toBeDefined(); + const data = success?.data as SnippetResponse | undefined; + expect(data?.id).toBe(VALID_ID); + expect(data?.name).toBe("Create table"); + expect(data?.content.sql).toBe(SQL); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a result event with the full response under --output-format=stream-json", () => { + const { layer, out } = setup({ format: "stream-json" }); + return Effect.gen(function* () { + yield* legacySnippetsDownload({ snippetId: VALID_ID, projectRef: Option.none() }); + const success = out.messages.find((m) => m.type === "success"); + expect(success).toBeDefined(); + const data = success?.data as SnippetResponse | undefined; + expect(data?.content.sql).toBe(SQL); + }).pipe(Effect.provide(layer)); + }); + + it.live( + "non-UUID input emits Go-format `invalid UUID length: N`, flushes telemetry+cache, skips API", + () => { + const { layer, api, telemetry, cache } = setup(); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacySnippetsDownload({ snippetId: INVALID_ID, projectRef: Option.none() }), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const dump = JSON.stringify(exit.cause); + expect(dump).toContain("LegacySnippetsInvalidIdError"); + // Go's `uuid.Parse` returns `invalid UUID length: 10` for "not-a-uuid" + // (length 10), wrapped by download.go:17 as `invalid snippet ID: %w`. + expect(dump).toContain("invalid snippet ID: invalid UUID length: 10"); + } + expect(api.requests).toHaveLength(0); + // Go's PersistentPostRun + Execute both still fire on this error path. + expect(telemetry.flushed).toBe(true); + expect(cache.cached).toBe(true); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("a 42-char input also produces the length error", () => { + const { layer } = setup(); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacySnippetsDownload({ snippetId: TOO_LONG_ID, projectRef: Option.none() }), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const dump = JSON.stringify(exit.cause); + expect(dump).toContain("invalid snippet ID: invalid UUID length: 42"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("a 36-char input with wrong dash positions emits `invalid UUID format`", () => { + const { layer } = setup(); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacySnippetsDownload({ snippetId: WRONG_FORMAT_ID, projectRef: Option.none() }), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const dump = JSON.stringify(exit.cause); + expect(dump).toContain("invalid snippet ID: invalid UUID format"); + // The offending value must NOT be embedded (Go does not include it). + expect(dump).not.toContain(WRONG_FORMAT_ID); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("calls GET /v1/snippets/{id} with the validated UUID and no project_ref query", () => { + const { layer, api } = setup(); + return Effect.gen(function* () { + yield* legacySnippetsDownload({ snippetId: VALID_ID, projectRef: Option.none() }); + expect(api.requests).toHaveLength(1); + expect(api.requests[0]?.method).toBe("GET"); + expect(api.requests[0]?.url).toContain(`/v1/snippets/${VALID_ID}`); + expect(api.requests[0]?.urlParams).toBe(""); + }).pipe(Effect.provide(layer)); + }); + + it.live("uses --project-ref flag value when resolving the linked-project cache", () => { + const flagRef = "zzzzzzzzzzzzzzzzzzzz"; + const { layer, cache } = setup(); + return Effect.gen(function* () { + yield* legacySnippetsDownload({ snippetId: VALID_ID, projectRef: Option.some(flagRef) }); + // The download endpoint itself takes only the snippet ID, but the + // resolved project ref still flows into the linked-project cache write + // (Go's PersistentPostRun behaviour). + expect(cache.cached).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with LegacySnippetsDownloadUnexpectedStatusError on HTTP 503", () => { + const { layer } = setup({ status: 503 }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacySnippetsDownload({ snippetId: VALID_ID, projectRef: Option.none() }), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const dump = JSON.stringify(exit.cause); + expect(dump).toContain("LegacySnippetsDownloadUnexpectedStatusError"); + expect(dump).toContain("unexpected download snippet status 503"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with LegacySnippetsDownloadNetworkError on transport failure", () => { + const { layer } = setup({ network: "fail" }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacySnippetsDownload({ snippetId: VALID_ID, projectRef: Option.none() }), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const dump = JSON.stringify(exit.cause); + expect(dump).toContain("LegacySnippetsDownloadNetworkError"); + expect(dump).toContain("failed to download snippet"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("flushes telemetry and writes linked-project cache on success", () => { + const { layer, telemetry, cache } = setup(); + return Effect.gen(function* () { + yield* legacySnippetsDownload({ snippetId: VALID_ID, projectRef: Option.none() }); + expect(telemetry.flushed).toBe(true); + expect(cache.cached).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("flushes telemetry and writes linked-project cache even on API failure", () => { + const { layer, telemetry, cache } = setup({ status: 500 }); + return Effect.gen(function* () { + yield* Effect.exit( + legacySnippetsDownload({ snippetId: VALID_ID, 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 }); + return Effect.gen(function* () { + yield* legacySnippetsDownload({ snippetId: VALID_ID, projectRef: Option.none() }).pipe( + withJsonErrorHandling, + ); + expect(out.messages.some((m) => m.type === "fail")).toBe(true); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/cli/src/legacy/commands/snippets/list/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/snippets/list/SIDE_EFFECTS.md index cb6aa1bd6a..371f2f6e1c 100644 --- a/apps/cli/src/legacy/commands/snippets/list/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/snippets/list/SIDE_EFFECTS.md @@ -2,62 +2,98 @@ ## Files Read -| Path | Format | When | -| -------------------------- | ------------------------- | ---------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| Path | Format | When | +| ---------------------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------- | +| keyring `"Supabase CLI"` / `` | OS keychain | when `SUPABASE_ACCESS_TOKEN` unset and keyring available; account = `LegacyCliConfig.profile` | +| keyring `"Supabase CLI"` / `access-token` | OS keychain | legacy-key fallback when the profile-keyed lookup misses | +| `~/.supabase/access-token` | plain text (token string) | last-resort fallback after env + keyring miss | +| `/supabase/.temp/project-ref` | plain text | when `--project-ref` flag and `PROJECT_ID` env are unset | +| `/supabase/.temp/linked-project.json` | JSON | always — `linkedProjectCache` reads to decide whether to write | ## Files Written -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | +| Path | Format | When | +| ---------------------------------------------- | ------ | ------------------------------------------------------------------- | +| `~/.supabase/telemetry.json` | JSON | always (`Effect.ensuring(telemetryState.flush)`) | +| `/supabase/.temp/linked-project.json` | JSON | best-effort after `--project-ref` resolves (Go `PersistentPostRun`) | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | -------------- | ------------ | ------------ | ------------------------------------------------------------------------------ | -| `GET` | `/v1/snippets` | Bearer token | none | `{data: [{id, name, visibility, owner: {username}, inserted_at, updated_at}]}` | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | -------------------------------- | ------------ | ------------ | -------------------------------------------------------------------------------------------- | +| `GET` | `/v1/snippets?project_ref=` | Bearer token | none | `{data: [{id, name, visibility, owner: {username}, inserted_at, updated_at, ...}], cursor?}` | ## Environment Variables | Variable | Purpose | Required? | | ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`) | | `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| `SUPABASE_PROFILE` | profile selector (built-in name or YAML file path) | no (defaults to `supabase`) | ## Exit Codes -| Code | Condition | -| ---- | ------------------------------------------------ | -| `0` | success — snippet list printed to stdout | -| `1` | authentication error — no valid token found | -| `1` | API error — non-2xx response from `/v1/snippets` | -| `1` | network / connection failure | +| Code | Condition | +| ---- | ------------------------------------------------------------------- | +| `0` | success | +| `1` | `LegacySnippetsEnvNotSupportedError` — `--output env` was requested | +| `1` | `LegacyInvalidProjectRefError` / `LegacyProjectNotLinkedError` | +| `1` | `LegacySnippetsListUnexpectedStatusError` — non-2xx response | +| `1` | `LegacySnippetsListNetworkError` — transport-level failure | + +## Telemetry Events Fired + +| Event | When | Notable properties | +| ---------------------- | ------------------------------------------ | ---------------------------------------------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--project-ref` allowed verbatim) | ## Output -### `--output-format text` (Go CLI compatible) +### `--output-format text` / Go `--output pretty` (Go CLI compatible) -Prints a Markdown-style table with columns: `ID`, `NAME`, `VISIBILITY`, `OWNER`, `CREATED AT (UTC)`, `UPDATED AT (UTC)`. +Glamour-styled ASCII table with columns `ID`, `NAME`, `VISIBILITY`, `OWNER`, `CREATED AT (UTC)`, `UPDATED AT (UTC)`. Pipe characters in `name` and `owner.username` are escaped as `\|` to match Go's `strings.ReplaceAll`. ``` - ID | NAME | VISIBILITY | OWNER | CREATED AT (UTC) | UPDATED AT (UTC) - test-snippet | Create table | user | supaseed | 2023-10-13 17:48:58 | 2023-10-13 17:48:58 + ID | NAME | VISIBILITY | OWNER | CREATED AT (UTC) | UPDATED AT (UTC) + --------------|--------------|------------|----------|---------------------|--------------------- + test-snippet | Create table | user | supaseed | 2023-10-13 17:48:58 | 2023-10-13 17:48:58 ``` -### `--output-format json` +### Go `--output json` -Single JSON object with the full snippets list response. +Indented JSON with alphabetically-sorted keys and a trailing newline. Empty `data` is rendered as `null` to match Go's `encoding/json` nil-slice serialization. ```json -{"data": [{"id": "…", "name": "…", "visibility": "user", …}]} +{ + "data": [ + { "favorite": false, "id": "…", "inserted_at": "…", "name": "…", "owner": { … }, ... } + ] +} ``` -### `--output-format stream-json` +### Go `--output yaml` + +YAML rendering of the full `V1ListAllSnippetsOutput` response. + +### Go `--output toml` + +TOML rendering of the full `V1ListAllSnippetsOutput` response, with a trailing newline. + +### Go `--output env` + +Not supported — fails with `--output env flag is not supported`. Byte-exact match against Go's `utils.ErrEnvNotSupported` (`apps/cli-go/internal/utils/output.go:41`). + +### `--output-format json` (TS extension) + +Single `success` event whose `data` is the full `V1ListAllSnippetsOutput` payload. + +### `--output-format stream-json` (TS extension) -One `result` event on success. +NDJSON `success` event with the full response as `data`. ## Notes -- Requires `--project-ref` or a linked project. -- Phase 0 proxy: all invocations are forwarded to the bundled Go binary via `LegacyGoProxy`. +- When both Go `--output` and TS `--output-format` are set, Go's flag wins (matches the precedence used elsewhere in legacy ports). +- `--output env` is rejected **after** project-ref resolution but **before** the API call, matching Go's lifecycle: cobra resolves `--project-ref` in `PersistentPreRunE`, `list.Run` checks `OutputFormat.Value` before invoking the encoder. The error message is byte-exact with `utils.ErrEnvNotSupported`. +- The linked-project cache fires after project-ref resolves (Go `PersistentPostRun`); the telemetry state always flushes (Go `Execute`). Both run on success and on every error path — the two `Effect.ensuring` blocks in the handler model the post-run order exactly. diff --git a/apps/cli/src/legacy/commands/snippets/list/list.command.ts b/apps/cli/src/legacy/commands/snippets/list/list.command.ts index 99136b5877..a97ba0a55b 100644 --- a/apps/cli/src/legacy/commands/snippets/list/list.command.ts +++ b/apps/cli/src/legacy/commands/snippets/list/list.command.ts @@ -1,5 +1,9 @@ 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 { legacySnippetsList } from "./list.handler.ts"; const config = { @@ -23,5 +27,14 @@ export const legacySnippetsListCommand = Command.make("list", config).pipe( description: "List snippets for a specific project", }, ]), - Command.withHandler((flags) => legacySnippetsList(flags)), + Command.withHandler((flags) => + legacySnippetsList(flags).pipe( + // No `safeFlags` — Go's `cmd/snippets.go` does not call + // `markFlagTelemetrySafe` for `--project-ref`, so the telemetry payload + // redacts the value (matches Go's default behavior for unmarked flags). + withLegacyCommandInstrumentation({ flags }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyManagementApiRuntimeLayer(["snippets", "list"])), ); diff --git a/apps/cli/src/legacy/commands/snippets/list/list.handler.ts b/apps/cli/src/legacy/commands/snippets/list/list.handler.ts index 35e44a55bc..9c08129eba 100644 --- a/apps/cli/src/legacy/commands/snippets/list/list.handler.ts +++ b/apps/cli/src/legacy/commands/snippets/list/list.handler.ts @@ -1,12 +1,92 @@ import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; + +import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; +import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { LegacyOutputFlag } from "../../../../shared/legacy/global-flags.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { encodeGoJson, encodeToml, encodeYaml } from "../../../shared/legacy-go-output.encoders.ts"; +import { 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 { + LegacySnippetsEnvNotSupportedError, + LegacySnippetsListNetworkError, + LegacySnippetsListUnexpectedStatusError, +} from "../snippets.errors.ts"; +import { renderSnippetsTable } from "../snippets.format.ts"; import type { LegacySnippetsListFlags } from "./list.command.ts"; +const mapListError = mapLegacyHttpError({ + networkError: LegacySnippetsListNetworkError, + statusError: LegacySnippetsListUnexpectedStatusError, + networkMessage: (cause) => `failed to list snippets: ${cause}`, + statusMessage: (status, body) => `unexpected list snippets status ${status}: ${body}`, +}); + export const legacySnippetsList = Effect.fn("legacy.snippets.list")(function* ( flags: LegacySnippetsListFlags, ) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["snippets", "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; + + // Mirror Go's lifecycle (apps/cli-go/cmd/root.go:93-167 + 175-183): + // PersistentPreRunE → resolve project ref + // Run → reject --output env / call API / render + // PersistentPostRun → write linked-project cache (needs `ref`) + // Execute → flush telemetry (no `ref` required) + // The two `Effect.ensuring` blocks model the post-run order exactly: + // `telemetryState.flush` runs on every exit, `linkedProjectCache.cache(ref)` + // runs whenever ref resolution succeeded. + yield* Effect.gen(function* () { + const ref = yield* resolver.resolve(flags.projectRef); + + yield* Effect.gen(function* () { + if (Option.getOrUndefined(goOutputFlag) === "env") { + return yield* Effect.fail( + new LegacySnippetsEnvNotSupportedError({ + message: "--output env flag is not supported", + }), + ); + } + + const fetching = + output.format === "text" ? yield* output.task("Fetching snippets...") : undefined; + const response = yield* api.v1.listAllSnippets({ project_ref: ref }).pipe( + Effect.tapError(() => fetching?.fail() ?? Effect.void), + Effect.catch(mapListError), + ); + yield* fetching?.clear() ?? Effect.void; + + const goFmt = Option.getOrUndefined(goOutputFlag); + + if (goFmt === "json") { + // Go marshals `SnippetList{}` (nil Data slice) as `{"data": null}`; + // the generated schema decodes nil → []. `nullForEmptyArrays` preserves + // the Go-bytes shape for the empty-list fixture. + yield* output.raw(encodeGoJson(response, { nullForEmptyArrays: ["data"] })); + return; + } + if (goFmt === "yaml") { + yield* output.raw(encodeYaml(response)); + return; + } + if (goFmt === "toml") { + yield* output.raw(encodeToml(response) + "\n"); + return; + } + + // goFmt is undefined or "pretty" — defer to TS --output-format for + // JSON/stream-json, otherwise render the Glamour table. + if (output.format === "json" || output.format === "stream-json") { + yield* output.success("", response); + return; + } + + yield* output.raw(renderSnippetsTable(response.data)); + }).pipe(Effect.ensuring(linkedProjectCache.cache(ref))); + }).pipe(Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/snippets/list/list.integration.test.ts b/apps/cli/src/legacy/commands/snippets/list/list.integration.test.ts new file mode 100644 index 0000000000..96e7a06c0f --- /dev/null +++ b/apps/cli/src/legacy/commands/snippets/list/list.integration.test.ts @@ -0,0 +1,314 @@ +import { type V1ListAllSnippetsOutput } from "@supabase/api/effect"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, Option } from "effect"; + +import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { + buildLegacyTestRuntime, + LEGACY_VALID_REF, + mockLegacyCliConfig, + mockLegacyLinkedProjectCacheTracked, + mockLegacyPlatformApi, + mockLegacyTelemetryStateTracked, + useLegacyTempWorkdir, +} from "../../../../../tests/helpers/legacy-mocks.ts"; +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { legacySnippetsList } from "./list.handler.ts"; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +type SnippetsResponse = typeof V1ListAllSnippetsOutput.Type; + +const SNIPPET_ID = "00000000-0000-0000-0000-000000000001"; + +const SNIPPET_BASE = { + id: SNIPPET_ID, + inserted_at: "2023-10-13T17:48:58.491Z", + updated_at: "2023-10-13T17:48:58.491Z", + type: "sql" as const, + visibility: "user" as const, + name: "Create table", + description: null, + project: { id: 1, name: "Proj" }, + owner: { id: 7, username: "supaseed" }, + updated_by: { id: 7, username: "supaseed" }, + favorite: false, +}; + +const SINGLE_RESPONSE: SnippetsResponse = { + data: [SNIPPET_BASE], +}; + +const PIPE_RESPONSE: SnippetsResponse = { + data: [ + { + ...SNIPPET_BASE, + // The Schema literal for `visibility` only accepts a fixed set, but Go + // applies the pipe-escape unconditionally — verify the escape on `name` + // and `owner.username` (the user-controlled fields that can realistically + // contain `|`). + name: "name|with|pipes", + owner: { id: 7, username: "user|name" }, + }, + ], +}; + +const RAW_TIMESTAMP_RESPONSE: SnippetsResponse = { + data: [ + { + ...SNIPPET_BASE, + inserted_at: "not-an-rfc3339", + updated_at: "2023-10-13T17:48:58.491Z", + }, + ], +}; + +const EMPTY_RESPONSE: SnippetsResponse = { + data: [], +}; + +// --------------------------------------------------------------------------- +// Setup +// --------------------------------------------------------------------------- + +interface SetupOpts { + format?: "text" | "json" | "stream-json"; + goOutput?: "env" | "pretty" | "json" | "toml" | "yaml"; + response?: SnippetsResponse; + status?: number; + network?: "fail"; +} + +const tempRoot = useLegacyTempWorkdir("supabase-snippets-list-int-"); + +function setup(opts: SetupOpts = {}) { + const out = mockOutput({ format: opts.format ?? "text" }); + const telemetry = mockLegacyTelemetryStateTracked(); + const cache = mockLegacyLinkedProjectCacheTracked(); + const api = mockLegacyPlatformApi({ + response: { status: opts.status ?? 200, body: opts.response ?? SINGLE_RESPONSE }, + network: opts.network, + }); + const cliConfig = mockLegacyCliConfig({ workdir: tempRoot.current }); + const layer = buildLegacyTestRuntime({ + out, + api, + cliConfig, + telemetry: telemetry.layer, + linkedProjectCache: cache.layer, + goOutput: opts.goOutput === undefined ? Option.none() : Option.some(opts.goOutput), + }); + return { layer, out, api, telemetry, cache }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("legacy snippets list integration", () => { + it.live("renders an ASCII table in text mode with all six columns", () => { + const { layer, out } = setup(); + return Effect.gen(function* () { + yield* legacySnippetsList({ projectRef: Option.none() }); + expect(out.stdoutText).toContain("ID"); + expect(out.stdoutText).toContain("NAME"); + expect(out.stdoutText).toContain("VISIBILITY"); + expect(out.stdoutText).toContain("OWNER"); + expect(out.stdoutText).toContain("CREATED AT (UTC)"); + expect(out.stdoutText).toContain("UPDATED AT (UTC)"); + expect(out.stdoutText).toContain(SNIPPET_ID); + expect(out.stdoutText).toContain("Create table"); + expect(out.stdoutText).toContain("supaseed"); + }).pipe(Effect.provide(layer)); + }); + + it.live("escapes `|` characters embedded in snippet name and owner username", () => { + const { layer, out } = setup({ response: PIPE_RESPONSE }); + return Effect.gen(function* () { + yield* legacySnippetsList({ projectRef: Option.none() }); + expect(out.stdoutText).toContain("name\\|with\\|pipes"); + expect(out.stdoutText).toContain("user\\|name"); + }).pipe(Effect.provide(layer)); + }); + + it.live("formats RFC3339 timestamps as UTC YYYY-MM-DD HH:MM:SS", () => { + const { layer, out } = setup(); + return Effect.gen(function* () { + yield* legacySnippetsList({ projectRef: Option.none() }); + expect(out.stdoutText).toContain("2023-10-13 17:48:58"); + }).pipe(Effect.provide(layer)); + }); + + it.live("leaves a non-RFC3339 inserted_at string untouched", () => { + const { layer, out } = setup({ response: RAW_TIMESTAMP_RESPONSE }); + return Effect.gen(function* () { + yield* legacySnippetsList({ projectRef: Option.none() }); + expect(out.stdoutText).toContain("not-an-rfc3339"); + // The valid updated_at is still formatted. + expect(out.stdoutText).toContain("2023-10-13 17:48:58"); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a success event with the full response under --output-format=json", () => { + const { layer, out } = setup({ format: "json" }); + return Effect.gen(function* () { + yield* legacySnippetsList({ projectRef: Option.none() }); + const success = out.messages.find((m) => m.type === "success"); + expect(success).toBeDefined(); + const data = success?.data as SnippetsResponse | undefined; + expect(data?.data).toHaveLength(1); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a result event under --output-format=stream-json", () => { + const { layer, out } = setup({ format: "stream-json" }); + return Effect.gen(function* () { + yield* legacySnippetsList({ projectRef: Option.none() }); + expect(out.messages.some((m) => m.type === "success")).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("Go --output=json emits alphabetically-keyed JSON with `data: null` for empty", () => { + const { layer, out } = setup({ goOutput: "json", response: EMPTY_RESPONSE }); + return Effect.gen(function* () { + yield* legacySnippetsList({ projectRef: Option.none() }); + // Mirrors Go's `api.SnippetList{}` → `{"data": null}\n` (no other keys). + expect(out.stdoutText).toBe(`{ + "data": null +} +`); + }).pipe(Effect.provide(layer)); + }); + + it.live("Go --output=yaml emits a `data:` block", () => { + const { layer, out } = setup({ goOutput: "yaml" }); + return Effect.gen(function* () { + yield* legacySnippetsList({ projectRef: Option.none() }); + expect(out.stdoutText).toContain("data:"); + expect(out.stdoutText).toContain(SNIPPET_ID); + }).pipe(Effect.provide(layer)); + }); + + it.live("Go --output=toml emits the response", () => { + const { layer, out } = setup({ goOutput: "toml" }); + return Effect.gen(function* () { + yield* legacySnippetsList({ projectRef: Option.none() }); + expect(out.stdoutText.length).toBeGreaterThan(0); + expect(out.stdoutText).toContain(SNIPPET_ID); + }).pipe(Effect.provide(layer)); + }); + + it.live( + "Go --output=env fails with LegacySnippetsEnvNotSupportedError, flushes telemetry+cache, and does not call the API", + () => { + const { layer, api, telemetry, cache } = setup({ goOutput: "env" }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacySnippetsList({ projectRef: Option.none() })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const dump = JSON.stringify(exit.cause); + expect(dump).toContain("LegacySnippetsEnvNotSupportedError"); + // Byte-exact match against Go's `ErrEnvNotSupported` + // (apps/cli-go/internal/utils/output.go:41). + expect(dump).toContain("--output env flag is not supported"); + } + expect(api.requests).toHaveLength(0); + // Go's PersistentPostRun + Execute both fire on this error path. + expect(telemetry.flushed).toBe(true); + expect(cache.cached).toBe(true); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("Go --output=pretty falls through to the text renderer", () => { + const { layer, out } = setup({ goOutput: "pretty" }); + return Effect.gen(function* () { + yield* legacySnippetsList({ projectRef: Option.none() }); + expect(out.stdoutText).toContain("VISIBILITY"); + expect(out.stdoutText).toContain(SNIPPET_ID); + }).pipe(Effect.provide(layer)); + }); + + it.live("Go --output wins over --output-format when both are set", () => { + const { layer, out } = setup({ format: "json", goOutput: "yaml" }); + return Effect.gen(function* () { + yield* legacySnippetsList({ projectRef: Option.none() }); + expect(out.stdoutText).toContain("data:"); + expect(out.stdoutText.startsWith("{")).toBe(false); + }).pipe(Effect.provide(layer)); + }); + + it.live("passes the resolved project_ref as a `project_ref` query parameter", () => { + const { layer, api } = setup(); + return Effect.gen(function* () { + yield* legacySnippetsList({ projectRef: Option.none() }); + expect(api.requests).toHaveLength(1); + expect(api.requests[0]?.urlWithParams).toContain( + `/v1/snippets?project_ref=${LEGACY_VALID_REF}`, + ); + }).pipe(Effect.provide(layer)); + }); + + it.live("uses --project-ref flag value over the resolver's linked-project default", () => { + const flagRef = "zzzzzzzzzzzzzzzzzzzz"; + const { layer, api } = setup(); + return Effect.gen(function* () { + yield* legacySnippetsList({ projectRef: Option.some(flagRef) }); + expect(api.requests[0]?.urlWithParams).toContain(`project_ref=${flagRef}`); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with LegacySnippetsListUnexpectedStatusError on HTTP 503", () => { + const { layer } = setup({ status: 503 }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacySnippetsList({ projectRef: Option.none() })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const dump = JSON.stringify(exit.cause); + expect(dump).toContain("LegacySnippetsListUnexpectedStatusError"); + expect(dump).toContain("unexpected list snippets status 503"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with LegacySnippetsListNetworkError on transport failure", () => { + const { layer } = setup({ network: "fail" }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacySnippetsList({ projectRef: Option.none() })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const dump = JSON.stringify(exit.cause); + expect(dump).toContain("LegacySnippetsListNetworkError"); + expect(dump).toContain("failed to list snippets"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("flushes telemetry and writes linked-project cache on success", () => { + const { layer, telemetry, cache } = setup(); + return Effect.gen(function* () { + yield* legacySnippetsList({ projectRef: Option.none() }); + expect(telemetry.flushed).toBe(true); + expect(cache.cached).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("flushes telemetry and writes linked-project cache even on API failure", () => { + const { layer, telemetry, cache } = setup({ status: 500 }); + return Effect.gen(function* () { + yield* Effect.exit(legacySnippetsList({ 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 }); + return Effect.gen(function* () { + yield* legacySnippetsList({ projectRef: Option.none() }).pipe(withJsonErrorHandling); + expect(out.messages.some((m) => m.type === "fail")).toBe(true); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/cli/src/legacy/commands/snippets/snippets.e2e.test.ts b/apps/cli/src/legacy/commands/snippets/snippets.e2e.test.ts new file mode 100644 index 0000000000..587b8371f6 --- /dev/null +++ b/apps/cli/src/legacy/commands/snippets/snippets.e2e.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from "vitest"; +import { runSupabase } from "../../../../tests/helpers/cli.ts"; + +const E2E_TIMEOUT_MS = 30_000; +const TEST_PROJECT_REF = "abcdefghijklmnopqrst"; +const TEST_TOKEN = "sbp_" + "a".repeat(40); + +describe("supabase snippets (legacy)", () => { + // Golden-path e2e: exercises the real subprocess boundary for the only + // API-free code path in `snippets download` — the UUID pre-check in + // `download.handler.ts`. This validates that the compiled-binary wiring + // (Command.provide, runtime layer, withJsonErrorHandling) correctly + // surfaces the Go-format `invalid snippet ID:` prefix to stdout/stderr + // with exit code 1. + test( + "download with invalid UUID exits 1 with Go-format message", + { timeout: E2E_TIMEOUT_MS }, + async () => { + const { exitCode, stdout, stderr } = await runSupabase( + ["snippets", "download", "not-a-uuid", "--project-ref", TEST_PROJECT_REF], + { entrypoint: "legacy", env: { SUPABASE_ACCESS_TOKEN: TEST_TOKEN } }, + ); + expect(exitCode).toBe(1); + expect(`${stdout}${stderr}`).toContain("invalid snippet ID"); + }, + ); +}); diff --git a/apps/cli/src/legacy/commands/snippets/snippets.errors.ts b/apps/cli/src/legacy/commands/snippets/snippets.errors.ts new file mode 100644 index 0000000000..e030544b4b --- /dev/null +++ b/apps/cli/src/legacy/commands/snippets/snippets.errors.ts @@ -0,0 +1,43 @@ +import { Data } from "effect"; + +export class LegacySnippetsListNetworkError extends Data.TaggedError( + "LegacySnippetsListNetworkError", +)<{ + readonly message: string; +}> {} + +export class LegacySnippetsListUnexpectedStatusError extends Data.TaggedError( + "LegacySnippetsListUnexpectedStatusError", +)<{ + readonly status: number; + readonly body: string; + readonly message: string; +}> {} + +// Mirrors Go's `utils.ErrEnvNotSupported` ("--output env is not supported"), +// returned from `list.Run` when `OutputFormat.Value == OutputEnv`. +export class LegacySnippetsEnvNotSupportedError extends Data.TaggedError( + "LegacySnippetsEnvNotSupportedError", +)<{ + readonly message: string; +}> {} + +// Wraps `uuid.Parse` failure in `download.Run`; message preserves Go's +// `invalid snippet ID: ` prefix so callers see the same string. +export class LegacySnippetsInvalidIdError extends Data.TaggedError("LegacySnippetsInvalidIdError")<{ + readonly message: string; +}> {} + +export class LegacySnippetsDownloadNetworkError extends Data.TaggedError( + "LegacySnippetsDownloadNetworkError", +)<{ + readonly message: string; +}> {} + +export class LegacySnippetsDownloadUnexpectedStatusError extends Data.TaggedError( + "LegacySnippetsDownloadUnexpectedStatusError", +)<{ + readonly status: number; + readonly body: string; + readonly message: string; +}> {} diff --git a/apps/cli/src/legacy/commands/snippets/snippets.format.ts b/apps/cli/src/legacy/commands/snippets/snippets.format.ts new file mode 100644 index 0000000000..a23974a105 --- /dev/null +++ b/apps/cli/src/legacy/commands/snippets/snippets.format.ts @@ -0,0 +1,40 @@ +import { renderGlamourTable } from "../../output/legacy-glamour-table.ts"; +import { formatLegacyTimestamp } from "../../shared/legacy-timestamp.format.ts"; + +const HEADERS = [ + "ID", + "NAME", + "VISIBILITY", + "OWNER", + "CREATED AT (UTC)", + "UPDATED AT (UTC)", +] as const; + +// Reproduces `strings.ReplaceAll(value, "|", "\\|")` from +// `apps/cli-go/internal/snippets/list/list.go:33-36`; every cell that can +// contain user-provided text needs this escape so markdown table parsers +// don't split the cell on an embedded pipe. +export function escapePipe(value: string): string { + return value.replaceAll("|", "\\|"); +} + +export interface SnippetRow { + readonly id: string; + readonly name: string; + readonly visibility: string; + readonly owner: { readonly username: string }; + readonly inserted_at: string; + readonly updated_at: string; +} + +export function renderSnippetsTable(items: ReadonlyArray): string { + const rows = items.map((snippet) => [ + snippet.id, + escapePipe(snippet.name), + escapePipe(snippet.visibility), + escapePipe(snippet.owner.username), + formatLegacyTimestamp(snippet.inserted_at), + formatLegacyTimestamp(snippet.updated_at), + ]); + return renderGlamourTable(HEADERS, rows); +} diff --git a/apps/cli/src/legacy/commands/snippets/snippets.format.unit.test.ts b/apps/cli/src/legacy/commands/snippets/snippets.format.unit.test.ts new file mode 100644 index 0000000000..d6d1e48870 --- /dev/null +++ b/apps/cli/src/legacy/commands/snippets/snippets.format.unit.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; + +import { escapePipe, renderSnippetsTable } from "./snippets.format.ts"; + +describe("escapePipe", () => { + it("escapes a single pipe", () => { + expect(escapePipe("a|b")).toBe("a\\|b"); + }); + + it("escapes all pipes in the value", () => { + expect(escapePipe("name|with|pipes")).toBe("name\\|with\\|pipes"); + }); + + it("returns the value unchanged when there is no pipe", () => { + expect(escapePipe("plain")).toBe("plain"); + }); + + it("returns the empty string unchanged", () => { + expect(escapePipe("")).toBe(""); + }); +}); + +describe("renderSnippetsTable", () => { + it("renders headers in a Glamour ASCII table", () => { + const out = renderSnippetsTable([]); + expect(out).toContain("ID"); + expect(out).toContain("NAME"); + expect(out).toContain("VISIBILITY"); + expect(out).toContain("OWNER"); + expect(out).toContain("CREATED AT (UTC)"); + expect(out).toContain("UPDATED AT (UTC)"); + }); + + it("escapes pipes in name, visibility, and owner.username", () => { + const out = renderSnippetsTable([ + { + id: "00000000-0000-0000-0000-000000000001", + name: "name|here", + visibility: "user|public", + owner: { username: "user|name" }, + inserted_at: "2023-10-13T17:48:58.491Z", + updated_at: "2023-10-13T17:48:58.491Z", + }, + ]); + expect(out).toContain("name\\|here"); + expect(out).toContain("user\\|public"); + expect(out).toContain("user\\|name"); + }); + + it("formats RFC3339 timestamps as UTC YYYY-MM-DD HH:MM:SS", () => { + const out = renderSnippetsTable([ + { + id: "00000000-0000-0000-0000-000000000001", + name: "n", + visibility: "user", + owner: { username: "u" }, + inserted_at: "2023-10-13T17:48:58.491Z", + updated_at: "2023-10-13T17:48:58.491Z", + }, + ]); + expect(out).toContain("2023-10-13 17:48:58"); + }); +}); diff --git a/apps/cli/src/legacy/shared/legacy-timestamp.format.ts b/apps/cli/src/legacy/shared/legacy-timestamp.format.ts new file mode 100644 index 0000000000..1d164e02d5 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-timestamp.format.ts @@ -0,0 +1,26 @@ +function pad2(value: number): string { + return value.toString().padStart(2, "0"); +} + +/** + * Reproduces `utils.FormatTimestamp` from `apps/cli-go/internal/utils/render.go:17`: + * parse RFC3339; on success format as UTC "YYYY-MM-DD HH:MM:SS"; on failure + * return the input verbatim. + */ +export function formatLegacyTimestamp(value: string): string { + if (value.length === 0) return value; + // Go uses time.Parse(time.RFC3339, value). Date.parse accepts a broader format + // surface, so we additionally require the year-month-day prefix to weed out + // values like "2026-02-08 16:44:07" (already-formatted) that Date.parse would + // happily accept but Go's strict RFC3339 parser would reject. + if (!/^\d{4}-\d{2}-\d{2}T/.test(value)) { + return value; + } + const parsed = Date.parse(value); + if (Number.isNaN(parsed)) return value; + const date = new Date(parsed); + return ( + `${date.getUTCFullYear()}-${pad2(date.getUTCMonth() + 1)}-${pad2(date.getUTCDate())} ` + + `${pad2(date.getUTCHours())}:${pad2(date.getUTCMinutes())}:${pad2(date.getUTCSeconds())}` + ); +} diff --git a/apps/cli/src/legacy/shared/legacy-timestamp.format.unit.test.ts b/apps/cli/src/legacy/shared/legacy-timestamp.format.unit.test.ts new file mode 100644 index 0000000000..a7b9166563 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-timestamp.format.unit.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; + +import { formatLegacyTimestamp } from "./legacy-timestamp.format.ts"; + +describe("formatLegacyTimestamp", () => { + it("formats valid RFC3339 to YYYY-MM-DD HH:MM:SS UTC", () => { + expect(formatLegacyTimestamp("2026-02-08T16:44:07Z")).toBe("2026-02-08 16:44:07"); + }); + + it("handles offsets by normalizing to UTC", () => { + expect(formatLegacyTimestamp("2026-02-08T18:44:07+02:00")).toBe("2026-02-08 16:44:07"); + }); + + it("falls back to the original value for already-formatted timestamps", () => { + // Go's time.Parse(time.RFC3339, ...) rejects "2026-02-08 16:44:07" (space, not T). + expect(formatLegacyTimestamp("2026-02-08 16:44:07")).toBe("2026-02-08 16:44:07"); + }); + + it("falls back for malformed input", () => { + expect(formatLegacyTimestamp("not-a-timestamp")).toBe("not-a-timestamp"); + }); + + it("returns empty string unchanged", () => { + expect(formatLegacyTimestamp("")).toBe(""); + }); +}); diff --git a/apps/cli/tests/helpers/legacy-mocks.ts b/apps/cli/tests/helpers/legacy-mocks.ts index 88ac993331..548fd6c033 100644 --- a/apps/cli/tests/helpers/legacy-mocks.ts +++ b/apps/cli/tests/helpers/legacy-mocks.ts @@ -10,6 +10,7 @@ import * as HttpClientError from "effect/unstable/http/HttpClientError"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import * as HttpClientRequestModule from "effect/unstable/http/HttpClientRequest"; import type * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import * as UrlParams from "effect/unstable/http/UrlParams"; import { afterEach, beforeEach } from "vitest"; import { LegacyCredentials } from "../../src/legacy/auth/legacy-credentials.service.ts"; @@ -199,6 +200,16 @@ export interface LegacyRecordedRequest { readonly method: string; readonly headers: Readonly>; readonly body?: unknown; + // Captured separately because Effect's HttpClient keeps `urlParams` on the + // request struct and only merges it into the final URL inside the real + // transport layer (`HttpClient.ts:747`). Tests that need to assert on + // GET-style query parameters (e.g. `/v1/snippets?project_ref=…`) read this + // serialized form instead of `url`. + readonly urlParams: string; + // Convenience: `url + "?" + urlParams` (or just `url` when there are none). + // Use this when an assertion wants to check the path and query in one + // string, mirroring what `curl -v` would print as the request line. + readonly urlWithParams: string; } export interface LegacyApiResponse { @@ -250,11 +261,14 @@ export function mockLegacyPlatformApi( body = decoded; } } + const params = UrlParams.toString(request.urlParams); const recorded: LegacyRecordedRequest = { url: request.url, method: request.method, headers: request.headers, body, + urlParams: params, + urlWithParams: params === "" ? request.url : `${request.url}?${params}`, }; requests.push(recorded); From fb4dee3c3f7ba009dbdd722fe27dd5da2197f7f6 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 29 May 2026 09:27:28 +0100 Subject: [PATCH 2/3] fix(cli): align snippets table pipe-escape with orgs port (Glamour round-trip) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The orgs port (#5379) documented an important parity rule: Go's `strings.ReplaceAll(value, "|", "\\|")` is a markdown-intermediate escape that Glamour decodes back to literal `|` in the rendered ASCII bytes. `renderGlamourTable` bypasses the markdown round-trip, so passing pre-escaped values produces `\|` in stdout where Go produces `|` — a byte-level parity divergence for any snippet name, visibility, or owner username containing a pipe. Fix: drop the `escapePipe` helper, pass raw values to renderGlamourTable. Updated unit + integration tests to assert literal `|` preservation (matching the pattern orgs.format.unit.test.ts uses). Also adopts the `yield* new ErrorClass(...)` shorthand from orgs handlers for tagged-error short-circuits. --- .../snippets/download/download.handler.ts | 8 ++--- .../commands/snippets/list/SIDE_EFFECTS.md | 4 ++- .../commands/snippets/list/list.handler.ts | 8 ++--- .../snippets/list/list.integration.test.ts | 16 +++++---- .../commands/snippets/snippets.format.ts | 32 +++++++++++------- .../snippets/snippets.format.unit.test.ts | 33 +++++-------------- 6 files changed, 48 insertions(+), 53 deletions(-) diff --git a/apps/cli/src/legacy/commands/snippets/download/download.handler.ts b/apps/cli/src/legacy/commands/snippets/download/download.handler.ts index f43b6b8469..6f1a4b8301 100644 --- a/apps/cli/src/legacy/commands/snippets/download/download.handler.ts +++ b/apps/cli/src/legacy/commands/snippets/download/download.handler.ts @@ -58,11 +58,9 @@ export const legacySnippetsDownload = Effect.fn("legacy.snippets.download")(func yield* Effect.gen(function* () { if (!UUID_RE.test(flags.snippetId)) { - return yield* Effect.fail( - new LegacySnippetsInvalidIdError({ - message: uuidErrorMessage(flags.snippetId), - }), - ); + return yield* new LegacySnippetsInvalidIdError({ + message: uuidErrorMessage(flags.snippetId), + }); } const fetching = diff --git a/apps/cli/src/legacy/commands/snippets/list/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/snippets/list/SIDE_EFFECTS.md index 371f2f6e1c..867b2331ac 100644 --- a/apps/cli/src/legacy/commands/snippets/list/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/snippets/list/SIDE_EFFECTS.md @@ -52,7 +52,9 @@ ### `--output-format text` / Go `--output pretty` (Go CLI compatible) -Glamour-styled ASCII table with columns `ID`, `NAME`, `VISIBILITY`, `OWNER`, `CREATED AT (UTC)`, `UPDATED AT (UTC)`. Pipe characters in `name` and `owner.username` are escaped as `\|` to match Go's `strings.ReplaceAll`. +Glamour-styled ASCII table with columns `ID`, `NAME`, `VISIBILITY`, `OWNER`, `CREATED AT (UTC)`, `UPDATED AT (UTC)`. Literal `|` characters in `name`, `visibility`, or `owner.username` are passed through verbatim (Go applies a `strings.ReplaceAll` markdown-intermediate escape that glamour decodes back; the final ASCII bytes carry the raw `|`). + +API-supplied strings are not stripped of ANSI / terminal control sequences before rendering — matches Go's glamour pass-through. ``` ID | NAME | VISIBILITY | OWNER | CREATED AT (UTC) | UPDATED AT (UTC) diff --git a/apps/cli/src/legacy/commands/snippets/list/list.handler.ts b/apps/cli/src/legacy/commands/snippets/list/list.handler.ts index 9c08129eba..92c5e24c14 100644 --- a/apps/cli/src/legacy/commands/snippets/list/list.handler.ts +++ b/apps/cli/src/legacy/commands/snippets/list/list.handler.ts @@ -46,11 +46,9 @@ export const legacySnippetsList = Effect.fn("legacy.snippets.list")(function* ( yield* Effect.gen(function* () { if (Option.getOrUndefined(goOutputFlag) === "env") { - return yield* Effect.fail( - new LegacySnippetsEnvNotSupportedError({ - message: "--output env flag is not supported", - }), - ); + return yield* new LegacySnippetsEnvNotSupportedError({ + message: "--output env flag is not supported", + }); } const fetching = diff --git a/apps/cli/src/legacy/commands/snippets/list/list.integration.test.ts b/apps/cli/src/legacy/commands/snippets/list/list.integration.test.ts index 96e7a06c0f..8c085ac3c5 100644 --- a/apps/cli/src/legacy/commands/snippets/list/list.integration.test.ts +++ b/apps/cli/src/legacy/commands/snippets/list/list.integration.test.ts @@ -45,10 +45,10 @@ const PIPE_RESPONSE: SnippetsResponse = { data: [ { ...SNIPPET_BASE, - // The Schema literal for `visibility` only accepts a fixed set, but Go - // applies the pipe-escape unconditionally — verify the escape on `name` - // and `owner.username` (the user-controlled fields that can realistically - // contain `|`). + // Go's `strings.ReplaceAll(value, "|", "\\|")` is a markdown-intermediate + // escape that glamour decodes back to literal `|` in the rendered ASCII + // bytes. `renderGlamourTable` bypasses glamour, so we pass raw values — + // any `|` in `name` / `owner.username` must appear literally in stdout. name: "name|with|pipes", owner: { id: 7, username: "user|name" }, }, @@ -124,12 +124,14 @@ describe("legacy snippets list integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("escapes `|` characters embedded in snippet name and owner username", () => { + it.live("preserves literal `|` characters in snippet name and owner username (Go parity)", () => { const { layer, out } = setup({ response: PIPE_RESPONSE }); return Effect.gen(function* () { yield* legacySnippetsList({ projectRef: Option.none() }); - expect(out.stdoutText).toContain("name\\|with\\|pipes"); - expect(out.stdoutText).toContain("user\\|name"); + expect(out.stdoutText).toContain("name|with|pipes"); + expect(out.stdoutText).toContain("user|name"); + // No `\|` escape — Go's intermediate escape is round-tripped by glamour. + expect(out.stdoutText).not.toContain("\\|"); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/legacy/commands/snippets/snippets.format.ts b/apps/cli/src/legacy/commands/snippets/snippets.format.ts index a23974a105..9b4b5f6ab0 100644 --- a/apps/cli/src/legacy/commands/snippets/snippets.format.ts +++ b/apps/cli/src/legacy/commands/snippets/snippets.format.ts @@ -1,6 +1,24 @@ import { renderGlamourTable } from "../../output/legacy-glamour-table.ts"; import { formatLegacyTimestamp } from "../../shared/legacy-timestamp.format.ts"; +// --------------------------------------------------------------------------- +// Pure formatter — no Effect / no service dependencies, kept unit-testable. +// Reproduces Go's `snippets/list/list.go:27-41` markdown-table + glamour pipeline. +// +// Go writes each cell wrapped in backticks with `strings.ReplaceAll(value, "|", "\\|")` +// applied; glamour then decodes the `\|` escape and strips the backticks, so the +// final ASCII bytes contain raw `|` (not `\|`). `renderGlamourTable` lays out +// cells directly without the markdown round-trip, so we pass raw values — any +// `|` in `name`, `visibility`, or `owner.username` appears literally in stdout, +// byte-matching the Go binary. (Same parity rule documented in orgs.format.ts.) +// +// Note (Go parity): API-supplied strings are NOT stripped of ANSI escape +// sequences or other terminal control bytes before rendering. Go's glamour +// has identical pass-through behaviour. If a future security review decides +// to sanitize, it should land at the renderer (`legacy-glamour-table.ts`), +// not per-command. +// --------------------------------------------------------------------------- + const HEADERS = [ "ID", "NAME", @@ -10,14 +28,6 @@ const HEADERS = [ "UPDATED AT (UTC)", ] as const; -// Reproduces `strings.ReplaceAll(value, "|", "\\|")` from -// `apps/cli-go/internal/snippets/list/list.go:33-36`; every cell that can -// contain user-provided text needs this escape so markdown table parsers -// don't split the cell on an embedded pipe. -export function escapePipe(value: string): string { - return value.replaceAll("|", "\\|"); -} - export interface SnippetRow { readonly id: string; readonly name: string; @@ -30,9 +40,9 @@ export interface SnippetRow { export function renderSnippetsTable(items: ReadonlyArray): string { const rows = items.map((snippet) => [ snippet.id, - escapePipe(snippet.name), - escapePipe(snippet.visibility), - escapePipe(snippet.owner.username), + snippet.name, + snippet.visibility, + snippet.owner.username, formatLegacyTimestamp(snippet.inserted_at), formatLegacyTimestamp(snippet.updated_at), ]); diff --git a/apps/cli/src/legacy/commands/snippets/snippets.format.unit.test.ts b/apps/cli/src/legacy/commands/snippets/snippets.format.unit.test.ts index d6d1e48870..ddfe7e6607 100644 --- a/apps/cli/src/legacy/commands/snippets/snippets.format.unit.test.ts +++ b/apps/cli/src/legacy/commands/snippets/snippets.format.unit.test.ts @@ -1,27 +1,9 @@ import { describe, expect, it } from "vitest"; -import { escapePipe, renderSnippetsTable } from "./snippets.format.ts"; - -describe("escapePipe", () => { - it("escapes a single pipe", () => { - expect(escapePipe("a|b")).toBe("a\\|b"); - }); - - it("escapes all pipes in the value", () => { - expect(escapePipe("name|with|pipes")).toBe("name\\|with\\|pipes"); - }); - - it("returns the value unchanged when there is no pipe", () => { - expect(escapePipe("plain")).toBe("plain"); - }); - - it("returns the empty string unchanged", () => { - expect(escapePipe("")).toBe(""); - }); -}); +import { renderSnippetsTable } from "./snippets.format.ts"; describe("renderSnippetsTable", () => { - it("renders headers in a Glamour ASCII table", () => { + it("renders headers in a Glamour ASCII table when the list is empty", () => { const out = renderSnippetsTable([]); expect(out).toContain("ID"); expect(out).toContain("NAME"); @@ -31,7 +13,7 @@ describe("renderSnippetsTable", () => { expect(out).toContain("UPDATED AT (UTC)"); }); - it("escapes pipes in name, visibility, and owner.username", () => { + it("preserves literal `|` characters in name, visibility, and owner (Glamour decodes Go's escape back)", () => { const out = renderSnippetsTable([ { id: "00000000-0000-0000-0000-000000000001", @@ -42,9 +24,12 @@ describe("renderSnippetsTable", () => { updated_at: "2023-10-13T17:48:58.491Z", }, ]); - expect(out).toContain("name\\|here"); - expect(out).toContain("user\\|public"); - expect(out).toContain("user\\|name"); + expect(out).toContain("name|here"); + expect(out).toContain("user|public"); + expect(out).toContain("user|name"); + // No `\|` escape — Go's `strings.ReplaceAll` is a markdown intermediate + // that glamour decodes; the final bytes carry the raw `|`. + expect(out).not.toContain("\\|"); }); it("formats RFC3339 timestamps as UTC YYYY-MM-DD HH:MM:SS", () => { From ea5b15babbfdb6f4e6aeb08fd40dffd58b9b385d Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 29 May 2026 09:44:36 +0100 Subject: [PATCH 3/3] fix(cli): bypass typed API client for snippets to tolerate missing description MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cli-e2e suite caught two failures: 1. `snippets:download > prints SQL content to stdout` exited 1 with `SchemaError: Missing key at ["description"]`. The generated `V1GetASnippetOutput` schema declares `description` as `Union[String, Null]` (present-but-nullable), but real `/v1/snippets/{id}` responses omit the field entirely. Strict decode through `LegacyPlatformApi` therefore fails on every realistic payload — only the synthetic Go unit-test fixture (which uses a nil slice) round-trips. 2. `snippets:list --output json` returned `{"data": null}` while the test expects `Array.isArray(parsed.data)` to be true. The `nullForEmptyArrays` hack was modelling a Go unit-fixture (nil slice → null) rather than the actual API behaviour (empty array → []). Go's `encoding/json` preserves nil-vs-empty faithfully; we did not. Fix: drop both handlers to raw `HttpClient.HttpClient` (same workaround as `legacy-linked-project-cache.layer.ts` and `legacySuggestUpgrade`), and remove `nullForEmptyArrays: ["data"]`. The raw bypass lets us echo whatever the API actually returned — preserving `[]` when the API sends `[]`, and tolerating responses without `description`. Adds tolerant accessors (`readString`, `asRecord`, `parseSnippetsResponse`, `toSnippetRow`, `readSql`) to extract only the fields the renderer needs. Keeps error mapping byte-exact (`failed to list snippets`, `unexpected list snippets status N: …`, equivalents for download), spinner / output-mode dispatch, and the nested `Effect.ensuring` lifecycle blocks for telemetry and linked-project cache parity. Updated the unit test that asserted `{"data": null}` for empty input — the realistic round-trip is `{"data": []}` now. All 36 snippets in-process tests pass; the 17 snippets e2e tests pass locally. --- .../snippets/download/download.handler.ts | 80 ++++++++--- .../commands/snippets/list/list.handler.ts | 124 ++++++++++++++---- .../snippets/list/list.integration.test.ts | 9 +- 3 files changed, 167 insertions(+), 46 deletions(-) diff --git a/apps/cli/src/legacy/commands/snippets/download/download.handler.ts b/apps/cli/src/legacy/commands/snippets/download/download.handler.ts index 6f1a4b8301..086709b41e 100644 --- a/apps/cli/src/legacy/commands/snippets/download/download.handler.ts +++ b/apps/cli/src/legacy/commands/snippets/download/download.handler.ts @@ -1,9 +1,12 @@ -import { Effect } from "effect"; +import { Effect, Option } from "effect"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; -import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; +import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; import { Output } from "../../../../shared/output/output.service.ts"; -import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; +import { resolveLegacyAccessToken } from "../../../shared/legacy-resolve-token.ts"; +import { 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 { @@ -21,13 +24,6 @@ import type { LegacySnippetsDownloadFlags } from "./download.command.ts"; // `invalid snippet ID:` prefix from `apps/cli-go/internal/snippets/download/download.go:17`. const UUID_RE = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/; -const mapDownloadError = mapLegacyHttpError({ - networkError: LegacySnippetsDownloadNetworkError, - statusError: LegacySnippetsDownloadUnexpectedStatusError, - networkMessage: (cause) => `failed to download snippet: ${cause}`, - statusMessage: (status, body) => `unexpected download snippet status ${status}: ${body}`, -}); - // Mirrors Go's `uuid.Parse` (google/uuid v1.6.0) error surface: // - len(s) not in {32, 36, 38, 41} → `invalid UUID length: N` // - len(s) == 36 but dashes/hex chars wrong → `invalid UUID format` @@ -41,18 +37,31 @@ function uuidErrorMessage(value: string): string { return "invalid snippet ID: invalid UUID format"; } +// Tolerant body parse — see `list.handler.ts` for the rationale. The real +// `/v1/snippets/{id}` payload omits `description`, which the generated +// `V1GetASnippetOutput` schema declares as `Union[String, Null]` (required). +// Routing through the typed client surfaces `SchemaError: Missing key …` on +// every non-test response. Same workaround as `legacy-linked-project-cache.layer.ts`. +function asRecord(obj: unknown): Record { + return typeof obj === "object" && obj !== null ? (obj as Record) : {}; +} + +function readSql(body: unknown): string { + const content = asRecord(asRecord(body)["content"]); + const sql = content["sql"]; + return typeof sql === "string" ? sql : ""; +} + export const legacySnippetsDownload = Effect.fn("legacy.snippets.download")(function* ( flags: LegacySnippetsDownloadFlags, ) { const output = yield* Output; - const api = yield* LegacyPlatformApi; + const httpClient = yield* HttpClient.HttpClient; + const cliConfig = yield* LegacyCliConfig; const resolver = yield* LegacyProjectRefResolver; const linkedProjectCache = yield* LegacyLinkedProjectCache; const telemetryState = yield* LegacyTelemetryState; - // Same lifecycle as `list` — see that handler for the Go cross-reference. - // The UUID short-circuit lives inside the inner block so the linked-project - // cache still fires (Go's PersistentPostRun runs after the failing Run). yield* Effect.gen(function* () { const ref = yield* resolver.resolve(flags.projectRef); @@ -63,11 +72,46 @@ export const legacySnippetsDownload = Effect.fn("legacy.snippets.download")(func }); } + const tokenOpt = yield* resolveLegacyAccessToken; + const authHeader: ( + req: HttpClientRequest.HttpClientRequest, + ) => HttpClientRequest.HttpClientRequest = Option.isSome(tokenOpt) + ? HttpClientRequest.bearerToken(tokenOpt.value) + : (req) => req; + const request = HttpClientRequest.get( + `${cliConfig.apiUrl}/v1/snippets/${flags.snippetId}`, + ).pipe(authHeader, HttpClientRequest.setHeader("User-Agent", cliConfig.userAgent)); + const fetching = output.format === "text" ? yield* output.task("Downloading snippet...") : undefined; - const response = yield* api.v1.getASnippet({ id: flags.snippetId }).pipe( + const response = yield* httpClient.execute(request).pipe( Effect.tapError(() => fetching?.fail() ?? Effect.void), - Effect.catch(mapDownloadError), + Effect.catch( + (cause) => + new LegacySnippetsDownloadNetworkError({ + message: `failed to download snippet: ${cause.reason.description ?? cause.reason._tag}`, + }), + ), + ); + + if (response.status !== 200) { + yield* fetching?.fail() ?? Effect.void; + const rawBody = yield* response.text.pipe(Effect.orElseSucceed(() => "")); + const body = sanitizeLegacyErrorBody(rawBody); + return yield* new LegacySnippetsDownloadUnexpectedStatusError({ + status: response.status, + body, + message: `unexpected download snippet status ${response.status}: ${body}`, + }); + } + + const rawBody = yield* response.json.pipe( + Effect.catch( + (cause) => + new LegacySnippetsDownloadNetworkError({ + message: `failed to download snippet: ${String(cause)}`, + }), + ), ); yield* fetching?.clear() ?? Effect.void; @@ -76,14 +120,14 @@ export const legacySnippetsDownload = Effect.fn("legacy.snippets.download")(func // alongside `content.sql`, matching the SIDE_EFFECTS.md contract and the // shape `snippets list --output-format json` uses for its response. if (output.format === "json" || output.format === "stream-json") { - yield* output.success("", response); + yield* output.success("", asRecord(rawBody)); return; } // Go's `download.Run` ignores `--output` entirely and always runs // `fmt.Println(resp.JSON200.Content.Sql)` (download.go:25). Mirror that: // no branching on `LegacyOutputFlag`. - yield* output.raw(response.content.sql + "\n"); + yield* output.raw(readSql(rawBody) + "\n"); }).pipe(Effect.ensuring(linkedProjectCache.cache(ref))); }).pipe(Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/snippets/list/list.handler.ts b/apps/cli/src/legacy/commands/snippets/list/list.handler.ts index 92c5e24c14..fbbf5212dd 100644 --- a/apps/cli/src/legacy/commands/snippets/list/list.handler.ts +++ b/apps/cli/src/legacy/commands/snippets/list/list.handler.ts @@ -1,11 +1,14 @@ import { Effect, Option } from "effect"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; -import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; +import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; import { LegacyOutputFlag } from "../../../../shared/legacy/global-flags.ts"; import { Output } from "../../../../shared/output/output.service.ts"; import { encodeGoJson, encodeToml, encodeYaml } from "../../../shared/legacy-go-output.encoders.ts"; -import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; +import { resolveLegacyAccessToken } from "../../../shared/legacy-resolve-token.ts"; +import { 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 { @@ -13,22 +16,59 @@ import { LegacySnippetsListNetworkError, LegacySnippetsListUnexpectedStatusError, } from "../snippets.errors.ts"; -import { renderSnippetsTable } from "../snippets.format.ts"; +import { renderSnippetsTable, type SnippetRow } from "../snippets.format.ts"; import type { LegacySnippetsListFlags } from "./list.command.ts"; -const mapListError = mapLegacyHttpError({ - networkError: LegacySnippetsListNetworkError, - statusError: LegacySnippetsListUnexpectedStatusError, - networkMessage: (cause) => `failed to list snippets: ${cause}`, - statusMessage: (status, body) => `unexpected list snippets status ${status}: ${body}`, -}); +// Tolerant accessors for the API response body. The real `/v1/snippets` +// payload regularly omits optional fields like `description` that the +// generated `V1ListAllSnippetsOutput` schema declares as `Union[String, Null]` +// (present-but-nullable). Routing through the typed client therefore fails +// with a `SchemaError: Missing key …` on any real-world response — see the +// cli-e2e `snippets-download-prints-sql-content-to-stdout` failure that +// prompted the bypass. Same workaround pattern as +// `legacy-linked-project-cache.layer.ts` and `legacySuggestUpgrade`. +function readString(obj: unknown, key: string): string { + if (typeof obj === "object" && obj !== null && key in obj) { + const value = (obj as Record)[key]; + return typeof value === "string" ? value : ""; + } + return ""; +} + +function asRecord(obj: unknown): Record { + return typeof obj === "object" && obj !== null ? (obj as Record) : {}; +} + +interface SnippetsResponseBody { + readonly data: ReadonlyArray; +} + +function parseSnippetsResponse(body: unknown): SnippetsResponseBody { + const root = asRecord(body); + const data = Array.isArray(root["data"]) ? root["data"] : []; + return { data }; +} + +function toSnippetRow(raw: unknown): SnippetRow { + const item = asRecord(raw); + const owner = asRecord(item["owner"]); + return { + id: readString(item, "id"), + name: readString(item, "name"), + visibility: readString(item, "visibility"), + owner: { username: readString(owner, "username") }, + inserted_at: readString(item, "inserted_at"), + updated_at: readString(item, "updated_at"), + }; +} export const legacySnippetsList = Effect.fn("legacy.snippets.list")(function* ( flags: LegacySnippetsListFlags, ) { const output = yield* Output; const goOutputFlag = yield* LegacyOutputFlag; - const api = yield* LegacyPlatformApi; + const httpClient = yield* HttpClient.HttpClient; + const cliConfig = yield* LegacyCliConfig; const resolver = yield* LegacyProjectRefResolver; const linkedProjectCache = yield* LegacyLinkedProjectCache; const telemetryState = yield* LegacyTelemetryState; @@ -38,9 +78,6 @@ export const legacySnippetsList = Effect.fn("legacy.snippets.list")(function* ( // Run → reject --output env / call API / render // PersistentPostRun → write linked-project cache (needs `ref`) // Execute → flush telemetry (no `ref` required) - // The two `Effect.ensuring` blocks model the post-run order exactly: - // `telemetryState.flush` runs on every exit, `linkedProjectCache.cache(ref)` - // runs whenever ref resolution succeeded. yield* Effect.gen(function* () { const ref = yield* resolver.resolve(flags.projectRef); @@ -51,40 +88,77 @@ export const legacySnippetsList = Effect.fn("legacy.snippets.list")(function* ( }); } + const tokenOpt = yield* resolveLegacyAccessToken; + const authHeader: ( + req: HttpClientRequest.HttpClientRequest, + ) => HttpClientRequest.HttpClientRequest = Option.isSome(tokenOpt) + ? HttpClientRequest.bearerToken(tokenOpt.value) + : (req) => req; + const request = HttpClientRequest.get(`${cliConfig.apiUrl}/v1/snippets`).pipe( + HttpClientRequest.setUrlParams({ project_ref: ref }), + authHeader, + HttpClientRequest.setHeader("User-Agent", cliConfig.userAgent), + ); + const fetching = output.format === "text" ? yield* output.task("Fetching snippets...") : undefined; - const response = yield* api.v1.listAllSnippets({ project_ref: ref }).pipe( + const response = yield* httpClient.execute(request).pipe( Effect.tapError(() => fetching?.fail() ?? Effect.void), - Effect.catch(mapListError), + Effect.catch( + (cause) => + new LegacySnippetsListNetworkError({ + message: `failed to list snippets: ${cause.reason.description ?? cause.reason._tag}`, + }), + ), + ); + + if (response.status !== 200) { + yield* fetching?.fail() ?? Effect.void; + const rawBody = yield* response.text.pipe(Effect.orElseSucceed(() => "")); + const body = sanitizeLegacyErrorBody(rawBody); + return yield* new LegacySnippetsListUnexpectedStatusError({ + status: response.status, + body, + message: `unexpected list snippets status ${response.status}: ${body}`, + }); + } + + const rawBody = yield* response.json.pipe( + Effect.catch( + (cause) => + new LegacySnippetsListNetworkError({ + message: `failed to list snippets: ${String(cause)}`, + }), + ), ); yield* fetching?.clear() ?? Effect.void; + const parsed = parseSnippetsResponse(rawBody); const goFmt = Option.getOrUndefined(goOutputFlag); if (goFmt === "json") { - // Go marshals `SnippetList{}` (nil Data slice) as `{"data": null}`; - // the generated schema decodes nil → []. `nullForEmptyArrays` preserves - // the Go-bytes shape for the empty-list fixture. - yield* output.raw(encodeGoJson(response, { nullForEmptyArrays: ["data"] })); + // Round-trip the raw body so a real API `data: []` stays `data: []` + // (and a hypothetical `data: null` would stay null). Go's + // `encoding/json` preserves nil-vs-empty; bypassing the typed client + // means we can faithfully mirror that here too. + yield* output.raw(encodeGoJson(rawBody)); return; } if (goFmt === "yaml") { - yield* output.raw(encodeYaml(response)); + yield* output.raw(encodeYaml(rawBody)); return; } if (goFmt === "toml") { - yield* output.raw(encodeToml(response) + "\n"); + yield* output.raw(encodeToml(asRecord(rawBody)) + "\n"); return; } - // goFmt is undefined or "pretty" — defer to TS --output-format for - // JSON/stream-json, otherwise render the Glamour table. if (output.format === "json" || output.format === "stream-json") { - yield* output.success("", response); + yield* output.success("", asRecord(rawBody)); return; } - yield* output.raw(renderSnippetsTable(response.data)); + yield* output.raw(renderSnippetsTable(parsed.data.map(toSnippetRow))); }).pipe(Effect.ensuring(linkedProjectCache.cache(ref))); }).pipe(Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/snippets/list/list.integration.test.ts b/apps/cli/src/legacy/commands/snippets/list/list.integration.test.ts index 8c085ac3c5..ec48c7fa33 100644 --- a/apps/cli/src/legacy/commands/snippets/list/list.integration.test.ts +++ b/apps/cli/src/legacy/commands/snippets/list/list.integration.test.ts @@ -172,13 +172,16 @@ describe("legacy snippets list integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("Go --output=json emits alphabetically-keyed JSON with `data: null` for empty", () => { + it.live("Go --output=json emits alphabetically-keyed JSON, preserving empty arrays", () => { const { layer, out } = setup({ goOutput: "json", response: EMPTY_RESPONSE }); return Effect.gen(function* () { yield* legacySnippetsList({ projectRef: Option.none() }); - // Mirrors Go's `api.SnippetList{}` → `{"data": null}\n` (no other keys). + // The API returns `{"data": []}`; Go's `encoding/json` round-trip + // preserves nil-vs-empty (real responses always send `[]`, never null). + // Our raw-HTTP bypass means we faithfully echo whatever the API sent — + // no `nullForEmptyArrays` coercion. expect(out.stdoutText).toBe(`{ - "data": null + "data": [] } `); }).pipe(Effect.provide(layer));