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
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz Source: /opt/alga-psa on psa.joliet.tech
459 lines
15 KiB
JavaScript
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();
|
|
}
|
|
}
|