@atolis-hq/corum 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +223 -0
- package/dist/src/adapters/index.js +12 -0
- package/dist/src/adapters/openapi/index.js +12 -0
- package/dist/src/adapters/openapi/mapper.js +218 -0
- package/dist/src/adapters/openapi/parser.js +16 -0
- package/dist/src/bin/corum.js +164 -0
- package/dist/src/cli.js +20 -0
- package/dist/src/graph/index.js +128 -0
- package/dist/src/graph/overlay.js +136 -0
- package/dist/src/import/config.js +39 -0
- package/dist/src/import/runner.js +56 -0
- package/dist/src/loader/cluster-loader.js +120 -0
- package/dist/src/loader/constants.js +32 -0
- package/dist/src/loader/edge-loader.js +59 -0
- package/dist/src/loader/fs-utils.js +20 -0
- package/dist/src/loader/index.js +108 -0
- package/dist/src/loader/pack-loader.js +99 -0
- package/dist/src/mcp/index.js +333 -0
- package/dist/src/mcp/serializers.js +68 -0
- package/dist/src/openapi-to-api-endpoints.js +240 -0
- package/dist/src/reconcile/index.js +46 -0
- package/dist/src/schema/index.js +16 -0
- package/dist/src/source/config-file.js +22 -0
- package/dist/src/source/config.js +71 -0
- package/dist/src/source/content-utils.js +13 -0
- package/dist/src/source/file-source.js +135 -0
- package/dist/src/source/git-cache.js +54 -0
- package/dist/src/source/git-source.js +333 -0
- package/dist/src/source/index.js +8 -0
- package/dist/src/web/server.js +557 -0
- package/dist/src/writer/graph-writer.js +153 -0
- package/package.json +36 -0
- package/web/app.jsx +668 -0
- package/web/favicon.svg +19 -0
- package/web/index.html +41 -0
- package/web/nav.js +141 -0
- package/web/primitives.jsx +583 -0
- package/web/router.js +49 -0
- package/web/style.css +827 -0
package/README.md
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
<div width="100%" align="center">
|
|
2
|
+
<img src="assets/corum-logo.svg" width="120" height="120" /><br>
|
|
3
|
+
<h1>Corum</h1>
|
|
4
|
+
</div>
|
|
5
|
+
|
|
6
|
+
Corum loads design graph files from disk into an in-memory graph and exposes the graph through MCP tools.
|
|
7
|
+
|
|
8
|
+
## Requirements
|
|
9
|
+
|
|
10
|
+
- Node.js 20 or newer
|
|
11
|
+
- npm
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
From the repository root:
|
|
16
|
+
|
|
17
|
+
```powershell
|
|
18
|
+
npm install
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
This installs TypeScript, the MCP SDK, and YAML parsing dependencies.
|
|
22
|
+
|
|
23
|
+
## Build
|
|
24
|
+
|
|
25
|
+
Compile the TypeScript sources into `dist/`:
|
|
26
|
+
|
|
27
|
+
```powershell
|
|
28
|
+
npm run build
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
The build command runs `tsc`.
|
|
32
|
+
|
|
33
|
+
## Test
|
|
34
|
+
|
|
35
|
+
Run the full test suite:
|
|
36
|
+
|
|
37
|
+
```powershell
|
|
38
|
+
npm test
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
This compiles the project and runs the Node test runner against:
|
|
42
|
+
|
|
43
|
+
- `test/schema.test.ts`
|
|
44
|
+
- `test/loader.test.ts`
|
|
45
|
+
- `test/graph.test.ts`
|
|
46
|
+
- `test/mcp.test.ts`
|
|
47
|
+
- `test/writer.test.ts`
|
|
48
|
+
- `test/serializer.test.ts`
|
|
49
|
+
|
|
50
|
+
The fixture graph used by the tests is in `fixtures/sample-graph`. The tests verify that it loads as `45` nodes and `38` edges.
|
|
51
|
+
|
|
52
|
+
## Run The MCP Server
|
|
53
|
+
|
|
54
|
+
Build first:
|
|
55
|
+
|
|
56
|
+
```powershell
|
|
57
|
+
npm run build
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Run the MCP server against the default graph path:
|
|
61
|
+
|
|
62
|
+
```powershell
|
|
63
|
+
npm run mcp
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
By default, the server loads:
|
|
67
|
+
|
|
68
|
+
```text
|
|
69
|
+
.corum/graph
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
To run it against the sample graph fixture instead:
|
|
73
|
+
|
|
74
|
+
```powershell
|
|
75
|
+
$env:CORUM_GRAPH_PATH = "fixtures/sample-graph"
|
|
76
|
+
npm run mcp
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
To reload the in-memory graph when graph YAML or template YAML files change, pass `--watch` to the built server:
|
|
80
|
+
|
|
81
|
+
```powershell
|
|
82
|
+
node dist/src/mcp/index.js --watch
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
The same watcher can be enabled for the web server with `node dist/src/web/server.js --watch`, or for either server by setting `CORUM_FILE_WATCHER=true`.
|
|
86
|
+
|
|
87
|
+
Starting with powershell
|
|
88
|
+
```
|
|
89
|
+
$env:CORUM_GRAPH_PATH = "fixtures/sample-graph";
|
|
90
|
+
$env:CORUM_WEB_PORT = 3001;
|
|
91
|
+
$env:CORUM_FILE_WATCHER="true";
|
|
92
|
+
npm run web
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
To run the web app against a git repository instead of a filesystem graph path, set `CORUM_SOURCE=git` before starting the app.
|
|
96
|
+
|
|
97
|
+
For a local git repository:
|
|
98
|
+
|
|
99
|
+
```powershell
|
|
100
|
+
$env:CORUM_SOURCE = "git"
|
|
101
|
+
$env:CORUM_GIT_LOCAL_PATH = "C:\git\atolis-hq\corum-design-graph"
|
|
102
|
+
$env:CORUM_GIT_BRANCH = "main"
|
|
103
|
+
$env:CORUM_GIT_POLL_SECONDS = 10
|
|
104
|
+
$env:CORUM_WEB_PORT = 3001
|
|
105
|
+
npm run web
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
For a remote repository:
|
|
109
|
+
|
|
110
|
+
```powershell
|
|
111
|
+
$env:CORUM_SOURCE = "git"
|
|
112
|
+
$env:CORUM_GIT_REMOTE_URL = "https://github.com/org/design-repo.git"
|
|
113
|
+
$env:CORUM_GIT_BRANCH = "main"
|
|
114
|
+
$env:CORUM_GIT_POLL_SECONDS = 10
|
|
115
|
+
$env:CORUM_WEB_PORT = 3001
|
|
116
|
+
npm run web
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
For private remote repositories, also set `CORUM_GIT_TOKEN`. `CORUM_GIT_USERNAME` defaults to `x-access-token` when a token is present.
|
|
120
|
+
|
|
121
|
+
The same git source config is used by `npm run mcp`. Git-backed startup expects graph files in `.corum/graph` and template packs in `.corum/packs`, and loads the selected branch at process start.
|
|
122
|
+
|
|
123
|
+
`CORUM_GIT_POLL_SECONDS` is optional. When set to a positive number of seconds, the web server polls the git source for branch/ref changes, invalidates its cached multi-branch view, and reloads the app automatically. If it is not set, git-backed content is not polled.
|
|
124
|
+
|
|
125
|
+
`CORUM_FILE_WATCHER` only watches filesystem graph paths. It does not watch git refs. For git-backed web sessions, you can either enable `CORUM_GIT_POLL_SECONDS` or use the always-visible `Reload` button in the branch bar to force a refresh.
|
|
126
|
+
|
|
127
|
+
The MCP server exposes these tools:
|
|
128
|
+
|
|
129
|
+
- `list_nodes`: lists graph nodes, optionally filtered by `template`, `component`, `state`, or `stability`
|
|
130
|
+
- `list_templates`: lists loaded graph templates with summary metadata
|
|
131
|
+
- `get_template`: returns full details for a loaded graph template
|
|
132
|
+
- `get_cluster`: returns a root node, owned child nodes, and edges inside that cluster
|
|
133
|
+
- `get_linked_fields`: returns `maps-to` edges touching fields owned by a root node
|
|
134
|
+
|
|
135
|
+
Each tool accepts an optional `format` argument:
|
|
136
|
+
|
|
137
|
+
- `yaml`: default, human-readable YAML
|
|
138
|
+
- `json`: pretty JSON
|
|
139
|
+
- `toon`: TOON output via the official `@toon-format/toon` encoder for lower token use
|
|
140
|
+
|
|
141
|
+
Each tool also accepts `compact_keys: true` to shorten common graph keys before serialization. This works with all formats:
|
|
142
|
+
|
|
143
|
+
```text
|
|
144
|
+
id -> i
|
|
145
|
+
template -> t
|
|
146
|
+
component -> cp
|
|
147
|
+
state -> s
|
|
148
|
+
stability -> st
|
|
149
|
+
schemaVersion -> sv
|
|
150
|
+
lastModifiedAt -> lm
|
|
151
|
+
extractedFrom -> xf
|
|
152
|
+
properties -> p
|
|
153
|
+
root -> r
|
|
154
|
+
children -> ch
|
|
155
|
+
edges -> e
|
|
156
|
+
nodes -> n
|
|
157
|
+
from -> fr
|
|
158
|
+
to -> to
|
|
159
|
+
type -> ty
|
|
160
|
+
notes -> nt
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
## MCP Client Configuration
|
|
164
|
+
|
|
165
|
+
This repo includes a project-level `.mcp.json`:
|
|
166
|
+
|
|
167
|
+
```json
|
|
168
|
+
{
|
|
169
|
+
"mcpServers": {
|
|
170
|
+
"corum": {
|
|
171
|
+
"command": "node",
|
|
172
|
+
"args": ["dist/src/mcp/index.js"],
|
|
173
|
+
"env": {
|
|
174
|
+
"CORUM_GRAPH_PATH": "fixtures/sample-graph"
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Build the project before using this config from an MCP client:
|
|
182
|
+
|
|
183
|
+
```powershell
|
|
184
|
+
npm run build
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
The checked-in config points at `fixtures/sample-graph` so the tools return sample nodes immediately. Change `CORUM_GRAPH_PATH` to `.corum/graph` when you have graph component files there.
|
|
188
|
+
|
|
189
|
+
## MCP Smoke Test
|
|
190
|
+
|
|
191
|
+
Run a local MCP client against the configured server and print graph data:
|
|
192
|
+
|
|
193
|
+
```powershell
|
|
194
|
+
npm run mcp:smoke
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
The smoke test starts the MCP server over stdio and calls:
|
|
198
|
+
|
|
199
|
+
- `list_nodes`
|
|
200
|
+
- `list_nodes` filtered to `APIEndpoint`
|
|
201
|
+
- `get_cluster` for `orders.DomainModel.order`
|
|
202
|
+
- `get_linked_fields` for `orders.DomainModel.order`
|
|
203
|
+
|
|
204
|
+
## Useful Development Commands
|
|
205
|
+
|
|
206
|
+
Type-check without emitting files:
|
|
207
|
+
|
|
208
|
+
```powershell
|
|
209
|
+
npx tsc --noEmit
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
Run one compiled test file after building:
|
|
213
|
+
|
|
214
|
+
```powershell
|
|
215
|
+
npm run build
|
|
216
|
+
node --test dist/test/loader.test.js
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
Clean generated build output manually if needed:
|
|
220
|
+
|
|
221
|
+
```powershell
|
|
222
|
+
Remove-Item -Recurse -Force dist
|
|
223
|
+
```
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
const registry = new Map();
|
|
2
|
+
export function registerAdapter(adapter) {
|
|
3
|
+
registry.set(adapter.adapterId, adapter);
|
|
4
|
+
}
|
|
5
|
+
export function getAdapter(adapterId) {
|
|
6
|
+
const adapter = registry.get(adapterId);
|
|
7
|
+
if (!adapter)
|
|
8
|
+
throw new Error(`Unknown adapter: ${adapterId}`);
|
|
9
|
+
return adapter;
|
|
10
|
+
}
|
|
11
|
+
import { OpenAPIAdapter } from './openapi/index.js';
|
|
12
|
+
registerAdapter(new OpenAPIAdapter());
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { parseSpec } from './parser.js';
|
|
2
|
+
import { mapDocument } from './mapper.js';
|
|
3
|
+
export class OpenAPIAdapter {
|
|
4
|
+
adapterId = 'openapi';
|
|
5
|
+
async import(entry, context) {
|
|
6
|
+
const { document, diagnostics } = await parseSpec(entry.spec);
|
|
7
|
+
if (!document)
|
|
8
|
+
return { nodes: [], edges: [], diagnostics };
|
|
9
|
+
const { nodes, edges, diagnostics: mapDiagnostics } = mapDocument(document, entry, context.packConfig);
|
|
10
|
+
return { nodes, edges, diagnostics: [...diagnostics, ...mapDiagnostics] };
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
export function deriveComponent(path, mapping) {
|
|
2
|
+
if (mapping.strategy === 'hardcoded')
|
|
3
|
+
return mapping.component;
|
|
4
|
+
if (mapping.strategy === 'tag')
|
|
5
|
+
return undefined;
|
|
6
|
+
const segments = path.split('/').filter(Boolean);
|
|
7
|
+
if ('pattern' in mapping) {
|
|
8
|
+
const match = path.match(mapping.pattern);
|
|
9
|
+
return match?.[1];
|
|
10
|
+
}
|
|
11
|
+
return segments[mapping.segment];
|
|
12
|
+
}
|
|
13
|
+
export function deriveScalarType(type, format, scalarTypes) {
|
|
14
|
+
if (format && scalarTypes[`${type}/${format}`])
|
|
15
|
+
return scalarTypes[`${type}/${format}`];
|
|
16
|
+
return scalarTypes[type];
|
|
17
|
+
}
|
|
18
|
+
export function isRefSchema(schema) {
|
|
19
|
+
return typeof schema === 'object' && schema !== null && '$ref' in schema;
|
|
20
|
+
}
|
|
21
|
+
export function deriveNodeId(kind, component, name, parentId, section) {
|
|
22
|
+
if (kind === 'operation')
|
|
23
|
+
return `${component}.APIEndpoint.${name}`;
|
|
24
|
+
return `${parentId}.${section}.${name}`;
|
|
25
|
+
}
|
|
26
|
+
export function refName(ref) {
|
|
27
|
+
return ref.split('/').pop() ?? ref;
|
|
28
|
+
}
|
|
29
|
+
export function mapDocument(document, entry, packConfig) {
|
|
30
|
+
const nodes = [];
|
|
31
|
+
const edges = [];
|
|
32
|
+
const diagnostics = [];
|
|
33
|
+
const sharedSchemas = new Map();
|
|
34
|
+
if (document.components?.schemas) {
|
|
35
|
+
for (const [name, schema] of Object.entries(document.components.schemas)) {
|
|
36
|
+
if (isRefSchema(schema))
|
|
37
|
+
continue;
|
|
38
|
+
const s = schema;
|
|
39
|
+
if (s.type !== 'object' && s.enum) {
|
|
40
|
+
const component = deriveComponentForSchema(name, document, entry);
|
|
41
|
+
if (!component)
|
|
42
|
+
continue;
|
|
43
|
+
const enumId = `${component}.EnumDefinition.${name}`;
|
|
44
|
+
const enumNode = makeNode(packConfig.constructs.enumDefinition?.template ?? 'EnumDefinition', component, entry.spec, enumId);
|
|
45
|
+
nodes.push(enumNode);
|
|
46
|
+
sharedSchemas.set(name, enumId);
|
|
47
|
+
s.enum.forEach((value) => {
|
|
48
|
+
const valueId = deriveNodeId('enumValue', undefined, String(value), enumId, 'values');
|
|
49
|
+
const valueNode = makeNode(packConfig.constructs.enumValue?.template ?? 'EnumValue', component, entry.spec, valueId);
|
|
50
|
+
valueNode.properties = { name: String(value) };
|
|
51
|
+
nodes.push(valueNode);
|
|
52
|
+
edges.push({ id: `${enumId}__has-value__${valueId}`, from: enumId, to: valueId, type: 'has-value', state: 'implemented', stability: 'unstable' });
|
|
53
|
+
});
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
const component = deriveComponentForSchema(name, document, entry);
|
|
57
|
+
if (!component) {
|
|
58
|
+
diagnostics.push({ severity: 'warning', file: entry.spec, message: `Cannot derive component for schema ${name}, skipping` });
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const schemaId = `${component}.Schema.${name}`;
|
|
62
|
+
sharedSchemas.set(name, schemaId);
|
|
63
|
+
const node = makeNode(packConfig.constructs.requestSchema?.template ?? 'Schema', component, entry.spec, schemaId);
|
|
64
|
+
nodes.push(node);
|
|
65
|
+
emitFields(s, schemaId, 'fields', packConfig, entry.spec, nodes, edges, diagnostics, sharedSchemas);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
for (const [urlPath, pathItem] of Object.entries(document.paths ?? {})) {
|
|
69
|
+
if (!pathItem)
|
|
70
|
+
continue;
|
|
71
|
+
const methods = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options'];
|
|
72
|
+
for (const method of methods) {
|
|
73
|
+
const operation = pathItem[method];
|
|
74
|
+
if (!operation)
|
|
75
|
+
continue;
|
|
76
|
+
const operationId = operation.operationId ?? `${method}-${urlPath.replace(/\//g, '-').replace(/^-/, '')}`;
|
|
77
|
+
const component = entry.componentMapping.strategy === 'tag'
|
|
78
|
+
? operation.tags?.[0]
|
|
79
|
+
: deriveComponent(urlPath, entry.componentMapping);
|
|
80
|
+
if (!component) {
|
|
81
|
+
diagnostics.push({ severity: 'warning', file: entry.spec, message: `Cannot derive component for ${method.toUpperCase()} ${urlPath}, skipping` });
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
const endpointId = deriveNodeId('operation', component, operationId);
|
|
85
|
+
const endpointNode = makeNode(packConfig.constructs.operation.template, component, entry.spec, endpointId);
|
|
86
|
+
endpointNode.properties = {
|
|
87
|
+
method: method.toUpperCase(),
|
|
88
|
+
path: urlPath,
|
|
89
|
+
...(operation.operationId && { operationId: operation.operationId }),
|
|
90
|
+
...(operation.summary && { description: operation.summary }),
|
|
91
|
+
};
|
|
92
|
+
nodes.push(endpointNode);
|
|
93
|
+
const requestBody = operation.requestBody;
|
|
94
|
+
if (requestBody?.content) {
|
|
95
|
+
const jsonContent = requestBody.content['application/json'];
|
|
96
|
+
if (jsonContent?.schema) {
|
|
97
|
+
emitSchemaNode(jsonContent.schema, `${operationId}-request`, endpointId, 'schemas', packConfig, entry.spec, nodes, edges, diagnostics, sharedSchemas);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
for (const [status, response] of Object.entries(operation.responses ?? {})) {
|
|
101
|
+
const responseObj = response;
|
|
102
|
+
const jsonContent = responseObj.content?.['application/json'];
|
|
103
|
+
if (jsonContent?.schema) {
|
|
104
|
+
emitSchemaNode(jsonContent.schema, `${operationId}-response-${status}`, endpointId, 'schemas', packConfig, entry.spec, nodes, edges, diagnostics, sharedSchemas);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return { nodes, edges, diagnostics };
|
|
110
|
+
}
|
|
111
|
+
function makeNode(template, component, specPath, id) {
|
|
112
|
+
return {
|
|
113
|
+
id,
|
|
114
|
+
template,
|
|
115
|
+
component,
|
|
116
|
+
state: 'implemented',
|
|
117
|
+
stability: 'unstable',
|
|
118
|
+
schemaVersion: '1',
|
|
119
|
+
lastModifiedAt: new Date().toISOString().split('T')[0],
|
|
120
|
+
extractedFrom: specPath,
|
|
121
|
+
derivation: 'determined',
|
|
122
|
+
derivedBy: 'adapter:openapi',
|
|
123
|
+
properties: {},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
function emitSchemaNode(schema, name, parentId, section, packConfig, specPath, nodes, edges, diagnostics, sharedSchemas) {
|
|
127
|
+
if (isRefSchema(schema)) {
|
|
128
|
+
const refId = sharedSchemas.get(refName(schema.$ref));
|
|
129
|
+
if (refId) {
|
|
130
|
+
const parent = nodes.find(n => n.id === parentId);
|
|
131
|
+
if (parent)
|
|
132
|
+
parent.properties[`${section}.${name}`] = refId;
|
|
133
|
+
}
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const schemaId = deriveNodeId('schema', undefined, name, parentId, section);
|
|
137
|
+
const [component] = parentId.split('.');
|
|
138
|
+
const node = makeNode(packConfig.constructs.requestSchema?.template ?? 'Schema', component, specPath, schemaId);
|
|
139
|
+
nodes.push(node);
|
|
140
|
+
edges.push({ id: `${parentId}__has-field__${schemaId}`, from: parentId, to: schemaId, type: 'has-field', state: 'implemented', stability: 'unstable' });
|
|
141
|
+
emitFields(schema, schemaId, 'fields', packConfig, specPath, nodes, edges, diagnostics, sharedSchemas);
|
|
142
|
+
}
|
|
143
|
+
function emitFields(schema, parentId, section, packConfig, specPath, nodes, edges, diagnostics, sharedSchemas) {
|
|
144
|
+
for (const [fieldName, fieldSchema] of Object.entries(schema.properties ?? {})) {
|
|
145
|
+
const fieldId = deriveNodeId('field', undefined, fieldName, parentId, section);
|
|
146
|
+
const [component] = parentId.split('.');
|
|
147
|
+
const fieldNode = makeNode(packConfig.constructs.schemaProperty?.template ?? 'Field', component, specPath, fieldId);
|
|
148
|
+
const required = Array.isArray(schema.required) && schema.required.includes(fieldName);
|
|
149
|
+
if (isRefSchema(fieldSchema)) {
|
|
150
|
+
const ref = refName(fieldSchema.$ref);
|
|
151
|
+
fieldNode.properties = { $ref: sharedSchemas.get(ref) ?? ref, nullable: !required, cardinality: 'one' };
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
const fs = fieldSchema;
|
|
155
|
+
if (fs.enum && fs.type !== 'object') {
|
|
156
|
+
const enumRef = sharedSchemas.get(fieldName);
|
|
157
|
+
fieldNode.properties = { ...(enumRef ? { $ref: enumRef } : { type: 'string' }), nullable: !required, cardinality: 'one' };
|
|
158
|
+
}
|
|
159
|
+
else if (fs.type === 'array') {
|
|
160
|
+
const items = fs.items;
|
|
161
|
+
if (isRefSchema(items)) {
|
|
162
|
+
fieldNode.properties = { objectRef: refName(items.$ref), nullable: !required, cardinality: 'many' };
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
const itemType = deriveScalarType(items?.type ?? 'string', items?.format, packConfig.scalarTypes);
|
|
166
|
+
fieldNode.properties = { type: itemType ?? 'string', nullable: !required, cardinality: 'many' };
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
const scalarType = deriveScalarType(fs.type ?? 'string', fs.format, packConfig.scalarTypes);
|
|
171
|
+
if (scalarType) {
|
|
172
|
+
fieldNode.properties = { type: scalarType, nullable: !required, cardinality: 'one' };
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
diagnostics.push({ severity: 'warning', file: specPath, message: `Unknown type for field ${fieldId}: ${fs.type}/${fs.format}` });
|
|
176
|
+
fieldNode.properties = { type: 'string', nullable: !required, cardinality: 'one' };
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
nodes.push(fieldNode);
|
|
181
|
+
edges.push({ id: `${parentId}__has-field__${fieldId}`, from: parentId, to: fieldId, type: 'has-field', state: 'implemented', stability: 'unstable' });
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
function deriveComponentForSchema(name, document, entry, visited = new Set()) {
|
|
185
|
+
if (visited.has(name))
|
|
186
|
+
return undefined;
|
|
187
|
+
visited.add(name);
|
|
188
|
+
// Direct: find an operation that references this schema
|
|
189
|
+
for (const [urlPath, pathItem] of Object.entries(document.paths ?? {})) {
|
|
190
|
+
if (!pathItem)
|
|
191
|
+
continue;
|
|
192
|
+
const methods = ['get', 'post', 'put', 'patch', 'delete'];
|
|
193
|
+
for (const method of methods) {
|
|
194
|
+
const operation = pathItem[method];
|
|
195
|
+
if (!operation)
|
|
196
|
+
continue;
|
|
197
|
+
if (referencesSchema(operation, name)) {
|
|
198
|
+
return entry.componentMapping.strategy === 'tag'
|
|
199
|
+
? operation.tags?.[0]
|
|
200
|
+
: deriveComponent(urlPath, entry.componentMapping);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
// Indirect: find another component schema that references this one and use its component
|
|
205
|
+
for (const [schemaName, schema] of Object.entries(document.components?.schemas ?? {})) {
|
|
206
|
+
if (schemaName === name || isRefSchema(schema))
|
|
207
|
+
continue;
|
|
208
|
+
if (JSON.stringify(schema).includes(`"#/components/schemas/${name}"`)) {
|
|
209
|
+
const comp = deriveComponentForSchema(schemaName, document, entry, visited);
|
|
210
|
+
if (comp)
|
|
211
|
+
return comp;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return undefined;
|
|
215
|
+
}
|
|
216
|
+
function referencesSchema(operation, schemaName) {
|
|
217
|
+
return JSON.stringify(operation).includes(`"#/components/schemas/${schemaName}"`);
|
|
218
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import SwaggerParser from '@apidevtools/swagger-parser';
|
|
2
|
+
export async function parseSpec(specPath) {
|
|
3
|
+
const diagnostics = [];
|
|
4
|
+
try {
|
|
5
|
+
const document = await SwaggerParser.bundle(specPath);
|
|
6
|
+
return { document, diagnostics };
|
|
7
|
+
}
|
|
8
|
+
catch (err) {
|
|
9
|
+
diagnostics.push({
|
|
10
|
+
severity: 'error',
|
|
11
|
+
file: specPath,
|
|
12
|
+
message: `Failed to parse OpenAPI spec: ${err instanceof Error ? err.message : String(err)}`,
|
|
13
|
+
});
|
|
14
|
+
return { document: null, diagnostics };
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { loadImportConfig, buildOpenAPIConfig } from '../import/config.js';
|
|
5
|
+
import { runImport } from '../import/runner.js';
|
|
6
|
+
import { loadGraph } from '../loader/index.js';
|
|
7
|
+
import { startMcpServer } from '../mcp/index.js';
|
|
8
|
+
import { createGraphRuntimeConfig } from '../source/config.js';
|
|
9
|
+
import { startWebServer } from '../web/server.js';
|
|
10
|
+
const program = new Command();
|
|
11
|
+
program
|
|
12
|
+
.name('corum')
|
|
13
|
+
.description('Corum graph CLI')
|
|
14
|
+
.version('0.1.0');
|
|
15
|
+
// ── mcp ──────────────────────────────────────────────────────────────────────
|
|
16
|
+
program
|
|
17
|
+
.command('mcp')
|
|
18
|
+
.description('Start the MCP stdio server (+ web UI by default)')
|
|
19
|
+
.option('--no-web', 'Suppress the web UI')
|
|
20
|
+
.option('--watch', 'Enable file watcher')
|
|
21
|
+
.option('--graph <path>', 'Override graph path')
|
|
22
|
+
.action(async (opts) => {
|
|
23
|
+
if (opts.graph)
|
|
24
|
+
process.env.CORUM_GRAPH_PATH = path.resolve(opts.graph);
|
|
25
|
+
await startMcpServer({ noWeb: !opts.web, watch: opts.watch ?? false });
|
|
26
|
+
});
|
|
27
|
+
// ── web ──────────────────────────────────────────────────────────────────────
|
|
28
|
+
program
|
|
29
|
+
.command('web')
|
|
30
|
+
.description('Start the web UI')
|
|
31
|
+
.option('--port <n>', 'Port to listen on', parseInt)
|
|
32
|
+
.option('--graph <path>', 'Override graph path')
|
|
33
|
+
.action(async (opts) => {
|
|
34
|
+
if (opts.graph)
|
|
35
|
+
process.env.CORUM_GRAPH_PATH = path.resolve(opts.graph);
|
|
36
|
+
const config = createGraphRuntimeConfig();
|
|
37
|
+
let graph;
|
|
38
|
+
try {
|
|
39
|
+
graph = await loadGraph({ source: config.source, strict: true });
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
process.stderr.write(`[ERROR] ${err instanceof Error ? err.message : String(err)}\n`);
|
|
43
|
+
process.exit(2);
|
|
44
|
+
}
|
|
45
|
+
await startWebServer(graph, {
|
|
46
|
+
graphPath: config.graphPath,
|
|
47
|
+
source: config.source,
|
|
48
|
+
port: opts.port,
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
// ── init ─────────────────────────────────────────────────────────────────────
|
|
52
|
+
const CONFIG_TEMPLATE = `# Corum project configuration
|
|
53
|
+
# Uncomment and set the options relevant to your setup.
|
|
54
|
+
# All values can be overridden by environment variables (CORUM_*) or CLI flags.
|
|
55
|
+
|
|
56
|
+
# Source type: 'file' (default) or 'git'
|
|
57
|
+
# Maps to: CORUM_SOURCE
|
|
58
|
+
# source: file
|
|
59
|
+
|
|
60
|
+
# ── File source (default) ─────────────────────────────────────────────────────
|
|
61
|
+
# Local path to the graph directory.
|
|
62
|
+
# Maps to: CORUM_GRAPH_PATH
|
|
63
|
+
# graph: .corum/graph
|
|
64
|
+
|
|
65
|
+
# ── Git source ────────────────────────────────────────────────────────────────
|
|
66
|
+
# Uncomment 'source: git' above and configure one of the following:
|
|
67
|
+
|
|
68
|
+
# Local path to a git repository containing the graph.
|
|
69
|
+
# Maps to: CORUM_GIT_LOCAL_PATH
|
|
70
|
+
# git_local_path: /path/to/repo
|
|
71
|
+
|
|
72
|
+
# Remote URL of a git repository containing the graph.
|
|
73
|
+
# Maps to: CORUM_GIT_REMOTE_URL
|
|
74
|
+
# git_remote_url: https://github.com/org/repo
|
|
75
|
+
|
|
76
|
+
# Default branch to load (git source only).
|
|
77
|
+
# Maps to: CORUM_GIT_BRANCH
|
|
78
|
+
# git_branch: main
|
|
79
|
+
|
|
80
|
+
# How often to poll the remote for changes, in seconds (remote git only).
|
|
81
|
+
# Maps to: CORUM_GIT_POLL_SECONDS
|
|
82
|
+
# git_poll_seconds: 30
|
|
83
|
+
|
|
84
|
+
# Auth token for private repositories. Prefer setting CORUM_GIT_TOKEN as an
|
|
85
|
+
# environment variable rather than storing a token in this file.
|
|
86
|
+
# git_token: ""
|
|
87
|
+
|
|
88
|
+
# Auth username (default: x-access-token, suits GitHub PATs and Actions tokens).
|
|
89
|
+
# Maps to: CORUM_GIT_USERNAME
|
|
90
|
+
# git_username: x-access-token
|
|
91
|
+
`;
|
|
92
|
+
program
|
|
93
|
+
.command('init')
|
|
94
|
+
.description('Scaffold .corum/config.yaml with commented defaults')
|
|
95
|
+
.action(() => {
|
|
96
|
+
const configPath = path.join(process.cwd(), '.corum', 'config.yaml');
|
|
97
|
+
if (existsSync(configPath)) {
|
|
98
|
+
process.stdout.write(`.corum/config.yaml already exists — not overwriting\n`);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
mkdirSync(path.dirname(configPath), { recursive: true });
|
|
102
|
+
writeFileSync(configPath, CONFIG_TEMPLATE);
|
|
103
|
+
process.stdout.write(`Created .corum/config.yaml\n`);
|
|
104
|
+
});
|
|
105
|
+
// ── import ───────────────────────────────────────────────────────────────────
|
|
106
|
+
const importCmd = program.command('import')
|
|
107
|
+
.description('Import specifications into the graph')
|
|
108
|
+
.option('--config <path>', 'Path to import config YAML')
|
|
109
|
+
.option('--graph <path>', 'Override CORUM_GRAPH_PATH')
|
|
110
|
+
.action(async (opts) => {
|
|
111
|
+
if (!opts.config) {
|
|
112
|
+
importCmd.help();
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
const runtimeConfig = buildRuntimeConfig(opts.graph);
|
|
117
|
+
const config = loadImportConfig(path.resolve(opts.config));
|
|
118
|
+
const result = await runImport(config, runtimeConfig);
|
|
119
|
+
reportDiagnostics(result.diagnostics);
|
|
120
|
+
if (result.diagnostics.some(d => d.severity === 'error'))
|
|
121
|
+
process.exit(1);
|
|
122
|
+
}
|
|
123
|
+
catch (err) {
|
|
124
|
+
process.stderr.write(`[ERROR] ${err instanceof Error ? err.message : String(err)}\n`);
|
|
125
|
+
process.exit(2);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
importCmd
|
|
129
|
+
.command('openapi <spec>')
|
|
130
|
+
.description('Import an OpenAPI spec into the graph')
|
|
131
|
+
.option('--component-strategy <strategy>', 'Component mapping: uri-segment, tag, hardcoded', 'uri-segment')
|
|
132
|
+
.option('--segment <n>', 'URI segment index (uri-segment strategy)', parseInt)
|
|
133
|
+
.option('--pattern <regex>', 'Regex pattern (uri-segment strategy)')
|
|
134
|
+
.option('--component <name>', 'Component name (hardcoded strategy)')
|
|
135
|
+
.option('--graph <path>', 'Override CORUM_GRAPH_PATH')
|
|
136
|
+
.action(async (spec, opts) => {
|
|
137
|
+
try {
|
|
138
|
+
const runtimeConfig = buildRuntimeConfig(opts.graph);
|
|
139
|
+
const entry = buildOpenAPIConfig(spec, opts.componentStrategy, opts.segment, opts.pattern, opts.component);
|
|
140
|
+
const result = await runImport({ imports: [entry] }, runtimeConfig);
|
|
141
|
+
reportDiagnostics(result.diagnostics);
|
|
142
|
+
if (result.diagnostics.some(d => d.severity === 'error'))
|
|
143
|
+
process.exit(1);
|
|
144
|
+
}
|
|
145
|
+
catch (err) {
|
|
146
|
+
process.stderr.write(`[ERROR] ${err instanceof Error ? err.message : String(err)}\n`);
|
|
147
|
+
process.exit(2);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
function buildRuntimeConfig(graphOverride) {
|
|
151
|
+
if (graphOverride)
|
|
152
|
+
process.env.CORUM_GRAPH_PATH = path.resolve(graphOverride);
|
|
153
|
+
return createGraphRuntimeConfig();
|
|
154
|
+
}
|
|
155
|
+
function reportDiagnostics(diagnostics) {
|
|
156
|
+
for (const d of diagnostics) {
|
|
157
|
+
const prefix = d.severity === 'error' ? 'ERROR' : 'WARN';
|
|
158
|
+
process.stderr.write(`[${prefix}] ${d.file}: ${d.message}\n`);
|
|
159
|
+
}
|
|
160
|
+
const errors = diagnostics.filter(d => d.severity === 'error').length;
|
|
161
|
+
const warnings = diagnostics.filter(d => d.severity === 'warning').length;
|
|
162
|
+
process.stdout.write(`Import complete. ${errors} error(s), ${warnings} warning(s).\n`);
|
|
163
|
+
}
|
|
164
|
+
program.parse();
|
package/dist/src/cli.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { parse } from "yaml";
|
|
4
|
+
import { convertOpenApiToApiEndpointDocuments, serializeApiEndpointDocument, } from "./openapi-to-api-endpoints.js";
|
|
5
|
+
const [sourcePath, targetRoot] = process.argv.slice(2);
|
|
6
|
+
if (!sourcePath || !targetRoot) {
|
|
7
|
+
console.error("Usage: node dist/src/cli.js <openapi-yaml> <graph-component-dir>");
|
|
8
|
+
process.exit(1);
|
|
9
|
+
}
|
|
10
|
+
const source = await readFile(sourcePath, "utf8");
|
|
11
|
+
const openApi = parse(source);
|
|
12
|
+
const component = path.basename(targetRoot);
|
|
13
|
+
const outputDir = path.join(targetRoot, "api-endpoints");
|
|
14
|
+
const documents = convertOpenApiToApiEndpointDocuments(openApi, { component });
|
|
15
|
+
await rm(outputDir, { recursive: true, force: true });
|
|
16
|
+
await mkdir(outputDir, { recursive: true });
|
|
17
|
+
for (const { fileName, document } of documents) {
|
|
18
|
+
await writeFile(path.join(outputDir, fileName), serializeApiEndpointDocument(document), "utf8");
|
|
19
|
+
}
|
|
20
|
+
console.log(`Wrote ${documents.length} APIEndpoint files to ${outputDir}`);
|