@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
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { stringify } from "yaml";
|
|
2
|
+
const HTTP_METHODS = new Set(["get", "put", "post", "delete", "options", "head", "patch", "trace"]);
|
|
3
|
+
export function convertOpenApiToApiEndpointDocuments(openApi, options) {
|
|
4
|
+
const output = [];
|
|
5
|
+
for (const [path, pathItem] of Object.entries(openApi.paths ?? {})) {
|
|
6
|
+
if (!isRecord(pathItem)) {
|
|
7
|
+
continue;
|
|
8
|
+
}
|
|
9
|
+
for (const [method, operation] of Object.entries(pathItem)) {
|
|
10
|
+
if (!HTTP_METHODS.has(method) || !isRecord(operation)) {
|
|
11
|
+
continue;
|
|
12
|
+
}
|
|
13
|
+
const slug = slugify(operation.operationId ?? `${method}-${path}`);
|
|
14
|
+
const ctx = {
|
|
15
|
+
openApi,
|
|
16
|
+
schemas: {},
|
|
17
|
+
enums: {},
|
|
18
|
+
schemaNamesInProgress: new Set(),
|
|
19
|
+
};
|
|
20
|
+
const properties = {
|
|
21
|
+
method: method.toUpperCase(),
|
|
22
|
+
path,
|
|
23
|
+
responses: {},
|
|
24
|
+
};
|
|
25
|
+
const requestSchema = firstContentSchema(operation.requestBody?.content);
|
|
26
|
+
if (requestSchema) {
|
|
27
|
+
const requestName = `${slug}-request`;
|
|
28
|
+
createSchema(requestName, requestSchema, ctx, operation.requestBody?.description);
|
|
29
|
+
properties.request = requestName;
|
|
30
|
+
}
|
|
31
|
+
for (const [status, response] of Object.entries(operation.responses ?? {})) {
|
|
32
|
+
if (!isRecord(response)) {
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
const responseName = `${slug}-response-${status}`;
|
|
36
|
+
const responseSchema = firstContentSchema(response.content);
|
|
37
|
+
if (responseSchema) {
|
|
38
|
+
createSchema(responseName, responseSchema, ctx, response.description);
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
createEmptyBodySchema(responseName, response.description, ctx);
|
|
42
|
+
}
|
|
43
|
+
properties.responses[status] = responseName;
|
|
44
|
+
}
|
|
45
|
+
const document = {
|
|
46
|
+
"schema-version": "1.0",
|
|
47
|
+
id: `${options.component}.api-endpoints.${slug}`,
|
|
48
|
+
template: "APIEndpoint",
|
|
49
|
+
state: "proposed",
|
|
50
|
+
stability: "unstable",
|
|
51
|
+
name: `${method.toUpperCase()} ${path}`,
|
|
52
|
+
...(operation.description || operation.summary
|
|
53
|
+
? { description: operation.description ?? operation.summary }
|
|
54
|
+
: {}),
|
|
55
|
+
properties,
|
|
56
|
+
schemas: ctx.schemas,
|
|
57
|
+
};
|
|
58
|
+
if (Object.keys(ctx.enums).length > 0) {
|
|
59
|
+
document.enums = ctx.enums;
|
|
60
|
+
}
|
|
61
|
+
output.push({
|
|
62
|
+
fileName: `${slug}.yaml`,
|
|
63
|
+
document,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return output.sort((left, right) => left.fileName.localeCompare(right.fileName));
|
|
68
|
+
}
|
|
69
|
+
export function serializeApiEndpointDocument(document) {
|
|
70
|
+
return stringify(document, {
|
|
71
|
+
lineWidth: 0,
|
|
72
|
+
sortMapEntries: false,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
function createSchema(name, sourceSchema, ctx, description) {
|
|
76
|
+
if (ctx.schemas[name] || ctx.schemaNamesInProgress.has(name)) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
ctx.schemaNamesInProgress.add(name);
|
|
80
|
+
ctx.schemas[name] = {
|
|
81
|
+
...(description ? { description } : {}),
|
|
82
|
+
fields: {},
|
|
83
|
+
};
|
|
84
|
+
const schema = resolveSchema(sourceSchema, ctx.openApi);
|
|
85
|
+
const logicalName = typeof sourceSchema.$ref === "string" ? refTail(sourceSchema.$ref) : name;
|
|
86
|
+
const required = new Set(Array.isArray(schema.required) ? schema.required : []);
|
|
87
|
+
const fields = {};
|
|
88
|
+
if (schema.type === "array") {
|
|
89
|
+
fields.items = createField(`${name}Item`, schema.items ?? {}, ctx, false, logicalName);
|
|
90
|
+
fields.items.cardinality = "many";
|
|
91
|
+
}
|
|
92
|
+
else if (schema.type === "object" || isRecord(schema.properties) || isRecord(schema.additionalProperties)) {
|
|
93
|
+
for (const [fieldName, propertySchema] of Object.entries(schema.properties ?? {})) {
|
|
94
|
+
if (!isRecord(propertySchema)) {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
fields[fieldName] = createField(fieldName, propertySchema, ctx, required.has(fieldName), logicalName);
|
|
98
|
+
}
|
|
99
|
+
if (Object.keys(fields).length === 0 && isRecord(schema.additionalProperties)) {
|
|
100
|
+
fields.additionalProperties = createField("additionalProperties", schema.additionalProperties, ctx, true, logicalName);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
fields.value = createField("value", schema, ctx, true, logicalName);
|
|
105
|
+
}
|
|
106
|
+
ctx.schemas[name] = {
|
|
107
|
+
...(description || schema.description ? { description: description ?? schema.description } : {}),
|
|
108
|
+
fields,
|
|
109
|
+
};
|
|
110
|
+
ctx.schemaNamesInProgress.delete(name);
|
|
111
|
+
}
|
|
112
|
+
function createEmptyBodySchema(name, description, ctx) {
|
|
113
|
+
ctx.schemas[name] = {
|
|
114
|
+
...(description ? { description } : {}),
|
|
115
|
+
fields: {
|
|
116
|
+
message: {
|
|
117
|
+
scalarType: "string",
|
|
118
|
+
nullable: true,
|
|
119
|
+
cardinality: "one",
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
function createField(fieldName, sourceSchema, ctx, required, ownerName) {
|
|
125
|
+
const cardinality = sourceSchema.type === "array" ? "many" : "one";
|
|
126
|
+
const schema = sourceSchema.type === "array" && isRecord(sourceSchema.items) ? sourceSchema.items : sourceSchema;
|
|
127
|
+
if (isRecord(schema) && typeof schema.$ref === "string") {
|
|
128
|
+
const refName = refTail(schema.$ref);
|
|
129
|
+
const target = resolveSchema(schema, ctx.openApi);
|
|
130
|
+
createSchema(refName, target, ctx, target.description);
|
|
131
|
+
return {
|
|
132
|
+
objectRef: refName,
|
|
133
|
+
nullable: !required,
|
|
134
|
+
cardinality,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
if (Array.isArray(schema.enum)) {
|
|
138
|
+
const enumName = enumNameFor(ownerName, fieldName);
|
|
139
|
+
ctx.enums[enumName] = {
|
|
140
|
+
...(schema.description ? { description: schema.description } : {}),
|
|
141
|
+
values: Object.fromEntries(schema.enum.map((value) => [
|
|
142
|
+
slugify(String(value)),
|
|
143
|
+
{
|
|
144
|
+
name: String(value),
|
|
145
|
+
},
|
|
146
|
+
])),
|
|
147
|
+
};
|
|
148
|
+
return {
|
|
149
|
+
objectRef: enumName,
|
|
150
|
+
nullable: !required,
|
|
151
|
+
cardinality,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
if (schema.type === "object" || isRecord(schema.properties) || isRecord(schema.additionalProperties)) {
|
|
155
|
+
const objectName = pascalCase(fieldName);
|
|
156
|
+
createSchema(objectName, schema, ctx, schema.description);
|
|
157
|
+
return {
|
|
158
|
+
objectRef: objectName,
|
|
159
|
+
nullable: !required,
|
|
160
|
+
cardinality,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
return {
|
|
164
|
+
scalarType: scalarTypeFor(schema),
|
|
165
|
+
nullable: !required,
|
|
166
|
+
cardinality,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
function firstContentSchema(content) {
|
|
170
|
+
if (!isRecord(content)) {
|
|
171
|
+
return undefined;
|
|
172
|
+
}
|
|
173
|
+
const mediaType = ["application/json", "application/xml", "application/x-www-form-urlencoded"].find((type) => isRecord(content[type]?.schema)) ?? Object.keys(content).find((type) => isRecord(content[type]?.schema));
|
|
174
|
+
if (!mediaType) {
|
|
175
|
+
return undefined;
|
|
176
|
+
}
|
|
177
|
+
return content[mediaType].schema;
|
|
178
|
+
}
|
|
179
|
+
function resolveSchema(schema, openApi) {
|
|
180
|
+
if (typeof schema.$ref !== "string") {
|
|
181
|
+
return schema;
|
|
182
|
+
}
|
|
183
|
+
const pointer = schema.$ref.replace(/^#\//, "").split("/");
|
|
184
|
+
let current = openApi;
|
|
185
|
+
for (const segment of pointer) {
|
|
186
|
+
current = current?.[segment.replace(/~1/g, "/").replace(/~0/g, "~")];
|
|
187
|
+
}
|
|
188
|
+
if (!isRecord(current)) {
|
|
189
|
+
throw new Error(`Unable to resolve OpenAPI reference ${schema.$ref}`);
|
|
190
|
+
}
|
|
191
|
+
return current;
|
|
192
|
+
}
|
|
193
|
+
function scalarTypeFor(schema) {
|
|
194
|
+
if (schema.type === "integer") {
|
|
195
|
+
return "integer";
|
|
196
|
+
}
|
|
197
|
+
if (schema.type === "number") {
|
|
198
|
+
return "decimal";
|
|
199
|
+
}
|
|
200
|
+
if (schema.type === "boolean") {
|
|
201
|
+
return "boolean";
|
|
202
|
+
}
|
|
203
|
+
if (schema.type === "string" && schema.format === "date-time") {
|
|
204
|
+
return "datetime";
|
|
205
|
+
}
|
|
206
|
+
if (schema.type === "string" && schema.format === "date") {
|
|
207
|
+
return "date";
|
|
208
|
+
}
|
|
209
|
+
if (schema.type === "string" && schema.format === "time") {
|
|
210
|
+
return "time";
|
|
211
|
+
}
|
|
212
|
+
return "string";
|
|
213
|
+
}
|
|
214
|
+
function enumNameFor(ownerName, fieldName) {
|
|
215
|
+
if (fieldName.toLowerCase() === "status") {
|
|
216
|
+
return `${pascalCase(ownerName)}Status`;
|
|
217
|
+
}
|
|
218
|
+
return `${pascalCase(ownerName)}${pascalCase(fieldName)}`;
|
|
219
|
+
}
|
|
220
|
+
function refTail(ref) {
|
|
221
|
+
return ref.split("/").at(-1) ?? ref;
|
|
222
|
+
}
|
|
223
|
+
function slugify(value) {
|
|
224
|
+
return value
|
|
225
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
|
|
226
|
+
.replace(/[^a-zA-Z0-9]+/g, "-")
|
|
227
|
+
.replace(/^-|-$/g, "")
|
|
228
|
+
.toLowerCase();
|
|
229
|
+
}
|
|
230
|
+
function pascalCase(value) {
|
|
231
|
+
return value
|
|
232
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
233
|
+
.split(/[^a-zA-Z0-9]+/)
|
|
234
|
+
.filter(Boolean)
|
|
235
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
236
|
+
.join("");
|
|
237
|
+
}
|
|
238
|
+
function isRecord(value) {
|
|
239
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
240
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
const ADAPTER_OWNED = new Set(['method', 'path', 'operationId', 'type', 'nullable', 'cardinality', '$ref']);
|
|
2
|
+
const HUMAN_OWNED = new Set(['state', 'stability', 'notes']);
|
|
3
|
+
export function diffNodes(incoming, existing, specPath) {
|
|
4
|
+
const toAdd = [];
|
|
5
|
+
const toUpdate = [];
|
|
6
|
+
const incomingIds = new Set(incoming.map(n => n.id));
|
|
7
|
+
for (const node of incoming) {
|
|
8
|
+
const current = existing.get(node.id);
|
|
9
|
+
if (!current) {
|
|
10
|
+
toAdd.push(node);
|
|
11
|
+
continue;
|
|
12
|
+
}
|
|
13
|
+
const merged = {
|
|
14
|
+
...current,
|
|
15
|
+
properties: mergeProperties(current.properties, node.properties),
|
|
16
|
+
extractedFrom: node.extractedFrom,
|
|
17
|
+
derivation: node.derivation,
|
|
18
|
+
derivedBy: node.derivedBy,
|
|
19
|
+
lastModifiedAt: node.lastModifiedAt,
|
|
20
|
+
state: current.state,
|
|
21
|
+
stability: current.stability,
|
|
22
|
+
};
|
|
23
|
+
if (!nodesEqual(current, merged)) {
|
|
24
|
+
toUpdate.push(merged);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const toRemove = [];
|
|
28
|
+
for (const [id, node] of existing) {
|
|
29
|
+
if (node.extractedFrom === specPath && !incomingIds.has(id)) {
|
|
30
|
+
toRemove.push({ ...node, state: 'removed' });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return { toAdd, toUpdate, toRemove };
|
|
34
|
+
}
|
|
35
|
+
function mergeProperties(current, incoming) {
|
|
36
|
+
const merged = { ...current };
|
|
37
|
+
for (const [key, value] of Object.entries(incoming)) {
|
|
38
|
+
if (ADAPTER_OWNED.has(key)) {
|
|
39
|
+
merged[key] = value;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return merged;
|
|
43
|
+
}
|
|
44
|
+
function nodesEqual(a, b) {
|
|
45
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
46
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export class LoadError extends Error {
|
|
2
|
+
diagnostics;
|
|
3
|
+
constructor(diagnostics) {
|
|
4
|
+
const errorCount = diagnostics.filter(d => d.severity === 'error').length;
|
|
5
|
+
super(`Graph load failed with ${errorCount} error(s)`);
|
|
6
|
+
this.diagnostics = diagnostics;
|
|
7
|
+
this.name = 'LoadError';
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export class QueryError extends Error {
|
|
11
|
+
constructor(message) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = 'QueryError';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export { SourceError } from '../source/index.js';
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { parse as parseYaml } from 'yaml';
|
|
4
|
+
export function loadProjectConfig(cwd) {
|
|
5
|
+
const configPath = findConfigFile(cwd);
|
|
6
|
+
if (!configPath)
|
|
7
|
+
return {};
|
|
8
|
+
const content = readFileSync(configPath, 'utf8');
|
|
9
|
+
return parseYaml(content) ?? {};
|
|
10
|
+
}
|
|
11
|
+
function findConfigFile(cwd) {
|
|
12
|
+
let dir = cwd;
|
|
13
|
+
while (true) {
|
|
14
|
+
const candidate = path.join(dir, '.corum', 'config.yaml');
|
|
15
|
+
if (existsSync(candidate))
|
|
16
|
+
return candidate;
|
|
17
|
+
const parent = path.dirname(dir);
|
|
18
|
+
if (parent === dir)
|
|
19
|
+
return undefined;
|
|
20
|
+
dir = parent;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { FileGraphSource } from './file-source.js';
|
|
3
|
+
import { GitGraphSource } from './git-source.js';
|
|
4
|
+
import { SourceError } from './index.js';
|
|
5
|
+
import { loadProjectConfig } from './config-file.js';
|
|
6
|
+
export function createGraphRuntimeConfig(env = process.env, cwd = process.cwd()) {
|
|
7
|
+
const fileConfig = loadProjectConfig(cwd);
|
|
8
|
+
const e = {
|
|
9
|
+
...env,
|
|
10
|
+
CORUM_SOURCE: env.CORUM_SOURCE ?? fileConfig.source,
|
|
11
|
+
CORUM_GRAPH_PATH: env.CORUM_GRAPH_PATH ?? fileConfig.graph,
|
|
12
|
+
CORUM_GIT_LOCAL_PATH: env.CORUM_GIT_LOCAL_PATH ?? fileConfig.git_local_path,
|
|
13
|
+
CORUM_GIT_REMOTE_URL: env.CORUM_GIT_REMOTE_URL ?? fileConfig.git_remote_url,
|
|
14
|
+
CORUM_GIT_BRANCH: env.CORUM_GIT_BRANCH ?? fileConfig.git_branch,
|
|
15
|
+
CORUM_GIT_POLL_SECONDS: env.CORUM_GIT_POLL_SECONDS ?? (fileConfig.git_poll_seconds !== undefined ? String(fileConfig.git_poll_seconds) : undefined),
|
|
16
|
+
CORUM_GIT_TOKEN: env.CORUM_GIT_TOKEN ?? fileConfig.git_token,
|
|
17
|
+
CORUM_GIT_USERNAME: env.CORUM_GIT_USERNAME ?? fileConfig.git_username,
|
|
18
|
+
};
|
|
19
|
+
const sourceKind = (e.CORUM_SOURCE ?? 'filesystem').toLowerCase();
|
|
20
|
+
if (sourceKind === 'filesystem' || sourceKind === 'file' || sourceKind === 'fs') {
|
|
21
|
+
const graphPath = e.CORUM_GRAPH_PATH ?? path.join(cwd, '.corum/graph');
|
|
22
|
+
return {
|
|
23
|
+
kind: 'filesystem',
|
|
24
|
+
source: new FileGraphSource({ graphDir: graphPath }),
|
|
25
|
+
graphPath,
|
|
26
|
+
fileWatcherGraphPath: graphPath,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
if (sourceKind !== 'git') {
|
|
30
|
+
throw new SourceError(`unsupported CORUM_SOURCE: ${e.CORUM_SOURCE}`);
|
|
31
|
+
}
|
|
32
|
+
const localPath = emptyToUndefined(e.CORUM_GIT_LOCAL_PATH);
|
|
33
|
+
const remoteUrl = emptyToUndefined(e.CORUM_GIT_REMOTE_URL);
|
|
34
|
+
if (!localPath && !remoteUrl) {
|
|
35
|
+
throw new SourceError('CORUM_SOURCE=git requires CORUM_GIT_LOCAL_PATH or CORUM_GIT_REMOTE_URL');
|
|
36
|
+
}
|
|
37
|
+
if (localPath && remoteUrl) {
|
|
38
|
+
throw new SourceError('CORUM_SOURCE=git requires only one of CORUM_GIT_LOCAL_PATH or CORUM_GIT_REMOTE_URL');
|
|
39
|
+
}
|
|
40
|
+
const graphDir = '.corum/graph';
|
|
41
|
+
const token = emptyToUndefined(e.CORUM_GIT_TOKEN);
|
|
42
|
+
const auth = token
|
|
43
|
+
? { username: e.CORUM_GIT_USERNAME ?? 'x-access-token', token }
|
|
44
|
+
: undefined;
|
|
45
|
+
const repoLabel = localPath ?? remoteUrl;
|
|
46
|
+
const gitPollSeconds = parseOptionalSeconds(e.CORUM_GIT_POLL_SECONDS);
|
|
47
|
+
return {
|
|
48
|
+
kind: 'git',
|
|
49
|
+
source: new GitGraphSource({
|
|
50
|
+
localPath,
|
|
51
|
+
remoteUrl,
|
|
52
|
+
graphDir,
|
|
53
|
+
defaultBranch: emptyToUndefined(e.CORUM_GIT_BRANCH),
|
|
54
|
+
auth,
|
|
55
|
+
}),
|
|
56
|
+
graphPath: `git:${repoLabel}/${graphDir.replace(/\\/g, '/')}`,
|
|
57
|
+
gitPollSeconds,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
function emptyToUndefined(value) {
|
|
61
|
+
return value && value.trim() !== '' ? value : undefined;
|
|
62
|
+
}
|
|
63
|
+
function parseOptionalSeconds(value) {
|
|
64
|
+
if (!value || value.trim() === '')
|
|
65
|
+
return undefined;
|
|
66
|
+
const parsed = Number(value);
|
|
67
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
68
|
+
throw new SourceError('CORUM_GIT_POLL_SECONDS must be a positive number of seconds');
|
|
69
|
+
}
|
|
70
|
+
return parsed;
|
|
71
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export function listYamlKeys(content, prefix) {
|
|
2
|
+
const normalised = prefix && !prefix.endsWith('/') ? `${prefix}/` : prefix;
|
|
3
|
+
return [...content.keys()].filter(key => key.endsWith('.yaml') && (normalised === '' || key.startsWith(normalised)));
|
|
4
|
+
}
|
|
5
|
+
export function readYaml(content, key) {
|
|
6
|
+
const value = content.get(key);
|
|
7
|
+
if (value === undefined)
|
|
8
|
+
throw new Error(`${key} not found in ContentMap`);
|
|
9
|
+
return value;
|
|
10
|
+
}
|
|
11
|
+
export function hasKey(content, key) {
|
|
12
|
+
return content.has(key);
|
|
13
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import * as git from 'isomorphic-git';
|
|
5
|
+
import { parse as parseYaml } from 'yaml';
|
|
6
|
+
import { isPackRef } from '../loader/fs-utils.js';
|
|
7
|
+
import { SourceError } from './index.js';
|
|
8
|
+
const DEFAULT_GRAPH_DIR = '.corum/graph';
|
|
9
|
+
const DEFAULT_PACKS_PATH = '.corum/packs';
|
|
10
|
+
export class FileGraphSource {
|
|
11
|
+
graphDir;
|
|
12
|
+
defaultBranchOverride;
|
|
13
|
+
packsPath;
|
|
14
|
+
constructor(options = {}) {
|
|
15
|
+
this.graphDir = options.graphDir ?? DEFAULT_GRAPH_DIR;
|
|
16
|
+
this.defaultBranchOverride = options.defaultBranch;
|
|
17
|
+
this.packsPath = options.packsPath;
|
|
18
|
+
}
|
|
19
|
+
async defaultBranch() {
|
|
20
|
+
if (this.defaultBranchOverride)
|
|
21
|
+
return this.defaultBranchOverride;
|
|
22
|
+
try {
|
|
23
|
+
const repoRoot = await git.findRoot({ fs, filepath: this.graphDir });
|
|
24
|
+
const branch = await git.currentBranch({ fs, dir: repoRoot });
|
|
25
|
+
if (branch)
|
|
26
|
+
return branch;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
// Not a git repo, or detached HEAD.
|
|
30
|
+
}
|
|
31
|
+
return 'main';
|
|
32
|
+
}
|
|
33
|
+
async listBranches() {
|
|
34
|
+
return [await this.defaultBranch()];
|
|
35
|
+
}
|
|
36
|
+
async loadPackContent(_ref) {
|
|
37
|
+
const map = new Map();
|
|
38
|
+
const graphYamlPath = path.join(this.graphDir, 'graph.yaml');
|
|
39
|
+
if (!existsSync(graphYamlPath)) {
|
|
40
|
+
const packDir = path.resolve(this.graphDir, this.packsPath ?? DEFAULT_PACKS_PATH);
|
|
41
|
+
readPackTemplatesIntoMap(packDir, map);
|
|
42
|
+
return map;
|
|
43
|
+
}
|
|
44
|
+
const doc = parseYaml(readFileSync(graphYamlPath, 'utf-8'));
|
|
45
|
+
const packs = Array.isArray(doc.templatePacks) ? doc.templatePacks : [];
|
|
46
|
+
for (const pack of packs) {
|
|
47
|
+
if (!isPackRef(pack))
|
|
48
|
+
continue;
|
|
49
|
+
const packDir = path.resolve(this.graphDir, pack.path);
|
|
50
|
+
readPackTemplatesIntoMap(packDir, map);
|
|
51
|
+
}
|
|
52
|
+
return map;
|
|
53
|
+
}
|
|
54
|
+
async loadGraphContent(_ref) {
|
|
55
|
+
const map = new Map();
|
|
56
|
+
if (!existsSync(this.graphDir))
|
|
57
|
+
return map;
|
|
58
|
+
const excludeDirs = new Set(this.resolvePackDirs().map(d => path.resolve(d)));
|
|
59
|
+
walkYamlFilesIntoMap(this.graphDir, this.graphDir, map, excludeDirs);
|
|
60
|
+
return map;
|
|
61
|
+
}
|
|
62
|
+
resolvePackDirs() {
|
|
63
|
+
const graphYamlPath = path.join(this.graphDir, 'graph.yaml');
|
|
64
|
+
if (!existsSync(graphYamlPath)) {
|
|
65
|
+
return [path.resolve(this.graphDir, this.packsPath ?? DEFAULT_PACKS_PATH)];
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
const doc = parseYaml(readFileSync(graphYamlPath, 'utf-8'));
|
|
69
|
+
const packs = Array.isArray(doc.templatePacks) ? doc.templatePacks : [];
|
|
70
|
+
return packs.filter(isPackRef).map(p => path.resolve(this.graphDir, p.path));
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return [];
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
async commit(branch, changes, _message, options = {}) {
|
|
77
|
+
const defaultBranch = await this.defaultBranch();
|
|
78
|
+
if (branch !== defaultBranch) {
|
|
79
|
+
throw new SourceError(`FileGraphSource only supports its local branch '${defaultBranch}', got '${branch}'`);
|
|
80
|
+
}
|
|
81
|
+
if (options.replaceGraphContent && existsSync(this.graphDir)) {
|
|
82
|
+
rmSync(this.graphDir, { recursive: true, force: true });
|
|
83
|
+
}
|
|
84
|
+
for (const [key, content] of changes) {
|
|
85
|
+
const filePath = resolveContentPath(this.graphDir, key);
|
|
86
|
+
mkdirSync(path.dirname(filePath), { recursive: true });
|
|
87
|
+
writeFileSync(filePath, content);
|
|
88
|
+
}
|
|
89
|
+
// TODO: create a git commit for these changes via git.add() + git.commit().
|
|
90
|
+
// Currently _message is unused — file writes are persisted but not committed to git history.
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function readPackTemplatesIntoMap(packDir, map) {
|
|
94
|
+
if (!existsSync(packDir))
|
|
95
|
+
return;
|
|
96
|
+
const packName = path.basename(packDir);
|
|
97
|
+
const packContent = new Map();
|
|
98
|
+
walkYamlFilesIntoMap(packDir, packDir, packContent);
|
|
99
|
+
for (const [relKey, content] of packContent) {
|
|
100
|
+
map.set(`${packName}/${relKey}`, content);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
function resolveContentPath(baseDir, key) {
|
|
104
|
+
if (key.includes('\\') ||
|
|
105
|
+
key.includes('\0') ||
|
|
106
|
+
path.posix.isAbsolute(key) ||
|
|
107
|
+
path.win32.isAbsolute(key)) {
|
|
108
|
+
throw new SourceError(`invalid ContentMap key: ${key}`);
|
|
109
|
+
}
|
|
110
|
+
const normalised = path.posix.normalize(key);
|
|
111
|
+
if (normalised === '..' || normalised.startsWith('../') || normalised === '.') {
|
|
112
|
+
throw new SourceError(`invalid ContentMap key: ${key}`);
|
|
113
|
+
}
|
|
114
|
+
const resolvedBase = path.resolve(baseDir);
|
|
115
|
+
const resolvedPath = path.resolve(resolvedBase, ...normalised.split('/'));
|
|
116
|
+
const relative = path.relative(resolvedBase, resolvedPath);
|
|
117
|
+
if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
118
|
+
throw new SourceError(`ContentMap key escapes graphDir: ${key}`);
|
|
119
|
+
}
|
|
120
|
+
return resolvedPath;
|
|
121
|
+
}
|
|
122
|
+
function walkYamlFilesIntoMap(baseDir, currentDir, map, excludeDirs = new Set()) {
|
|
123
|
+
for (const entry of readdirSync(currentDir, { withFileTypes: true })) {
|
|
124
|
+
const fullPath = path.join(currentDir, entry.name);
|
|
125
|
+
if (entry.isDirectory()) {
|
|
126
|
+
if (!excludeDirs.has(path.resolve(fullPath))) {
|
|
127
|
+
walkYamlFilesIntoMap(baseDir, fullPath, map, excludeDirs);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
else if (entry.isFile() && entry.name.endsWith('.yaml')) {
|
|
131
|
+
const key = path.relative(baseDir, fullPath).split(path.sep).join('/');
|
|
132
|
+
map.set(key, readFileSync(fullPath, 'utf-8'));
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync, mkdirSync, rmSync } from 'node:fs';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import * as git from 'isomorphic-git';
|
|
7
|
+
import { SourceError } from './index.js';
|
|
8
|
+
const CACHE_BASE = path.join(os.homedir(), '.config', 'corum', 'cache');
|
|
9
|
+
export class GitCacheManager {
|
|
10
|
+
cacheDir(remoteUrl) {
|
|
11
|
+
const hash = createHash('sha256').update(remoteUrl).digest('hex').slice(0, 16);
|
|
12
|
+
return path.join(CACHE_BASE, hash);
|
|
13
|
+
}
|
|
14
|
+
async ensureCloned(remoteUrl, onAuth) {
|
|
15
|
+
const dir = this.cacheDir(remoteUrl);
|
|
16
|
+
mkdirSync(dir, { recursive: true });
|
|
17
|
+
if (!existsSync(path.join(dir, '.git'))) {
|
|
18
|
+
await clone(remoteUrl, dir, onAuth);
|
|
19
|
+
return dir;
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
await git.fetch({
|
|
23
|
+
fs,
|
|
24
|
+
http: (await import('isomorphic-git/http/node')).default,
|
|
25
|
+
dir,
|
|
26
|
+
remote: 'origin',
|
|
27
|
+
singleBranch: false,
|
|
28
|
+
onAuth,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
rmSync(dir, { recursive: true, force: true });
|
|
33
|
+
mkdirSync(dir, { recursive: true });
|
|
34
|
+
await clone(remoteUrl, dir, onAuth, 'failed to recover cache');
|
|
35
|
+
}
|
|
36
|
+
return dir;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
async function clone(remoteUrl, dir, onAuth, message = 'failed to clone') {
|
|
40
|
+
try {
|
|
41
|
+
await git.clone({
|
|
42
|
+
fs,
|
|
43
|
+
http: (await import('isomorphic-git/http/node')).default,
|
|
44
|
+
dir,
|
|
45
|
+
url: remoteUrl,
|
|
46
|
+
noCheckout: true,
|
|
47
|
+
singleBranch: false,
|
|
48
|
+
onAuth,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
throw new SourceError(`${message} ${remoteUrl}`, err);
|
|
53
|
+
}
|
|
54
|
+
}
|