diff --git a/packages/opencode/scripts/diff-sdk-types.sh b/packages/opencode/scripts/diff-sdk-types.sh new file mode 100755 index 0000000000..85aa9d4e70 --- /dev/null +++ b/packages/opencode/scripts/diff-sdk-types.sh @@ -0,0 +1,51 @@ +#!/bin/bash +# Compare SDK types generated from Hono vs HttpApi specs. +# Sorts types alphabetically so only meaningful body differences show. +# +# Usage: ./scripts/diff-sdk-types.sh # full diff +# ./scripts/diff-sdk-types.sh --stat # summary only +set -euo pipefail + +DIR="$(cd "$(dirname "$0")/.." && pwd)" +SDK="$(cd "$DIR/../sdk/js" && pwd)" + +normalize() { + python3 -c " +import re, sys +content = open(sys.argv[1]).read() +blocks = re.split(r'(?=^export (?:type|function|const) )', content, flags=re.MULTILINE) +header, body = blocks[0], blocks[1:] +body.sort(key=lambda b: m.group(1) if (m := re.match(r'export \w+ (\w+)', b)) else '') +sys.stdout.write(header + ''.join(body)) +" "$1" +} + +echo "Generating Hono SDK..." >&2 +(cd "$SDK" && bun run script/build.ts >/dev/null 2>&1) +normalize "$SDK/src/v2/gen/types.gen.ts" > /tmp/sdk-types-hono.ts +git -C "$SDK" checkout -- src/ 2>/dev/null + +echo "Generating HttpApi SDK..." >&2 +(cd "$SDK" && OPENCODE_SDK_OPENAPI=httpapi bun run script/build.ts >/dev/null 2>&1) +normalize "$SDK/src/v2/gen/types.gen.ts" > /tmp/sdk-types-httpapi.ts +git -C "$SDK" checkout -- src/ 2>/dev/null + +echo "" >&2 +if [[ "${1:-}" == "--stat" ]]; then + honly=$(diff /tmp/sdk-types-hono.ts /tmp/sdk-types-httpapi.ts | grep -c '^< export type' || true) + aonly=$(diff /tmp/sdk-types-hono.ts /tmp/sdk-types-httpapi.ts | grep -c '^> export type' || true) + total=$(diff /tmp/sdk-types-hono.ts /tmp/sdk-types-httpapi.ts | wc -l | tr -d ' ') + echo "Hono-only: $honly types HttpApi-only: $aonly types Diff lines: $total" + echo "" + if [[ $honly -gt 0 ]]; then + echo "=== Hono-only types ===" + diff /tmp/sdk-types-hono.ts /tmp/sdk-types-httpapi.ts | grep '^< export type' | sed 's/< export type / /' | sed 's/ .*//' + echo "" + fi + if [[ $aonly -gt 0 ]]; then + echo "=== HttpApi-only types ===" + diff /tmp/sdk-types-hono.ts /tmp/sdk-types-httpapi.ts | grep '^> export type' | sed 's/> export type / /' | sed 's/ .*//' + fi +else + diff /tmp/sdk-types-hono.ts /tmp/sdk-types-httpapi.ts || true +fi diff --git a/packages/opencode/src/server/routes/instance/httpapi/public.ts b/packages/opencode/src/server/routes/instance/httpapi/public.ts index a0e6a72236..be42a7ce89 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/public.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/public.ts @@ -58,10 +58,6 @@ const InstanceQueryParameters = [ }, ] satisfies OpenApiParameter[] -// These refs already match the legacy SDK's expected body shape. Expanding all -// other refs lets us strip Effect's `null` from optional fields in one place. -const LegacyBodyRefParameters = new Set(["Auth", "Config", "Part", "WorktreeRemoveInput", "WorktreeResetInput"]) - // Query schemas describe decoded Effect values, but the generated SDK needs the // public call shape. These keep SDK callers passing numbers/booleans while the // server still decodes string query params at runtime. @@ -82,6 +78,13 @@ function matchLegacyOpenApi(input: Record) { // actual schema from any parent union that references them. fixSelfReferencingComponents(spec) + // Effect's Schema.optional emits `anyOf: [T, {type:"null"}]` in OpenAPI, + // but the legacy SDK expected plain `T` for optional fields. Strip null + // from all component schemas so both request and response types match. + for (const [name, schema] of Object.entries(spec.components?.schemas ?? {})) { + spec.components!.schemas![name] = stripOptionalNull(structuredClone(schema)) + } + for (const [path, item] of Object.entries(spec.paths ?? {})) { const isInstanceRoute = !path.startsWith("/global/") && !path.startsWith("/auth/") for (const method of ["get", "post", "put", "delete", "patch"] as const) { @@ -91,23 +94,12 @@ function matchLegacyOpenApi(input: Record) { // Hono's generated OpenAPI never marked request bodies as required. Keep // that SDK surface stable during the HttpApi migration. delete operation.requestBody.required - // Effect's Schema.optional emits `anyOf: [T, {type:"null"}]` in OpenAPI, - // but the legacy SDK expected plain `T` for optional fields. Expand - // non-legacy $refs and strip the null arms so the SDK surface is stable. - for (const media of Object.values(operation.requestBody.content ?? {})) { - const ref = media.schema?.$ref?.replace("#/components/schemas/", "") - if (ref && LegacyBodyRefParameters.has(ref)) continue - if (ref && spec.components?.schemas?.[ref]) { - media.schema = stripOptionalNull(structuredClone(spec.components.schemas[ref])) - continue - } - if (media.schema) media.schema = stripOptionalNull(media.schema) - } if (path === "/experimental/workspace" && method === "post") { // Workspace creation fields `branch` and `extra` are Schema.NullOr — // genuinely nullable, not just optional. Re-add the null that the - // global strip above removed. - const properties = operation.requestBody.content?.["application/json"]?.schema?.properties + // component-level strip above removed. + const ref = operation.requestBody.content?.["application/json"]?.schema?.$ref?.replace("#/components/schemas/", "") + const properties = ref ? spec.components?.schemas?.[ref]?.properties : operation.requestBody.content?.["application/json"]?.schema?.properties if (properties?.branch) properties.branch = { anyOf: [properties.branch, { type: "null" }] } if (properties?.extra) properties.extra = { anyOf: [properties.extra, { type: "null" }] } }