PSA/ee/scripts/generate-chat-registry.mjs
Hermes 284313f908
Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Initial import of AlgaPSA codebase from PSA server
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz

Source: /opt/alga-psa on psa.joliet.tech
2026-06-22 16:12:17 -05:00

459 lines
15 KiB
JavaScript

#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath, pathToFileURL } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const repoRoot = path.resolve(__dirname, '..', '..');
const OVERRIDES_DIR = path.resolve(repoRoot, 'ee/docs/api-registry');
// Dual-edition: generate one registry per edition from its OpenAPI spec.
// - CE registry feeds the CE `meta/mcp-registry` endpoint + local connector.
// - EE registry additionally powers the in-app chat assistant.
// Set MCP_REGISTRY_EDITION=ce|ee to generate just one.
const TARGETS = [
{
edition: 'ce',
specPath: path.resolve(repoRoot, 'sdk/docs/openapi/alga-openapi.ce.json'),
outputPath: path.resolve(repoRoot, 'server/src/lib/mcp/registry.generated.ts'),
importPath: '@alga-psa/agent-tooling/registry/schema',
},
{
edition: 'ee',
specPath: path.resolve(repoRoot, 'sdk/docs/openapi/alga-openapi.ee.json'),
outputPath: path.resolve(repoRoot, 'ee/server/src/chat/registry/apiRegistry.generated.ts'),
importPath: './apiRegistry.schema',
},
];
const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete'];
const PLACEHOLDER_DESCRIPTION =
'This operation was generated automatically from the route inventory. Replace with canonical OpenAPI metadata.';
function singularize(segment) {
if (segment.endsWith('ies')) {
return `${segment.slice(0, -3)}y`;
}
if (segment.endsWith('ses')) {
return segment.slice(0, -2);
}
if (segment.endsWith('s') && !segment.endsWith('ss')) {
return segment.slice(0, -1);
}
return segment;
}
function humanizeSegment(segment) {
return segment
.replace(/[_-]+/g, ' ')
.trim()
.split(/\s+/)
.filter(Boolean)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
function deriveResourceSegments(pathName) {
const rawSegments = pathName.split('/').filter(Boolean);
const apiIndex = rawSegments.findIndex((segment) => segment === 'api');
const segments = apiIndex === -1 ? rawSegments : rawSegments.slice(apiIndex + 1);
if (segments[0] && /^v\d+$/i.test(segments[0])) {
segments.shift();
}
return segments;
}
function deriveFallbackNames(method, pathName) {
const segments = deriveResourceSegments(pathName);
const nonParamSegments = segments.filter((segment) => !segment.startsWith('{'));
const pathParams = segments.filter((segment) => segment.startsWith('{') && segment.endsWith('}'));
const primary = nonParamSegments[0] ?? 'resource';
const primaryLabel = humanizeSegment(singularize(primary));
const trailing = nonParamSegments.slice(1).map((segment) => humanizeSegment(singularize(segment)));
const tailLabel = trailing.join(' ');
if (method === 'get' && pathParams.length === 1 && nonParamSegments.length === 1) {
return {
displayName: `Get ${primaryLabel}`,
summary: `Get ${primaryLabel.toLowerCase()} by ID`,
};
}
if (method === 'put' && pathParams.length === 1 && nonParamSegments.length === 1) {
return {
displayName: `Update ${primaryLabel}`,
summary: `Update ${primaryLabel.toLowerCase()} by ID`,
};
}
if (method === 'patch' && pathParams.length === 1 && nonParamSegments.length === 1) {
return {
displayName: `Update ${primaryLabel}`,
summary: `Update ${primaryLabel.toLowerCase()} fields by ID`,
};
}
if (method === 'delete' && pathParams.length === 1 && nonParamSegments.length === 1) {
return {
displayName: `Delete ${primaryLabel}`,
summary: `Delete ${primaryLabel.toLowerCase()} by ID`,
};
}
if (method === 'get' && pathParams.length > 0 && tailLabel) {
return {
displayName: `List ${primaryLabel} ${tailLabel}`,
summary: `List ${tailLabel.toLowerCase()} for a ${primaryLabel.toLowerCase()}`,
};
}
return null;
}
function inferPathParameters(pathName, existingParameters) {
const existingPathParamNames = new Set(
(existingParameters ?? [])
.filter((param) => param.in === 'path')
.map((param) => param.name),
);
const inferred = [];
for (const match of pathName.matchAll(/\{([^}]+)\}/g)) {
const name = match[1];
if (!name || existingPathParamNames.has(name)) {
continue;
}
inferred.push({
name,
in: 'path',
required: true,
description:
name === 'id'
? 'Resource identifier.'
: `${humanizeSegment(name)} path parameter.`,
schema: {
type: 'string',
...(name === 'id' ? { format: 'uuid' } : {}),
},
});
}
return inferred;
}
function loadSpec(specPath) {
if (!fs.existsSync(specPath)) {
throw new Error(`OpenAPI spec not found at ${specPath}. Run npm run openapi:generate first.`);
}
const content = fs.readFileSync(specPath, 'utf-8');
return JSON.parse(content);
}
function resolveRef(spec, maybeRef) {
if (!maybeRef || typeof maybeRef !== 'object' || !('$ref' in maybeRef)) {
return maybeRef;
}
const ref = maybeRef.$ref;
if (typeof ref !== 'string' || !ref.startsWith('#/')) {
throw new Error(`Unsupported $ref format: ${ref}`);
}
const parts = ref.slice(2).split('/');
let current = spec;
for (const part of parts) {
current = current?.[part];
if (current === undefined) {
throw new Error(`Failed to resolve $ref: ${ref}`);
}
}
return current;
}
function cloneSchema(schema) {
if (schema === undefined) return undefined;
return JSON.parse(JSON.stringify(schema));
}
function collectParameters(spec, pathItem, operation) {
const params = [
...(Array.isArray(pathItem?.parameters) ? pathItem.parameters : []),
...(Array.isArray(operation?.parameters) ? operation.parameters : []),
];
const seen = new Set();
const collected = [];
for (const paramRef of params) {
if (!paramRef) continue;
const param = resolveRef(spec, paramRef);
if (!param || typeof param !== 'object') continue;
if ('in' in param === false || 'name' in param === false) continue;
if (param.in !== 'query' && param.in !== 'path' && param.in !== 'header') continue;
const key = `${param.in}:${param.name}`;
if (seen.has(key)) continue;
seen.add(key);
collected.push({
name: param.name,
in: param.in,
required: Boolean(param.required),
description: param.description,
schema: cloneSchema(resolveRef(spec, param.schema ?? {})),
});
}
return collected;
}
function extractRequestBody(spec, operation) {
if (!operation || !operation.requestBody) return { schema: undefined, example: undefined };
const requestBody = resolveRef(spec, operation.requestBody);
if (!requestBody || typeof requestBody !== 'object' || !requestBody.content) {
return { schema: undefined, example: undefined };
}
const jsonContent = requestBody.content['application/json'];
if (!jsonContent) return { schema: undefined, example: undefined };
const schema = jsonContent.schema ? resolveRef(spec, jsonContent.schema) : undefined;
const example = jsonContent.example ?? Object.values(jsonContent.examples ?? {})[0]?.value;
return {
schema: cloneSchema(schema),
example: cloneSchema(example),
};
}
function extractResponseBody(spec, operation) {
const responses = operation?.responses ?? {};
const statuses = Object.keys(responses).filter((code) => code.startsWith('2')).sort();
if (statuses.length === 0) return undefined;
const status = statuses[0];
const response = resolveRef(spec, responses[status]);
if (!response || typeof response !== 'object') return undefined;
if (!response.content) return undefined;
const jsonContent = response.content['application/json'];
if (!jsonContent) return undefined;
const schema = jsonContent.schema ? resolveRef(spec, jsonContent.schema) : undefined;
return cloneSchema(schema);
}
function loadOverrides(dir) {
const byId = new Map();
const byMethodPath = new Map();
if (!fs.existsSync(dir)) {
return { byId, byMethodPath };
}
const files = fs.readdirSync(dir).filter((file) => file.endsWith('.json'));
for (const file of files) {
const filePath = path.join(dir, file);
const content = fs.readFileSync(filePath, 'utf-8');
let parsed;
try {
parsed = JSON.parse(content);
} catch (error) {
throw new Error(`Failed to parse override file ${filePath}: ${error.message}`);
}
const entries = Array.isArray(parsed?.entries) ? parsed.entries : [];
for (const entry of entries) {
const match = entry?.match ?? {};
if (match.id) {
const list = byId.get(match.id) ?? [];
list.push(entry);
byId.set(match.id, list);
}
if (match.method && match.path) {
const key = `${String(match.method).toLowerCase()} ${match.path}`;
const list = byMethodPath.get(key) ?? [];
list.push(entry);
byMethodPath.set(key, list);
}
}
}
return { byId, byMethodPath };
}
function applyOverrides(entry, overrides) {
const matches = [];
if (overrides.byId.has(entry.id)) {
matches.push(...(overrides.byId.get(entry.id) ?? []));
}
const key = `${entry.method} ${entry.path}`;
if (overrides.byMethodPath.has(key)) {
matches.push(...(overrides.byMethodPath.get(key) ?? []));
}
for (const override of matches) {
const metadata = override?.metadata ?? {};
if (metadata.displayName) entry.displayName = metadata.displayName;
if (metadata.summary) entry.summary = metadata.summary;
if (metadata.description) entry.description = metadata.description;
if (metadata.rbacResource) entry.rbacResource = metadata.rbacResource;
if (typeof metadata.approvalRequired === 'boolean') {
entry.approvalRequired = metadata.approvalRequired;
}
if (Array.isArray(metadata.playbooks)) entry.playbooks = metadata.playbooks;
if (Array.isArray(metadata.examples)) {
entry.examples = metadata.examples.map((example) => normalizeExample(example));
}
if (Array.isArray(metadata.parameters)) entry.parameters = metadata.parameters;
if (metadata.requestBodySchema !== undefined) entry.requestBodySchema = metadata.requestBodySchema;
if (metadata.responseBodySchema !== undefined) entry.responseBodySchema = metadata.responseBodySchema;
}
}
function normalizeExample(example) {
if (!example || typeof example !== 'object') {
return example;
}
const normalized = { ...example };
const request = normalized.request;
if (!request || typeof request !== 'object' || Array.isArray(request)) {
return normalized;
}
if ('path' in request && !('params' in request)) {
const { path, ...rest } = request;
normalized.request = {
...rest,
params: path,
};
return normalized;
}
normalized.request = { ...request };
return normalized;
}
function createEntryId(method, pathName, operationId) {
if (operationId) return operationId;
return `${method}-${pathName.replace(/[{}]/g, '').replace(/[\\/]/g, '_').replace(/[^a-zA-Z0-9_]/g, '')}`.toLowerCase();
}
function collectOperations(spec) {
const entries = [];
const paths = spec.paths ?? {};
for (const [pathName, pathItem] of Object.entries(paths)) {
if (!pathItem || typeof pathItem !== 'object') continue;
for (const method of HTTP_METHODS) {
const operation = pathItem[method];
if (!operation || typeof operation !== 'object') continue;
const id = createEntryId(method, pathName, operation.operationId);
const parameters = collectParameters(spec, pathItem, operation);
const fallback = deriveFallbackNames(method, pathName);
const { schema: requestBodySchema, example: requestExample } = extractRequestBody(spec, operation);
const responseBodySchema = extractResponseBody(spec, operation);
const displayName = operation['x-chat-display-name']
?? operation.summary
?? fallback?.displayName
?? `${method.toUpperCase()} ${pathName}`;
const summary = operation.summary
?? fallback?.summary
?? `${method.toUpperCase()} ${pathName}`;
const description = operation.description;
const normalizedDescription =
description === PLACEHOLDER_DESCRIPTION && fallback ? undefined : description;
const entry = {
id,
method,
path: pathName,
displayName,
summary,
description: normalizedDescription,
tags: Array.isArray(operation.tags) ? operation.tags : [],
rbacResource: operation['x-chat-rbac-resource'] ?? operation['x-rbac-resource'],
approvalRequired: Boolean(operation['x-chat-approval-required']),
parameters: [...parameters, ...inferPathParameters(pathName, parameters)],
requestBodySchema,
requestExample,
responseBodySchema,
};
entries.push(entry);
}
}
return entries;
}
function writeOutput(entries, outputPath, importPath) {
const header = `/* eslint-disable */\n// AUTO-GENERATED FILE. DO NOT EDIT.\n// Generated by ee/scripts/generate-chat-registry.mjs\n`;
// `import type`: the schema is used only for the annotation, so it is erased
// at runtime — the CE registry can live in the server bundle without pulling
// @alga-psa/agent-tooling into the runtime graph.
const importLine = `import type { ChatApiRegistryEntry } from '${importPath}';\n\n`;
const sanitized = entries.map((entry) => JSON.parse(JSON.stringify(entry)));
const body = `export const chatApiRegistry: ChatApiRegistryEntry[] = ${JSON.stringify(sanitized, null, 2)};\n\nexport default chatApiRegistry;\n`;
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
fs.writeFileSync(outputPath, `${header}${importLine}${body}`);
}
function generateTarget(target, overrides) {
const spec = loadSpec(target.specPath);
const entries = collectOperations(spec);
for (const entry of entries) {
applyOverrides(entry, overrides);
}
writeOutput(entries, target.outputPath, target.importPath);
console.log(
`[${target.edition}] generated ${entries.length} entries -> ${path.relative(repoRoot, target.outputPath)}`,
);
return entries;
}
function main() {
const overrides = loadOverrides(OVERRIDES_DIR);
const only = process.env.MCP_REGISTRY_EDITION;
const targets = only ? TARGETS.filter((t) => t.edition === only) : TARGETS;
if (targets.length === 0) {
throw new Error(`No registry targets matched MCP_REGISTRY_EDITION="${only}".`);
}
const byEdition = {};
for (const target of targets) {
byEdition[target.edition] = generateTarget(target, overrides);
}
// Invariant (T005): the CE surface must be a subset of EE — EE is a superset.
if (byEdition.ce && byEdition.ee) {
const eeIds = new Set(byEdition.ee.map((e) => e.id));
const ceOnly = byEdition.ce.filter((e) => !eeIds.has(e.id)).map((e) => e.id);
if (ceOnly.length > 0) {
throw new Error(
`CE registry has ${ceOnly.length} endpoint(s) absent from EE (EE must be a superset): ` +
ceOnly.slice(0, 10).join(', '),
);
}
console.log(`OK: CE (${byEdition.ce.length}) is a subset of EE (${byEdition.ee.length}).`);
}
}
const arg1 = process.argv[1];
const arg1Url = arg1 ? pathToFileURL(path.resolve(arg1)).href : '';
if (arg1 && import.meta.url === arg1Url) {
main();
} else {
// Support running via `node ee/scripts/generate-chat-registry.mjs`
const resolvedArg = arg1 ? path.resolve(arg1) : '';
if (resolvedArg === __filename) {
main();
}
}