mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-19 05:02:49 +00:00
* Add lefthook formatter checks * Fix SDK mode hydration * Stabilize SDK mode integration test
378 lines
12 KiB
TypeScript
378 lines
12 KiB
TypeScript
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync, existsSync, appendFileSync, statSync } from "fs";
|
|
import { join } from "path";
|
|
import { tmpdir } from "os";
|
|
import { createGenerator, type Config } from "ts-json-schema-generator";
|
|
import { maxSatisfying, rsort, valid } from "semver";
|
|
import { x as extractTar } from "tar";
|
|
import type { JSONSchema7 } from "json-schema";
|
|
import { fetchWithCache } from "./cache.js";
|
|
|
|
const REGISTRY_URL = "https://registry.npmjs.org/ai";
|
|
const TARGET_TYPE = "UIMessage";
|
|
const DEFAULT_MAJOR = 6;
|
|
const RESOURCE_DIR = join(import.meta.dirname, "..");
|
|
const OUTPUT_DIR = join(RESOURCE_DIR, "artifacts", "json-schema");
|
|
const OUTPUT_PATH = join(OUTPUT_DIR, "ui-message.json");
|
|
const SCHEMA_ID = "https://sandbox-agent/schemas/vercel-ai-sdk/ui-message.json";
|
|
|
|
interface RegistryResponse {
|
|
versions?: Record<
|
|
string,
|
|
{
|
|
dist?: { tarball?: string };
|
|
dependencies?: Record<string, string>;
|
|
peerDependencies?: Record<string, string>;
|
|
}
|
|
>;
|
|
"dist-tags"?: Record<string, string>;
|
|
}
|
|
|
|
interface Args {
|
|
version: string | null;
|
|
major: number;
|
|
}
|
|
|
|
function parseArgs(): Args {
|
|
const args = process.argv.slice(2);
|
|
const versionArg = args.find((arg) => arg.startsWith("--version="));
|
|
const majorArg = args.find((arg) => arg.startsWith("--major="));
|
|
|
|
const version = versionArg ? versionArg.split("=")[1] : null;
|
|
const major = majorArg ? Number(majorArg.split("=")[1]) : DEFAULT_MAJOR;
|
|
|
|
return {
|
|
version,
|
|
major: Number.isFinite(major) && major > 0 ? major : DEFAULT_MAJOR,
|
|
};
|
|
}
|
|
|
|
function log(message: string): void {
|
|
console.log(message);
|
|
}
|
|
|
|
function ensureOutputDir(): void {
|
|
if (!existsSync(OUTPUT_DIR)) {
|
|
mkdirSync(OUTPUT_DIR, { recursive: true });
|
|
}
|
|
}
|
|
|
|
async function fetchRegistry(url: string): Promise<RegistryResponse> {
|
|
const registry = await fetchWithCache(url);
|
|
return JSON.parse(registry) as RegistryResponse;
|
|
}
|
|
|
|
function resolveLatestVersion(registry: RegistryResponse, major: number): string {
|
|
const versions = Object.keys(registry.versions ?? {});
|
|
const candidates = versions.filter((version) => valid(version) && version.startsWith(`${major}.`));
|
|
const sorted = rsort(candidates);
|
|
if (sorted.length === 0) {
|
|
throw new Error(`No versions found for major ${major}`);
|
|
}
|
|
return sorted[0];
|
|
}
|
|
|
|
function resolveVersionFromRange(registry: RegistryResponse, range: string): string {
|
|
if (registry.versions?.[range]) {
|
|
return range;
|
|
}
|
|
|
|
const versions = Object.keys(registry.versions ?? {}).filter((version) => valid(version));
|
|
const resolved = maxSatisfying(versions, range);
|
|
if (!resolved) {
|
|
throw new Error(`No versions satisfy range ${range}`);
|
|
}
|
|
|
|
return resolved;
|
|
}
|
|
|
|
async function downloadTarball(url: string, destination: string): Promise<void> {
|
|
const response = await fetch(url);
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to download tarball: ${response.status} ${response.statusText}`);
|
|
}
|
|
const buffer = Buffer.from(await response.arrayBuffer());
|
|
writeFileSync(destination, buffer);
|
|
}
|
|
|
|
async function extractPackage(tarballPath: string, targetDir: string): Promise<void> {
|
|
mkdirSync(targetDir, { recursive: true });
|
|
await extractTar({
|
|
file: tarballPath,
|
|
cwd: targetDir,
|
|
strip: 1,
|
|
});
|
|
}
|
|
|
|
function packageDirFor(name: string, nodeModulesDir: string): string {
|
|
const parts = name.split("/");
|
|
return join(nodeModulesDir, ...parts);
|
|
}
|
|
|
|
async function installPackage(name: string, versionRange: string, nodeModulesDir: string, installed: Set<string>): Promise<void> {
|
|
const encodedName = name.startsWith("@") ? `@${encodeURIComponent(name.slice(1))}` : encodeURIComponent(name);
|
|
const registryUrl = `https://registry.npmjs.org/${encodedName}`;
|
|
const registry = await fetchRegistry(registryUrl);
|
|
const version = resolveVersionFromRange(registry, versionRange);
|
|
const installKey = `${name}@${version}`;
|
|
|
|
if (installed.has(installKey)) {
|
|
return;
|
|
}
|
|
|
|
installed.add(installKey);
|
|
|
|
const tarball = registry.versions?.[version]?.dist?.tarball;
|
|
if (!tarball) {
|
|
throw new Error(`No tarball found for ${installKey}`);
|
|
}
|
|
|
|
const tempDir = mkdtempSync(join(tmpdir(), "vercel-ai-sdk-dep-"));
|
|
const tarballPath = join(tempDir, `${name.replace("/", "-")}-${version}.tgz`);
|
|
const packageDir = packageDirFor(name, nodeModulesDir);
|
|
|
|
try {
|
|
await downloadTarball(tarball, tarballPath);
|
|
await extractPackage(tarballPath, packageDir);
|
|
|
|
const packageJsonPath = join(packageDir, "package.json");
|
|
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as {
|
|
dependencies?: Record<string, string>;
|
|
peerDependencies?: Record<string, string>;
|
|
};
|
|
|
|
const dependencies = {
|
|
...packageJson.dependencies,
|
|
...packageJson.peerDependencies,
|
|
};
|
|
|
|
for (const [depName, depRange] of Object.entries(dependencies)) {
|
|
await installPackage(depName, depRange, nodeModulesDir, installed);
|
|
}
|
|
} finally {
|
|
rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
function writeTempTsconfig(tempDir: string): string {
|
|
const tsconfigPath = join(tempDir, "tsconfig.json");
|
|
const tsconfig = {
|
|
compilerOptions: {
|
|
target: "ES2022",
|
|
module: "NodeNext",
|
|
moduleResolution: "NodeNext",
|
|
strict: true,
|
|
skipLibCheck: true,
|
|
esModuleInterop: true,
|
|
},
|
|
};
|
|
writeFileSync(tsconfigPath, JSON.stringify(tsconfig, null, 2));
|
|
return tsconfigPath;
|
|
}
|
|
|
|
function writeEntryFile(tempDir: string): string {
|
|
const entryPath = join(tempDir, "entry.ts");
|
|
const contents = `import type { ${TARGET_TYPE} as AI${TARGET_TYPE} } from "ai";\nexport type ${TARGET_TYPE} = AI${TARGET_TYPE};\n`;
|
|
writeFileSync(entryPath, contents);
|
|
return entryPath;
|
|
}
|
|
|
|
function patchValueOfAlias(nodeModulesDir: string): void {
|
|
const aiTypesPath = join(nodeModulesDir, "ai", "dist", "index.d.ts");
|
|
if (!existsSync(aiTypesPath)) {
|
|
log(" [warn] ai types not found for ValueOf patch");
|
|
return;
|
|
}
|
|
|
|
const contents = readFileSync(aiTypesPath, "utf-8");
|
|
const valueOfMatch = contents.match(/type ValueOf[\\s\\S]*?;/);
|
|
if (valueOfMatch) {
|
|
const snippet = valueOfMatch[0].replace(/\\s+/g, " ").slice(0, 200);
|
|
log(` [debug] ValueOf alias snippet: ${snippet}`);
|
|
} else {
|
|
log(" [warn] ValueOf alias declaration not found");
|
|
}
|
|
|
|
let patched = contents.replace(/ObjectType\\s*\\[\\s*ValueType\\s*\\]/, "ObjectType[string]");
|
|
|
|
if (patched !== contents) {
|
|
writeFileSync(aiTypesPath, patched);
|
|
log(" [patch] Adjusted ValueOf alias for schema generation");
|
|
return;
|
|
}
|
|
|
|
const valueOfIndex = contents.indexOf("ValueOf");
|
|
const preview = valueOfIndex === -1 ? contents.slice(0, 200) : contents.slice(valueOfIndex, valueOfIndex + 200);
|
|
log(" [warn] ValueOf alias not found in ai types");
|
|
log(` [debug] ai types path: ${aiTypesPath}`);
|
|
log(` [debug] preview: ${preview.replace(/\\s+/g, " ").slice(0, 200)}`);
|
|
}
|
|
|
|
function ensureTypeFestShim(nodeModulesDir: string): void {
|
|
const typeFestDir = join(nodeModulesDir, "type-fest");
|
|
if (!existsSync(typeFestDir)) {
|
|
mkdirSync(typeFestDir, { recursive: true });
|
|
}
|
|
|
|
const packageJsonPath = join(typeFestDir, "package.json");
|
|
const typesPath = join(typeFestDir, "index.d.ts");
|
|
|
|
if (!existsSync(packageJsonPath)) {
|
|
const pkg = {
|
|
name: "type-fest",
|
|
version: "0.0.0",
|
|
types: "index.d.ts",
|
|
};
|
|
writeFileSync(packageJsonPath, JSON.stringify(pkg, null, 2));
|
|
}
|
|
|
|
const shim = `export type ValueOf<\n ObjectType,\n ValueType extends keyof ObjectType = keyof ObjectType,\n> = ObjectType[string];\n\nexport type Simplify<T> = { [KeyType in keyof T]: T[KeyType] } & {};\n`;
|
|
writeFileSync(typesPath, shim);
|
|
log(" [shim] Wrote type-fest ValueOf shim");
|
|
}
|
|
|
|
function generateSchema(entryPath: string, tsconfigPath: string): JSONSchema7 {
|
|
const config: Config = {
|
|
path: entryPath,
|
|
tsconfig: tsconfigPath,
|
|
type: TARGET_TYPE,
|
|
expose: "export",
|
|
skipTypeCheck: true,
|
|
};
|
|
|
|
const generator = createGenerator(config);
|
|
return generator.createSchema(TARGET_TYPE) as JSONSchema7;
|
|
}
|
|
|
|
function addSchemaMetadata(schema: JSONSchema7, version: string): JSONSchema7 {
|
|
const withMeta: JSONSchema7 = {
|
|
...schema,
|
|
$schema: schema.$schema ?? "http://json-schema.org/draft-07/schema#",
|
|
$id: SCHEMA_ID,
|
|
title: schema.title ?? TARGET_TYPE,
|
|
description: schema.description ?? `Vercel AI SDK v${version} ${TARGET_TYPE}`,
|
|
};
|
|
|
|
return withMeta;
|
|
}
|
|
|
|
function loadFallback(): JSONSchema7 | null {
|
|
if (!existsSync(OUTPUT_PATH)) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const content = readFileSync(OUTPUT_PATH, "utf-8");
|
|
return JSON.parse(content) as JSONSchema7;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function patchUiMessageTypes(nodeModulesDir: string): void {
|
|
const aiTypesPath = join(nodeModulesDir, "ai", "dist", "index.d.ts");
|
|
if (!existsSync(aiTypesPath)) {
|
|
log(" [warn] ai types not found for UIMessage patch");
|
|
return;
|
|
}
|
|
|
|
const contents = readFileSync(aiTypesPath, "utf-8");
|
|
let patched = contents;
|
|
|
|
const replaceAlias = (typeName: string, replacement: string): boolean => {
|
|
const start = patched.indexOf(`type ${typeName}`);
|
|
if (start === -1) {
|
|
log(` [warn] ${typeName} alias not found for patch`);
|
|
return false;
|
|
}
|
|
const end = patched.indexOf(";", start);
|
|
if (end === -1) {
|
|
log(` [warn] ${typeName} alias not terminated`);
|
|
return false;
|
|
}
|
|
const snippet = patched.slice(start, Math.min(end + 1, start + 400)).replace(/\\s+/g, " ");
|
|
log(` [debug] ${typeName} alias snippet: ${snippet}`);
|
|
|
|
patched = patched.slice(0, start) + replacement + patched.slice(end + 1);
|
|
return true;
|
|
};
|
|
|
|
const dataReplaced = replaceAlias(
|
|
"DataUIPart",
|
|
"type DataUIPart<DATA_TYPES extends UIDataTypes> = {\\n type: `data-${string}`;\\n id?: string;\\n data: unknown;\\n};",
|
|
);
|
|
if (dataReplaced) {
|
|
log(" [patch] Simplified DataUIPart to avoid indexed access");
|
|
}
|
|
|
|
const toolReplaced = replaceAlias(
|
|
"ToolUIPart",
|
|
"type ToolUIPart<TOOLS extends UITools = UITools> = {\\n type: `tool-${string}`;\\n} & UIToolInvocation<UITool>;",
|
|
);
|
|
if (toolReplaced) {
|
|
log(" [patch] Simplified ToolUIPart to avoid indexed access");
|
|
}
|
|
|
|
if (patched !== contents) {
|
|
writeFileSync(aiTypesPath, patched);
|
|
}
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
log("Vercel AI SDK UIMessage Schema Extractor");
|
|
log("========================================\n");
|
|
|
|
const args = parseArgs();
|
|
ensureOutputDir();
|
|
|
|
const registry = await fetchRegistry(REGISTRY_URL);
|
|
const version = args.version ?? resolveLatestVersion(registry, args.major);
|
|
|
|
log(`Target version: ai@${version}`);
|
|
|
|
const tempDir = mkdtempSync(join(tmpdir(), "vercel-ai-sdk-"));
|
|
const nodeModulesDir = join(tempDir, "node_modules");
|
|
|
|
try {
|
|
log(` [debug] temp dir: ${tempDir}`);
|
|
await installPackage("ai", version, nodeModulesDir, new Set());
|
|
ensureTypeFestShim(nodeModulesDir);
|
|
patchUiMessageTypes(nodeModulesDir);
|
|
patchValueOfAlias(nodeModulesDir);
|
|
|
|
const tsconfigPath = writeTempTsconfig(tempDir);
|
|
const entryPath = writeEntryFile(tempDir);
|
|
log(` [debug] entry path: ${entryPath}`);
|
|
log(` [debug] tsconfig path: ${tsconfigPath}`);
|
|
if (existsSync(entryPath)) {
|
|
const entryStat = statSync(entryPath);
|
|
log(` [debug] entry size: ${entryStat.size}`);
|
|
}
|
|
|
|
const schema = generateSchema(entryPath, tsconfigPath);
|
|
const schemaWithMeta = addSchemaMetadata(schema, version);
|
|
|
|
writeFileSync(OUTPUT_PATH, JSON.stringify(schemaWithMeta, null, 2));
|
|
log(`\n [wrote] ${OUTPUT_PATH}`);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
log(`\n [error] ${message}`);
|
|
if (error instanceof Error && error.stack) {
|
|
log(error.stack);
|
|
}
|
|
|
|
const fallback = loadFallback();
|
|
if (fallback) {
|
|
log(" [fallback] Keeping existing schema artifact");
|
|
return;
|
|
}
|
|
|
|
process.exitCode = 1;
|
|
} finally {
|
|
rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error("Fatal error:", error);
|
|
process.exit(1);
|
|
});
|