scripts/codegen-web-platform-schemas.ts
Rama main · réplica de lectura del repositorio original.
#!/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<S, T>`, `ToolSource`, `ToolName<S>` — for
* the typed `callTool<S, T>(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 <file>` 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 <T extends ts.Node>(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 <name> = "<value>";` 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, ".")}`);