1#!/usr/bin/env node
2
3import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
4import * as NodeServices from "@effect/platform-node/NodeServices";
5import { make as makeJsonSchemaGenerator } from "@effect/openapi-generator/JsonSchemaGenerator";
6import * as Effect from "effect/Effect";
7import * as FileSystem from "effect/FileSystem";
8import * as Layer from "effect/Layer";
9import * as Logger from "effect/Logger";
10import * as Path from "effect/Path";
11import * as Schema from "effect/Schema";
12import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
13import { ChildProcess, ChildProcessSpawner } from "effect/process";
14
15const UPSTREAM_REF = "687a119f0fcaace47e1f1abcc77cec6c813fd6da";
16const USER_AGENT = "effect-codex-app-server-generator";
17const GITHUB_API_BASE =
18 "https://api.github.com/repos/openai/codex/contents/codex-rs/app-server-protocol";
19
20const 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);
28type GithubContentEntry = (typeof GithubContentEntries.Type)[number];
29
30const 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);
36const decodeGithubContentEntries = Schema.decodeEffect(Schema.fromJsonString(GithubContentEntries));
37const decodeJsonSchemaDocument = Schema.decodeEffect(Schema.fromJsonString(JsonSchemaDocument));
38
39interface GeneratedPaths {
40 readonly generatedDir: string;
41 readonly schemaOutputPath: string;
42 readonly metaOutputPath: string;
43 readonly namespacesOutputPath: string;
44}
45
46interface MethodEntry {
47 readonly method: string;
48 readonly paramsType?: string;
49}
50
51interface JsonSchemaFile {
52 readonly namespace?: string;
53 readonly exportName: string;
54 readonly fileName: string;
55 readonly downloadUrl: string;
56 readonly qualifiedName: string;
57}
58
59class 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
68const 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.
146const DefinitionOverrides: Record<string, Schema.Json> = {
147 PlanType: { type: "string" },
148};
149
150const 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
161const 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
167const 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
188const 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
193function 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
228function 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
276function 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
291type JsonSchemaNode = { readonly [key: string]: Schema.Json };
292
293function 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.
304function 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
369function 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
379function 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
393function 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
406function 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
429function 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
466function 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
477function 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
490function 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
508function renderSchemaTypeReference(schemaName: string) {
509 return schemaName === "undefined" ? "undefined" : `CodexSchema.${schemaName}`;
510}
511
512function 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
527function 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
559function 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
617const 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
857generateFiles().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);