From 9b583fd807127aaf1cadb20a01d615b4be0275f8 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 30 Jul 2026 13:29:48 +0100 Subject: [PATCH 1/9] fix(cli): match Go encoder casing for -o toml/yaml and JSON HTML escaping (CLI-1975) --- apps/cli/AGENTS.md | 1 + .../commands/backups/list/SIDE_EFFECTS.md | 11 +- .../commands/backups/list/list.handler.ts | 55 +- .../backups/list/list.integration.test.ts | 29 +- .../commands/branches/branches.go-payload.ts | 51 + .../branches/create/create.handler.ts | 14 +- .../branches/get/get.integration.test.ts | 14 + .../commands/branches/list/list.handler.ts | 21 +- .../branches/list/list.integration.test.ts | 85 +- .../branches/update/update.handler.ts | 14 +- .../legacy/commands/domains/SIDE_EFFECTS.md | 1 - .../domains/create/create.integration.test.ts | 3 +- .../legacy/commands/domains/domains.emit.ts | 75 +- .../domains/get/get.integration.test.ts | 6 +- .../commands/functions/list/list.encoders.ts | 97 +- .../functions/list/list.encoders.unit.test.ts | 33 +- .../commands/functions/list/list.handler.ts | 2 +- .../functions/list/list.integration.test.ts | 11 +- .../commands/orgs/create/create.handler.ts | 14 +- .../orgs/create/create.integration.test.ts | 3 +- .../legacy/commands/orgs/list/list.handler.ts | 11 +- .../orgs/list/list.integration.test.ts | 3 +- .../legacy/commands/orgs/orgs.go-payload.ts | 29 + .../projects/api-keys/api-keys.handler.ts | 38 +- .../create/create.integration.test.ts | 3 +- .../commands/projects/list/list.handler.ts | 56 +- .../projects/list/list.integration.test.ts | 6 +- .../commands/secrets/list/SIDE_EFFECTS.md | 2 +- .../commands/secrets/list/list.handler.ts | 29 +- .../secrets/list/list.integration.test.ts | 5 +- .../commands/services/services.handler.ts | 34 +- .../services/services.integration.test.ts | 4 +- .../commands/snippets/list/list.handler.ts | 73 +- .../snippets/list/list.integration.test.ts | 57 +- .../commands/snippets/snippets.errors.ts | 10 + .../ssl-enforcement/get/get.handler.ts | 14 +- .../get/get.integration.test.ts | 8 +- .../ssl-enforcement.go-payload.ts | 15 + .../ssl-enforcement/update/update.handler.ts | 14 +- .../update/update.integration.test.ts | 8 +- .../legacy/commands/sso/add/SIDE_EFFECTS.md | 2 +- .../legacy/commands/sso/add/add.handler.ts | 14 +- .../legacy/commands/sso/list/SIDE_EFFECTS.md | 2 +- .../legacy/commands/sso/list/list.handler.ts | 14 +- .../commands/sso/remove/SIDE_EFFECTS.md | 2 +- .../commands/sso/remove/remove.handler.ts | 11 +- .../legacy/commands/sso/show/SIDE_EFFECTS.md | 2 +- .../legacy/commands/sso/show/show.handler.ts | 11 +- .../sso/show/show.integration.test.ts | 42 +- .../src/legacy/commands/sso/sso.go-payload.ts | 75 ++ .../commands/sso/update/SIDE_EFFECTS.md | 2 +- .../commands/sso/update/update.handler.ts | 14 +- .../activate/activate.handler.ts | 18 +- .../check-availability.handler.ts | 18 +- .../vanity-subdomains/get/get.handler.ts | 29 +- .../vanity-subdomains.integration.test.ts | 18 +- apps/cli/src/legacy/shared/legacy-go-json.ts | 29 +- .../legacy/shared/legacy-go-json.unit.test.ts | 16 +- .../shared/legacy-go-output.encoders.ts | 41 +- .../legacy-go-struct-output.encoders.ts | 1123 +++++++++++++++++ ...acy-go-struct-output.encoders.unit.test.ts | 722 +++++++++++ .../shared/legacy-project-create-core.ts | 28 +- .../src/shared/services/services.shared.ts | 4 - 63 files changed, 2919 insertions(+), 277 deletions(-) create mode 100644 apps/cli/src/legacy/commands/branches/branches.go-payload.ts create mode 100644 apps/cli/src/legacy/commands/orgs/orgs.go-payload.ts create mode 100644 apps/cli/src/legacy/commands/ssl-enforcement/ssl-enforcement.go-payload.ts create mode 100644 apps/cli/src/legacy/commands/sso/sso.go-payload.ts create mode 100644 apps/cli/src/legacy/shared/legacy-go-struct-output.encoders.ts create mode 100644 apps/cli/src/legacy/shared/legacy-go-struct-output.encoders.unit.test.ts diff --git a/apps/cli/AGENTS.md b/apps/cli/AGENTS.md index 6596169854..f2302dc902 100644 --- a/apps/cli/AGENTS.md +++ b/apps/cli/AGENTS.md @@ -160,6 +160,7 @@ src/legacy/commands// .layers.ts # runtime layer composition for the command family .format.ts # text formatters (timestamps, regions, booleans) .encoders.ts # Go-compatible JSON / YAML / TOML / env encoders + .go-payload.ts # Go struct specs mirroring types.gen.go — drive `-o yaml|toml` key casing (CLI-1975) SIDE_EFFECTS.md ``` diff --git a/apps/cli/src/legacy/commands/backups/list/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/backups/list/SIDE_EFFECTS.md index cff8a366c1..2fd5ddd678 100644 --- a/apps/cli/src/legacy/commands/backups/list/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/backups/list/SIDE_EFFECTS.md @@ -66,11 +66,18 @@ Indented JSON (`json.MarshalIndent(resp, "", " ")` equivalent) of the full back ### `--output yaml` -YAML document (`yaml@2` equivalent of Go's `yaml.v3`) of the full backup response. +YAML document matching Go's `yaml.v3` output byte-for-byte (CLI-1975): keys are +the lowercased Go struct field names (`walgenabled`, `physicalbackupdata`), nil +pointers render as explicit `null`, and nested mappings use yaml.v3's 4-column +indentation. ### `--output toml` -TOML document (`smol-toml` equivalent of Go's `BurntSushi/toml`) of the full backup response. JSON shape is preserved; leaf order may differ from Go. +TOML document matching Go's `BurntSushi/toml` output byte-for-byte (CLI-1975): +keys are the PascalCase Go struct field names (`WalgEnabled`, +`[PhysicalBackupData]`), nil pointer fields are omitted, and sub-tables follow +the primitive keys with 2-space indentation. An empty `backups` array is +treated as Go's nil slice (omitted). ### `--output env` 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 496fa0f5b8..db84b0e474 100644 --- a/apps/cli/src/legacy/commands/backups/list/list.handler.ts +++ b/apps/cli/src/legacy/commands/backups/list/list.handler.ts @@ -12,17 +12,47 @@ import { LegacyBackupListNetworkError, LegacyBackupListUnexpectedStatusError, } from "../backups.errors.ts"; +import { encodeEnv, encodeGoJson } from "../../../shared/legacy-go-output.encoders.ts"; import { - encodeEnv, - encodeGoJson, - encodeToml, - encodeYaml, -} from "../../../shared/legacy-go-output.encoders.ts"; + encodeLegacyGoToml, + encodeLegacyGoYaml, + legacyGoBool, + legacyGoInt, + legacyGoPtr, + legacyGoSlice, + legacyGoString, + legacyGoStruct, +} from "../../../shared/legacy-go-struct-output.encoders.ts"; import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; import { formatLegacyTimestamp } from "../../../shared/legacy-timestamp.format.ts"; import { formatRegion } from "../backups.format.ts"; import type { LegacyBackupsListFlags } from "./list.command.ts"; +/** Mirror of Go's `api.V1BackupsResponse` (`apps/cli-go/pkg/api/types.gen.go`). */ +const LEGACY_GO_BACKUPS_RESPONSE = legacyGoStruct([ + [ + "backups", + legacyGoSlice( + legacyGoStruct([ + ["id", legacyGoInt], + ["inserted_at", legacyGoString], + ["is_physical_backup", legacyGoBool], + ["status", legacyGoString], + ]), + ), + ], + [ + "physical_backup_data", + legacyGoStruct([ + ["earliest_physical_backup_date_unix", legacyGoPtr(legacyGoInt)], + ["latest_physical_backup_date_unix", legacyGoPtr(legacyGoInt)], + ]), + ], + ["pitr_enabled", legacyGoBool], + ["region", legacyGoString], + ["walg_enabled", legacyGoBool], +]); + type BackupsResponse = typeof V1ListAllBackupsOutput.Type; const mapListError = mapLegacyHttpError({ @@ -95,11 +125,22 @@ export const legacyBackupsList = Effect.fn("legacy.backups.list")(function* ( return; } if (goFmt === "yaml") { - yield* output.raw(encodeYaml(response)); + yield* output.raw(encodeLegacyGoYaml(response, LEGACY_GO_BACKUPS_RESPONSE)); return; } if (goFmt === "toml") { - yield* output.raw(encodeToml(response) + "\n"); + // The schema decodes Go's PITR-only `"backups": null` to `[]` (see the + // `nullForEmptyArrays` JSON hint above); mirror that by treating an + // empty list as Go's nil slice, which BurntSushi omits entirely. + yield* output.raw( + encodeLegacyGoToml( + { + ...response, + backups: response.backups.length > 0 ? response.backups : undefined, + }, + LEGACY_GO_BACKUPS_RESPONSE, + ), + ); return; } if (goFmt === "env") { diff --git a/apps/cli/src/legacy/commands/backups/list/list.integration.test.ts b/apps/cli/src/legacy/commands/backups/list/list.integration.test.ts index 2dfdc91641..d889e13a1d 100644 --- a/apps/cli/src/legacy/commands/backups/list/list.integration.test.ts +++ b/apps/cli/src/legacy/commands/backups/list/list.integration.test.ts @@ -165,7 +165,8 @@ describe("legacy backups list integration", () => { return Effect.gen(function* () { yield* legacyBackupsList({ projectRef: Option.none() }); expect(out.stdoutText).toContain("region: ap-southeast-1"); - expect(out.stdoutText).toContain("walg_enabled: true"); + // yaml.v3 lowercases the whole Go field name (CLI-1975). + expect(out.stdoutText).toContain("walgenabled: true"); }).pipe(Effect.provide(layer)); }); @@ -173,8 +174,30 @@ describe("legacy backups list integration", () => { const { layer, out } = setup({ goOutput: "toml", response: PITR_RESPONSE }); return Effect.gen(function* () { yield* legacyBackupsList({ projectRef: Option.none() }); - expect(out.stdoutText).toContain('region = "ap-southeast-1"'); - expect(out.stdoutText).toContain("walg_enabled = true"); + // BurntSushi emits PascalCase Go field names (CLI-1975). + expect(out.stdoutText).toContain('Region = "ap-southeast-1"'); + expect(out.stdoutText).toContain("WalgEnabled = true"); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits [[Backups]] array-of-tables for --output toml with logical backups", () => { + const { layer, out } = setup({ goOutput: "toml", response: LOGICAL_RESPONSE }); + return Effect.gen(function* () { + yield* legacyBackupsList({ projectRef: Option.none() }); + // Byte-exact Go parity (CLI-1975): primitives first, then the Backups + // array-of-tables and the (empty) PhysicalBackupData table. + expect(out.stdoutText).toBe(`PitrEnabled = true +Region = "ap-southeast-1" +WalgEnabled = true + +[[Backups]] + Id = 1 + InsertedAt = "2026-02-08T16:44:07Z" + IsPhysicalBackup = true + Status = "COMPLETED" + +[PhysicalBackupData] +`); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/legacy/commands/branches/branches.go-payload.ts b/apps/cli/src/legacy/commands/branches/branches.go-payload.ts new file mode 100644 index 0000000000..78d5f8fabf --- /dev/null +++ b/apps/cli/src/legacy/commands/branches/branches.go-payload.ts @@ -0,0 +1,51 @@ +import { + type LegacyGoType, + legacyGoBool, + legacyGoFloat32, + legacyGoInt, + legacyGoPtr, + legacyGoSlice, + legacyGoString, + legacyGoStruct, + legacyGoTime, + legacyGoTomlListWrapper, + legacyGoUuid, +} from "../../shared/legacy-go-struct-output.encoders.ts"; + +/** + * Mirror of Go's `api.BranchResponse` (`apps/cli-go/pkg/api/types.gen.go`) — + * field order and pointer-ness drive the `-o yaml` / `-o toml` byte shape + * (CLI-1975). Shared by `branches list`, `branches create`, and + * `branches update`, which all encode this struct. + */ +export const LEGACY_GO_BRANCH_RESPONSE: LegacyGoType = legacyGoStruct([ + ["created_at", legacyGoTime], + ["deletion_scheduled_at", legacyGoPtr(legacyGoTime)], + ["git_branch", legacyGoPtr(legacyGoString)], + ["id", legacyGoUuid], + ["is_default", legacyGoBool], + ["latest_check_run_id", legacyGoPtr(legacyGoFloat32)], + ["name", legacyGoString], + ["notify_url", legacyGoPtr(legacyGoString)], + ["parent_project_ref", legacyGoString], + ["persistent", legacyGoBool], + ["pr_number", legacyGoPtr(legacyGoInt)], + ["preview_project_status", legacyGoPtr(legacyGoString)], + ["project_ref", legacyGoString], + ["review_requested_at", legacyGoPtr(legacyGoTime)], + ["status", legacyGoString], + ["updated_at", legacyGoTime], + ["with_data", legacyGoBool], +]); + +/** `branches list -o yaml` encodes the bare `[]api.BranchResponse`. */ +export const LEGACY_GO_BRANCHES_LIST: LegacyGoType = legacyGoSlice(LEGACY_GO_BRANCH_RESPONSE); + +/** + * `branches list -o toml` wraps the slice: + * `struct{ Branches []api.BranchResponse `toml:"branches"` }`. + */ +export const LEGACY_GO_BRANCHES_TOML_WRAPPER: LegacyGoType = legacyGoTomlListWrapper( + "branches", + LEGACY_GO_BRANCH_RESPONSE, +); diff --git a/apps/cli/src/legacy/commands/branches/create/create.handler.ts b/apps/cli/src/legacy/commands/branches/create/create.handler.ts index b952999506..1d6f185d12 100644 --- a/apps/cli/src/legacy/commands/branches/create/create.handler.ts +++ b/apps/cli/src/legacy/commands/branches/create/create.handler.ts @@ -11,14 +11,14 @@ import { CONTEXT_CANCELED_MESSAGE } from "../../../../shared/output/errors.ts"; import { Output } from "../../../../shared/output/output.service.ts"; import { detectGitBranch } from "../../../../shared/git/git-branch.ts"; import { legacyAqua } from "../../../shared/legacy-colors.ts"; +import { encodeEnv, encodeGoJson } from "../../../shared/legacy-go-output.encoders.ts"; import { - encodeEnv, - encodeGoJson, - encodeToml, - encodeYaml, -} from "../../../shared/legacy-go-output.encoders.ts"; + encodeLegacyGoToml, + encodeLegacyGoYaml, +} from "../../../shared/legacy-go-struct-output.encoders.ts"; import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; import { legacyGateMapError } from "../../../shared/legacy-upgrade-suggest.ts"; +import { LEGACY_GO_BRANCH_RESPONSE } from "../branches.go-payload.ts"; import { LegacyBranchesCreateCancelledError, LegacyBranchesCreateNetworkError, @@ -123,12 +123,12 @@ export const legacyBranchesCreate = Effect.fn("legacy.branches.create")(function } if (goFmt === "yaml") { yield* output.raw("Created preview branch:\n"); - yield* output.raw(encodeYaml(created)); + yield* output.raw(encodeLegacyGoYaml(created, LEGACY_GO_BRANCH_RESPONSE)); return; } if (goFmt === "toml") { yield* output.raw("Created preview branch:\n"); - yield* output.raw(encodeToml(created) + "\n"); + yield* output.raw(encodeLegacyGoToml(created, LEGACY_GO_BRANCH_RESPONSE)); return; } if (goFmt === "env") { diff --git a/apps/cli/src/legacy/commands/branches/get/get.integration.test.ts b/apps/cli/src/legacy/commands/branches/get/get.integration.test.ts index 27ae81902d..00a7d63012 100644 --- a/apps/cli/src/legacy/commands/branches/get/get.integration.test.ts +++ b/apps/cli/src/legacy/commands/branches/get/get.integration.test.ts @@ -244,6 +244,20 @@ describe("legacy branches get integration", () => { }).pipe(Effect.provide(layer)); }); + it.live( + "keeps env-map keys verbatim for --output toml (map payload, exempt from CLI-1975)", + () => { + const { layer, out } = setup({ goOutput: "toml" }); + return Effect.gen(function* () { + yield* legacyBranchesGet({ ...baseFlags, name: Option.some(BRANCH_UUID) }); + // Go encodes a map[string]string here — BurntSushi keeps map keys as-is + // (no PascalCase remap), so the CLI-1975 struct remap must NOT apply. + expect(out.stdoutText).toContain('SUPABASE_URL = "'); + expect(out.stdoutText).toContain('POSTGRES_URL = "'); + }).pipe(Effect.provide(layer)); + }, + ); + it.live("emits standard-env map for --output env (env-format encoder)", () => { const { layer, out } = setup({ goOutput: "env" }); return Effect.gen(function* () { diff --git a/apps/cli/src/legacy/commands/branches/list/list.handler.ts b/apps/cli/src/legacy/commands/branches/list/list.handler.ts index edcccc51e0..a09a36b41e 100644 --- a/apps/cli/src/legacy/commands/branches/list/list.handler.ts +++ b/apps/cli/src/legacy/commands/branches/list/list.handler.ts @@ -7,8 +7,16 @@ import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-proje import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; import { LegacyOutputFlag } from "../../../../shared/legacy/global-flags.ts"; import { Output } from "../../../../shared/output/output.service.ts"; -import { encodeGoJson, encodeToml, encodeYaml } from "../../../shared/legacy-go-output.encoders.ts"; +import { encodeGoJson } from "../../../shared/legacy-go-output.encoders.ts"; +import { + encodeLegacyGoToml, + encodeLegacyGoYaml, +} from "../../../shared/legacy-go-struct-output.encoders.ts"; import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; +import { + LEGACY_GO_BRANCHES_LIST, + LEGACY_GO_BRANCHES_TOML_WRAPPER, +} from "../branches.go-payload.ts"; import { LegacyBranchesEnvNotSupportedError, LegacyBranchesListNetworkError, @@ -59,11 +67,18 @@ export const legacyBranchesList = Effect.fn("legacy.branches.list")(function* ( return; } if (goFmt === "yaml") { - yield* output.raw(encodeYaml(branches)); + yield* output.raw(encodeLegacyGoYaml(branches, LEGACY_GO_BRANCHES_LIST)); return; } if (goFmt === "toml") { - yield* output.raw(encodeToml({ branches }) + "\n"); + // Go builds the list with `append` (`list.go:70-80`), so an empty list + // stays a nil slice and BurntSushi emits nothing for the wrapper. + yield* output.raw( + encodeLegacyGoToml( + { branches: branches.length > 0 ? branches : undefined }, + LEGACY_GO_BRANCHES_TOML_WRAPPER, + ), + ); return; } diff --git a/apps/cli/src/legacy/commands/branches/list/list.integration.test.ts b/apps/cli/src/legacy/commands/branches/list/list.integration.test.ts index 00cd3c8625..008fdbb855 100644 --- a/apps/cli/src/legacy/commands/branches/list/list.integration.test.ts +++ b/apps/cli/src/legacy/commands/branches/list/list.integration.test.ts @@ -147,11 +147,72 @@ describe("legacy branches list integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("emits a YAML array for --output yaml", () => { - const { layer, out } = setup({ goOutput: "yaml", response: [SAMPLE_BRANCH] }); + it.live("emits Go-byte-exact YAML for --output yaml", () => { + // Second branch has every optional (Go pointer) field absent: Go + // zero-fills the value fields and emits explicit nulls for nil pointers. + const zeroBranch: Branches[number] = { + id: "00000000-0000-0000-0000-000000000000", + name: "Production", + project_ref: "production-project-ref", + parent_project_ref: "production-project-ref", + is_default: true, + persistent: false, + status: "FUNCTIONS_DEPLOYED", + created_at: "0001-01-01T00:00:00Z", + updated_at: "0001-01-01T00:00:00Z", + with_data: false, + }; + const { layer, out } = setup({ goOutput: "yaml", response: [SAMPLE_BRANCH, zeroBranch] }); return Effect.gen(function* () { yield* legacyBranchesList({ projectRef: Option.none() }); - expect(out.stdoutText).toContain("name: feat-1"); + // Byte-exact Go parity: yaml.v3 lowercases the Go field names, renders + // nil pointers as null, and leaves time.Time timestamps unquoted + // (CLI-1975; golden shape verified against apps/cli-go). + expect(out.stdoutText).toBe(`- createdat: 2026-05-27T01:02:03Z + deletionscheduledat: null + gitbranch: feat-1 + id: 11111111-2222-3333-4444-555555555555 + isdefault: false + latestcheckrunid: null + name: feat-1 + notifyurl: null + parentprojectref: bbbbbbbbbbbbbbbbbbbb + persistent: false + prnumber: null + previewprojectstatus: null + projectref: aaaaaaaaaaaaaaaaaaaa + reviewrequestedat: null + status: MIGRATIONS_PASSED + updatedat: 2026-05-27T01:02:04Z + withdata: true +- createdat: 0001-01-01T00:00:00Z + deletionscheduledat: null + gitbranch: null + id: 00000000-0000-0000-0000-000000000000 + isdefault: true + latestcheckrunid: null + name: Production + notifyurl: null + parentprojectref: production-project-ref + persistent: false + prnumber: null + previewprojectstatus: null + projectref: production-project-ref + reviewrequestedat: null + status: FUNCTIONS_DEPLOYED + updatedat: 0001-01-01T00:00:00Z + withdata: false +`); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits nothing for --output toml when the branch list is empty (Go nil slice)", () => { + const { layer, out } = setup({ goOutput: "toml", response: [] }); + return Effect.gen(function* () { + yield* legacyBranchesList({ projectRef: Option.none() }); + // Go builds the list with append, so an empty list stays a nil slice + // and BurntSushi writes no bytes at all. + expect(out.stdoutText).toBe(""); }).pipe(Effect.provide(layer)); }); @@ -159,8 +220,22 @@ describe("legacy branches list integration", () => { const { layer, out } = setup({ goOutput: "toml", response: [SAMPLE_BRANCH] }); return Effect.gen(function* () { yield* legacyBranchesList({ projectRef: Option.none() }); - expect(out.stdoutText).toContain("[[branches]]"); - expect(out.stdoutText).toContain('name = "feat-1"'); + // Byte-exact Go parity: BurntSushi emits PascalCase Go field names, + // 2-space indentation, native TOML datetimes, and omits nil pointers + // (CLI-1975; golden shape verified against apps/cli-go). + expect(out.stdoutText).toBe(`[[branches]] + CreatedAt = 2026-05-27T01:02:03Z + GitBranch = "feat-1" + Id = "11111111-2222-3333-4444-555555555555" + IsDefault = false + Name = "feat-1" + ParentProjectRef = "bbbbbbbbbbbbbbbbbbbb" + Persistent = false + ProjectRef = "aaaaaaaaaaaaaaaaaaaa" + Status = "MIGRATIONS_PASSED" + UpdatedAt = 2026-05-27T01:02:04Z + WithData = true +`); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/legacy/commands/branches/update/update.handler.ts b/apps/cli/src/legacy/commands/branches/update/update.handler.ts index 6e8ba5c59c..6e8dbc9190 100644 --- a/apps/cli/src/legacy/commands/branches/update/update.handler.ts +++ b/apps/cli/src/legacy/commands/branches/update/update.handler.ts @@ -8,14 +8,14 @@ import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state. import { LegacyOutputFlag } from "../../../../shared/legacy/global-flags.ts"; import { Output } from "../../../../shared/output/output.service.ts"; import { Tty } from "../../../../shared/runtime/tty.service.ts"; +import { encodeEnv, encodeGoJson } from "../../../shared/legacy-go-output.encoders.ts"; import { - encodeEnv, - encodeGoJson, - encodeToml, - encodeYaml, -} from "../../../shared/legacy-go-output.encoders.ts"; + encodeLegacyGoToml, + encodeLegacyGoYaml, +} from "../../../shared/legacy-go-struct-output.encoders.ts"; import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; import { legacyGateMapError } from "../../../shared/legacy-upgrade-suggest.ts"; +import { LEGACY_GO_BRANCH_RESPONSE } from "../branches.go-payload.ts"; import { LegacyBranchesUpdateNetworkError, LegacyBranchesUpdateUnexpectedStatusError, @@ -91,12 +91,12 @@ export const legacyBranchesUpdate = Effect.fn("legacy.branches.update")(function } if (goFmt === "yaml") { yield* output.raw("Updated preview branch:\n", "stderr"); - yield* output.raw(encodeYaml(updated)); + yield* output.raw(encodeLegacyGoYaml(updated, LEGACY_GO_BRANCH_RESPONSE)); return; } if (goFmt === "toml") { yield* output.raw("Updated preview branch:\n", "stderr"); - yield* output.raw(encodeToml(updated) + "\n"); + yield* output.raw(encodeLegacyGoToml(updated, LEGACY_GO_BRANCH_RESPONSE)); return; } if (goFmt === "env") { diff --git a/apps/cli/src/legacy/commands/domains/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/domains/SIDE_EFFECTS.md index 6d6b607d8c..313d4cda9b 100644 --- a/apps/cli/src/legacy/commands/domains/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/domains/SIDE_EFFECTS.md @@ -95,7 +95,6 @@ suppressed on stderr. `delete` ignores `-o`. - The project-ref fallback env var is `SUPABASE_PROJECT_ID`, matching Go (Go calls `viper.GetString("PROJECT_ID")` under `viper.SetEnvPrefix("SUPABASE")`, which resolves to the `SUPABASE_PROJECT_ID` environment variable). - **Documented divergences from Go (intentional):** - `--include-raw-output` is declared as a normal boolean **on each subcommand** (Go declares it as a persistent flag on the `domains` group). Two consequences: (a) it must appear after the subcommand name (`domains get --include-raw-output`) rather than before it (`domains --include-raw-output get`), matching how `--project-ref` is already handled shell-wide; (b) it cannot reproduce Cobra's help-hiding or the `Flag --include-raw-output has been deprecated` stderr warning, which Effect CLI has no hook for. It still reproduces the behavioral effect (forces `-o json` when `-o` is unset/pretty); on `delete` it is inert, matching Go. - - `-o json|yaml|toml|env` encode the decoded snake_case response, not Go's PascalCase struct keys (consistent with `backups list` / `sso add`). - The degenerate `validation_records != 1` status message approximates Go's `%+v` struct dump (which embeds a non-deterministic pointer address). - Text-mode status output is newline-terminated even for Go's `Fprintf` branches. Without the final newline, interactive shell prompts can redraw over the last status line, hiding the ACME TXT record. - In a structured `-o` mode the human status is suppressed on stderr. Go technically still writes `PrintStatus` to stderr, but the `5_*`/`4_*` messages carry no trailing newline, so they fuse with Go's version-update notice and are stripped together by the e2e normalizer — making Go's observable machine-output stderr empty. Suppressing keeps stdout clean and matches the parity contract. diff --git a/apps/cli/src/legacy/commands/domains/create/create.integration.test.ts b/apps/cli/src/legacy/commands/domains/create/create.integration.test.ts index c541d03a8d..10c6915a6d 100644 --- a/apps/cli/src/legacy/commands/domains/create/create.integration.test.ts +++ b/apps/cli/src/legacy/commands/domains/create/create.integration.test.ts @@ -229,7 +229,8 @@ describe("legacy domains create integration", () => { const { layer, out } = setup({ goOutput: "yaml" }); return Effect.gen(function* () { yield* legacyDomainsCreate(flags()); - expect(out.stdoutText).toContain(`custom_hostname: ${CUSTOM_HOSTNAME}`); + // yaml.v3 lowercases the whole Go field name (CLI-1975). + expect(out.stdoutText).toContain(`customhostname: ${CUSTOM_HOSTNAME}`); expect(out.stderrText).toBe(""); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/legacy/commands/domains/domains.emit.ts b/apps/cli/src/legacy/commands/domains/domains.emit.ts index f293394a84..de9f8010c0 100644 --- a/apps/cli/src/legacy/commands/domains/domains.emit.ts +++ b/apps/cli/src/legacy/commands/domains/domains.emit.ts @@ -2,14 +2,75 @@ import { Effect, Option } from "effect"; import { LegacyOutputFlag } from "../../../shared/legacy/global-flags.ts"; import { Output } from "../../../shared/output/output.service.ts"; +import { encodeEnv, encodeGoJson } from "../../shared/legacy-go-output.encoders.ts"; import { - encodeEnv, - encodeGoJson, - encodeToml, - encodeYaml, -} from "../../shared/legacy-go-output.encoders.ts"; + encodeLegacyGoToml, + encodeLegacyGoYaml, + legacyGoAny, + legacyGoBool, + legacyGoPtr, + legacyGoSlice, + legacyGoString, + legacyGoStruct, +} from "../../shared/legacy-go-struct-output.encoders.ts"; import { formatHostnameStatus, type LegacyHostnameResponse } from "./domains.format.ts"; +/** + * Mirror of Go's `api.UpdateCustomHostnameResponse` + * (`apps/cli-go/pkg/api/types.gen.go`) — every hostname subcommand encodes + * this struct for `-o yaml` / `-o toml`, so keys derive from the Go field + * names and non-pointer fields are zero-filled (CLI-1975). + */ +const LEGACY_GO_HOSTNAME_RESPONSE = legacyGoStruct([ + ["custom_hostname", legacyGoString], + [ + "data", + legacyGoStruct([ + ["errors", legacyGoSlice(legacyGoAny)], + ["messages", legacyGoSlice(legacyGoAny)], + [ + "result", + legacyGoStruct([ + ["custom_origin_server", legacyGoString], + ["hostname", legacyGoString], + ["id", legacyGoString], + [ + "ownership_verification", + legacyGoStruct([ + ["name", legacyGoString], + ["type", legacyGoString], + ["value", legacyGoString], + ]), + ], + [ + "ssl", + legacyGoStruct([ + ["status", legacyGoString], + [ + "validation_errors", + legacyGoPtr(legacyGoSlice(legacyGoStruct([["message", legacyGoString]]))), + ], + [ + "validation_records", + legacyGoSlice( + legacyGoStruct([ + ["txt_name", legacyGoString], + ["txt_value", legacyGoString], + ]), + ), + ], + ]), + ], + ["status", legacyGoString], + ["verification_errors", legacyGoPtr(legacyGoSlice(legacyGoString))], + ]), + ], + ["success", legacyGoBool], + ]), + ], + ["status", legacyGoString], +]); + function normalizeLegacyHostnameResponse( response: LegacyHostnameResponse, ): Record { @@ -77,11 +138,11 @@ export const emitLegacyHostnameResult = Effect.fnUntraced(function* ( return; } if (effectiveGoFmt === "yaml") { - yield* output.raw(encodeYaml(normalizeLegacyHostnameResponse(response))); + yield* output.raw(encodeLegacyGoYaml(response, LEGACY_GO_HOSTNAME_RESPONSE)); return; } if (effectiveGoFmt === "toml") { - yield* output.raw(encodeToml(normalizeLegacyHostnameResponse(response)) + "\n"); + yield* output.raw(encodeLegacyGoToml(response, LEGACY_GO_HOSTNAME_RESPONSE)); return; } if (effectiveGoFmt === "env") { diff --git a/apps/cli/src/legacy/commands/domains/get/get.integration.test.ts b/apps/cli/src/legacy/commands/domains/get/get.integration.test.ts index b7eb71d565..0b5d5b7f5e 100644 --- a/apps/cli/src/legacy/commands/domains/get/get.integration.test.ts +++ b/apps/cli/src/legacy/commands/domains/get/get.integration.test.ts @@ -238,7 +238,8 @@ describe("legacy domains get integration", () => { const { layer, out } = setup({ goOutput: "yaml" }); return Effect.gen(function* () { yield* legacyDomainsGet(baseFlags); - expect(out.stdoutText).toContain("custom_hostname: shop.acme.dev"); + // yaml.v3 lowercases the whole Go field name (CLI-1975). + expect(out.stdoutText).toContain("customhostname: shop.acme.dev"); }).pipe(Effect.provide(layer)); }); @@ -246,7 +247,8 @@ describe("legacy domains get integration", () => { const { layer, out } = setup({ goOutput: "toml" }); return Effect.gen(function* () { yield* legacyDomainsGet(baseFlags); - expect(out.stdoutText).toContain('custom_hostname = "shop.acme.dev"'); + // BurntSushi emits PascalCase Go field names (CLI-1975). + expect(out.stdoutText).toContain('CustomHostname = "shop.acme.dev"'); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/legacy/commands/functions/list/list.encoders.ts b/apps/cli/src/legacy/commands/functions/list/list.encoders.ts index b07e557d7b..64c10dc699 100644 --- a/apps/cli/src/legacy/commands/functions/list/list.encoders.ts +++ b/apps/cli/src/legacy/commands/functions/list/list.encoders.ts @@ -1,4 +1,38 @@ -import { encodeGoJson, encodeToml, encodeYaml } from "../../../shared/legacy-go-output.encoders.ts"; +import { encodeGoJson } from "../../../shared/legacy-go-output.encoders.ts"; +import { + encodeLegacyGoToml, + encodeLegacyGoYaml, + legacyGoBool, + legacyGoInt, + legacyGoPtr, + legacyGoSlice, + legacyGoString, + legacyGoStruct, + legacyGoTomlListWrapper, +} from "../../../shared/legacy-go-struct-output.encoders.ts"; + +/** Mirror of Go's `api.FunctionResponse` (`apps/cli-go/pkg/api/types.gen.go`). */ +const LEGACY_GO_FUNCTION_RESPONSE = legacyGoStruct([ + ["created_at", legacyGoInt], + ["entrypoint_path", legacyGoPtr(legacyGoString)], + ["ezbr_sha256", legacyGoPtr(legacyGoString)], + ["id", legacyGoString], + ["import_map", legacyGoPtr(legacyGoBool)], + ["import_map_path", legacyGoPtr(legacyGoString)], + ["name", legacyGoString], + ["slug", legacyGoString], + ["status", legacyGoString], + ["updated_at", legacyGoInt], + ["verify_jwt", legacyGoPtr(legacyGoBool)], + ["version", legacyGoInt], +]); + +const LEGACY_GO_FUNCTIONS_LIST = legacyGoSlice(LEGACY_GO_FUNCTION_RESPONSE); + +const LEGACY_GO_FUNCTIONS_TOML_WRAPPER = legacyGoTomlListWrapper( + "functions", + LEGACY_GO_FUNCTION_RESPONSE, +); interface LegacyFunctionRecord { readonly id: string; @@ -193,39 +227,12 @@ export function decodeFunctionsResponse( } } -function escapeGoJsonHtmlChars(text: string): string { - return text - .replaceAll("<", "\\u003c") - .replaceAll(">", "\\u003e") - .replaceAll("&", "\\u0026") - .replaceAll("\u2028", "\\u2028") - .replaceAll("\u2029", "\\u2029"); -} - export function hasJsonContentType(response: { readonly headers: Readonly>; }) { return (response.headers["content-type"] ?? "").includes("json"); } -function toGoYamlFunction(function_: Functions[number]) { - const base = baseFunctionFields(function_); - return { - createdat: base.created_at, - entrypointpath: function_.entrypoint_path ?? null, - ezbrsha256: function_.ezbr_sha256 ?? null, - id: base.id, - importmap: function_.import_map ?? null, - importmappath: function_.import_map_path ?? null, - name: base.name, - slug: base.slug, - status: base.status, - updatedat: base.updated_at, - verifyjwt: function_.verify_jwt ?? null, - version: base.version, - }; -} - function toGoJsonFunction(function_: Functions[number]) { const base = baseFunctionFields(function_); return { @@ -240,34 +247,20 @@ function toGoJsonFunction(function_: Functions[number]) { }; } -function toGoTomlFunction(function_: Functions[number]) { - const base = baseFunctionFields(function_); - return { - CreatedAt: base.created_at, - ...(function_.entrypoint_path != null ? { EntrypointPath: function_.entrypoint_path } : {}), - ...(function_.ezbr_sha256 != null ? { EzbrSha256: function_.ezbr_sha256 } : {}), - Id: base.id, - ...(function_.import_map != null ? { ImportMap: function_.import_map } : {}), - ...(function_.import_map_path != null ? { ImportMapPath: function_.import_map_path } : {}), - Name: base.name, - Slug: base.slug, - Status: base.status, - UpdatedAt: base.updated_at, - ...(function_.verify_jwt != null ? { VerifyJwt: function_.verify_jwt } : {}), - Version: base.version, - }; -} - export function encodeFunctionsGoJson(parsed: ParsedFunctions): string { - return escapeGoJsonHtmlChars( - parsed.isNil ? encodeGoJson(null) : encodeGoJson(parsed.functions.map(toGoJsonFunction)), - ); + return parsed.isNil ? encodeGoJson(null) : encodeGoJson(parsed.functions.map(toGoJsonFunction)); } export function encodeFunctionsGoYaml(functions: Functions): string { - return encodeYaml(functions.map(toGoYamlFunction)); + return encodeLegacyGoYaml(functions, LEGACY_GO_FUNCTIONS_LIST); } -export function encodeFunctionsGoToml(functions: Functions): string { - return encodeToml({ functions: functions.map(toGoTomlFunction) }); +export function encodeFunctionsGoToml(parsed: ParsedFunctions): string { + // Go encodes `Functions: *resp.JSON200` — a JSON `null` body decodes to a + // nil slice (BurntSushi emits nothing), while `[]` decodes to a non-nil + // empty slice (`functions = []`). + return encodeLegacyGoToml( + { functions: parsed.isNil ? undefined : parsed.functions }, + LEGACY_GO_FUNCTIONS_TOML_WRAPPER, + ); } diff --git a/apps/cli/src/legacy/commands/functions/list/list.encoders.unit.test.ts b/apps/cli/src/legacy/commands/functions/list/list.encoders.unit.test.ts index 5185e7e2ea..cdc34d4896 100644 --- a/apps/cli/src/legacy/commands/functions/list/list.encoders.unit.test.ts +++ b/apps/cli/src/legacy/commands/functions/list/list.encoders.unit.test.ts @@ -135,18 +135,25 @@ describe("list encoders", () => { importmappath: null`); }); - it("keeps Go TOML keys in struct order", () => { - expect(encodeFunctionsGoToml([SAMPLE_FUNCTION])).toContain(`[[functions]] -CreatedAt = 1687423025152 -EntrypointPath = "functions/hello-world/index.ts" -Id = "11111111-2222-3333-4444-555555555555" -ImportMap = false -Name = "Hello World" -Slug = "hello-world" -Status = "ACTIVE" -UpdatedAt = 1687423025152 -VerifyJwt = true -Version = 2 -`); + it("keeps Go TOML keys in struct order with BurntSushi's 2-space indentation", () => { + expect(encodeFunctionsGoToml({ functions: [SAMPLE_FUNCTION], isNil: false })).toBe( + `[[functions]] + CreatedAt = 1687423025152 + EntrypointPath = "functions/hello-world/index.ts" + Id = "11111111-2222-3333-4444-555555555555" + ImportMap = false + Name = "Hello World" + Slug = "hello-world" + Status = "ACTIVE" + UpdatedAt = 1687423025152 + VerifyJwt = true + Version = 2 +`, + ); + }); + + it("emits nothing for a nil TOML list and `functions = []` for a decoded empty list", () => { + expect(encodeFunctionsGoToml({ functions: [], isNil: true })).toBe(""); + expect(encodeFunctionsGoToml({ functions: [], isNil: false })).toBe("functions = []\n"); }); }); diff --git a/apps/cli/src/legacy/commands/functions/list/list.handler.ts b/apps/cli/src/legacy/commands/functions/list/list.handler.ts index 62aaa0b851..bf86350057 100644 --- a/apps/cli/src/legacy/commands/functions/list/list.handler.ts +++ b/apps/cli/src/legacy/commands/functions/list/list.handler.ts @@ -109,7 +109,7 @@ export const legacyFunctionsList = Effect.fn("legacy.functions.list")(function* return; } if (goFmt === "toml") { - yield* output.raw(encodeFunctionsGoToml(functions)); + yield* output.raw(encodeFunctionsGoToml({ functions, isNil })); return; } if (goFmt === "pretty") { diff --git a/apps/cli/src/legacy/commands/functions/list/list.integration.test.ts b/apps/cli/src/legacy/commands/functions/list/list.integration.test.ts index fa2f9d58b9..e7171e018c 100644 --- a/apps/cli/src/legacy/commands/functions/list/list.integration.test.ts +++ b/apps/cli/src/legacy/commands/functions/list/list.integration.test.ts @@ -185,12 +185,13 @@ describe("legacy functions list integration", () => { const { layer, out } = setup({ goOutput: "toml" }); return Effect.gen(function* () { yield* legacyFunctionsList({ projectRef: Option.none() }); + // BurntSushi indents array-of-table keys by 2 spaces (CLI-1975). expect(out.stdoutText).toContain(`[[functions]] -CreatedAt = 1687423025152 -EntrypointPath = "functions/hello-world/index.ts" -Id = "11111111-2222-3333-4444-555555555555" -ImportMap = false -Name = "Hello World"`); + CreatedAt = 1687423025152 + EntrypointPath = "functions/hello-world/index.ts" + Id = "11111111-2222-3333-4444-555555555555" + ImportMap = false + Name = "Hello World"`); expect(out.stdoutText).not.toContain("created_at"); expect(out.stdoutText).not.toContain("entrypoint_path"); expect(out.stdoutText.endsWith("\n\n")).toBe(false); diff --git a/apps/cli/src/legacy/commands/orgs/create/create.handler.ts b/apps/cli/src/legacy/commands/orgs/create/create.handler.ts index f710ff5b9c..f9cb5542c9 100644 --- a/apps/cli/src/legacy/commands/orgs/create/create.handler.ts +++ b/apps/cli/src/legacy/commands/orgs/create/create.handler.ts @@ -5,13 +5,13 @@ import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts" import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; import { LegacyOutputFlag } from "../../../../shared/legacy/global-flags.ts"; import { Output } from "../../../../shared/output/output.service.ts"; +import { encodeEnv, encodeGoJson } from "../../../shared/legacy-go-output.encoders.ts"; import { - encodeEnv, - encodeGoJson, - encodeToml, - encodeYaml, -} from "../../../shared/legacy-go-output.encoders.ts"; + encodeLegacyGoToml, + encodeLegacyGoYaml, +} from "../../../shared/legacy-go-struct-output.encoders.ts"; import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; +import { LEGACY_GO_ORGANIZATION_RESPONSE } from "../orgs.go-payload.ts"; import { LegacyOrgsCreateNetworkError, LegacyOrgsCreateUnexpectedStatusError, @@ -67,12 +67,12 @@ export const legacyOrgsCreate = Effect.fn("legacy.orgs.create")(function* ( } if (goFmt === "yaml") { yield* output.raw(preamble); - yield* output.raw(encodeYaml(created)); + yield* output.raw(encodeLegacyGoYaml(created, LEGACY_GO_ORGANIZATION_RESPONSE)); return; } if (goFmt === "toml") { yield* output.raw(preamble); - yield* output.raw(encodeToml(created) + "\n"); + yield* output.raw(encodeLegacyGoToml(created, LEGACY_GO_ORGANIZATION_RESPONSE)); return; } if (goFmt === "env") { diff --git a/apps/cli/src/legacy/commands/orgs/create/create.integration.test.ts b/apps/cli/src/legacy/commands/orgs/create/create.integration.test.ts index 5401a18497..716a1895ce 100644 --- a/apps/cli/src/legacy/commands/orgs/create/create.integration.test.ts +++ b/apps/cli/src/legacy/commands/orgs/create/create.integration.test.ts @@ -109,7 +109,8 @@ describe("legacy orgs create integration", () => { return Effect.gen(function* () { yield* legacyOrgsCreate({ name: "Acme" }); expect(out.stdoutText).toContain("Created organization: combined-fuchsia-lion\n"); - expect(out.stdoutText).toContain('name = "Acme"'); + // Go field names (PascalCase) at the top level — no table header (CLI-1975). + expect(out.stdoutText).toContain('Name = "Acme"'); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/legacy/commands/orgs/list/list.handler.ts b/apps/cli/src/legacy/commands/orgs/list/list.handler.ts index 0cca870961..b243901cb7 100644 --- a/apps/cli/src/legacy/commands/orgs/list/list.handler.ts +++ b/apps/cli/src/legacy/commands/orgs/list/list.handler.ts @@ -5,8 +5,13 @@ import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts" import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; import { LegacyOutputFlag } from "../../../../shared/legacy/global-flags.ts"; import { Output } from "../../../../shared/output/output.service.ts"; -import { encodeGoJson, encodeToml, encodeYaml } from "../../../shared/legacy-go-output.encoders.ts"; +import { encodeGoJson } from "../../../shared/legacy-go-output.encoders.ts"; +import { + encodeLegacyGoToml, + encodeLegacyGoYaml, +} from "../../../shared/legacy-go-struct-output.encoders.ts"; import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; +import { LEGACY_GO_ORGS_LIST, LEGACY_GO_ORGS_TOML_WRAPPER } from "../orgs.go-payload.ts"; import { LegacyOrgsEnvNotSupportedError, LegacyOrgsListNetworkError, @@ -57,11 +62,11 @@ export const legacyOrgsList = Effect.fn("legacy.orgs.list")(function* ( return; } if (goFmt === "yaml") { - yield* output.raw(encodeYaml(orgs)); + yield* output.raw(encodeLegacyGoYaml(orgs, LEGACY_GO_ORGS_LIST)); return; } if (goFmt === "toml") { - yield* output.raw(encodeToml({ organizations: orgs }) + "\n"); + yield* output.raw(encodeLegacyGoToml({ organizations: orgs }, LEGACY_GO_ORGS_TOML_WRAPPER)); return; } diff --git a/apps/cli/src/legacy/commands/orgs/list/list.integration.test.ts b/apps/cli/src/legacy/commands/orgs/list/list.integration.test.ts index 431aac47a1..7f263ce6d0 100644 --- a/apps/cli/src/legacy/commands/orgs/list/list.integration.test.ts +++ b/apps/cli/src/legacy/commands/orgs/list/list.integration.test.ts @@ -139,7 +139,8 @@ describe("legacy orgs list integration", () => { return Effect.gen(function* () { yield* legacyOrgsList({}); expect(out.stdoutText).toContain("[[organizations]]"); - expect(out.stdoutText).toContain('name = "Test Org"'); + // Go field names (PascalCase) with BurntSushi's 2-space indent (CLI-1975). + expect(out.stdoutText).toContain(' Name = "Test Org"'); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/legacy/commands/orgs/orgs.go-payload.ts b/apps/cli/src/legacy/commands/orgs/orgs.go-payload.ts new file mode 100644 index 0000000000..9afe048534 --- /dev/null +++ b/apps/cli/src/legacy/commands/orgs/orgs.go-payload.ts @@ -0,0 +1,29 @@ +import { + type LegacyGoType, + legacyGoSlice, + legacyGoString, + legacyGoStruct, + legacyGoTomlListWrapper, +} from "../../shared/legacy-go-struct-output.encoders.ts"; + +/** + * Mirror of Go's `api.OrganizationResponseV1` (`apps/cli-go/pkg/api/types.gen.go`). + * Shared by `orgs list` and `orgs create` for `-o yaml` / `-o toml` (CLI-1975). + */ +export const LEGACY_GO_ORGANIZATION_RESPONSE: LegacyGoType = legacyGoStruct([ + ["id", legacyGoString], + ["name", legacyGoString], + ["slug", legacyGoString], +]); + +/** `orgs list -o yaml` encodes the bare `[]api.OrganizationResponseV1`. */ +export const LEGACY_GO_ORGS_LIST: LegacyGoType = legacyGoSlice(LEGACY_GO_ORGANIZATION_RESPONSE); + +/** + * `orgs list -o toml` wraps the slice: + * `struct{ Organizations []api.OrganizationResponseV1 `toml:"organizations"` }`. + */ +export const LEGACY_GO_ORGS_TOML_WRAPPER: LegacyGoType = legacyGoTomlListWrapper( + "organizations", + LEGACY_GO_ORGANIZATION_RESPONSE, +); diff --git a/apps/cli/src/legacy/commands/projects/api-keys/api-keys.handler.ts b/apps/cli/src/legacy/commands/projects/api-keys/api-keys.handler.ts index fadbf65a63..aba4f4178d 100644 --- a/apps/cli/src/legacy/commands/projects/api-keys/api-keys.handler.ts +++ b/apps/cli/src/legacy/commands/projects/api-keys/api-keys.handler.ts @@ -8,17 +8,43 @@ import { LegacyOutputFlag } from "../../../../shared/legacy/global-flags.ts"; import { Output } from "../../../../shared/output/output.service.ts"; import { apiKeysToEnv } from "../../../shared/legacy-api-keys.format.ts"; import { legacyGetProjectApiKeys } from "../../../shared/legacy-get-api-keys.ts"; +import { encodeEnv, encodeGoJson, encodeToml } from "../../../shared/legacy-go-output.encoders.ts"; import { - encodeEnv, - encodeGoJson, - encodeToml, - encodeYaml, -} from "../../../shared/legacy-go-output.encoders.ts"; + encodeLegacyGoYaml, + legacyGoAny, + legacyGoMap, + legacyGoNullable, + legacyGoSlice, + legacyGoString, + legacyGoStruct, + legacyGoTime, +} from "../../../shared/legacy-go-struct-output.encoders.ts"; import { renderProjectApiKeysTable } from "../projects.format.ts"; import type { LegacyProjectsApiKeysFlags } from "./api-keys.command.ts"; type ApiKeys = typeof V1GetProjectApiKeysOutput.Type; +/** + * Mirror of Go's `api.ApiKeyResponse` (`apps/cli-go/pkg/api/types.gen.go`). + * Only `-o yaml` hits the raw struct — `-o toml`/`-o env` encode the + * `SUPABASE__KEY` env map instead (`api_keys.go:34-36`) — and yaml.v3 + * renders the `nullable.Nullable[T]` fields as `map[bool]T` (CLI-1975). + */ +const LEGACY_GO_API_KEYS_LIST = legacyGoSlice( + legacyGoStruct([ + ["api_key", legacyGoNullable(legacyGoString)], + ["description", legacyGoNullable(legacyGoString)], + ["hash", legacyGoNullable(legacyGoString)], + ["id", legacyGoNullable(legacyGoString)], + ["inserted_at", legacyGoNullable(legacyGoTime)], + ["name", legacyGoString], + ["prefix", legacyGoNullable(legacyGoString)], + ["secret_jwt_template", legacyGoNullable(legacyGoMap(legacyGoAny))], + ["type", legacyGoNullable(legacyGoString)], + ["updated_at", legacyGoNullable(legacyGoTime)], + ]), +); + export const legacyProjectsApiKeys = Effect.fn("legacy.projects.api-keys")(function* ( flags: LegacyProjectsApiKeysFlags, ) { @@ -57,7 +83,7 @@ export const legacyProjectsApiKeys = Effect.fn("legacy.projects.api-keys")(funct return; } if (goFmt === "yaml") { - yield* output.raw(encodeYaml(keys)); + yield* output.raw(encodeLegacyGoYaml(keys, LEGACY_GO_API_KEYS_LIST)); return; } diff --git a/apps/cli/src/legacy/commands/projects/create/create.integration.test.ts b/apps/cli/src/legacy/commands/projects/create/create.integration.test.ts index d38c35c021..8941e0ff88 100644 --- a/apps/cli/src/legacy/commands/projects/create/create.integration.test.ts +++ b/apps/cli/src/legacy/commands/projects/create/create.integration.test.ts @@ -335,7 +335,8 @@ describe("legacy projects create integration", () => { dbPassword: Option.some("s3cret-pass"), region: Option.some("us-east-1"), }); - expect(out.stdoutText).toContain('name = "alpha"'); + // Go field names (PascalCase) at the top level — no table header (CLI-1975). + expect(out.stdoutText).toContain('Name = "alpha"'); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/legacy/commands/projects/list/list.handler.ts b/apps/cli/src/legacy/commands/projects/list/list.handler.ts index 3fc4e0b63f..6ac9159fc5 100644 --- a/apps/cli/src/legacy/commands/projects/list/list.handler.ts +++ b/apps/cli/src/legacy/commands/projects/list/list.handler.ts @@ -7,7 +7,17 @@ import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-proje import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; import { LegacyOutputFlag } from "../../../../shared/legacy/global-flags.ts"; import { Output } from "../../../../shared/output/output.service.ts"; -import { encodeGoJson, encodeToml, encodeYaml } from "../../../shared/legacy-go-output.encoders.ts"; +import { encodeGoJson } from "../../../shared/legacy-go-output.encoders.ts"; +import { + type LegacyGoType, + encodeLegacyGoToml, + encodeLegacyGoYaml, + legacyGoBool, + legacyGoSlice, + legacyGoString, + legacyGoStruct, + legacyGoTomlListWrapper, +} from "../../../shared/legacy-go-struct-output.encoders.ts"; import { sanitizeLegacyErrorBody } from "../../../shared/legacy-http-errors.ts"; import { LegacyProjectsEnvNotSupportedError, @@ -21,6 +31,39 @@ import { } from "../projects.format.ts"; import type { LegacyProjectsListFlags } from "./list.command.ts"; +/** + * Mirror of Go's `linkedProject` (`apps/cli-go/internal/projects/list/list.go`): + * an embedded `api.V1ProjectWithDatabaseResponse` (fields inlined first, in + * declaration order) plus the CLI-added `Linked bool` (CLI-1975). + */ +const LEGACY_GO_LINKED_PROJECT: LegacyGoType = legacyGoStruct([ + ["created_at", legacyGoString], + [ + "database", + legacyGoStruct([ + ["host", legacyGoString], + ["postgres_engine", legacyGoString], + ["release_channel", legacyGoString], + ["version", legacyGoString], + ]), + ], + ["id", legacyGoString], + ["name", legacyGoString], + ["organization_id", legacyGoString], + ["organization_slug", legacyGoString], + ["ref", legacyGoString], + ["region", legacyGoString], + ["status", legacyGoString], + ["linked", legacyGoBool], +]); + +const LEGACY_GO_PROJECTS_LIST = legacyGoSlice(LEGACY_GO_LINKED_PROJECT); + +const LEGACY_GO_PROJECTS_TOML_WRAPPER = legacyGoTomlListWrapper( + "projects", + LEGACY_GO_LINKED_PROJECT, +); + export const legacyProjectsList = Effect.fn("legacy.projects.list")(function* ( _flags: LegacyProjectsListFlags, ) { @@ -110,11 +153,18 @@ export const legacyProjectsList = Effect.fn("legacy.projects.list")(function* ( return; } if (goFmt === "yaml") { - yield* output.raw(encodeYaml(projects)); + yield* output.raw(encodeLegacyGoYaml(projects, LEGACY_GO_PROJECTS_LIST)); return; } if (goFmt === "toml") { - yield* output.raw(encodeToml({ projects }) + "\n"); + // Go builds the list with `append` (`list.go:36-42`), so an empty list + // stays a nil slice and BurntSushi emits nothing for the wrapper. + yield* output.raw( + encodeLegacyGoToml( + { projects: projects.length > 0 ? projects : undefined }, + LEGACY_GO_PROJECTS_TOML_WRAPPER, + ), + ); return; } diff --git a/apps/cli/src/legacy/commands/projects/list/list.integration.test.ts b/apps/cli/src/legacy/commands/projects/list/list.integration.test.ts index d8cc3286d0..4251cabda7 100644 --- a/apps/cli/src/legacy/commands/projects/list/list.integration.test.ts +++ b/apps/cli/src/legacy/commands/projects/list/list.integration.test.ts @@ -185,7 +185,11 @@ describe("legacy projects list integration", () => { return Effect.gen(function* () { yield* legacyProjectsList({}); expect(out.stdoutText).toContain("[[projects]]"); - expect(out.stdoutText).toContain('name = "alpha"'); + // Go field names (PascalCase), embedded fields first, `Linked` last, + // and the Database sub-table after the primitives (CLI-1975). + expect(out.stdoutText).toContain(' Name = "alpha"'); + expect(out.stdoutText).toContain(" Linked = true"); + expect(out.stdoutText).toContain(" [projects.Database]"); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/legacy/commands/secrets/list/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/secrets/list/SIDE_EFFECTS.md index 9a1ce5d299..4fce22869c 100644 --- a/apps/cli/src/legacy/commands/secrets/list/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/secrets/list/SIDE_EFFECTS.md @@ -72,7 +72,7 @@ YAML document of the sorted secret array. ### `--output toml` -TOML document wrapping the sorted array as `[[secrets]]`. JSON shape is preserved; leaf order may differ from Go's `BurntSushi/toml` encoder. +TOML document wrapping the sorted array as `[[secrets]]`, matching Go's `BurntSushi/toml` output byte-for-byte (CLI-1975): PascalCase Go struct field names (`Name`, `UpdatedAt`, `Value`), 2-space indentation, nil pointer fields omitted. ### `--output env` diff --git a/apps/cli/src/legacy/commands/secrets/list/list.handler.ts b/apps/cli/src/legacy/commands/secrets/list/list.handler.ts index 335cf34de3..0f6448a7e0 100644 --- a/apps/cli/src/legacy/commands/secrets/list/list.handler.ts +++ b/apps/cli/src/legacy/commands/secrets/list/list.handler.ts @@ -7,7 +7,16 @@ import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-proje import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; import { LegacyOutputFlag } from "../../../../shared/legacy/global-flags.ts"; import { Output } from "../../../../shared/output/output.service.ts"; -import { encodeGoJson, encodeToml, encodeYaml } from "../../../shared/legacy-go-output.encoders.ts"; +import { encodeGoJson } from "../../../shared/legacy-go-output.encoders.ts"; +import { + encodeLegacyGoToml, + encodeLegacyGoYaml, + legacyGoPtr, + legacyGoSlice, + legacyGoString, + legacyGoStruct, + legacyGoTomlListWrapper, +} from "../../../shared/legacy-go-struct-output.encoders.ts"; import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; import { LegacySecretsEnvNotSupportedError, @@ -30,6 +39,20 @@ function sortSecrets(secrets: Secrets): Secrets { return [...secrets].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); } +/** Mirror of Go's `api.SecretResponse` (`apps/cli-go/pkg/api/types.gen.go`). */ +const LEGACY_GO_SECRET_RESPONSE = legacyGoStruct([ + ["name", legacyGoString], + ["updated_at", legacyGoPtr(legacyGoString)], + ["value", legacyGoString], +]); + +const LEGACY_GO_SECRETS_LIST = legacyGoSlice(LEGACY_GO_SECRET_RESPONSE); + +const LEGACY_GO_SECRETS_TOML_WRAPPER = legacyGoTomlListWrapper( + "secrets", + LEGACY_GO_SECRET_RESPONSE, +); + export const legacySecretsList = Effect.fn("legacy.secrets.list")(function* ( flags: LegacySecretsListFlags, ) { @@ -66,11 +89,11 @@ export const legacySecretsList = Effect.fn("legacy.secrets.list")(function* ( return; } if (goFmt === "yaml") { - yield* output.raw(encodeYaml(sorted)); + yield* output.raw(encodeLegacyGoYaml(sorted, LEGACY_GO_SECRETS_LIST)); return; } if (goFmt === "toml") { - yield* output.raw(encodeToml({ secrets: sorted }) + "\n"); + yield* output.raw(encodeLegacyGoToml({ secrets: sorted }, LEGACY_GO_SECRETS_TOML_WRAPPER)); return; } diff --git a/apps/cli/src/legacy/commands/secrets/list/list.integration.test.ts b/apps/cli/src/legacy/commands/secrets/list/list.integration.test.ts index 335e81431f..fa722be43f 100644 --- a/apps/cli/src/legacy/commands/secrets/list/list.integration.test.ts +++ b/apps/cli/src/legacy/commands/secrets/list/list.integration.test.ts @@ -167,8 +167,9 @@ describe("legacy secrets list integration", () => { return Effect.gen(function* () { yield* legacySecretsList({ projectRef: Option.none() }); expect(out.stdoutText).toContain("[[secrets]]"); - expect(out.stdoutText).toContain('name = "BAR"'); - expect(out.stdoutText).toContain('value = "digest-bar"'); + // Go field names (PascalCase) with BurntSushi's 2-space indent (CLI-1975). + expect(out.stdoutText).toContain(' Name = "BAR"'); + expect(out.stdoutText).toContain(' Value = "digest-bar"'); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/legacy/commands/services/services.handler.ts b/apps/cli/src/legacy/commands/services/services.handler.ts index 7fa9a9e81b..ce75560dc1 100644 --- a/apps/cli/src/legacy/commands/services/services.handler.ts +++ b/apps/cli/src/legacy/commands/services/services.handler.ts @@ -13,9 +13,16 @@ import { legacyResolveEdgeRuntimeImage } from "../../shared/legacy-edge-runtime- import { legacyReadServiceVersionOverrides } from "../../shared/legacy-service-version-overrides.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 { encodeGoJson } from "../../shared/legacy-go-output.encoders.ts"; +import { + encodeLegacyGoToml, + encodeLegacyGoYaml, + legacyGoSlice, + legacyGoString, + legacyGoStruct, + legacyGoTomlListWrapper, +} from "../../shared/legacy-go-struct-output.encoders.ts"; import { - encodeLegacyTomlRows, fetchLinkedServiceVersions, formatServicesWarning, listLocalServiceVersions, @@ -27,6 +34,25 @@ import { import type { LegacyServicesFlags } from "./services.command.ts"; import { LegacyServicesEnvNotSupportedError } from "./services.errors.ts"; +/** + * Mirror of Go's hand-written `imageVersion` + * (`apps/cli-go/internal/services/services.go`) — declaration order is + * Name, Local, Remote (not alphabetical), and `Remote` is always emitted + * even when empty (CLI-1975). + */ +const LEGACY_GO_IMAGE_VERSION = legacyGoStruct([ + ["name", legacyGoString], + ["local", legacyGoString], + ["remote", legacyGoString], +]); + +const LEGACY_GO_SERVICES_LIST = legacyGoSlice(LEGACY_GO_IMAGE_VERSION); + +const LEGACY_GO_SERVICES_TOML_WRAPPER = legacyGoTomlListWrapper( + "services", + LEGACY_GO_IMAGE_VERSION, +); + export const legacyServices = Effect.fn("legacy.services")(function* (_flags: LegacyServicesFlags) { const output = yield* Output; const legacyOutput = yield* LegacyOutputFlag; @@ -158,12 +184,12 @@ export const legacyServices = Effect.fn("legacy.services")(function* (_flags: Le } if (goOutput === "yaml") { - yield* output.raw(encodeYaml(rows)); + yield* output.raw(encodeLegacyGoYaml(rows, LEGACY_GO_SERVICES_LIST)); return; } if (goOutput === "toml") { - yield* output.raw(encodeToml(encodeLegacyTomlRows(rows))); + yield* output.raw(encodeLegacyGoToml({ services: rows }, LEGACY_GO_SERVICES_TOML_WRAPPER)); return; } diff --git a/apps/cli/src/legacy/commands/services/services.integration.test.ts b/apps/cli/src/legacy/commands/services/services.integration.test.ts index 86c2ceb807..8012bcc491 100644 --- a/apps/cli/src/legacy/commands/services/services.integration.test.ts +++ b/apps/cli/src/legacy/commands/services/services.integration.test.ts @@ -534,7 +534,9 @@ major_version = 15 yield* legacyServices({}).pipe(Effect.provide(layer)); expect(out.stdoutText).toContain("[[services]]"); - expect(out.stdoutText).toContain('name = "supabase/postgres"'); + // Go's hand-written imageVersion struct emits PascalCase field names in + // declaration order (Name, Local, Remote) with 2-space indent (CLI-1975). + expect(out.stdoutText).toContain(' Name = "supabase/postgres"'); }); }); 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 fbbf5212dd..486e15a9ce 100644 --- a/apps/cli/src/legacy/commands/snippets/list/list.handler.ts +++ b/apps/cli/src/legacy/commands/snippets/list/list.handler.ts @@ -6,7 +6,18 @@ 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 { encodeGoJson } from "../../../shared/legacy-go-output.encoders.ts"; +import { + encodeLegacyGoToml, + encodeLegacyGoYaml, + legacyGoBool, + legacyGoFloat32, + legacyGoNullable, + legacyGoPtr, + legacyGoSlice, + legacyGoString, + legacyGoStruct, +} from "../../../shared/legacy-go-struct-output.encoders.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"; @@ -15,6 +26,7 @@ import { LegacySnippetsEnvNotSupportedError, LegacySnippetsListNetworkError, LegacySnippetsListUnexpectedStatusError, + LegacySnippetsTomlEncodeError, } from "../snippets.errors.ts"; import { renderSnippetsTable, type SnippetRow } from "../snippets.format.ts"; import type { LegacySnippetsListFlags } from "./list.command.ts"; @@ -39,6 +51,51 @@ function asRecord(obj: unknown): Record { return typeof obj === "object" && obj !== null ? (obj as Record) : {}; } +/** + * Mirror of Go's `api.SnippetList` (`apps/cli-go/pkg/api/types.gen.go`). The + * `description` field is a `nullable.Nullable[string]` — yaml.v3 renders it as + * a `map[bool]string`, and BurntSushi refuses it whenever present (CLI-1975). + */ +const LEGACY_GO_SNIPPET_LIST = legacyGoStruct([ + ["cursor", legacyGoPtr(legacyGoString)], + [ + "data", + legacyGoSlice( + legacyGoStruct([ + ["description", legacyGoNullable(legacyGoString)], + ["favorite", legacyGoBool], + ["id", legacyGoString], + ["inserted_at", legacyGoString], + ["name", legacyGoString], + [ + "owner", + legacyGoStruct([ + ["id", legacyGoFloat32], + ["username", legacyGoString], + ]), + ], + [ + "project", + legacyGoStruct([ + ["id", legacyGoFloat32], + ["name", legacyGoString], + ]), + ], + ["type", legacyGoString], + ["updated_at", legacyGoString], + [ + "updated_by", + legacyGoStruct([ + ["id", legacyGoFloat32], + ["username", legacyGoString], + ]), + ], + ["visibility", legacyGoString], + ]), + ), + ], +]); + interface SnippetsResponseBody { readonly data: ReadonlyArray; } @@ -145,11 +202,21 @@ export const legacySnippetsList = Effect.fn("legacy.snippets.list")(function* ( return; } if (goFmt === "yaml") { - yield* output.raw(encodeYaml(rawBody)); + yield* output.raw(encodeLegacyGoYaml(rawBody, LEGACY_GO_SNIPPET_LIST)); return; } if (goFmt === "toml") { - yield* output.raw(encodeToml(asRecord(rawBody)) + "\n"); + // BurntSushi cannot encode the `nullable.Nullable[string]` description + // field (`map[bool]string`), so Go fails whenever any snippet carries a + // `description` key. Mirror the failure byte-for-byte. + const toml = yield* Effect.try({ + try: () => encodeLegacyGoToml(rawBody, LEGACY_GO_SNIPPET_LIST), + catch: (cause) => + new LegacySnippetsTomlEncodeError({ + message: `failed to output toml: ${cause instanceof Error ? cause.message : String(cause)}`, + }), + }); + yield* output.raw(toml); return; } 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 ec48c7fa33..da845a438b 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 @@ -76,7 +76,10 @@ const EMPTY_RESPONSE: SnippetsResponse = { interface SetupOpts { format?: "text" | "json" | "stream-json"; goOutput?: "env" | "pretty" | "json" | "toml" | "yaml"; - response?: SnippetsResponse; + // The handler consumes the raw JSON body (schema-bypass, see the handler's + // tolerant accessors), so tests may pass shapes the generated schema would + // reject — e.g. snippets without the `description` key. + response?: SnippetsResponse | { readonly data: ReadonlyArray> }; status?: number; network?: "fail"; } @@ -196,12 +199,56 @@ describe("legacy snippets list integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("Go --output=toml emits the response", () => { - const { layer, out } = setup({ goOutput: "toml" }); + it.live("Go --output=toml fails like Go when a snippet carries a description", () => { + // Go's BurntSushi encoder refuses the `nullable.Nullable[string]` + // description field (`map[bool]string`) — `snippets list -o toml` fails + // with this exact message whenever any snippet has a `description` key + // (present-with-value or explicit null), verified against apps/cli-go. + const { layer } = setup({ goOutput: "toml" }); + 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("LegacySnippetsTomlEncodeError"); + expect(dump).toContain( + "failed to output toml: toml: cannot encode a map with non-string key type", + ); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("Go --output=toml emits Go-shaped bytes when no snippet has a description", () => { + // In practice the Management API always includes `description` (the + // schema marks it required, value-or-null), so this success branch is + // realistically unreachable in production — Go fails there too. It is + // kept to pin the encoder bytes for the shape where the key is absent. + const { description: _omitted, ...withoutDescription } = SNIPPET_BASE; + const { layer, out } = setup({ + goOutput: "toml", + response: { data: [withoutDescription] }, + }); return Effect.gen(function* () { yield* legacySnippetsList({ projectRef: Option.none() }); - expect(out.stdoutText.length).toBeGreaterThan(0); - expect(out.stdoutText).toContain(SNIPPET_ID); + // PascalCase Go field names, sub-tables after primitives, 2-space indent. + expect(out.stdoutText).toBe(`[[Data]] + Favorite = false + Id = "${SNIPPET_ID}" + InsertedAt = "2023-10-13T17:48:58.491Z" + Name = "Create table" + Type = "sql" + UpdatedAt = "2023-10-13T17:48:58.491Z" + Visibility = "user" + [Data.Owner] + Id = 7.0 + Username = "supaseed" + [Data.Project] + Id = 1.0 + Name = "Proj" + [Data.UpdatedBy] + Id = 7.0 + Username = "supaseed" +`); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/legacy/commands/snippets/snippets.errors.ts b/apps/cli/src/legacy/commands/snippets/snippets.errors.ts index e030544b4b..3038ca03bc 100644 --- a/apps/cli/src/legacy/commands/snippets/snippets.errors.ts +++ b/apps/cli/src/legacy/commands/snippets/snippets.errors.ts @@ -22,6 +22,16 @@ export class LegacySnippetsEnvNotSupportedError extends Data.TaggedError( readonly message: string; }> {} +// Mirrors Go's `utils.EncodeOutput` TOML failure: `snippets list -o toml` +// fails whenever a snippet carries a `description`, because BurntSushi +// refuses the `nullable.Nullable[string]` (`map[bool]string`) field +// ("failed to output toml: toml: cannot encode a map with non-string key type"). +export class LegacySnippetsTomlEncodeError extends Data.TaggedError( + "LegacySnippetsTomlEncodeError", +)<{ + 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")<{ diff --git a/apps/cli/src/legacy/commands/ssl-enforcement/get/get.handler.ts b/apps/cli/src/legacy/commands/ssl-enforcement/get/get.handler.ts index c3891ad572..49d1816f26 100644 --- a/apps/cli/src/legacy/commands/ssl-enforcement/get/get.handler.ts +++ b/apps/cli/src/legacy/commands/ssl-enforcement/get/get.handler.ts @@ -6,12 +6,12 @@ import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-proje import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; import { LegacyOutputFlag } from "../../../../shared/legacy/global-flags.ts"; import { Output } from "../../../../shared/output/output.service.ts"; +import { encodeEnv, encodeGoJson } from "../../../shared/legacy-go-output.encoders.ts"; import { - encodeEnv, - encodeGoJson, - encodeToml, - encodeYaml, -} from "../../../shared/legacy-go-output.encoders.ts"; + encodeLegacyGoToml, + encodeLegacyGoYaml, +} from "../../../shared/legacy-go-struct-output.encoders.ts"; +import { LEGACY_GO_SSL_ENFORCEMENT_RESPONSE } from "../ssl-enforcement.go-payload.ts"; import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; import { LegacySslEnforcementGetNetworkError, @@ -62,11 +62,11 @@ export const legacySslEnforcementGet = Effect.fn("legacy.ssl-enforcement.get")(f return; } if (goFmt === "yaml") { - yield* output.raw(encodeYaml(response)); + yield* output.raw(encodeLegacyGoYaml(response, LEGACY_GO_SSL_ENFORCEMENT_RESPONSE)); return; } if (goFmt === "toml") { - yield* output.raw(encodeToml(response) + "\n"); + yield* output.raw(encodeLegacyGoToml(response, LEGACY_GO_SSL_ENFORCEMENT_RESPONSE)); return; } if (goFmt === "env") { diff --git a/apps/cli/src/legacy/commands/ssl-enforcement/get/get.integration.test.ts b/apps/cli/src/legacy/commands/ssl-enforcement/get/get.integration.test.ts index 986fdbb71b..8ff8b65973 100644 --- a/apps/cli/src/legacy/commands/ssl-enforcement/get/get.integration.test.ts +++ b/apps/cli/src/legacy/commands/ssl-enforcement/get/get.integration.test.ts @@ -138,7 +138,7 @@ describe("legacy ssl-enforcement get integration", () => { const { layer, out } = setup({ goOutput: "yaml", response: SSL_ENFORCED }); return Effect.gen(function* () { yield* legacySslEnforcementGet({ projectRef: Option.none() }); - expect(out.stdoutText).toContain("appliedSuccessfully: true"); + expect(out.stdoutText).toContain("appliedsuccessfully: true"); expect(out.stdoutText).toContain("database: true"); }).pipe(Effect.provide(layer)); }); @@ -147,8 +147,8 @@ describe("legacy ssl-enforcement get integration", () => { const { layer, out } = setup({ goOutput: "toml", response: SSL_ENFORCED }); return Effect.gen(function* () { yield* legacySslEnforcementGet({ projectRef: Option.none() }); - expect(out.stdoutText).toContain("appliedSuccessfully = true"); - expect(out.stdoutText).toContain("[currentConfig]"); + expect(out.stdoutText).toContain("AppliedSuccessfully = true"); + expect(out.stdoutText).toContain("[CurrentConfig]"); }).pipe(Effect.provide(layer)); }); @@ -187,7 +187,7 @@ describe("legacy ssl-enforcement get integration", () => { const { layer, out } = setup({ format: "json", goOutput: "yaml", response: SSL_ENFORCED }); return Effect.gen(function* () { yield* legacySslEnforcementGet({ projectRef: Option.none() }); - expect(out.stdoutText).toContain("appliedSuccessfully: true"); + expect(out.stdoutText).toContain("appliedsuccessfully: true"); expect(out.stdoutText.startsWith("{")).toBe(false); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/legacy/commands/ssl-enforcement/ssl-enforcement.go-payload.ts b/apps/cli/src/legacy/commands/ssl-enforcement/ssl-enforcement.go-payload.ts new file mode 100644 index 0000000000..733082643e --- /dev/null +++ b/apps/cli/src/legacy/commands/ssl-enforcement/ssl-enforcement.go-payload.ts @@ -0,0 +1,15 @@ +import { + type LegacyGoType, + legacyGoBool, + legacyGoStruct, +} from "../../shared/legacy-go-struct-output.encoders.ts"; + +/** + * Mirror of Go's `api.SslEnforcementResponse` (`apps/cli-go/pkg/api/types.gen.go`). + * Shared by `ssl-enforcement get` and `ssl-enforcement update` for + * `-o yaml` / `-o toml` (CLI-1975). + */ +export const LEGACY_GO_SSL_ENFORCEMENT_RESPONSE: LegacyGoType = legacyGoStruct([ + ["appliedSuccessfully", legacyGoBool], + ["currentConfig", legacyGoStruct([["database", legacyGoBool]])], +]); diff --git a/apps/cli/src/legacy/commands/ssl-enforcement/update/update.handler.ts b/apps/cli/src/legacy/commands/ssl-enforcement/update/update.handler.ts index 615429cc08..eb5cf951c3 100644 --- a/apps/cli/src/legacy/commands/ssl-enforcement/update/update.handler.ts +++ b/apps/cli/src/legacy/commands/ssl-enforcement/update/update.handler.ts @@ -6,12 +6,12 @@ import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-proje import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; import { LegacyOutputFlag } from "../../../../shared/legacy/global-flags.ts"; import { Output } from "../../../../shared/output/output.service.ts"; +import { encodeEnv, encodeGoJson } from "../../../shared/legacy-go-output.encoders.ts"; import { - encodeEnv, - encodeGoJson, - encodeToml, - encodeYaml, -} from "../../../shared/legacy-go-output.encoders.ts"; + encodeLegacyGoToml, + encodeLegacyGoYaml, +} from "../../../shared/legacy-go-struct-output.encoders.ts"; +import { LEGACY_GO_SSL_ENFORCEMENT_RESPONSE } from "../ssl-enforcement.go-payload.ts"; import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; import { LegacySslEnforcementMutuallyExclusiveFlagsError, @@ -79,11 +79,11 @@ export const legacySslEnforcementUpdate = Effect.fn("legacy.ssl-enforcement.upda return; } if (goFmt === "yaml") { - yield* output.raw(encodeYaml(response)); + yield* output.raw(encodeLegacyGoYaml(response, LEGACY_GO_SSL_ENFORCEMENT_RESPONSE)); return; } if (goFmt === "toml") { - yield* output.raw(encodeToml(response) + "\n"); + yield* output.raw(encodeLegacyGoToml(response, LEGACY_GO_SSL_ENFORCEMENT_RESPONSE)); return; } if (goFmt === "env") { diff --git a/apps/cli/src/legacy/commands/ssl-enforcement/update/update.integration.test.ts b/apps/cli/src/legacy/commands/ssl-enforcement/update/update.integration.test.ts index e5aa6b4f32..0187e6cbfc 100644 --- a/apps/cli/src/legacy/commands/ssl-enforcement/update/update.integration.test.ts +++ b/apps/cli/src/legacy/commands/ssl-enforcement/update/update.integration.test.ts @@ -292,7 +292,7 @@ describe("legacy ssl-enforcement update integration", () => { enableDbSslEnforcement: true, disableDbSslEnforcement: false, }); - expect(out.stdoutText).toContain("appliedSuccessfully: true"); + expect(out.stdoutText).toContain("appliedsuccessfully: true"); expect(out.stdoutText).toContain("database: true"); }).pipe(Effect.provide(layer)); }); @@ -305,8 +305,8 @@ describe("legacy ssl-enforcement update integration", () => { enableDbSslEnforcement: true, disableDbSslEnforcement: false, }); - expect(out.stdoutText).toContain("appliedSuccessfully = true"); - expect(out.stdoutText).toContain("[currentConfig]"); + expect(out.stdoutText).toContain("AppliedSuccessfully = true"); + expect(out.stdoutText).toContain("[CurrentConfig]"); }).pipe(Effect.provide(layer)); }); @@ -365,7 +365,7 @@ describe("legacy ssl-enforcement update integration", () => { enableDbSslEnforcement: true, disableDbSslEnforcement: false, }); - expect(out.stdoutText).toContain("appliedSuccessfully: true"); + expect(out.stdoutText).toContain("appliedsuccessfully: true"); expect(out.stdoutText.startsWith("{")).toBe(false); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/legacy/commands/sso/add/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/sso/add/SIDE_EFFECTS.md index 350a2d34c9..3b2078c420 100644 --- a/apps/cli/src/legacy/commands/sso/add/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/sso/add/SIDE_EFFECTS.md @@ -65,7 +65,7 @@ Glamour-styled property/value markdown table plus optional `## Attribute Mapping ### `--output json` / `--output yaml` / `--output toml` -Response verbatim (Go-compatible alphabetised keys for JSON). +Response re-encoded per format, matching the Go binary byte-for-byte (CLI-1975): JSON keeps the snake_case JSON tags with alphabetised keys and Go's HTML escaping (`<`/`>`/`&` as `\u003c`-style escapes — visible in `metadata_xml`); YAML uses yaml.v3's lowercased Go struct field names (`metadataxml`, explicit `null` for nil pointers); TOML uses BurntSushi's PascalCase Go struct field names (`MetadataXml`) with nil pointers omitted. ### `--output env` diff --git a/apps/cli/src/legacy/commands/sso/add/add.handler.ts b/apps/cli/src/legacy/commands/sso/add/add.handler.ts index 29b3592ba9..b2526468df 100644 --- a/apps/cli/src/legacy/commands/sso/add/add.handler.ts +++ b/apps/cli/src/legacy/commands/sso/add/add.handler.ts @@ -6,12 +6,12 @@ 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, encodeGoStructJsonBody } from "../../../shared/legacy-go-output.encoders.ts"; import { - encodeGoJson, - encodeGoStructJsonBody, - encodeToml, - encodeYaml, -} from "../../../shared/legacy-go-output.encoders.ts"; + encodeLegacyGoToml, + encodeLegacyGoYaml, +} from "../../../shared/legacy-go-struct-output.encoders.ts"; +import { LEGACY_GO_SSO_PROVIDER_RESPONSE } from "../sso.go-payload.ts"; import { sanitizeLegacyErrorBody } from "../../../shared/legacy-http-errors.ts"; import { resolveLegacyAccessToken } from "../../../shared/legacy-resolve-token.ts"; import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; @@ -168,11 +168,11 @@ export const legacySsoAdd = Effect.fn("legacy.sso.add")(function* (flags: Legacy return; } if (goFmt === "yaml") { - yield* output.raw(encodeYaml(parsedJson)); + yield* output.raw(encodeLegacyGoYaml(parsedJson, LEGACY_GO_SSO_PROVIDER_RESPONSE)); return; } if (goFmt === "toml") { - yield* output.raw(encodeToml(parsedJson) + "\n"); + yield* output.raw(encodeLegacyGoToml(parsedJson, LEGACY_GO_SSO_PROVIDER_RESPONSE)); return; } if (goFmt === "env") { diff --git a/apps/cli/src/legacy/commands/sso/list/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/sso/list/SIDE_EFFECTS.md index 4648596fd8..58b81bb134 100644 --- a/apps/cli/src/legacy/commands/sso/list/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/sso/list/SIDE_EFFECTS.md @@ -55,7 +55,7 @@ Glamour-styled ASCII table with columns `TYPE`, `IDENTITY PROVIDER ID`, `DOMAINS ### `--output json` / `--output yaml` / `--output toml` -Encoded `{providers: items}` (Go-compatible alphabetised keys for JSON). +Encoded `{providers: items}` matching the Go binary byte-for-byte (CLI-1975): JSON keeps snake_case tags with alphabetised keys; YAML/TOML derive item keys from the Go struct field names (yaml.v3 lowercases them, BurntSushi keeps PascalCase). ### `--output env` diff --git a/apps/cli/src/legacy/commands/sso/list/list.handler.ts b/apps/cli/src/legacy/commands/sso/list/list.handler.ts index 9f3a49d8f2..d83e097e51 100644 --- a/apps/cli/src/legacy/commands/sso/list/list.handler.ts +++ b/apps/cli/src/legacy/commands/sso/list/list.handler.ts @@ -5,12 +5,12 @@ 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 { encodeEnv, encodeGoJson } from "../../../shared/legacy-go-output.encoders.ts"; import { - encodeEnv, - encodeGoJson, - encodeToml, - encodeYaml, -} from "../../../shared/legacy-go-output.encoders.ts"; + encodeLegacyGoToml, + encodeLegacyGoYaml, +} from "../../../shared/legacy-go-struct-output.encoders.ts"; +import { LEGACY_GO_SSO_PROVIDERS_WRAPPER } from "../sso.go-payload.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"; @@ -83,11 +83,11 @@ export const legacySsoList = Effect.fn("legacy.sso.list")(function* (flags: Lega return; } if (goFmt === "yaml") { - yield* output.raw(encodeYaml(payload)); + yield* output.raw(encodeLegacyGoYaml(payload, LEGACY_GO_SSO_PROVIDERS_WRAPPER)); return; } if (goFmt === "toml") { - yield* output.raw(encodeToml(payload) + "\n"); + yield* output.raw(encodeLegacyGoToml(payload, LEGACY_GO_SSO_PROVIDERS_WRAPPER)); return; } if (goFmt === "env") { diff --git a/apps/cli/src/legacy/commands/sso/remove/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/sso/remove/SIDE_EFFECTS.md index 34bcb32e3c..9447e5aa68 100644 --- a/apps/cli/src/legacy/commands/sso/remove/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/sso/remove/SIDE_EFFECTS.md @@ -56,7 +56,7 @@ Glamour-styled property/value markdown table showing the removed provider's deta ### `--output json` / `--output yaml` / `--output toml` -Response verbatim (Go-compatible alphabetised keys for JSON). +Response re-encoded per format, matching the Go binary byte-for-byte (CLI-1975): JSON keeps the snake_case JSON tags with alphabetised keys and Go's HTML escaping (`<`/`>`/`&` as `\u003c`-style escapes — visible in `metadata_xml`); YAML uses yaml.v3's lowercased Go struct field names (`metadataxml`, explicit `null` for nil pointers); TOML uses BurntSushi's PascalCase Go struct field names (`MetadataXml`) with nil pointers omitted. ### `--output env` diff --git a/apps/cli/src/legacy/commands/sso/remove/remove.handler.ts b/apps/cli/src/legacy/commands/sso/remove/remove.handler.ts index c7cc0bf8e6..ebc8fddc4a 100644 --- a/apps/cli/src/legacy/commands/sso/remove/remove.handler.ts +++ b/apps/cli/src/legacy/commands/sso/remove/remove.handler.ts @@ -5,7 +5,12 @@ 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 { encodeGoJson } from "../../../shared/legacy-go-output.encoders.ts"; +import { + encodeLegacyGoToml, + encodeLegacyGoYaml, +} from "../../../shared/legacy-go-struct-output.encoders.ts"; +import { LEGACY_GO_SSO_PROVIDER_RESPONSE } from "../sso.go-payload.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"; @@ -82,11 +87,11 @@ export const legacySsoRemove = Effect.fn("legacy.sso.remove")(function* ( return; } if (goFmt === "yaml") { - yield* output.raw(encodeYaml(response)); + yield* output.raw(encodeLegacyGoYaml(response, LEGACY_GO_SSO_PROVIDER_RESPONSE)); return; } if (goFmt === "toml") { - yield* output.raw(encodeToml(response) + "\n"); + yield* output.raw(encodeLegacyGoToml(response, LEGACY_GO_SSO_PROVIDER_RESPONSE)); return; } if (goFmt === "env") { diff --git a/apps/cli/src/legacy/commands/sso/show/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/sso/show/SIDE_EFFECTS.md index ea25d6e730..edb50728d4 100644 --- a/apps/cli/src/legacy/commands/sso/show/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/sso/show/SIDE_EFFECTS.md @@ -61,7 +61,7 @@ Glamour-styled property/value markdown table plus optional `## Attribute Mapping ### `--output json` / `--output yaml` / `--output toml` -Response verbatim (Go-compatible alphabetised keys for JSON). +Response re-encoded per format, matching the Go binary byte-for-byte (CLI-1975): JSON keeps the snake_case JSON tags with alphabetised keys and Go's HTML escaping (`<`/`>`/`&` as `\u003c`-style escapes — visible in `metadata_xml`); YAML uses yaml.v3's lowercased Go struct field names (`metadataxml`, explicit `null` for nil pointers); TOML uses BurntSushi's PascalCase Go struct field names (`MetadataXml`) with nil pointers omitted. ### `--output env` diff --git a/apps/cli/src/legacy/commands/sso/show/show.handler.ts b/apps/cli/src/legacy/commands/sso/show/show.handler.ts index cd35e4495c..e25c3455cb 100644 --- a/apps/cli/src/legacy/commands/sso/show/show.handler.ts +++ b/apps/cli/src/legacy/commands/sso/show/show.handler.ts @@ -5,7 +5,12 @@ 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 { encodeGoJson } from "../../../shared/legacy-go-output.encoders.ts"; +import { + encodeLegacyGoToml, + encodeLegacyGoYaml, +} from "../../../shared/legacy-go-struct-output.encoders.ts"; +import { LEGACY_GO_SSO_PROVIDER_RESPONSE } from "../sso.go-payload.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"; @@ -86,11 +91,11 @@ export const legacySsoShow = Effect.fn("legacy.sso.show")(function* (flags: Lega return; } if (goFmt === "yaml") { - yield* output.raw(encodeYaml(response)); + yield* output.raw(encodeLegacyGoYaml(response, LEGACY_GO_SSO_PROVIDER_RESPONSE)); return; } if (goFmt === "toml") { - yield* output.raw(encodeToml(response) + "\n"); + yield* output.raw(encodeLegacyGoToml(response, LEGACY_GO_SSO_PROVIDER_RESPONSE)); return; } diff --git a/apps/cli/src/legacy/commands/sso/show/show.integration.test.ts b/apps/cli/src/legacy/commands/sso/show/show.integration.test.ts index fcaf74702f..4cbd6de8a3 100644 --- a/apps/cli/src/legacy/commands/sso/show/show.integration.test.ts +++ b/apps/cli/src/legacy/commands/sso/show/show.integration.test.ts @@ -174,7 +174,7 @@ describe("legacy sso show integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("Go --output=json encodes response", () => { + it.live("Go --output=json encodes response with Go's HTML escaping", () => { const { layer, out } = setup({ goOutput: "json" }); return Effect.gen(function* () { yield* legacySsoShow({ @@ -184,10 +184,14 @@ describe("legacy sso show integration", () => { }); expect(out.stdoutText.startsWith("{")).toBe(true); expect(out.stdoutText).toContain(VALID_PROVIDER_ID); + // Go's json.Encoder escapes `<` / `>` / `&` by default (CLI-1975), so + // metadata_xml must carry \u003c-style escapes byte-for-byte. + expect(out.stdoutText).toContain('"metadata_xml": "\\u003c?xml version=\\"2.0\\"?\\u003e"'); + expect(out.stdoutText).not.toContain('"metadata_xml": " { + it.live("Go --output=yaml encodes the provider with yaml.v3's byte shape", () => { const { layer, out } = setup({ goOutput: "yaml" }); return Effect.gen(function* () { yield* legacySsoShow({ @@ -195,11 +199,26 @@ describe("legacy sso show integration", () => { providerId: VALID_PROVIDER_ID, metadata: false, }); - expect(out.stdoutText).toContain(VALID_PROVIDER_ID); + // Byte-exact Go parity (CLI-1975): lowercased Go field names, explicit + // nulls for nil pointers, 4-column nesting, quoted string timestamps. + expect(out.stdoutText).toBe(`createdat: "2023-03-28T13:50:14.464Z" +domains: + - createdat: null + domain: example.com + updatedat: null +id: ${VALID_PROVIDER_ID} +saml: + attributemapping: null + entityid: https://example.com + metadataurl: https://example.com + metadataxml: + nameidformat: null +updatedat: "2023-03-28T13:50:14.464Z" +`); }).pipe(Effect.provide(layer)); }); - it.live("Go --output=toml encodes response", () => { + it.live("Go --output=toml encodes the provider with BurntSushi's byte shape", () => { const { layer, out } = setup({ goOutput: "toml" }); return Effect.gen(function* () { yield* legacySsoShow({ @@ -207,7 +226,20 @@ describe("legacy sso show integration", () => { providerId: VALID_PROVIDER_ID, metadata: false, }); - expect(out.stdoutText).toContain(VALID_PROVIDER_ID); + // Byte-exact Go parity (CLI-1975): PascalCase Go field names, nil + // pointers omitted, sub-tables after primitives. + expect(out.stdoutText).toBe(`CreatedAt = "2023-03-28T13:50:14.464Z" +Id = "${VALID_PROVIDER_ID}" +UpdatedAt = "2023-03-28T13:50:14.464Z" + +[[Domains]] + Domain = "example.com" + +[Saml] + EntityId = "https://example.com" + MetadataUrl = "https://example.com" + MetadataXml = "" +`); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/legacy/commands/sso/sso.go-payload.ts b/apps/cli/src/legacy/commands/sso/sso.go-payload.ts new file mode 100644 index 0000000000..997d44399e --- /dev/null +++ b/apps/cli/src/legacy/commands/sso/sso.go-payload.ts @@ -0,0 +1,75 @@ +import { + type LegacyGoType, + legacyGoAny, + legacyGoBool, + legacyGoMap, + legacyGoPtr, + legacyGoSlice, + legacyGoString, + legacyGoStruct, + legacyGoTomlListWrapper, +} from "../../shared/legacy-go-struct-output.encoders.ts"; + +/** + * Mirror of Go's `api.GetProviderResponse` / `CreateProviderResponse` / + * `UpdateProviderResponse` / `DeleteProviderResponse` — all four share the + * exact same anonymous shape in `apps/cli-go/pkg/api/types.gen.go`. Shared by + * `sso show`, `sso add`, `sso update`, `sso remove`, and (as list items) + * `sso list` for `-o yaml` / `-o toml` (CLI-1975). + */ +export const LEGACY_GO_SSO_PROVIDER_RESPONSE: LegacyGoType = legacyGoStruct([ + ["created_at", legacyGoPtr(legacyGoString)], + [ + "domains", + legacyGoPtr( + legacyGoSlice( + legacyGoStruct([ + ["created_at", legacyGoPtr(legacyGoString)], + ["domain", legacyGoPtr(legacyGoString)], + ["updated_at", legacyGoPtr(legacyGoString)], + ]), + ), + ), + ], + ["id", legacyGoString], + [ + "saml", + legacyGoPtr( + legacyGoStruct([ + [ + "attribute_mapping", + legacyGoPtr( + legacyGoStruct([ + [ + "keys", + legacyGoMap( + legacyGoStruct([ + ["array", legacyGoPtr(legacyGoBool)], + ["default", legacyGoAny], + ["name", legacyGoPtr(legacyGoString)], + ["names", legacyGoPtr(legacyGoSlice(legacyGoString))], + ]), + ), + ], + ]), + ), + ], + ["entity_id", legacyGoString], + ["metadata_url", legacyGoPtr(legacyGoString)], + ["metadata_xml", legacyGoPtr(legacyGoString)], + ["name_id_format", legacyGoPtr(legacyGoString)], + ]), + ), + ], + ["updated_at", legacyGoPtr(legacyGoString)], +]); + +/** + * `sso list` encodes `map[string]any{"providers": resp.JSON200.Items}` + * (`list.go:35-37`) — a single lowercase key wrapping the provider structs, + * which renders identically to a one-field tagged wrapper struct. + */ +export const LEGACY_GO_SSO_PROVIDERS_WRAPPER: LegacyGoType = legacyGoTomlListWrapper( + "providers", + LEGACY_GO_SSO_PROVIDER_RESPONSE, +); diff --git a/apps/cli/src/legacy/commands/sso/update/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/sso/update/SIDE_EFFECTS.md index 4fa58fb871..0d11aab777 100644 --- a/apps/cli/src/legacy/commands/sso/update/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/sso/update/SIDE_EFFECTS.md @@ -67,7 +67,7 @@ Glamour-styled property/value markdown table plus optional `## Attribute Mapping ### `--output json` / `--output yaml` / `--output toml` -Response verbatim (Go-compatible alphabetised keys for JSON). +Response re-encoded per format, matching the Go binary byte-for-byte (CLI-1975): JSON keeps the snake_case JSON tags with alphabetised keys and Go's HTML escaping (`<`/`>`/`&` as `\u003c`-style escapes — visible in `metadata_xml`); YAML uses yaml.v3's lowercased Go struct field names (`metadataxml`, explicit `null` for nil pointers); TOML uses BurntSushi's PascalCase Go struct field names (`MetadataXml`) with nil pointers omitted. ### `--output env` diff --git a/apps/cli/src/legacy/commands/sso/update/update.handler.ts b/apps/cli/src/legacy/commands/sso/update/update.handler.ts index 77e86d906f..08fc25e587 100644 --- a/apps/cli/src/legacy/commands/sso/update/update.handler.ts +++ b/apps/cli/src/legacy/commands/sso/update/update.handler.ts @@ -12,12 +12,12 @@ import { hasExplicitValueFlag, } from "../../../../shared/cli/cobra-flag-groups.ts"; import { Output } from "../../../../shared/output/output.service.ts"; +import { encodeGoJson, encodeGoStructJsonBody } from "../../../shared/legacy-go-output.encoders.ts"; import { - encodeGoJson, - encodeGoStructJsonBody, - encodeToml, - encodeYaml, -} from "../../../shared/legacy-go-output.encoders.ts"; + encodeLegacyGoToml, + encodeLegacyGoYaml, +} from "../../../shared/legacy-go-struct-output.encoders.ts"; +import { LEGACY_GO_SSO_PROVIDER_RESPONSE } from "../sso.go-payload.ts"; import { mapLegacyHttpError, sanitizeLegacyErrorBody } from "../../../shared/legacy-http-errors.ts"; import { resolveLegacyAccessToken } from "../../../shared/legacy-resolve-token.ts"; import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; @@ -305,11 +305,11 @@ export const legacySsoUpdate = Effect.fn("legacy.sso.update")(function* ( return; } if (goFmt === "yaml") { - yield* output.raw(encodeYaml(parsedJson)); + yield* output.raw(encodeLegacyGoYaml(parsedJson, LEGACY_GO_SSO_PROVIDER_RESPONSE)); return; } if (goFmt === "toml") { - yield* output.raw(encodeToml(parsedJson) + "\n"); + yield* output.raw(encodeLegacyGoToml(parsedJson, LEGACY_GO_SSO_PROVIDER_RESPONSE)); return; } if (goFmt === "env") { diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/activate/activate.handler.ts b/apps/cli/src/legacy/commands/vanity-subdomains/activate/activate.handler.ts index 2d8a04f6b6..1d68b40550 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/activate/activate.handler.ts +++ b/apps/cli/src/legacy/commands/vanity-subdomains/activate/activate.handler.ts @@ -10,12 +10,13 @@ import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-proje import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; import { LegacyOutputFlag } from "../../../../shared/legacy/global-flags.ts"; import { Output } from "../../../../shared/output/output.service.ts"; +import { encodeEnv, encodeGoJson } from "../../../shared/legacy-go-output.encoders.ts"; import { - encodeEnv, - encodeGoJson, - encodeToml, - encodeYaml, -} from "../../../shared/legacy-go-output.encoders.ts"; + encodeLegacyGoToml, + encodeLegacyGoYaml, + legacyGoString, + legacyGoStruct, +} from "../../../shared/legacy-go-struct-output.encoders.ts"; import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; import { LegacyDesiredSubdomainRequiredError, @@ -24,6 +25,9 @@ import { } from "../vanity-subdomains.errors.ts"; import type { LegacyVanitySubdomainsActivateFlags } from "./activate.command.ts"; +/** Mirror of Go's `api.ActivateVanitySubdomainResponse` (`types.gen.go`). */ +const LEGACY_GO_ACTIVATE_VANITY_RESPONSE = legacyGoStruct([["custom_domain", legacyGoString]]); + const mapActivateError = mapLegacyHttpError({ networkError: LegacyVanitySubdomainsActivateNetworkError, statusError: LegacyVanitySubdomainsActivateUnexpectedStatusError, @@ -96,11 +100,11 @@ export const legacyVanitySubdomainsActivate = Effect.fn("legacy.vanity-subdomain return; } if (legacyOutput === "yaml") { - yield* output.raw(encodeYaml(response)); + yield* output.raw(encodeLegacyGoYaml(response, LEGACY_GO_ACTIVATE_VANITY_RESPONSE)); return; } if (legacyOutput === "toml") { - yield* output.raw(encodeToml({ CustomDomain: response.custom_domain }) + "\n"); + yield* output.raw(encodeLegacyGoToml(response, LEGACY_GO_ACTIVATE_VANITY_RESPONSE)); return; } if (legacyOutput === "env") { diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/check-availability.handler.ts b/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/check-availability.handler.ts index 01271d408b..a1de5873f8 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/check-availability.handler.ts +++ b/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/check-availability.handler.ts @@ -10,12 +10,13 @@ import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-proje import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; import { LegacyOutputFlag } from "../../../../shared/legacy/global-flags.ts"; import { Output } from "../../../../shared/output/output.service.ts"; +import { encodeEnv, encodeGoJson } from "../../../shared/legacy-go-output.encoders.ts"; import { - encodeEnv, - encodeGoJson, - encodeToml, - encodeYaml, -} from "../../../shared/legacy-go-output.encoders.ts"; + encodeLegacyGoToml, + encodeLegacyGoYaml, + legacyGoBool, + legacyGoStruct, +} from "../../../shared/legacy-go-struct-output.encoders.ts"; import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; import { LegacyDesiredSubdomainRequiredError, @@ -24,6 +25,9 @@ import { } from "../vanity-subdomains.errors.ts"; import type { LegacyVanitySubdomainsCheckAvailabilityFlags } from "./check-availability.command.ts"; +/** Mirror of Go's `api.SubdomainAvailabilityResponse` (`types.gen.go`). */ +const LEGACY_GO_AVAILABILITY_RESPONSE = legacyGoStruct([["available", legacyGoBool]]); + const mapCheckError = mapLegacyHttpError({ networkError: LegacyVanitySubdomainsCheckNetworkError, statusError: LegacyVanitySubdomainsCheckUnexpectedStatusError, @@ -100,11 +104,11 @@ export const legacyVanitySubdomainsCheckAvailability = Effect.fn( return; } if (legacyOutput === "yaml") { - yield* output.raw(encodeYaml(response)); + yield* output.raw(encodeLegacyGoYaml(response, LEGACY_GO_AVAILABILITY_RESPONSE)); return; } if (legacyOutput === "toml") { - yield* output.raw(encodeToml({ Available: response.available }) + "\n"); + yield* output.raw(encodeLegacyGoToml(response, LEGACY_GO_AVAILABILITY_RESPONSE)); return; } if (legacyOutput === "env") { diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/get/get.handler.ts b/apps/cli/src/legacy/commands/vanity-subdomains/get/get.handler.ts index 192725f606..3170ec4efe 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/get/get.handler.ts +++ b/apps/cli/src/legacy/commands/vanity-subdomains/get/get.handler.ts @@ -6,12 +6,14 @@ import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-proje import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; import { LegacyOutputFlag } from "../../../../shared/legacy/global-flags.ts"; import { Output } from "../../../../shared/output/output.service.ts"; +import { encodeEnv, encodeGoJson } from "../../../shared/legacy-go-output.encoders.ts"; import { - encodeEnv, - encodeGoJson, - encodeToml, - encodeYaml, -} from "../../../shared/legacy-go-output.encoders.ts"; + encodeLegacyGoToml, + encodeLegacyGoYaml, + legacyGoPtr, + legacyGoString, + legacyGoStruct, +} from "../../../shared/legacy-go-struct-output.encoders.ts"; import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; import { legacyGateMapError } from "../../../shared/legacy-upgrade-suggest.ts"; import { @@ -20,6 +22,12 @@ import { } from "../vanity-subdomains.errors.ts"; import type { LegacyVanitySubdomainsGetFlags } from "./get.command.ts"; +/** Mirror of Go's `api.VanitySubdomainConfigResponse` (`types.gen.go`). */ +const LEGACY_GO_VANITY_CONFIG_RESPONSE = legacyGoStruct([ + ["custom_domain", legacyGoPtr(legacyGoString)], + ["status", legacyGoString], +]); + const mapGetError = mapLegacyHttpError({ networkError: LegacyVanitySubdomainsGetNetworkError, statusError: LegacyVanitySubdomainsGetUnexpectedStatusError, @@ -56,18 +64,11 @@ export const legacyVanitySubdomainsGet = Effect.fn("legacy.vanity-subdomains.get return; } if (legacyOutput === "yaml") { - yield* output.raw(encodeYaml(response)); + yield* output.raw(encodeLegacyGoYaml(response, LEGACY_GO_VANITY_CONFIG_RESPONSE)); return; } if (legacyOutput === "toml") { - yield* output.raw( - encodeToml({ - Status: response.status, - ...(response.custom_domain === undefined - ? {} - : { CustomDomain: response.custom_domain }), - }) + "\n", - ); + yield* output.raw(encodeLegacyGoToml(response, LEGACY_GO_VANITY_CONFIG_RESPONSE)); return; } if (legacyOutput === "env") { diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/vanity-subdomains.integration.test.ts b/apps/cli/src/legacy/commands/vanity-subdomains/vanity-subdomains.integration.test.ts index 54e636a3bc..6c2f27a84d 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/vanity-subdomains.integration.test.ts +++ b/apps/cli/src/legacy/commands/vanity-subdomains/vanity-subdomains.integration.test.ts @@ -192,7 +192,8 @@ describe("legacy vanity-subdomains get", () => { return Effect.gen(function* () { yield* legacyVanitySubdomainsGet({ projectRef: Option.none() }); expect(out.stdoutText).toContain("status: custom-domain-used"); - expect(out.stdoutText).toContain("custom_domain: example.com"); + // yaml.v3 lowercases the whole Go field name (CLI-1975). + expect(out.stdoutText).toContain("customdomain: example.com"); }).pipe(Effect.provide(layer)); }); @@ -203,9 +204,9 @@ describe("legacy vanity-subdomains get", () => { return Effect.gen(function* () { yield* legacyVanitySubdomainsGet({ projectRef: Option.none() }); - expect(out.stdoutText).toBe( - 'Status = "custom-domain-used"\nCustomDomain = "example.com"\n\n', - ); + // Go declaration order (CustomDomain before Status) and a single + // trailing newline, matching BurntSushi (CLI-1975). + expect(out.stdoutText).toBe('CustomDomain = "example.com"\nStatus = "custom-domain-used"\n'); }).pipe(Effect.provide(layer)); }); @@ -216,7 +217,7 @@ describe("legacy vanity-subdomains get", () => { return Effect.gen(function* () { yield* legacyVanitySubdomainsGet({ projectRef: Option.none() }); - expect(out.stdoutText).toBe('Status = "not-used"\n\n'); + expect(out.stdoutText).toBe('Status = "not-used"\n'); }).pipe(Effect.provide(layer)); }); @@ -348,7 +349,7 @@ describe("legacy vanity-subdomains check-availability", () => { projectRef: Option.none(), desiredSubdomain: Option.some("example.com"), }); - expect(out.stdoutText).toBe("Available = true\n\n"); + expect(out.stdoutText).toBe("Available = true\n"); }).pipe(Effect.provide(layer)); }); @@ -505,7 +506,8 @@ describe("legacy vanity-subdomains activate", () => { projectRef: Option.none(), desiredSubdomain: Option.some("example.com"), }); - expect(out.stdoutText).toContain("custom_domain: example.com"); + // yaml.v3 lowercases the whole Go field name (CLI-1975). + expect(out.stdoutText).toContain("customdomain: example.com"); }).pipe(Effect.provide(layer)); }); @@ -519,7 +521,7 @@ describe("legacy vanity-subdomains activate", () => { projectRef: Option.none(), desiredSubdomain: Option.some("example.com"), }); - expect(out.stdoutText).toBe('CustomDomain = "example.com"\n\n'); + expect(out.stdoutText).toBe('CustomDomain = "example.com"\n'); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/legacy/shared/legacy-go-json.ts b/apps/cli/src/legacy/shared/legacy-go-json.ts index cf75bccd58..4887fa9d3a 100644 --- a/apps/cli/src/legacy/shared/legacy-go-json.ts +++ b/apps/cli/src/legacy/shared/legacy-go-json.ts @@ -70,7 +70,7 @@ export function escapeGoJsonString(value: string): string { return out + '"'; } -function walk(value: unknown, depth: number): string { +function walk(value: unknown, depth: number, pretty: boolean): string { if (value === null || value === undefined) return "null"; switch (typeof value) { case "string": @@ -83,19 +83,23 @@ function walk(value: unknown, depth: number): string { case "boolean": return value ? "true" : "false"; } - const indent = " ".repeat(depth + 1); - const closeIndent = " ".repeat(depth); + const indent = pretty ? " ".repeat(depth + 1) : ""; + const closeIndent = pretty ? " ".repeat(depth) : ""; + const open = pretty ? "\n" : ""; + const separator = pretty ? ",\n" : ","; + const close = pretty ? "\n" : ""; if (Array.isArray(value)) { if (value.length === 0) return "[]"; - const items = value.map((item) => indent + walk(item, depth + 1)); - return `[\n${items.join(",\n")}\n${closeIndent}]`; + const items = value.map((item) => indent + walk(item, depth + 1, pretty)); + return `[${open}${items.join(separator)}${close}${closeIndent}]`; } const entries = Object.entries(value as Record); if (entries.length === 0) return "{}"; + const colon = pretty ? ": " : ":"; const lines = entries.map( - ([key, val]) => `${indent}${escapeGoJsonString(key)}: ${walk(val, depth + 1)}`, + ([key, val]) => `${indent}${escapeGoJsonString(key)}${colon}${walk(val, depth + 1, pretty)}`, ); - return `{\n${lines.join(",\n")}\n${closeIndent}}`; + return `{${open}${lines.join(separator)}${close}${closeIndent}}`; } /** @@ -104,5 +108,14 @@ function walk(value: unknown, depth: number): string { * Go string escaping, and a trailing newline. */ export function encodeGoJsonIndented(value: unknown): string { - return walk(value, 0) + "\n"; + return walk(value, 0, true) + "\n"; +} + +/** + * Encodes a value the way Go's `json.Marshal` does: compact separators + * (`{"k":v}`), object keys in insertion (struct) order, Go string escaping + * (HTML characters included), and no trailing newline. + */ +export function encodeGoJsonCompact(value: unknown): string { + return walk(value, 0, false); } diff --git a/apps/cli/src/legacy/shared/legacy-go-json.unit.test.ts b/apps/cli/src/legacy/shared/legacy-go-json.unit.test.ts index b89dbfa422..c5d39eae8d 100644 --- a/apps/cli/src/legacy/shared/legacy-go-json.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-go-json.unit.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { encodeGoJsonIndented, escapeGoJsonString } from "./legacy-go-json.ts"; +import { encodeGoJsonCompact, encodeGoJsonIndented, escapeGoJsonString } from "./legacy-go-json.ts"; describe("escapeGoJsonString", () => { it("escapes quotes and backslashes like Go", () => { @@ -57,3 +57,17 @@ describe("encodeGoJsonIndented", () => { expect(encodeGoJsonIndented({ issues: [] })).toBe(`{\n "issues": []\n}\n`); }); }); + +describe("encodeGoJsonCompact", () => { + it("matches Go's json.Marshal compact shape with HTML escaping", () => { + expect(encodeGoJsonCompact({ metadata_xml: "&stuff", type: "saml" })).toBe( + '{"metadata_xml":"\\u003cxml\\u003e\\u0026stuff\\u003c/xml\\u003e","type":"saml"}', + ); + }); + + it("keeps insertion order, compact separators, and no trailing newline", () => { + expect(encodeGoJsonCompact({ b: [1, 2], a: { c: true } })).toBe('{"b":[1,2],"a":{"c":true}}'); + expect(encodeGoJsonCompact([])).toBe("[]"); + expect(encodeGoJsonCompact(null)).toBe("null"); + }); +}); diff --git a/apps/cli/src/legacy/shared/legacy-go-output.encoders.ts b/apps/cli/src/legacy/shared/legacy-go-output.encoders.ts index 8b5038dcfe..423cee70fa 100644 --- a/apps/cli/src/legacy/shared/legacy-go-output.encoders.ts +++ b/apps/cli/src/legacy/shared/legacy-go-output.encoders.ts @@ -1,10 +1,16 @@ import { stringify as stringifyToml } from "smol-toml"; import { stringify as stringifyYaml } from "yaml"; +import { encodeGoJsonCompact, encodeGoJsonIndented } from "./legacy-go-json.ts"; + /** - * Reproduces Go's `encoding/json` output: + * Reproduces Go's `json.Encoder` output (`utils.EncodeOutput` with `-o json`): * - Top-level and nested struct fields serialize in alphabetical key order. - * - Trailing newline (matches `encoding/json` MarshalIndent + fmt.Println). + * - Go string escaping, including the default HTML escapes (`<` / `>` / `&` + * become `\u003c` / `\u003e` / `\u0026` — Go never calls + * `SetEscapeHTML(false)` on this path), `\u0008`/`\u000c` for + * backspace/form feed, and escaped U+2028/U+2029. + * - Trailing newline (matches `json.Encoder.Encode`). * * The optional `nullForEmptyArrays` option mirrors Go's `null` serialization for nil * slices: when the schema decodes both `null` and `[]` to `[]` upstream, the caller can @@ -34,7 +40,7 @@ export function encodeGoJson( } source = patched; } - return JSON.stringify(sortKeysDeep(source), null, 2) + "\n"; + return encodeGoJsonIndented(sortKeysDeep(source)); } function sortKeysDeep(value: unknown): unknown { @@ -42,7 +48,11 @@ function sortKeysDeep(value: unknown): unknown { if (value === null || typeof value !== "object") return value; const sorted: Record = {}; for (const key of Object.keys(value as Record).sort()) { - sorted[key] = sortKeysDeep((value as Record)[key]); + const child = (value as Record)[key]; + // JSON.stringify used to drop undefined properties; the Go-faithful walker + // renders them as null, so drop them here to keep the old key surface. + if (child === undefined) continue; + sorted[key] = sortKeysDeep(child); } return sorted; } @@ -51,24 +61,39 @@ function sortKeysDeep(value: unknown): unknown { * Serialize an outbound API request body the way Go's `json.Marshal` would * for a struct: keys sorted alphabetically (the `@supabase/api`-generated * structs declare fields alphabetically, and `json.Marshal` serializes in - * field-declaration order), no indentation, no trailing newline. + * field-declaration order), Go string escaping (HTML characters included, + * matching `json.Marshal`'s default `escapeHTML: true`), no indentation, no + * trailing newline. * * Use this on the raw-HTTP code path in `sso add` / `sso update` (and future * handlers that bypass the typed client). The cli-e2e replay server compares - * recorded request bodies via `JSON.stringify`-based string equality, so - * key-order parity is required for parity tests to pass. + * recorded request bodies via string equality against bodies the Go CLI + * produced, so both key order and escaping must match `json.Marshal`. * * `encodeGoJson` is the parallel for human-facing `--output json` output * (indented + trailing `\n`). */ export function encodeGoStructJsonBody(value: unknown): string { - return JSON.stringify(sortKeysDeep(value)); + return encodeGoJsonCompact(sortKeysDeep(value)); } +/** + * Go-compatible YAML for **map** payloads (`branches get` envs, `sso info`, + * `status`, `postgres-config`, …). Struct payloads must NOT use this — Go's + * yaml.v3 derives keys from the Go field names, not the JSON tags; use + * `encodeLegacyGoYaml` from `legacy-go-struct-output.encoders.ts` with the + * payload's Go struct spec instead (CLI-1975). + */ export function encodeYaml(value: unknown): string { return stringifyYaml(value); } +/** + * Go-compatible TOML for **map** payloads. Struct payloads must NOT use this — + * BurntSushi emits PascalCase Go field names with 2-space table indentation; + * use `encodeLegacyGoToml` from `legacy-go-struct-output.encoders.ts` with the + * payload's Go struct spec instead (CLI-1975). + */ export function encodeToml(value: unknown): string { // smol-toml refuses top-level non-object values; wrap if needed. if (typeof value !== "object" || value === null || Array.isArray(value)) { diff --git a/apps/cli/src/legacy/shared/legacy-go-struct-output.encoders.ts b/apps/cli/src/legacy/shared/legacy-go-struct-output.encoders.ts new file mode 100644 index 0000000000..a7e9049088 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-go-struct-output.encoders.ts @@ -0,0 +1,1123 @@ +/** + * Byte-faithful reproductions of the Go CLI's `-o yaml` / `-o toml` output for + * **struct** payloads (CLI-1975). + * + * Go's `utils.EncodeOutput` (`apps/cli-go/internal/utils/output.go`) hands the + * raw Go structs to `gopkg.in/yaml.v3` and `github.com/BurntSushi/toml`. + * Neither library reads the `json:` tags, so the emitted keys are derived from + * the Go **field names**, not the snake_case JSON the Management API returns: + * + * - yaml.v3 lowercases the whole field name (`ProjectRef` → `projectref`) + * and renders nil pointers as explicit `null`. + * - BurntSushi keeps the PascalCase field name (`ProjectRef`), omits nil + * pointers entirely, and renders `time.Time` as a native TOML datetime. + * + * Because the TypeScript CLI only ever sees the decoded snake_case JSON, each + * payload family declares a {@link LegacyGoType} spec mirroring its Go struct + * (field order = Go declaration order, from `apps/cli-go/pkg/api/types.gen.go` + * or the command's own package). The two encoders here then reproduce the + * exact bytes the Go binary prints — including zero-value filling for + * non-pointer fields, nil-vs-empty slice handling, yaml.v3's scalar quoting + * heuristics and 4-space indentation algorithm, and BurntSushi's 2-space table + * indentation and blank-line placement. + * + * Everything in this file is pure and Effect-free so it stays unit-testable. + * The golden bytes asserted in the unit tests were captured from a scratch Go + * program running the repo's own `utils.EncodeOutput` (BurntSushi toml v1.6.0, + * yaml.v3 v3.0.1) over the same payloads. + */ + +// --------------------------------------------------------------------------- +// Go struct specs +// --------------------------------------------------------------------------- + +export type LegacyGoType = + | { readonly kind: "string" } + | { readonly kind: "uuid" } + | { readonly kind: "bool" } + | { readonly kind: "int" } + | { readonly kind: "float"; readonly bits: 32 | 64 } + /** Go `time.Time` — native TOML datetime, unquoted yaml timestamp. */ + | { readonly kind: "time" } + /** Go `interface{}` — shape inferred from the JSON value like `encoding/json` decoding. */ + | { readonly kind: "any" } + | { readonly kind: "ptr"; readonly elem: LegacyGoType } + /** oapi-codegen `nullable.Nullable[T]` — a `map[bool]T` under the hood. */ + | { readonly kind: "nullable"; readonly elem: LegacyGoType } + | { readonly kind: "slice"; readonly elem: LegacyGoType } + | { readonly kind: "map"; readonly value: LegacyGoType } + | { readonly kind: "struct"; readonly fields: ReadonlyArray }; + +interface LegacyGoStructField { + /** JSON tag name — the key present in the decoded payload. */ + readonly json: string; + /** Go field name (PascalCase). */ + readonly go: string; + readonly type: LegacyGoType; +} + +export const legacyGoString: LegacyGoType = { kind: "string" }; +export const legacyGoUuid: LegacyGoType = { kind: "uuid" }; +export const legacyGoBool: LegacyGoType = { kind: "bool" }; +export const legacyGoInt: LegacyGoType = { kind: "int" }; +export const legacyGoFloat32: LegacyGoType = { kind: "float", bits: 32 }; +export const legacyGoFloat64: LegacyGoType = { kind: "float", bits: 64 }; +export const legacyGoTime: LegacyGoType = { kind: "time" }; +export const legacyGoAny: LegacyGoType = { kind: "any" }; + +export function legacyGoPtr(elem: LegacyGoType): LegacyGoType { + return { kind: "ptr", elem }; +} +export function legacyGoNullable(elem: LegacyGoType): LegacyGoType { + return { kind: "nullable", elem }; +} +export function legacyGoSlice(elem: LegacyGoType): LegacyGoType { + return { kind: "slice", elem }; +} +export function legacyGoMap(value: LegacyGoType): LegacyGoType { + return { kind: "map", value }; +} + +/** + * A struct field spec entry: `[jsonName, type]` derives the Go field name + * mechanically (each snake_case token capitalized: `api_key` → `ApiKey`, + * matching oapi-codegen's generated names — verified against `types.gen.go`), + * or `[jsonName, type, goName]` for explicit names. + */ +export type LegacyGoFieldSpec = + | readonly [json: string, type: LegacyGoType] + | readonly [json: string, type: LegacyGoType, goName: string]; + +export function legacyGoStruct(fields: ReadonlyArray): LegacyGoType { + return { + kind: "struct", + fields: fields.map(([json, type, goName]) => ({ + json, + go: goName ?? legacyGoFieldName(json), + type, + })), + }; +} + +/** + * The anonymous wrapper struct Go list commands use for TOML output, e.g. + * `struct{ Branches []api.BranchResponse `toml:"branches"` }` — the `toml:` + * tag keeps the wrapper key lowercase while the elements keep Go field names. + * + * Also models Go's single-key `map[string]any{"providers": items}` wrapper + * (`sso list`, all formats): a one-field lowercase-keyed struct renders + * identically to a one-key map in both encoders. + */ +export function legacyGoTomlListWrapper(key: string, elem: LegacyGoType): LegacyGoType { + return { kind: "struct", fields: [{ json: key, go: key, type: legacyGoSlice(elem) }] }; +} + +/** `api_key` → `ApiKey`, `dbAllowedCidrs` → `DbAllowedCidrs`. */ +export function legacyGoFieldName(jsonName: string): string { + return jsonName + .split("_") + .map((part) => (part.length === 0 ? part : part[0]?.toUpperCase() + part.slice(1))) + .join(""); +} + +// --------------------------------------------------------------------------- +// Normalized Go value tree (decoded JSON + spec → what the Go structs hold) +// --------------------------------------------------------------------------- + +type GoValue = + | { readonly k: "nil" } + | { readonly k: "str"; readonly v: string } + | { readonly k: "bool"; readonly v: boolean } + | { readonly k: "int"; readonly v: number } + | { readonly k: "float"; readonly v: number; readonly bits: 32 | 64 } + | { readonly k: "time"; readonly v: string } + | { readonly k: "struct"; readonly entries: ReadonlyArray } + | { + readonly k: "map"; + readonly nil: boolean; + readonly entries: ReadonlyArray; + } + /** `nullable.Nullable[T]`: nil map, `{false: zero}` (explicit null) or `{true: value}`. */ + | { readonly k: "nullable"; readonly present: boolean | undefined; readonly value?: GoValue } + | { + readonly k: "slice"; + readonly nil: boolean; + readonly items: ReadonlyArray; + readonly tables: boolean; + }; + +const GO_ZERO_TIME = "0001-01-01T00:00:00Z"; +const GO_ZERO_UUID = "00000000-0000-0000-0000-000000000000"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function zeroValue(type: LegacyGoType): GoValue { + switch (type.kind) { + case "string": + return { k: "str", v: "" }; + case "uuid": + return { k: "str", v: GO_ZERO_UUID }; + case "bool": + return { k: "bool", v: false }; + case "int": + return { k: "int", v: 0 }; + case "float": + return { k: "float", v: 0, bits: type.bits }; + case "time": + return { k: "time", v: GO_ZERO_TIME }; + case "struct": + return normalize(undefined, type); + case "ptr": + case "any": + return { k: "nil" }; + case "nullable": + return { k: "nullable", present: undefined }; + case "slice": + return { k: "slice", nil: true, items: [], tables: elementsAreTables(type.elem, []) }; + case "map": + return { k: "map", nil: true, entries: [] }; + } +} + +function elementsAreTables(elem: LegacyGoType, items: ReadonlyArray): boolean { + switch (elem.kind) { + case "struct": + case "map": + case "nullable": + return true; + case "ptr": + case "slice": + return elem.kind === "ptr" ? elementsAreTables(elem.elem, items) : false; + case "any": + // Like Go's runtime type inspection: JSON objects decode to + // map[string]interface{} which BurntSushi treats as tables. + return items.length > 0 && items.every(isRecord); + default: + return false; + } +} + +function normalize(value: unknown, type: LegacyGoType): GoValue { + switch (type.kind) { + case "string": + return typeof value === "string" ? { k: "str", v: value } : zeroValue(type); + case "uuid": + return typeof value === "string" && value.length > 0 + ? { k: "str", v: value } + : zeroValue(type); + case "bool": + return { k: "bool", v: value === true }; + case "int": + return typeof value === "number" && Number.isFinite(value) + ? { k: "int", v: value } + : zeroValue(type); + case "float": + return typeof value === "number" && Number.isFinite(value) + ? { k: "float", v: value, bits: type.bits } + : zeroValue(type); + case "time": + return typeof value === "string" && value.length > 0 + ? { k: "time", v: normalizeGoTime(value) } + : zeroValue(type); + case "ptr": + return value === undefined || value === null ? { k: "nil" } : normalize(value, type.elem); + case "nullable": + // oapi-codegen: absent key → nil map; explicit JSON null → {false: zero}; + // value → {true: value}. + if (value === undefined) return { k: "nullable", present: undefined }; + if (value === null) return { k: "nullable", present: false, value: zeroValue(type.elem) }; + return { k: "nullable", present: true, value: normalize(value, type.elem) }; + case "slice": { + if (!Array.isArray(value)) { + return { k: "slice", nil: true, items: [], tables: elementsAreTables(type.elem, []) }; + } + return { + k: "slice", + nil: false, + items: value.map((item) => normalize(item, type.elem)), + tables: elementsAreTables(type.elem, value), + }; + } + case "map": { + if (!isRecord(value)) return { k: "map", nil: true, entries: [] }; + return { + k: "map", + nil: false, + entries: Object.entries(value).map(([key, v]) => [key, normalize(v, type.value)] as const), + }; + } + case "struct": { + const record = isRecord(value) ? value : {}; + return { + k: "struct", + entries: type.fields.map( + (field) => [field.go, normalize(record[field.json], field.type)] as const, + ), + }; + } + case "any": + return normalizeAny(value); + } +} + +/** Mirror `encoding/json` decoding into `interface{}`. */ +function normalizeAny(value: unknown): GoValue { + if (value === undefined || value === null) return { k: "nil" }; + if (typeof value === "string") return { k: "str", v: value }; + if (typeof value === "boolean") return { k: "bool", v: value }; + if (typeof value === "number") { + // JSON numbers decode to float64 in Go's interface{} world. + return { k: "float", v: value, bits: 64 }; + } + if (Array.isArray(value)) { + return { + k: "slice", + nil: false, + items: value.map(normalizeAny), + tables: value.length > 0 && value.every(isRecord), + }; + } + if (isRecord(value)) { + return { + k: "map", + nil: false, + entries: Object.entries(value).map(([key, v]) => [key, normalizeAny(v)] as const), + }; + } + return { k: "nil" }; +} + +/** + * Render an RFC3339 input the way Go formats a decoded `time.Time` with + * `time.RFC3339Nano`: trailing zeros trimmed from the fraction (the dot is + * dropped when the fraction is all zeros) and a zero offset rendered as `Z`. + */ +function normalizeGoTime(value: string): string { + const match = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(\.\d+)?(Z|[+-]\d{2}:\d{2})$/.exec(value); + if (match === null) return value; + const [, base, fraction, offset] = match; + let frac = ""; + if (fraction !== undefined) { + const digits = fraction.slice(1).replace(/0+$/, ""); + if (digits.length > 0) frac = `.${digits}`; + } + const zone = offset === "Z" || offset === "+00:00" || offset === "-00:00" ? "Z" : offset; + return `${base}${frac}${zone}`; +} + +// --------------------------------------------------------------------------- +// Go float formatting (strconv.FormatFloat(f, 'g', -1, bits)) +// --------------------------------------------------------------------------- + +/** Shortest round-trip digits for a float32 value (Go marshals via the typed field). */ +function shortestFloat32(value: number): string { + const rounded = Math.fround(value); + for (let precision = 1; precision <= 9; precision++) { + const candidate = rounded.toPrecision(precision); + if (Math.fround(Number(candidate)) === rounded) return String(Number(candidate)); + } + return String(rounded); +} + +/** + * `strconv.FormatFloat(f, 'g', -1, bits)`: shortest digits, switching to + * scientific notation when the decimal exponent is < -4 or >= 6 (Go uses + * `eprec = 6` for shortest formatting), with a sign and >= 2 exponent digits. + */ +export function legacyGoFormatFloat(value: number, bits: 32 | 64): string { + if (Number.isNaN(value)) return "NaN"; + if (value === Infinity) return "+Inf"; + if (value === -Infinity) return "-Inf"; + const repr = bits === 32 ? shortestFloat32(value) : String(value); + // JS String(-0) drops the sign; Go's FormatFloat keeps it ("-0"). + const negative = repr.startsWith("-") || Object.is(value, -0); + const unsigned = negative ? repr.slice(1) : repr; + // Decompose into digits + decimal exponent. + const expMatch = /^(\d+)(?:\.(\d+))?(?:e([+-]\d+))?$/.exec(unsigned); + if (expMatch === null) return repr; + const intPart = expMatch[1] as string; + const fracPart = expMatch[2] ?? ""; + const expPart = expMatch[3]; + let digits = intPart + fracPart; + // decimal-point position (value = 0.digits * 10^dp) + let dp = intPart.length + (expPart !== undefined ? Number(expPart) : 0); + if (/^0+$/.test(digits)) return negative ? "-0" : "0"; + // Strip leading zeros (adjusting the decimal position) and trailing zeros. + while (digits.startsWith("0")) { + digits = digits.slice(1); + dp -= 1; + } + digits = digits.replace(/0+$/, ""); + const exp = dp - 1; + const sign = negative ? "-" : ""; + if (exp < -4 || exp >= 6) { + const mantissa = digits.length > 1 ? `${digits[0]}.${digits.slice(1)}` : digits; + const expSign = exp < 0 ? "-" : "+"; + const expDigits = String(Math.abs(exp)).padStart(2, "0"); + return `${sign}${mantissa}e${expSign}${expDigits}`; + } + if (dp <= 0) { + return `${sign}0.${"0".repeat(-dp)}${digits}`; + } + if (dp >= digits.length) { + return `${sign}${digits}${"0".repeat(dp - digits.length)}`; + } + return `${sign}${digits.slice(0, dp)}.${digits.slice(dp)}`; +} + +// --------------------------------------------------------------------------- +// YAML encoder (gopkg.in/yaml.v3 v3.0.1 semantics) +// --------------------------------------------------------------------------- + +/** + * Encode a decoded payload as the Go CLI's `-o yaml` output for the given Go + * struct spec. Returns the full document bytes (trailing newline included). + */ +export function encodeLegacyGoYaml(value: unknown, type: LegacyGoType): string { + return yamlDocument(normalize(value, type)); +} + +function yamlDocument(root: GoValue): string { + switch (root.k) { + case "slice": + if (root.items.length === 0) return "[]\n"; + return yamlSequence(root.items, 0); + case "struct": + if (root.entries.length === 0) return "{}\n"; + return yamlMapping(yamlStructEntries(root.entries), 0); + case "map": { + if (root.entries.length === 0) return "{}\n"; + return yamlMapping(yamlMapEntries(root.entries), 0); + } + case "nullable": + if (root.present === undefined) return "{}\n"; + return yamlNullableBlock(root.present, root.value ?? { k: "nil" }, 0); + default: + return `${yamlScalar(root)}\n`; + } +} + +/** + * A populated `nullable.Nullable[T]` is a `map[bool]T`; yaml.v3 renders the + * bool key plain (`true:` / `false:`), unlike the string keys `"true"` would + * produce. + */ +function yamlNullableBlock(present: boolean, value: GoValue, indent: number): string { + const pad = " ".repeat(indent); + return `${pad}${present ? "true" : "false"}:${yamlValueSuffix(value, indent)}`; +} + +/** yaml.v3's indent algorithm: children of a mapping align to the next 4-column stop. */ +function yamlNextIndent(indent: number): number { + return 4 * Math.floor((indent + 4) / 4); +} + +function yamlStructEntries( + entries: ReadonlyArray, +): ReadonlyArray { + // yaml.v3 lowercases Go field names wholesale (no yaml tags on these structs). + return entries.map(([go, value]) => [go.toLowerCase(), value] as const); +} + +function yamlMapEntries( + entries: ReadonlyArray, +): ReadonlyArray { + return [...entries].sort(([a], [b]) => (yamlKeyLess(a, b) ? -1 : yamlKeyLess(b, a) ? 1 : 0)); +} + +/** Port of yaml.v3's `keyList.Less` natural string ordering (sorter.go). */ +function yamlKeyLess(a: string, b: string): boolean { + const ar = [...a]; + const br = [...b]; + let digits = false; + for (let i = 0; i < ar.length && i < br.length; i++) { + const ac = ar[i] as string; + const bc = br[i] as string; + if (ac === bc) { + digits = isDigit(ac); + continue; + } + const al = isLetter(ac); + const bl = isLetter(bc); + if (al && bl) return ac < bc; + if (al || bl) return digits ? al : bl; + let an = 0n; + let bn = 0n; + if (ac === "0" || bc === "0") { + for (let j = i - 1; j >= 0 && isDigit(ar[j] as string); j--) { + if (ar[j] !== "0") { + an = 1n; + bn = 1n; + break; + } + } + } + let ai = i; + let bi = i; + for (; ai < ar.length && isDigit(ar[ai] as string); ai++) { + an = an * 10n + BigInt((ar[ai] as string).charCodeAt(0) - 48); + } + for (; bi < br.length && isDigit(br[bi] as string); bi++) { + bn = bn * 10n + BigInt((br[bi] as string).charCodeAt(0) - 48); + } + if (an !== bn) return an < bn; + if (ai !== bi) return ai < bi; + return ac < bc; + } + return ar.length < br.length; +} + +function isDigit(c: string): boolean { + return c >= "0" && c <= "9"; +} + +function isLetter(c: string): boolean { + return /\p{L}/u.test(c); +} + +function yamlMapping(entries: ReadonlyArray, indent: number): string { + const pad = " ".repeat(indent); + let out = ""; + for (const [key, value] of entries) { + const keyScalar = yamlKeyScalar(key); + out += `${pad}${keyScalar}:${yamlValueSuffix(value, indent)}`; + } + return out; +} + +/** + * Everything after `key:` — either ` \n`, a block-literal header plus + * content lines, or `\n` plus an indented child block. + */ +function yamlValueSuffix(value: GoValue, indent: number): string { + switch (value.k) { + case "nil": + return " null\n"; + case "str": { + const style = yamlStringStyle(value.v); + if (style === "literal") return yamlBlockLiteral(value.v, indent); + return ` ${yamlStringScalar(value.v, style)}\n`; + } + case "bool": + case "int": + case "float": + case "time": + return ` ${yamlScalar(value)}\n`; + case "slice": + if (value.items.length === 0) return " []\n"; + return `\n${yamlSequence(value.items, yamlNextIndent(indent))}`; + case "struct": + if (value.entries.length === 0) return " {}\n"; + return `\n${yamlMapping(yamlStructEntries(value.entries), yamlNextIndent(indent))}`; + case "map": + if (value.entries.length === 0) return " {}\n"; + return `\n${yamlMapping(yamlMapEntries(value.entries), yamlNextIndent(indent))}`; + case "nullable": + // nil `map[bool]T` renders as an empty flow mapping; a populated one + // becomes a nested mapping with a bool key (`true:` / `false:`). + if (value.present === undefined) return " {}\n"; + return `\n${yamlNullableBlock( + value.present, + value.value ?? { k: "nil" }, + yamlNextIndent(indent), + )}`; + } +} + +function yamlSequence(items: ReadonlyArray, indent: number): string { + const pad = " ".repeat(indent); + let out = ""; + for (const item of items) { + switch (item.k) { + case "struct": + case "map": { + const entries = + item.k === "struct" ? yamlStructEntries(item.entries) : yamlMapEntries(item.entries); + if (entries.length === 0) { + out += `${pad}- {}\n`; + break; + } + // Compact form: the first key rides on the `- ` line; the block keeps + // a +2 indent (yaml.v3 special-cases indent inside sequence items). + const block = yamlMapping(entries, indent + 2); + out += `${pad}- ${block.slice(indent + 2)}`; + break; + } + case "slice": + if (item.items.length === 0) { + out += `${pad}- []\n`; + break; + } + out += `${pad}-\n${yamlSequence(item.items, indent + 2)}`; + break; + case "nullable": + if (item.present === undefined) { + out += `${pad}- {}\n`; + break; + } + out += `${pad}- ${yamlNullableBlock( + item.present, + item.value ?? { k: "nil" }, + indent + 2, + ).slice(indent + 2)}`; + break; + case "str": { + const style = yamlStringStyle(item.v); + if (style === "literal") { + out += `${pad}-${yamlBlockLiteral(item.v, indent + 2)}`; + break; + } + out += `${pad}- ${yamlStringScalar(item.v, style)}\n`; + break; + } + default: + out += `${pad}- ${yamlScalar(item)}\n`; + } + } + return out; +} + +function yamlScalar(value: GoValue): string { + switch (value.k) { + case "nil": + return "null"; + case "bool": + return value.v ? "true" : "false"; + case "int": + return String(value.v); + case "float": + return legacyGoFormatFloat(value.v, value.bits); + case "time": + return value.v; + case "str": { + const style = yamlStringStyle(value.v); + // Multi-line strings only reach here as mapping/sequence values, which + // are handled by yamlValueSuffix; fall back to double quoting. + return yamlStringScalar(value.v, style === "literal" ? "double" : style); + } + default: + return ""; + } +} + +function yamlKeyScalar(key: string): string { + const style = yamlStringStyle(key); + return yamlStringScalar(key, style === "literal" ? "double" : style); +} + +type YamlStringStyle = "plain" | "single" | "double" | "literal"; + +/** + * Mirror of yaml.v3's style selection: `encode.go` requests literal for + * multi-line strings and double quotes for strings that resolve to a + * non-string tag; the emitter (`emitterc.go`) downgrades plain to single (or + * double) based on its scalar analysis. + */ +function yamlStringStyle(s: string): YamlStringStyle { + if (s.length === 0) return "double"; + if (yamlHasSpecialChars(s)) return "double"; + if (s.includes("\n")) { + // Block scalars are rejected when a space precedes a line break or the + // string ends in a space (emitter `block_allowed` analysis). + if (/ \n/.test(s) || s.endsWith(" ")) return "double"; + return "literal"; + } + if (s.includes("\t")) return "double"; + if (!yamlResolvesToString(s)) return "double"; + if (yamlPlainDisallowed(s)) return "single"; + return "plain"; +} + +/** + * Characters yaml.v3 treats as "special" (not printable) or line breaks other + * than `\n` — all of these force double-quoted style with escapes. U+2028 and + * U+2029 are technically YAML line breaks, but every realistic payload + * containing them round-trips through the double-quoted `\L` / `\P` escapes. + */ +function yamlHasSpecialChars(s: string): boolean { + for (const ch of s) { + const code = ch.codePointAt(0) as number; + if (code === 0x09 || code === 0x0a) continue; + if (code < 0x20) return true; + if (code === 0x7f) return true; + if (code >= 0x80 && code < 0xa0) return true; // C1 controls incl. NEL + if (code === 0x2028 || code === 0x2029) return true; + if (code === 0xfffe || code === 0xffff) return true; + if (code >= 0xd800 && code <= 0xdfff) return true; // unpaired surrogate + } + return false; +} + +/** Emitter `block_plain_allowed` analysis for single-line printable strings. */ +function yamlPlainDisallowed(s: string): boolean { + if (s.startsWith(" ") || s.endsWith(" ")) return true; + if (s.startsWith("---") || s.startsWith("...")) return true; + const first = s[0] as string; + if ("#,[]{}&*!|>'\"%@`".includes(first)) return true; + if ((first === "?" || first === ":" || first === "-") && (s.length === 1 || s[1] === " ")) { + return true; + } + // ':' followed by whitespace/end and '#' preceded by whitespace break plain. + if (/: |:$/.test(s)) return true; + if (/ #/.test(s)) return true; + return false; +} + +/** + * Would yaml.v3's `resolve("", s)` produce a non-string tag? Also covers the + * YAML 1.1 "old bool" and base-60 spellings the encoder force-quotes. + */ +function yamlResolvesToString(s: string): boolean { + if (YAML_OLD_BOOLS.has(s)) return false; + if (YAML_RESOLVE_MAP.has(s)) return false; + if (YAML_BASE60.test(s)) return false; + const first = s[0] as string; + if (first === ".") { + // resolve()'s '.'-hint branch: strconv.ParseFloat. + return !/^\.\d+(?:[eE][+-]?\d+)?$/.test(s); + } + if (first === "+" || first === "-" || isDigit(first)) { + if (yamlIsTimestamp(s)) return false; + const plain = s.replaceAll("_", ""); + if (goParseIntBase0(plain)) return false; + if (YAML_STYLE_FLOAT.test(plain)) return false; + return true; + } + // 'M'-hint characters (yYnNtTfFoO~) resolve via the exact map only. + return true; +} + +const YAML_OLD_BOOLS = new Set([ + "y", + "Y", + "yes", + "Yes", + "YES", + "n", + "N", + "no", + "No", + "NO", + "on", + "On", + "ON", + "off", + "Off", + "OFF", +]); + +const YAML_RESOLVE_MAP = new Set([ + "true", + "True", + "TRUE", + "false", + "False", + "FALSE", + "~", + "null", + "Null", + "NULL", + ".nan", + ".NaN", + ".NAN", + ".inf", + ".Inf", + ".INF", + "+.inf", + "+.Inf", + "+.INF", + "-.inf", + "-.Inf", + "-.INF", +]); + +const YAML_BASE60 = /^[-+]?[0-9][0-9_]*(?::[0-5]?[0-9])+(?:\.[0-9_]*)?$/; +const YAML_STYLE_FLOAT = /^[-+]?(\.[0-9]+|[0-9]+(\.[0-9]*)?)([eE][-+]?[0-9]+)?$/; + +/** `strconv.ParseInt(s, 0, 64)` / `ParseUint` success (underscores pre-stripped). */ +function goParseIntBase0(plain: string): boolean { + let body = plain; + let negative = false; + if (body.startsWith("+") || body.startsWith("-")) { + negative = body.startsWith("-"); + body = body.slice(1); + } + // Length-gate before BigInt so a pathological megabyte-of-digits value from + // the API can't trigger quadratic bigint parsing: uint64 needs at most 20 + // decimal / 16 hex / 22 octal / 64 binary significant digits. + let parsed: bigint; + if (/^0[bB][01]+$/.test(body)) { + const digits = body.slice(2).replace(/^0+(?=.)/, ""); + if (digits.length > 64) return false; + parsed = BigInt(`0b${digits}`); + } else if (/^0[oO][0-7]+$/.test(body)) { + const digits = body.slice(2).replace(/^0+(?=.)/, ""); + if (digits.length > 22) return false; + parsed = BigInt(`0o${digits}`); + } else if (/^0[xX][0-9a-fA-F]+$/.test(body)) { + const digits = body.slice(2).replace(/^0+(?=.)/, ""); + if (digits.length > 16) return false; + parsed = BigInt(`0x${digits}`); + } else if (/^0[0-7]*$/.test(body)) { + const digits = body.replace(/^0+(?=.)/, ""); + if (digits.length > 22) return false; + parsed = digits === "0" ? 0n : BigInt(`0o${digits}`); + } else if (/^[1-9][0-9]*$/.test(body)) { + if (body.length > 20) return false; + parsed = BigInt(body); + } else { + return false; + } + // resolve() falls back from ParseInt to ParseUint, so the accepted range is + // [-2^63, 2^64) — anything beyond either bound is not an int. + if (negative) return parsed <= 9223372036854775808n; + return parsed < 18446744073709551616n; +} + +/** yaml.v3's `parseTimestamp` layouts (resolve.go `allowedTimestampFormats`). */ +function yamlIsTimestamp(s: string): boolean { + const match = + /^(\d{4})-(\d{1,2})-(\d{1,2})(?:([Tt ])(\d{1,2}):(\d{1,2}):(\d{1,2})(?:\.\d+)?(Z|[+-]\d{2}:\d{2})?)?$/.exec( + s, + ); + if (match === null) return false; + const [, , monthRaw, dayRaw, separator, hour, minute, second, offset] = match; + // The space-separated layout has no timezone; T/t layouts require one. + if (hour !== undefined) { + if (separator === " " && offset !== undefined) return false; + if (separator !== " " && offset === undefined) return false; + if (Number(hour) > 23 || Number(minute) > 59 || Number(second) > 59) return false; + } + const month = Number(monthRaw); + const day = Number(dayRaw); + if (month < 1 || month > 12 || day < 1 || day > 31) return false; + return true; +} + +function yamlStringScalar(s: string, style: Exclude): string { + switch (style) { + case "plain": + return s; + case "single": + return `'${s.replaceAll("'", "''")}'`; + case "double": + return yamlDoubleQuoted(s); + } +} + +function yamlDoubleQuoted(s: string): string { + let out = '"'; + for (const ch of s) { + const code = ch.codePointAt(0) as number; + switch (code) { + case 0x00: + out += "\\0"; + break; + case 0x07: + out += "\\a"; + break; + case 0x08: + out += "\\b"; + break; + case 0x09: + out += "\\t"; + break; + case 0x0a: + out += "\\n"; + break; + case 0x0b: + out += "\\v"; + break; + case 0x0c: + out += "\\f"; + break; + case 0x0d: + out += "\\r"; + break; + case 0x1b: + out += "\\e"; + break; + case 0x22: + out += '\\"'; + break; + case 0x5c: + out += "\\\\"; + break; + case 0x85: + out += "\\N"; + break; + case 0x2028: + out += "\\L"; + break; + case 0x2029: + out += "\\P"; + break; + default: + if (code < 0x20 || code === 0x7f || (code >= 0x80 && code < 0xa0)) { + out += `\\x${code.toString(16).toUpperCase().padStart(2, "0")}`; + } else if (code >= 0xd800 && code <= 0xdfff) { + out += `\\u${code.toString(16).toUpperCase().padStart(4, "0")}`; + } else { + out += ch; + } + } + } + return out + '"'; +} + +/** + * `key: |-` block literal: chomping indicator from the trailing newlines, an + * explicit `4` indentation indicator when the first line starts with a space + * or is empty, and content indented to the next 4-column stop. + * + * Unlike Go's streaming bufio-backed encoder (which can flush partial output + * before a later error), this builds the whole document in memory — callers + * emit all-or-nothing, which only differs observably from Go on multi-KB + * payloads that fail mid-encode. + */ +function yamlBlockLiteral(s: string, indent: number): string { + const contentIndent = yamlNextIndent(indent); + const pad = " ".repeat(contentIndent); + const trailingNewlines = s.length - s.replace(/\n+$/, "").length; + const chomp = trailingNewlines === 0 ? "-" : trailingNewlines === 1 ? "" : "+"; + const indicator = s.startsWith(" ") || s.startsWith("\n") ? "4" : ""; + const lines = s.split("\n"); + if (s.endsWith("\n")) lines.pop(); + // yaml.v3 merges a leading empty line's break with the header newline + // (verified empirically: "\nx" → `|4-\n x\n`, "\n\nx" → `|4-\n\n x\n`). + if (lines[0] === "") lines.shift(); + const body = lines.map((line) => (line.length === 0 ? "" : `${pad}${line}`)).join("\n"); + return ` |${indicator}${chomp}\n${body}\n`; +} + +// --------------------------------------------------------------------------- +// TOML encoder (github.com/BurntSushi/toml v1.6.0 semantics) +// --------------------------------------------------------------------------- + +/** + * Thrown when the payload contains a populated `nullable.Nullable` field — + * BurntSushi refuses `map[bool]T` and the Go CLI fails with this message + * (observed on `snippets list -o toml`). + */ +export class LegacyGoTomlEncodeError extends Error { + constructor() { + super("toml: cannot encode a map with non-string key type"); + this.name = "LegacyGoTomlEncodeError"; + } +} + +/** + * Encode a decoded payload as the Go CLI's `-o toml` output for the given Go + * struct spec. Returns the full document bytes (BurntSushi emits nothing for + * an all-nil payload, so the result can be the empty string). + * + * Throws {@link LegacyGoTomlEncodeError} when a populated nullable field is + * present, matching Go's runtime failure. + */ +export function encodeLegacyGoToml(value: unknown, type: LegacyGoType): string { + const state = { out: "", hasWritten: false }; + tomlEncode(state, [], normalize(value, type)); + return state.out; +} + +interface TomlState { + out: string; + hasWritten: boolean; +} + +function tomlWrite(state: TomlState, text: string): void { + state.out += text; + state.hasWritten = true; +} + +/** `enc.newline()` — a separator newline suppressed until something is written. */ +function tomlNewline(state: TomlState): void { + if (state.hasWritten) state.out += "\n"; +} + +function tomlIndent(key: ReadonlyArray): string { + return " ".repeat(Math.max(key.length - 1, 0)); +} + +function tomlIsTable(value: GoValue): boolean { + switch (value.k) { + case "struct": + return true; + case "map": + return true; + case "nullable": + return true; + case "slice": + // Array-of-tables only when non-empty with table elements (BurntSushi's + // isTableArray returns false for empty slices). + return value.tables && value.items.length > 0; + default: + return false; + } +} + +function tomlIsNil(value: GoValue): boolean { + switch (value.k) { + case "nil": + return true; + case "slice": + return value.nil; + case "map": + return value.nil; + case "nullable": + return value.present === undefined; + default: + return false; + } +} + +function tomlEncode(state: TomlState, key: ReadonlyArray, value: GoValue): void { + if (tomlIsNil(value)) return; + switch (value.k) { + case "struct": + case "map": + tomlTable(state, key, value); + return; + case "nullable": + // Populated nullable.Nullable[T] is a map[bool]T — BurntSushi panics. + throw new LegacyGoTomlEncodeError(); + case "slice": + if (tomlIsTable(value)) { + tomlArrayOfTables(state, key, value.items); + return; + } + tomlKeyValue(state, key, value); + return; + default: + tomlKeyValue(state, key, value); + } +} + +function tomlTable( + state: TomlState, + key: ReadonlyArray, + value: Extract, +): void { + if (key.length === 1) { + // Extra newline between top-level tables. + tomlNewline(state); + } + if (key.length > 0) { + tomlWrite(state, `${tomlIndent(key)}[${key.map(tomlKeyName).join(".")}]\n`); + } + const entries = + value.k === "map" + ? [...value.entries].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + : value.entries; + const direct = entries.filter(([, v]) => !tomlIsTable(v)); + const sub = entries.filter(([, v]) => tomlIsTable(v)); + for (const [name, v] of direct) { + if (tomlIsNil(v)) continue; + tomlEncode(state, [...key, name], v); + } + for (const [name, v] of sub) { + if (tomlIsNil(v)) continue; + tomlEncode(state, [...key, name], v); + } +} + +function tomlArrayOfTables( + state: TomlState, + key: ReadonlyArray, + items: ReadonlyArray, +): void { + for (const item of items) { + if (tomlIsNil(item)) continue; + tomlNewline(state); + tomlWrite(state, `${tomlIndent(key)}[[${key.map(tomlKeyName).join(".")}]]\n`); + if (item.k === "struct" || item.k === "map") { + const entries = + item.k === "map" + ? [...item.entries].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + : item.entries; + const direct = entries.filter(([, v]) => !tomlIsTable(v)); + const sub = entries.filter(([, v]) => tomlIsTable(v)); + for (const [name, v] of [...direct, ...sub]) { + if (tomlIsNil(v)) continue; + tomlEncode(state, [...key, name], v); + } + } else if (item.k === "nullable") { + throw new LegacyGoTomlEncodeError(); + } + } +} + +function tomlKeyValue(state: TomlState, key: ReadonlyArray, value: GoValue): void { + const name = key[key.length - 1] as string; + tomlWrite(state, `${tomlIndent(key)}${tomlKeyName(name)} = ${tomlElement(value)}\n`); +} + +const TOML_BARE_KEY = /^[A-Za-z0-9_-]+$/; + +function tomlKeyName(name: string): string { + return TOML_BARE_KEY.test(name) ? name : tomlQuoted(name); +} + +function tomlElement(value: GoValue): string { + switch (value.k) { + case "str": + return tomlQuoted(value.v); + case "bool": + return value.v ? "true" : "false"; + case "int": + return String(value.v); + case "float": { + const repr = legacyGoFormatFloat(value.v, value.bits); + // TOML floats must carry a decimal point unless in exponent form. + return repr.includes(".") || repr.includes("e") ? repr : `${repr}.0`; + } + case "time": + return value.v; + case "slice": + return `[${value.items.map(tomlElement).join(", ")}]`; + case "nullable": + throw new LegacyGoTomlEncodeError(); + default: + return ""; + } +} + +/** BurntSushi's `dblQuotedReplacer` escape set. */ +function tomlQuoted(s: string): string { + let out = '"'; + for (const ch of s) { + const code = ch.codePointAt(0) as number; + switch (code) { + case 0x22: + out += '\\"'; + break; + case 0x5c: + out += "\\\\"; + break; + case 0x08: + out += "\\b"; + break; + case 0x09: + out += "\\t"; + break; + case 0x0a: + out += "\\n"; + break; + case 0x0c: + out += "\\f"; + break; + case 0x0d: + out += "\\r"; + break; + default: + if (code < 0x20 || code === 0x7f) { + out += `\\u${code.toString(16).padStart(4, "0")}`; + } else { + out += ch; + } + } + } + return out + '"'; +} diff --git a/apps/cli/src/legacy/shared/legacy-go-struct-output.encoders.unit.test.ts b/apps/cli/src/legacy/shared/legacy-go-struct-output.encoders.unit.test.ts new file mode 100644 index 0000000000..42f3742b30 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-go-struct-output.encoders.unit.test.ts @@ -0,0 +1,722 @@ +import { describe, expect, it } from "vitest"; + +import { + LegacyGoTomlEncodeError, + encodeLegacyGoToml, + encodeLegacyGoYaml, + legacyGoAny, + legacyGoBool, + legacyGoFieldName, + legacyGoFloat32, + legacyGoFloat64, + legacyGoFormatFloat, + legacyGoInt, + legacyGoMap, + legacyGoNullable, + legacyGoPtr, + legacyGoSlice, + legacyGoString, + legacyGoStruct, + legacyGoTime, + legacyGoTomlListWrapper, + legacyGoUuid, +} from "./legacy-go-struct-output.encoders.ts"; + +/** + * Every golden byte string in this file was captured from a scratch Go + * program calling the Go CLI's own `utils.EncodeOutput` + * (`apps/cli-go/internal/utils/output.go`) with BurntSushi toml v1.6.0 and + * yaml.v3 v3.0.1 — the exact library versions pinned in `apps/cli-go/go.mod`. + */ + +// Mirror of `api.BranchResponse` (apps/cli-go/pkg/api/types.gen.go). +const BRANCH_RESPONSE = legacyGoStruct([ + ["created_at", legacyGoTime], + ["deletion_scheduled_at", legacyGoPtr(legacyGoTime)], + ["git_branch", legacyGoPtr(legacyGoString)], + ["id", legacyGoUuid], + ["is_default", legacyGoBool], + ["latest_check_run_id", legacyGoPtr(legacyGoFloat32)], + ["name", legacyGoString], + ["notify_url", legacyGoPtr(legacyGoString)], + ["parent_project_ref", legacyGoString], + ["persistent", legacyGoBool], + ["pr_number", legacyGoPtr(legacyGoInt)], + ["preview_project_status", legacyGoPtr(legacyGoString)], + ["project_ref", legacyGoString], + ["review_requested_at", legacyGoPtr(legacyGoTime)], + ["status", legacyGoString], + ["updated_at", legacyGoTime], + ["with_data", legacyGoBool], +]); + +const SAMPLE_BRANCH = { + id: "11111111-2222-3333-4444-555555555555", + name: "feat-1", + project_ref: "aaaaaaaaaaaaaaaaaaaa", + parent_project_ref: "bbbbbbbbbbbbbbbbbbbb", + is_default: false, + git_branch: "feat-1", + persistent: false, + status: "MIGRATIONS_PASSED", + created_at: "2026-05-27T01:02:03Z", + updated_at: "2026-05-27T01:02:04Z", + with_data: true, +}; + +// All pointer fields absent — Go zero-fills the value fields. +const ZERO_BRANCH = { + name: "Production", + is_default: true, + parent_project_ref: "production-project-ref", + project_ref: "production-project-ref", + status: "FUNCTIONS_DEPLOYED", +}; + +describe("encodeLegacyGoToml", () => { + it("matches Go byte-for-byte for a branches list wrapper (PascalCase, nil pointers omitted, native datetimes)", () => { + const wrapper = legacyGoTomlListWrapper("branches", BRANCH_RESPONSE); + expect(encodeLegacyGoToml({ branches: [SAMPLE_BRANCH, ZERO_BRANCH] }, wrapper)).toBe( + `[[branches]] + CreatedAt = 2026-05-27T01:02:03Z + GitBranch = "feat-1" + Id = "11111111-2222-3333-4444-555555555555" + IsDefault = false + Name = "feat-1" + ParentProjectRef = "bbbbbbbbbbbbbbbbbbbb" + Persistent = false + ProjectRef = "aaaaaaaaaaaaaaaaaaaa" + Status = "MIGRATIONS_PASSED" + UpdatedAt = 2026-05-27T01:02:04Z + WithData = true + +[[branches]] + CreatedAt = 0001-01-01T00:00:00Z + Id = "00000000-0000-0000-0000-000000000000" + IsDefault = true + Name = "Production" + ParentProjectRef = "production-project-ref" + Persistent = false + ProjectRef = "production-project-ref" + Status = "FUNCTIONS_DEPLOYED" + UpdatedAt = 0001-01-01T00:00:00Z + WithData = false +`, + ); + }); + + it("emits a top-level struct without a table header (branches create)", () => { + expect(encodeLegacyGoToml(SAMPLE_BRANCH, BRANCH_RESPONSE)).toBe( + `CreatedAt = 2026-05-27T01:02:03Z +GitBranch = "feat-1" +Id = "11111111-2222-3333-4444-555555555555" +IsDefault = false +Name = "feat-1" +ParentProjectRef = "bbbbbbbbbbbbbbbbbbbb" +Persistent = false +ProjectRef = "aaaaaaaaaaaaaaaaaaaa" +Status = "MIGRATIONS_PASSED" +UpdatedAt = 2026-05-27T01:02:04Z +WithData = true +`, + ); + }); + + it("emits nothing for a nil list and `key = []` for a decoded empty list", () => { + const wrapper = legacyGoTomlListWrapper("branches", BRANCH_RESPONSE); + // Go: `var result []api.BranchResponse` stays nil when empty → no output. + expect(encodeLegacyGoToml({ branches: undefined }, wrapper)).toBe(""); + // Go: a decoded `[]` is a non-nil empty slice → `branches = []`. + expect(encodeLegacyGoToml({ branches: [] }, wrapper)).toBe("branches = []\n"); + }); + + it("nests sub-tables after primitives with 2-space indentation (hostnames shape)", () => { + // Mirror of api.UpdateCustomHostnameResponse. + const spec = legacyGoStruct([ + ["custom_hostname", legacyGoString], + [ + "data", + legacyGoStruct([ + ["errors", legacyGoSlice(legacyGoAny)], + ["messages", legacyGoSlice(legacyGoAny)], + [ + "result", + legacyGoStruct([ + ["custom_origin_server", legacyGoString], + ["hostname", legacyGoString], + ["id", legacyGoString], + [ + "ownership_verification", + legacyGoStruct([ + ["name", legacyGoString], + ["type", legacyGoString], + ["value", legacyGoString], + ]), + ], + [ + "ssl", + legacyGoStruct([ + ["status", legacyGoString], + ["validation_errors", legacyGoPtr(legacyGoSlice(legacyGoAny))], + [ + "validation_records", + legacyGoSlice( + legacyGoStruct([ + ["txt_name", legacyGoString], + ["txt_value", legacyGoString], + ]), + ), + ], + ]), + ], + ["status", legacyGoString], + ["verification_errors", legacyGoPtr(legacyGoSlice(legacyGoString))], + ]), + ], + ["success", legacyGoBool], + ]), + ], + ["status", legacyGoString], + ]); + const payload = { + custom_hostname: "custom.example.com", + status: "2_initiated", + data: { + success: true, + result: { + hostname: "custom.example.com", + id: "hostname-id-1", + status: "pending", + ssl: { + status: "pending_validation", + validation_records: [{ txt_name: "_acme.example.com", txt_value: "token-1" }], + }, + ownership_verification: { + name: "_cf-custom-hostname.example.com", + type: "txt", + value: "value-1", + }, + }, + }, + }; + expect(encodeLegacyGoToml(payload, spec)).toBe( + `CustomHostname = "custom.example.com" +Status = "2_initiated" + +[Data] + Success = true + [Data.Result] + CustomOriginServer = "" + Hostname = "custom.example.com" + Id = "hostname-id-1" + Status = "pending" + [Data.Result.OwnershipVerification] + Name = "_cf-custom-hostname.example.com" + Type = "txt" + Value = "value-1" + [Data.Result.Ssl] + Status = "pending_validation" + + [[Data.Result.Ssl.ValidationRecords]] + TxtName = "_acme.example.com" + TxtValue = "token-1" +`, + ); + }); + + it("escapes strings like BurntSushi and quotes string-typed timestamps (sso provider)", () => { + const spec = legacyGoStruct([ + ["created_at", legacyGoPtr(legacyGoString)], + [ + "domains", + legacyGoPtr( + legacyGoSlice( + legacyGoStruct([ + ["created_at", legacyGoPtr(legacyGoString)], + ["domain", legacyGoPtr(legacyGoString)], + ["updated_at", legacyGoPtr(legacyGoString)], + ]), + ), + ), + ], + ["id", legacyGoString], + [ + "saml", + legacyGoPtr( + legacyGoStruct([ + [ + "attribute_mapping", + legacyGoPtr(legacyGoStruct([["keys", legacyGoMap(legacyGoAny)]])), + ], + ["entity_id", legacyGoString], + ["metadata_url", legacyGoPtr(legacyGoString)], + ["metadata_xml", legacyGoPtr(legacyGoString)], + ["name_id_format", legacyGoPtr(legacyGoString)], + ]), + ), + ], + ["updated_at", legacyGoPtr(legacyGoString)], + ]); + const payload = { + id: "8b64a95d-6e29-4c58-8f04-1d0ac6bcda31", + created_at: "2026-05-27T01:02:03.123456Z", + updated_at: "2026-05-27T01:02:03.123456Z", + domains: [{ domain: "example.com", created_at: "2026-05-27T01:02:03Z" }], + saml: { + entity_id: "https://example.com/saml/metadata", + metadata_xml: + '\n&', + }, + }; + expect(encodeLegacyGoToml(payload, spec)).toBe( + `CreatedAt = "2026-05-27T01:02:03.123456Z" +Id = "8b64a95d-6e29-4c58-8f04-1d0ac6bcda31" +UpdatedAt = "2026-05-27T01:02:03.123456Z" + +[[Domains]] + CreatedAt = "2026-05-27T01:02:03Z" + Domain = "example.com" + +[Saml] + EntityId = "https://example.com/saml/metadata" + MetadataXml = "\\n&" +`, + ); + }); + + it("keeps hand-written Go struct declaration order (services imageVersion)", () => { + const spec = legacyGoTomlListWrapper( + "services", + legacyGoStruct([ + ["name", legacyGoString], + ["local", legacyGoString], + ["remote", legacyGoString], + ]), + ); + expect( + encodeLegacyGoToml( + { services: [{ name: "supabase/postgres", local: "17.4.1.037", remote: "" }] }, + spec, + ), + ).toBe( + `[[services]] + Name = "supabase/postgres" + Local = "17.4.1.037" + Remote = "" +`, + ); + }); + + it("renders inline primitive arrays (network bans wrapper)", () => { + const spec = legacyGoStruct([["banned_ips", legacyGoSlice(legacyGoString), "banned_ips"]]); + expect(encodeLegacyGoToml({ banned_ips: ["1.2.3.4", "5.6.7.8"] }, spec)).toBe( + 'banned_ips = ["1.2.3.4", "5.6.7.8"]\n', + ); + }); + + it("skips nil nullable fields and fails like Go on populated ones", () => { + const spec = legacyGoStruct([ + ["desc", legacyGoNullable(legacyGoString)], + ["name", legacyGoString], + ]); + expect(encodeLegacyGoToml({ name: "x" }, spec)).toBe('Name = "x"\n'); + expect(() => encodeLegacyGoToml({ name: "x", desc: null }, spec)).toThrow( + new LegacyGoTomlEncodeError().message, + ); + expect(() => encodeLegacyGoToml({ name: "x", desc: "d" }, spec)).toThrow( + "toml: cannot encode a map with non-string key type", + ); + }); + + it("renders floats with a decimal point and Go's exponent form", () => { + const spec = legacyGoStruct([ + ["f1", legacyGoFloat32], + ["f2", legacyGoFloat64], + ["f6", legacyGoFloat64], + ]); + expect(encodeLegacyGoToml({ f1: 1, f2: 1000000, f6: 1234567 }, spec)).toBe( + `F1 = 1.0 +F2 = 1e+06 +F6 = 1.234567e+06 +`, + ); + }); + + it("sorts map keys and quotes non-bare keys (branches get envs)", () => { + const spec = legacyGoMap(legacyGoString); + expect( + encodeLegacyGoToml( + { SUPABASE_ANON_KEY: "anon", POSTGRES_URL: "postgres://u:p@h:6543/postgres" }, + spec, + ), + ).toBe( + `POSTGRES_URL = "postgres://u:p@h:6543/postgres" +SUPABASE_ANON_KEY = "anon" +`, + ); + }); +}); + +describe("encodeLegacyGoYaml", () => { + it("matches Go byte-for-byte for a branches list (lowercased keys, explicit nulls)", () => { + expect(encodeLegacyGoYaml([SAMPLE_BRANCH, ZERO_BRANCH], legacyGoSlice(BRANCH_RESPONSE))).toBe( + `- createdat: 2026-05-27T01:02:03Z + deletionscheduledat: null + gitbranch: feat-1 + id: 11111111-2222-3333-4444-555555555555 + isdefault: false + latestcheckrunid: null + name: feat-1 + notifyurl: null + parentprojectref: bbbbbbbbbbbbbbbbbbbb + persistent: false + prnumber: null + previewprojectstatus: null + projectref: aaaaaaaaaaaaaaaaaaaa + reviewrequestedat: null + status: MIGRATIONS_PASSED + updatedat: 2026-05-27T01:02:04Z + withdata: true +- createdat: 0001-01-01T00:00:00Z + deletionscheduledat: null + gitbranch: null + id: 00000000-0000-0000-0000-000000000000 + isdefault: true + latestcheckrunid: null + name: Production + notifyurl: null + parentprojectref: production-project-ref + persistent: false + prnumber: null + previewprojectstatus: null + projectref: production-project-ref + reviewrequestedat: null + status: FUNCTIONS_DEPLOYED + updatedat: 0001-01-01T00:00:00Z + withdata: false +`, + ); + }); + + it("renders an empty list as [] regardless of nil-ness", () => { + expect(encodeLegacyGoYaml([], legacyGoSlice(BRANCH_RESPONSE))).toBe("[]\n"); + expect(encodeLegacyGoYaml(undefined, legacyGoSlice(BRANCH_RESPONSE))).toBe("[]\n"); + }); + + it("uses 4-column indentation, block literals, and quoted string timestamps (sso show)", () => { + const spec = legacyGoStruct([ + ["created_at", legacyGoPtr(legacyGoString)], + [ + "domains", + legacyGoPtr( + legacyGoSlice( + legacyGoStruct([ + ["created_at", legacyGoPtr(legacyGoString)], + ["domain", legacyGoPtr(legacyGoString)], + ["updated_at", legacyGoPtr(legacyGoString)], + ]), + ), + ), + ], + ["id", legacyGoString], + [ + "saml", + legacyGoPtr( + legacyGoStruct([ + [ + "attribute_mapping", + legacyGoPtr(legacyGoStruct([["keys", legacyGoMap(legacyGoAny)]])), + ], + ["entity_id", legacyGoString], + ["metadata_url", legacyGoPtr(legacyGoString)], + ["metadata_xml", legacyGoPtr(legacyGoString)], + ["name_id_format", legacyGoPtr(legacyGoString)], + ]), + ), + ], + ["updated_at", legacyGoPtr(legacyGoString)], + ]); + const payload = { + id: "8b64a95d-6e29-4c58-8f04-1d0ac6bcda31", + created_at: "2026-05-27T01:02:03.123456Z", + updated_at: "2026-05-27T01:02:03.123456Z", + domains: [{ domain: "example.com", created_at: "2026-05-27T01:02:03Z" }], + saml: { + entity_id: "https://example.com/saml/metadata", + metadata_xml: + '\n&', + }, + }; + expect(encodeLegacyGoYaml(payload, spec)).toBe( + `createdat: "2026-05-27T01:02:03.123456Z" +domains: + - createdat: "2026-05-27T01:02:03Z" + domain: example.com + updatedat: null +id: 8b64a95d-6e29-4c58-8f04-1d0ac6bcda31 +saml: + attributemapping: null + entityid: https://example.com/saml/metadata + metadataurl: null + metadataxml: |- + + & + nameidformat: null +updatedat: "2026-05-27T01:02:03.123456Z" +`, + ); + }); + + it("renders nullable fields the way yaml.v3 renders map[bool]T (api keys)", () => { + // Mirror of api.ApiKeyResponse. + const spec = legacyGoSlice( + legacyGoStruct([ + ["api_key", legacyGoNullable(legacyGoString)], + ["description", legacyGoNullable(legacyGoString)], + ["hash", legacyGoNullable(legacyGoString)], + ["id", legacyGoNullable(legacyGoString)], + ["inserted_at", legacyGoNullable(legacyGoTime)], + ["name", legacyGoString], + ["prefix", legacyGoNullable(legacyGoString)], + ["secret_jwt_template", legacyGoNullable(legacyGoMap(legacyGoAny))], + ["type", legacyGoNullable(legacyGoString)], + ["updated_at", legacyGoNullable(legacyGoTime)], + ]), + ); + const payload = [ + { name: "anon", api_key: "anon-key-value", id: "key-id-1", type: "legacy" }, + { name: "service_role" }, + ]; + expect(encodeLegacyGoYaml(payload, spec)).toBe( + `- apikey: + true: anon-key-value + description: {} + hash: {} + id: + true: key-id-1 + insertedat: {} + name: anon + prefix: {} + secretjwttemplate: {} + type: + true: legacy + updatedat: {} +- apikey: {} + description: {} + hash: {} + id: {} + insertedat: {} + name: service_role + prefix: {} + secretjwttemplate: {} + type: {} + updatedat: {} +`, + ); + }); + + it("renders an explicit JSON null nullable as a false-keyed zero (snippets description)", () => { + const spec = legacyGoStruct([ + ["desc", legacyGoNullable(legacyGoString)], + ["name", legacyGoString], + ]); + expect(encodeLegacyGoYaml({ desc: null, name: "x" }, spec)).toBe( + `desc: + false: "" +name: x +`, + ); + }); + + it("renders nil and empty slices as [] and nested maps at +4 (backups list)", () => { + const spec = legacyGoStruct([ + [ + "backups", + legacyGoSlice( + legacyGoStruct([ + ["id", legacyGoInt], + ["inserted_at", legacyGoString], + ["is_physical_backup", legacyGoBool], + ["status", legacyGoString], + ]), + ), + ], + [ + "physical_backup_data", + legacyGoStruct([ + ["earliest_physical_backup_date_unix", legacyGoPtr(legacyGoInt)], + ["latest_physical_backup_date_unix", legacyGoPtr(legacyGoInt)], + ]), + ], + ["pitr_enabled", legacyGoBool], + ["region", legacyGoString], + ["walg_enabled", legacyGoBool], + ]); + const payload = { + backups: [], + physical_backup_data: { earliest_physical_backup_date_unix: 1687279254 }, + pitr_enabled: true, + region: "us-east-1", + walg_enabled: true, + }; + expect(encodeLegacyGoYaml(payload, spec)).toBe( + `backups: [] +physicalbackupdata: + earliestphysicalbackupdateunix: 1687279254 + latestphysicalbackupdateunix: null +pitrenabled: true +region: us-east-1 +walgenabled: true +`, + ); + }); + + it("quotes strings exactly like yaml.v3's resolver and emitter", () => { + const spec = legacyGoMap(legacyGoString); + const payload = { + k01: "yes", + k04: "~", + k07: " leading-space", + k09: "has # hash", + k10: "#leads", + k16: "a:b", + k17: "- dash", + k18: "-dash", + k20: "12:34", + k21: "0123", + k22: "+123", + k25: "0o777", + k31: "tab\there", + k33: 'double "quotes" inside', + k36: "&", + k37: "2002-12-14", + k38: "null", + k40: "=", + k41: "<<", + k43: "1_000", + k44: "0x_1F", + k45: "with: colon", + k46: "", + k47: "2026-05-27T01:02:03Z", + k48: "true", + k49: "1e5", + k50: "17", + k51: "17.4.1.037", + k52: "2001-12-14 21:59:43.10 -5", + k53: "2001-12-15 2:59:43.10", + }; + expect(encodeLegacyGoYaml(payload, spec)).toBe( + `k01: "yes" +k04: "~" +k07: ' leading-space' +k09: 'has # hash' +k10: '#leads' +k16: a:b +k17: '- dash' +k18: -dash +k20: "12:34" +k21: "0123" +k22: "+123" +k25: "0o777" +k31: "tab\\there" +k33: double "quotes" inside +k36: & +k37: "2002-12-14" +k38: "null" +k40: = +k41: << +k43: "1_000" +k44: "0x_1F" +k45: 'with: colon' +k46: "" +k47: "2026-05-27T01:02:03Z" +k48: "true" +k49: "1e5" +k50: "17" +k51: 17.4.1.037 +k52: 2001-12-14 21:59:43.10 -5 +k53: "2001-12-15 2:59:43.10" +`, + ); + }); + + it("renders block literal chomping indicators like yaml.v3", () => { + const spec = legacyGoMap(legacyGoString); + expect( + encodeLegacyGoYaml( + { k27: "line1\nline2\n", k28: "line1\nline2\n\n", k29: "with\rcarriage" }, + spec, + ), + ).toBe( + `k27: | + line1 + line2 +k28: |+ + line1 + line2 + +k29: "with\\rcarriage" +`, + ); + }); + + it("renders floats with Go's g-format exponent switch", () => { + const spec = legacyGoMap(legacyGoAny); + expect( + encodeLegacyGoYaml({ f2: 1000000, f3: 78125, f4: 0.5, f5: 0.000001, f6: 1234567 }, spec), + ).toBe( + `f2: 1e+06 +f3: 78125 +f4: 0.5 +f5: 1e-06 +f6: 1.234567e+06 +`, + ); + }); + + it("sorts plain map keys with yaml.v3's natural ordering", () => { + const spec = legacyGoMap(legacyGoString); + expect(encodeLegacyGoYaml({ z: "1", a: "2", "10": "3", "2": "4", B: "5", b: "6" }, spec)).toBe( + `"2": "4" +"10": "3" +B: "5" +a: "2" +b: "6" +z: "1" +`, + ); + }); +}); + +describe("legacyGoFieldName", () => { + it("capitalizes snake_case tokens like oapi-codegen", () => { + expect(legacyGoFieldName("api_key")).toBe("ApiKey"); + expect(legacyGoFieldName("metadata_xml")).toBe("MetadataXml"); + expect(legacyGoFieldName("parent_project_ref")).toBe("ParentProjectRef"); + expect(legacyGoFieldName("ezbr_sha256")).toBe("EzbrSha256"); + }); + + it("capitalizes the first letter of camelCase tags", () => { + expect(legacyGoFieldName("appliedSuccessfully")).toBe("AppliedSuccessfully"); + expect(legacyGoFieldName("currentConfig")).toBe("CurrentConfig"); + }); +}); + +describe("legacyGoFormatFloat", () => { + it("matches strconv.FormatFloat(f, 'g', -1, 64)", () => { + expect(legacyGoFormatFloat(1, 64)).toBe("1"); + expect(legacyGoFormatFloat(123456, 64)).toBe("123456"); + expect(legacyGoFormatFloat(1000000, 64)).toBe("1e+06"); + expect(legacyGoFormatFloat(1234567, 64)).toBe("1.234567e+06"); + expect(legacyGoFormatFloat(0.5, 64)).toBe("0.5"); + expect(legacyGoFormatFloat(0.000001, 64)).toBe("1e-06"); + expect(legacyGoFormatFloat(0, 64)).toBe("0"); + expect(legacyGoFormatFloat(-2.5, 64)).toBe("-2.5"); + }); + + it("rounds through float32 like Go's typed fields", () => { + expect(legacyGoFormatFloat(16777217, 32)).toBe("1.6777216e+07"); + expect(legacyGoFormatFloat(78125, 32)).toBe("78125"); + expect(legacyGoFormatFloat(0.5, 32)).toBe("0.5"); + }); +}); diff --git a/apps/cli/src/legacy/shared/legacy-project-create-core.ts b/apps/cli/src/legacy/shared/legacy-project-create-core.ts index e35b02a09d..3dcac77e12 100644 --- a/apps/cli/src/legacy/shared/legacy-project-create-core.ts +++ b/apps/cli/src/legacy/shared/legacy-project-create-core.ts @@ -5,7 +5,13 @@ import { LegacyPlatformApi } from "../auth/legacy-platform-api.service.ts"; import { LegacyCliConfig } from "../config/legacy-cli-config.service.ts"; import { LegacyOutputFlag } from "../../shared/legacy/global-flags.ts"; import { Output } from "../../shared/output/output.service.ts"; -import { encodeEnv, encodeGoJson, encodeToml, encodeYaml } from "./legacy-go-output.encoders.ts"; +import { encodeEnv, encodeGoJson } from "./legacy-go-output.encoders.ts"; +import { + encodeLegacyGoToml, + encodeLegacyGoYaml, + legacyGoString, + legacyGoStruct, +} from "./legacy-go-struct-output.encoders.ts"; import { sanitizeLegacyErrorBody } from "./legacy-http-errors.ts"; import { LegacyProjectsCreateNetworkError, @@ -25,6 +31,22 @@ import { type CreateInput = typeof V1CreateAProjectInput.Type; +/** + * Mirror of Go's `api.V1ProjectResponse` (`apps/cli-go/pkg/api/types.gen.go`) + * — `projects create -o yaml|toml` encodes the raw struct, so keys derive + * from the Go field names (CLI-1975). + */ +const LEGACY_GO_PROJECT_RESPONSE = legacyGoStruct([ + ["created_at", legacyGoString], + ["id", legacyGoString], + ["name", legacyGoString], + ["organization_id", legacyGoString], + ["organization_slug", legacyGoString], + ["ref", legacyGoString], + ["region", legacyGoString], + ["status", legacyGoString], +]); + export interface LegacyProjectCreateInput { readonly name: string; readonly orgId: string; @@ -145,11 +167,11 @@ export const legacyProjectCreateCore = Effect.fnUntraced(function* ( return { ref: id, dbPassword }; } if (goFmt === "yaml") { - yield* output.raw(encodeYaml(created)); + yield* output.raw(encodeLegacyGoYaml(created, LEGACY_GO_PROJECT_RESPONSE)); return { ref: id, dbPassword }; } if (goFmt === "toml") { - yield* output.raw(encodeToml(created) + "\n"); + yield* output.raw(encodeLegacyGoToml(created, LEGACY_GO_PROJECT_RESPONSE)); return { ref: id, dbPassword }; } if (goFmt === "env") { diff --git a/apps/cli/src/shared/services/services.shared.ts b/apps/cli/src/shared/services/services.shared.ts index 4eee5e5ef2..94e9c59d84 100644 --- a/apps/cli/src/shared/services/services.shared.ts +++ b/apps/cli/src/shared/services/services.shared.ts @@ -418,10 +418,6 @@ export function formatServicesWarning(message: string, textMode: boolean): strin return `${prefix} ${first}\n${rest.join("\n")}\n`; } -export function encodeLegacyTomlRows(rows: ReadonlyArray) { - return { services: rows } as const; -} - export function fetchLinkedServiceVersions(input: ServiceFetchConfig) { return Effect.gen(function* () { const exit = yield* Effect.gen(function* () { From e020a45acfb180d4fd3ab6cf090cefbb498e76b7 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 30 Jul 2026 14:15:34 +0100 Subject: [PATCH 2/9] fix(cli): match Go yaml timestamp validation and unicode key ordering (review: codex) - yamlIsTimestamp now mirrors time.Parse's calendar validation (days-in-month with proleptic-Gregorian leap years) and zone-offset range checks (hour > 24, minute > 60 rejected), so strings like "2025-02-31" stay plain exactly as yaml.v3 leaves them. - TOML map keys sort by UTF-8 byte order (sort.Strings) and yaml.v3's keyList.Less rune comparisons use code points, not JS UTF-16 units, fixing astral-vs-high-BMP key ordering in both encoders. - yamlHasSpecialChars/yamlDoubleQuoted now port libyaml's is_printable: astral characters, U+FEFF, and U+FFFE/U+FFFF double-quote with \x/\u/\U escapes. All new golden bytes captured from Go's own utils.EncodeOutput with the pinned BurntSushi toml v1.6.0 / yaml.v3 v3.0.1. --- .../legacy-go-struct-output.encoders.ts | 82 ++++++++++--- ...acy-go-struct-output.encoders.unit.test.ts | 116 ++++++++++++++++++ 2 files changed, 183 insertions(+), 15 deletions(-) diff --git a/apps/cli/src/legacy/shared/legacy-go-struct-output.encoders.ts b/apps/cli/src/legacy/shared/legacy-go-struct-output.encoders.ts index a7e9049088..df0e265b83 100644 --- a/apps/cli/src/legacy/shared/legacy-go-struct-output.encoders.ts +++ b/apps/cli/src/legacy/shared/legacy-go-struct-output.encoders.ts @@ -427,6 +427,23 @@ function yamlMapEntries( return [...entries].sort(([a], [b]) => (yamlKeyLess(a, b) ? -1 : yamlKeyLess(b, a) ? 1 : 0)); } +/** + * Go string ordering: `sort.Strings` compares UTF-8 bytes and yaml.v3's + * `keyList.Less` compares runes — both equal Unicode code-point order, which + * differs from JS `<` (UTF-16 code-unit order) when an astral character meets + * a high-BMP one (e.g. Go sorts U+E000 before U+1F600, UTF-16 the reverse). + */ +function goStringCompare(a: string, b: string): number { + let i = 0; + while (i < a.length && i < b.length) { + const ac = a.codePointAt(i) as number; + const bc = b.codePointAt(i) as number; + if (ac !== bc) return ac < bc ? -1 : 1; + i += ac > 0xffff ? 2 : 1; + } + return a.length - b.length; +} + /** Port of yaml.v3's `keyList.Less` natural string ordering (sorter.go). */ function yamlKeyLess(a: string, b: string): boolean { const ar = [...a]; @@ -441,7 +458,8 @@ function yamlKeyLess(a: string, b: string): boolean { } const al = isLetter(ac); const bl = isLetter(bc); - if (al && bl) return ac < bc; + // Go compares runes (`ar[i] < br[i]`), i.e. code points, not UTF-16 units. + if (al && bl) return (ac.codePointAt(0) as number) < (bc.codePointAt(0) as number); if (al || bl) return digits ? al : bl; let an = 0n; let bn = 0n; @@ -464,7 +482,7 @@ function yamlKeyLess(a: string, b: string): boolean { } if (an !== bn) return an < bn; if (ai !== bi) return ai < bi; - return ac < bc; + return (ac.codePointAt(0) as number) < (bc.codePointAt(0) as number); } return ar.length < br.length; } @@ -640,16 +658,25 @@ function yamlHasSpecialChars(s: string): boolean { for (const ch of s) { const code = ch.codePointAt(0) as number; if (code === 0x09 || code === 0x0a) continue; - if (code < 0x20) return true; - if (code === 0x7f) return true; - if (code >= 0x80 && code < 0xa0) return true; // C1 controls incl. NEL + if (!yamlIsPrintable(code)) return true; if (code === 0x2028 || code === 0x2029) return true; - if (code === 0xfffe || code === 0xffff) return true; - if (code >= 0xd800 && code <= 0xdfff) return true; // unpaired surrogate } return false; } +/** + * libyaml's `is_printable` (yamlprivateh.go) in code-point terms. Notably the + * byte-oriented original never accepts a 4-byte UTF-8 lead, so every astral + * character — as well as C0/C1 controls, DEL, surrogates, the U+FEFF BOM, and + * U+FFFE/U+FFFF — is "not printable" and gets double-quoted escapes. + */ +function yamlIsPrintable(code: number): boolean { + if (code === 0x0a) return true; + if (code >= 0x20 && code <= 0x7e) return true; + if (code >= 0xa0 && code <= 0xd7ff) return true; + return code >= 0xe000 && code <= 0xfffd && code !== 0xfeff; +} + /** Emitter `block_plain_allowed` analysis for single-line printable strings. */ function yamlPlainDisallowed(s: string): boolean { if (s.startsWith(" ") || s.endsWith(" ")) return true; @@ -776,26 +803,47 @@ function goParseIntBase0(plain: string): boolean { return parsed < 18446744073709551616n; } -/** yaml.v3's `parseTimestamp` layouts (resolve.go `allowedTimestampFormats`). */ +/** + * yaml.v3's `parseTimestamp` layouts (resolve.go `allowedTimestampFormats`), + * which delegates to `time.Parse` — so calendar dates and zone offsets are + * validated exactly like Go's time package (verified against the Go binary: + * `2025-02-31` and `2100-02-29` stay plain, `2024-02-29` is a timestamp). + */ function yamlIsTimestamp(s: string): boolean { const match = /^(\d{4})-(\d{1,2})-(\d{1,2})(?:([Tt ])(\d{1,2}):(\d{1,2}):(\d{1,2})(?:\.\d+)?(Z|[+-]\d{2}:\d{2})?)?$/.exec( s, ); if (match === null) return false; - const [, , monthRaw, dayRaw, separator, hour, minute, second, offset] = match; + const [, yearRaw, monthRaw, dayRaw, separator, hour, minute, second, offset] = match; // The space-separated layout has no timezone; T/t layouts require one. if (hour !== undefined) { if (separator === " " && offset !== undefined) return false; if (separator !== " " && offset === undefined) return false; if (Number(hour) > 23 || Number(minute) > 59 || Number(second) > 59) return false; } + // time.Parse's zone-offset range checks (time/format.go): the hour is + // rejected above 24 and the minute above 60 — `+24:59` and `+00:60` are + // accepted, `+25:00` and `+23:99` are not (verified against Go 1.26). + if (offset !== undefined && offset !== "Z") { + if (Number(offset.slice(1, 3)) > 24 || Number(offset.slice(4, 6)) > 60) return false; + } const month = Number(monthRaw); const day = Number(dayRaw); - if (month < 1 || month > 12 || day < 1 || day > 31) return false; + if (month < 1 || month > 12) return false; + if (day < 1 || day > goDaysInMonth(Number(yearRaw), month)) return false; return true; } +/** `time.Parse`'s "day out of range" bound (`daysIn`, proleptic Gregorian). */ +function goDaysInMonth(year: number, month: number): number { + if (month === 2) { + const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); + return leap ? 29 : 28; + } + return month === 4 || month === 6 || month === 9 || month === 11 ? 30 : 31; +} + function yamlStringScalar(s: string, style: Exclude): string { switch (style) { case "plain": @@ -855,12 +903,16 @@ function yamlDoubleQuoted(s: string): string { out += "\\P"; break; default: - if (code < 0x20 || code === 0x7f || (code >= 0x80 && code < 0xa0)) { + // Non-printables escape by rune width like yaml.v3's double-quoted + // writer: `\xXX`, `\uXXXX`, or `\U00XXXXXX` with uppercase hex. + if (yamlIsPrintable(code)) { + out += ch; + } else if (code <= 0xff) { out += `\\x${code.toString(16).toUpperCase().padStart(2, "0")}`; - } else if (code >= 0xd800 && code <= 0xdfff) { + } else if (code <= 0xffff) { out += `\\u${code.toString(16).toUpperCase().padStart(4, "0")}`; } else { - out += ch; + out += `\\U${code.toString(16).toUpperCase().padStart(8, "0")}`; } } } @@ -1009,7 +1061,7 @@ function tomlTable( } const entries = value.k === "map" - ? [...value.entries].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + ? [...value.entries].sort(([a], [b]) => goStringCompare(a, b)) : value.entries; const direct = entries.filter(([, v]) => !tomlIsTable(v)); const sub = entries.filter(([, v]) => tomlIsTable(v)); @@ -1035,7 +1087,7 @@ function tomlArrayOfTables( if (item.k === "struct" || item.k === "map") { const entries = item.k === "map" - ? [...item.entries].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + ? [...item.entries].sort(([a], [b]) => goStringCompare(a, b)) : item.entries; const direct = entries.filter(([, v]) => !tomlIsTable(v)); const sub = entries.filter(([, v]) => tomlIsTable(v)); diff --git a/apps/cli/src/legacy/shared/legacy-go-struct-output.encoders.unit.test.ts b/apps/cli/src/legacy/shared/legacy-go-struct-output.encoders.unit.test.ts index 42f3742b30..b549493350 100644 --- a/apps/cli/src/legacy/shared/legacy-go-struct-output.encoders.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-go-struct-output.encoders.unit.test.ts @@ -352,6 +352,33 @@ F6 = 1.234567e+06 ).toBe( `POSTGRES_URL = "postgres://u:p@h:6543/postgres" SUPABASE_ANON_KEY = "anon" +`, + ); + }); + + it("sorts map keys by UTF-8 byte order like Go's sort.Strings", () => { + // Go orders U+E000/U+FF21 before the astral U+1D400/U+1F600 (UTF-8 byte + // order); JS `<` on UTF-16 units would sort both astral keys first. + const spec = legacyGoMap(legacyGoString); + expect( + encodeLegacyGoToml( + { + "\u{1F600}": "emoji", + "\uE000": "private-use", + z: "ascii", + é: "latin", + A: "fullwidth-A", + "\u{1D400}": "math-bold-A", + }, + spec, + ), + ).toBe( + `z = "ascii" +"é" = "latin" +"\uE000" = "private-use" +"A" = "fullwidth-A" +"\u{1D400}" = "math-bold-A" +"\u{1F600}" = "emoji" `, ); }); @@ -683,6 +710,95 @@ B: "5" a: "2" b: "6" z: "1" +`, + ); + }); + + it("sorts unicode map keys by rune and escapes astral keys like yaml.v3", () => { + // keyList.Less compares runes, so the astral U+1F600/U+1D400 sort after + // U+E000/U+FF21 (JS `<` on UTF-16 units would say the opposite), and the + // emitter double-quotes astral characters (4-byte UTF-8 is not printable + // to libyaml) as \U-escapes. + const spec = legacyGoMap(legacyGoString); + expect( + encodeLegacyGoYaml( + { + "\u{1F600}": "emoji", + "\uE000": "private-use", + z: "ascii", + é: "latin", + A: "fullwidth-A", + "\u{1D400}": "math-bold-A", + }, + spec, + ), + ).toBe( + `\uE000: private-use +"\\U0001F600": emoji +z: ascii +é: latin +A: fullwidth-A +"\\U0001D400": math-bold-A +`, + ); + }); + + it("validates calendar dates and zone offsets like time.Parse before quoting timestamps", () => { + const spec = legacyGoMap(legacyGoString); + expect( + encodeLegacyGoYaml( + { + t01: "2025-02-31", + t02: "2024-02-29", + t03: "2023-02-29", + t04: "2100-02-29", + t05: "2000-02-29", + t06: "2025-04-31", + t07: "2025-01-01T00:00:00+24:00", + t08: "2025-01-01T00:00:00+25:00", + t09: "2025-01-01T00:00:00+23:99", + t10: "2025-01-01T00:00:00+00:60", + t11: "0000-02-29", + t12: "1900-02-29", + }, + spec, + ), + ).toBe( + `t01: 2025-02-31 +t02: "2024-02-29" +t03: 2023-02-29 +t04: 2100-02-29 +t05: "2000-02-29" +t06: 2025-04-31 +t07: "2025-01-01T00:00:00+24:00" +t08: 2025-01-01T00:00:00+25:00 +t09: 2025-01-01T00:00:00+23:99 +t10: "2025-01-01T00:00:00+00:60" +t11: "0000-02-29" +t12: 1900-02-29 +`, + ); + }); + + it("escapes non-printable scalars with \\x/\\u/\\U like yaml.v3's emitter", () => { + const spec = legacyGoMap(legacyGoString); + expect( + encodeLegacyGoYaml( + { + e1: "a\uFEFFb", + e2: "a\uFFFEb", + e3: "mixed \u{1F600} emoji", + e4: "\u{1D400}", + e5: "nel\u0085break", + }, + spec, + ), + ).toBe( + `e1: "a\\uFEFFb" +e2: "a\\uFFFEb" +e3: "mixed \\U0001F600 emoji" +e4: "\\U0001D400" +e5: "nel\\Nbreak" `, ); }); From a479e84f3602485a938b2154a0de2448835f6738 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 30 Jul 2026 15:59:08 +0100 Subject: [PATCH 3/9] fix(cli): match Go float32 ties, ns truncation, and inline TOML tables (review: codex) --- .../legacy/commands/sso/list/list.handler.ts | 13 +- .../sso/list/list.integration.test.ts | 21 +++ .../legacy/commands/sso/show/show.handler.ts | 13 +- .../cli/src/legacy/commands/sso/sso.errors.ts | 8 + .../legacy-go-struct-output.encoders.ts | 148 ++++++++++++++++-- ...acy-go-struct-output.encoders.unit.test.ts | 79 ++++++++++ 6 files changed, 265 insertions(+), 17 deletions(-) diff --git a/apps/cli/src/legacy/commands/sso/list/list.handler.ts b/apps/cli/src/legacy/commands/sso/list/list.handler.ts index d83e097e51..43e8f4f263 100644 --- a/apps/cli/src/legacy/commands/sso/list/list.handler.ts +++ b/apps/cli/src/legacy/commands/sso/list/list.handler.ts @@ -22,6 +22,7 @@ import { LegacySsoListNetworkError, LegacySsoListSamlDisabledError, LegacySsoListUnexpectedStatusError, + LegacySsoTomlEncodeError, } from "../sso.errors.ts"; import { renderListProviders } from "../sso.format.ts"; import type { LegacySsoListFlags } from "./list.command.ts"; @@ -87,7 +88,17 @@ export const legacySsoList = Effect.fn("legacy.sso.list")(function* (flags: Lega return; } if (goFmt === "toml") { - yield* output.raw(encodeLegacyGoToml(payload, LEGACY_GO_SSO_PROVIDERS_WRAPPER)); + // Mirror Go's `utils.EncodeOutput` failure wrapping when BurntSushi + // rejects the payload (e.g. a nil element in an attribute-mapping + // `default` array). + const toml = yield* Effect.try({ + try: () => encodeLegacyGoToml(payload, LEGACY_GO_SSO_PROVIDERS_WRAPPER), + catch: (cause) => + new LegacySsoTomlEncodeError({ + message: `failed to output toml: ${cause instanceof Error ? cause.message : String(cause)}`, + }), + }); + yield* output.raw(toml); return; } if (goFmt === "env") { diff --git a/apps/cli/src/legacy/commands/sso/list/list.integration.test.ts b/apps/cli/src/legacy/commands/sso/list/list.integration.test.ts index 59bbbd2fcc..cc926f8219 100644 --- a/apps/cli/src/legacy/commands/sso/list/list.integration.test.ts +++ b/apps/cli/src/legacy/commands/sso/list/list.integration.test.ts @@ -214,6 +214,27 @@ describe("legacy sso list integration", () => { }).pipe(Effect.provide(layer)); }); + it.live("Go --output=toml fails like BurntSushi on a nil attribute-mapping array element", () => { + const item = { + ...PROVIDER_ITEM, + saml: { + ...PROVIDER_ITEM.saml, + attribute_mapping: { keys: { a: { name: "xyz", default: [null, "x"] } } }, + }, + }; + const { layer, out } = setup({ goOutput: "toml", body: { items: [item] } }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacySsoList({ projectRef: Option.none() })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const dump = JSON.stringify(exit.cause); + expect(dump).toContain("LegacySsoTomlEncodeError"); + expect(dump).toContain("failed to output toml: toml: cannot encode array with nil element"); + } + expect(out.stdoutText).toBe(""); + }).pipe(Effect.provide(layer)); + }); + it.live("Go --output=env emits a flat PROVIDERS= entry", () => { const { layer, out } = setup({ goOutput: "env" }); return Effect.gen(function* () { diff --git a/apps/cli/src/legacy/commands/sso/show/show.handler.ts b/apps/cli/src/legacy/commands/sso/show/show.handler.ts index e25c3455cb..54521d09e0 100644 --- a/apps/cli/src/legacy/commands/sso/show/show.handler.ts +++ b/apps/cli/src/legacy/commands/sso/show/show.handler.ts @@ -19,6 +19,7 @@ import { LegacySsoShowNetworkError, LegacySsoShowNotFoundError, LegacySsoShowUnexpectedStatusError, + LegacySsoTomlEncodeError, } from "../sso.errors.ts"; import { renderSingleProvider, validateUuid } from "../sso.format.ts"; import type { LegacySsoShowFlags } from "./show.command.ts"; @@ -95,7 +96,17 @@ export const legacySsoShow = Effect.fn("legacy.sso.show")(function* (flags: Lega return; } if (goFmt === "toml") { - yield* output.raw(encodeLegacyGoToml(response, LEGACY_GO_SSO_PROVIDER_RESPONSE)); + // Mirror Go's `utils.EncodeOutput` failure wrapping when BurntSushi + // rejects the payload (e.g. a nil element in an attribute-mapping + // `default` array). + const toml = yield* Effect.try({ + try: () => encodeLegacyGoToml(response, LEGACY_GO_SSO_PROVIDER_RESPONSE), + catch: (cause) => + new LegacySsoTomlEncodeError({ + message: `failed to output toml: ${cause instanceof Error ? cause.message : String(cause)}`, + }), + }); + yield* output.raw(toml); return; } diff --git a/apps/cli/src/legacy/commands/sso/sso.errors.ts b/apps/cli/src/legacy/commands/sso/sso.errors.ts index 55630760c0..e7533192d6 100644 --- a/apps/cli/src/legacy/commands/sso/sso.errors.ts +++ b/apps/cli/src/legacy/commands/sso/sso.errors.ts @@ -9,6 +9,14 @@ export class LegacySsoInvalidUuidError extends Data.TaggedError("LegacySsoInvali readonly message: string; }> {} +// Shared across list / show: mirrors Go's `utils.EncodeOutput` TOML failure +// ("failed to output toml: %w") — reachable when an `attribute_mapping` +// `default` value cannot be encoded by BurntSushi (e.g. an array with a nil +// element). +export class LegacySsoTomlEncodeError extends Data.TaggedError("LegacySsoTomlEncodeError")<{ + readonly message: string; +}> {} + // `sso list` export class LegacySsoListNetworkError extends Data.TaggedError("LegacySsoListNetworkError")<{ readonly message: string; diff --git a/apps/cli/src/legacy/shared/legacy-go-struct-output.encoders.ts b/apps/cli/src/legacy/shared/legacy-go-struct-output.encoders.ts index df0e265b83..41492359f6 100644 --- a/apps/cli/src/legacy/shared/legacy-go-struct-output.encoders.ts +++ b/apps/cli/src/legacy/shared/legacy-go-struct-output.encoders.ts @@ -291,8 +291,11 @@ function normalizeAny(value: unknown): GoValue { /** * Render an RFC3339 input the way Go formats a decoded `time.Time` with - * `time.RFC3339Nano`: trailing zeros trimmed from the fraction (the dot is - * dropped when the fraction is all zeros) and a zero offset rendered as `Z`. + * `time.RFC3339Nano`: the fraction truncated (not rounded) to nanoseconds — + * `time`'s `parseNanoseconds` keeps at most 9 fractional digits, so + * `.1234567895` decodes as `.123456789` — then trailing zeros trimmed (the + * dot is dropped when the fraction is all zeros) and a zero offset rendered + * as `Z`. */ function normalizeGoTime(value: string): string { const match = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(\.\d+)?(Z|[+-]\d{2}:\d{2})$/.exec(value); @@ -300,7 +303,7 @@ function normalizeGoTime(value: string): string { const [, base, fraction, offset] = match; let frac = ""; if (fraction !== undefined) { - const digits = fraction.slice(1).replace(/0+$/, ""); + const digits = fraction.slice(1, 10).replace(/0+$/, ""); if (digits.length > 0) frac = `.${digits}`; } const zone = offset === "Z" || offset === "+00:00" || offset === "-00:00" ? "Z" : offset; @@ -311,14 +314,79 @@ function normalizeGoTime(value: string): string { // Go float formatting (strconv.FormatFloat(f, 'g', -1, bits)) // --------------------------------------------------------------------------- -/** Shortest round-trip digits for a float32 value (Go marshals via the typed field). */ +/** + * Shortest round-trip digits for a float32 value (Go marshals via the typed + * field), matching Ryu as used by `strconv.FormatFloat(f, 'g', -1, 32)`: the + * fewest significant digits that parse back to the same float32, taking the + * candidate correctly rounded from the exact binary value — an exact decimal + * tie goes to the even final digit, where JS `toPrecision` would round half + * up (verified against Go: 4249.03125 → `4249.0312`, 4249.09375 → + * `4249.0938`). Returns `e<±exp>` for {@link legacyGoFormatFloat}. + */ function shortestFloat32(value: number): string { const rounded = Math.fround(value); - for (let precision = 1; precision <= 9; precision++) { - const candidate = rounded.toPrecision(precision); - if (Math.fround(Number(candidate)) === rounded) return String(Number(candidate)); + if (rounded === 0) return "0"; + const sign = rounded < 0 ? "-" : ""; + // Exact float32 decomposition: |rounded| = mantissa * 2^exp2. + const view = new DataView(new ArrayBuffer(4)); + view.setFloat32(0, Math.abs(rounded)); + const bits = view.getUint32(0); + const biased = (bits >>> 23) & 0xff; + const frac = BigInt(bits & 0x7fffff); + const mantissa = biased === 0 ? frac : frac | 0x800000n; + const exp2 = (biased === 0 ? 1 : biased) - 127 - 23; + // Exact decimal expansion: |rounded| = 0. * 10^dp (binary fractions + // terminate in decimal, via m * 2^-k = m * 5^k * 10^-k). + let digits: string; + let dp: number; + if (exp2 >= 0) { + digits = (mantissa << BigInt(exp2)).toString(); + dp = digits.length; + } else { + digits = (mantissa * 5n ** BigInt(-exp2)).toString(); + dp = digits.length + exp2; + } + const significant = digits.replace(/0+$/, ""); + // 9 significant digits always round-trip a float32, so the loop exits. + for (let precision = 1; precision <= significant.length; precision++) { + const [candidate, candidateDp] = roundDecimalDigits(digits, dp, precision); + if ( + Math.fround(Number(`${candidate}e${candidateDp - candidate.length}`)) === Math.abs(rounded) + ) { + return `${sign}${candidate}e${candidateDp - candidate.length >= 0 ? "+" : ""}${candidateDp - candidate.length}`; + } + } + return `${sign}${significant}e${dp - significant.length >= 0 ? "+" : ""}${dp - significant.length}`; +} + +/** + * Round an exact decimal expansion `0. * 10^dp` to `precision` + * significant digits — nearest, with exact halves to the even final digit + * (Ryu's tie rule) — returning the rounded digits (trailing zeros stripped) + * and their decimal-point position. + */ +function roundDecimalDigits( + digits: string, + dp: number, + precision: number, +): readonly [digits: string, dp: number] { + let head = digits.slice(0, precision); + const rest = digits.slice(precision); + const restHalf = rest.length > 0 ? "5".padEnd(rest.length, "0") : ""; + const roundUp = + rest > restHalf || + (rest === restHalf && rest !== "" && "13579".includes(head[head.length - 1] as string)); + let candidateDp = dp; + if (roundUp) { + head = (BigInt(head) + 1n).toString(); + if (head.length > precision) { + // 999… carried over into 100…: one more digit before the point. + candidateDp += 1; + head = head.slice(0, precision); + } } - return String(rounded); + const stripped = head.replace(/0+$/, ""); + return [stripped.length > 0 ? stripped : "0", candidateDp]; } /** @@ -949,13 +1017,13 @@ function yamlBlockLiteral(s: string, indent: number): string { // --------------------------------------------------------------------------- /** - * Thrown when the payload contains a populated `nullable.Nullable` field — - * BurntSushi refuses `map[bool]T` and the Go CLI fails with this message - * (observed on `snippets list -o toml`). + * Thrown when BurntSushi would refuse the payload: a populated + * `nullable.Nullable` field (`map[bool]T` has a non-string key type — observed + * on `snippets list -o toml`) or a `nil` element inside an inline array. */ export class LegacyGoTomlEncodeError extends Error { - constructor() { - super("toml: cannot encode a map with non-string key type"); + constructor(message = "toml: cannot encode a map with non-string key type") { + super(message); this.name = "LegacyGoTomlEncodeError"; } } @@ -1129,11 +1197,61 @@ function tomlElement(value: GoValue): string { return value.v; case "slice": return `[${value.items.map(tomlElement).join(", ")}]`; + case "struct": + case "map": + return tomlInlineTable(value); case "nullable": throw new LegacyGoTomlEncodeError(); - default: - return ""; + case "nil": + // BurntSushi's `eElement` rejects nil inline-array elements (verified: + // `[null, "x"]` fails, while nil *map values* are silently skipped). + throw new LegacyGoTomlEncodeError("toml: cannot encode array with nil element"); + } +} + +/** + * BurntSushi's inline-table form, used for map/struct elements of arrays that + * are not arrays-of-tables (e.g. `interface{}` values holding + * `[{"a":1},"x"]`): `{k = v, ...}` with nil entries skipped and, like block + * tables, non-table values before table values — map keys byte-sorted within + * each group (verified: `[{"a":{"b":1},"z":2},"x"]` → `[{z = 2.0, a = {b = + * 1.0}}, "x"]`). + * + * The `", "` separator replicates `eMap`/`eStruct` exactly: it is decided by + * the entry's *position* — for maps, group index with a trailing comma after + * the direct group when sub-tables follow; for structs, declaration index — + * so a skipped nil entry in the final position leaves a dangling `", "` + * (verified: `[{"10":1,"b":null},false]` → `[{10 = 1.0, }, false]`). + */ +function tomlInlineTable(value: Extract): string { + let out = "{"; + if (value.k === "map") { + const sorted = [...value.entries].sort(([a], [b]) => goStringCompare(a, b)); + const direct = sorted.filter(([, v]) => !tomlIsTable(v)); + const sub = sorted.filter(([, v]) => tomlIsTable(v)); + const writeGroup = ( + group: ReadonlyArray, + trailingComma: boolean, + ): void => { + for (const [index, [name, v]] of group.entries()) { + if (tomlIsNil(v)) continue; + out += `${tomlKeyName(name)} = ${tomlElement(v)}`; + if (trailingComma || index !== group.length - 1) out += ", "; + } + }; + writeGroup(direct, sub.length > 0); + writeGroup(sub, false); + } else { + const fields = value.entries.map(([name, v], index) => [name, v, index] as const); + const direct = fields.filter(([, v]) => !tomlIsTable(v)); + const sub = fields.filter(([, v]) => tomlIsTable(v)); + for (const [name, v, index] of [...direct, ...sub]) { + if (tomlIsNil(v)) continue; + out += `${tomlKeyName(name)} = ${tomlElement(v)}`; + if (index !== value.entries.length - 1) out += ", "; + } } + return `${out}}`; } /** BurntSushi's `dblQuotedReplacer` escape set. */ diff --git a/apps/cli/src/legacy/shared/legacy-go-struct-output.encoders.unit.test.ts b/apps/cli/src/legacy/shared/legacy-go-struct-output.encoders.unit.test.ts index b549493350..23f28da950 100644 --- a/apps/cli/src/legacy/shared/legacy-go-struct-output.encoders.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-go-struct-output.encoders.unit.test.ts @@ -356,6 +356,49 @@ SUPABASE_ANON_KEY = "anon" ); }); + it("renders map elements of mixed interface{} arrays as inline tables like BurntSushi", () => { + const spec = legacyGoStruct([["default", legacyGoAny, "Default"]]); + // Sorted byte order, non-table values before table values. + expect(encodeLegacyGoToml({ default: [{ b: 2, a: 1, C: 3 }, "x"] }, spec)).toBe( + 'Default = [{C = 3.0, a = 1.0, b = 2.0}, "x"]\n', + ); + expect(encodeLegacyGoToml({ default: [{ a: { b: 1 }, z: 2 }, "x"] }, spec)).toBe( + 'Default = [{z = 2.0, a = {b = 1.0}}, "x"]\n', + ); + expect(encodeLegacyGoToml({ default: [{ a: [{ b: 1 }], z: 2 }, "x"] }, spec)).toBe( + 'Default = [{z = 2.0, a = [{b = 1.0}]}, "x"]\n', + ); + // Non-bare keys are quoted; empty and all-nil tables collapse to {}. + expect(encodeLegacyGoToml({ default: [{ "a b": 1 }, "x"] }, spec)).toBe( + 'Default = [{"a b" = 1.0}, "x"]\n', + ); + expect(encodeLegacyGoToml({ default: [{}, "x"] }, spec)).toBe('Default = [{}, "x"]\n'); + expect(encodeLegacyGoToml({ default: [{ a: null }, "x"] }, spec)).toBe('Default = [{}, "x"]\n'); + // eMap decides the ", " separator by group position before skipping nil + // entries, so a nil in the final position leaves a dangling separator. + expect(encodeLegacyGoToml({ default: [{ "10": 78797, b: null }, false] }, spec)).toBe( + "Default = [{10 = 78797.0, }, false]\n", + ); + }); + + it("fails like Go on nil elements inside interface{} arrays", () => { + const spec = legacyGoStruct([["default", legacyGoAny, "Default"]]); + const message = "toml: cannot encode array with nil element"; + expect(() => encodeLegacyGoToml({ default: [null, "x"] }, spec)).toThrow(message); + expect(() => encodeLegacyGoToml({ default: [null] }, spec)).toThrow(message); + expect(() => encodeLegacyGoToml({ default: [[null], "x"] }, spec)).toThrow(message); + }); + + it("truncates time fractions to nanoseconds like time.Time's decoder", () => { + const spec = legacyGoStruct([["t", legacyGoTime, "T"]]); + expect(encodeLegacyGoToml({ t: "2026-01-01T00:00:00.1234567895Z" }, spec)).toBe( + "T = 2026-01-01T00:00:00.123456789Z\n", + ); + expect(encodeLegacyGoToml({ t: "2026-01-01T00:00:00.1000000005Z" }, spec)).toBe( + "T = 2026-01-01T00:00:00.1Z\n", + ); + }); + it("sorts map keys by UTF-8 byte order like Go's sort.Strings", () => { // Go orders U+E000/U+FF21 before the astral U+1D400/U+1F600 (UTF-8 byte // order); JS `<` on UTF-16 units would sort both astral keys first. @@ -780,6 +823,27 @@ t12: 1900-02-29 ); }); + it("truncates time fractions to nanoseconds like time.Time's decoder", () => { + const spec = legacyGoStruct([["t", legacyGoTime, "T"]]); + // time's `parseNanoseconds` keeps at most 9 fractional digits (truncation, + // not rounding), then RFC3339Nano trims trailing zeros. + expect(encodeLegacyGoYaml({ t: "2026-01-01T00:00:00.1234567895Z" }, spec)).toBe( + "t: 2026-01-01T00:00:00.123456789Z\n", + ); + expect(encodeLegacyGoYaml({ t: "2026-01-01T00:00:00.12345678901234Z" }, spec)).toBe( + "t: 2026-01-01T00:00:00.123456789Z\n", + ); + expect(encodeLegacyGoYaml({ t: "2026-01-01T00:00:00.9999999999Z" }, spec)).toBe( + "t: 2026-01-01T00:00:00.999999999Z\n", + ); + expect(encodeLegacyGoYaml({ t: "2026-01-01T00:00:00.1000000005Z" }, spec)).toBe( + "t: 2026-01-01T00:00:00.1Z\n", + ); + expect(encodeLegacyGoYaml({ t: "2026-01-01T00:00:00.1234567895+07:00" }, spec)).toBe( + "t: 2026-01-01T00:00:00.123456789+07:00\n", + ); + }); + it("escapes non-printable scalars with \\x/\\u/\\U like yaml.v3's emitter", () => { const spec = legacyGoMap(legacyGoString); expect( @@ -835,4 +899,19 @@ describe("legacyGoFormatFloat", () => { expect(legacyGoFormatFloat(78125, 32)).toBe("78125"); expect(legacyGoFormatFloat(0.5, 32)).toBe("0.5"); }); + + it("breaks exact shortest-digit ties to even like Ryu, not half-up", () => { + // 4249.03125 sits exactly between the two shortest 8-digit candidates; + // strconv keeps the even final digit both downward and upward. + expect(legacyGoFormatFloat(4249.03125, 32)).toBe("4249.0312"); + expect(legacyGoFormatFloat(4249.09375, 32)).toBe("4249.0938"); + expect(legacyGoFormatFloat(123456789, 32)).toBe("1.2345679e+08"); + expect(legacyGoFormatFloat(1048575.5, 32)).toBe("1.0485755e+06"); + expect(legacyGoFormatFloat(8388607.5, 32)).toBe("8.3886075e+06"); + // Boundaries: smallest subnormal, subnormal→normal edge, and max finite. + expect(legacyGoFormatFloat(1.401298464324817e-45, 32)).toBe("1e-45"); + expect(legacyGoFormatFloat(1.1754943508222875e-38, 32)).toBe("1.1754944e-38"); + expect(legacyGoFormatFloat(3.4028234663852886e38, 32)).toBe("3.4028235e+38"); + expect(legacyGoFormatFloat(-4249.03125, 32)).toBe("-4249.0312"); + }); }); From c701b9ae647f190b34f9cab6ab84628620f17934 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 30 Jul 2026 17:42:24 +0100 Subject: [PATCH 4/9] fix(cli): accept comma time fractions and wrap sso toml encode failures (review: codex) --- .../legacy/commands/sso/add/add.handler.ts | 12 ++++++++- .../commands/sso/remove/remove.handler.ts | 12 ++++++++- .../sso/remove/remove.integration.test.ts | 26 +++++++++++++++++++ .../commands/sso/update/update.handler.ts | 12 ++++++++- .../legacy-go-struct-output.encoders.ts | 6 ++++- ...acy-go-struct-output.encoders.unit.test.ts | 13 ++++++++++ 6 files changed, 77 insertions(+), 4 deletions(-) diff --git a/apps/cli/src/legacy/commands/sso/add/add.handler.ts b/apps/cli/src/legacy/commands/sso/add/add.handler.ts index b2526468df..1f4b479329 100644 --- a/apps/cli/src/legacy/commands/sso/add/add.handler.ts +++ b/apps/cli/src/legacy/commands/sso/add/add.handler.ts @@ -24,6 +24,7 @@ import { LegacySsoAddSamlDisabledError, LegacySsoAddUnexpectedStatusError, LegacySsoMutexFlagError, + LegacySsoTomlEncodeError, } from "../sso.errors.ts"; import { renderSingleProvider, toLegacySsoProviderView } from "../sso.format.ts"; import { validateMetadataUrl } from "../sso.metadata-url.ts"; @@ -172,7 +173,16 @@ export const legacySsoAdd = Effect.fn("legacy.sso.add")(function* (flags: Legacy return; } if (goFmt === "toml") { - yield* output.raw(encodeLegacyGoToml(parsedJson, LEGACY_GO_SSO_PROVIDER_RESPONSE)); + // Mirror Go's `utils.EncodeOutput` failure wrapping when BurntSushi + // rejects the payload (review r3684270640) — same pattern as list/show. + const toml = yield* Effect.try({ + try: () => encodeLegacyGoToml(parsedJson, LEGACY_GO_SSO_PROVIDER_RESPONSE), + catch: (cause) => + new LegacySsoTomlEncodeError({ + message: `failed to output toml: ${cause instanceof Error ? cause.message : String(cause)}`, + }), + }); + yield* output.raw(toml); return; } if (goFmt === "env") { diff --git a/apps/cli/src/legacy/commands/sso/remove/remove.handler.ts b/apps/cli/src/legacy/commands/sso/remove/remove.handler.ts index ebc8fddc4a..badca247ed 100644 --- a/apps/cli/src/legacy/commands/sso/remove/remove.handler.ts +++ b/apps/cli/src/legacy/commands/sso/remove/remove.handler.ts @@ -22,6 +22,7 @@ import { LegacySsoRemoveNetworkError, LegacySsoRemoveNotFoundError, LegacySsoRemoveUnexpectedStatusError, + LegacySsoTomlEncodeError, } from "../sso.errors.ts"; import { renderSingleProvider, validateUuid } from "../sso.format.ts"; import type { LegacySsoRemoveFlags } from "./remove.command.ts"; @@ -91,7 +92,16 @@ export const legacySsoRemove = Effect.fn("legacy.sso.remove")(function* ( return; } if (goFmt === "toml") { - yield* output.raw(encodeLegacyGoToml(response, LEGACY_GO_SSO_PROVIDER_RESPONSE)); + // Mirror Go's `utils.EncodeOutput` failure wrapping when BurntSushi + // rejects the payload (review r3684270640) — same pattern as list/show. + const toml = yield* Effect.try({ + try: () => encodeLegacyGoToml(response, LEGACY_GO_SSO_PROVIDER_RESPONSE), + catch: (cause) => + new LegacySsoTomlEncodeError({ + message: `failed to output toml: ${cause instanceof Error ? cause.message : String(cause)}`, + }), + }); + yield* output.raw(toml); return; } if (goFmt === "env") { diff --git a/apps/cli/src/legacy/commands/sso/remove/remove.integration.test.ts b/apps/cli/src/legacy/commands/sso/remove/remove.integration.test.ts index f9453efa84..1e667e10e5 100644 --- a/apps/cli/src/legacy/commands/sso/remove/remove.integration.test.ts +++ b/apps/cli/src/legacy/commands/sso/remove/remove.integration.test.ts @@ -198,6 +198,32 @@ describe("legacy sso remove integration", () => { }).pipe(Effect.provide(layer)); }); + it.live("Go --output=toml fails like Go's EncodeOutput on an unencodable payload", () => { + // BurntSushi rejects a nil array element; Go surfaces it as an ordinary + // `failed to output toml: …` command error, not a crash (review + // r3684270640 — the same wrapping list/show gained in the prior round). + const body = { + ...PROVIDER, + saml: { + ...PROVIDER.saml, + attribute_mapping: { keys: { a: { name: "xyz", default: [null, "x"] } } }, + }, + }; + const { layer, out } = setup({ goOutput: "toml", body }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacySsoRemove({ projectRef: Option.none(), providerId: VALID_PROVIDER_ID }), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const dump = JSON.stringify(exit.cause); + expect(dump).toContain("LegacySsoTomlEncodeError"); + expect(dump).toContain("failed to output toml: toml: cannot encode array with nil element"); + } + expect(out.stdoutText).toBe(""); + }).pipe(Effect.provide(layer)); + }); + it.live("TS --output-format=json emits success", () => { const { layer, out } = setup({ format: "json" }); return Effect.gen(function* () { diff --git a/apps/cli/src/legacy/commands/sso/update/update.handler.ts b/apps/cli/src/legacy/commands/sso/update/update.handler.ts index 08fc25e587..37c0859d56 100644 --- a/apps/cli/src/legacy/commands/sso/update/update.handler.ts +++ b/apps/cli/src/legacy/commands/sso/update/update.handler.ts @@ -33,6 +33,7 @@ import { LegacySsoUpdateNetworkError, LegacySsoUpdateNotFoundError, LegacySsoUpdateUnexpectedStatusError, + LegacySsoTomlEncodeError, } from "../sso.errors.ts"; import { renderSingleProvider, toLegacySsoProviderView, validateUuid } from "../sso.format.ts"; import { validateMetadataUrl } from "../sso.metadata-url.ts"; @@ -309,7 +310,16 @@ export const legacySsoUpdate = Effect.fn("legacy.sso.update")(function* ( return; } if (goFmt === "toml") { - yield* output.raw(encodeLegacyGoToml(parsedJson, LEGACY_GO_SSO_PROVIDER_RESPONSE)); + // Mirror Go's `utils.EncodeOutput` failure wrapping when BurntSushi + // rejects the payload (review r3684270640) — same pattern as list/show. + const toml = yield* Effect.try({ + try: () => encodeLegacyGoToml(parsedJson, LEGACY_GO_SSO_PROVIDER_RESPONSE), + catch: (cause) => + new LegacySsoTomlEncodeError({ + message: `failed to output toml: ${cause instanceof Error ? cause.message : String(cause)}`, + }), + }); + yield* output.raw(toml); return; } if (goFmt === "env") { diff --git a/apps/cli/src/legacy/shared/legacy-go-struct-output.encoders.ts b/apps/cli/src/legacy/shared/legacy-go-struct-output.encoders.ts index 41492359f6..85a67f2ccd 100644 --- a/apps/cli/src/legacy/shared/legacy-go-struct-output.encoders.ts +++ b/apps/cli/src/legacy/shared/legacy-go-struct-output.encoders.ts @@ -298,7 +298,11 @@ function normalizeAny(value: unknown): GoValue { * as `Z`. */ function normalizeGoTime(value: string): string { - const match = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(\.\d+)?(Z|[+-]\d{2}:\d{2})$/.exec(value); + // Go accepts `,` as the fractional separator on decode (`commaOrPeriod`, + // `time/format.go`; probed: `time.Time.UnmarshalJSON` parses + // `…00,123Z` and re-marshals it as `…00.123Z`), so both separators + // normalize to the dot Go emits (review r3684270625). + const match = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})([.,]\d+)?(Z|[+-]\d{2}:\d{2})$/.exec(value); if (match === null) return value; const [, base, fraction, offset] = match; let frac = ""; diff --git a/apps/cli/src/legacy/shared/legacy-go-struct-output.encoders.unit.test.ts b/apps/cli/src/legacy/shared/legacy-go-struct-output.encoders.unit.test.ts index 23f28da950..1610b065fb 100644 --- a/apps/cli/src/legacy/shared/legacy-go-struct-output.encoders.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-go-struct-output.encoders.unit.test.ts @@ -399,6 +399,19 @@ SUPABASE_ANON_KEY = "anon" ); }); + it("normalizes Go's accepted comma fractional separator to the dot Go re-emits", () => { + // Probed on go1.26: `time.Time.UnmarshalJSON` parses `…00,123Z` + // (`commaOrPeriod`, `time/format.go`) and `json.Marshal` re-emits + // `…00.123Z` — the encoders must match on both output formats. + const spec = legacyGoStruct([["t", legacyGoTime, "T"]]); + expect(encodeLegacyGoToml({ t: "2026-01-01T00:00:00,123Z" }, spec)).toBe( + "T = 2026-01-01T00:00:00.123Z\n", + ); + expect(encodeLegacyGoYaml({ t: "2026-01-01T00:00:00,1234567895Z" }, spec)).toBe( + "t: 2026-01-01T00:00:00.123456789Z\n", + ); + }); + it("sorts map keys by UTF-8 byte order like Go's sort.Strings", () => { // Go orders U+E000/U+FF21 before the astral U+1D400/U+1F600 (UTF-8 byte // order); JS `<` on UTF-16 units would sort both astral keys first. From 7ca5457be49ce718de3898749b8c3eaa065a6ee9 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 31 Jul 2026 02:20:45 +0100 Subject: [PATCH 5/9] fix(cli): match yaml.v3 comma timestamps, digit sorting, and float overflow quoting (review: codex) --- .../legacy-go-struct-output.encoders.ts | 44 ++++++++++++++----- ...acy-go-struct-output.encoders.unit.test.ts | 33 ++++++++++++++ 2 files changed, 66 insertions(+), 11 deletions(-) diff --git a/apps/cli/src/legacy/shared/legacy-go-struct-output.encoders.ts b/apps/cli/src/legacy/shared/legacy-go-struct-output.encoders.ts index 85a67f2ccd..528c70a128 100644 --- a/apps/cli/src/legacy/shared/legacy-go-struct-output.encoders.ts +++ b/apps/cli/src/legacy/shared/legacy-go-struct-output.encoders.ts @@ -516,7 +516,13 @@ function goStringCompare(a: string, b: string): number { return a.length - b.length; } -/** Port of yaml.v3's `keyList.Less` natural string ordering (sorter.go). */ +/** + * Port of yaml.v3's `keyList.Less` natural string ordering (sorter.go). + * Digit runs use `unicode.IsDigit` (any Unicode `Nd` digit — probed: Go + * orders `a3, a9, a10, a٢`, the Arabic-Indic key LAST, because the naive + * `rune - '0'` arithmetic yields a huge value for non-ASCII digits; review + * r3685767973). The ASCII-only {@link isDigit} stays for the scalar parser. + */ function yamlKeyLess(a: string, b: string): boolean { const ar = [...a]; const br = [...b]; @@ -525,7 +531,7 @@ function yamlKeyLess(a: string, b: string): boolean { const ac = ar[i] as string; const bc = br[i] as string; if (ac === bc) { - digits = isDigit(ac); + digits = isSortDigit(ac); continue; } const al = isLetter(ac); @@ -536,7 +542,7 @@ function yamlKeyLess(a: string, b: string): boolean { let an = 0n; let bn = 0n; if (ac === "0" || bc === "0") { - for (let j = i - 1; j >= 0 && isDigit(ar[j] as string); j--) { + for (let j = i - 1; j >= 0 && isSortDigit(ar[j] as string); j--) { if (ar[j] !== "0") { an = 1n; bn = 1n; @@ -546,11 +552,11 @@ function yamlKeyLess(a: string, b: string): boolean { } let ai = i; let bi = i; - for (; ai < ar.length && isDigit(ar[ai] as string); ai++) { - an = an * 10n + BigInt((ar[ai] as string).charCodeAt(0) - 48); + for (; ai < ar.length && isSortDigit(ar[ai] as string); ai++) { + an = an * 10n + BigInt(((ar[ai] as string).codePointAt(0) as number) - 48); } - for (; bi < br.length && isDigit(br[bi] as string); bi++) { - bn = bn * 10n + BigInt((br[bi] as string).charCodeAt(0) - 48); + for (; bi < br.length && isSortDigit(br[bi] as string); bi++) { + bn = bn * 10n + BigInt(((br[bi] as string).codePointAt(0) as number) - 48); } if (an !== bn) return an < bn; if (ai !== bi) return ai < bi; @@ -559,6 +565,11 @@ function yamlKeyLess(a: string, b: string): boolean { return ar.length < br.length; } +/** yaml.v3 sorter's `unicode.IsDigit` — any Unicode decimal digit (`Nd`). */ +function isSortDigit(c: string): boolean { + return /\p{Nd}/u.test(c); +} + function isDigit(c: string): boolean { return c >= "0" && c <= "9"; } @@ -774,14 +785,21 @@ function yamlResolvesToString(s: string): boolean { if (YAML_BASE60.test(s)) return false; const first = s[0] as string; if (first === ".") { - // resolve()'s '.'-hint branch: strconv.ParseFloat. - return !/^\.\d+(?:[eE][+-]?\d+)?$/.test(s); + // resolve()'s '.'-hint branch: strconv.ParseFloat — which ERRORS on + // overflow (±Inf), so an overflowing spelling stays a string and needs + // no quoting (probed: Go emits `1e999` plain; review r3685767974). + return !(/^\.\d+(?:[eE][+-]?\d+)?$/.test(s) && Number.isFinite(Number(s))); } if (first === "+" || first === "-" || isDigit(first)) { if (yamlIsTimestamp(s)) return false; const plain = s.replaceAll("_", ""); if (goParseIntBase0(plain)) return false; - if (YAML_STYLE_FLOAT.test(plain)) return false; + // strconv.ParseFloat overflow (→ ±Inf) is an error in resolve(), so the + // value resolves as a string and is emitted plain; underflow (1e-999 → 0) + // succeeds and stays float-tagged, hence quoted (probed both against Go, + // review r3685767974). `Number` mirrors the accepted shapes here because + // YAML_STYLE_FLOAT gates the syntax first. + if (YAML_STYLE_FLOAT.test(plain) && Number.isFinite(Number(plain))) return false; return true; } // 'M'-hint characters (yYnNtTfFoO~) resolve via the exact map only. @@ -882,8 +900,12 @@ function goParseIntBase0(plain: string): boolean { * `2025-02-31` and `2100-02-29` stay plain, `2024-02-29` is a timestamp). */ function yamlIsTimestamp(s: string): boolean { + // Fraction separator is `.` OR `,` — yaml.v3 resolves timestamps through + // Go's `time.Parse`, which accepts either (`commaOrPeriod`; probed: Go + // double-quotes the comma form exactly like the dot form, + // review r3685767963). const match = - /^(\d{4})-(\d{1,2})-(\d{1,2})(?:([Tt ])(\d{1,2}):(\d{1,2}):(\d{1,2})(?:\.\d+)?(Z|[+-]\d{2}:\d{2})?)?$/.exec( + /^(\d{4})-(\d{1,2})-(\d{1,2})(?:([Tt ])(\d{1,2}):(\d{1,2}):(\d{1,2})(?:[.,]\d+)?(Z|[+-]\d{2}:\d{2})?)?$/.exec( s, ); if (match === null) return false; diff --git a/apps/cli/src/legacy/shared/legacy-go-struct-output.encoders.unit.test.ts b/apps/cli/src/legacy/shared/legacy-go-struct-output.encoders.unit.test.ts index 1610b065fb..6411e21261 100644 --- a/apps/cli/src/legacy/shared/legacy-go-struct-output.encoders.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-go-struct-output.encoders.unit.test.ts @@ -399,6 +399,39 @@ SUPABASE_ANON_KEY = "anon" ); }); + it("quotes comma-fraction timestamp-shaped STRINGS like yaml.v3's resolver", () => { + // Probed on go1.26: the string field "2026-01-01T00:00:00,123Z" is + // double-quoted exactly like the dot form — yaml.v3 resolves timestamps + // through time.Parse, which accepts either separator (review r3685767963). + const spec = legacyGoStruct([["s", legacyGoString, "S"]]); + expect(encodeLegacyGoYaml({ s: "2026-01-01T00:00:00,123Z" }, spec)).toBe( + 's: "2026-01-01T00:00:00,123Z"\n', + ); + }); + + it("leaves overflowing float-shaped strings plain like yaml.v3's ParseFloat gate", () => { + // Probed on go1.26: resolve()'s strconv.ParseFloat ERRORS on overflow + // (±Inf), so the value stays string-tagged and needs no quoting; an + // underflowing exponent (1e-999 → 0) parses successfully and IS quoted + // (review r3685767974). + const spec = legacyGoStruct([["s", legacyGoString, "S"]]); + expect(encodeLegacyGoYaml({ s: "1e999" }, spec)).toBe("s: 1e999\n"); + expect(encodeLegacyGoYaml({ s: "-1e999" }, spec)).toBe("s: -1e999\n"); + expect(encodeLegacyGoYaml({ s: ".5e999" }, spec)).toBe("s: .5e999\n"); + expect(encodeLegacyGoYaml({ s: "1e-999" }, spec)).toBe('s: "1e-999"\n'); + expect(encodeLegacyGoYaml({ s: "1e10" }, spec)).toBe('s: "1e10"\n'); + }); + + it("orders Unicode-digit map keys with yaml.v3's naive rune arithmetic", () => { + // Probed on go1.26: keyList.Less finds digit runs with unicode.IsDigit + // but accumulates values as `rune - '0'`, so the Arabic-Indic key `a٢` + // (U+0662) sorts AFTER a10, not as the number 2 (review r3685767973). + const spec = legacyGoStruct([["default", legacyGoAny, "Default"]]); + expect(encodeLegacyGoYaml({ default: { a٢: 1, a3: 2, a10: 3, a9: 4 } }, spec)).toBe( + "default:\n a3: 2\n a9: 4\n a10: 3\n a٢: 1\n", + ); + }); + it("normalizes Go's accepted comma fractional separator to the dot Go re-emits", () => { // Probed on go1.26: `time.Time.UnmarshalJSON` parses `…00,123Z` // (`commaOrPeriod`, `time/format.go`) and `json.Marshal` re-emits From dddbf6b8800fdd59a4bfde6305e234a0ee9a3f6f Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 31 Jul 2026 11:17:53 +0100 Subject: [PATCH 6/9] fix(cli): wrap numeric key runs like Go int64 in yaml sorting (review: codex) --- .../legacy/shared/legacy-go-struct-output.encoders.ts | 8 ++++++-- .../legacy-go-struct-output.encoders.unit.test.ts | 10 ++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/apps/cli/src/legacy/shared/legacy-go-struct-output.encoders.ts b/apps/cli/src/legacy/shared/legacy-go-struct-output.encoders.ts index 528c70a128..aa3dc15d8f 100644 --- a/apps/cli/src/legacy/shared/legacy-go-struct-output.encoders.ts +++ b/apps/cli/src/legacy/shared/legacy-go-struct-output.encoders.ts @@ -552,11 +552,15 @@ function yamlKeyLess(a: string, b: string): boolean { } let ai = i; let bi = i; + // Go accumulates into `int64` WITHOUT overflow checks, so 19+-digit runs + // wrap negative and sort before shorter positive runs (probed: + // `a10000000000000000000` precedes `a9000000000000000000`; + // review r3689635556). `BigInt.asIntN(64, …)` reproduces the wrap. for (; ai < ar.length && isSortDigit(ar[ai] as string); ai++) { - an = an * 10n + BigInt(((ar[ai] as string).codePointAt(0) as number) - 48); + an = BigInt.asIntN(64, an * 10n + BigInt(((ar[ai] as string).codePointAt(0) as number) - 48)); } for (; bi < br.length && isSortDigit(br[bi] as string); bi++) { - bn = bn * 10n + BigInt(((br[bi] as string).codePointAt(0) as number) - 48); + bn = BigInt.asIntN(64, bn * 10n + BigInt(((br[bi] as string).codePointAt(0) as number) - 48)); } if (an !== bn) return an < bn; if (ai !== bi) return ai < bi; diff --git a/apps/cli/src/legacy/shared/legacy-go-struct-output.encoders.unit.test.ts b/apps/cli/src/legacy/shared/legacy-go-struct-output.encoders.unit.test.ts index 6411e21261..473a993b71 100644 --- a/apps/cli/src/legacy/shared/legacy-go-struct-output.encoders.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-go-struct-output.encoders.unit.test.ts @@ -422,6 +422,16 @@ SUPABASE_ANON_KEY = "anon" expect(encodeLegacyGoYaml({ s: "1e10" }, spec)).toBe('s: "1e10"\n'); }); + it("wraps 19+-digit numeric key runs like Go's unchecked int64 accumulation", () => { + // Probed on go1.26: `keyList.Less` accumulates into `int64` without + // overflow checks, so `a10000000000000000000` wraps negative and sorts + // BEFORE `a9000000000000000000` (review r3689635556). + const spec = legacyGoStruct([["default", legacyGoAny, "Default"]]); + expect( + encodeLegacyGoYaml({ default: { a9000000000000000000: 1, a10000000000000000000: 2 } }, spec), + ).toBe("default:\n a10000000000000000000: 2\n a9000000000000000000: 1\n"); + }); + it("orders Unicode-digit map keys with yaml.v3's naive rune arithmetic", () => { // Probed on go1.26: keyList.Less finds digit runs with unicode.IsDigit // but accumulates values as `rune - '0'`, so the Arabic-Indic key `a٢` From c7968ee9825ee089f09e5fd04d5bcb44c783010b Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 31 Jul 2026 11:50:27 +0100 Subject: [PATCH 7/9] docs(cli): record the toml error-path flush bound (review: codex) --- .../shared/legacy-go-struct-output.encoders.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/apps/cli/src/legacy/shared/legacy-go-struct-output.encoders.ts b/apps/cli/src/legacy/shared/legacy-go-struct-output.encoders.ts index aa3dc15d8f..2c5f1fbb5d 100644 --- a/apps/cli/src/legacy/shared/legacy-go-struct-output.encoders.ts +++ b/apps/cli/src/legacy/shared/legacy-go-struct-output.encoders.ts @@ -1065,6 +1065,20 @@ export class LegacyGoTomlEncodeError extends Error { * * Throws {@link LegacyGoTomlEncodeError} when a populated nullable field is * present, matching Go's runtime failure. + * + * DOCUMENTED BOUND (review r3689784209): on the ERROR path only, Go's stdout + * bytes can differ. BurntSushi encodes through an internal `bufio.Writer`, so + * when more than 4 KiB has been generated before a late encode error (e.g. a + * multi-KB `MetadataXml` scalar followed by a nil array element in a + * sub-table), Go has already auto-flushed whole 4096-byte chunks to stdout — + * probed on the repo's own `utils.EncodeOutput`: a 5000-char scalar + + * `[1, nil]` default leaves EXACTLY 4096 bytes flushed (the buffered tail is + * lost — NOT the full accumulated prefix), while the same payload under 4 KiB + * leaves 0 bytes. This all-in-memory port deliberately emits nothing on + * error: reproducing Go would mean emulating bufio's flush boundaries and + * large-write bypass over BurntSushi's internal write granularity, for + * unparseable partial output on a failure path. Do not "fix" this by + * emitting the accumulated prefix — that emits MORE than Go does. */ export function encodeLegacyGoToml(value: unknown, type: LegacyGoType): string { const state = { out: "", hasWritten: false }; From 95d24f6b7500c84ab741da4670db512699d4df2d Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Mon, 3 Aug 2026 10:42:04 +0100 Subject: [PATCH 8/9] refactor(cli): extract tomlOrderedEntries to dedupe sort+partition logic (CLI-1975) Hoist the map-sort + direct/sub table partitioning shared by tomlTable and tomlArrayOfTables into tomlOrderedEntries; tomlInlineTable keeps its own copy since its position-based separator logic doesn't unify cleanly (review kanadgupta). Also notes in the Go Parity Checklist that a types.gen.go regen requires re-auditing every go-payload.ts spec. --- apps/cli/AGENTS.md | 2 ++ .../legacy-go-struct-output.encoders.ts | 35 ++++++++++++------- 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/apps/cli/AGENTS.md b/apps/cli/AGENTS.md index f2302dc902..c2d89461a2 100644 --- a/apps/cli/AGENTS.md +++ b/apps/cli/AGENTS.md @@ -276,6 +276,8 @@ When porting a Management-API-style command, verify each item before marking the 7. **PostHog telemetry payload matches Go 1:1** — see the next section. +8. **Go API type regen re-syncs `*.go-payload.ts` specs** — when `apps/cli-go/pkg/api/types.gen.go` regenerates, re-audit every `*.go-payload.ts`/inline `LegacyGoType` struct spec that mirrors it (field order, JSON/Go name pairs); nothing checks this mechanically today (CLI-1975, review kanadgupta). + --- ## Legacy Port: Telemetry Parity diff --git a/apps/cli/src/legacy/shared/legacy-go-struct-output.encoders.ts b/apps/cli/src/legacy/shared/legacy-go-struct-output.encoders.ts index 2c5f1fbb5d..04c179fcd3 100644 --- a/apps/cli/src/legacy/shared/legacy-go-struct-output.encoders.ts +++ b/apps/cli/src/legacy/shared/legacy-go-struct-output.encoders.ts @@ -1159,6 +1159,27 @@ function tomlEncode(state: TomlState, key: ReadonlyArray, value: GoValue } } +/** + * Map/struct entries in BurntSushi's write order: map entries sorted by + * {@link goStringCompare} (structs keep declaration order), then both + * partitioned into non-table ("direct") and table ("sub") groups via + * {@link tomlIsTable} — `eStruct`/`eMap` always write direct fields before + * sub-tables. + */ +function tomlOrderedEntries(value: Extract): { + direct: ReadonlyArray; + sub: ReadonlyArray; +} { + const entries = + value.k === "map" + ? [...value.entries].sort(([a], [b]) => goStringCompare(a, b)) + : value.entries; + return { + direct: entries.filter(([, v]) => !tomlIsTable(v)), + sub: entries.filter(([, v]) => tomlIsTable(v)), + }; +} + function tomlTable( state: TomlState, key: ReadonlyArray, @@ -1171,12 +1192,7 @@ function tomlTable( if (key.length > 0) { tomlWrite(state, `${tomlIndent(key)}[${key.map(tomlKeyName).join(".")}]\n`); } - const entries = - value.k === "map" - ? [...value.entries].sort(([a], [b]) => goStringCompare(a, b)) - : value.entries; - const direct = entries.filter(([, v]) => !tomlIsTable(v)); - const sub = entries.filter(([, v]) => tomlIsTable(v)); + const { direct, sub } = tomlOrderedEntries(value); for (const [name, v] of direct) { if (tomlIsNil(v)) continue; tomlEncode(state, [...key, name], v); @@ -1197,12 +1213,7 @@ function tomlArrayOfTables( tomlNewline(state); tomlWrite(state, `${tomlIndent(key)}[[${key.map(tomlKeyName).join(".")}]]\n`); if (item.k === "struct" || item.k === "map") { - const entries = - item.k === "map" - ? [...item.entries].sort(([a], [b]) => goStringCompare(a, b)) - : item.entries; - const direct = entries.filter(([, v]) => !tomlIsTable(v)); - const sub = entries.filter(([, v]) => tomlIsTable(v)); + const { direct, sub } = tomlOrderedEntries(item); for (const [name, v] of [...direct, ...sub]) { if (tomlIsNil(v)) continue; tomlEncode(state, [...key, name], v); From 8c8860845b6d6d23cd2d10fb15a6c479f2e95fd8 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Mon, 3 Aug 2026 11:30:34 +0100 Subject: [PATCH 9/9] test(cli): add types.gen.go drift check for go-payload specs (CLI-1975) Parses the real Go struct declarations out of types.gen.go and structurally compares them against the LegacyGoType specs the *.go-payload.ts files hand-declare, so a future field add/remove/reorder/rename in the Go API types desyncs a spec loudly instead of silently corrupting -o yaml/-o toml output (review kanadgupta, PR #6002). --- ...struct-output.types-gen-drift.unit.test.ts | 133 +++++++ ...egacy-go-struct-output.types-gen-parser.ts | 338 ++++++++++++++++++ ...truct-output.types-gen-parser.unit.test.ts | 195 ++++++++++ 3 files changed, 666 insertions(+) create mode 100644 apps/cli/src/legacy/shared/legacy-go-struct-output.types-gen-drift.unit.test.ts create mode 100644 apps/cli/src/legacy/shared/legacy-go-struct-output.types-gen-parser.ts create mode 100644 apps/cli/src/legacy/shared/legacy-go-struct-output.types-gen-parser.unit.test.ts diff --git a/apps/cli/src/legacy/shared/legacy-go-struct-output.types-gen-drift.unit.test.ts b/apps/cli/src/legacy/shared/legacy-go-struct-output.types-gen-drift.unit.test.ts new file mode 100644 index 0000000000..e5562d9b7a --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-go-struct-output.types-gen-drift.unit.test.ts @@ -0,0 +1,133 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +import { LEGACY_GO_BRANCH_RESPONSE } from "../commands/branches/branches.go-payload.ts"; +import { LEGACY_GO_ORGANIZATION_RESPONSE } from "../commands/orgs/orgs.go-payload.ts"; +import { LEGACY_GO_SSL_ENFORCEMENT_RESPONSE } from "../commands/ssl-enforcement/ssl-enforcement.go-payload.ts"; +import { LEGACY_GO_SSO_PROVIDER_RESPONSE } from "../commands/sso/sso.go-payload.ts"; +import type { LegacyGoType } from "./legacy-go-struct-output.encoders.ts"; +import { + compareLegacyGoTypeToParsedGoType, + parseGoStruct, +} from "./legacy-go-struct-output.types-gen-parser.ts"; + +/** + * Mechanical drift check for the `*.go-payload.ts` specs against the real Go + * structs they mirror (CLI-1975, review kanadgupta). Parses + * `apps/cli-go/pkg/api/types.gen.go` and structurally compares each entry + * below against the runtime {@link LegacyGoType} spec it corresponds to — + * field order, pointer-ness, and coarse kind must match. When + * `types.gen.go` regenerates with a field added/removed/reordered/renamed, + * this test fails instead of silently producing wrong `-o yaml`/`-o toml` + * bytes. + */ + +const TYPES_GEN_GO_PATH = fileURLToPath( + new URL("../../../../cli-go/pkg/api/types.gen.go", import.meta.url), +); + +interface GoPayloadSpecEntry { + readonly specName: string; + readonly spec: LegacyGoType; + readonly goTypeName: string; +} + +/** + * `Create`/`Update`/`DeleteProviderResponse` share `GetProviderResponse`'s + * exact anonymous shape (see the doc comment in `sso.go-payload.ts`), so + * checking `GetProviderResponse` alone covers all four. Wrapper-only specs + * (`LEGACY_GO_*_TOML_WRAPPER`, `LEGACY_GO_SSO_PROVIDERS_WRAPPER`, + * `LEGACY_GO_*_LIST`) aren't distinct Go structs — they're a + * `legacyGoTomlListWrapper`/`legacyGoSlice` around one of the entries below — + * so they're intentionally excluded. + */ +const GO_PAYLOAD_SPEC_REGISTRY: ReadonlyArray = [ + { + specName: "LEGACY_GO_BRANCH_RESPONSE", + spec: LEGACY_GO_BRANCH_RESPONSE, + goTypeName: "BranchResponse", + }, + { + specName: "LEGACY_GO_ORGANIZATION_RESPONSE", + spec: LEGACY_GO_ORGANIZATION_RESPONSE, + goTypeName: "OrganizationResponseV1", + }, + { + specName: "LEGACY_GO_SSL_ENFORCEMENT_RESPONSE", + spec: LEGACY_GO_SSL_ENFORCEMENT_RESPONSE, + goTypeName: "SslEnforcementResponse", + }, + { + specName: "LEGACY_GO_SSO_PROVIDER_RESPONSE", + spec: LEGACY_GO_SSO_PROVIDER_RESPONSE, + goTypeName: "GetProviderResponse", + }, +]; + +describe("go-payload specs vs types.gen.go (drift check)", () => { + const source = readFileSync(TYPES_GEN_GO_PATH, "utf8"); + + it.each(GO_PAYLOAD_SPEC_REGISTRY)( + "$specName matches Go's $goTypeName with zero drift", + ({ spec, goTypeName }) => { + const parsed = parseGoStruct(source, goTypeName); + expect(compareLegacyGoTypeToParsedGoType(spec, parsed)).toEqual([]); + }, + ); + + it("has teeth: reports a mismatch when a field is dropped from the real struct", () => { + // A hand-mutated copy of the real SslEnforcementResponse with `database` + // dropped from the nested `currentConfig` struct. + const mutatedSource = ` +type SslEnforcementResponse struct { + AppliedSuccessfully bool \`json:"appliedSuccessfully"\` + CurrentConfig struct { + } \`json:"currentConfig"\` +} +`; + const parsed = parseGoStruct(mutatedSource, "SslEnforcementResponse"); + const mismatches = compareLegacyGoTypeToParsedGoType( + LEGACY_GO_SSL_ENFORCEMENT_RESPONSE, + parsed, + ); + expect(mismatches).not.toEqual([]); + expect(mismatches).toContainEqual( + expect.objectContaining({ message: expect.stringContaining("database") }), + ); + }); + + it("has teeth: reports a mismatch when a field's pointer-ness flips", () => { + // A hand-mutated copy of the real BranchResponse with `GitBranch` changed + // from `*string` to `string` (no longer a pointer). + const mutatedSource = ` +type BranchResponse struct { + CreatedAt time.Time \`json:"created_at"\` + DeletionScheduledAt *time.Time \`json:"deletion_scheduled_at,omitempty"\` + GitBranch string \`json:"git_branch,omitempty"\` + Id openapi_types.UUID \`json:"id"\` + IsDefault bool \`json:"is_default"\` + LatestCheckRunId *float32 \`json:"latest_check_run_id,omitempty"\` + Name string \`json:"name"\` + NotifyUrl *string \`json:"notify_url,omitempty"\` + ParentProjectRef string \`json:"parent_project_ref"\` + Persistent bool \`json:"persistent"\` + PrNumber *int32 \`json:"pr_number,omitempty"\` + PreviewProjectStatus *string \`json:"preview_project_status,omitempty"\` + ProjectRef string \`json:"project_ref"\` + ReviewRequestedAt *time.Time \`json:"review_requested_at,omitempty"\` + Status string \`json:"status"\` + UpdatedAt time.Time \`json:"updated_at"\` + WithData bool \`json:"with_data"\` +} +`; + const parsed = parseGoStruct(mutatedSource, "BranchResponse"); + const mismatches = compareLegacyGoTypeToParsedGoType(LEGACY_GO_BRANCH_RESPONSE, parsed); + expect(mismatches).toContainEqual( + expect.objectContaining({ + path: "$.git_branch", + message: expect.stringContaining("pointer-ness mismatch"), + }), + ); + }); +}); diff --git a/apps/cli/src/legacy/shared/legacy-go-struct-output.types-gen-parser.ts b/apps/cli/src/legacy/shared/legacy-go-struct-output.types-gen-parser.ts new file mode 100644 index 0000000000..f141f15be1 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-go-struct-output.types-gen-parser.ts @@ -0,0 +1,338 @@ +/** + * A best-effort parser for the Go struct declarations in + * `apps/cli-go/pkg/api/types.gen.go`, plus a comparison against the + * {@link LegacyGoType} specs the `*.go-payload.ts` files hand-declare to + * mirror them (CLI-1975). + * + * Nothing mechanically checked that a spec still matches its Go struct — if + * `types.gen.go` regenerates (field added/removed/reordered/renamed), a spec + * could silently desync. This module parses the real struct source and walks + * it in lockstep with the runtime `LegacyGoType` tree, so drift shows up as a + * failing test instead of a byte-format bug found in the wild (review + * kanadgupta, PR #6002). + * + * This is intentionally scoped to what the 4 current `*.go-payload.ts` specs + * need: `oapi-codegen`-generated struct bodies with `json:"..."` tags, plain + * fields, pointers, slices, `map[string]...`, recursively nested anonymous + * structs, and single-level type aliases (`type X string`). It is not a + * general Go parser. + */ + +import type { LegacyGoType } from "./legacy-go-struct-output.encoders.ts"; + +type GoParsedKind = + | "string" + | "bool" + | "int" + | "float" + | "time" + | "uuid" + | "any" + | "slice" + | "map" + | "struct" + | "unknown"; + +export interface GoParsedType { + readonly pointer: boolean; + readonly kind: GoParsedKind; + /** Slice element type, or map value type (Go maps here are always `map[string]V`). */ + readonly elem?: GoParsedType; + readonly fields?: ReadonlyArray; +} + +interface GoParsedField extends GoParsedType { + /** JSON tag name — the key present in the decoded payload. */ + readonly json: string; + /** Go field name (PascalCase). */ + readonly go: string; +} + +/** + * Parse the body of `type struct { ... }` out of `source` (the + * full text of `types.gen.go`, or a small inline fixture in tests) into a + * {@link GoParsedType} tree. + */ +export function parseGoStruct(source: string, typeName: string): GoParsedType { + const body = extractStructBody(source, typeName); + return { pointer: false, kind: "struct", fields: parseStructFields(body, source) }; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function extractStructBody(source: string, typeName: string): string { + const declaration = new RegExp(`type\\s+${escapeRegExp(typeName)}\\s+struct\\s*\\{`).exec(source); + if (declaration === null) { + throw new Error(`could not find "type ${typeName} struct {" in source`); + } + return extractBalancedBody(source, declaration.index + declaration[0].length); +} + +/** Given the index right after an opening `{`, return the text up to (excluding) its matching `}`. */ +function extractBalancedBody(text: string, openBraceEnd: number): string { + let depth = 1; + let i = openBraceEnd; + for (; i < text.length && depth > 0; i++) { + const ch = text[i]; + if (ch === "{") depth++; + else if (ch === "}") depth--; + } + if (depth !== 0) { + throw new Error("unbalanced braces while extracting Go struct body"); + } + return text.slice(openBraceEnd, i - 1); +} + +/** + * Split a struct body into one chunk of source text per field, keeping a + * nested anonymous struct's full multi-line text (braces, tags, comments) + * together with its enclosing field. Blank lines and full-line comments + * between fields are dropped; comments *inside* a nested struct are kept (and + * re-filtered when that nested body is parsed recursively). + */ +function splitTopLevelFields(body: string): ReadonlyArray { + const chunks: Array = []; + let current: Array = []; + let depth = 0; + for (const rawLine of body.split("\n")) { + const trimmed = rawLine.trim(); + if (depth === 0 && (trimmed === "" || trimmed.startsWith("//"))) { + continue; + } + current.push(rawLine); + for (const ch of rawLine) { + if (ch === "{") depth++; + else if (ch === "}") depth--; + } + if (depth === 0) { + chunks.push(current.join("\n")); + current = []; + } + } + return chunks; +} + +function parseStructFields(body: string, source: string): ReadonlyArray { + return splitTopLevelFields(body).map((chunk) => parseFieldChunk(chunk, source)); +} + +/** Find the LAST backtick-quoted segment in `text` — the field's own struct tag, textually after any nested field tags. */ +function splitTypeTextAndTag(text: string): { readonly typeText: string; readonly tag: string } { + const tagPattern = /`([^`]*)`/g; + let last: RegExpExecArray | null = null; + for (let match = tagPattern.exec(text); match !== null; match = tagPattern.exec(text)) { + last = match; + } + if (last === null) { + throw new Error(`no struct tag found in field declaration: ${text}`); + } + return { typeText: text.slice(0, last.index).trim(), tag: last[1] ?? "" }; +} + +function parseFieldChunk(chunk: string, source: string): GoParsedField { + const nameMatch = /^\s*([A-Za-z_]\w*)\s+([\s\S]*)$/.exec(chunk); + if (nameMatch === null) { + throw new Error(`could not parse Go field declaration: ${chunk}`); + } + const go = nameMatch[1]; + const rest = nameMatch[2]; + if (go === undefined || rest === undefined) { + throw new Error(`could not parse Go field declaration: ${chunk}`); + } + const { typeText, tag } = splitTypeTextAndTag(rest); + const jsonMatch = /json:"([^",]*)/.exec(tag); + const json = jsonMatch?.[1] ?? ""; + return { json, go, ...classifyGoType(typeText, source) }; +} + +function classifyGoType(rawText: string, source: string): GoParsedType { + let text = rawText.trim(); + let pointer = false; + if (text.startsWith("*")) { + pointer = true; + text = text.slice(1).trim(); + } + if (text.startsWith("[]")) { + return { pointer, kind: "slice", elem: classifyGoType(text.slice(2), source) }; + } + if (text.startsWith("map[string]")) { + return { pointer, kind: "map", elem: classifyGoType(text.slice("map[string]".length), source) }; + } + if (text === "interface{}") { + return { pointer, kind: "any" }; + } + if (/^struct\s*\{/.test(text)) { + const braceIndex = text.indexOf("{"); + const body = extractBalancedBody(text, braceIndex + 1); + return { pointer, kind: "struct", fields: parseStructFields(body, source) }; + } + return { pointer, ...classifyBaseType(text, source, new Set()) }; +} + +/** + * Classify a bare Go type identifier: a known primitive, or a `type + * ` alias resolved (recursively, guarded against cycles) from the + * rest of `source`. Falls back to `"unknown"` rather than throwing — this is + * a best-effort classifier for the comparison step, not a full Go type + * checker. + */ +function classifyBaseType( + text: string, + source: string, + seen: ReadonlySet, +): { readonly kind: GoParsedKind } { + if (text === "string") return { kind: "string" }; + if (text === "bool") return { kind: "bool" }; + if (/^u?int(8|16|32|64)?$/.test(text)) return { kind: "int" }; + if (/^float(32|64)$/.test(text)) return { kind: "float" }; + if (text === "time.Time") return { kind: "time" }; + if (text === "openapi_types.UUID") return { kind: "uuid" }; + if (seen.has(text)) return { kind: "unknown" }; + + const aliasMatch = new RegExp( + `^type\\s+${escapeRegExp(text)}\\s+([A-Za-z_][\\w.]*)\\s*$`, + "m", + ).exec(source); + const basetype = aliasMatch?.[1]; + if (basetype === undefined) return { kind: "unknown" }; + return classifyBaseType(basetype.trim(), source, new Set([...seen, text])); +} + +// --------------------------------------------------------------------------- +// Comparison: LegacyGoType (runtime spec) <-> GoParsedType (parsed Go source) +// --------------------------------------------------------------------------- + +export interface GoStructDriftMismatch { + readonly path: string; + readonly message: string; +} + +/** + * Walk a {@link LegacyGoType} spec and the parser's {@link GoParsedType} in + * lockstep, returning every mismatch found (field added/removed/reordered, + * pointer-ness changed, kind changed) rather than stopping at the first one. + * An empty array means no drift detected. + */ +export function compareLegacyGoTypeToParsedGoType( + legacy: LegacyGoType, + parsed: GoParsedType, + path = "$", +): ReadonlyArray { + const mismatches: Array = []; + compareType(legacy, parsed, path, mismatches); + return mismatches; +} + +/** `ptr`/`nullable` both mean "Go pointer" for this comparison (CLI-1975 design doc). */ +function unwrapLegacyPointer(type: LegacyGoType): { + readonly pointer: boolean; + readonly inner: LegacyGoType; +} { + if (type.kind === "ptr" || type.kind === "nullable") { + return { pointer: true, inner: type.elem }; + } + return { pointer: false, inner: type }; +} + +function legacyKindToParsedKind(kind: LegacyGoType["kind"]): GoParsedKind | undefined { + switch (kind) { + case "string": + case "bool": + case "int": + case "float": + case "time": + case "uuid": + case "any": + case "slice": + case "map": + case "struct": + return kind; + case "ptr": + case "nullable": + return undefined; + } +} + +function compareType( + legacy: LegacyGoType, + parsed: GoParsedType, + path: string, + mismatches: Array, +): void { + const { pointer: legacyPointer, inner } = unwrapLegacyPointer(legacy); + if (legacyPointer !== parsed.pointer) { + mismatches.push({ + path, + message: `pointer-ness mismatch: spec says pointer=${legacyPointer}, Go source says pointer=${parsed.pointer}`, + }); + } + + if (parsed.kind === "unknown") { + console.warn(`[types.gen.go drift check] skipping ${path}: could not classify the Go type`); + return; + } + + const expectedKind = legacyKindToParsedKind(inner.kind); + if (expectedKind === undefined) { + mismatches.push({ path, message: `unexpected doubly-wrapped pointer/nullable at ${path}` }); + return; + } + if (expectedKind !== parsed.kind) { + mismatches.push({ + path, + message: `kind mismatch: spec says "${expectedKind}", Go source says "${parsed.kind}"`, + }); + return; + } + + if (inner.kind === "slice" && parsed.kind === "slice") { + if (parsed.elem === undefined) { + mismatches.push({ path, message: "Go source is missing a slice element type" }); + return; + } + compareType(inner.elem, parsed.elem, `${path}[]`, mismatches); + } else if (inner.kind === "map" && parsed.kind === "map") { + if (parsed.elem === undefined) { + mismatches.push({ path, message: "Go source is missing a map value type" }); + return; + } + compareType(inner.value, parsed.elem, `${path}[]`, mismatches); + } else if (inner.kind === "struct" && parsed.kind === "struct") { + compareStructFields(inner.fields, parsed.fields ?? [], path, mismatches); + } +} + +function compareStructFields( + legacyFields: ReadonlyArray<{ readonly json: string; readonly type: LegacyGoType }>, + parsedFields: ReadonlyArray, + path: string, + mismatches: Array, +): void { + const legacyKeys = legacyFields.map((field) => field.json); + const parsedKeys = parsedFields.map((field) => field.json); + if (legacyKeys.join(",") !== parsedKeys.join(",")) { + mismatches.push({ + path, + message: `field set/order mismatch: spec has [${legacyKeys.join(", ")}], Go source has [${parsedKeys.join(", ")}]`, + }); + } + + const parsedByJson = new Map(parsedFields.map((field) => [field.json, field] as const)); + for (const legacyField of legacyFields) { + const parsedField = parsedByJson.get(legacyField.json); + if (parsedField === undefined) { + mismatches.push({ path, message: `field "${legacyField.json}" removed from Go struct` }); + continue; + } + compareType(legacyField.type, parsedField, `${path}.${legacyField.json}`, mismatches); + } + + const legacyKeySet = new Set(legacyKeys); + for (const parsedField of parsedFields) { + if (!legacyKeySet.has(parsedField.json)) { + mismatches.push({ path, message: `field "${parsedField.json}" added to Go struct` }); + } + } +} diff --git a/apps/cli/src/legacy/shared/legacy-go-struct-output.types-gen-parser.unit.test.ts b/apps/cli/src/legacy/shared/legacy-go-struct-output.types-gen-parser.unit.test.ts new file mode 100644 index 0000000000..2028d71150 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-go-struct-output.types-gen-parser.unit.test.ts @@ -0,0 +1,195 @@ +import { describe, expect, it } from "vitest"; + +import { parseGoStruct } from "./legacy-go-struct-output.types-gen-parser.ts"; + +describe("parseGoStruct", () => { + it("parses plain scalar fields", () => { + const source = ` +type Simple struct { + Name string \`json:"name"\` + Count int32 \`json:"count"\` + Ready bool \`json:"ready"\` + Score float64 \`json:"score"\` + CreatedAt time.Time \`json:"created_at"\` + Id openapi_types.UUID \`json:"id"\` +} +`; + expect(parseGoStruct(source, "Simple")).toEqual({ + pointer: false, + kind: "struct", + fields: [ + { json: "name", go: "Name", pointer: false, kind: "string" }, + { json: "count", go: "Count", pointer: false, kind: "int" }, + { json: "ready", go: "Ready", pointer: false, kind: "bool" }, + { json: "score", go: "Score", pointer: false, kind: "float" }, + { json: "created_at", go: "CreatedAt", pointer: false, kind: "time" }, + { json: "id", go: "Id", pointer: false, kind: "uuid" }, + ], + }); + }); + + it("parses pointer fields", () => { + const source = ` +type WithPointers struct { + Name *string \`json:"name,omitempty"\` + Count *int32 \`json:"count,omitempty"\` +} +`; + expect(parseGoStruct(source, "WithPointers")).toEqual({ + pointer: false, + kind: "struct", + fields: [ + { json: "name", go: "Name", pointer: true, kind: "string" }, + { json: "count", go: "Count", pointer: true, kind: "int" }, + ], + }); + }); + + it("parses slice fields, including a pointer to a slice", () => { + const source = ` +type WithSlice struct { + Tags []string \`json:"tags"\` + Items *[]string \`json:"items,omitempty"\` +} +`; + expect(parseGoStruct(source, "WithSlice")).toEqual({ + pointer: false, + kind: "struct", + fields: [ + { + json: "tags", + go: "Tags", + pointer: false, + kind: "slice", + elem: { pointer: false, kind: "string" }, + }, + { + json: "items", + go: "Items", + pointer: true, + kind: "slice", + elem: { pointer: false, kind: "string" }, + }, + ], + }); + }); + + it("parses map[string]... fields", () => { + const source = ` +type WithMap struct { + Labels map[string]string \`json:"labels"\` +} +`; + expect(parseGoStruct(source, "WithMap")).toEqual({ + pointer: false, + kind: "struct", + fields: [ + { + json: "labels", + go: "Labels", + pointer: false, + kind: "map", + elem: { pointer: false, kind: "string" }, + }, + ], + }); + }); + + it("parses recursively nested anonymous struct fields (saml/attribute_mapping/keys shape)", () => { + const source = ` +type WithNested struct { + Saml *struct { + AttributeMapping *struct { + Keys map[string]struct { + Name *string \`json:"name,omitempty"\` + } \`json:"keys"\` + } \`json:"attribute_mapping,omitempty"\` + EntityId string \`json:"entity_id"\` + } \`json:"saml,omitempty"\` +} +`; + expect(parseGoStruct(source, "WithNested")).toEqual({ + pointer: false, + kind: "struct", + fields: [ + { + json: "saml", + go: "Saml", + pointer: true, + kind: "struct", + fields: [ + { + json: "attribute_mapping", + go: "AttributeMapping", + pointer: true, + kind: "struct", + fields: [ + { + json: "keys", + go: "Keys", + pointer: false, + kind: "map", + elem: { + pointer: false, + kind: "struct", + fields: [{ json: "name", go: "Name", pointer: true, kind: "string" }], + }, + }, + ], + }, + { json: "entity_id", go: "EntityId", pointer: false, kind: "string" }, + ], + }, + ], + }); + }); + + it("resolves a single-level enum-alias field (type X string) to its base kind", () => { + const source = ` +type WithAlias struct { + Status BranchResponseStatus \`json:"status"\` +} + +// BranchResponseStatus This field is deprecated. List action runs to get branch status instead. +type BranchResponseStatus string +`; + expect(parseGoStruct(source, "WithAlias")).toEqual({ + pointer: false, + kind: "struct", + fields: [{ json: "status", go: "Status", pointer: false, kind: "string" }], + }); + }); + + it("skips a full-line // comment (including a Deprecated: line with a backtick-quoted phrase) before a field", () => { + const source = ` +type WithComment struct { + Name string \`json:"name"\` + + // LatestCheckRunId This field is deprecated and will not be populated. + // Deprecated: this property has been marked as deprecated upstream, but no \`x-deprecated-reason\` was set + LatestCheckRunId *float32 \`json:"latest_check_run_id,omitempty"\` +} +`; + expect(parseGoStruct(source, "WithComment")).toEqual({ + pointer: false, + kind: "struct", + fields: [ + { json: "name", go: "Name", pointer: false, kind: "string" }, + { json: "latest_check_run_id", go: "LatestCheckRunId", pointer: true, kind: "float" }, + ], + }); + }); + + it('falls back to kind "unknown" for an unresolvable identifier instead of throwing', () => { + const source = ` +type WithUnknown struct { + Email openapi_types.Email \`json:"email"\` +} +`; + expect(parseGoStruct(source, "WithUnknown")).toEqual({ + pointer: false, + kind: "struct", + fields: [{ json: "email", go: "Email", pointer: false, kind: "unknown" }], + }); + }); +});