#!/usr/bin/env bun /** * Generates the `.d.ts` trees under `web/src/_generated/` from the server-side * sources that own their shapes: the TypeBox schemas at * `src/platform/schemas/`, the connector-catalog wire types at * `src/connectors/catalog/types.ts`, and the REST response bodies at * `src/api/schemas/responses.ts`. * * Why this exists: web is a separate package (its own Dockerfile, * package.json, build context). The web shell needs the catalog's * derived TYPES — `ToolInput`, `ToolSource`, `ToolName` — for * the typed `callTool(args)` API in `web/src/api/client.ts` and * the `CreateInput` alias in `web/src/pages/settings/SkillsTab.tsx`. * It does NOT need the runtime schemas (TypeBox values). * * Sharing this via a cross-tree TypeScript path alias would force every * Dockerfile, dockerignore, Makefile, docker-compose, and CI invocation * to know about the cross-tree relationship. Generating a flat .d.ts * tree inside web/ keeps web a self-contained package — its build * doesn't reach outside web/, and the generated artifact is a normal * source file in version control. * * The generated trees are checked in. `scripts/check-codegen.ts` runs * after this script and fails on anything git reports under * `web/src/_generated/` — including a file this generator emitted that * was never added to git, which a diff over tracked paths cannot see. * That is what stops a client-side copy of a server type from drifting: * add a field to `RemoteOAuthInstall` without regenerating and CI fails, * rather than the field being quietly invisible to the web shell. * * Run: `bun run codegen` (alias for this script — see package.json). */ import { cpSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import ts from "typescript"; const REPO_ROOT = resolve(import.meta.dirname ?? __dirname, ".."); const TMP_OUT = join(REPO_ROOT, ".tmp-codegen"); const WEB_DEST = join(REPO_ROOT, "web/src/_generated/platform-schemas"); const CONNECTOR_TYPES_TMP = join(REPO_ROOT, ".tmp-codegen-connectors"); const CONNECTOR_TYPES_DEST = join(REPO_ROOT, "web/src/_generated/connector-catalog"); const API_TYPES_TMP = join(REPO_ROOT, ".tmp-codegen-api"); const API_TYPES_DEST = join(REPO_ROOT, "web/src/_generated/api"); const IDENTITY_SOURCES_SRC = join(REPO_ROOT, "src/tools/identity-sources.ts"); /** * Zero-import runtime modules mirrored verbatim into the web tree. * * A `.d.ts` emit gives the web shell types; these are the cases where it needs * the server's actual LOGIC and a hand-written second copy would drift with * nothing to catch it. Every entry must import nothing — a copied import would * not resolve from `web/src/_generated/` — and `check:codegen` fails the build * when the mirrored copy stops matching its source. */ const MIRRORED_MODULES: Array<{ src: string; dest: string }> = [ { src: "src/skills/loading.ts", dest: "web/src/_generated/skill-loading.ts" }, { src: "src/skills/tokens.ts", dest: "web/src/_generated/skill-tokens.ts" }, ]; const PERSONAL_CONNECTOR_PREFIX_DEST = join( REPO_ROOT, "web/src/_generated/personal-connector-prefix.ts", ); /** * Per-file header injected at the top of every generated .d.ts. * No timestamp — would change every regen and defeat the CI drift * check (`git log ` is the authoritative regen record). */ function header(sourcePath: string): string { return `/** * AUTOGENERATED — DO NOT EDIT. Source: ${sourcePath} * Regenerate: bun run codegen. AI agents: edit the source, not this file. */ `; } /** * Recursively prepend the header to every .d.ts under `dir`, naming the source * each file came from. `treeRoot` is the emitted tree's own root and `sourceRoot` * the repo-relative directory its layout mirrors, so the path a reader is sent to * is the real one for whichever tree this is called on. */ function injectHeaders(dir: string, treeRoot: string, sourceRoot: string): void { for (const entry of readdirSync(dir, { withFileTypes: true })) { const full = join(dir, entry.name); if (entry.isDirectory()) { injectHeaders(full, treeRoot, sourceRoot); continue; } if (!entry.name.endsWith(".d.ts")) continue; // Map output path back to source path for the header. const relFromOutput = full .slice(treeRoot.length + 1) .replaceAll("\\", "/") .replace(/\.d\.ts$/, ".ts"); const body = readFileSync(full, "utf-8"); writeFileSync(full, header(`${sourceRoot}/${relFromOutput}`) + body); } } /** * Emits a `.d.ts` for every source file a tsconfig compiles (its roots and * their imports), each from a program rooted at that file alone. * * Why not one `tsc -p`: the declaration printer writes a union in the order * of its members' type ids, and a mapped type's properties in the order of * its keys' ids. The checker interns a literal type once per program and * numbers it when first seen, so in a shared program `"failed"` takes its id * from whichever file mentions it first. A schema that adds a literal then * reorders unions in files that never import it, and two schema changes in * flight invalidate each other's generated files. A program rooted at one * file holds only that file's import closure, so its `.d.ts` depends on that * closure and nothing else. * * A file whose closure is every schema (`catalog.ts`) still inlines literal * unions from modules it imports, so a literal one schema adds would reorder * another schema's union there. `sortLiteralUnions` writes the literal * members of every union the checker prints in value order, which no other * file can move. */ function emitDeclarations(configPath: string): void { const parsed = ts.getParsedCommandLineOfConfigFile(join(REPO_ROOT, configPath), undefined, { ...ts.sys, onUnRecoverableConfigFileDiagnostic: (d) => failOn([d]), }); if (!parsed) throw new Error(`[codegen] could not read ${configPath}`); failOn(parsed.errors); const host = ts.createCompilerHost(parsed.options); const whole = ts.createProgram({ rootNames: parsed.fileNames, options: parsed.options, host }); failOn(ts.getPreEmitDiagnostics(whole)); const sources = whole .getSourceFiles() .filter((f) => !f.isDeclarationFile && !whole.isSourceFileFromExternalLibrary(f)) .map((f) => f.fileName) .sort(); let previous: ts.Program = whole; for (const file of sources) { const program = ts.createProgram({ rootNames: [file], options: parsed.options, host, oldProgram: previous, }); previous = program; const result = program.emit(program.getSourceFile(file), undefined, undefined, true, { afterDeclarations: [sortLiteralUnions], }); failOn(result.diagnostics); if (result.emitSkipped) throw new Error(`[codegen] tsc skipped the emit of ${file}`); } } /** * Declaration transformer: reorders the literal members of each union the * checker printed from a type by value (numbers ascending, then strings by * code unit), leaving any other member (`null`, `string`, an object type) in * its slot. A union copied from a source annotation is already in its author's * order and is left alone. Union order carries no meaning to the type system; * this only makes the printed order canonical. */ function sortLiteralUnions(context: ts.TransformationContext) { const { factory } = context; const visit = (node: ts.Node): ts.Node => { const visited = ts.visitEachChild(node, visit, context); if (!ts.isUnionTypeNode(visited) || ts.getParseTreeNode(node) !== undefined) return visited; const slots = visited.types.flatMap((t, i) => (literalKey(t) === undefined ? [] : [i])); if (slots.length < 2) return visited; const sorted = slots .map((i) => visited.types[i] as ts.TypeNode) .sort((a, b) => compareKeys(literalKey(a) as LiteralKey, literalKey(b) as LiteralKey)); const types = [...visited.types]; slots.forEach((slot, k) => { types[slot] = sorted[k] as ts.TypeNode; }); return factory.updateUnionTypeNode(visited, factory.createNodeArray(types)); }; return (root: T): T => ts.visitNode(root, visit) as T; } type LiteralKey = { rank: 0; value: number } | { rank: 1; value: string }; function literalKey(node: ts.TypeNode): LiteralKey | undefined { if (!ts.isLiteralTypeNode(node)) return undefined; const lit = node.literal; if (ts.isNumericLiteral(lit)) return { rank: 0, value: Number(lit.text) }; if ( ts.isPrefixUnaryExpression(lit) && lit.operator === ts.SyntaxKind.MinusToken && ts.isNumericLiteral(lit.operand) ) { return { rank: 0, value: -Number(lit.operand.text) }; } if (ts.isStringLiteral(lit)) return { rank: 1, value: lit.text }; return undefined; } function compareKeys(a: LiteralKey, b: LiteralKey): number { if (a.rank !== b.rank) return a.rank - b.rank; if (a.value === b.value) return 0; return a.value < b.value ? -1 : 1; } function failOn(diagnostics: readonly ts.Diagnostic[]): void { const errors = diagnostics.filter((d) => d.category === ts.DiagnosticCategory.Error); if (errors.length === 0) return; const text = ts.formatDiagnosticsWithColorAndContext(errors, { getCanonicalFileName: (f) => f, getCurrentDirectory: () => REPO_ROOT, getNewLine: () => "\n", }); throw new Error(`[codegen] tsc reported errors:\n${text}`); } // Mirrored string constants: a runtime `.ts` carrying a literal exported from // `src/`, which the web tier needs as a value (a `.d.ts` would give it only a // type). CI's `check:codegen` step catches drift between source and copy. function extractStringLiteral(source: string, name: string): string { // Match `export const = "";` with the value as a // double-quoted JS string literal. No escapes are interpreted; if a // mirrored source ever adds escapes, this regex needs to widen accordingly. const re = new RegExp(`export\\s+const\\s+${name}\\s*=\\s*"([^"\\\\]*)"`); const m = re.exec(source); if (!m || typeof m[1] !== "string") { throw new Error( `[codegen] could not extract ${name} — the source file format may have changed`, ); } return m[1]; } console.log( "[codegen] personal-connector-prefix → web/src/_generated/personal-connector-prefix.ts", ); { // Mirrored because the web tier needs the value, and with real stakes: this // marker is what separates the caller's own connector from a // same-named workspace source. A hand-copied copy that drifts stops the web // tier recognising a marked name, which mounts a workspace app's iframe for a // connector's tool call — the exact collision the marker exists to prevent, // and nothing else would catch it. `check:codegen` turns drift into a build // failure instead of a comment asking for discipline. const src = readFileSync(IDENTITY_SOURCES_SRC, "utf-8"); const prefix = extractStringLiteral(src, "PERSONAL_CONNECTOR_PREFIX"); const body = `${header("src/tools/identity-sources.ts")} // Personal-connector wire marker, mirrored from the server-side source. // See that file's header for why the marker sits outside slugify's alphabet. export const PERSONAL_CONNECTOR_PREFIX = ${JSON.stringify(prefix)}; `; mkdirSync(dirname(PERSONAL_CONNECTOR_PREFIX_DEST), { recursive: true }); writeFileSync(PERSONAL_CONNECTOR_PREFIX_DEST, body); console.log(`[codegen] OK → ${PERSONAL_CONNECTOR_PREFIX_DEST.replace(REPO_ROOT, ".")}`); } for (const { src, dest } of MIRRORED_MODULES) { console.log(`[codegen] ${src} → ${dest}`); const body = readFileSync(join(REPO_ROOT, src), "utf-8"); // An import in the source would land in the copy and fail to resolve from // the web tree — caught here rather than as a mystery build error in `web/`. if (/^\s*import\s/m.test(body)) { throw new Error( `[codegen] ${src} is mirrored into the web tree and must import nothing, but it does. ` + "Move the imported dependency out, or drop the file from MIRRORED_MODULES.", ); } const destPath = join(REPO_ROOT, dest); mkdirSync(dirname(destPath), { recursive: true }); writeFileSync(destPath, header(src) + body); console.log(`[codegen] OK → ${dest}`); } console.log("[codegen] platform-schemas → web/src/_generated/platform-schemas/"); // Clean previous outputs so removed schemas don't leave stale .d.ts files. rmSync(TMP_OUT, { recursive: true, force: true }); rmSync(WEB_DEST, { recursive: true, force: true }); // Emit declarations only via the dedicated tsconfig. The dedicated config // keeps the generation independent of the project tsconfig (which uses // `noEmit` and other compile-time-only settings). emitDeclarations("scripts/tsconfig.codegen-web.json"); // The tsc output preserves the source dir structure under `outDir`. // Move just the schemas tree into web/src/_generated/. mkdirSync(dirname(WEB_DEST), { recursive: true }); cpSync(TMP_OUT, WEB_DEST, { recursive: true }); rmSync(TMP_OUT, { recursive: true, force: true }); // Inject the do-not-edit header into every generated file. injectHeaders(WEB_DEST, WEB_DEST, "src/platform/schemas"); console.log(`[codegen] OK → ${WEB_DEST.replace(REPO_ROOT, ".")}`); // ── connector-catalog wire types ────────────────────────────────── // // The web shell imports `CatalogListing` / `ConnectorCatalogEntry` from here // rather than re-declaring them, because a hand-kept copy drifts silently: a // wire field missing from the client hides what it carries from the UI with // nothing to notice. `check:codegen` fails the build when the server type moves // without a regen. // // Only the type closure of `src/connectors/catalog/types.ts` is emitted — a small set // of leaf type modules. `ConnectorCatalogEntry` lives there beside // `CatalogListing` for that reason: the projection module that builds it also // imports half the runtime, and rooting the emit at a module with runtime // imports pulls ~80 files into a tree the web shell has no use for. console.log("[codegen] connector-catalog → web/src/_generated/connector-catalog/"); rmSync(CONNECTOR_TYPES_TMP, { recursive: true, force: true }); rmSync(CONNECTOR_TYPES_DEST, { recursive: true, force: true }); emitDeclarations("scripts/tsconfig.codegen-web-connectors.json"); mkdirSync(dirname(CONNECTOR_TYPES_DEST), { recursive: true }); cpSync(CONNECTOR_TYPES_TMP, CONNECTOR_TYPES_DEST, { recursive: true }); rmSync(CONNECTOR_TYPES_TMP, { recursive: true, force: true }); injectHeaders(CONNECTOR_TYPES_DEST, CONNECTOR_TYPES_DEST, "src"); console.log(`[codegen] OK → ${CONNECTOR_TYPES_DEST.replace(REPO_ROOT, ".")}`); // ── REST response bodies and SSE events ─────────────────────────────────────────── // // The body of every JSON route (`responses.ts`) and every SSE event // (`events.ts`). Both import nothing outside their directory, and the config's // `rootDir` is that directory, so tsc refuses an outside import rather than // emitting its graph here. console.log("[codegen] api → web/src/_generated/api/"); rmSync(API_TYPES_TMP, { recursive: true, force: true }); rmSync(API_TYPES_DEST, { recursive: true, force: true }); emitDeclarations("scripts/tsconfig.codegen-web-api.json"); mkdirSync(dirname(API_TYPES_DEST), { recursive: true }); cpSync(API_TYPES_TMP, API_TYPES_DEST, { recursive: true }); rmSync(API_TYPES_TMP, { recursive: true, force: true }); injectHeaders(API_TYPES_DEST, API_TYPES_DEST, "src/api/schemas"); console.log(`[codegen] OK → ${API_TYPES_DEST.replace(REPO_ROOT, ".")}`);