| 1 | #!/usr/bin/env node |
| 2 | |
| 3 | import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; |
| 4 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 5 | import { make as makeJsonSchemaGenerator } from "@effect/openapi-generator/JsonSchemaGenerator"; |
| 6 | import * as Effect from "effect/Effect"; |
| 7 | import * as FileSystem from "effect/FileSystem"; |
| 8 | import * as Layer from "effect/Layer"; |
| 9 | import * as Logger from "effect/Logger"; |
| 10 | import * as Path from "effect/Path"; |
| 11 | import * as Schema from "effect/Schema"; |
| 12 | import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; |
| 13 | import { ChildProcess, ChildProcessSpawner } from "effect/process"; |
| 14 | |
| 15 | const UPSTREAM_REF = "687a119f0fcaace47e1f1abcc77cec6c813fd6da"; |
| 16 | const USER_AGENT = "effect-codex-app-server-generator"; |
| 17 | const GITHUB_API_BASE = |
| 18 | "https://api.github.com/repos/openai/codex/contents/codex-rs/app-server-protocol"; |
| 19 | |
| 20 | const GithubContentEntries = Schema.Array( |
| 21 | Schema.Struct({ |
| 22 | name: Schema.String, |
| 23 | path: Schema.String, |
| 24 | download_url: Schema.NullOr(Schema.String), |
| 25 | type: Schema.String, |
| 26 | }), |
| 27 | ); |
| 28 | type GithubContentEntry = (typeof GithubContentEntries.Type)[number]; |
| 29 | |
| 30 | const JsonSchemaDocument = Schema.StructWithRest( |
| 31 | Schema.Struct({ |
| 32 | definitions: Schema.optionalKey(Schema.Record(Schema.String, Schema.Json)), |
| 33 | }), |
| 34 | [Schema.Record(Schema.String, Schema.Json)], |
| 35 | ); |
| 36 | const decodeGithubContentEntries = Schema.decodeEffect(Schema.fromJsonString(GithubContentEntries)); |
| 37 | const decodeJsonSchemaDocument = Schema.decodeEffect(Schema.fromJsonString(JsonSchemaDocument)); |
| 38 | |
| 39 | interface GeneratedPaths { |
| 40 | readonly generatedDir: string; |
| 41 | readonly schemaOutputPath: string; |
| 42 | readonly metaOutputPath: string; |
| 43 | readonly namespacesOutputPath: string; |
| 44 | } |
| 45 | |
| 46 | interface MethodEntry { |
| 47 | readonly method: string; |
| 48 | readonly paramsType?: string; |
| 49 | } |
| 50 | |
| 51 | interface JsonSchemaFile { |
| 52 | readonly namespace?: string; |
| 53 | readonly exportName: string; |
| 54 | readonly fileName: string; |
| 55 | readonly downloadUrl: string; |
| 56 | readonly qualifiedName: string; |
| 57 | } |
| 58 | |
| 59 | class GeneratorError extends Schema.TaggedError<GeneratorError>()("GeneratorError", { |
| 60 | detail: Schema.String, |
| 61 | cause: Schema.optional(Schema.Defect()), |
| 62 | }) { |
| 63 | override get message() { |
| 64 | return this.detail; |
| 65 | } |
| 66 | } |
| 67 | |
| 68 | const ManualSchemas: Record<string, Schema.Json> = { |
| 69 | GetAuthStatusParams: { |
| 70 | type: "object", |
| 71 | title: "GetAuthStatusParams", |
| 72 | properties: { |
| 73 | includeToken: { |
| 74 | anyOf: [{ type: "boolean" }, { type: "null" }], |
| 75 | }, |
| 76 | refreshToken: { |
| 77 | anyOf: [{ type: "boolean" }, { type: "null" }], |
| 78 | }, |
| 79 | }, |
| 80 | }, |
| 81 | GetConversationSummaryParams: { |
| 82 | title: "GetConversationSummaryParams", |
| 83 | oneOf: [ |
| 84 | { |
| 85 | type: "object", |
| 86 | properties: { |
| 87 | rolloutPath: { type: "string" }, |
| 88 | }, |
| 89 | required: ["rolloutPath"], |
| 90 | }, |
| 91 | { |
| 92 | type: "object", |
| 93 | properties: { |
| 94 | conversationId: { type: "string" }, |
| 95 | }, |
| 96 | required: ["conversationId"], |
| 97 | }, |
| 98 | ], |
| 99 | }, |
| 100 | GetConversationSummaryResponse: { |
| 101 | type: "object", |
| 102 | title: "GetConversationSummaryResponse", |
| 103 | properties: { |
| 104 | summary: {}, |
| 105 | }, |
| 106 | required: ["summary"], |
| 107 | }, |
| 108 | GitDiffToRemoteParams: { |
| 109 | type: "object", |
| 110 | title: "GitDiffToRemoteParams", |
| 111 | properties: { |
| 112 | cwd: { type: "string" }, |
| 113 | }, |
| 114 | required: ["cwd"], |
| 115 | }, |
| 116 | GitDiffToRemoteResponse: { |
| 117 | type: "object", |
| 118 | title: "GitDiffToRemoteResponse", |
| 119 | properties: { |
| 120 | sha: { type: "string" }, |
| 121 | diff: { type: "string" }, |
| 122 | }, |
| 123 | required: ["sha", "diff"], |
| 124 | }, |
| 125 | GetAuthStatusResponse: { |
| 126 | type: "object", |
| 127 | title: "GetAuthStatusResponse", |
| 128 | properties: { |
| 129 | authMethod: { |
| 130 | anyOf: [{}, { type: "null" }], |
| 131 | }, |
| 132 | authToken: { |
| 133 | anyOf: [{ type: "string" }, { type: "null" }], |
| 134 | }, |
| 135 | requiresOpenaiAuth: { |
| 136 | anyOf: [{ type: "boolean" }, { type: "null" }], |
| 137 | }, |
| 138 | }, |
| 139 | required: ["authMethod", "authToken", "requiresOpenaiAuth"], |
| 140 | }, |
| 141 | }; |
| 142 | |
| 143 | // Codex adds plan slugs between our protocol refreshes (0.159 added `promax`). |
| 144 | // T3 Code only uses the plan for labels, so an unknown slug must not fail the |
| 145 | // whole `account/read` decode and take the provider down with it. |
| 146 | const DefinitionOverrides: Record<string, Schema.Json> = { |
| 147 | PlanType: { type: "string" }, |
| 148 | }; |
| 149 | |
| 150 | const getGeneratedPaths = Effect.fn("getGeneratedPaths")(function* () { |
| 151 | const path = yield* Path.Path; |
| 152 | const generatedDir = path.join(import.meta.dirname, "..", "src", "_generated"); |
| 153 | return { |
| 154 | generatedDir, |
| 155 | schemaOutputPath: path.join(generatedDir, "schema.gen.ts"), |
| 156 | metaOutputPath: path.join(generatedDir, "meta.gen.ts"), |
| 157 | namespacesOutputPath: path.join(generatedDir, "namespaces.gen.ts"), |
| 158 | } satisfies GeneratedPaths; |
| 159 | }); |
| 160 | |
| 161 | const ensureGeneratedDir = Effect.fn("ensureGeneratedDir")(function* () { |
| 162 | const fs = yield* FileSystem.FileSystem; |
| 163 | const { generatedDir } = yield* getGeneratedPaths(); |
| 164 | yield* fs.makeDirectory(generatedDir, { recursive: true }); |
| 165 | }); |
| 166 | |
| 167 | const fetchText = Effect.fn("fetchText")(function* (url: string) { |
| 168 | // Unauthenticated GitHub API calls are capped at 60/hour; set GITHUB_TOKEN to lift that. |
| 169 | const token = process.env.GITHUB_TOKEN; |
| 170 | return yield* HttpClientRequest.get(url).pipe( |
| 171 | HttpClientRequest.setHeader("user-agent", USER_AGENT), |
| 172 | token && url.startsWith("https://api.github.com/") |
| 173 | ? HttpClientRequest.bearerToken(token) |
| 174 | : (request) => request, |
| 175 | HttpClient.execute, |
| 176 | Effect.flatMap(HttpClientResponse.filterStatusOk), |
| 177 | Effect.flatMap((okResponse) => okResponse.text), |
| 178 | Effect.mapError( |
| 179 | (cause) => |
| 180 | new GeneratorError({ |
| 181 | detail: `Failed to fetch ${url}`, |
| 182 | cause, |
| 183 | }), |
| 184 | ), |
| 185 | ); |
| 186 | }); |
| 187 | |
| 188 | const fetchDirectoryEntries = Effect.fn("fetchDirectoryEntries")(function* (path: string) { |
| 189 | const raw = yield* fetchText(`${GITHUB_API_BASE}/${path}?ref=${UPSTREAM_REF}`); |
| 190 | return yield* decodeGithubContentEntries(raw); |
| 191 | }); |
| 192 | |
| 193 | function collectSchemaEntries( |
| 194 | chunk: string, |
| 195 | ): ReadonlyArray<{ readonly name: string; readonly code: string }> { |
| 196 | const lines = chunk |
| 197 | .split("\n") |
| 198 | .map((line) => line.trim()) |
| 199 | .filter((line) => line.length > 0 && !line.startsWith("//")); |
| 200 | const entries: Array<{ name: string; code: string }> = []; |
| 201 | |
| 202 | for (let index = 0; index < lines.length; index += 1) { |
| 203 | const typeLine = lines[index]; |
| 204 | if (!typeLine?.startsWith("export type ")) { |
| 205 | continue; |
| 206 | } |
| 207 | |
| 208 | const constLine = lines[index + 1]; |
| 209 | if (!constLine?.startsWith("export const ")) { |
| 210 | throw new Error(`Malformed generator output near: ${typeLine}`); |
| 211 | } |
| 212 | |
| 213 | const match = /^export type ([A-Za-z0-9_]+)/.exec(typeLine); |
| 214 | if (!match?.[1]) { |
| 215 | throw new Error(`Could not extract schema name from: ${typeLine}`); |
| 216 | } |
| 217 | |
| 218 | entries.push({ |
| 219 | name: match[1], |
| 220 | code: `${typeLine}\n${constLine}`, |
| 221 | }); |
| 222 | index += 1; |
| 223 | } |
| 224 | |
| 225 | return entries; |
| 226 | } |
| 227 | |
| 228 | function normalizeNullableTypes(value: Schema.Json): Schema.Json { |
| 229 | if (Array.isArray(value)) { |
| 230 | return value.map(normalizeNullableTypes); |
| 231 | } |
| 232 | if (value === null || typeof value !== "object") { |
| 233 | return value; |
| 234 | } |
| 235 | |
| 236 | const normalizedEntries = Object.entries(value).map(([key, child]) => [ |
| 237 | key, |
| 238 | normalizeNullableTypes(child), |
| 239 | ]); |
| 240 | const normalizedObject = Object.fromEntries(normalizedEntries) as Record<string, Schema.Json>; |
| 241 | const typeValue = normalizedObject.type; |
| 242 | |
| 243 | if (!Array.isArray(typeValue)) { |
| 244 | return normalizedObject; |
| 245 | } |
| 246 | |
| 247 | const normalizedTypes = typeValue.filter((entry): entry is string => typeof entry === "string"); |
| 248 | if (normalizedTypes.length !== typeValue.length || !normalizedTypes.includes("null")) { |
| 249 | return normalizedObject; |
| 250 | } |
| 251 | |
| 252 | const nonNullTypes = normalizedTypes.filter((entry) => entry !== "null"); |
| 253 | if (nonNullTypes.length !== 1) { |
| 254 | return normalizedObject; |
| 255 | } |
| 256 | const nonNullType = nonNullTypes[0]!; |
| 257 | |
| 258 | const nextObject: Record<string, Schema.Json> = {}; |
| 259 | for (const [key, child] of Object.entries(normalizedObject)) { |
| 260 | if (key !== "type") { |
| 261 | nextObject[key] = child; |
| 262 | } |
| 263 | } |
| 264 | |
| 265 | return { |
| 266 | anyOf: [ |
| 267 | { |
| 268 | ...nextObject, |
| 269 | type: nonNullType, |
| 270 | }, |
| 271 | { type: "null" }, |
| 272 | ], |
| 273 | }; |
| 274 | } |
| 275 | |
| 276 | function stripNullDefaults(value: Schema.Json): Schema.Json { |
| 277 | if (Array.isArray(value)) { |
| 278 | return value.map(stripNullDefaults); |
| 279 | } |
| 280 | if (value === null || typeof value !== "object") { |
| 281 | return value; |
| 282 | } |
| 283 | |
| 284 | return Object.fromEntries( |
| 285 | Object.entries(value) |
| 286 | .filter(([key, child]) => !(key === "default" && child === null)) |
| 287 | .map(([key, child]) => [key, stripNullDefaults(child)]), |
| 288 | ) as Schema.Json; |
| 289 | } |
| 290 | |
| 291 | type JsonSchemaNode = { readonly [key: string]: Schema.Json }; |
| 292 | |
| 293 | function isJsonSchemaNode(value: Schema.Json | undefined): value is JsonSchemaNode { |
| 294 | return value !== null && typeof value === "object" && !Array.isArray(value); |
| 295 | } |
| 296 | |
| 297 | // Adapts Codex's JSON Schema to Effect's importer, visiting only schema |
| 298 | // positions so a field literally named "properties" is left alone: |
| 299 | // - Effect imports an object without additionalProperties as an open record. |
| 300 | // Codex omits it for plain structs, so close objects that list properties. |
| 301 | // - Effect cannot intersect shared object fields with object alternatives, |
| 302 | // which Codex uses for "one of these keys plus shared fields" |
| 303 | // (image_url | file_id). Fold the shared fields into each alternative. |
| 304 | function adaptSchemaForEffect(value: Schema.Json): Schema.Json { |
| 305 | if (!isJsonSchemaNode(value)) { |
| 306 | return value; |
| 307 | } |
| 308 | const node: Record<string, Schema.Json> = { ...value }; |
| 309 | for (const key of ["items", "additionalProperties"]) { |
| 310 | const child = node[key]; |
| 311 | if (isJsonSchemaNode(child)) node[key] = adaptSchemaForEffect(child); |
| 312 | } |
| 313 | for (const key of ["anyOf", "oneOf", "allOf"]) { |
| 314 | const child = node[key]; |
| 315 | if (Array.isArray(child)) node[key] = child.map(adaptSchemaForEffect); |
| 316 | } |
| 317 | for (const key of ["properties", "definitions"]) { |
| 318 | const child = node[key]; |
| 319 | if (isJsonSchemaNode(child)) { |
| 320 | node[key] = Object.fromEntries( |
| 321 | Object.entries(child).map(([name, schema]) => [name, adaptSchemaForEffect(schema)]), |
| 322 | ); |
| 323 | } |
| 324 | } |
| 325 | |
| 326 | const { properties } = node; |
| 327 | if ( |
| 328 | isJsonSchemaNode(properties) && |
| 329 | Object.keys(properties).length > 0 && |
| 330 | !("additionalProperties" in node) |
| 331 | ) { |
| 332 | node.additionalProperties = false; |
| 333 | } |
| 334 | |
| 335 | const alternativesKey = "anyOf" in node ? "anyOf" : "oneOf"; |
| 336 | const alternatives = node[alternativesKey]; |
| 337 | if ( |
| 338 | !isJsonSchemaNode(properties) || |
| 339 | !Array.isArray(alternatives) || |
| 340 | !alternatives.every( |
| 341 | (alternative) => isJsonSchemaNode(alternative) && alternative.type === "object", |
| 342 | ) |
| 343 | ) { |
| 344 | return node; |
| 345 | } |
| 346 | const { |
| 347 | properties: _shared, |
| 348 | required, |
| 349 | type: _type, |
| 350 | additionalProperties: _closed, |
| 351 | ...rest |
| 352 | } = node; |
| 353 | const sharedRequired = Array.isArray(required) ? required : []; |
| 354 | return { |
| 355 | ...rest, |
| 356 | [alternativesKey]: alternatives.map((alternative) => { |
| 357 | const branch = alternative as JsonSchemaNode; |
| 358 | const branchProperties = isJsonSchemaNode(branch.properties) ? branch.properties : {}; |
| 359 | const branchRequired = Array.isArray(branch.required) ? branch.required : []; |
| 360 | return { |
| 361 | ...branch, |
| 362 | properties: { ...properties, ...branchProperties }, |
| 363 | required: [...new Set([...sharedRequired, ...branchRequired])], |
| 364 | }; |
| 365 | }), |
| 366 | }; |
| 367 | } |
| 368 | |
| 369 | function toPascalCaseMethod(method: string) { |
| 370 | return method |
| 371 | .split("/") |
| 372 | .flatMap((segment) => segment.split(/(?=[A-Z])/)) |
| 373 | .flatMap((segment) => segment.split(/[-_]/)) |
| 374 | .filter(Boolean) |
| 375 | .map((segment) => segment[0]!.toUpperCase() + segment.slice(1)) |
| 376 | .join(""); |
| 377 | } |
| 378 | |
| 379 | function parseRequestEntries(fileContents: string): ReadonlyArray<MethodEntry> { |
| 380 | // Optional params render as `params?: Foo | undefined`; their JSON schema is `NullableFoo`. |
| 381 | const entryPattern = /\{\s*"method":\s*"([^"]+)",\s*id:\s*RequestId,\s*params(\??):\s*([^,}|]+)/g; |
| 382 | const entries: Array<MethodEntry> = []; |
| 383 | let match: RegExpExecArray | null; |
| 384 | while ((match = entryPattern.exec(fileContents)) !== null) { |
| 385 | entries.push({ |
| 386 | method: match[1]!, |
| 387 | paramsType: `${match[2] ? "Nullable" : ""}${match[3]!.trim()}`, |
| 388 | }); |
| 389 | } |
| 390 | return entries; |
| 391 | } |
| 392 | |
| 393 | function parseNotificationEntries(fileContents: string): ReadonlyArray<MethodEntry> { |
| 394 | const entryPattern = /\{\s*"method":\s*"([^"]+)"(?:,\s*"params":\s*([^ }]+))?\s*\}/g; |
| 395 | const entries: Array<MethodEntry> = []; |
| 396 | let match: RegExpExecArray | null; |
| 397 | while ((match = entryPattern.exec(fileContents)) !== null) { |
| 398 | entries.push({ |
| 399 | method: match[1]!, |
| 400 | ...(match[2] ? { paramsType: match[2].trim() } : {}), |
| 401 | }); |
| 402 | } |
| 403 | return entries; |
| 404 | } |
| 405 | |
| 406 | function resolveSchemaTypeName( |
| 407 | rawTypeName: string, |
| 408 | generatedSchemaNames: ReadonlySet<string>, |
| 409 | ): string { |
| 410 | if (rawTypeName === "undefined") { |
| 411 | return "undefined"; |
| 412 | } |
| 413 | |
| 414 | const candidates = [ |
| 415 | rawTypeName, |
| 416 | `V2${rawTypeName}`, |
| 417 | `V1${rawTypeName}`, |
| 418 | `SerdeJson${rawTypeName}`, |
| 419 | ]; |
| 420 | for (const candidate of candidates) { |
| 421 | if (generatedSchemaNames.has(candidate)) { |
| 422 | return candidate; |
| 423 | } |
| 424 | } |
| 425 | |
| 426 | throw new Error(`Unable to resolve schema type name: ${rawTypeName}`); |
| 427 | } |
| 428 | |
| 429 | function resolveResponseTypeName( |
| 430 | method: string, |
| 431 | paramsType: string | undefined, |
| 432 | generatedSchemaNames: ReadonlySet<string>, |
| 433 | ): string { |
| 434 | const overrides: Record<string, string> = { |
| 435 | "account/gatewayOAuth/cancel": "GatewayOAuthCancelResponse", |
| 436 | "account/gatewayOAuth/login": "GatewayOAuthLoginResponse", |
| 437 | "account/gatewayOAuth/read": "GatewayOAuthReadResponse", |
| 438 | "account/logout": "LogoutAccountResponse", |
| 439 | "account/rateLimits/read": "GetAccountRateLimitsResponse", |
| 440 | "account/usage/read": "GetAccountTokenUsageResponse", |
| 441 | "account/workspaceMessages/read": "GetWorkspaceMessagesResponse", |
| 442 | "config/batchWrite": "ConfigWriteResponse", |
| 443 | "config/mcpServer/reload": "McpServerRefreshResponse", |
| 444 | "config/value/write": "ConfigWriteResponse", |
| 445 | "configRequirements/read": "ConfigRequirementsReadResponse", |
| 446 | "externalAgentConfig/import/readHistories": "ExternalAgentConfigImportHistoriesReadResponse", |
| 447 | }; |
| 448 | |
| 449 | const override = overrides[method]; |
| 450 | if (override) { |
| 451 | return resolveSchemaTypeName(override, generatedSchemaNames); |
| 452 | } |
| 453 | |
| 454 | if (paramsType && paramsType !== "undefined") { |
| 455 | const fromParams = paramsType.replace(/Params$/, "Response"); |
| 456 | try { |
| 457 | return resolveSchemaTypeName(fromParams, generatedSchemaNames); |
| 458 | } catch { |
| 459 | // Fall through to method-based lookup. |
| 460 | } |
| 461 | } |
| 462 | |
| 463 | return resolveSchemaTypeName(`${toPascalCaseMethod(method)}Response`, generatedSchemaNames); |
| 464 | } |
| 465 | |
| 466 | function renderMethodConstants(constantName: string, entries: ReadonlyArray<MethodEntry>) { |
| 467 | return [ |
| 468 | `export const ${constantName} = {`, |
| 469 | ...entries.map( |
| 470 | (entry) => ` ${JSON.stringify(entry.method)}: ${JSON.stringify(entry.method)},`, |
| 471 | ), |
| 472 | "} as const;", |
| 473 | "", |
| 474 | ].join("\n"); |
| 475 | } |
| 476 | |
| 477 | function renderTypeInterface( |
| 478 | interfaceName: string, |
| 479 | entries: ReadonlyArray<MethodEntry>, |
| 480 | typeName: (entry: MethodEntry) => string, |
| 481 | ) { |
| 482 | return [ |
| 483 | `export interface ${interfaceName} {`, |
| 484 | ...entries.map((entry) => ` readonly ${JSON.stringify(entry.method)}: ${typeName(entry)};`), |
| 485 | "}", |
| 486 | "", |
| 487 | ].join("\n"); |
| 488 | } |
| 489 | |
| 490 | function renderSchemaMap( |
| 491 | constantName: string, |
| 492 | entries: ReadonlyArray<MethodEntry>, |
| 493 | typeName: (entry: MethodEntry) => string, |
| 494 | ) { |
| 495 | return [ |
| 496 | `export const ${constantName} = {`, |
| 497 | ...entries.map((entry) => { |
| 498 | const schemaName = typeName(entry); |
| 499 | return ` ${JSON.stringify(entry.method)}: ${ |
| 500 | schemaName === "undefined" ? "undefined" : `CodexSchema.${schemaName}` |
| 501 | },`; |
| 502 | }), |
| 503 | "} as const;", |
| 504 | "", |
| 505 | ].join("\n"); |
| 506 | } |
| 507 | |
| 508 | function renderSchemaTypeReference(schemaName: string) { |
| 509 | return schemaName === "undefined" ? "undefined" : `CodexSchema.${schemaName}`; |
| 510 | } |
| 511 | |
| 512 | function exportNameForPath(filePath: string): string { |
| 513 | const relative = filePath.replace(/^schema\/json\//, "").replace(/\.json$/, ""); |
| 514 | if (!relative.includes("/")) { |
| 515 | return relative; |
| 516 | } |
| 517 | |
| 518 | const [namespace, name] = relative.split("/", 2) as [string, string]; |
| 519 | const namespacePrefix = namespace |
| 520 | .split(/[^A-Za-z0-9]+/) |
| 521 | .filter(Boolean) |
| 522 | .map((segment) => segment[0]!.toUpperCase() + segment.slice(1)) |
| 523 | .join(""); |
| 524 | return `${namespacePrefix}${name}`; |
| 525 | } |
| 526 | |
| 527 | function buildJsonSchemaFiles( |
| 528 | entries: ReadonlyArray<GithubContentEntry>, |
| 529 | ): ReadonlyArray<JsonSchemaFile> { |
| 530 | return entries |
| 531 | .filter( |
| 532 | (entry) => |
| 533 | entry.type === "file" && |
| 534 | entry.name.endsWith(".json") && |
| 535 | entry.download_url !== null && |
| 536 | !entry.name.startsWith("codex_app_server_protocol."), |
| 537 | ) |
| 538 | .map((entry) => { |
| 539 | const relative = entry.path.replace(/^codex-rs\/app-server-protocol\/schema\/json\//, ""); |
| 540 | const parts = relative.split("/"); |
| 541 | if (parts.length > 1) { |
| 542 | return { |
| 543 | namespace: parts[0]!, |
| 544 | exportName: exportNameForPath(relative), |
| 545 | fileName: entry.name, |
| 546 | downloadUrl: entry.download_url!, |
| 547 | qualifiedName: relative.replace(/\.json$/, ""), |
| 548 | } satisfies JsonSchemaFile; |
| 549 | } |
| 550 | return { |
| 551 | exportName: exportNameForPath(relative), |
| 552 | fileName: entry.name, |
| 553 | downloadUrl: entry.download_url!, |
| 554 | qualifiedName: relative.replace(/\.json$/, ""), |
| 555 | } satisfies JsonSchemaFile; |
| 556 | }); |
| 557 | } |
| 558 | |
| 559 | function rewriteExternalRefs( |
| 560 | value: Schema.Json, |
| 561 | localDefinitionNames: ReadonlyMap<string, string>, |
| 562 | currentNamespace: string | undefined, |
| 563 | exportNameByQualifiedName: ReadonlyMap<string, string>, |
| 564 | ): Schema.Json { |
| 565 | if (Array.isArray(value)) { |
| 566 | return value.map((entry) => |
| 567 | rewriteExternalRefs(entry, localDefinitionNames, currentNamespace, exportNameByQualifiedName), |
| 568 | ); |
| 569 | } |
| 570 | if (value === null || typeof value !== "object") { |
| 571 | return value; |
| 572 | } |
| 573 | |
| 574 | return Object.fromEntries( |
| 575 | Object.entries(value).map(([key, child]) => { |
| 576 | if (key === "$ref" && typeof child === "string" && child.startsWith("#/definitions/")) { |
| 577 | const definitionName = child.slice("#/definitions/".length); |
| 578 | const localRewrite = localDefinitionNames.get(definitionName); |
| 579 | if (localRewrite) { |
| 580 | return [key, `#/components/schemas/${localRewrite}`]; |
| 581 | } |
| 582 | |
| 583 | const candidates = [ |
| 584 | ...(currentNamespace ? [`${currentNamespace}/${definitionName}`] : []), |
| 585 | definitionName, |
| 586 | definitionName.replace(/^v[12]\//, ""), |
| 587 | definitionName.replace(/^serde_json\//, ""), |
| 588 | `v2/${definitionName}`, |
| 589 | `v1/${definitionName}`, |
| 590 | `serde_json/${definitionName}`, |
| 591 | ]; |
| 592 | |
| 593 | const rewritten = candidates |
| 594 | .map((candidate) => exportNameByQualifiedName.get(candidate)) |
| 595 | .find((candidate) => candidate !== undefined); |
| 596 | |
| 597 | if (!rewritten) { |
| 598 | throw new Error(`Missing rewritten definition for ref: ${child}`); |
| 599 | } |
| 600 | |
| 601 | return [key, `#/components/schemas/${rewritten}`]; |
| 602 | } |
| 603 | |
| 604 | return [ |
| 605 | key, |
| 606 | rewriteExternalRefs( |
| 607 | child, |
| 608 | localDefinitionNames, |
| 609 | currentNamespace, |
| 610 | exportNameByQualifiedName, |
| 611 | ), |
| 612 | ]; |
| 613 | }), |
| 614 | ) as Schema.Json; |
| 615 | } |
| 616 | |
| 617 | const generateFiles = Effect.fn("generateFiles")(function* () { |
| 618 | yield* ensureGeneratedDir(); |
| 619 | |
| 620 | const [rootJsonEntries, v1JsonEntries, v2JsonEntries] = yield* Effect.all([ |
| 621 | fetchDirectoryEntries("schema/json"), |
| 622 | fetchDirectoryEntries("schema/json/v1"), |
| 623 | fetchDirectoryEntries("schema/json/v2"), |
| 624 | ]); |
| 625 | |
| 626 | const jsonSchemaFiles = [ |
| 627 | ...buildJsonSchemaFiles(rootJsonEntries), |
| 628 | ...buildJsonSchemaFiles(v1JsonEntries), |
| 629 | ...buildJsonSchemaFiles(v2JsonEntries), |
| 630 | ].toSorted((left, right) => left.exportName.localeCompare(right.exportName)); |
| 631 | |
| 632 | const exportNameByQualifiedName = new Map( |
| 633 | jsonSchemaFiles.map((file) => [file.qualifiedName, file.exportName]), |
| 634 | ); |
| 635 | const aggregateSchemas: Record<string, Schema.Json> = {}; |
| 636 | |
| 637 | for (const file of jsonSchemaFiles) { |
| 638 | const raw = yield* fetchText(file.downloadUrl); |
| 639 | const parsed = yield* decodeJsonSchemaDocument(raw); |
| 640 | const localDefinitionNames = new Map( |
| 641 | Object.keys(parsed.definitions ?? {}).map((definitionName) => [ |
| 642 | definitionName, |
| 643 | `${file.exportName}__${definitionName.replace(/[^A-Za-z0-9]/g, "")}`, |
| 644 | ]), |
| 645 | ); |
| 646 | |
| 647 | for (const [definitionName, definitionSchema] of Object.entries(parsed.definitions ?? {})) { |
| 648 | aggregateSchemas[localDefinitionNames.get(definitionName)!] = stripNullDefaults( |
| 649 | normalizeNullableTypes( |
| 650 | rewriteExternalRefs( |
| 651 | DefinitionOverrides[definitionName] ?? definitionSchema, |
| 652 | localDefinitionNames, |
| 653 | file.namespace, |
| 654 | exportNameByQualifiedName, |
| 655 | ), |
| 656 | ), |
| 657 | ); |
| 658 | } |
| 659 | |
| 660 | const topLevelSchema: Record<string, Schema.Json> = {}; |
| 661 | for (const [key, value] of Object.entries(parsed)) { |
| 662 | if (key !== "definitions") { |
| 663 | topLevelSchema[key] = value; |
| 664 | } |
| 665 | } |
| 666 | |
| 667 | aggregateSchemas[file.exportName] = stripNullDefaults( |
| 668 | normalizeNullableTypes( |
| 669 | rewriteExternalRefs( |
| 670 | topLevelSchema, |
| 671 | localDefinitionNames, |
| 672 | file.namespace, |
| 673 | exportNameByQualifiedName, |
| 674 | ), |
| 675 | ), |
| 676 | ); |
| 677 | } |
| 678 | |
| 679 | for (const [name, schema] of Object.entries(ManualSchemas)) { |
| 680 | if (!(name in aggregateSchemas)) { |
| 681 | aggregateSchemas[name] = stripNullDefaults(normalizeNullableTypes(schema)); |
| 682 | } |
| 683 | } |
| 684 | |
| 685 | const generator = makeJsonSchemaGenerator(); |
| 686 | for (const [name, schema] of Object.entries(aggregateSchemas).toSorted(([left], [right]) => |
| 687 | left.localeCompare(right), |
| 688 | )) { |
| 689 | aggregateSchemas[name] = adaptSchemaForEffect(schema); |
| 690 | generator.addSchema(name, aggregateSchemas[name] as never); |
| 691 | } |
| 692 | |
| 693 | const generatedEntries = new Map<string, string>(); |
| 694 | const output = generator.generate("openapi-3.1", aggregateSchemas as never, false).trim(); |
| 695 | if (output.length > 0) { |
| 696 | for (const entry of collectSchemaEntries(output)) { |
| 697 | if (!generatedEntries.has(entry.name)) { |
| 698 | generatedEntries.set(entry.name, entry.code); |
| 699 | } |
| 700 | } |
| 701 | } |
| 702 | |
| 703 | const generatedSchemaNames = new Set(generatedEntries.keys()); |
| 704 | const clientRequestRaw = yield* fetchText( |
| 705 | `https://raw.githubusercontent.com/openai/codex/${UPSTREAM_REF}/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts`, |
| 706 | ); |
| 707 | const clientNotificationRaw = yield* fetchText( |
| 708 | `https://raw.githubusercontent.com/openai/codex/${UPSTREAM_REF}/codex-rs/app-server-protocol/schema/typescript/ClientNotification.ts`, |
| 709 | ); |
| 710 | const serverRequestRaw = yield* fetchText( |
| 711 | `https://raw.githubusercontent.com/openai/codex/${UPSTREAM_REF}/codex-rs/app-server-protocol/schema/typescript/ServerRequest.ts`, |
| 712 | ); |
| 713 | const serverNotificationRaw = yield* fetchText( |
| 714 | `https://raw.githubusercontent.com/openai/codex/${UPSTREAM_REF}/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts`, |
| 715 | ); |
| 716 | |
| 717 | const clientRequestEntries = parseRequestEntries(clientRequestRaw); |
| 718 | const clientNotificationEntries = parseNotificationEntries(clientNotificationRaw); |
| 719 | const serverRequestEntries = parseRequestEntries(serverRequestRaw); |
| 720 | const serverNotificationEntries = parseNotificationEntries(serverNotificationRaw); |
| 721 | |
| 722 | const prelude = [ |
| 723 | "// This file is generated by the effect-codex-app-server package. Do not edit manually.", |
| 724 | `// Upstream protocol ref: ${UPSTREAM_REF}`, |
| 725 | "", |
| 726 | ]; |
| 727 | |
| 728 | const schemaOutput = [ |
| 729 | ...prelude, |
| 730 | 'import * as Schema from "effect/Schema";', |
| 731 | "", |
| 732 | [...generatedEntries.values()].join("\n\n"), |
| 733 | "", |
| 734 | ].join("\n"); |
| 735 | |
| 736 | const metaOutput = [ |
| 737 | ...prelude, |
| 738 | 'import * as CodexSchema from "./schema.gen.ts";', |
| 739 | "", |
| 740 | renderMethodConstants("CLIENT_REQUEST_METHODS", clientRequestEntries), |
| 741 | renderMethodConstants("CLIENT_NOTIFICATION_METHODS", clientNotificationEntries), |
| 742 | renderMethodConstants("SERVER_REQUEST_METHODS", serverRequestEntries), |
| 743 | renderMethodConstants("SERVER_NOTIFICATION_METHODS", serverNotificationEntries), |
| 744 | "export type ClientRequestMethod = keyof typeof CLIENT_REQUEST_METHODS;", |
| 745 | "export type ClientNotificationMethod = keyof typeof CLIENT_NOTIFICATION_METHODS;", |
| 746 | "export type ServerRequestMethod = keyof typeof SERVER_REQUEST_METHODS;", |
| 747 | "export type ServerNotificationMethod = keyof typeof SERVER_NOTIFICATION_METHODS;", |
| 748 | "", |
| 749 | renderTypeInterface("ClientRequestParamsByMethod", clientRequestEntries, (entry) => |
| 750 | renderSchemaTypeReference( |
| 751 | resolveSchemaTypeName(entry.paramsType ?? "undefined", generatedSchemaNames), |
| 752 | ), |
| 753 | ), |
| 754 | renderTypeInterface("ClientRequestResponsesByMethod", clientRequestEntries, (entry) => |
| 755 | renderSchemaTypeReference( |
| 756 | resolveResponseTypeName(entry.method, entry.paramsType, generatedSchemaNames), |
| 757 | ), |
| 758 | ), |
| 759 | renderTypeInterface("ClientNotificationParamsByMethod", clientNotificationEntries, (entry) => |
| 760 | renderSchemaTypeReference( |
| 761 | resolveSchemaTypeName(entry.paramsType ?? "undefined", generatedSchemaNames), |
| 762 | ), |
| 763 | ), |
| 764 | renderTypeInterface("ServerRequestParamsByMethod", serverRequestEntries, (entry) => |
| 765 | renderSchemaTypeReference( |
| 766 | resolveSchemaTypeName(entry.paramsType ?? "undefined", generatedSchemaNames), |
| 767 | ), |
| 768 | ), |
| 769 | renderTypeInterface("ServerRequestResponsesByMethod", serverRequestEntries, (entry) => |
| 770 | renderSchemaTypeReference( |
| 771 | resolveResponseTypeName(entry.method, entry.paramsType, generatedSchemaNames), |
| 772 | ), |
| 773 | ), |
| 774 | renderTypeInterface("ServerNotificationParamsByMethod", serverNotificationEntries, (entry) => |
| 775 | renderSchemaTypeReference( |
| 776 | resolveSchemaTypeName(entry.paramsType ?? "undefined", generatedSchemaNames), |
| 777 | ), |
| 778 | ), |
| 779 | renderSchemaMap("CLIENT_REQUEST_PARAMS", clientRequestEntries, (entry) => |
| 780 | resolveSchemaTypeName(entry.paramsType ?? "undefined", generatedSchemaNames), |
| 781 | ), |
| 782 | renderSchemaMap("CLIENT_REQUEST_RESPONSES", clientRequestEntries, (entry) => |
| 783 | resolveResponseTypeName(entry.method, entry.paramsType, generatedSchemaNames), |
| 784 | ), |
| 785 | renderSchemaMap("CLIENT_NOTIFICATION_PARAMS", clientNotificationEntries, (entry) => |
| 786 | resolveSchemaTypeName(entry.paramsType ?? "undefined", generatedSchemaNames), |
| 787 | ), |
| 788 | renderSchemaMap("SERVER_REQUEST_PARAMS", serverRequestEntries, (entry) => |
| 789 | resolveSchemaTypeName(entry.paramsType ?? "undefined", generatedSchemaNames), |
| 790 | ), |
| 791 | renderSchemaMap("SERVER_REQUEST_RESPONSES", serverRequestEntries, (entry) => |
| 792 | resolveResponseTypeName(entry.method, entry.paramsType, generatedSchemaNames), |
| 793 | ), |
| 794 | renderSchemaMap("SERVER_NOTIFICATION_PARAMS", serverNotificationEntries, (entry) => |
| 795 | resolveSchemaTypeName(entry.paramsType ?? "undefined", generatedSchemaNames), |
| 796 | ), |
| 797 | ].join("\n"); |
| 798 | |
| 799 | const namespaceGroups = new Map<string, Array<JsonSchemaFile>>(); |
| 800 | for (const file of jsonSchemaFiles) { |
| 801 | if (!file.namespace) { |
| 802 | continue; |
| 803 | } |
| 804 | const current = namespaceGroups.get(file.namespace) ?? []; |
| 805 | current.push(file); |
| 806 | namespaceGroups.set(file.namespace, current); |
| 807 | } |
| 808 | |
| 809 | const namespacesOutput = [ |
| 810 | ...prelude, |
| 811 | 'import * as CodexSchema from "./schema.gen.ts";', |
| 812 | "", |
| 813 | ...[...namespaceGroups.entries()] |
| 814 | .toSorted(([left], [right]) => left.localeCompare(right)) |
| 815 | .map(([namespace, files]) => { |
| 816 | const constantName = namespace.replace(/[^A-Za-z0-9]/g, ""); |
| 817 | return [ |
| 818 | `export const ${constantName} = {`, |
| 819 | ...files |
| 820 | .toSorted((left, right) => left.fileName.localeCompare(right.fileName)) |
| 821 | .map( |
| 822 | (file) => |
| 823 | ` ${JSON.stringify(file.fileName.replace(/\.json$/, ""))}: CodexSchema.${file.exportName},`, |
| 824 | ), |
| 825 | "} as const;", |
| 826 | "", |
| 827 | ].join("\n"); |
| 828 | }), |
| 829 | ].join("\n"); |
| 830 | |
| 831 | const fs = yield* FileSystem.FileSystem; |
| 832 | const { generatedDir, metaOutputPath, namespacesOutputPath, schemaOutputPath } = |
| 833 | yield* getGeneratedPaths(); |
| 834 | yield* fs.writeFileString(schemaOutputPath, schemaOutput); |
| 835 | yield* fs.writeFileString(metaOutputPath, metaOutput); |
| 836 | yield* fs.writeFileString(namespacesOutputPath, namespacesOutput); |
| 837 | |
| 838 | yield* Effect.log(`Generated Codex App Server schemas from ${UPSTREAM_REF}`); |
| 839 | |
| 840 | yield* Effect.service(ChildProcessSpawner.ChildProcessSpawner).pipe( |
| 841 | Effect.flatMap((spawner) => |
| 842 | spawner.spawn(ChildProcess.make("vp", ["fmt", generatedDir, "--write"])), |
| 843 | ), |
| 844 | Effect.flatMap((child) => child.exitCode), |
| 845 | Effect.tap((code) => |
| 846 | code === 0 |
| 847 | ? Effect.void |
| 848 | : Effect.fail( |
| 849 | new GeneratorError({ |
| 850 | detail: `vp fmt failed with exit code ${code}`, |
| 851 | }), |
| 852 | ), |
| 853 | ), |
| 854 | ); |
| 855 | }); |
| 856 | |
| 857 | generateFiles().pipe( |
| 858 | Effect.scoped, |
| 859 | Effect.provide( |
| 860 | Layer.mergeAll( |
| 861 | Logger.layer([Logger.consolePretty()]), |
| 862 | NodeServices.layer, |
| 863 | FetchHttpClient.layer, |
| 864 | ), |
| 865 | ), |
| 866 | NodeRuntime.runMain, |
| 867 | ); |