Skip to content
Source
Sign inSign up

generate.ts

904 lines29.5 KB

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