build.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424
  1. #!/usr/bin/env bun
  2. import { fileURLToPath } from "url"
  3. const dir = fileURLToPath(new URL("..", import.meta.url))
  4. process.chdir(dir)
  5. import { $ } from "bun"
  6. import path from "path"
  7. import { createClient } from "@hey-api/openapi-ts"
  8. const opencode = path.resolve(dir, "../../opencode")
  9. const client = path.resolve(dir, "../../client")
  10. if (!(await Bun.file(path.join(opencode, "package.json")).exists())) {
  11. await $`rm -rf dist`
  12. await $`bun tsc`
  13. process.exit(0)
  14. }
  15. await $`bun dev generate > ${dir}/openapi.json`.cwd(opencode)
  16. await $`bun -e ${`
  17. import { OpenApi } from "effect/unstable/httpapi"
  18. import { ClientApi } from "@opencode-ai/protocol/client"
  19. const output = process.argv.at(-1)
  20. if (!output) throw new Error("Missing OpenAPI output path")
  21. await Bun.write(output, JSON.stringify(OpenApi.fromApi(ClientApi)))
  22. `} ${path.join(dir, "openapi-v2.json")}`.cwd(client)
  23. type OpenApiDocument = {
  24. components?: { schemas?: Record<string, unknown> }
  25. paths?: Record<string, unknown>
  26. [key: string]: unknown
  27. }
  28. const document = (await Bun.file("./openapi.json").json()) as OpenApiDocument
  29. const v2Document = (await Bun.file("./openapi-v2.json").json()) as OpenApiDocument
  30. normalizeComponentNames(v2Document)
  31. deduplicateEquivalentComponent(v2Document, "Shell", "Shell1")
  32. renameCollidingComponents(document, v2Document)
  33. document.paths = { ...document.paths, ...v2Document.paths }
  34. document.components = {
  35. ...document.components,
  36. schemas: { ...document.components?.schemas, ...v2Document.components?.schemas },
  37. }
  38. inlineTypedAllOfConstraints(document)
  39. const schemas = document.components?.schemas
  40. if (schemas) {
  41. const reachable = new Set<string>()
  42. const visit = (value: unknown) => {
  43. if (Array.isArray(value)) {
  44. value.forEach(visit)
  45. return
  46. }
  47. if (typeof value !== "object" || value === null) return
  48. for (const [key, child] of Object.entries(value)) {
  49. if (key === "$ref" && typeof child === "string" && child.startsWith("#/components/schemas/")) {
  50. const name = child.slice("#/components/schemas/".length)
  51. if (reachable.has(name)) continue
  52. reachable.add(name)
  53. visit(schemas[name])
  54. } else {
  55. visit(child)
  56. }
  57. }
  58. }
  59. visit({ ...document, components: { ...document.components, schemas: undefined } })
  60. for (const name of Object.keys(schemas)) {
  61. if (
  62. /^(SessionAgentSelected|SessionModelSelected|SessionMoved|SessionRenamed|SessionForked|SessionInputPromoted|SessionInputAdmitted|SessionExecutionStarted|SessionExecutionSucceeded|SessionExecutionFailed|SessionExecutionInterrupted|SessionInstructionsUpdated|SessionSynthetic|SessionSkillActivated|SessionShellStarted|SessionShellEnded|SessionStepStarted|SessionStepEnded|SessionStepFailed|SessionTextStarted|SessionTextDelta|SessionTextEnded|SessionToolInputStarted|SessionToolInputDelta|SessionToolInputEnded|SessionToolCalled|SessionToolProgress|SessionToolSuccess|SessionToolFailed|SessionRetryScheduled|SessionCompactionStarted|SessionCompactionDelta|SessionCompactionEnded|SessionRevertStaged|SessionRevertCleared|SessionRevertCommitted)\d+$/.test(
  63. name,
  64. ) &&
  65. !reachable.has(name)
  66. )
  67. delete schemas[name]
  68. }
  69. await Bun.write("./openapi.json", JSON.stringify(document))
  70. }
  71. await createClient({
  72. input: "./openapi.json",
  73. output: {
  74. path: "./src/v2/gen",
  75. tsConfigPath: path.join(dir, "tsconfig.json"),
  76. clean: true,
  77. },
  78. plugins: [
  79. {
  80. name: "@hey-api/typescript",
  81. exportFromIndex: false,
  82. },
  83. {
  84. name: "@hey-api/sdk",
  85. instance: "OpencodeClient",
  86. exportFromIndex: false,
  87. auth: false,
  88. paramsStructure: "flat",
  89. },
  90. {
  91. name: "@hey-api/client-fetch",
  92. exportFromIndex: false,
  93. baseUrl: "http://localhost:4096",
  94. },
  95. ],
  96. })
  97. const generatedTypesPath = "./src/v2/gen/types.gen.ts"
  98. const generatedTypes = await Bun.file(generatedTypesPath).text()
  99. if (
  100. /export type (SessionAgentSelected|SessionModelSelected|SessionMoved|SessionRenamed|SessionForked|SessionInputPromoted|SessionInputAdmitted|SessionExecutionStarted|SessionExecutionSucceeded|SessionExecutionFailed|SessionExecutionInterrupted|SessionInstructionsUpdated|SessionSynthetic|SessionSkillActivated|SessionShellStarted|SessionShellEnded|SessionStepStarted|SessionStepEnded|SessionStepFailed|SessionTextStarted|SessionTextDelta|SessionTextEnded|SessionToolInputStarted|SessionToolInputDelta|SessionToolInputEnded|SessionToolCalled|SessionToolProgress|SessionToolSuccess|SessionToolFailed|SessionRetryScheduled|SessionCompactionStarted|SessionCompactionDelta|SessionCompactionEnded|SessionRevertStaged|SessionRevertCleared|SessionRevertCommitted)\d+ =/.test(
  101. generatedTypes,
  102. )
  103. ) {
  104. throw new Error("Session history generated duplicate Session event variants")
  105. }
  106. const sessionErrorTypesPatched = deduplicateEquivalentGeneratedTypes(
  107. generatedTypes,
  108. "SessionStructuredError",
  109. /^SessionStructuredError\d+$/,
  110. )
  111. const obsoleteSessionNext = [...sessionErrorTypesPatched.matchAll(/export type (SessionNext\w*) =/g)].map(
  112. (match) => match[1],
  113. )
  114. if (obsoleteSessionNext.length > 0) {
  115. throw new Error(`Obsolete SessionNext generated type noise reintroduced: ${obsoleteSessionNext.join(", ")}`)
  116. }
  117. const logTypesPatched = sessionErrorTypesPatched.replace(
  118. /(export type V2SessionLogData = \{[\s\S]*?query\?: \{\s*after\?: )string/,
  119. "$1number",
  120. )
  121. if (logTypesPatched === sessionErrorTypesPatched) {
  122. throw new Error("Session log numeric query patch did not apply")
  123. }
  124. const sessionListTypesPatched = logTypesPatched.replace(
  125. /(export type V2SessionListData = \{[\s\S]*?query\?: \{[\s\S]*?limit\?: )string( \| null)/,
  126. "$1number$2",
  127. )
  128. if (sessionListTypesPatched === logTypesPatched) {
  129. throw new Error("Session list numeric query patch did not apply")
  130. }
  131. const sessionMessagesTypesPatched = sessionListTypesPatched.replace(
  132. /(export type V2MessageListData = \{[\s\S]*?query\?: \{[\s\S]*?limit\?: )string( \| null)/,
  133. "$1number$2",
  134. )
  135. if (sessionMessagesTypesPatched === sessionListTypesPatched) {
  136. throw new Error("Session messages numeric query patch did not apply")
  137. }
  138. const eventSubscribeTypesPatched = sessionMessagesTypesPatched.replace(
  139. /(export type V2EventSubscribeResponses = \{\s*\/\*\*[\s\S]*?\*\/\s*200: )\{\s*id: string \| null;?\s*event: string;?\s*data: (?:V2EventStream(?:V2)?|V2EventJsonString);?\s*\};?/,
  140. "$1V2Event",
  141. )
  142. if (eventSubscribeTypesPatched === sessionMessagesTypesPatched) {
  143. throw new Error("Event subscribe response patch did not apply")
  144. }
  145. if (/SessionStructuredError\d/.test(eventSubscribeTypesPatched)) {
  146. throw new Error("Session structured error generated a name-mangled duplicate")
  147. }
  148. if (/\bSessionNext\w*\b/.test(eventSubscribeTypesPatched)) {
  149. throw new Error("Obsolete SessionNext generated type noise reintroduced")
  150. }
  151. if (/export type Shell\d+V2 =/.test(eventSubscribeTypesPatched)) {
  152. throw new Error("Shell generated a name-mangled duplicate")
  153. }
  154. await Bun.write(generatedTypesPath, eventSubscribeTypesPatched)
  155. const querySerializerPath = "./src/v2/gen/client/utils.gen.ts"
  156. const querySerializerSource = await Bun.file(querySerializerPath).text()
  157. const querySerializerPatched = querySerializerSource.replace(
  158. /if \(value === undefined \|\| value === null\) \{\s*continue;?\s*\}/,
  159. "if (value === undefined) {\n continue;\n }\n\n if (value === null) {\n search.push(`${name}=null`);\n continue;\n }",
  160. )
  161. if (querySerializerPatched === querySerializerSource) {
  162. throw new Error(
  163. `Query serializer null patch did not apply; @hey-api/openapi-ts output may have changed (${querySerializerPath})`,
  164. )
  165. }
  166. await Bun.write(querySerializerPath, querySerializerPatched)
  167. const generatedSdkPath = "./src/v2/gen/sdk.gen.ts"
  168. const generatedSdk = await Bun.file(generatedSdkPath).text()
  169. const logSdkPatched = generatedSdk.replace(
  170. /(Read the session log[\s\S]*?parameters: \{[\s\S]*?after\?: )string(\s*\|\s*null)?/,
  171. "$1number$2",
  172. )
  173. if (logSdkPatched === generatedSdk) {
  174. throw new Error("Session log numeric SDK patch did not apply")
  175. }
  176. const sessionListSdkPatched = logSdkPatched.replace(
  177. /(List sessions[\s\S]*?parameters\?: \{[\s\S]*?limit\?: )string( \| null)/,
  178. "$1number$2",
  179. )
  180. if (sessionListSdkPatched === logSdkPatched) {
  181. throw new Error("Session list numeric SDK patch did not apply")
  182. }
  183. const sessionMessagesSdkPatched = sessionListSdkPatched.replace(
  184. /(Get session messages[\s\S]*?parameters: \{[\s\S]*?limit\?: )string( \| null)/,
  185. "$1number$2",
  186. )
  187. if (sessionMessagesSdkPatched === sessionListSdkPatched) {
  188. throw new Error("Session messages numeric SDK patch did not apply")
  189. }
  190. await Bun.write(generatedSdkPath, sessionMessagesSdkPatched)
  191. // Patch a @hey-api/openapi-ts codegen bug: SseFn incorrectly passes the
  192. // endpoint's TError into the second generic of ServerSentEventsResult, which
  193. // is the AsyncGenerator's TReturn slot. Iterator return values have nothing
  194. // to do with HTTP errors, and any consumer that calls `.return()` or returns
  195. // from a mock generator gets type-checked against the wrong shape. Drop the
  196. // arg so TReturn defaults to void.
  197. const sseTypesPath = "./src/v2/gen/client/types.gen.ts"
  198. const sseTypesFile = Bun.file(sseTypesPath)
  199. const sseTypesSource = await sseTypesFile.text()
  200. const sseTypesPatched = sseTypesSource.replace(
  201. "=> Promise<ServerSentEventsResult<TData, TError>>",
  202. "=> Promise<ServerSentEventsResult<TData>>",
  203. )
  204. if (sseTypesPatched === sseTypesSource) {
  205. throw new Error(`SseFn patch did not apply; @hey-api/openapi-ts output may have changed (${sseTypesPath})`)
  206. }
  207. await Bun.write(sseTypesPath, sseTypesPatched)
  208. await $`bun prettier --write src/gen`
  209. await $`bun prettier --write src/v2`
  210. await $`rm -rf dist`
  211. await $`bun tsc`
  212. await $`rm openapi.json openapi-v2.json`
  213. function renameCollidingComponents(target: OpenApiDocument, source: OpenApiDocument) {
  214. const targetSchemas = target.components?.schemas
  215. const sourceSchemas = source.components?.schemas
  216. if (!targetSchemas || !sourceSchemas) return
  217. const renames = new Map<string, string>()
  218. for (const name of Object.keys(sourceSchemas)) {
  219. if (!Object.hasOwn(targetSchemas, name)) continue
  220. if (JSON.stringify(normalizeSchema(sourceSchemas[name])) === JSON.stringify(normalizeSchema(targetSchemas[name]))) {
  221. delete sourceSchemas[name]
  222. continue
  223. }
  224. let renamed = `${name}V2`
  225. let index = 2
  226. while (Object.hasOwn(targetSchemas, renamed) || Object.hasOwn(sourceSchemas, renamed)) {
  227. renamed = `${name}V2${index}`
  228. index++
  229. }
  230. renames.set(name, renamed)
  231. }
  232. if (renames.size === 0) return
  233. source.components = {
  234. ...source.components,
  235. schemas: Object.fromEntries(
  236. Object.entries(sourceSchemas).map(([name, schema]) => [renames.get(name) ?? name, rewriteRefs(schema, renames)]),
  237. ),
  238. }
  239. source.paths = rewriteRefs(source.paths, renames) as Record<string, unknown> | undefined
  240. }
  241. function normalizeComponentNames(document: OpenApiDocument) {
  242. const schemas = document.components?.schemas
  243. if (!schemas) return
  244. const canonical = new Map(Object.entries(schemas))
  245. const renames = new Map<string, string>()
  246. for (const name of Object.keys(schemas)) {
  247. const next = componentTypeName(name)
  248. if (next === name) continue
  249. const existing = canonical.get(next)
  250. if (existing !== undefined) {
  251. if (JSON.stringify(normalizeSchema(schemas[name])) !== JSON.stringify(normalizeSchema(existing))) continue
  252. renames.set(name, next)
  253. continue
  254. }
  255. renames.set(name, next)
  256. canonical.set(next, schemas[name])
  257. }
  258. if (renames.size === 0) return
  259. const renamed = new Set<string>()
  260. document.components = {
  261. ...document.components,
  262. schemas: Object.fromEntries(
  263. [
  264. ...Object.entries(schemas).filter(([name]) => !renames.has(name)),
  265. ...Object.entries(schemas).flatMap(([name, schema]) => {
  266. const next = renames.get(name)
  267. if (!next || Object.hasOwn(schemas, next) || renamed.has(next)) return []
  268. renamed.add(next)
  269. return [[next, schema] as const]
  270. }),
  271. ].map(([name, schema]) => [name, rewriteRefs(schema, renames)]),
  272. ),
  273. }
  274. document.paths = rewriteRefs(document.paths, renames) as Record<string, unknown> | undefined
  275. }
  276. function componentTypeName(name: string) {
  277. if (!name.includes(".")) return name
  278. return name
  279. .split(".")
  280. .filter((part) => !/^\d+$/.test(part))
  281. .map((part) => part.slice(0, 1).toUpperCase() + part.slice(1))
  282. .join("")
  283. }
  284. function deduplicateEquivalentComponent(document: OpenApiDocument, canonical: string, duplicate: string) {
  285. const schemas = document.components?.schemas
  286. if (!schemas?.[canonical] || !schemas[duplicate]) return
  287. if (JSON.stringify(normalizeSchema(schemas[canonical])) !== JSON.stringify(normalizeSchema(schemas[duplicate]))) {
  288. throw new Error(`${duplicate} no longer has the same wire shape as ${canonical}`)
  289. }
  290. const renames = new Map([[duplicate, canonical]])
  291. const rewritten = rewriteRefs(schemas, renames) as Record<string, unknown>
  292. delete rewritten[duplicate]
  293. document.components = { ...document.components, schemas: rewritten }
  294. document.paths = rewriteRefs(document.paths, renames) as Record<string, unknown> | undefined
  295. }
  296. function deduplicateEquivalentGeneratedTypes(source: string, canonical: string, duplicates: RegExp) {
  297. const canonicalType = generatedType(source, canonical)
  298. if (!canonicalType) throw new Error(`Generated canonical type missing: ${canonical}`)
  299. const names = [...source.matchAll(/export type (\w+) =/g)]
  300. .map((match) => match[1])
  301. .filter((name): name is string => name !== undefined && duplicates.test(name))
  302. return names.reduce((patched, name) => {
  303. const duplicate = generatedType(patched, name)
  304. const currentCanonical = generatedType(patched, canonical)
  305. if (!duplicate || !currentCanonical) throw new Error(`Generated type declaration missing while comparing ${name}`)
  306. if (normalizeGeneratedType(currentCanonical.shape) !== normalizeGeneratedType(duplicate.shape)) {
  307. throw new Error(`${name} no longer has the same generated type shape as ${canonical}`)
  308. }
  309. return (patched.slice(0, duplicate.start) + patched.slice(duplicate.end)).replaceAll(name, canonical)
  310. }, source)
  311. }
  312. function generatedType(source: string, name: string) {
  313. const start = source.indexOf(`export type ${name} =`)
  314. if (start === -1) return undefined
  315. const next = source.indexOf("\n\nexport type ", start + 1)
  316. const shapeEnd = next === -1 ? source.length : next
  317. return {
  318. start,
  319. end: next === -1 ? source.length : next + 2,
  320. shape: source.slice(source.indexOf("=", start) + 1, shapeEnd),
  321. }
  322. }
  323. function normalizeGeneratedType(shape: string) {
  324. return shape.replaceAll(/\s/g, "")
  325. }
  326. function normalizeSchema(value: unknown, key?: string): unknown {
  327. if (Array.isArray(value)) {
  328. const flattened =
  329. key === "anyOf"
  330. ? value.flatMap((item) =>
  331. typeof item === "object" && item !== null && Object.keys(item).length === 1 && "anyOf" in item
  332. ? Array.isArray(item.anyOf)
  333. ? item.anyOf
  334. : [item]
  335. : [item],
  336. )
  337. : value
  338. const expanded =
  339. key === "anyOf"
  340. ? flattened.flatMap((item) => {
  341. if (typeof item !== "object" || item === null || !("type" in item) || !("enum" in item)) return [item]
  342. if (Object.keys(item).some((property) => property !== "type" && property !== "enum")) return [item]
  343. if (!Array.isArray(item.enum)) return [item]
  344. return item.enum.map((member) => ({ type: item.type, enum: [member] }))
  345. })
  346. : flattened
  347. const normalized = expanded.map((item) => normalizeSchema(item))
  348. if (key !== "anyOf" && key !== "required" && key !== "enum") return normalized
  349. return [...new Map(normalized.map((item) => [JSON.stringify(item), item])).values()].sort((a, b) =>
  350. JSON.stringify(a).localeCompare(JSON.stringify(b)),
  351. )
  352. }
  353. if (typeof value !== "object" || value === null) return value
  354. return Object.fromEntries(
  355. Object.entries(value)
  356. .sort(([left], [right]) => left.localeCompare(right))
  357. .map(([property, child]) => [property, normalizeSchema(child, property)]),
  358. )
  359. }
  360. function rewriteRefs(value: unknown, renames: Map<string, string>): unknown {
  361. if (Array.isArray(value)) return value.map((item) => rewriteRefs(item, renames))
  362. if (typeof value !== "object" || value === null) return value
  363. return Object.fromEntries(
  364. Object.entries(value).map(([key, child]) => {
  365. if (key !== "$ref" || typeof child !== "string") return [key, rewriteRefs(child, renames)]
  366. const prefix = "#/components/schemas/"
  367. if (!child.startsWith(prefix)) return [key, child]
  368. return [key, `${prefix}${renames.get(child.slice(prefix.length)) ?? child.slice(prefix.length)}`]
  369. }),
  370. )
  371. }
  372. function inlineTypedAllOfConstraints(value: unknown): void {
  373. if (Array.isArray(value)) {
  374. value.forEach(inlineTypedAllOfConstraints)
  375. return
  376. }
  377. if (typeof value !== "object" || value === null) return
  378. const schema = value as { allOf?: unknown; type?: unknown; [key: string]: unknown }
  379. if (typeof schema.type === "string" && Array.isArray(schema.allOf) && schema.allOf.every(isConstraintSchema)) {
  380. for (const item of schema.allOf) Object.assign(schema, item)
  381. delete schema.allOf
  382. }
  383. Object.values(schema).forEach(inlineTypedAllOfConstraints)
  384. }
  385. function isConstraintSchema(value: unknown): value is Record<string, unknown> {
  386. if (typeof value !== "object" || value === null || Array.isArray(value)) return false
  387. return !Object.keys(value).some(
  388. (key) => key === "$ref" || key === "type" || key === "allOf" || key === "anyOf" || key === "oneOf",
  389. )
  390. }