Files
tutti/tools/scripts/generate-openapi.mjs
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

474 lines
12 KiB
JavaScript

import { createClient } from "@hey-api/openapi-ts";
import {
cpSync,
existsSync,
mkdtempSync,
mkdirSync,
readFileSync,
readdirSync,
rmSync,
writeFileSync
} from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, relative, resolve } from "node:path";
import { execFileSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import YAML from "yaml";
import {
normalizeOpenApiFragmentRefs,
resolveOpenApiFragmentPath as resolveFragmentPath
} from "./openapi-fragments.mjs";
const __dirname = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(__dirname, "../..");
const specPath = resolve(
repoRoot,
"services/tuttid/api/openapi/tuttid.v1.yaml"
);
const fragmentExtensionKey = "x-tutti-openapi-fragments";
const goTypesConfigPath = resolve(
repoRoot,
"services/tuttid/api/openapi/oapi-codegen.types.yaml"
);
const goServerConfigPath = resolve(
repoRoot,
"services/tuttid/api/openapi/oapi-codegen.server.yaml"
);
const goTypesOutputPath = resolve(
repoRoot,
"services/tuttid/api/generated/types.gen.go"
);
const goServerOutputPath = resolve(
repoRoot,
"services/tuttid/api/generated/server.gen.go"
);
const tsOutputDir = resolve(
repoRoot,
"packages/clients/tuttid-ts/src/generated"
);
const prettierConfigPath = resolve(
repoRoot,
"packages/configs/prettier/base.mjs"
);
const checkOnly = process.argv.includes("--check");
syncWorkbenchOpenApiSchema();
const scratchRoot = mkdtempSync(join(tmpdir(), "tutti-openapi-"));
try {
const composedSpecPath = resolve(scratchRoot, "tuttid.v1.composed.yaml");
const goTypesScratchPath = resolve(scratchRoot, "go/types.gen.go");
const goServerScratchPath = resolve(scratchRoot, "go/server.gen.go");
const tsScratchDir = resolve(scratchRoot, "ts");
const tsSpecPath = resolve(scratchRoot, "ts/tuttid-ts-spec.yaml");
writeComposedOpenAPISpec(composedSpecPath);
generateGo(goTypesConfigPath, goTypesScratchPath, composedSpecPath);
generateGo(goServerConfigPath, goServerScratchPath, composedSpecPath);
hardenGeneratedGoServer(goServerScratchPath);
writeTypeScriptSpec(tsSpecPath, composedSpecPath);
await generateTypeScript(tsScratchDir, tsSpecPath);
writeGeneratedFile(
goTypesOutputPath,
readFileSync(goTypesScratchPath, "utf8")
);
writeGeneratedFile(
goServerOutputPath,
readFileSync(goServerScratchPath, "utf8")
);
writeGeneratedDirectory(tsOutputDir, tsScratchDir);
} finally {
rmSync(scratchRoot, { force: true, recursive: true });
}
function generateGo(configPath, outputPath, inputPath) {
mkdirSync(dirname(outputPath), { recursive: true });
execFileSync(
"go",
[
"tool",
"oapi-codegen",
"-config",
configPath,
"-o",
outputPath,
inputPath
],
{
cwd: resolve(repoRoot, "services/tuttid"),
stdio: "inherit"
}
);
}
function writeComposedOpenAPISpec(outputPath) {
const document = YAML.parse(readFileSync(specPath, "utf8"));
const fragmentRefs = normalizeOpenApiFragmentRefs(
document?.[fragmentExtensionKey],
fragmentExtensionKey
);
delete document[fragmentExtensionKey];
for (const fragmentRef of fragmentRefs) {
const fragmentPath = resolveOpenApiFragmentPath(fragmentRef);
const fragment = YAML.parse(readFileSync(fragmentPath, "utf8"));
mergeOpenApiFragment(document, fragment, fragmentPath);
}
mkdirSync(dirname(outputPath), { recursive: true });
writeFileSync(outputPath, YAML.stringify(document), "utf8");
}
function resolveOpenApiFragmentPath(fragmentRef) {
return resolveFragmentPath(fragmentRef, {
repoRoot,
specPath,
resolvePackageSpecifier(specifier) {
return fileURLToPath(import.meta.resolve(specifier));
}
});
}
function mergeOpenApiFragment(document, fragment, fragmentPath) {
mergeTags(document, fragment, fragmentPath);
mergeMapSection(document, fragment, "paths", fragmentPath);
const fragmentComponents = fragment?.components;
if (
fragmentComponents &&
typeof fragmentComponents === "object" &&
!Array.isArray(fragmentComponents)
) {
document.components ??= {};
for (const componentSection of Object.keys(fragmentComponents)) {
mergeMapSection(
document.components,
fragmentComponents,
componentSection,
fragmentPath
);
}
}
}
function mergeTags(document, fragment, fragmentPath) {
if (!Array.isArray(fragment?.tags) || fragment.tags.length === 0) {
return;
}
document.tags ??= [];
const existingNames = new Set(
document.tags
.map((tag) => (tag && typeof tag === "object" ? tag.name : ""))
.filter(Boolean)
);
for (const tag of fragment.tags) {
if (!tag || typeof tag !== "object" || !tag.name) {
throw new Error(`Invalid OpenAPI tag in ${fragmentPath}`);
}
if (existingNames.has(tag.name)) {
continue;
}
document.tags.push(tag);
existingNames.add(tag.name);
}
}
function mergeMapSection(
targetParent,
sourceParent,
sectionName,
fragmentPath
) {
const source = sourceParent?.[sectionName];
if (!source) {
return;
}
if (typeof source !== "object" || Array.isArray(source)) {
throw new Error(
`OpenAPI section ${sectionName} in ${fragmentPath} must be an object`
);
}
targetParent[sectionName] ??= {};
const target = targetParent[sectionName];
for (const [key, value] of Object.entries(source)) {
if (target[key] == null) {
target[key] = value;
continue;
}
mergeOpenApiObject(
target[key],
value,
`${sectionName}.${key}`,
fragmentPath
);
}
}
function mergeOpenApiObject(target, source, location, fragmentPath) {
if (Array.isArray(target) || Array.isArray(source)) {
if (
location.endsWith(".enum") &&
Array.isArray(target) &&
Array.isArray(source)
) {
const seen = new Set(target);
for (const value of source) {
if (!seen.has(value)) {
target.push(value);
seen.add(value);
}
}
return;
}
if (JSON.stringify(target) === JSON.stringify(source)) {
return;
}
throw new Error(
`Conflicting OpenAPI array at ${location} from ${fragmentPath}`
);
}
if (!isPlainObject(target) || !isPlainObject(source)) {
if (target === source) {
return;
}
throw new Error(
`Conflicting OpenAPI value at ${location} from ${fragmentPath}`
);
}
for (const [key, value] of Object.entries(source)) {
const childLocation = `${location}.${key}`;
if (target[key] == null) {
target[key] = value;
continue;
}
mergeOpenApiObject(target[key], value, childLocation, fragmentPath);
}
}
function isPlainObject(value) {
return value != null && typeof value === "object" && !Array.isArray(value);
}
function hardenGeneratedGoServer(outputPath) {
const source = readFileSync(outputPath, "utf8");
const decodePattern =
"\tif err := json.NewDecoder(r.Body).Decode(&body); err != nil {\n";
const strictDecode = [
"\tdecoder := json.NewDecoder(r.Body)\n",
"\tdecoder.DisallowUnknownFields()\n",
"\tif err := decoder.Decode(&body); err != nil {\n"
].join("");
const hardened = source.replaceAll(decodePattern, strictDecode);
if (hardened === source) {
throw new Error(
`Generated Go server decode hardening did not find any JSON body decoders in ${outputPath}`
);
}
writeFileSync(outputPath, hardened, "utf8");
}
function syncWorkbenchOpenApiSchema() {
execFileSync(
process.execPath,
[
resolve(repoRoot, "tools/scripts/sync-workbench-openapi-schema.mjs"),
...(checkOnly ? ["--check"] : [])
],
{
cwd: repoRoot,
stdio: "inherit"
}
);
}
async function generateTypeScript(outputDir, inputPath) {
mkdirSync(outputDir, { recursive: true });
await createClient({
input: inputPath,
output: outputDir,
plugins: [
{
name: "@hey-api/typescript"
},
{
name: "@hey-api/client-fetch"
},
{
name: "@hey-api/sdk",
client: "@hey-api/client-fetch",
operations: "flat",
paramsStructure: "grouped",
responseStyle: "fields"
}
]
});
for (const entry of listDirectoryEntries(outputDir)) {
if (!entry.endsWith(".ts")) {
continue;
}
const filePath = resolve(outputDir, entry);
const source = readFileSync(filePath, "utf8");
const normalized = source.replaceAll(
/((?:from|export\s+\*\s+from)\s+['"])(\.[^'"]+)(['"])/g,
(_match, prefix, specifier, suffix) => {
if (
specifier.endsWith(".ts") ||
specifier.endsWith(".js") ||
specifier.endsWith(".json")
) {
return `${prefix}${specifier}${suffix}`;
}
const siblingFile = resolve(dirname(filePath), `${specifier}.ts`);
if (existsSync(siblingFile)) {
return `${prefix}${specifier}.ts${suffix}`;
}
const siblingIndex = resolve(dirname(filePath), specifier, "index.ts");
if (existsSync(siblingIndex)) {
return `${prefix}${specifier}/index.ts${suffix}`;
}
return `${prefix}${specifier}.ts${suffix}`;
}
);
if (normalized !== source) {
writeFileSync(filePath, normalized, "utf8");
}
}
formatGeneratedTypeScript(outputDir);
}
function writeTypeScriptSpec(outputPath, inputPath) {
const document = YAML.parse(readFileSync(inputPath, "utf8"));
if (
document?.paths &&
typeof document.paths === "object" &&
!Array.isArray(document.paths)
) {
delete document.paths[
"/v1/workspaces/{workspaceID}/terminals/{terminalID}/ws"
];
}
mkdirSync(dirname(outputPath), { recursive: true });
writeFileSync(outputPath, YAML.stringify(document), "utf8");
}
function formatGeneratedTypeScript(outputDir) {
const tsFiles = listDirectoryEntries(outputDir)
.filter((entry) => entry.endsWith(".ts"))
.map((entry) => resolve(outputDir, entry));
if (tsFiles.length === 0) {
return;
}
execFileSync(
process.platform === "win32" ? "pnpm.cmd" : "pnpm",
["exec", "prettier", "--write", "--config", prettierConfigPath, ...tsFiles],
{
cwd: repoRoot,
stdio: "inherit"
}
);
}
function writeGeneratedFile(targetPath, content) {
mkdirSync(dirname(targetPath), { recursive: true });
if (checkOnly) {
const existing = readExistingText(targetPath);
if (existing !== content) {
throw new Error(`Generated API artifacts are out of date: ${targetPath}`);
}
return;
}
writeFileSync(targetPath, content, "utf8");
}
function writeGeneratedDirectory(targetDir, sourceDir) {
if (checkOnly) {
compareDirectories(targetDir, sourceDir);
return;
}
rmSync(targetDir, { force: true, recursive: true });
mkdirSync(dirname(targetDir), { recursive: true });
cpSync(sourceDir, targetDir, { recursive: true });
}
function compareDirectories(targetDir, sourceDir) {
const targetEntries = listDirectoryEntries(targetDir);
const sourceEntries = listDirectoryEntries(sourceDir);
const sourceSet = new Set(sourceEntries);
for (const entry of targetEntries) {
if (!sourceSet.has(entry)) {
throw new Error(
`Generated API artifacts are out of date: unexpected file ${join(targetDir, entry)}`
);
}
}
for (const entry of sourceEntries) {
const targetPath = resolve(targetDir, entry);
const sourcePath = resolve(sourceDir, entry);
if (!targetEntries.includes(entry)) {
throw new Error(
`Generated API artifacts are out of date: missing file ${targetPath}`
);
}
const targetContent = readExistingText(targetPath);
const sourceContent = readFileSync(sourcePath, "utf8");
if (targetContent !== sourceContent) {
throw new Error(`Generated API artifacts are out of date: ${targetPath}`);
}
}
}
function listDirectoryEntries(rootDir) {
if (!existsSync(rootDir)) {
return [];
}
return walk(rootDir, rootDir).sort();
}
function walk(rootDir, currentDir) {
const entries = [];
for (const entry of readdirSync(currentDir, { withFileTypes: true })) {
const fullPath = resolve(currentDir, entry.name);
if (entry.isDirectory()) {
entries.push(...walk(rootDir, fullPath));
continue;
}
entries.push(relative(rootDir, fullPath));
}
return entries;
}
function readExistingText(path) {
if (!existsSync(path)) {
return "";
}
return readFileSync(path, "utf8");
}