@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,333 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import * as git from 'isomorphic-git';
|
|
4
|
+
import { parse as parseYaml } from 'yaml';
|
|
5
|
+
import { SourceError } from './index.js';
|
|
6
|
+
import { GitCacheManager } from './git-cache.js';
|
|
7
|
+
const DEFAULT_GRAPH_DIR = '.corum/graph';
|
|
8
|
+
export class GitGraphSource {
|
|
9
|
+
graphDir;
|
|
10
|
+
localPath;
|
|
11
|
+
remoteUrl;
|
|
12
|
+
defaultBranchOverride;
|
|
13
|
+
auth;
|
|
14
|
+
branchLoad;
|
|
15
|
+
cacheManager;
|
|
16
|
+
cachedDir;
|
|
17
|
+
constructor(options) {
|
|
18
|
+
if (!options.localPath && !options.remoteUrl) {
|
|
19
|
+
throw new SourceError('GitGraphSource requires either localPath or remoteUrl');
|
|
20
|
+
}
|
|
21
|
+
if (options.localPath && options.remoteUrl) {
|
|
22
|
+
throw new SourceError('GitGraphSource requires either localPath or remoteUrl, not both');
|
|
23
|
+
}
|
|
24
|
+
this.graphDir = normalizeRepoPath('', options.graphDir ?? DEFAULT_GRAPH_DIR);
|
|
25
|
+
this.localPath = options.localPath;
|
|
26
|
+
this.remoteUrl = options.remoteUrl;
|
|
27
|
+
this.defaultBranchOverride = options.defaultBranch;
|
|
28
|
+
this.auth = options.auth;
|
|
29
|
+
this.branchLoad = options.branchLoad ?? {};
|
|
30
|
+
this.cacheManager = new GitCacheManager();
|
|
31
|
+
}
|
|
32
|
+
onAuth() {
|
|
33
|
+
if (!this.auth)
|
|
34
|
+
return undefined;
|
|
35
|
+
return () => ({ username: this.auth.username, password: this.auth.token });
|
|
36
|
+
}
|
|
37
|
+
async dir() {
|
|
38
|
+
if (this.localPath)
|
|
39
|
+
return this.localPath;
|
|
40
|
+
this.cachedDir = await this.cacheManager.ensureCloned(this.remoteUrl, this.onAuth());
|
|
41
|
+
return this.cachedDir;
|
|
42
|
+
}
|
|
43
|
+
async defaultBranch() {
|
|
44
|
+
if (this.defaultBranchOverride)
|
|
45
|
+
return this.defaultBranchOverride;
|
|
46
|
+
try {
|
|
47
|
+
const dir = await this.dir();
|
|
48
|
+
if (this.remoteUrl) {
|
|
49
|
+
const branches = await git.listBranches({ fs, dir, remote: 'origin' });
|
|
50
|
+
if (branches.includes('main'))
|
|
51
|
+
return 'main';
|
|
52
|
+
if (branches.includes('master'))
|
|
53
|
+
return 'master';
|
|
54
|
+
if (branches.length > 0)
|
|
55
|
+
return branches[0];
|
|
56
|
+
}
|
|
57
|
+
const branch = await git.currentBranch({ fs, dir });
|
|
58
|
+
if (branch)
|
|
59
|
+
return branch;
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// Fall back below.
|
|
63
|
+
}
|
|
64
|
+
return 'main';
|
|
65
|
+
}
|
|
66
|
+
async listBranches() {
|
|
67
|
+
const dir = await this.dir();
|
|
68
|
+
let branches;
|
|
69
|
+
try {
|
|
70
|
+
branches = this.remoteUrl
|
|
71
|
+
? await git.listBranches({ fs, dir, remote: 'origin' })
|
|
72
|
+
: await git.listBranches({ fs, dir });
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
throw new SourceError('failed to list branches', err);
|
|
76
|
+
}
|
|
77
|
+
const defaultBranch = await this.defaultBranch();
|
|
78
|
+
const { staleDaysThreshold, maxBranches } = this.branchLoad;
|
|
79
|
+
if (staleDaysThreshold !== undefined) {
|
|
80
|
+
const cutoff = Date.now() - staleDaysThreshold * 24 * 60 * 60 * 1000;
|
|
81
|
+
const fresh = [];
|
|
82
|
+
for (const branch of branches) {
|
|
83
|
+
if (branch === defaultBranch) {
|
|
84
|
+
fresh.push(branch);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
const sha = await this.resolveBranchOid(branch);
|
|
89
|
+
const { commit } = await git.readCommit({ fs, dir, oid: sha });
|
|
90
|
+
if (commit.author.timestamp * 1000 >= cutoff)
|
|
91
|
+
fresh.push(branch);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
// Skip unreadable branches.
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
branches = fresh;
|
|
98
|
+
}
|
|
99
|
+
if (maxBranches !== undefined && branches.length > maxBranches) {
|
|
100
|
+
const withDates = [];
|
|
101
|
+
for (const branch of branches) {
|
|
102
|
+
try {
|
|
103
|
+
const sha = await this.resolveBranchOid(branch);
|
|
104
|
+
const { commit } = await git.readCommit({ fs, dir, oid: sha });
|
|
105
|
+
withDates.push({ branch, timestamp: commit.author.timestamp });
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
withDates.push({ branch, timestamp: 0 });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
withDates.sort((a, b) => b.timestamp - a.timestamp);
|
|
112
|
+
branches = [
|
|
113
|
+
defaultBranch,
|
|
114
|
+
...withDates.filter(item => item.branch !== defaultBranch).slice(0, maxBranches - 1).map(item => item.branch),
|
|
115
|
+
];
|
|
116
|
+
}
|
|
117
|
+
return branches;
|
|
118
|
+
}
|
|
119
|
+
async loadPackContent(_ref) {
|
|
120
|
+
const defaultRef = await this.defaultBranch();
|
|
121
|
+
const dir = await this.dir();
|
|
122
|
+
const map = new Map();
|
|
123
|
+
const commitSha = await this.resolveBranchOid(defaultRef);
|
|
124
|
+
const graphYamlRepoPath = `${this.graphDir}/graph.yaml`;
|
|
125
|
+
let graphYamlContent;
|
|
126
|
+
try {
|
|
127
|
+
const { blob } = await git.readBlob({ fs, dir, oid: commitSha, filepath: graphYamlRepoPath });
|
|
128
|
+
graphYamlContent = Buffer.from(blob).toString('utf-8');
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return map;
|
|
132
|
+
}
|
|
133
|
+
const doc = parseYaml(graphYamlContent);
|
|
134
|
+
const packs = Array.isArray(doc.templatePacks) ? doc.templatePacks : [];
|
|
135
|
+
const allFiles = await git.listFiles({ fs, dir, ref: commitSha });
|
|
136
|
+
for (const pack of packs) {
|
|
137
|
+
if (typeof pack.path !== 'string')
|
|
138
|
+
continue;
|
|
139
|
+
const packPath = pack.path;
|
|
140
|
+
const absPackPath = normalizeRepoPath(this.graphDir, packPath);
|
|
141
|
+
const packName = absPackPath.split('/').pop() ?? packPath;
|
|
142
|
+
const packPrefix = absPackPath.endsWith('/') ? absPackPath : `${absPackPath}/`;
|
|
143
|
+
for (const filePath of allFiles.filter(file => file.startsWith(packPrefix) && file.endsWith('.yaml'))) {
|
|
144
|
+
try {
|
|
145
|
+
const { blob } = await git.readBlob({ fs, dir, oid: commitSha, filepath: filePath });
|
|
146
|
+
const relKey = filePath.slice(packPrefix.length);
|
|
147
|
+
map.set(`${packName}/${relKey}`, Buffer.from(blob).toString('utf-8'));
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
// Skip blobs that cannot be read — corrupt object store or pack not yet fetched.
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return map;
|
|
155
|
+
}
|
|
156
|
+
async loadGraphContent(ref) {
|
|
157
|
+
const dir = await this.dir();
|
|
158
|
+
const map = new Map();
|
|
159
|
+
const commitSha = await this.resolveBranchOid(ref);
|
|
160
|
+
const prefix = this.graphDir.endsWith('/') ? this.graphDir : `${this.graphDir}/`;
|
|
161
|
+
const allFiles = await git.listFiles({ fs, dir, ref: commitSha });
|
|
162
|
+
for (const filePath of allFiles.filter(file => file.startsWith(prefix) && file.endsWith('.yaml'))) {
|
|
163
|
+
try {
|
|
164
|
+
const { blob } = await git.readBlob({ fs, dir, oid: commitSha, filepath: filePath });
|
|
165
|
+
map.set(filePath.slice(prefix.length), Buffer.from(blob).toString('utf-8'));
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
// Skip blobs that cannot be read — corrupt object store or ref not yet fetched.
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return map;
|
|
172
|
+
}
|
|
173
|
+
async commit(branch, changes, message, options = {}) {
|
|
174
|
+
const defaultBranch = await this.defaultBranch();
|
|
175
|
+
if (branch === defaultBranch) {
|
|
176
|
+
throw new SourceError(`cannot commit to default branch '${branch}' - it is read-only`);
|
|
177
|
+
}
|
|
178
|
+
const dir = await this.dir();
|
|
179
|
+
const prefix = this.graphDir.endsWith('/') ? this.graphDir : `${this.graphDir}/`;
|
|
180
|
+
let parentSha;
|
|
181
|
+
try {
|
|
182
|
+
parentSha = await this.resolveBranchOid(branch);
|
|
183
|
+
}
|
|
184
|
+
catch (err) {
|
|
185
|
+
throw new SourceError(`cannot resolve branch '${branch}'`, err);
|
|
186
|
+
}
|
|
187
|
+
const { commit: parentCommit } = await git.readCommit({ fs, dir, oid: parentSha });
|
|
188
|
+
const blobMap = new Map();
|
|
189
|
+
for (const [key, content] of changes) {
|
|
190
|
+
const repoPath = `${prefix}${normalizeContentKey(key)}`;
|
|
191
|
+
const oid = await git.writeBlob({ fs, dir, blob: Buffer.from(content, 'utf-8') });
|
|
192
|
+
blobMap.set(repoPath, oid);
|
|
193
|
+
}
|
|
194
|
+
const newTreeOid = options.replaceGraphContent
|
|
195
|
+
? await buildReplacedGraphTree(fs, dir, parentCommit.tree, prefix, blobMap)
|
|
196
|
+
: await buildUpdatedTree(fs, dir, parentCommit.tree, blobMap);
|
|
197
|
+
const now = Math.floor(Date.now() / 1000);
|
|
198
|
+
const newCommitOid = await git.writeCommit({
|
|
199
|
+
fs,
|
|
200
|
+
dir,
|
|
201
|
+
commit: {
|
|
202
|
+
tree: newTreeOid,
|
|
203
|
+
parent: [parentSha],
|
|
204
|
+
message,
|
|
205
|
+
author: { name: 'corum', email: 'corum@localhost', timestamp: now, timezoneOffset: 0 },
|
|
206
|
+
committer: { name: 'corum', email: 'corum@localhost', timestamp: now, timezoneOffset: 0 },
|
|
207
|
+
},
|
|
208
|
+
});
|
|
209
|
+
await git.writeRef({ fs, dir, ref: `refs/heads/${branch}`, value: newCommitOid, force: true });
|
|
210
|
+
if (this.remoteUrl) {
|
|
211
|
+
try {
|
|
212
|
+
await git.push({
|
|
213
|
+
fs,
|
|
214
|
+
http: (await import('isomorphic-git/http/node')).default,
|
|
215
|
+
dir,
|
|
216
|
+
remote: 'origin',
|
|
217
|
+
ref: branch,
|
|
218
|
+
onAuth: this.onAuth(),
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
catch (err) {
|
|
222
|
+
throw new SourceError(`failed to push branch '${branch}'`, err);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
async reloadSignature() {
|
|
227
|
+
const dir = await this.dir();
|
|
228
|
+
const branches = await this.listBranches();
|
|
229
|
+
const refs = [];
|
|
230
|
+
for (const branch of [...branches].sort((a, b) => a.localeCompare(b))) {
|
|
231
|
+
try {
|
|
232
|
+
refs.push(`${branch}:${await this.resolveBranchOid(branch)}`);
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
refs.push(`${branch}:unresolved`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return refs.join('|');
|
|
239
|
+
}
|
|
240
|
+
async resolveBranchOid(branch) {
|
|
241
|
+
const dir = await this.dir();
|
|
242
|
+
if (this.remoteUrl) {
|
|
243
|
+
try {
|
|
244
|
+
return await git.resolveRef({ fs, dir, ref: `refs/remotes/origin/${branch}` });
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
// Newly-created branch may only exist locally before push.
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return git.resolveRef({ fs, dir, ref: branch });
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
async function buildReplacedGraphTree(fsImpl, dir, rootTreeOid, graphPrefix, blobMap) {
|
|
254
|
+
const graphDir = graphPrefix.replace(/\/$/, '');
|
|
255
|
+
const prunedRoot = await removeTreePath(fsImpl, dir, rootTreeOid, graphDir.split('/'));
|
|
256
|
+
return buildUpdatedTree(fsImpl, dir, prunedRoot, blobMap);
|
|
257
|
+
}
|
|
258
|
+
async function removeTreePath(fsImpl, dir, treeOid, parts) {
|
|
259
|
+
const { tree } = await git.readTree({ fs: fsImpl, dir, oid: treeOid });
|
|
260
|
+
const [head, ...tail] = parts;
|
|
261
|
+
if (!head)
|
|
262
|
+
return treeOid;
|
|
263
|
+
if (tail.length === 0) {
|
|
264
|
+
return git.writeTree({ fs: fsImpl, dir, tree: tree.filter(entry => entry.path !== head) });
|
|
265
|
+
}
|
|
266
|
+
const entries = [...tree];
|
|
267
|
+
const idx = entries.findIndex(entry => entry.path === head && entry.type === 'tree');
|
|
268
|
+
if (idx < 0)
|
|
269
|
+
return treeOid;
|
|
270
|
+
const nextOid = await removeTreePath(fsImpl, dir, entries[idx].oid, tail);
|
|
271
|
+
entries[idx] = { ...entries[idx], oid: nextOid };
|
|
272
|
+
return git.writeTree({ fs: fsImpl, dir, tree: entries });
|
|
273
|
+
}
|
|
274
|
+
async function buildUpdatedTree(fsImpl, dir, rootTreeOid, blobMap) {
|
|
275
|
+
// TODO: rebuildTree iterates the full blobMap at every tree level — O(blobs × depth).
|
|
276
|
+
// For Stage 1 this is acceptable (graph repos are small). A future optimisation is to
|
|
277
|
+
// pre-bucket blobMap entries by their top-level path segment before recursing, reducing
|
|
278
|
+
// work to O(blobs) total. The current approach is correct but redundant at deeper levels.
|
|
279
|
+
async function rebuildTree(treeOid, prefix) {
|
|
280
|
+
const { tree } = await git.readTree({ fs: fsImpl, dir, oid: treeOid });
|
|
281
|
+
const entries = [...tree];
|
|
282
|
+
for (const [repoPath, blobOid] of blobMap) {
|
|
283
|
+
if (!repoPath.startsWith(prefix))
|
|
284
|
+
continue;
|
|
285
|
+
const remainder = repoPath.slice(prefix.length);
|
|
286
|
+
const parts = remainder.split('/');
|
|
287
|
+
if (parts.length === 1) {
|
|
288
|
+
const fileName = parts[0];
|
|
289
|
+
const entry = { mode: '100644', path: fileName, oid: blobOid, type: 'blob' };
|
|
290
|
+
const existing = entries.findIndex(item => item.path === fileName);
|
|
291
|
+
if (existing >= 0)
|
|
292
|
+
entries[existing] = entry;
|
|
293
|
+
else
|
|
294
|
+
entries.push(entry);
|
|
295
|
+
}
|
|
296
|
+
else {
|
|
297
|
+
const subdir = parts[0];
|
|
298
|
+
const existing = entries.find(item => item.path === subdir && item.type === 'tree');
|
|
299
|
+
const subTreeOid = existing?.oid ?? await git.writeTree({ fs: fsImpl, dir, tree: [] });
|
|
300
|
+
const newSubTreeOid = await rebuildTree(subTreeOid, `${prefix}${subdir}/`);
|
|
301
|
+
const entry = { mode: '040000', path: subdir, oid: newSubTreeOid, type: 'tree' };
|
|
302
|
+
const idx = entries.findIndex(item => item.path === subdir);
|
|
303
|
+
if (idx >= 0)
|
|
304
|
+
entries[idx] = entry;
|
|
305
|
+
else
|
|
306
|
+
entries.push(entry);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
entries.sort((a, b) => a.path.localeCompare(b.path));
|
|
310
|
+
return git.writeTree({ fs: fsImpl, dir, tree: entries });
|
|
311
|
+
}
|
|
312
|
+
return rebuildTree(rootTreeOid, '');
|
|
313
|
+
}
|
|
314
|
+
function normalizeRepoPath(baseDir, value) {
|
|
315
|
+
const posixValue = value.replace(/\\/g, '/');
|
|
316
|
+
const normalized = posixValue.startsWith('/')
|
|
317
|
+
? path.posix.normalize(posixValue.slice(1))
|
|
318
|
+
: path.posix.normalize(baseDir ? path.posix.join(baseDir, posixValue) : posixValue);
|
|
319
|
+
if (normalized === '..' || normalized.startsWith('../')) {
|
|
320
|
+
throw new SourceError(`path escapes repository root: ${value}`);
|
|
321
|
+
}
|
|
322
|
+
return normalized;
|
|
323
|
+
}
|
|
324
|
+
function normalizeContentKey(key) {
|
|
325
|
+
if (key.includes('\\') || key.includes('\0') || path.posix.isAbsolute(key) || /^[a-zA-Z]:/.test(key)) {
|
|
326
|
+
throw new SourceError(`invalid ContentMap key: ${key}`);
|
|
327
|
+
}
|
|
328
|
+
const normalized = path.posix.normalize(key);
|
|
329
|
+
if (normalized === '.' || normalized === '..' || normalized.startsWith('../')) {
|
|
330
|
+
throw new SourceError(`invalid ContentMap key: ${key}`);
|
|
331
|
+
}
|
|
332
|
+
return normalized;
|
|
333
|
+
}
|