Files
tutti/tools/scripts/sync-workbench-openapi-schema.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

303 lines
8.4 KiB
JavaScript

import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { parseDocument, stringify } from "yaml";
const __dirname = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(__dirname, "../..");
const snapshotSchemaPath = resolve(
repoRoot,
"packages/workbench/snapshot/src/schema.json"
);
const openApiPath = resolve(
repoRoot,
"services/tuttid/api/openapi/tuttid.v1.yaml"
);
const checkOnly = process.argv.includes("--check");
const schemaNames = [
"WorkbenchSize",
"WorkbenchSafeArea",
"WorkbenchLayoutConstraints",
"WorkbenchLayoutBasis",
"WorkbenchLayoutPreset",
"WorkbenchNormalizedFrame",
"WorkbenchLockedLayout",
"WorkbenchFrame",
"WorkbenchSnapshotNode",
"WorkbenchSnapshotSpace",
"WorkbenchSnapshot"
];
const sourceSchema = JSON.parse(readFileSync(snapshotSchemaPath, "utf8"));
const openApiSource = readExistingText(openApiPath);
const syncedOpenApiSource = replaceWorkbenchSchemas(
openApiSource,
buildOpenApiWorkbenchSchemas(sourceSchema)
);
if (checkOnly) {
if (openApiSource !== syncedOpenApiSource) {
throw new Error(
"OpenAPI workbench schemas are out of sync with packages/workbench/snapshot/src/schema.json. Run pnpm sync:workbench-openapi-schema."
);
}
} else if (openApiSource !== syncedOpenApiSource) {
writeFileSync(openApiPath, syncedOpenApiSource, "utf8");
}
function buildOpenApiWorkbenchSchemas(schema) {
const defs = schema.$defs ?? {};
return {
WorkbenchSize: convertJsonSchemaToOpenApi(defs.size),
WorkbenchSafeArea: convertJsonSchemaToOpenApi(defs.safeArea),
WorkbenchLayoutConstraints: convertJsonSchemaToOpenApi(
defs.layoutConstraints
),
WorkbenchLayoutBasis: convertJsonSchemaToOpenApi(defs.layoutBasis),
WorkbenchLayoutPreset: convertJsonSchemaToOpenApi(defs.layoutPreset),
WorkbenchNormalizedFrame: convertJsonSchemaToOpenApi(defs.normalizedFrame),
WorkbenchLockedLayout: convertJsonSchemaToOpenApi(defs.lockedLayout),
WorkbenchFrame: convertJsonSchemaToOpenApi(defs.frame),
WorkbenchSnapshotNode: convertJsonSchemaToOpenApi(defs.node),
WorkbenchSnapshotSpace: convertJsonSchemaToOpenApi(defs.space),
WorkbenchSnapshot: convertJsonSchemaToOpenApi(
omitKeys(schema, ["$schema", "$id", "title", "$defs"])
)
};
}
function convertJsonSchemaToOpenApi(value) {
if (value === true) {
return { nullable: true };
}
if (Array.isArray(value)) {
return value.map(convertJsonSchemaToOpenApi);
}
if (!isRecord(value)) {
return value;
}
if (isNullableAnyOfRef(value)) {
return {
allOf: [convertJsonSchemaToOpenApi(value.anyOf[0])],
nullable: true
};
}
const output = {};
for (const [key, entryValue] of Object.entries(value)) {
if (key === "$ref") {
output.$ref = rewriteRef(entryValue);
continue;
}
if (key === "$defs" || key === "$schema" || key === "$id") {
continue;
}
if (key === "const") {
output.type = Number.isInteger(entryValue)
? "integer"
: typeof entryValue;
output.enum = [entryValue];
continue;
}
if (key === "exclusiveMinimum" && typeof entryValue === "number") {
output.minimum = entryValue;
output.exclusiveMinimum = true;
continue;
}
if (key === "uniqueItems") {
output.uniqueItems = entryValue;
continue;
}
if (key === "type" && Array.isArray(entryValue)) {
const nonNullTypes = entryValue.filter((item) => item !== "null");
if (nonNullTypes.length === 1 && entryValue.includes("null")) {
output.type = nonNullTypes[0];
output.nullable = true;
continue;
}
}
output[key] = convertJsonSchemaToOpenApi(entryValue);
}
if (
output.type === "object" &&
output.additionalProperties === undefined &&
output.properties === undefined
) {
output.additionalProperties = true;
}
if (Array.isArray(output.enum) && output.type === undefined) {
const enumTypes = new Set(output.enum.map((item) => typeof item));
if (enumTypes.size === 1) {
output.type = [...enumTypes][0];
}
}
return output;
}
function replaceWorkbenchSchemas(openApiSource, schemas) {
const document = parseDocument(openApiSource, { keepSourceTokens: true });
const schemasNode = document.getIn(["components", "schemas"], true);
if (!schemasNode?.items) {
throw new Error(`Unable to locate components.schemas in ${openApiPath}`);
}
const replacements = schemaNames
.map((schemaName) =>
locateSchemaReplacement(openApiSource, schemasNode, schemaName, schemas)
)
.filter(Boolean);
const missingSchemaNames = schemaNames.filter(
(schemaName) =>
!schemasNode.items.some((pair) => pair.key?.value === schemaName)
);
if (missingSchemaNames.length > 0) {
const firstWorkbenchSchema = replacements.reduce(
(first, replacement) =>
replacement.start < first.start ? replacement : first,
replacements[0]
);
if (!firstWorkbenchSchema) {
throw new Error(`Unable to locate Workbench schemas in ${openApiPath}`);
}
replacements.push({
start: firstWorkbenchSchema.start,
end: firstWorkbenchSchema.start,
content: missingSchemaNames
.map((schemaName) =>
renderIndentedSchema(schemaName, schemas[schemaName])
)
.join("")
});
}
let output = openApiSource;
for (const replacement of replacements.sort(
(left, right) => right.start - left.start
)) {
output =
output.slice(0, replacement.start) +
replacement.content +
output.slice(replacement.end);
}
return output;
}
function locateSchemaReplacement(
openApiSource,
schemasNode,
schemaName,
schemas
) {
const pairIndex = schemasNode.items.findIndex(
(pair) => pair.key?.value === schemaName
);
const pair = schemasNode.items[pairIndex];
if (!pair) {
return null;
}
if (!pair.key?.range || !pair.value?.range) {
throw new Error(`Unable to read ${schemaName} in ${openApiPath}`);
}
const nextPair = schemasNode.items[pairIndex + 1];
const start = lineStartBefore(openApiSource, pair.key.range[0]);
const end = nextPair?.key?.range
? lineStartBefore(openApiSource, nextPair.key.range[0])
: pair.value.range[2];
return {
start,
end,
content: renderIndentedSchema(schemaName, schemas[schemaName])
};
}
function renderIndentedSchema(schemaName, schema) {
return stringify(
{
[schemaName]: schema
},
{
lineWidth: 0,
singleQuote: false
}
)
.trimEnd()
.split("\n")
.map((line) => ` ${line}`)
.join("\n")
.concat("\n");
}
function lineStartBefore(source, index) {
return source.lastIndexOf("\n", index) + 1;
}
function rewriteRef(value) {
if (typeof value !== "string") {
throw new Error(`Expected string $ref, got ${typeof value}`);
}
return value
.replace("#/$defs/frame", "#/components/schemas/WorkbenchFrame")
.replace("#/$defs/size", "#/components/schemas/WorkbenchSize")
.replace("#/$defs/safeArea", "#/components/schemas/WorkbenchSafeArea")
.replace(
"#/$defs/layoutConstraints",
"#/components/schemas/WorkbenchLayoutConstraints"
)
.replace("#/$defs/layoutBasis", "#/components/schemas/WorkbenchLayoutBasis")
.replace(
"#/$defs/layoutPreset",
"#/components/schemas/WorkbenchLayoutPreset"
)
.replace(
"#/$defs/normalizedFrame",
"#/components/schemas/WorkbenchNormalizedFrame"
)
.replace(
"#/$defs/lockedLayout",
"#/components/schemas/WorkbenchLockedLayout"
)
.replace("#/$defs/node", "#/components/schemas/WorkbenchSnapshotNode")
.replace("#/$defs/space", "#/components/schemas/WorkbenchSnapshotSpace");
}
function isNullableAnyOfRef(value) {
return (
Array.isArray(value.anyOf) &&
value.anyOf.length === 2 &&
isRecord(value.anyOf[0]) &&
typeof value.anyOf[0].$ref === "string" &&
isRecord(value.anyOf[1]) &&
value.anyOf[1].type === "null"
);
}
function omitKeys(value, keys) {
const ignored = new Set(keys);
return Object.fromEntries(
Object.entries(value).filter(([key]) => !ignored.has(key))
);
}
function readExistingText(path) {
if (!existsSync(path)) {
return "";
}
return readFileSync(path, "utf8");
}
function isRecord(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}