uigraph 1.0.1 → 1.0.2
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 +30 -51
- package/dist/bin.mjs +1269 -1921
- package/package.json +2 -2
- package/dist/agents.md +0 -28
- package/dist/plan-prompt.md +0 -56
- package/dist/prompt.md +0 -44
package/dist/bin.mjs
CHANGED
|
@@ -1,1230 +1,556 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
3
|
import { Command, InvalidArgumentError, Option } from "@commander-js/extra-typings";
|
|
4
|
-
import {
|
|
5
|
-
import { AGENT_LABELS, AGENT_NAMES,
|
|
4
|
+
import { select } from "@inquirer/prompts";
|
|
5
|
+
import { AGENT_LABELS, AGENT_NAMES, analyzeReview, fetchReview, fileIconPath, lensSettingsSchema, listRuns, readLocalReview, readRun, readSettings, saveSettings } from "@uigraph/lens/agents";
|
|
6
6
|
import { LogLevels, consola } from "consola";
|
|
7
7
|
import { execFile, spawn } from "node:child_process";
|
|
8
8
|
import fs from "node:fs/promises";
|
|
9
|
-
import os from "node:os";
|
|
10
9
|
import path from "node:path";
|
|
11
10
|
import { inspect, promisify } from "node:util";
|
|
12
11
|
import open from "open";
|
|
13
12
|
import pc from "picocolors";
|
|
14
|
-
import fs$1, { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
15
13
|
import { AstToUiConverter, DynamoEditorSchema, MongoEditorSchema, SqlToAstParser, contextSchema, convertDynamoSchemaToAst, convertMermaidToReactFlow, convertMermaidToReactFlowWithContext, convertMongoSchemaToAst, convertReactFlowToC4UiGraph, convertReactFlowToSequenceUiGraph, convertUiGraphToMermaid, isC4ReactFlowDiagram, isSequenceDiagram } from "@uigraph/sdk";
|
|
16
14
|
import { GraphQLError, Kind, NoSchemaIntrospectionCustomRule, buildSchema, execute, getOperationAST, parse, print, specifiedRules, validate } from "graphql";
|
|
17
15
|
import { createHash, randomUUID } from "node:crypto";
|
|
18
16
|
import { glob } from "tinyglobby";
|
|
19
17
|
import { isMap, isScalar, isSeq, parse as parse$1, parseDocument, stringify, visit } from "yaml";
|
|
20
18
|
import { z } from "zod";
|
|
19
|
+
import fs$1, { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
21
20
|
import { setTimeout as setTimeout$1 } from "node:timers/promises";
|
|
21
|
+
import os from "node:os";
|
|
22
22
|
import http from "node:http";
|
|
23
23
|
import { fileURLToPath } from "node:url";
|
|
24
|
-
//#region src/
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
text: renderBlock(value, column(source, start)) + trailingNewline
|
|
67
|
-
};
|
|
68
|
-
}
|
|
69
|
-
function setField(source, map, key, value) {
|
|
70
|
-
if (!isMap(map) || map.flow === true) throw new Error(`Expected a block mapping to set "${key}"`);
|
|
71
|
-
const existing = map.get(key, true);
|
|
72
|
-
if (isScalar(existing) && (typeof value === "string" || typeof value === "number")) return replaceScalar(existing, value);
|
|
73
|
-
if (existing !== void 0) throw new Error(`Cannot replace the "${key}" field in place`);
|
|
74
|
-
const first = map.items[0]?.key;
|
|
75
|
-
if (first === void 0) throw new Error(`Cannot add "${key}" to an empty mapping`);
|
|
76
|
-
const indent = column(source, nodeRange(first, "a key")[0]);
|
|
77
|
-
const end = nodeRange(map, "a mapping")[1];
|
|
78
|
-
return {
|
|
79
|
-
start: end,
|
|
80
|
-
end,
|
|
81
|
-
text: `${source[end - 1] === "\n" ? "" : "\n"}${" ".repeat(indent)}${renderBlock({ [key]: value }, indent)}\n`
|
|
82
|
-
};
|
|
83
|
-
}
|
|
84
|
-
function appendItem(source, document, path, value) {
|
|
85
|
-
const sequence = document.getIn(path, true);
|
|
86
|
-
if (isSeq(sequence) && sequence.flow !== true) {
|
|
87
|
-
const range = nodeRange(sequence, path.join("."));
|
|
88
|
-
const indent = column(source, range[0]);
|
|
89
|
-
const needsNewline = source[range[1] - 1] === "\n" ? "" : "\n";
|
|
90
|
-
return {
|
|
91
|
-
start: range[1],
|
|
92
|
-
end: range[1],
|
|
93
|
-
text: `${needsNewline}${" ".repeat(indent)}- ${renderBlock(value, indent + 2)}\n`
|
|
94
|
-
};
|
|
95
|
-
}
|
|
96
|
-
const key = path.at(-1);
|
|
97
|
-
if (typeof key !== "string") throw new Error(`Cannot add an item to ${path.join(".")}`);
|
|
98
|
-
const parent = path.length === 1 ? document.contents : document.getIn(path.slice(0, -1), true);
|
|
99
|
-
if (!isMap(parent) || parent.flow === true) throw new Error(`Cannot add an item to ${path.join(".")}`);
|
|
100
|
-
const pair = parent.items.find((item) => isScalar(item.key) && item.key.value === key);
|
|
101
|
-
if (pair !== void 0) {
|
|
102
|
-
if (!isSeq(pair.value) || pair.value.items.length > 0) throw new Error(`${path.join(".")} must be a list`);
|
|
103
|
-
const keyColumn = column(source, nodeRange(pair.key, key)[0]);
|
|
104
|
-
const [valueStart, end] = nodeRange(pair.value, key);
|
|
105
|
-
return {
|
|
106
|
-
start: source.lastIndexOf(":", valueStart) + 1,
|
|
107
|
-
end,
|
|
108
|
-
text: `\n${" ".repeat(keyColumn + 2)}- ${renderBlock(value, keyColumn + 4)}`
|
|
109
|
-
};
|
|
110
|
-
}
|
|
111
|
-
return setField(source, parent, key, [value]);
|
|
24
|
+
//#region src/analysis/repo-access.ts
|
|
25
|
+
const EXCLUDED_DIRECTORIES = [
|
|
26
|
+
".git",
|
|
27
|
+
"node_modules",
|
|
28
|
+
"dist",
|
|
29
|
+
"build",
|
|
30
|
+
"out",
|
|
31
|
+
".next",
|
|
32
|
+
".nuxt",
|
|
33
|
+
".turbo",
|
|
34
|
+
".cache",
|
|
35
|
+
"coverage",
|
|
36
|
+
"target",
|
|
37
|
+
"vendor",
|
|
38
|
+
".venv",
|
|
39
|
+
"venv",
|
|
40
|
+
"__pycache__",
|
|
41
|
+
".terraform",
|
|
42
|
+
".claude"
|
|
43
|
+
];
|
|
44
|
+
const SECRET_FILE_PATTERNS = [
|
|
45
|
+
/^\.env(\..+)?$/,
|
|
46
|
+
/\.(pem|key|p12|pfx|jks|keystore|crt|cer|der)$/,
|
|
47
|
+
/^id_(rsa|dsa|ecdsa|ed25519)/,
|
|
48
|
+
/^credentials/,
|
|
49
|
+
/^secrets?\./,
|
|
50
|
+
/^\.npmrc$/,
|
|
51
|
+
/^\.netrc$/,
|
|
52
|
+
/^\.pypirc$/,
|
|
53
|
+
/\.tfstate(\.backup)?$/
|
|
54
|
+
];
|
|
55
|
+
const ALLOWED_ENV_FILES = [
|
|
56
|
+
".env.example",
|
|
57
|
+
".env.sample",
|
|
58
|
+
".env.template"
|
|
59
|
+
];
|
|
60
|
+
function isExcludedPath(relativePath) {
|
|
61
|
+
const segments = relativePath.split("/");
|
|
62
|
+
if (segments.some((segment) => EXCLUDED_DIRECTORIES.includes(segment))) return true;
|
|
63
|
+
const fileName = segments[segments.length - 1];
|
|
64
|
+
if (ALLOWED_ENV_FILES.includes(fileName)) return false;
|
|
65
|
+
return SECRET_FILE_PATTERNS.some((pattern) => pattern.test(fileName));
|
|
112
66
|
}
|
|
113
|
-
function
|
|
114
|
-
const
|
|
115
|
-
if (
|
|
116
|
-
const
|
|
117
|
-
if (
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
start: lineStart(source, start),
|
|
122
|
-
end,
|
|
123
|
-
text: ""
|
|
124
|
-
};
|
|
125
|
-
}
|
|
126
|
-
const [start, , end] = nodeRange(sequence, path.join("."));
|
|
67
|
+
async function resolveRepositoryFile(root, requested) {
|
|
68
|
+
const normalized = path.posix.normalize(requested.replaceAll("\\", "/"));
|
|
69
|
+
if (normalized === ".." || normalized.startsWith("../") || path.isAbsolute(normalized)) throw new Error(`Path must be relative to the repository: ${requested}`);
|
|
70
|
+
const relativePath = normalized.replace(/^\.\//, "");
|
|
71
|
+
if (isExcludedPath(relativePath)) throw new Error(`Path is excluded from analysis: ${relativePath}`);
|
|
72
|
+
const absolutePath = path.join(root, relativePath);
|
|
73
|
+
const realPath = await fs.realpath(absolutePath);
|
|
74
|
+
if (realPath !== root && !realPath.startsWith(root + path.sep)) throw new Error(`Path resolves outside the repository: ${relativePath}`);
|
|
127
75
|
return {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
76
|
+
relativePath,
|
|
77
|
+
realPath,
|
|
78
|
+
stat: await fs.stat(realPath)
|
|
131
79
|
};
|
|
132
80
|
}
|
|
133
|
-
function findItemIndex(document, path, name) {
|
|
134
|
-
const sequence = document.getIn(path, true);
|
|
135
|
-
if (!isSeq(sequence)) throw new Error(`${path.join(".")} must be a list`);
|
|
136
|
-
const index = sequence.items.findIndex((item) => isMap(item) && item.get("name") === name);
|
|
137
|
-
if (index === -1) throw new Error(`${path.join(".")} has no entry named "${name}"`);
|
|
138
|
-
return index;
|
|
139
|
-
}
|
|
140
81
|
//#endregion
|
|
141
|
-
//#region src/artifacts/
|
|
82
|
+
//#region src/artifacts/load.ts
|
|
83
|
+
const ROOT_CONFIG = ".uigraph.yaml";
|
|
84
|
+
const ORG_ID = "local";
|
|
85
|
+
const ACTOR_ID = "uigraph";
|
|
86
|
+
const MAX_FILE_BYTES = 2e6;
|
|
87
|
+
const HTTP_METHODS = [
|
|
88
|
+
"get",
|
|
89
|
+
"put",
|
|
90
|
+
"post",
|
|
91
|
+
"delete",
|
|
92
|
+
"patch",
|
|
93
|
+
"options",
|
|
94
|
+
"head",
|
|
95
|
+
"trace"
|
|
96
|
+
];
|
|
97
|
+
const DOC_FILE_TYPES = [
|
|
98
|
+
"pdf",
|
|
99
|
+
"html",
|
|
100
|
+
"markdown",
|
|
101
|
+
"doc",
|
|
102
|
+
"txt",
|
|
103
|
+
"image",
|
|
104
|
+
"video",
|
|
105
|
+
"audio",
|
|
106
|
+
"other"
|
|
107
|
+
];
|
|
142
108
|
const DOC_EXTENSIONS$1 = {
|
|
143
|
-
pdf: "
|
|
144
|
-
html: "
|
|
145
|
-
|
|
146
|
-
|
|
109
|
+
".pdf": "pdf",
|
|
110
|
+
".html": "html",
|
|
111
|
+
".htm": "html",
|
|
112
|
+
".md": "markdown",
|
|
113
|
+
".markdown": "markdown",
|
|
114
|
+
".doc": "doc",
|
|
115
|
+
".docx": "doc",
|
|
116
|
+
".txt": "txt",
|
|
117
|
+
".png": "image",
|
|
118
|
+
".jpg": "image",
|
|
119
|
+
".jpeg": "image",
|
|
120
|
+
".gif": "image",
|
|
121
|
+
".webp": "image",
|
|
122
|
+
".svg": "image",
|
|
123
|
+
".mp4": "video",
|
|
124
|
+
".mov": "video",
|
|
125
|
+
".webm": "video",
|
|
126
|
+
".mp3": "audio",
|
|
127
|
+
".wav": "audio",
|
|
128
|
+
".ogg": "audio",
|
|
129
|
+
".m4a": "audio"
|
|
147
130
|
};
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
131
|
+
const DATABASE_DIALECTS = [
|
|
132
|
+
"postgres",
|
|
133
|
+
"mysql",
|
|
134
|
+
"sqlite",
|
|
135
|
+
"other",
|
|
136
|
+
"dynamodb",
|
|
137
|
+
"mongodb"
|
|
138
|
+
];
|
|
139
|
+
const queryEntrySchema = z.looseObject({
|
|
140
|
+
name: z.string().min(1),
|
|
141
|
+
database: z.string().min(1),
|
|
142
|
+
path: z.string().min(1).optional(),
|
|
143
|
+
queryText: z.string().min(1).optional(),
|
|
144
|
+
description: z.string().optional(),
|
|
145
|
+
tags: z.array(z.string()).optional()
|
|
146
|
+
});
|
|
147
|
+
const queryFileSchema = z.looseObject({ queries: z.array(queryEntrySchema).default([]) });
|
|
148
|
+
const componentRefSchema = z.looseObject({
|
|
149
|
+
componentId: z.string().min(1),
|
|
150
|
+
componentLinkId: z.string().optional(),
|
|
151
|
+
serviceName: z.string().optional(),
|
|
152
|
+
apiGroupName: z.string().optional(),
|
|
153
|
+
operationId: z.string().optional(),
|
|
154
|
+
testPackName: z.string().optional(),
|
|
155
|
+
docName: z.string().optional(),
|
|
156
|
+
architectureDiagramName: z.string().optional(),
|
|
157
|
+
modalFields: z.array(z.looseObject({
|
|
158
|
+
componentFieldId: z.string(),
|
|
159
|
+
label: z.string().optional(),
|
|
160
|
+
type: z.string().optional(),
|
|
161
|
+
data: z.array(z.unknown()).optional()
|
|
162
|
+
})).optional()
|
|
163
|
+
});
|
|
164
|
+
const mapSchema = z.looseObject({
|
|
165
|
+
name: z.string().min(1),
|
|
166
|
+
description: z.string().optional(),
|
|
167
|
+
frames: z.array(z.looseObject({
|
|
168
|
+
name: z.string().min(1),
|
|
169
|
+
description: z.string().optional(),
|
|
170
|
+
imagePath: z.string().min(1).optional(),
|
|
171
|
+
focalPoints: z.array(z.looseObject({
|
|
172
|
+
name: z.string().min(1),
|
|
173
|
+
x: z.number(),
|
|
174
|
+
y: z.number(),
|
|
175
|
+
visibility: z.string().optional(),
|
|
176
|
+
color: z.string().optional(),
|
|
177
|
+
components: z.array(componentRefSchema).optional()
|
|
178
|
+
})).optional()
|
|
179
|
+
})).optional()
|
|
180
|
+
});
|
|
181
|
+
const CANVAS_FILE = ".uigraph/maps/canvas.json";
|
|
182
|
+
const canvasPointSchema = z.object({
|
|
183
|
+
x: z.number(),
|
|
184
|
+
y: z.number()
|
|
185
|
+
});
|
|
186
|
+
const canvasFileSchema = z.record(z.string(), z.object({
|
|
187
|
+
zoom: z.number().optional(),
|
|
188
|
+
navigationX: z.number().optional(),
|
|
189
|
+
navigationY: z.number().optional(),
|
|
190
|
+
framePositions: z.record(z.string(), canvasPointSchema).optional(),
|
|
191
|
+
frames: z.record(z.string(), z.object({
|
|
192
|
+
groups: z.array(canvasPointSchema.extend({
|
|
193
|
+
name: z.string(),
|
|
194
|
+
description: z.string(),
|
|
195
|
+
width: z.number(),
|
|
196
|
+
height: z.number()
|
|
197
|
+
})).optional(),
|
|
198
|
+
links: z.array(canvasPointSchema.extend({
|
|
199
|
+
kind: z.enum(["frame", "map"]),
|
|
200
|
+
map: z.string(),
|
|
201
|
+
frame: z.string().optional(),
|
|
202
|
+
label: z.string()
|
|
203
|
+
})).optional()
|
|
204
|
+
})).optional()
|
|
205
|
+
}));
|
|
206
|
+
const configSchema = z.looseObject({
|
|
207
|
+
version: z.literal(1),
|
|
208
|
+
service: z.looseObject({
|
|
209
|
+
name: z.string().min(1),
|
|
210
|
+
category: z.string().min(1),
|
|
211
|
+
description: z.string().min(1),
|
|
212
|
+
repository: z.looseObject({
|
|
213
|
+
provider: z.string(),
|
|
214
|
+
url: z.string()
|
|
215
|
+
}).optional(),
|
|
216
|
+
ownership: z.looseObject({ team: z.string().min(1) }).optional(),
|
|
217
|
+
labels: z.array(z.string()).optional(),
|
|
218
|
+
integrations: z.looseObject({
|
|
219
|
+
slack: z.looseObject({ url: z.string() }).optional(),
|
|
220
|
+
jira: z.looseObject({ url: z.string() }).optional()
|
|
221
|
+
}).optional()
|
|
222
|
+
}).optional(),
|
|
223
|
+
apis: z.array(z.looseObject({
|
|
224
|
+
name: z.string().min(1),
|
|
225
|
+
type: z.enum([
|
|
226
|
+
"openapi",
|
|
227
|
+
"graphql",
|
|
228
|
+
"grpc"
|
|
229
|
+
]),
|
|
230
|
+
path: z.string().min(1)
|
|
231
|
+
})).default([]),
|
|
232
|
+
dependencies: z.array(z.looseObject({
|
|
233
|
+
name: z.string().min(1),
|
|
234
|
+
service: z.string().min(1),
|
|
235
|
+
direction: z.enum(["upstream", "downstream"]),
|
|
236
|
+
criticality: z.enum(["hard", "soft"]),
|
|
237
|
+
type: z.enum([
|
|
238
|
+
"http",
|
|
239
|
+
"graphql",
|
|
240
|
+
"grpc",
|
|
241
|
+
"database"
|
|
242
|
+
]).optional(),
|
|
243
|
+
description: z.string().optional(),
|
|
244
|
+
apiGroupName: z.string().optional(),
|
|
245
|
+
apiEndpointNames: z.array(z.string().min(1)).optional(),
|
|
246
|
+
databaseName: z.string().optional()
|
|
247
|
+
})).default([]),
|
|
248
|
+
architectureDiagrams: z.array(z.looseObject({
|
|
249
|
+
name: z.string().min(1),
|
|
250
|
+
path: z.string().min(1),
|
|
251
|
+
contextPath: z.string().min(1).optional()
|
|
252
|
+
})).default([]),
|
|
253
|
+
docs: z.array(z.looseObject({
|
|
254
|
+
name: z.string().min(1),
|
|
255
|
+
path: z.string().min(1),
|
|
256
|
+
fileType: z.enum(DOC_FILE_TYPES).optional(),
|
|
257
|
+
description: z.string().optional()
|
|
258
|
+
})).default([]),
|
|
259
|
+
databases: z.array(z.looseObject({
|
|
260
|
+
name: z.string().min(1),
|
|
261
|
+
dialect: z.enum(DATABASE_DIALECTS),
|
|
262
|
+
dbType: z.string().optional(),
|
|
263
|
+
schemaPath: z.string().min(1)
|
|
264
|
+
})).default([]),
|
|
265
|
+
queries: z.array(queryEntrySchema).default([]),
|
|
266
|
+
queryFiles: z.array(z.string().min(1)).default([]),
|
|
267
|
+
maps: z.array(mapSchema).default([])
|
|
268
|
+
});
|
|
269
|
+
const PROTOCOL_LABELS = {
|
|
270
|
+
openapi: "REST",
|
|
271
|
+
graphql: "GraphQL",
|
|
272
|
+
grpc: "gRPC"
|
|
195
273
|
};
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
const { source, document } = await readYaml(root, file);
|
|
199
|
-
await writeArtifactFile(root, file, applyEdits(source, [appendItem(source, document, ["maps"], {
|
|
200
|
-
name: input.name,
|
|
201
|
-
...input.description === "" ? {} : { description: input.description }
|
|
202
|
-
})]));
|
|
203
|
-
}
|
|
204
|
-
async function editMap(root, configFile, file, name, edit) {
|
|
205
|
-
await loadValid(root, configFile);
|
|
206
|
-
const { source, document } = await readYaml(root, file);
|
|
207
|
-
const index = findItemIndex(document, ["maps"], name);
|
|
208
|
-
const node = document.getIn(["maps", index], true);
|
|
209
|
-
if (!isMap(node)) throw new Error(`maps[${name}] must be a mapping`);
|
|
210
|
-
const next = edit(node.toJSON());
|
|
211
|
-
await writeArtifactFile(root, file, applyEdits(source, [next === null ? removeItem(source, document, ["maps"], index) : replaceItem(source, node, next)]));
|
|
212
|
-
}
|
|
213
|
-
async function editCanvas(root, edit) {
|
|
214
|
-
const present = await exists(path.join(root, CANVAS_FILE));
|
|
215
|
-
let canvas = {};
|
|
216
|
-
if (present) {
|
|
217
|
-
const { realPath } = await resolveRepositoryFile(root, CANVAS_FILE);
|
|
218
|
-
canvas = canvasFileSchema.parse(JSON.parse(await fs.readFile(realPath, "utf8")));
|
|
219
|
-
}
|
|
220
|
-
const before = JSON.stringify(canvas);
|
|
221
|
-
edit(canvas);
|
|
222
|
-
if (JSON.stringify(canvas) === before) return;
|
|
223
|
-
if (Object.keys(canvas).length === 0) {
|
|
224
|
-
if (present) await deleteArtifactFile(root, CANVAS_FILE);
|
|
225
|
-
return;
|
|
226
|
-
}
|
|
227
|
-
await writeArtifactFile(root, CANVAS_FILE, `${JSON.stringify(canvas, null, 2)}\n`);
|
|
274
|
+
function slugify(value) {
|
|
275
|
+
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
228
276
|
}
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
for (const mapCanvas of Object.values(canvas)) for (const frameCanvas of Object.values(mapCanvas.frames ?? {})) {
|
|
232
|
-
if (frameCanvas.links === void 0) continue;
|
|
233
|
-
frameCanvas.links = frameCanvas.links.flatMap((link) => {
|
|
234
|
-
if (!(link.map === from.map && (from.frame === void 0 || link.frame === from.frame))) return [link];
|
|
235
|
-
if (to === null) return [];
|
|
236
|
-
return [{
|
|
237
|
-
...link,
|
|
238
|
-
map: to.map,
|
|
239
|
-
...from.frame === void 0 ? {} : { frame: to.frame }
|
|
240
|
-
}];
|
|
241
|
-
});
|
|
242
|
-
}
|
|
243
|
-
});
|
|
277
|
+
function describe(error) {
|
|
278
|
+
return error instanceof Error ? error.message : String(error);
|
|
244
279
|
}
|
|
245
|
-
|
|
246
|
-
const
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
const base = slugify(frameName);
|
|
250
|
-
let imagePath = `${directory}/${base}${extension}`;
|
|
251
|
-
for (let suffix = 2; await exists(path.join(root, imagePath)); suffix++) imagePath = `${directory}/${base}-${suffix}${extension}`;
|
|
252
|
-
await writeArtifactFile(root, imagePath, upload.data);
|
|
253
|
-
return imagePath;
|
|
280
|
+
function contentHash(...parts) {
|
|
281
|
+
const hash = createHash("sha256");
|
|
282
|
+
for (const part of parts) hash.update(part);
|
|
283
|
+
return hash.digest("hex");
|
|
254
284
|
}
|
|
255
|
-
async function
|
|
256
|
-
const
|
|
257
|
-
|
|
258
|
-
|
|
285
|
+
async function readArtifactFile(root, relativePath) {
|
|
286
|
+
const { realPath, stat } = await resolveRepositoryFile(root, relativePath);
|
|
287
|
+
if (!stat.isFile()) throw new Error(`Not a file: ${relativePath}`);
|
|
288
|
+
if (stat.size > MAX_FILE_BYTES) throw new Error(`File is larger than ${MAX_FILE_BYTES} bytes: ${relativePath}`);
|
|
289
|
+
return {
|
|
290
|
+
text: await fs.readFile(realPath, "utf8"),
|
|
291
|
+
updatedAt: stat.mtime.toISOString()
|
|
292
|
+
};
|
|
259
293
|
}
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
294
|
+
function parseOpenApi(text) {
|
|
295
|
+
const spec = parse$1(text);
|
|
296
|
+
if (spec === null || typeof spec !== "object") throw new Error("not an OpenAPI document");
|
|
297
|
+
return Object.entries(spec.paths ?? {}).flatMap(([route, operations]) => HTTP_METHODS.filter((method) => operations[method] !== void 0).map((method) => {
|
|
298
|
+
const operation = operations[method];
|
|
299
|
+
return {
|
|
300
|
+
method: method.toUpperCase(),
|
|
301
|
+
path: route,
|
|
302
|
+
operationId: typeof operation.operationId === "string" ? operation.operationId : `${method.toUpperCase()} ${route}`,
|
|
303
|
+
summary: typeof operation.summary === "string" ? operation.summary : "",
|
|
304
|
+
description: typeof operation.description === "string" ? operation.description : "",
|
|
305
|
+
parameters: "[]"
|
|
306
|
+
};
|
|
307
|
+
}));
|
|
268
308
|
}
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
309
|
+
function parseGraphQLSchema(text) {
|
|
310
|
+
return parse(text).definitions.flatMap((definition) => {
|
|
311
|
+
if (definition.kind !== Kind.OBJECT_TYPE_DEFINITION && definition.kind !== Kind.OBJECT_TYPE_EXTENSION || ![
|
|
312
|
+
"Query",
|
|
313
|
+
"Mutation",
|
|
314
|
+
"Subscription"
|
|
315
|
+
].includes(definition.name.value)) return [];
|
|
316
|
+
return (definition.fields ?? []).map((field) => {
|
|
317
|
+
const args = field.arguments === void 0 || field.arguments.length === 0 ? "" : `(${field.arguments.map((arg) => `${arg.name.value}: ${print(arg.type)}`).join(", ")})`;
|
|
318
|
+
return {
|
|
319
|
+
method: definition.name.value,
|
|
320
|
+
path: `${field.name.value}${args}: ${print(field.type)}`,
|
|
321
|
+
operationId: field.name.value,
|
|
322
|
+
summary: "",
|
|
323
|
+
description: field.description?.value ?? "",
|
|
324
|
+
parameters: "[]"
|
|
325
|
+
};
|
|
283
326
|
});
|
|
284
|
-
}
|
|
285
|
-
return images.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
327
|
+
});
|
|
286
328
|
}
|
|
287
|
-
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
329
|
+
function parseProto(text) {
|
|
330
|
+
const packageName = /^\s*package\s+([\w.]+)\s*;/m.exec(text)?.[1] ?? "";
|
|
331
|
+
const services = [...text.matchAll(/\bservice\s+(\w+)\s*\{/g)];
|
|
332
|
+
return [...text.matchAll(/\brpc\s+(\w+)\s*\(\s*(stream\s+)?[\w.]+\s*\)\s*returns\s*\(\s*(stream\s+)?[\w.]+\s*\)/g)].map((rpc) => {
|
|
333
|
+
const service = services.filter((match) => match.index < rpc.index).pop();
|
|
334
|
+
if (service === void 0) throw new Error(`rpc ${rpc[1]} is outside a service`);
|
|
335
|
+
const clientStream = rpc[2] !== void 0;
|
|
336
|
+
const serverStream = rpc[3] !== void 0;
|
|
337
|
+
const qualifiedService = packageName === "" ? service[1] : `${packageName}.${service[1]}`;
|
|
338
|
+
return {
|
|
339
|
+
method: clientStream && serverStream ? "bidirectional" : clientStream ? "client_streaming" : serverStream ? "server_streaming" : "unary",
|
|
340
|
+
path: `${qualifiedService}/${rpc[1]}`,
|
|
341
|
+
operationId: rpc[1],
|
|
342
|
+
summary: "",
|
|
343
|
+
description: "",
|
|
344
|
+
parameters: JSON.stringify({
|
|
345
|
+
packageName,
|
|
346
|
+
serviceName: service[1]
|
|
347
|
+
})
|
|
348
|
+
};
|
|
349
|
+
});
|
|
297
350
|
}
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
return relativePath === ".uigraph.yaml" || relativePath.startsWith(".uigraph/");
|
|
351
|
+
function parseApiSpec(type, text) {
|
|
352
|
+
if (type === "openapi") return parseOpenApi(text);
|
|
353
|
+
if (type === "graphql") return parseGraphQLSchema(text);
|
|
354
|
+
if (type === "grpc") return parseProto(text);
|
|
355
|
+
throw new Error(`Unknown API type: ${String(type)}`);
|
|
304
356
|
}
|
|
305
|
-
function
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
if (!isArtifactPath(relativePath) || relativePath.endsWith("/")) throw new Error(`Only ${ROOT_CONFIG} and files under .uigraph/ can be written: ${requested}`);
|
|
309
|
-
return relativePath;
|
|
357
|
+
async function convertDiagram(mermaid, context) {
|
|
358
|
+
if (context === void 0) return convertMermaidToReactFlow(mermaid);
|
|
359
|
+
return convertMermaidToReactFlowWithContext(mermaid, contextSchema.parse(context), { repositionNodes: true });
|
|
310
360
|
}
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
361
|
+
function parseDatabaseSchema(dialect, text) {
|
|
362
|
+
if (dialect === "dynamodb") {
|
|
363
|
+
const table = DynamoEditorSchema.parse(JSON.parse(text));
|
|
364
|
+
return {
|
|
365
|
+
sdkDialect: dialect,
|
|
366
|
+
ast: convertDynamoSchemaToAst(table),
|
|
367
|
+
sourceContent: table,
|
|
368
|
+
noSQLSchema: { dynamo: { table } }
|
|
369
|
+
};
|
|
317
370
|
}
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
371
|
+
if (dialect === "mongodb") {
|
|
372
|
+
const schema = MongoEditorSchema.parse(JSON.parse(text));
|
|
373
|
+
return {
|
|
374
|
+
sdkDialect: dialect,
|
|
375
|
+
ast: convertMongoSchemaToAst(schema),
|
|
376
|
+
sourceContent: schema,
|
|
377
|
+
noSQLSchema: { mongo: { collections: schema.collections } }
|
|
378
|
+
};
|
|
323
379
|
}
|
|
380
|
+
const sdkDialect = dialect === "postgres" ? "postgresql" : dialect === "other" ? SqlToAstParser.detectDialect(text) : dialect;
|
|
381
|
+
return {
|
|
382
|
+
sdkDialect,
|
|
383
|
+
ast: new SqlToAstParser(sdkDialect).parse(text),
|
|
384
|
+
sourceContent: void 0,
|
|
385
|
+
noSQLSchema: null
|
|
386
|
+
};
|
|
324
387
|
}
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
});
|
|
338
|
-
if (stat === null) {
|
|
339
|
-
if (!isLast) await fs.mkdir(current);
|
|
340
|
-
continue;
|
|
341
|
-
}
|
|
342
|
-
if (stat.isSymbolicLink()) throw new Error(`Refusing to write through a symbolic link: ${relativePath}`);
|
|
343
|
-
if (!isLast && !stat.isDirectory()) throw new Error(`Not a directory: ${segments.slice(0, index + 1).join("/")}`);
|
|
344
|
-
if (isLast && !stat.isFile()) throw new Error(`Not a file: ${relativePath}`);
|
|
345
|
-
}
|
|
346
|
-
const temporary = path.join(path.dirname(current), `.${path.basename(current)}.${randomUUID()}.tmp`);
|
|
347
|
-
await fs.writeFile(temporary, content, { flag: "wx" });
|
|
348
|
-
await fs.rename(temporary, current);
|
|
349
|
-
return relativePath;
|
|
388
|
+
function toTables(ast) {
|
|
389
|
+
return ast.tables.map((table) => ({
|
|
390
|
+
name: table.name,
|
|
391
|
+
columns: table.columns.map((column) => ({
|
|
392
|
+
name: column.name,
|
|
393
|
+
type: column.dataType.name,
|
|
394
|
+
nullable: column.nullable,
|
|
395
|
+
description: column.comment || null,
|
|
396
|
+
isPrimaryKey: table.constraints.some((c) => c.type === "primary_key" && c.columns.includes(column.name))
|
|
397
|
+
})),
|
|
398
|
+
indexes: []
|
|
399
|
+
}));
|
|
350
400
|
}
|
|
351
|
-
function
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
401
|
+
function toServiceEntity(id, service, updatedAt) {
|
|
402
|
+
return {
|
|
403
|
+
id,
|
|
404
|
+
orgId: ORG_ID,
|
|
405
|
+
folderId: null,
|
|
406
|
+
teamId: null,
|
|
407
|
+
name: service.name,
|
|
408
|
+
description: service.description,
|
|
409
|
+
status: "active",
|
|
410
|
+
tier: "tier3",
|
|
411
|
+
category: service.category,
|
|
412
|
+
language: "",
|
|
413
|
+
gitRepoUrl: service.repository?.url ?? null,
|
|
414
|
+
jiraProjectUrl: service.integrations?.jira?.url ?? null,
|
|
415
|
+
slackChannelUrl: service.integrations?.slack?.url ?? null,
|
|
416
|
+
lastCommitSha: null,
|
|
417
|
+
labels: service.labels ?? [],
|
|
418
|
+
metadata: "{}",
|
|
419
|
+
docLinks: null,
|
|
420
|
+
createdBy: ACTOR_ID,
|
|
421
|
+
updatedBy: null,
|
|
422
|
+
stats: {
|
|
423
|
+
serviceId: id,
|
|
424
|
+
endpointCount: 0,
|
|
425
|
+
diagramCount: 0,
|
|
426
|
+
dbTableCount: 0,
|
|
427
|
+
docCount: 0,
|
|
428
|
+
testCaseCount: 0
|
|
429
|
+
},
|
|
430
|
+
createdAt: updatedAt,
|
|
431
|
+
updatedAt
|
|
432
|
+
};
|
|
358
433
|
}
|
|
359
|
-
async function
|
|
360
|
-
const
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
const
|
|
371
|
-
const
|
|
372
|
-
if (
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
await writeArtifactFile(root, contextPath, `${JSON.stringify(next.context, null, 2)}\n`);
|
|
434
|
+
async function loadArtifacts(root, configFile = ROOT_CONFIG) {
|
|
435
|
+
const errors = [];
|
|
436
|
+
const configFiles = [configFile];
|
|
437
|
+
if (configFile === ".uigraph.yaml") configFiles.push(...(await glob(".uigraph/services/*.yaml", {
|
|
438
|
+
cwd: root,
|
|
439
|
+
dot: true,
|
|
440
|
+
onlyFiles: true,
|
|
441
|
+
followSymbolicLinks: false
|
|
442
|
+
})).sort());
|
|
443
|
+
const configs = [];
|
|
444
|
+
for (const file of configFiles) try {
|
|
445
|
+
const { text, updatedAt } = await readArtifactFile(root, file);
|
|
446
|
+
const parsed = configSchema.safeParse(parse$1(text));
|
|
447
|
+
if (!parsed.success) {
|
|
448
|
+
for (const issue of parsed.error.issues) errors.push(`${file}: ${issue.path.join(".") || "root"}: ${issue.message}`);
|
|
449
|
+
continue;
|
|
376
450
|
}
|
|
451
|
+
configs.push({
|
|
452
|
+
file,
|
|
453
|
+
config: parsed.data,
|
|
454
|
+
updatedAt
|
|
455
|
+
});
|
|
456
|
+
} catch (error) {
|
|
457
|
+
errors.push(`${file}: ${describe(error)}`);
|
|
377
458
|
}
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
const entry = document.getIn(["architectureDiagrams", diagram.index], true);
|
|
381
|
-
if (!isMap(entry) || entry.flow === true) throw new Error(`${diagram.file}: architectureDiagrams[${diagram.index}] must be a block mapping`);
|
|
382
|
-
const edits = [];
|
|
383
|
-
if (name !== diagram.entry.name) edits.push(replaceScalar(entry.get("name", true), name));
|
|
384
|
-
if (contextPath !== diagram.entry.contextPath) {
|
|
385
|
-
const pathPair = entry.items.find((pair) => isScalar(pair.key) && pair.key.value === "path");
|
|
386
|
-
const key = pathPair?.key;
|
|
387
|
-
const value = pathPair?.value;
|
|
388
|
-
if (!key?.range || !value?.range) throw new Error(`${diagram.file}: architectureDiagrams[${diagram.index}] has no path`);
|
|
389
|
-
const indent = key.range[0] - source.lastIndexOf("\n", key.range[0]) - 1;
|
|
390
|
-
edits.push({
|
|
391
|
-
start: value.range[1],
|
|
392
|
-
end: value.range[1],
|
|
393
|
-
text: `\n${" ".repeat(indent)}contextPath: ${stringify(contextPath, { lineWidth: 0 }).trimEnd()}`
|
|
394
|
-
});
|
|
395
|
-
}
|
|
396
|
-
await writeArtifactFile(root, diagram.file, applyEdits(source, edits));
|
|
459
|
+
for (const { file, config } of configs) if (config.service === void 0) {
|
|
460
|
+
if (config.apis.length > 0 || config.dependencies.length > 0 || config.databases.length > 0 || config.queries.length > 0 || config.queryFiles.length > 0) errors.push(`${file}: apis, dependencies, databases and queries require a service block`);
|
|
397
461
|
}
|
|
398
|
-
const
|
|
399
|
-
const
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
async function createDiagram(root, configFile, file, name) {
|
|
408
|
-
const { db, errors } = await loadArtifacts(root, configFile);
|
|
409
|
-
if (errors.length > 0) throw new Error(`The .uigraph artifacts are invalid:\n${errors.join("\n")}`);
|
|
410
|
-
if (db.diagrams.some((d) => d.file === file && d.entry.name === name)) throw new Error(`Another diagram in ${file} is named "${name}"`);
|
|
411
|
-
const base = slugify(name);
|
|
412
|
-
if (base === "") throw new Error("The diagram name must contain a letter or digit");
|
|
413
|
-
let folder = base;
|
|
414
|
-
for (let suffix = 2; db.diagrams.some((d) => d.entity.id === folder) || await fs.lstat(path.join(root, ".uigraph/diagrams", folder)).then(() => true, (error) => {
|
|
415
|
-
if (error.code === "ENOENT") return false;
|
|
416
|
-
throw error;
|
|
417
|
-
}); suffix++) folder = `${base}-${suffix}`;
|
|
418
|
-
const entry = {
|
|
419
|
-
name,
|
|
420
|
-
path: `.uigraph/diagrams/${folder}/${folder}.mmd`,
|
|
421
|
-
contextPath: `.uigraph/diagrams/${folder}/context.json`
|
|
422
|
-
};
|
|
423
|
-
const empty = convertUiGraphToMermaid({
|
|
424
|
-
nodes: [],
|
|
425
|
-
edges: []
|
|
426
|
-
});
|
|
427
|
-
await writeArtifactFile(root, entry.path, empty.mermaid);
|
|
428
|
-
await writeArtifactFile(root, entry.contextPath, `${JSON.stringify(empty.context, null, 2)}\n`);
|
|
429
|
-
const { source, document } = await readYaml(root, file);
|
|
430
|
-
await writeArtifactFile(root, file, applyEdits(source, [appendItem(source, document, ["architectureDiagrams"], entry)]));
|
|
431
|
-
return folder;
|
|
432
|
-
}
|
|
433
|
-
async function deleteDiagram(root, configFile, diagramId) {
|
|
434
|
-
const { db, errors } = await loadArtifacts(root, configFile);
|
|
435
|
-
if (errors.length > 0) throw new Error(`The .uigraph artifacts are invalid:\n${errors.join("\n")}`);
|
|
436
|
-
const diagram = db.diagrams.find((d) => d.entity.id === diagramId);
|
|
437
|
-
if (diagram === void 0) throw new Error(`Diagram ${diagramId} not found`);
|
|
438
|
-
const images = await listDiagramImages(root, diagram.entry.path);
|
|
439
|
-
const { source, document } = await readYaml(root, diagram.file);
|
|
440
|
-
await writeArtifactFile(root, diagram.file, applyEdits(source, [removeItem(source, document, ["architectureDiagrams"], diagram.index)]));
|
|
441
|
-
for (const file of [
|
|
442
|
-
diagram.entry.path,
|
|
443
|
-
diagram.entry.contextPath,
|
|
444
|
-
...images.map((image) => image.imagePath)
|
|
445
|
-
]) if (file !== void 0 && isArtifactPath(file)) await deleteArtifactFile(root, file);
|
|
446
|
-
}
|
|
447
|
-
async function renameDiagramReferences(root, files, serviceName, oldName, newName) {
|
|
448
|
-
for (const file of files) {
|
|
449
|
-
const { source, document } = await readYaml(root, file);
|
|
450
|
-
const edits = [];
|
|
451
|
-
visit(document, { Map(_, map) {
|
|
452
|
-
if (map.get("serviceName") === serviceName && map.get("architectureDiagramName") === oldName) edits.push(replaceScalar(map.get("architectureDiagramName", true), newName));
|
|
453
|
-
} });
|
|
454
|
-
if (edits.length > 0) await writeArtifactFile(root, file, applyEdits(source, edits));
|
|
455
|
-
}
|
|
456
|
-
}
|
|
457
|
-
const diagramCandidateSchema = z.object({
|
|
458
|
-
id: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "Use a kebab-case id").max(48).describe("Kebab-case id. It becomes the diagram directory name under .uigraph/diagrams/."),
|
|
459
|
-
name: z.string().min(1).max(60).describe("Plain display name that says what the diagram shows"),
|
|
460
|
-
type: z.enum([
|
|
461
|
-
"flowchart",
|
|
462
|
-
"sequenceDiagram",
|
|
463
|
-
"stateDiagram-v2",
|
|
464
|
-
"C4Container",
|
|
465
|
-
"C4Component"
|
|
466
|
-
]).describe("Mermaid diagram type"),
|
|
467
|
-
summary: z.string().min(1).max(160).describe("One line saying what the diagram shows"),
|
|
468
|
-
evidence: z.array(z.string().min(1)).min(1).max(4).describe("Repository-relative files that prove this flow or structure exists"),
|
|
469
|
-
recommended: z.boolean().describe("Pre-selected for the owner")
|
|
470
|
-
});
|
|
471
|
-
const docCandidateSchema = z.object({
|
|
472
|
-
path: z.string().min(1).describe("Repository-relative path of an existing Markdown or HTML document"),
|
|
473
|
-
name: z.string().min(1).max(60).describe("Plain display name, such as \"Deployment guide\""),
|
|
474
|
-
summary: z.string().min(1).max(160).describe("One line saying what the document covers")
|
|
475
|
-
});
|
|
476
|
-
function findPlanProblems(diagrams) {
|
|
477
|
-
const problems = [];
|
|
478
|
-
const seen = /* @__PURE__ */ new Set();
|
|
479
|
-
for (const { id } of diagrams) {
|
|
480
|
-
if (seen.has(id)) problems.push(`Duplicate id: ${id}`);
|
|
481
|
-
seen.add(id);
|
|
482
|
-
}
|
|
483
|
-
const recommended = diagrams.filter((diagram) => diagram.recommended).length;
|
|
484
|
-
if (recommended < 1 || recommended > 8) problems.push(`Mark between 1 and 8 diagrams as recommended (found ${recommended})`);
|
|
485
|
-
return problems;
|
|
486
|
-
}
|
|
487
|
-
function formatApprovedDiagrams(selection, existing) {
|
|
488
|
-
const lines = [];
|
|
489
|
-
if (selection.diagrams.length === 0) lines.push("The owner approved no new architecture diagrams. Do not generate any new architecture diagram.");
|
|
490
|
-
else {
|
|
491
|
-
lines.push("Approved diagrams. Generate exactly these under architectureDiagrams, each at .uigraph/diagrams/<id>/<id>.mmd with a context.json, using the listed Mermaid type. Do not generate any other new diagram:");
|
|
492
|
-
for (const diagram of selection.diagrams) lines.push(`- ${diagram.id} | ${diagram.name} | ${diagram.type} | ${diagram.summary} | evidence: ${diagram.evidence.join(", ")}`);
|
|
493
|
-
}
|
|
494
|
-
if (selection.docs.length > 0) {
|
|
495
|
-
lines.push("Approved existing documents. Add each one under docs with the listed name and its current path, and set fileType to markdown or html. Do not copy, move, or rewrite these files. This replaces the rule in references/docs.md that existing repository documents are never doc artifacts:");
|
|
496
|
-
for (const doc of selection.docs) lines.push(`- ${doc.path} | ${doc.name} | ${doc.summary}`);
|
|
497
|
-
}
|
|
498
|
-
if (existing.length > 0) lines.push(`Diagrams already in the repository (keep them and update them when the code changed): ${existing.join(", ")}.`);
|
|
499
|
-
if (selection.focus !== null) lines.push(`The owner asked you to focus on: "${selection.focus}". If this names a flow the code shows, add one diagram for it. If the code does not show it, leave it out and say so in your summary. Never invent a flow.`);
|
|
500
|
-
return lines.join("\n");
|
|
501
|
-
}
|
|
502
|
-
//#endregion
|
|
503
|
-
//#region src/analysis/repo-access.ts
|
|
504
|
-
const EXCLUDED_DIRECTORIES = [
|
|
505
|
-
".git",
|
|
506
|
-
"node_modules",
|
|
507
|
-
"dist",
|
|
508
|
-
"build",
|
|
509
|
-
"out",
|
|
510
|
-
".next",
|
|
511
|
-
".nuxt",
|
|
512
|
-
".turbo",
|
|
513
|
-
".cache",
|
|
514
|
-
"coverage",
|
|
515
|
-
"target",
|
|
516
|
-
"vendor",
|
|
517
|
-
".venv",
|
|
518
|
-
"venv",
|
|
519
|
-
"__pycache__",
|
|
520
|
-
".terraform",
|
|
521
|
-
".claude"
|
|
522
|
-
];
|
|
523
|
-
const SECRET_FILE_PATTERNS = [
|
|
524
|
-
/^\.env(\..+)?$/,
|
|
525
|
-
/\.(pem|key|p12|pfx|jks|keystore|crt|cer|der)$/,
|
|
526
|
-
/^id_(rsa|dsa|ecdsa|ed25519)/,
|
|
527
|
-
/^credentials/,
|
|
528
|
-
/^secrets?\./,
|
|
529
|
-
/^\.npmrc$/,
|
|
530
|
-
/^\.netrc$/,
|
|
531
|
-
/^\.pypirc$/,
|
|
532
|
-
/\.tfstate(\.backup)?$/
|
|
533
|
-
];
|
|
534
|
-
const ALLOWED_ENV_FILES = [
|
|
535
|
-
".env.example",
|
|
536
|
-
".env.sample",
|
|
537
|
-
".env.template"
|
|
538
|
-
];
|
|
539
|
-
const IGNORE_GLOBS = EXCLUDED_DIRECTORIES.map((dir) => `**/${dir}/**`);
|
|
540
|
-
const MAX_READ_BYTES = 2e5;
|
|
541
|
-
const MAX_READ_LINES = 2e3;
|
|
542
|
-
const MAX_LIST_RESULTS = 2e3;
|
|
543
|
-
const MAX_SEARCH_MATCHES = 200;
|
|
544
|
-
const MAX_SEARCH_FILE_BYTES = 1e6;
|
|
545
|
-
function isExcludedPath(relativePath) {
|
|
546
|
-
const segments = relativePath.split("/");
|
|
547
|
-
if (segments.some((segment) => EXCLUDED_DIRECTORIES.includes(segment))) return true;
|
|
548
|
-
const fileName = segments[segments.length - 1];
|
|
549
|
-
if (ALLOWED_ENV_FILES.includes(fileName)) return false;
|
|
550
|
-
return SECRET_FILE_PATTERNS.some((pattern) => pattern.test(fileName));
|
|
551
|
-
}
|
|
552
|
-
async function resolveRepositoryFile(root, requested) {
|
|
553
|
-
const normalized = path.posix.normalize(requested.replaceAll("\\", "/"));
|
|
554
|
-
if (normalized === ".." || normalized.startsWith("../") || path.isAbsolute(normalized)) throw new Error(`Path must be relative to the repository: ${requested}`);
|
|
555
|
-
const relativePath = normalized.replace(/^\.\//, "");
|
|
556
|
-
if (isExcludedPath(relativePath)) throw new Error(`Path is excluded from analysis: ${relativePath}`);
|
|
557
|
-
const absolutePath = path.join(root, relativePath);
|
|
558
|
-
const realPath = await fs.realpath(absolutePath);
|
|
559
|
-
if (realPath !== root && !realPath.startsWith(root + path.sep)) throw new Error(`Path resolves outside the repository: ${relativePath}`);
|
|
560
|
-
return {
|
|
561
|
-
relativePath,
|
|
562
|
-
realPath,
|
|
563
|
-
stat: await fs.stat(realPath)
|
|
564
|
-
};
|
|
565
|
-
}
|
|
566
|
-
async function listRepositoryFiles(root, pattern) {
|
|
567
|
-
const files = await glob(pattern, {
|
|
568
|
-
cwd: root,
|
|
569
|
-
ignore: IGNORE_GLOBS,
|
|
570
|
-
dot: true,
|
|
571
|
-
followSymbolicLinks: false,
|
|
572
|
-
onlyFiles: true
|
|
573
|
-
});
|
|
574
|
-
const allowed = [];
|
|
575
|
-
for (const file of files.sort()) {
|
|
576
|
-
if (isExcludedPath(file)) continue;
|
|
577
|
-
if ((await fs.lstat(path.join(root, file))).isSymbolicLink()) continue;
|
|
578
|
-
allowed.push(file);
|
|
579
|
-
}
|
|
580
|
-
return allowed;
|
|
581
|
-
}
|
|
582
|
-
function isBinary(buffer) {
|
|
583
|
-
return buffer.subarray(0, 8e3).includes(0);
|
|
584
|
-
}
|
|
585
|
-
function textResult(text) {
|
|
586
|
-
return { content: [{
|
|
587
|
-
type: "text",
|
|
588
|
-
text
|
|
589
|
-
}] };
|
|
590
|
-
}
|
|
591
|
-
function errorResult(error) {
|
|
592
|
-
return {
|
|
593
|
-
content: [{
|
|
594
|
-
type: "text",
|
|
595
|
-
text: error instanceof Error ? error.message : String(error)
|
|
596
|
-
}],
|
|
597
|
-
isError: true
|
|
598
|
-
};
|
|
599
|
-
}
|
|
600
|
-
function createRepositoryTools(root, skill, onToolUse, plan) {
|
|
601
|
-
const onSubmit = plan?.onSubmit;
|
|
602
|
-
const listFiles = agentTool("list_files", "List repository files matching a glob pattern. Generated directories and secret files are never listed.", { pattern: z.string().default("**/*").describe("Glob relative to the repository root, e.g. src/**/*.ts") }, async ({ pattern }) => {
|
|
603
|
-
onToolUse({
|
|
604
|
-
tool: "list_files",
|
|
605
|
-
pattern
|
|
606
|
-
});
|
|
607
|
-
try {
|
|
608
|
-
const files = await listRepositoryFiles(root, pattern);
|
|
609
|
-
const shown = files.slice(0, MAX_LIST_RESULTS);
|
|
610
|
-
const more = files.length > shown.length ? `\n… ${files.length - shown.length} more files; narrow the pattern` : "";
|
|
611
|
-
return textResult(shown.length === 0 ? "No files matched." : shown.join("\n") + more);
|
|
612
|
-
} catch (error) {
|
|
613
|
-
return errorResult(error);
|
|
614
|
-
}
|
|
615
|
-
});
|
|
616
|
-
const readFile = agentTool("read_file", "Read a repository text file. Lines are prefixed with their line numbers.", {
|
|
617
|
-
path: z.string().describe("Repository-relative file path"),
|
|
618
|
-
offset: z.number().int().min(1).default(1).describe("First line to read, 1-based"),
|
|
619
|
-
limit: z.number().int().min(1).max(MAX_READ_LINES).default(MAX_READ_LINES).describe("Maximum number of lines to read")
|
|
620
|
-
}, async ({ path: requested, offset, limit }) => {
|
|
621
|
-
onToolUse({
|
|
622
|
-
tool: "read_file",
|
|
623
|
-
path: requested
|
|
624
|
-
});
|
|
625
|
-
try {
|
|
626
|
-
const { realPath, stat } = await resolveRepositoryFile(root, requested);
|
|
627
|
-
if (!stat.isFile()) return errorResult(`Not a file: ${requested}`);
|
|
628
|
-
const handle = await fs.open(realPath, "r");
|
|
629
|
-
const buffer = Buffer.alloc(Math.min(stat.size, MAX_READ_BYTES));
|
|
630
|
-
await handle.read(buffer, 0, buffer.length, 0);
|
|
631
|
-
await handle.close();
|
|
632
|
-
if (isBinary(buffer)) return errorResult(`Binary file skipped: ${requested}`);
|
|
633
|
-
return textResult(buffer.toString("utf8").split("\n").slice(offset - 1, offset - 1 + limit).map((line, index) => `${offset + index}\t${line}`).join("\n") + (stat.size > MAX_READ_BYTES ? `\n… file truncated at ${MAX_READ_BYTES} bytes` : ""));
|
|
634
|
-
} catch (error) {
|
|
635
|
-
return errorResult(error);
|
|
636
|
-
}
|
|
637
|
-
});
|
|
638
|
-
const search = agentTool("search", "Search repository text files with a JavaScript regular expression. Returns path:line: text matches.", {
|
|
639
|
-
pattern: z.string().describe("JavaScript regular expression"),
|
|
640
|
-
glob: z.string().default("**/*").describe("Glob limiting which files are searched"),
|
|
641
|
-
caseInsensitive: z.boolean().default(false)
|
|
642
|
-
}, async ({ pattern, glob: fileGlob, caseInsensitive }) => {
|
|
643
|
-
onToolUse({
|
|
644
|
-
tool: "search",
|
|
645
|
-
pattern,
|
|
646
|
-
glob: fileGlob
|
|
647
|
-
});
|
|
648
|
-
try {
|
|
649
|
-
const regex = new RegExp(pattern, caseInsensitive ? "i" : "");
|
|
650
|
-
const files = await listRepositoryFiles(root, fileGlob);
|
|
651
|
-
const matches = [];
|
|
652
|
-
for (const file of files) {
|
|
653
|
-
const absolutePath = path.join(root, file);
|
|
654
|
-
if ((await fs.stat(absolutePath)).size > MAX_SEARCH_FILE_BYTES) continue;
|
|
655
|
-
const buffer = await fs.readFile(absolutePath);
|
|
656
|
-
if (isBinary(buffer)) continue;
|
|
657
|
-
const lines = buffer.toString("utf8").split("\n");
|
|
658
|
-
for (let index = 0; index < lines.length; index++) {
|
|
659
|
-
if (regex.test(lines[index])) matches.push(`${file}:${index + 1}: ${lines[index].trim().slice(0, 300)}`);
|
|
660
|
-
if (matches.length >= MAX_SEARCH_MATCHES) break;
|
|
661
|
-
}
|
|
662
|
-
if (matches.length >= MAX_SEARCH_MATCHES) {
|
|
663
|
-
matches.push(`… stopped at ${MAX_SEARCH_MATCHES} matches`);
|
|
664
|
-
break;
|
|
665
|
-
}
|
|
666
|
-
}
|
|
667
|
-
return textResult(matches.length === 0 ? "No matches." : matches.join("\n"));
|
|
668
|
-
} catch (error) {
|
|
669
|
-
return errorResult(error);
|
|
670
|
-
}
|
|
671
|
-
});
|
|
672
|
-
const writeFile = agentTool("write_file", "Create or replace a UIGraph artifact. Only .uigraph.yaml and files under .uigraph/ can be written.", {
|
|
673
|
-
path: z.string().describe("Repository-relative path: .uigraph.yaml or .uigraph/..."),
|
|
674
|
-
content: z.string().describe("Complete file content")
|
|
675
|
-
}, async ({ path: requested, content }) => {
|
|
676
|
-
onToolUse({
|
|
677
|
-
tool: "write_file",
|
|
678
|
-
path: requested
|
|
679
|
-
});
|
|
680
|
-
try {
|
|
681
|
-
return textResult(`Wrote ${await writeArtifactFile(root, requested, content)}`);
|
|
682
|
-
} catch (error) {
|
|
683
|
-
return errorResult(error);
|
|
462
|
+
const services = [];
|
|
463
|
+
const serviceFiles = [];
|
|
464
|
+
const serviceIdByName = /* @__PURE__ */ new Map();
|
|
465
|
+
for (const { file, config, updatedAt } of configs) {
|
|
466
|
+
if (config.service === void 0) continue;
|
|
467
|
+
const id = slugify(config.service.name);
|
|
468
|
+
if (serviceIdByName.has(config.service.name) || services.some((s) => s.id === id)) {
|
|
469
|
+
errors.push(`${file}: duplicate service "${config.service.name}"`);
|
|
470
|
+
continue;
|
|
684
471
|
}
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
});
|
|
691
|
-
const readSkillFile = agentTool("read_skill_file", "Read a file from the UIGraph skill, e.g. references/uigraph-yaml-schema.md or assets/templates/context-example.json.", { path: z.string().describe("Path relative to the skill directory") }, async ({ path: requested }) => {
|
|
692
|
-
onToolUse({
|
|
693
|
-
tool: "read_skill_file",
|
|
694
|
-
path: requested
|
|
472
|
+
serviceIdByName.set(config.service.name, id);
|
|
473
|
+
services.push(toServiceEntity(id, config.service, updatedAt));
|
|
474
|
+
serviceFiles.push({
|
|
475
|
+
serviceId: id,
|
|
476
|
+
file
|
|
695
477
|
});
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
onToolUse({
|
|
706
|
-
tool: "submit_diagram_plan",
|
|
707
|
-
count: diagrams.length
|
|
708
|
-
});
|
|
478
|
+
}
|
|
479
|
+
const apiGroups = [];
|
|
480
|
+
for (const { file, config, updatedAt } of configs) {
|
|
481
|
+
if (config.service === void 0) continue;
|
|
482
|
+
const serviceId = serviceIdByName.get(config.service.name);
|
|
483
|
+
if (serviceId === void 0) continue;
|
|
484
|
+
for (const api of config.apis) {
|
|
485
|
+
let text;
|
|
486
|
+
let endpoints;
|
|
709
487
|
try {
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
const { stat } = await resolveRepositoryFile(root, file);
|
|
713
|
-
if (!stat.isFile()) problems.push(`${diagram.id}: evidence is not a file: ${file}`);
|
|
714
|
-
} catch {
|
|
715
|
-
problems.push(`${diagram.id}: evidence file not found: ${file}`);
|
|
716
|
-
}
|
|
717
|
-
for (const doc of docs) {
|
|
718
|
-
if (!/\.(md|markdown|html?)$/i.test(doc.path)) {
|
|
719
|
-
problems.push(`${doc.path}: not a Markdown or HTML document`);
|
|
720
|
-
continue;
|
|
721
|
-
}
|
|
722
|
-
try {
|
|
723
|
-
const { stat } = await resolveRepositoryFile(root, doc.path);
|
|
724
|
-
if (!stat.isFile()) problems.push(`${doc.path}: document is not a file`);
|
|
725
|
-
} catch {
|
|
726
|
-
problems.push(`${doc.path}: document not found`);
|
|
727
|
-
}
|
|
728
|
-
}
|
|
729
|
-
if (problems.length > 0) return errorResult(`The plan was not accepted. Fix these and submit again:\n${problems.map((problem) => `- ${problem}`).join("\n")}`);
|
|
730
|
-
submit({
|
|
731
|
-
diagrams,
|
|
732
|
-
docs
|
|
733
|
-
});
|
|
734
|
-
return textResult("Plan received. Stop here.");
|
|
488
|
+
text = (await readArtifactFile(root, api.path)).text;
|
|
489
|
+
endpoints = parseApiSpec(api.type, text);
|
|
735
490
|
} catch (error) {
|
|
736
|
-
|
|
491
|
+
errors.push(`${file}: apis[${api.name}]: ${describe(error)}`);
|
|
492
|
+
continue;
|
|
737
493
|
}
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
".mov": "video",
|
|
799
|
-
".webm": "video",
|
|
800
|
-
".mp3": "audio",
|
|
801
|
-
".wav": "audio",
|
|
802
|
-
".ogg": "audio",
|
|
803
|
-
".m4a": "audio"
|
|
804
|
-
};
|
|
805
|
-
const DATABASE_DIALECTS = [
|
|
806
|
-
"postgres",
|
|
807
|
-
"mysql",
|
|
808
|
-
"sqlite",
|
|
809
|
-
"other",
|
|
810
|
-
"dynamodb",
|
|
811
|
-
"mongodb"
|
|
812
|
-
];
|
|
813
|
-
const queryEntrySchema = z.looseObject({
|
|
814
|
-
name: z.string().min(1),
|
|
815
|
-
database: z.string().min(1),
|
|
816
|
-
path: z.string().min(1).optional(),
|
|
817
|
-
queryText: z.string().min(1).optional(),
|
|
818
|
-
description: z.string().optional(),
|
|
819
|
-
tags: z.array(z.string()).optional()
|
|
820
|
-
});
|
|
821
|
-
const queryFileSchema = z.looseObject({ queries: z.array(queryEntrySchema).default([]) });
|
|
822
|
-
const componentRefSchema = z.looseObject({
|
|
823
|
-
componentId: z.string().min(1),
|
|
824
|
-
componentLinkId: z.string().optional(),
|
|
825
|
-
serviceName: z.string().optional(),
|
|
826
|
-
apiGroupName: z.string().optional(),
|
|
827
|
-
operationId: z.string().optional(),
|
|
828
|
-
testPackName: z.string().optional(),
|
|
829
|
-
docName: z.string().optional(),
|
|
830
|
-
architectureDiagramName: z.string().optional(),
|
|
831
|
-
modalFields: z.array(z.looseObject({
|
|
832
|
-
componentFieldId: z.string(),
|
|
833
|
-
label: z.string().optional(),
|
|
834
|
-
type: z.string().optional(),
|
|
835
|
-
data: z.array(z.unknown()).optional()
|
|
836
|
-
})).optional()
|
|
837
|
-
});
|
|
838
|
-
const mapSchema = z.looseObject({
|
|
839
|
-
name: z.string().min(1),
|
|
840
|
-
description: z.string().optional(),
|
|
841
|
-
frames: z.array(z.looseObject({
|
|
842
|
-
name: z.string().min(1),
|
|
843
|
-
description: z.string().optional(),
|
|
844
|
-
imagePath: z.string().min(1).optional(),
|
|
845
|
-
focalPoints: z.array(z.looseObject({
|
|
846
|
-
name: z.string().min(1),
|
|
847
|
-
x: z.number(),
|
|
848
|
-
y: z.number(),
|
|
849
|
-
visibility: z.string().optional(),
|
|
850
|
-
color: z.string().optional(),
|
|
851
|
-
components: z.array(componentRefSchema).optional()
|
|
852
|
-
})).optional()
|
|
853
|
-
})).optional()
|
|
854
|
-
});
|
|
855
|
-
const CANVAS_FILE = ".uigraph/maps/canvas.json";
|
|
856
|
-
const canvasPointSchema = z.object({
|
|
857
|
-
x: z.number(),
|
|
858
|
-
y: z.number()
|
|
859
|
-
});
|
|
860
|
-
const canvasFileSchema = z.record(z.string(), z.object({
|
|
861
|
-
zoom: z.number().optional(),
|
|
862
|
-
navigationX: z.number().optional(),
|
|
863
|
-
navigationY: z.number().optional(),
|
|
864
|
-
framePositions: z.record(z.string(), canvasPointSchema).optional(),
|
|
865
|
-
frames: z.record(z.string(), z.object({
|
|
866
|
-
groups: z.array(canvasPointSchema.extend({
|
|
867
|
-
name: z.string(),
|
|
868
|
-
description: z.string(),
|
|
869
|
-
width: z.number(),
|
|
870
|
-
height: z.number()
|
|
871
|
-
})).optional(),
|
|
872
|
-
links: z.array(canvasPointSchema.extend({
|
|
873
|
-
kind: z.enum(["frame", "map"]),
|
|
874
|
-
map: z.string(),
|
|
875
|
-
frame: z.string().optional(),
|
|
876
|
-
label: z.string()
|
|
877
|
-
})).optional()
|
|
878
|
-
})).optional()
|
|
879
|
-
}));
|
|
880
|
-
const configSchema = z.looseObject({
|
|
881
|
-
version: z.literal(1),
|
|
882
|
-
service: z.looseObject({
|
|
883
|
-
name: z.string().min(1),
|
|
884
|
-
category: z.string().min(1),
|
|
885
|
-
description: z.string().min(1),
|
|
886
|
-
repository: z.looseObject({
|
|
887
|
-
provider: z.string(),
|
|
888
|
-
url: z.string()
|
|
889
|
-
}).optional(),
|
|
890
|
-
ownership: z.looseObject({ team: z.string().min(1) }).optional(),
|
|
891
|
-
labels: z.array(z.string()).optional(),
|
|
892
|
-
integrations: z.looseObject({
|
|
893
|
-
slack: z.looseObject({ url: z.string() }).optional(),
|
|
894
|
-
jira: z.looseObject({ url: z.string() }).optional()
|
|
895
|
-
}).optional()
|
|
896
|
-
}).optional(),
|
|
897
|
-
apis: z.array(z.looseObject({
|
|
898
|
-
name: z.string().min(1),
|
|
899
|
-
type: z.enum([
|
|
900
|
-
"openapi",
|
|
901
|
-
"graphql",
|
|
902
|
-
"grpc"
|
|
903
|
-
]),
|
|
904
|
-
path: z.string().min(1)
|
|
905
|
-
})).default([]),
|
|
906
|
-
dependencies: z.array(z.looseObject({
|
|
907
|
-
name: z.string().min(1),
|
|
908
|
-
service: z.string().min(1),
|
|
909
|
-
direction: z.enum(["upstream", "downstream"]),
|
|
910
|
-
criticality: z.enum(["hard", "soft"]),
|
|
911
|
-
type: z.enum([
|
|
912
|
-
"http",
|
|
913
|
-
"graphql",
|
|
914
|
-
"grpc",
|
|
915
|
-
"database"
|
|
916
|
-
]).optional(),
|
|
917
|
-
description: z.string().optional(),
|
|
918
|
-
apiGroupName: z.string().optional(),
|
|
919
|
-
apiEndpointNames: z.array(z.string().min(1)).optional(),
|
|
920
|
-
databaseName: z.string().optional()
|
|
921
|
-
})).default([]),
|
|
922
|
-
architectureDiagrams: z.array(z.looseObject({
|
|
923
|
-
name: z.string().min(1),
|
|
924
|
-
path: z.string().min(1),
|
|
925
|
-
contextPath: z.string().min(1).optional()
|
|
926
|
-
})).default([]),
|
|
927
|
-
docs: z.array(z.looseObject({
|
|
928
|
-
name: z.string().min(1),
|
|
929
|
-
path: z.string().min(1),
|
|
930
|
-
fileType: z.enum(DOC_FILE_TYPES).optional(),
|
|
931
|
-
description: z.string().optional()
|
|
932
|
-
})).default([]),
|
|
933
|
-
databases: z.array(z.looseObject({
|
|
934
|
-
name: z.string().min(1),
|
|
935
|
-
dialect: z.enum(DATABASE_DIALECTS),
|
|
936
|
-
dbType: z.string().optional(),
|
|
937
|
-
schemaPath: z.string().min(1)
|
|
938
|
-
})).default([]),
|
|
939
|
-
queries: z.array(queryEntrySchema).default([]),
|
|
940
|
-
queryFiles: z.array(z.string().min(1)).default([]),
|
|
941
|
-
maps: z.array(mapSchema).default([])
|
|
942
|
-
});
|
|
943
|
-
const PROTOCOL_LABELS = {
|
|
944
|
-
openapi: "REST",
|
|
945
|
-
graphql: "GraphQL",
|
|
946
|
-
grpc: "gRPC"
|
|
947
|
-
};
|
|
948
|
-
function slugify(value) {
|
|
949
|
-
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
950
|
-
}
|
|
951
|
-
function describe(error) {
|
|
952
|
-
return error instanceof Error ? error.message : String(error);
|
|
953
|
-
}
|
|
954
|
-
function contentHash(...parts) {
|
|
955
|
-
const hash = createHash("sha256");
|
|
956
|
-
for (const part of parts) hash.update(part);
|
|
957
|
-
return hash.digest("hex");
|
|
958
|
-
}
|
|
959
|
-
async function readArtifactFile(root, relativePath) {
|
|
960
|
-
const { realPath, stat } = await resolveRepositoryFile(root, relativePath);
|
|
961
|
-
if (!stat.isFile()) throw new Error(`Not a file: ${relativePath}`);
|
|
962
|
-
if (stat.size > MAX_FILE_BYTES) throw new Error(`File is larger than ${MAX_FILE_BYTES} bytes: ${relativePath}`);
|
|
963
|
-
return {
|
|
964
|
-
text: await fs.readFile(realPath, "utf8"),
|
|
965
|
-
updatedAt: stat.mtime.toISOString()
|
|
966
|
-
};
|
|
967
|
-
}
|
|
968
|
-
function parseOpenApi(text) {
|
|
969
|
-
const spec = parse$1(text);
|
|
970
|
-
if (spec === null || typeof spec !== "object") throw new Error("not an OpenAPI document");
|
|
971
|
-
return Object.entries(spec.paths ?? {}).flatMap(([route, operations]) => HTTP_METHODS.filter((method) => operations[method] !== void 0).map((method) => {
|
|
972
|
-
const operation = operations[method];
|
|
973
|
-
return {
|
|
974
|
-
method: method.toUpperCase(),
|
|
975
|
-
path: route,
|
|
976
|
-
operationId: typeof operation.operationId === "string" ? operation.operationId : `${method.toUpperCase()} ${route}`,
|
|
977
|
-
summary: typeof operation.summary === "string" ? operation.summary : "",
|
|
978
|
-
description: typeof operation.description === "string" ? operation.description : "",
|
|
979
|
-
parameters: "[]"
|
|
980
|
-
};
|
|
981
|
-
}));
|
|
982
|
-
}
|
|
983
|
-
function parseGraphQLSchema(text) {
|
|
984
|
-
return parse(text).definitions.flatMap((definition) => {
|
|
985
|
-
if (definition.kind !== Kind.OBJECT_TYPE_DEFINITION && definition.kind !== Kind.OBJECT_TYPE_EXTENSION || ![
|
|
986
|
-
"Query",
|
|
987
|
-
"Mutation",
|
|
988
|
-
"Subscription"
|
|
989
|
-
].includes(definition.name.value)) return [];
|
|
990
|
-
return (definition.fields ?? []).map((field) => {
|
|
991
|
-
const args = field.arguments === void 0 || field.arguments.length === 0 ? "" : `(${field.arguments.map((arg) => `${arg.name.value}: ${print(arg.type)}`).join(", ")})`;
|
|
992
|
-
return {
|
|
993
|
-
method: definition.name.value,
|
|
994
|
-
path: `${field.name.value}${args}: ${print(field.type)}`,
|
|
995
|
-
operationId: field.name.value,
|
|
996
|
-
summary: "",
|
|
997
|
-
description: field.description?.value ?? "",
|
|
998
|
-
parameters: "[]"
|
|
999
|
-
};
|
|
1000
|
-
});
|
|
1001
|
-
});
|
|
1002
|
-
}
|
|
1003
|
-
function parseProto(text) {
|
|
1004
|
-
const packageName = /^\s*package\s+([\w.]+)\s*;/m.exec(text)?.[1] ?? "";
|
|
1005
|
-
const services = [...text.matchAll(/\bservice\s+(\w+)\s*\{/g)];
|
|
1006
|
-
return [...text.matchAll(/\brpc\s+(\w+)\s*\(\s*(stream\s+)?[\w.]+\s*\)\s*returns\s*\(\s*(stream\s+)?[\w.]+\s*\)/g)].map((rpc) => {
|
|
1007
|
-
const service = services.filter((match) => match.index < rpc.index).pop();
|
|
1008
|
-
if (service === void 0) throw new Error(`rpc ${rpc[1]} is outside a service`);
|
|
1009
|
-
const clientStream = rpc[2] !== void 0;
|
|
1010
|
-
const serverStream = rpc[3] !== void 0;
|
|
1011
|
-
const qualifiedService = packageName === "" ? service[1] : `${packageName}.${service[1]}`;
|
|
1012
|
-
return {
|
|
1013
|
-
method: clientStream && serverStream ? "bidirectional" : clientStream ? "client_streaming" : serverStream ? "server_streaming" : "unary",
|
|
1014
|
-
path: `${qualifiedService}/${rpc[1]}`,
|
|
1015
|
-
operationId: rpc[1],
|
|
1016
|
-
summary: "",
|
|
1017
|
-
description: "",
|
|
1018
|
-
parameters: JSON.stringify({
|
|
1019
|
-
packageName,
|
|
1020
|
-
serviceName: service[1]
|
|
1021
|
-
})
|
|
1022
|
-
};
|
|
1023
|
-
});
|
|
1024
|
-
}
|
|
1025
|
-
function parseApiSpec(type, text) {
|
|
1026
|
-
if (type === "openapi") return parseOpenApi(text);
|
|
1027
|
-
if (type === "graphql") return parseGraphQLSchema(text);
|
|
1028
|
-
if (type === "grpc") return parseProto(text);
|
|
1029
|
-
throw new Error(`Unknown API type: ${String(type)}`);
|
|
1030
|
-
}
|
|
1031
|
-
async function convertDiagram(mermaid, context) {
|
|
1032
|
-
if (context === void 0) return convertMermaidToReactFlow(mermaid);
|
|
1033
|
-
return convertMermaidToReactFlowWithContext(mermaid, contextSchema.parse(context), { repositionNodes: true });
|
|
1034
|
-
}
|
|
1035
|
-
function parseDatabaseSchema(dialect, text) {
|
|
1036
|
-
if (dialect === "dynamodb") {
|
|
1037
|
-
const table = DynamoEditorSchema.parse(JSON.parse(text));
|
|
1038
|
-
return {
|
|
1039
|
-
sdkDialect: dialect,
|
|
1040
|
-
ast: convertDynamoSchemaToAst(table),
|
|
1041
|
-
sourceContent: table,
|
|
1042
|
-
noSQLSchema: { dynamo: { table } }
|
|
1043
|
-
};
|
|
1044
|
-
}
|
|
1045
|
-
if (dialect === "mongodb") {
|
|
1046
|
-
const schema = MongoEditorSchema.parse(JSON.parse(text));
|
|
1047
|
-
return {
|
|
1048
|
-
sdkDialect: dialect,
|
|
1049
|
-
ast: convertMongoSchemaToAst(schema),
|
|
1050
|
-
sourceContent: schema,
|
|
1051
|
-
noSQLSchema: { mongo: { collections: schema.collections } }
|
|
1052
|
-
};
|
|
1053
|
-
}
|
|
1054
|
-
const sdkDialect = dialect === "postgres" ? "postgresql" : dialect === "other" ? SqlToAstParser.detectDialect(text) : dialect;
|
|
1055
|
-
return {
|
|
1056
|
-
sdkDialect,
|
|
1057
|
-
ast: new SqlToAstParser(sdkDialect).parse(text),
|
|
1058
|
-
sourceContent: void 0,
|
|
1059
|
-
noSQLSchema: null
|
|
1060
|
-
};
|
|
1061
|
-
}
|
|
1062
|
-
function toTables(ast) {
|
|
1063
|
-
return ast.tables.map((table) => ({
|
|
1064
|
-
name: table.name,
|
|
1065
|
-
columns: table.columns.map((column) => ({
|
|
1066
|
-
name: column.name,
|
|
1067
|
-
type: column.dataType.name,
|
|
1068
|
-
nullable: column.nullable,
|
|
1069
|
-
description: column.comment || null,
|
|
1070
|
-
isPrimaryKey: table.constraints.some((c) => c.type === "primary_key" && c.columns.includes(column.name))
|
|
1071
|
-
})),
|
|
1072
|
-
indexes: []
|
|
1073
|
-
}));
|
|
1074
|
-
}
|
|
1075
|
-
function toServiceEntity(id, service, updatedAt) {
|
|
1076
|
-
return {
|
|
1077
|
-
id,
|
|
1078
|
-
orgId: ORG_ID,
|
|
1079
|
-
folderId: null,
|
|
1080
|
-
teamId: null,
|
|
1081
|
-
name: service.name,
|
|
1082
|
-
description: service.description,
|
|
1083
|
-
status: "active",
|
|
1084
|
-
tier: "tier3",
|
|
1085
|
-
category: service.category,
|
|
1086
|
-
language: "",
|
|
1087
|
-
gitRepoUrl: service.repository?.url ?? null,
|
|
1088
|
-
jiraProjectUrl: service.integrations?.jira?.url ?? null,
|
|
1089
|
-
slackChannelUrl: service.integrations?.slack?.url ?? null,
|
|
1090
|
-
lastCommitSha: null,
|
|
1091
|
-
labels: service.labels ?? [],
|
|
1092
|
-
metadata: "{}",
|
|
1093
|
-
docLinks: null,
|
|
1094
|
-
createdBy: ACTOR_ID,
|
|
1095
|
-
updatedBy: null,
|
|
1096
|
-
stats: {
|
|
1097
|
-
serviceId: id,
|
|
1098
|
-
endpointCount: 0,
|
|
1099
|
-
diagramCount: 0,
|
|
1100
|
-
dbTableCount: 0,
|
|
1101
|
-
docCount: 0,
|
|
1102
|
-
testCaseCount: 0
|
|
1103
|
-
},
|
|
1104
|
-
createdAt: updatedAt,
|
|
1105
|
-
updatedAt
|
|
1106
|
-
};
|
|
1107
|
-
}
|
|
1108
|
-
async function loadArtifacts(root, configFile = ROOT_CONFIG) {
|
|
1109
|
-
const errors = [];
|
|
1110
|
-
const configFiles = [configFile];
|
|
1111
|
-
if (configFile === ".uigraph.yaml") configFiles.push(...(await glob(".uigraph/services/*.yaml", {
|
|
1112
|
-
cwd: root,
|
|
1113
|
-
dot: true,
|
|
1114
|
-
onlyFiles: true,
|
|
1115
|
-
followSymbolicLinks: false
|
|
1116
|
-
})).sort());
|
|
1117
|
-
const configs = [];
|
|
1118
|
-
for (const file of configFiles) try {
|
|
1119
|
-
const { text, updatedAt } = await readArtifactFile(root, file);
|
|
1120
|
-
const parsed = configSchema.safeParse(parse$1(text));
|
|
1121
|
-
if (!parsed.success) {
|
|
1122
|
-
for (const issue of parsed.error.issues) errors.push(`${file}: ${issue.path.join(".") || "root"}: ${issue.message}`);
|
|
1123
|
-
continue;
|
|
1124
|
-
}
|
|
1125
|
-
configs.push({
|
|
1126
|
-
file,
|
|
1127
|
-
config: parsed.data,
|
|
1128
|
-
updatedAt
|
|
1129
|
-
});
|
|
1130
|
-
} catch (error) {
|
|
1131
|
-
errors.push(`${file}: ${describe(error)}`);
|
|
1132
|
-
}
|
|
1133
|
-
for (const { file, config } of configs) if (config.service === void 0) {
|
|
1134
|
-
if (config.apis.length > 0 || config.dependencies.length > 0 || config.databases.length > 0 || config.queries.length > 0 || config.queryFiles.length > 0) errors.push(`${file}: apis, dependencies, databases and queries require a service block`);
|
|
1135
|
-
}
|
|
1136
|
-
const services = [];
|
|
1137
|
-
const serviceFiles = [];
|
|
1138
|
-
const serviceIdByName = /* @__PURE__ */ new Map();
|
|
1139
|
-
for (const { file, config, updatedAt } of configs) {
|
|
1140
|
-
if (config.service === void 0) continue;
|
|
1141
|
-
const id = slugify(config.service.name);
|
|
1142
|
-
if (serviceIdByName.has(config.service.name) || services.some((s) => s.id === id)) {
|
|
1143
|
-
errors.push(`${file}: duplicate service "${config.service.name}"`);
|
|
1144
|
-
continue;
|
|
1145
|
-
}
|
|
1146
|
-
serviceIdByName.set(config.service.name, id);
|
|
1147
|
-
services.push(toServiceEntity(id, config.service, updatedAt));
|
|
1148
|
-
serviceFiles.push({
|
|
1149
|
-
serviceId: id,
|
|
1150
|
-
file
|
|
1151
|
-
});
|
|
1152
|
-
}
|
|
1153
|
-
const apiGroups = [];
|
|
1154
|
-
for (const { file, config, updatedAt } of configs) {
|
|
1155
|
-
if (config.service === void 0) continue;
|
|
1156
|
-
const serviceId = serviceIdByName.get(config.service.name);
|
|
1157
|
-
if (serviceId === void 0) continue;
|
|
1158
|
-
for (const api of config.apis) {
|
|
1159
|
-
let text;
|
|
1160
|
-
let endpoints;
|
|
1161
|
-
try {
|
|
1162
|
-
text = (await readArtifactFile(root, api.path)).text;
|
|
1163
|
-
endpoints = parseApiSpec(api.type, text);
|
|
1164
|
-
} catch (error) {
|
|
1165
|
-
errors.push(`${file}: apis[${api.name}]: ${describe(error)}`);
|
|
1166
|
-
continue;
|
|
1167
|
-
}
|
|
1168
|
-
const id = slugify(`${serviceId}-${api.name}`);
|
|
1169
|
-
const hash = contentHash(text);
|
|
1170
|
-
const fileName = path.posix.basename(api.path);
|
|
1171
|
-
apiGroups.push({
|
|
1172
|
-
group: {
|
|
1173
|
-
id,
|
|
1174
|
-
serviceId,
|
|
1175
|
-
orgId: ORG_ID,
|
|
1176
|
-
name: api.name,
|
|
1177
|
-
version: "1",
|
|
1178
|
-
label: null,
|
|
1179
|
-
protocol: PROTOCOL_LABELS[api.type],
|
|
1180
|
-
specKey: fileName,
|
|
1181
|
-
specHash: hash,
|
|
1182
|
-
createdBy: ACTOR_ID,
|
|
1183
|
-
updatedBy: null,
|
|
1184
|
-
createdAt: updatedAt,
|
|
1185
|
-
updatedAt
|
|
1186
|
-
},
|
|
1187
|
-
version: {
|
|
1188
|
-
id: `${id}-v1`,
|
|
1189
|
-
orgId: ORG_ID,
|
|
1190
|
-
apiGroupId: id,
|
|
1191
|
-
versionNumber: 1,
|
|
1192
|
-
label: null,
|
|
1193
|
-
specKey: fileName,
|
|
1194
|
-
specHash: hash,
|
|
1195
|
-
isAutoVersion: false,
|
|
1196
|
-
createdBy: ACTOR_ID,
|
|
1197
|
-
createdAt: updatedAt
|
|
1198
|
-
},
|
|
1199
|
-
spec: api.type === "openapi" ? {
|
|
1200
|
-
fileName,
|
|
1201
|
-
content: text
|
|
1202
|
-
} : null,
|
|
1203
|
-
endpoints: endpoints.map((endpoint, index) => ({
|
|
1204
|
-
id: slugify(`${id}-${endpoint.method}-${endpoint.path}`),
|
|
1205
|
-
apiGroupId: id,
|
|
1206
|
-
serviceId,
|
|
1207
|
-
orgId: ORG_ID,
|
|
1208
|
-
operationId: endpoint.operationId,
|
|
1209
|
-
method: endpoint.method,
|
|
1210
|
-
path: endpoint.path,
|
|
1211
|
-
summary: endpoint.summary,
|
|
1212
|
-
description: endpoint.description,
|
|
1213
|
-
tags: [],
|
|
1214
|
-
parameters: endpoint.parameters,
|
|
1215
|
-
requestBody: "{}",
|
|
1216
|
-
responses: "{}",
|
|
1217
|
-
exampleRequests: "[]",
|
|
1218
|
-
exampleResponses: "[]",
|
|
1219
|
-
order: index,
|
|
1220
|
-
sla: null,
|
|
1221
|
-
createdBy: ACTOR_ID,
|
|
1222
|
-
updatedBy: null,
|
|
1223
|
-
createdAt: updatedAt,
|
|
1224
|
-
updatedAt
|
|
1225
|
-
}))
|
|
1226
|
-
});
|
|
1227
|
-
}
|
|
494
|
+
const id = slugify(`${serviceId}-${api.name}`);
|
|
495
|
+
const hash = contentHash(text);
|
|
496
|
+
const fileName = path.posix.basename(api.path);
|
|
497
|
+
apiGroups.push({
|
|
498
|
+
group: {
|
|
499
|
+
id,
|
|
500
|
+
serviceId,
|
|
501
|
+
orgId: ORG_ID,
|
|
502
|
+
name: api.name,
|
|
503
|
+
version: "1",
|
|
504
|
+
label: null,
|
|
505
|
+
protocol: PROTOCOL_LABELS[api.type],
|
|
506
|
+
specKey: fileName,
|
|
507
|
+
specHash: hash,
|
|
508
|
+
createdBy: ACTOR_ID,
|
|
509
|
+
updatedBy: null,
|
|
510
|
+
createdAt: updatedAt,
|
|
511
|
+
updatedAt
|
|
512
|
+
},
|
|
513
|
+
version: {
|
|
514
|
+
id: `${id}-v1`,
|
|
515
|
+
orgId: ORG_ID,
|
|
516
|
+
apiGroupId: id,
|
|
517
|
+
versionNumber: 1,
|
|
518
|
+
label: null,
|
|
519
|
+
specKey: fileName,
|
|
520
|
+
specHash: hash,
|
|
521
|
+
isAutoVersion: false,
|
|
522
|
+
createdBy: ACTOR_ID,
|
|
523
|
+
createdAt: updatedAt
|
|
524
|
+
},
|
|
525
|
+
spec: api.type === "openapi" ? {
|
|
526
|
+
fileName,
|
|
527
|
+
content: text
|
|
528
|
+
} : null,
|
|
529
|
+
endpoints: endpoints.map((endpoint, index) => ({
|
|
530
|
+
id: slugify(`${id}-${endpoint.method}-${endpoint.path}`),
|
|
531
|
+
apiGroupId: id,
|
|
532
|
+
serviceId,
|
|
533
|
+
orgId: ORG_ID,
|
|
534
|
+
operationId: endpoint.operationId,
|
|
535
|
+
method: endpoint.method,
|
|
536
|
+
path: endpoint.path,
|
|
537
|
+
summary: endpoint.summary,
|
|
538
|
+
description: endpoint.description,
|
|
539
|
+
tags: [],
|
|
540
|
+
parameters: endpoint.parameters,
|
|
541
|
+
requestBody: "{}",
|
|
542
|
+
responses: "{}",
|
|
543
|
+
exampleRequests: "[]",
|
|
544
|
+
exampleResponses: "[]",
|
|
545
|
+
order: index,
|
|
546
|
+
sla: null,
|
|
547
|
+
createdBy: ACTOR_ID,
|
|
548
|
+
updatedBy: null,
|
|
549
|
+
createdAt: updatedAt,
|
|
550
|
+
updatedAt
|
|
551
|
+
}))
|
|
552
|
+
});
|
|
553
|
+
}
|
|
1228
554
|
}
|
|
1229
555
|
const diagrams = [];
|
|
1230
556
|
const diagramIds = /* @__PURE__ */ new Set();
|
|
@@ -1347,7 +673,7 @@ async function loadArtifacts(root, configFile = ROOT_CONFIG) {
|
|
|
1347
673
|
fileUrl: null,
|
|
1348
674
|
fileName: entry.name,
|
|
1349
675
|
serviceName: config.service?.name ?? null,
|
|
1350
|
-
fileType: entry.fileType ?? DOC_EXTENSIONS[path.posix.extname(entry.path).toLowerCase()] ?? "other",
|
|
676
|
+
fileType: entry.fileType ?? DOC_EXTENSIONS$1[path.posix.extname(entry.path).toLowerCase()] ?? "other",
|
|
1351
677
|
description: entry.description ?? "",
|
|
1352
678
|
contentHash: `${stat.size}-${stat.mtimeMs}`,
|
|
1353
679
|
createdBy: ACTOR_ID,
|
|
@@ -1552,533 +878,791 @@ async function loadArtifacts(root, configFile = ROOT_CONFIG) {
|
|
|
1552
878
|
});
|
|
1553
879
|
}
|
|
1554
880
|
}
|
|
1555
|
-
let canvas = {};
|
|
1556
|
-
try {
|
|
1557
|
-
const parsed = canvasFileSchema.safeParse(JSON.parse((await readArtifactFile(root, CANVAS_FILE)).text));
|
|
1558
|
-
if (parsed.success) canvas = parsed.data;
|
|
1559
|
-
else for (const issue of parsed.error.issues) errors.push(`${CANVAS_FILE}: ${issue.path.join(".") || "root"}: ${issue.message}`);
|
|
1560
|
-
} catch (error) {
|
|
1561
|
-
if (error.code !== "ENOENT") errors.push(`${CANVAS_FILE}: ${describe(error)}`);
|
|
881
|
+
let canvas = {};
|
|
882
|
+
try {
|
|
883
|
+
const parsed = canvasFileSchema.safeParse(JSON.parse((await readArtifactFile(root, CANVAS_FILE)).text));
|
|
884
|
+
if (parsed.success) canvas = parsed.data;
|
|
885
|
+
else for (const issue of parsed.error.issues) errors.push(`${CANVAS_FILE}: ${issue.path.join(".") || "root"}: ${issue.message}`);
|
|
886
|
+
} catch (error) {
|
|
887
|
+
if (error.code !== "ENOENT") errors.push(`${CANVAS_FILE}: ${describe(error)}`);
|
|
888
|
+
}
|
|
889
|
+
const maps = [];
|
|
890
|
+
const mapIds = /* @__PURE__ */ new Set();
|
|
891
|
+
for (const { file, config, updatedAt } of configs) for (const [index, entry] of config.maps.entries()) {
|
|
892
|
+
const where = `${file}: maps[${entry.name}]`;
|
|
893
|
+
const id = slugify(entry.name);
|
|
894
|
+
if (mapIds.has(id)) {
|
|
895
|
+
errors.push(`${where}: another map has the same name`);
|
|
896
|
+
continue;
|
|
897
|
+
}
|
|
898
|
+
mapIds.add(id);
|
|
899
|
+
const mapCanvas = canvas[entry.name] ?? {};
|
|
900
|
+
const frames = [];
|
|
901
|
+
const frameIds = /* @__PURE__ */ new Set();
|
|
902
|
+
for (const [frameIndex, frameEntry] of (entry.frames ?? []).entries()) {
|
|
903
|
+
const frameWhere = `${where}: frames[${frameEntry.name}]`;
|
|
904
|
+
const frameId = `${id}--${slugify(frameEntry.name)}`;
|
|
905
|
+
if (frameIds.has(frameId)) {
|
|
906
|
+
errors.push(`${frameWhere}: another frame has the same name`);
|
|
907
|
+
continue;
|
|
908
|
+
}
|
|
909
|
+
frameIds.add(frameId);
|
|
910
|
+
let imageHash = null;
|
|
911
|
+
if (frameEntry.imagePath !== void 0) try {
|
|
912
|
+
const { stat } = await resolveRepositoryFile(root, frameEntry.imagePath);
|
|
913
|
+
if (!stat.isFile()) throw new Error(`Not a file: ${frameEntry.imagePath}`);
|
|
914
|
+
imageHash = `${stat.size}-${stat.mtimeMs}`;
|
|
915
|
+
} catch (error) {
|
|
916
|
+
errors.push(`${frameWhere}: ${describe(error)}`);
|
|
917
|
+
continue;
|
|
918
|
+
}
|
|
919
|
+
const pointNames = /* @__PURE__ */ new Set();
|
|
920
|
+
const focalPoints = [];
|
|
921
|
+
for (const [pointIndex, point] of (frameEntry.focalPoints ?? []).entries()) {
|
|
922
|
+
if (pointNames.has(point.name)) {
|
|
923
|
+
errors.push(`${frameWhere}: focalPoints[${point.name}]: another focal point has the same name`);
|
|
924
|
+
continue;
|
|
925
|
+
}
|
|
926
|
+
pointNames.add(point.name);
|
|
927
|
+
const pointId = `${frameId}--p${pointIndex}`;
|
|
928
|
+
focalPoints.push({
|
|
929
|
+
point: {
|
|
930
|
+
id: pointId,
|
|
931
|
+
frameId,
|
|
932
|
+
orgId: ORG_ID,
|
|
933
|
+
name: point.name,
|
|
934
|
+
locationX: point.x,
|
|
935
|
+
locationY: point.y,
|
|
936
|
+
visibility: point.visibility ?? "public",
|
|
937
|
+
color: point.color ?? null,
|
|
938
|
+
isActive: true,
|
|
939
|
+
createdBy: ACTOR_ID,
|
|
940
|
+
updatedBy: null,
|
|
941
|
+
createdByCommitHash: null,
|
|
942
|
+
updatedByCommitHash: null,
|
|
943
|
+
createdAt: updatedAt,
|
|
944
|
+
updatedAt
|
|
945
|
+
},
|
|
946
|
+
components: (point.components ?? []).map((ref, refIndex) => ({
|
|
947
|
+
ref,
|
|
948
|
+
meta: {
|
|
949
|
+
id: `${pointId}--c${refIndex}`,
|
|
950
|
+
focalPointId: pointId,
|
|
951
|
+
orgId: ORG_ID,
|
|
952
|
+
frameId,
|
|
953
|
+
componentId: ref.componentId,
|
|
954
|
+
componentLinkDiagramId: null,
|
|
955
|
+
componentLinkApiEndpointId: null,
|
|
956
|
+
componentLinkTestPackId: null,
|
|
957
|
+
componentLinkServiceDocId: null,
|
|
958
|
+
componentModalFields: (ref.modalFields ?? []).map((field, order) => ({
|
|
959
|
+
componentFieldId: field.componentFieldId,
|
|
960
|
+
label: field.label ?? null,
|
|
961
|
+
type: field.type ?? null,
|
|
962
|
+
required: null,
|
|
963
|
+
isReadonly: null,
|
|
964
|
+
data: field.data ?? null,
|
|
965
|
+
options: null,
|
|
966
|
+
order
|
|
967
|
+
})),
|
|
968
|
+
createdBy: ACTOR_ID,
|
|
969
|
+
updatedBy: null,
|
|
970
|
+
createdByCommitHash: null,
|
|
971
|
+
updatedByCommitHash: null,
|
|
972
|
+
createdAt: updatedAt,
|
|
973
|
+
updatedAt
|
|
974
|
+
}
|
|
975
|
+
}))
|
|
976
|
+
});
|
|
977
|
+
}
|
|
978
|
+
const frameCanvas = mapCanvas.frames?.[frameEntry.name] ?? {};
|
|
979
|
+
frames.push({
|
|
980
|
+
name: frameEntry.name,
|
|
981
|
+
frame: {
|
|
982
|
+
id: frameId,
|
|
983
|
+
mapId: id,
|
|
984
|
+
orgId: ORG_ID,
|
|
985
|
+
parentFrameId: null,
|
|
986
|
+
name: frameEntry.name,
|
|
987
|
+
description: frameEntry.description ?? "",
|
|
988
|
+
templateType: "default",
|
|
989
|
+
screenshotAssetId: frameEntry.imagePath ?? null,
|
|
990
|
+
screenshotImageUrl: null,
|
|
991
|
+
screenshotContentHash: imageHash,
|
|
992
|
+
status: "active",
|
|
993
|
+
order: frameIndex,
|
|
994
|
+
source: "cli",
|
|
995
|
+
focalPointCount: focalPoints.length,
|
|
996
|
+
createdBy: ACTOR_ID,
|
|
997
|
+
updatedBy: null,
|
|
998
|
+
createdByCommitHash: null,
|
|
999
|
+
updatedByCommitHash: null,
|
|
1000
|
+
createdByActor: null,
|
|
1001
|
+
updatedByActor: null,
|
|
1002
|
+
createdAt: updatedAt,
|
|
1003
|
+
updatedAt
|
|
1004
|
+
},
|
|
1005
|
+
focalPoints,
|
|
1006
|
+
groups: (frameCanvas.groups ?? []).map((group, groupIndex) => ({
|
|
1007
|
+
id: `${frameId}--g${groupIndex}`,
|
|
1008
|
+
frameId,
|
|
1009
|
+
orgId: ORG_ID,
|
|
1010
|
+
name: group.name,
|
|
1011
|
+
description: group.description,
|
|
1012
|
+
locationX: group.x,
|
|
1013
|
+
locationY: group.y,
|
|
1014
|
+
width: group.width,
|
|
1015
|
+
height: group.height,
|
|
1016
|
+
order: groupIndex,
|
|
1017
|
+
isActive: true,
|
|
1018
|
+
createdBy: ACTOR_ID,
|
|
1019
|
+
updatedBy: null,
|
|
1020
|
+
createdAt: updatedAt,
|
|
1021
|
+
updatedAt
|
|
1022
|
+
})),
|
|
1023
|
+
links: (frameCanvas.links ?? []).map((link, linkIndex) => ({
|
|
1024
|
+
target: {
|
|
1025
|
+
map: link.map,
|
|
1026
|
+
frame: link.frame
|
|
1027
|
+
},
|
|
1028
|
+
link: {
|
|
1029
|
+
id: `${frameId}--l${linkIndex}`,
|
|
1030
|
+
frameId,
|
|
1031
|
+
orgId: ORG_ID,
|
|
1032
|
+
kind: link.kind,
|
|
1033
|
+
targetFrameId: null,
|
|
1034
|
+
targetMapId: null,
|
|
1035
|
+
label: link.label,
|
|
1036
|
+
locationX: link.x,
|
|
1037
|
+
locationY: link.y,
|
|
1038
|
+
isActive: true,
|
|
1039
|
+
createdBy: ACTOR_ID,
|
|
1040
|
+
updatedBy: null,
|
|
1041
|
+
createdAt: updatedAt,
|
|
1042
|
+
updatedAt
|
|
1043
|
+
}
|
|
1044
|
+
}))
|
|
1045
|
+
});
|
|
1046
|
+
}
|
|
1047
|
+
maps.push({
|
|
1048
|
+
file,
|
|
1049
|
+
index,
|
|
1050
|
+
entry,
|
|
1051
|
+
canvas: mapCanvas,
|
|
1052
|
+
frames,
|
|
1053
|
+
map: {
|
|
1054
|
+
id,
|
|
1055
|
+
orgId: ORG_ID,
|
|
1056
|
+
folderId: null,
|
|
1057
|
+
teamId: null,
|
|
1058
|
+
name: entry.name,
|
|
1059
|
+
description: entry.description ?? "",
|
|
1060
|
+
status: "active",
|
|
1061
|
+
createdBy: ACTOR_ID,
|
|
1062
|
+
updatedBy: null,
|
|
1063
|
+
createdByCommitHash: null,
|
|
1064
|
+
updatedByCommitHash: null,
|
|
1065
|
+
createdAt: updatedAt,
|
|
1066
|
+
updatedAt,
|
|
1067
|
+
previewImgUrls: []
|
|
1068
|
+
}
|
|
1069
|
+
});
|
|
1070
|
+
}
|
|
1071
|
+
const dependencies = [];
|
|
1072
|
+
for (const { file, config } of configs) {
|
|
1073
|
+
if (config.service === void 0) continue;
|
|
1074
|
+
const service = services.find((s) => s.id === serviceIdByName.get(config.service.name));
|
|
1075
|
+
if (service === void 0) continue;
|
|
1076
|
+
const names = /* @__PURE__ */ new Set();
|
|
1077
|
+
for (const dependency of config.dependencies) {
|
|
1078
|
+
const where = `${file}: dependencies[${dependency.name}]`;
|
|
1079
|
+
if (names.has(dependency.name)) errors.push(`${where}: duplicate dependency name`);
|
|
1080
|
+
names.add(dependency.name);
|
|
1081
|
+
if (dependency.service === config.service.name) errors.push(`${where}: a service cannot depend on itself`);
|
|
1082
|
+
const endpointNames = dependency.apiEndpointNames ?? [];
|
|
1083
|
+
if (new Set(endpointNames).size !== endpointNames.length) errors.push(`${where}: apiEndpointNames must be unique`);
|
|
1084
|
+
const target = services.find((s) => s.id === slugify(dependency.service));
|
|
1085
|
+
dependencies.push({
|
|
1086
|
+
id: slugify(`${service.id}-${dependency.name}`),
|
|
1087
|
+
name: dependency.name,
|
|
1088
|
+
service: toDependencyService(service),
|
|
1089
|
+
dependency: target === void 0 ? null : toDependencyService(target),
|
|
1090
|
+
dependencyName: dependency.service,
|
|
1091
|
+
direction: dependency.direction,
|
|
1092
|
+
type: dependency.type ?? null,
|
|
1093
|
+
criticality: dependency.criticality,
|
|
1094
|
+
description: dependency.description ?? null,
|
|
1095
|
+
apiGroupName: dependency.apiGroupName ?? null,
|
|
1096
|
+
apiEndpointNames: endpointNames,
|
|
1097
|
+
databaseName: dependency.databaseName ?? null
|
|
1098
|
+
});
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
for (const service of services) {
|
|
1102
|
+
service.stats.endpointCount = apiGroups.filter(({ group }) => group.serviceId === service.id).reduce((count, { endpoints }) => count + endpoints.length, 0);
|
|
1103
|
+
service.stats.diagramCount = serviceDiagrams.filter((link) => link.serviceId === service.id).length;
|
|
1104
|
+
service.stats.docCount = docs.filter(({ serviceDoc }) => serviceDoc.serviceId === service.id).length;
|
|
1105
|
+
service.stats.dbTableCount = databases.filter(({ serviceDb }) => serviceDb.serviceId === service.id).reduce((count, { serviceDb }) => count + serviceDb.tables.length, 0);
|
|
1106
|
+
}
|
|
1107
|
+
return {
|
|
1108
|
+
errors,
|
|
1109
|
+
db: {
|
|
1110
|
+
configFiles: configs.map(({ file }) => file),
|
|
1111
|
+
services,
|
|
1112
|
+
serviceFiles,
|
|
1113
|
+
apiGroups,
|
|
1114
|
+
diagrams,
|
|
1115
|
+
serviceDiagrams,
|
|
1116
|
+
docs,
|
|
1117
|
+
standaloneDocs,
|
|
1118
|
+
databases,
|
|
1119
|
+
queries,
|
|
1120
|
+
maps,
|
|
1121
|
+
dependencies
|
|
1122
|
+
}
|
|
1123
|
+
};
|
|
1124
|
+
}
|
|
1125
|
+
function toDependencyService(service) {
|
|
1126
|
+
return {
|
|
1127
|
+
id: service.id,
|
|
1128
|
+
name: service.name,
|
|
1129
|
+
description: service.description,
|
|
1130
|
+
status: service.status,
|
|
1131
|
+
tier: service.tier,
|
|
1132
|
+
category: service.category,
|
|
1133
|
+
language: service.language,
|
|
1134
|
+
gitRepoUrl: service.gitRepoUrl,
|
|
1135
|
+
updatedAt: service.updatedAt,
|
|
1136
|
+
metadata: null
|
|
1137
|
+
};
|
|
1138
|
+
}
|
|
1139
|
+
//#endregion
|
|
1140
|
+
//#region src/version.ts
|
|
1141
|
+
const { version } = createRequire(import.meta.url)("../package.json");
|
|
1142
|
+
const VERSION = version;
|
|
1143
|
+
const BUILD_ID = createHash("sha1").update(readFileSync(new URL(import.meta.url))).digest("hex").slice(0, 8);
|
|
1144
|
+
//#endregion
|
|
1145
|
+
//#region src/banner.ts
|
|
1146
|
+
const NAME = "UIGraph";
|
|
1147
|
+
const WEBSITE = "https://uigraph.app";
|
|
1148
|
+
const TAGLINE = "Map your project into services, APIs and diagrams";
|
|
1149
|
+
function blue(text) {
|
|
1150
|
+
return pc.isColorSupported ? `\x1b[38;2;59;107;255m${text}\x1b[39m` : text;
|
|
1151
|
+
}
|
|
1152
|
+
const MARK = [[
|
|
1153
|
+
0,
|
|
1154
|
+
0,
|
|
1155
|
+
pc.white("⏺")
|
|
1156
|
+
]];
|
|
1157
|
+
for (let col = 1; col < 5; col++) MARK.push([
|
|
1158
|
+
0,
|
|
1159
|
+
col,
|
|
1160
|
+
blue("━")
|
|
1161
|
+
]);
|
|
1162
|
+
MARK.push([
|
|
1163
|
+
0,
|
|
1164
|
+
5,
|
|
1165
|
+
blue("⏺")
|
|
1166
|
+
]);
|
|
1167
|
+
MARK.push([
|
|
1168
|
+
1,
|
|
1169
|
+
5,
|
|
1170
|
+
blue("┃")
|
|
1171
|
+
]);
|
|
1172
|
+
MARK.push([
|
|
1173
|
+
2,
|
|
1174
|
+
5,
|
|
1175
|
+
blue("⏺")
|
|
1176
|
+
]);
|
|
1177
|
+
for (let col = 4; col > 0; col--) MARK.push([
|
|
1178
|
+
2,
|
|
1179
|
+
col,
|
|
1180
|
+
blue("━")
|
|
1181
|
+
]);
|
|
1182
|
+
MARK.push([
|
|
1183
|
+
2,
|
|
1184
|
+
0,
|
|
1185
|
+
blue("⏺")
|
|
1186
|
+
]);
|
|
1187
|
+
MARK.push([
|
|
1188
|
+
1,
|
|
1189
|
+
0,
|
|
1190
|
+
blue("┃")
|
|
1191
|
+
]);
|
|
1192
|
+
function imageProtocol() {
|
|
1193
|
+
const program = process.env.TERM_PROGRAM;
|
|
1194
|
+
if (program === "iTerm.app" || program === "WezTerm") return "iterm";
|
|
1195
|
+
if (program === "ghostty" || process.env.TERM === "xterm-kitty" || process.env.KITTY_WINDOW_ID) return "kitty";
|
|
1196
|
+
return null;
|
|
1197
|
+
}
|
|
1198
|
+
function logoImage(protocol) {
|
|
1199
|
+
const file = fs$1.readFileSync(new URL("./uigraph-mark.png", import.meta.url));
|
|
1200
|
+
const png = file.toString("base64");
|
|
1201
|
+
if (protocol === "iterm") return `\x1b]1337;File=inline=1;size=${file.length};height=3;preserveAspectRatio=1:${png}\x07`;
|
|
1202
|
+
const chunks = png.match(/.{1,4096}/g);
|
|
1203
|
+
return chunks.map((chunk, index) => {
|
|
1204
|
+
const more = index < chunks.length - 1 ? 1 : 0;
|
|
1205
|
+
return `\x1b_G${index === 0 ? `a=T,f=100,r=3,C=1,q=2,m=${more}` : `m=${more}`};${chunk}\x1b\\`;
|
|
1206
|
+
}).join("");
|
|
1207
|
+
}
|
|
1208
|
+
function render(frame, markFrames) {
|
|
1209
|
+
const grid = Array.from({ length: 3 }, () => Array(6).fill(" "));
|
|
1210
|
+
for (const [row, col, char] of MARK.slice(0, Math.min(frame, markFrames))) grid[row][col] = char;
|
|
1211
|
+
const letters = Math.max(0, frame - markFrames);
|
|
1212
|
+
const done = letters > 7;
|
|
1213
|
+
const text = [
|
|
1214
|
+
pc.bold(NAME.slice(0, letters)) + (done ? pc.dim(` v${VERSION}`) : ""),
|
|
1215
|
+
done ? blue(WEBSITE) : "",
|
|
1216
|
+
done ? pc.dim(TAGLINE) : ""
|
|
1217
|
+
];
|
|
1218
|
+
return grid.map((cells, row) => ` ${cells.join("")} ${text[row]}\x1b[K`).join("\n");
|
|
1219
|
+
}
|
|
1220
|
+
async function showBanner() {
|
|
1221
|
+
if (!process.stdout.isTTY) return;
|
|
1222
|
+
const protocol = imageProtocol();
|
|
1223
|
+
const markFrames = protocol ? 0 : MARK.length;
|
|
1224
|
+
const frames = markFrames + 7 + 1;
|
|
1225
|
+
process.stdout.write("\n");
|
|
1226
|
+
for (let frame = 1; frame <= frames; frame++) {
|
|
1227
|
+
process.stdout.write(`${frame === 1 ? "" : "\x1B[2A\r"}${render(frame, markFrames)}`);
|
|
1228
|
+
if (frame === frames && protocol) process.stdout.write(`\x1b7\x1b[2A\r\x1b[2C${logoImage(protocol)}\x1b8`);
|
|
1229
|
+
await setTimeout$1(20);
|
|
1230
|
+
}
|
|
1231
|
+
process.stdout.write("\n");
|
|
1232
|
+
}
|
|
1233
|
+
//#endregion
|
|
1234
|
+
//#region src/artifacts/yaml-edit.ts
|
|
1235
|
+
function applyEdits(source, edits) {
|
|
1236
|
+
return [...edits].sort((a, b) => b.start - a.start).reduce((text, edit) => text.slice(0, edit.start) + edit.text + text.slice(edit.end), source);
|
|
1237
|
+
}
|
|
1238
|
+
async function readYaml(root, file) {
|
|
1239
|
+
const { realPath } = await resolveRepositoryFile(root, file);
|
|
1240
|
+
const source = await fs.readFile(realPath, "utf8");
|
|
1241
|
+
return {
|
|
1242
|
+
source,
|
|
1243
|
+
document: parseDocument(source)
|
|
1244
|
+
};
|
|
1245
|
+
}
|
|
1246
|
+
function nodeRange(node, what) {
|
|
1247
|
+
const range = node?.range;
|
|
1248
|
+
if (range === void 0 || range === null) throw new Error(`Cannot locate ${what} in the YAML file`);
|
|
1249
|
+
return range;
|
|
1250
|
+
}
|
|
1251
|
+
function column(source, position) {
|
|
1252
|
+
return position - (source.lastIndexOf("\n", position - 1) + 1);
|
|
1253
|
+
}
|
|
1254
|
+
function lineStart(source, position) {
|
|
1255
|
+
return source.lastIndexOf("\n", position - 1) + 1;
|
|
1256
|
+
}
|
|
1257
|
+
function renderBlock(value, indent) {
|
|
1258
|
+
return stringify(value, { lineWidth: 0 }).trimEnd().split("\n").map((line, index) => index === 0 ? line : " ".repeat(indent) + line).join("\n");
|
|
1259
|
+
}
|
|
1260
|
+
function replaceScalar(node, value) {
|
|
1261
|
+
if (!isScalar(node)) throw new Error(`Expected a YAML scalar to replace with "${value}"`);
|
|
1262
|
+
const [start, end] = nodeRange(node, "a scalar");
|
|
1263
|
+
return {
|
|
1264
|
+
start,
|
|
1265
|
+
end,
|
|
1266
|
+
text: stringify(value, { lineWidth: 0 }).trimEnd()
|
|
1267
|
+
};
|
|
1268
|
+
}
|
|
1269
|
+
function replaceItem(source, node, value) {
|
|
1270
|
+
if (!isMap(node) || node.flow === true) throw new Error("Expected a block mapping in the YAML file");
|
|
1271
|
+
const [start, end] = nodeRange(node, "an entry");
|
|
1272
|
+
const trailingNewline = source[end - 1] === "\n" ? "\n" : "";
|
|
1273
|
+
return {
|
|
1274
|
+
start,
|
|
1275
|
+
end,
|
|
1276
|
+
text: renderBlock(value, column(source, start)) + trailingNewline
|
|
1277
|
+
};
|
|
1278
|
+
}
|
|
1279
|
+
function setField(source, map, key, value) {
|
|
1280
|
+
if (!isMap(map) || map.flow === true) throw new Error(`Expected a block mapping to set "${key}"`);
|
|
1281
|
+
const existing = map.get(key, true);
|
|
1282
|
+
if (isScalar(existing) && (typeof value === "string" || typeof value === "number")) return replaceScalar(existing, value);
|
|
1283
|
+
if (existing !== void 0) throw new Error(`Cannot replace the "${key}" field in place`);
|
|
1284
|
+
const first = map.items[0]?.key;
|
|
1285
|
+
if (first === void 0) throw new Error(`Cannot add "${key}" to an empty mapping`);
|
|
1286
|
+
const indent = column(source, nodeRange(first, "a key")[0]);
|
|
1287
|
+
const end = nodeRange(map, "a mapping")[1];
|
|
1288
|
+
return {
|
|
1289
|
+
start: end,
|
|
1290
|
+
end,
|
|
1291
|
+
text: `${source[end - 1] === "\n" ? "" : "\n"}${" ".repeat(indent)}${renderBlock({ [key]: value }, indent)}\n`
|
|
1292
|
+
};
|
|
1293
|
+
}
|
|
1294
|
+
function appendItem(source, document, path, value) {
|
|
1295
|
+
const sequence = document.getIn(path, true);
|
|
1296
|
+
if (isSeq(sequence) && sequence.flow !== true) {
|
|
1297
|
+
const range = nodeRange(sequence, path.join("."));
|
|
1298
|
+
const indent = column(source, range[0]);
|
|
1299
|
+
const needsNewline = source[range[1] - 1] === "\n" ? "" : "\n";
|
|
1300
|
+
return {
|
|
1301
|
+
start: range[1],
|
|
1302
|
+
end: range[1],
|
|
1303
|
+
text: `${needsNewline}${" ".repeat(indent)}- ${renderBlock(value, indent + 2)}\n`
|
|
1304
|
+
};
|
|
1305
|
+
}
|
|
1306
|
+
const key = path.at(-1);
|
|
1307
|
+
if (typeof key !== "string") throw new Error(`Cannot add an item to ${path.join(".")}`);
|
|
1308
|
+
const parent = path.length === 1 ? document.contents : document.getIn(path.slice(0, -1), true);
|
|
1309
|
+
if (!isMap(parent) || parent.flow === true) throw new Error(`Cannot add an item to ${path.join(".")}`);
|
|
1310
|
+
const pair = parent.items.find((item) => isScalar(item.key) && item.key.value === key);
|
|
1311
|
+
if (pair !== void 0) {
|
|
1312
|
+
if (!isSeq(pair.value) || pair.value.items.length > 0) throw new Error(`${path.join(".")} must be a list`);
|
|
1313
|
+
const keyColumn = column(source, nodeRange(pair.key, key)[0]);
|
|
1314
|
+
const [valueStart, end] = nodeRange(pair.value, key);
|
|
1315
|
+
return {
|
|
1316
|
+
start: source.lastIndexOf(":", valueStart) + 1,
|
|
1317
|
+
end,
|
|
1318
|
+
text: `\n${" ".repeat(keyColumn + 2)}- ${renderBlock(value, keyColumn + 4)}`
|
|
1319
|
+
};
|
|
1562
1320
|
}
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
componentLinkServiceDocId: null,
|
|
1632
|
-
componentModalFields: (ref.modalFields ?? []).map((field, order) => ({
|
|
1633
|
-
componentFieldId: field.componentFieldId,
|
|
1634
|
-
label: field.label ?? null,
|
|
1635
|
-
type: field.type ?? null,
|
|
1636
|
-
required: null,
|
|
1637
|
-
isReadonly: null,
|
|
1638
|
-
data: field.data ?? null,
|
|
1639
|
-
options: null,
|
|
1640
|
-
order
|
|
1641
|
-
})),
|
|
1642
|
-
createdBy: ACTOR_ID,
|
|
1643
|
-
updatedBy: null,
|
|
1644
|
-
createdByCommitHash: null,
|
|
1645
|
-
updatedByCommitHash: null,
|
|
1646
|
-
createdAt: updatedAt,
|
|
1647
|
-
updatedAt
|
|
1648
|
-
}
|
|
1649
|
-
}))
|
|
1650
|
-
});
|
|
1651
|
-
}
|
|
1652
|
-
const frameCanvas = mapCanvas.frames?.[frameEntry.name] ?? {};
|
|
1653
|
-
frames.push({
|
|
1654
|
-
name: frameEntry.name,
|
|
1655
|
-
frame: {
|
|
1656
|
-
id: frameId,
|
|
1657
|
-
mapId: id,
|
|
1658
|
-
orgId: ORG_ID,
|
|
1659
|
-
parentFrameId: null,
|
|
1660
|
-
name: frameEntry.name,
|
|
1661
|
-
description: frameEntry.description ?? "",
|
|
1662
|
-
templateType: "default",
|
|
1663
|
-
screenshotAssetId: frameEntry.imagePath ?? null,
|
|
1664
|
-
screenshotImageUrl: null,
|
|
1665
|
-
screenshotContentHash: imageHash,
|
|
1666
|
-
status: "active",
|
|
1667
|
-
order: frameIndex,
|
|
1668
|
-
source: "cli",
|
|
1669
|
-
focalPointCount: focalPoints.length,
|
|
1670
|
-
createdBy: ACTOR_ID,
|
|
1671
|
-
updatedBy: null,
|
|
1672
|
-
createdByCommitHash: null,
|
|
1673
|
-
updatedByCommitHash: null,
|
|
1674
|
-
createdByActor: null,
|
|
1675
|
-
updatedByActor: null,
|
|
1676
|
-
createdAt: updatedAt,
|
|
1677
|
-
updatedAt
|
|
1678
|
-
},
|
|
1679
|
-
focalPoints,
|
|
1680
|
-
groups: (frameCanvas.groups ?? []).map((group, groupIndex) => ({
|
|
1681
|
-
id: `${frameId}--g${groupIndex}`,
|
|
1682
|
-
frameId,
|
|
1683
|
-
orgId: ORG_ID,
|
|
1684
|
-
name: group.name,
|
|
1685
|
-
description: group.description,
|
|
1686
|
-
locationX: group.x,
|
|
1687
|
-
locationY: group.y,
|
|
1688
|
-
width: group.width,
|
|
1689
|
-
height: group.height,
|
|
1690
|
-
order: groupIndex,
|
|
1691
|
-
isActive: true,
|
|
1692
|
-
createdBy: ACTOR_ID,
|
|
1693
|
-
updatedBy: null,
|
|
1694
|
-
createdAt: updatedAt,
|
|
1695
|
-
updatedAt
|
|
1696
|
-
})),
|
|
1697
|
-
links: (frameCanvas.links ?? []).map((link, linkIndex) => ({
|
|
1698
|
-
target: {
|
|
1699
|
-
map: link.map,
|
|
1700
|
-
frame: link.frame
|
|
1701
|
-
},
|
|
1702
|
-
link: {
|
|
1703
|
-
id: `${frameId}--l${linkIndex}`,
|
|
1704
|
-
frameId,
|
|
1705
|
-
orgId: ORG_ID,
|
|
1706
|
-
kind: link.kind,
|
|
1707
|
-
targetFrameId: null,
|
|
1708
|
-
targetMapId: null,
|
|
1709
|
-
label: link.label,
|
|
1710
|
-
locationX: link.x,
|
|
1711
|
-
locationY: link.y,
|
|
1712
|
-
isActive: true,
|
|
1713
|
-
createdBy: ACTOR_ID,
|
|
1714
|
-
updatedBy: null,
|
|
1715
|
-
createdAt: updatedAt,
|
|
1716
|
-
updatedAt
|
|
1717
|
-
}
|
|
1718
|
-
}))
|
|
1719
|
-
});
|
|
1720
|
-
}
|
|
1721
|
-
maps.push({
|
|
1722
|
-
file,
|
|
1723
|
-
index,
|
|
1724
|
-
entry,
|
|
1725
|
-
canvas: mapCanvas,
|
|
1726
|
-
frames,
|
|
1727
|
-
map: {
|
|
1728
|
-
id,
|
|
1729
|
-
orgId: ORG_ID,
|
|
1730
|
-
folderId: null,
|
|
1731
|
-
teamId: null,
|
|
1732
|
-
name: entry.name,
|
|
1733
|
-
description: entry.description ?? "",
|
|
1734
|
-
status: "active",
|
|
1735
|
-
createdBy: ACTOR_ID,
|
|
1736
|
-
updatedBy: null,
|
|
1737
|
-
createdByCommitHash: null,
|
|
1738
|
-
updatedByCommitHash: null,
|
|
1739
|
-
createdAt: updatedAt,
|
|
1740
|
-
updatedAt,
|
|
1741
|
-
previewImgUrls: []
|
|
1742
|
-
}
|
|
1321
|
+
return setField(source, parent, key, [value]);
|
|
1322
|
+
}
|
|
1323
|
+
function removeItem(source, document, path, index) {
|
|
1324
|
+
const sequence = document.getIn(path, true);
|
|
1325
|
+
if (!isSeq(sequence) || sequence.flow === true) throw new Error(`${path.join(".")} must be a block list`);
|
|
1326
|
+
const item = sequence.items[index];
|
|
1327
|
+
if (item === void 0) throw new Error(`${path.join(".")} has no item ${index}`);
|
|
1328
|
+
if (sequence.items.length > 1) {
|
|
1329
|
+
const [start, , end] = nodeRange(item, "an item");
|
|
1330
|
+
return {
|
|
1331
|
+
start: lineStart(source, start),
|
|
1332
|
+
end,
|
|
1333
|
+
text: ""
|
|
1334
|
+
};
|
|
1335
|
+
}
|
|
1336
|
+
const [start, , end] = nodeRange(sequence, path.join("."));
|
|
1337
|
+
return {
|
|
1338
|
+
start: source.lastIndexOf(":", start) + 1,
|
|
1339
|
+
end,
|
|
1340
|
+
text: " []\n"
|
|
1341
|
+
};
|
|
1342
|
+
}
|
|
1343
|
+
function findItemIndex(document, path, name) {
|
|
1344
|
+
const sequence = document.getIn(path, true);
|
|
1345
|
+
if (!isSeq(sequence)) throw new Error(`${path.join(".")} must be a list`);
|
|
1346
|
+
const index = sequence.items.findIndex((item) => isMap(item) && item.get("name") === name);
|
|
1347
|
+
if (index === -1) throw new Error(`${path.join(".")} has no entry named "${name}"`);
|
|
1348
|
+
return index;
|
|
1349
|
+
}
|
|
1350
|
+
//#endregion
|
|
1351
|
+
//#region src/artifacts/write.ts
|
|
1352
|
+
const MAX_WRITE_BYTES = 2e6;
|
|
1353
|
+
const MAX_UPLOAD_BYTES = 5e7;
|
|
1354
|
+
function isArtifactPath(relativePath) {
|
|
1355
|
+
return relativePath === ".uigraph.yaml" || relativePath.startsWith(".uigraph/");
|
|
1356
|
+
}
|
|
1357
|
+
function toArtifactPath(requested) {
|
|
1358
|
+
const relativePath = path.posix.normalize(requested.replaceAll("\\", "/")).replace(/^\.\//, "");
|
|
1359
|
+
if (path.posix.isAbsolute(relativePath) || relativePath.split("/").includes("..")) throw new Error(`Path must be relative to the repository: ${requested}`);
|
|
1360
|
+
if (!isArtifactPath(relativePath) || relativePath.endsWith("/")) throw new Error(`Only ${ROOT_CONFIG} and files under .uigraph/ can be written: ${requested}`);
|
|
1361
|
+
return relativePath;
|
|
1362
|
+
}
|
|
1363
|
+
async function deleteArtifactFile(root, requested) {
|
|
1364
|
+
const relativePath = toArtifactPath(requested);
|
|
1365
|
+
let current = root;
|
|
1366
|
+
for (const segment of relativePath.split("/")) {
|
|
1367
|
+
current = path.join(current, segment);
|
|
1368
|
+
if ((await fs.lstat(current)).isSymbolicLink()) throw new Error(`Refusing to delete through a symbolic link: ${relativePath}`);
|
|
1369
|
+
}
|
|
1370
|
+
await fs.rm(current);
|
|
1371
|
+
const artifactRoot = path.join(root, ".uigraph");
|
|
1372
|
+
for (let directory = path.dirname(current); directory.startsWith(`${artifactRoot}${path.sep}`); directory = path.dirname(directory)) {
|
|
1373
|
+
if ((await fs.readdir(directory)).length > 0) return;
|
|
1374
|
+
await fs.rmdir(directory);
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
async function writeArtifactFile(root, requested, content) {
|
|
1378
|
+
const relativePath = toArtifactPath(requested);
|
|
1379
|
+
const limit = typeof content === "string" ? MAX_WRITE_BYTES : MAX_UPLOAD_BYTES;
|
|
1380
|
+
if (Buffer.byteLength(content) > limit) throw new Error(`Content is larger than ${limit} bytes`);
|
|
1381
|
+
const segments = relativePath.split("/");
|
|
1382
|
+
let current = root;
|
|
1383
|
+
for (const [index, segment] of segments.entries()) {
|
|
1384
|
+
current = path.join(current, segment);
|
|
1385
|
+
const isLast = index === segments.length - 1;
|
|
1386
|
+
const stat = await fs.lstat(current).catch((error) => {
|
|
1387
|
+
if (error.code === "ENOENT") return null;
|
|
1388
|
+
throw error;
|
|
1743
1389
|
});
|
|
1390
|
+
if (stat === null) {
|
|
1391
|
+
if (!isLast) await fs.mkdir(current);
|
|
1392
|
+
continue;
|
|
1393
|
+
}
|
|
1394
|
+
if (stat.isSymbolicLink()) throw new Error(`Refusing to write through a symbolic link: ${relativePath}`);
|
|
1395
|
+
if (!isLast && !stat.isDirectory()) throw new Error(`Not a directory: ${segments.slice(0, index + 1).join("/")}`);
|
|
1396
|
+
if (isLast && !stat.isFile()) throw new Error(`Not a file: ${relativePath}`);
|
|
1744
1397
|
}
|
|
1745
|
-
const
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1398
|
+
const temporary = path.join(path.dirname(current), `.${path.basename(current)}.${randomUUID()}.tmp`);
|
|
1399
|
+
await fs.writeFile(temporary, content, { flag: "wx" });
|
|
1400
|
+
await fs.rename(temporary, current);
|
|
1401
|
+
return relativePath;
|
|
1402
|
+
}
|
|
1403
|
+
function exportDiagram(nodes, edges) {
|
|
1404
|
+
if (isC4ReactFlowDiagram(nodes)) return convertReactFlowToC4UiGraph(nodes, edges);
|
|
1405
|
+
if (isSequenceDiagram(nodes)) return convertReactFlowToSequenceUiGraph(nodes, edges);
|
|
1406
|
+
return convertUiGraphToMermaid({
|
|
1407
|
+
nodes,
|
|
1408
|
+
edges
|
|
1409
|
+
});
|
|
1410
|
+
}
|
|
1411
|
+
async function saveDiagram(root, configFile, diagramId, input) {
|
|
1412
|
+
const { db, errors } = await loadArtifacts(root, configFile);
|
|
1413
|
+
if (errors.length > 0) throw new Error(`The .uigraph artifacts are invalid:\n${errors.join("\n")}`);
|
|
1414
|
+
const diagram = db.diagrams.find((d) => d.entity.id === diagramId);
|
|
1415
|
+
if (diagram === void 0) throw new Error(`Diagram ${diagramId} not found`);
|
|
1416
|
+
const name = input.name === void 0 || input.name === null || input.name.trim() === "" ? diagram.entry.name : input.name.trim();
|
|
1417
|
+
if (db.diagrams.some((d) => d.file === diagram.file && d.entity.id !== diagramId && d.entry.name === name)) throw new Error(`Another diagram in ${diagram.file} is named "${name}"`);
|
|
1418
|
+
let contextPath = diagram.entry.contextPath;
|
|
1419
|
+
if (input.content !== void 0 && input.content !== null) {
|
|
1420
|
+
const parsed = JSON.parse(input.content);
|
|
1421
|
+
if (!Array.isArray(parsed.nodes) || !Array.isArray(parsed.edges)) throw new Error("Diagram content must have nodes and edges arrays");
|
|
1422
|
+
const next = exportDiagram(parsed.nodes, parsed.edges);
|
|
1423
|
+
const previous = exportDiagram(diagram.flow.nodes, diagram.flow.edges);
|
|
1424
|
+
if (JSON.stringify(next) !== JSON.stringify(previous)) {
|
|
1425
|
+
contextPath ??= path.posix.join(path.posix.dirname(diagram.entry.path), "context.json");
|
|
1426
|
+
await writeArtifactFile(root, diagram.entry.path, next.mermaid);
|
|
1427
|
+
await writeArtifactFile(root, contextPath, `${JSON.stringify(next.context, null, 2)}\n`);
|
|
1773
1428
|
}
|
|
1774
1429
|
}
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1430
|
+
if (name !== diagram.entry.name || contextPath !== diagram.entry.contextPath) {
|
|
1431
|
+
const { source, document } = await readYaml(root, diagram.file);
|
|
1432
|
+
const entry = document.getIn(["architectureDiagrams", diagram.index], true);
|
|
1433
|
+
if (!isMap(entry) || entry.flow === true) throw new Error(`${diagram.file}: architectureDiagrams[${diagram.index}] must be a block mapping`);
|
|
1434
|
+
const edits = [];
|
|
1435
|
+
if (name !== diagram.entry.name) edits.push(replaceScalar(entry.get("name", true), name));
|
|
1436
|
+
if (contextPath !== diagram.entry.contextPath) {
|
|
1437
|
+
const pathPair = entry.items.find((pair) => isScalar(pair.key) && pair.key.value === "path");
|
|
1438
|
+
const key = pathPair?.key;
|
|
1439
|
+
const value = pathPair?.value;
|
|
1440
|
+
if (!key?.range || !value?.range) throw new Error(`${diagram.file}: architectureDiagrams[${diagram.index}] has no path`);
|
|
1441
|
+
const indent = key.range[0] - source.lastIndexOf("\n", key.range[0]) - 1;
|
|
1442
|
+
edits.push({
|
|
1443
|
+
start: value.range[1],
|
|
1444
|
+
end: value.range[1],
|
|
1445
|
+
text: `\n${" ".repeat(indent)}contextPath: ${stringify(contextPath, { lineWidth: 0 }).trimEnd()}`
|
|
1446
|
+
});
|
|
1447
|
+
}
|
|
1448
|
+
await writeArtifactFile(root, diagram.file, applyEdits(source, edits));
|
|
1780
1449
|
}
|
|
1450
|
+
const mermaid = await fs.readFile(path.join(root, diagram.entry.path), "utf8");
|
|
1451
|
+
const context = contextPath === void 0 ? "" : await fs.readFile(path.join(root, contextPath), "utf8");
|
|
1781
1452
|
return {
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
serviceFiles,
|
|
1787
|
-
apiGroups,
|
|
1788
|
-
diagrams,
|
|
1789
|
-
serviceDiagrams,
|
|
1790
|
-
docs,
|
|
1791
|
-
standaloneDocs,
|
|
1792
|
-
databases,
|
|
1793
|
-
queries,
|
|
1794
|
-
maps,
|
|
1795
|
-
dependencies
|
|
1796
|
-
}
|
|
1453
|
+
id: diagramId,
|
|
1454
|
+
name,
|
|
1455
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1456
|
+
contentHash: contentHash(mermaid, context)
|
|
1797
1457
|
};
|
|
1798
1458
|
}
|
|
1799
|
-
function
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1459
|
+
async function createDiagram(root, configFile, file, name) {
|
|
1460
|
+
const { db, errors } = await loadArtifacts(root, configFile);
|
|
1461
|
+
if (errors.length > 0) throw new Error(`The .uigraph artifacts are invalid:\n${errors.join("\n")}`);
|
|
1462
|
+
if (db.diagrams.some((d) => d.file === file && d.entry.name === name)) throw new Error(`Another diagram in ${file} is named "${name}"`);
|
|
1463
|
+
const base = slugify(name);
|
|
1464
|
+
if (base === "") throw new Error("The diagram name must contain a letter or digit");
|
|
1465
|
+
let folder = base;
|
|
1466
|
+
for (let suffix = 2; db.diagrams.some((d) => d.entity.id === folder) || await fs.lstat(path.join(root, ".uigraph/diagrams", folder)).then(() => true, (error) => {
|
|
1467
|
+
if (error.code === "ENOENT") return false;
|
|
1468
|
+
throw error;
|
|
1469
|
+
}); suffix++) folder = `${base}-${suffix}`;
|
|
1470
|
+
const entry = {
|
|
1471
|
+
name,
|
|
1472
|
+
path: `.uigraph/diagrams/${folder}/${folder}.mmd`,
|
|
1473
|
+
contextPath: `.uigraph/diagrams/${folder}/context.json`
|
|
1811
1474
|
};
|
|
1475
|
+
const empty = convertUiGraphToMermaid({
|
|
1476
|
+
nodes: [],
|
|
1477
|
+
edges: []
|
|
1478
|
+
});
|
|
1479
|
+
await writeArtifactFile(root, entry.path, empty.mermaid);
|
|
1480
|
+
await writeArtifactFile(root, entry.contextPath, `${JSON.stringify(empty.context, null, 2)}\n`);
|
|
1481
|
+
const { source, document } = await readYaml(root, file);
|
|
1482
|
+
await writeArtifactFile(root, file, applyEdits(source, [appendItem(source, document, ["architectureDiagrams"], entry)]));
|
|
1483
|
+
return folder;
|
|
1812
1484
|
}
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
const
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
const
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
if (
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
}));
|
|
1837
|
-
const instructions = files.get("SKILL.md");
|
|
1838
|
-
if (instructions === void 0) throw new Error(`${SKILL_SOURCE} has no ${SKILL_DIRECTORY}SKILL.md`);
|
|
1839
|
-
return {
|
|
1840
|
-
instructions: instructions.replace(/^---\n[\s\S]*?\n---\n/, ""),
|
|
1841
|
-
files
|
|
1842
|
-
};
|
|
1485
|
+
async function deleteDiagram(root, configFile, diagramId) {
|
|
1486
|
+
const { db, errors } = await loadArtifacts(root, configFile);
|
|
1487
|
+
if (errors.length > 0) throw new Error(`The .uigraph artifacts are invalid:\n${errors.join("\n")}`);
|
|
1488
|
+
const diagram = db.diagrams.find((d) => d.entity.id === diagramId);
|
|
1489
|
+
if (diagram === void 0) throw new Error(`Diagram ${diagramId} not found`);
|
|
1490
|
+
const images = await listDiagramImages(root, diagram.entry.path);
|
|
1491
|
+
const { source, document } = await readYaml(root, diagram.file);
|
|
1492
|
+
await writeArtifactFile(root, diagram.file, applyEdits(source, [removeItem(source, document, ["architectureDiagrams"], diagram.index)]));
|
|
1493
|
+
for (const file of [
|
|
1494
|
+
diagram.entry.path,
|
|
1495
|
+
diagram.entry.contextPath,
|
|
1496
|
+
...images.map((image) => image.imagePath)
|
|
1497
|
+
]) if (file !== void 0 && isArtifactPath(file)) await deleteArtifactFile(root, file);
|
|
1498
|
+
}
|
|
1499
|
+
async function renameDiagramReferences(root, files, serviceName, oldName, newName) {
|
|
1500
|
+
for (const file of files) {
|
|
1501
|
+
const { source, document } = await readYaml(root, file);
|
|
1502
|
+
const edits = [];
|
|
1503
|
+
visit(document, { Map(_, map) {
|
|
1504
|
+
if (map.get("serviceName") === serviceName && map.get("architectureDiagramName") === oldName) edits.push(replaceScalar(map.get("architectureDiagramName", true), newName));
|
|
1505
|
+
} });
|
|
1506
|
+
if (edits.length > 0) await writeArtifactFile(root, file, applyEdits(source, edits));
|
|
1507
|
+
}
|
|
1843
1508
|
}
|
|
1844
1509
|
//#endregion
|
|
1845
|
-
//#region src/
|
|
1846
|
-
const
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
function
|
|
1853
|
-
|
|
1854
|
-
if (
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
throw new Error(`Unknown tool event: ${JSON.stringify(event)}`);
|
|
1861
|
-
}
|
|
1862
|
-
function describeUsage(outcome) {
|
|
1863
|
-
return `${outcome.tokens.toLocaleString()} tokens${outcome.costUsd === null ? "" : ` · ~$${outcome.costUsd.toFixed(2)} estimated`}`;
|
|
1864
|
-
}
|
|
1865
|
-
async function runSession(root, skill, prompt, abortController, settings, mode = "generate") {
|
|
1866
|
-
let toolCalls = 0;
|
|
1867
|
-
const tools = createRepositoryTools(root, skill.files, (event) => {
|
|
1868
|
-
toolCalls++;
|
|
1869
|
-
console.log(pc.dim(` ${String(toolCalls).padStart(3)} `) + describeToolEvent(event));
|
|
1870
|
-
}, mode === "generate" ? void 0 : { onSubmit: mode.onPlan });
|
|
1871
|
-
let outcome;
|
|
1872
|
-
try {
|
|
1873
|
-
outcome = await runAgent({
|
|
1874
|
-
agent: settings.agent,
|
|
1875
|
-
cwd: root,
|
|
1876
|
-
systemPrompt: mode === "generate" ? `${PROMPT}\n# UIGraph skill\n\nThe skill below was written for interactive use. The run instructions above override its approval workflow, and its file references resolve through \`read_skill_file\`.\n\n${skill.instructions}` : PLAN_PROMPT,
|
|
1877
|
-
prompt,
|
|
1878
|
-
tools,
|
|
1879
|
-
signal: abortController.signal,
|
|
1880
|
-
model: settings.model,
|
|
1881
|
-
maxBudgetUsd: settings.maxBudgetUsd,
|
|
1882
|
-
maxTurns: mode === "generate" ? void 0 : PLAN_MAX_TURNS,
|
|
1883
|
-
clientApp: `uigraph/${VERSION}`,
|
|
1884
|
-
log: consola.debug
|
|
1885
|
-
});
|
|
1886
|
-
} catch (error) {
|
|
1887
|
-
if (abortController.signal.aborted) throw new AnalysisCancelledError("Analysis cancelled");
|
|
1888
|
-
if (error instanceof AgentLimitError) throw new AnalysisLimitError(error.message, { cause: error });
|
|
1889
|
-
if (error instanceof AgentError) throw new AnalysisError(error.message, { cause: error });
|
|
1510
|
+
//#region src/artifacts/docs.ts
|
|
1511
|
+
const DOC_EXTENSIONS = {
|
|
1512
|
+
pdf: ".pdf",
|
|
1513
|
+
html: ".html",
|
|
1514
|
+
markdown: ".md",
|
|
1515
|
+
txt: ".txt"
|
|
1516
|
+
};
|
|
1517
|
+
async function loadValid(root, configFile) {
|
|
1518
|
+
const { db, errors } = await loadArtifacts(root, configFile);
|
|
1519
|
+
if (errors.length > 0) throw new Error(`The .uigraph artifacts are invalid:\n${errors.join("\n")}`);
|
|
1520
|
+
return db;
|
|
1521
|
+
}
|
|
1522
|
+
async function exists(file) {
|
|
1523
|
+
return fs.lstat(file).then(() => true, (error) => {
|
|
1524
|
+
if (error.code === "ENOENT") return false;
|
|
1890
1525
|
throw error;
|
|
1891
|
-
}
|
|
1892
|
-
if (abortController.signal.aborted) throw new AnalysisCancelledError("Analysis cancelled");
|
|
1893
|
-
return outcome;
|
|
1526
|
+
});
|
|
1894
1527
|
}
|
|
1895
|
-
function
|
|
1896
|
-
|
|
1897
|
-
if (
|
|
1528
|
+
async function createDoc(root, configFile, file, input, data) {
|
|
1529
|
+
const db = await loadValid(root, configFile);
|
|
1530
|
+
if ([...db.docs, ...db.standaloneDocs].some((doc) => doc.file === file && doc.entry.name === input.name)) throw new Error(`Another doc in ${file} is named "${input.name}"`);
|
|
1531
|
+
const extension = DOC_EXTENSIONS[input.fileType];
|
|
1532
|
+
if (extension === void 0) throw new Error(`Unsupported doc type "${input.fileType}"`);
|
|
1533
|
+
const base = slugify(input.name);
|
|
1534
|
+
if (base === "") throw new Error("The doc name must contain a letter or digit");
|
|
1535
|
+
let docPath = `.uigraph/docs/${base}${extension}`;
|
|
1536
|
+
for (let suffix = 2; await exists(path.join(root, docPath)); suffix++) docPath = `.uigraph/docs/${base}-${suffix}${extension}`;
|
|
1537
|
+
await writeArtifactFile(root, docPath, data);
|
|
1538
|
+
const { source, document } = await readYaml(root, file);
|
|
1539
|
+
await writeArtifactFile(root, file, applyEdits(source, [appendItem(source, document, ["docs"], {
|
|
1540
|
+
name: input.name,
|
|
1541
|
+
path: docPath,
|
|
1542
|
+
fileType: input.fileType,
|
|
1543
|
+
...input.description === "" ? {} : { description: input.description }
|
|
1544
|
+
})]));
|
|
1545
|
+
return docPath;
|
|
1898
1546
|
}
|
|
1899
|
-
async function
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1547
|
+
async function deleteDoc(root, configFile, file, name) {
|
|
1548
|
+
const db = await loadValid(root, configFile);
|
|
1549
|
+
const allDocs = [...db.docs, ...db.standaloneDocs];
|
|
1550
|
+
const doc = allDocs.find((d) => d.file === file && d.entry.name === name);
|
|
1551
|
+
if (doc === void 0) throw new Error(`No doc named "${name}" in ${file}`);
|
|
1552
|
+
const { source, document } = await readYaml(root, file);
|
|
1553
|
+
await writeArtifactFile(root, file, applyEdits(source, [removeItem(source, document, ["docs"], findItemIndex(document, ["docs"], name))]));
|
|
1554
|
+
const stillUsed = allDocs.some((d) => d !== doc && d.entry.path === doc.entry.path);
|
|
1555
|
+
if (isArtifactPath(doc.entry.path) && !stillUsed) await deleteArtifactFile(root, doc.entry.path);
|
|
1906
1556
|
}
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1557
|
+
//#endregion
|
|
1558
|
+
//#region src/artifacts/maps.ts
|
|
1559
|
+
const IMAGE_EXTENSIONS = {
|
|
1560
|
+
"image/png": ".png",
|
|
1561
|
+
"image/jpeg": ".jpg",
|
|
1562
|
+
"image/webp": ".webp",
|
|
1563
|
+
"image/gif": ".gif"
|
|
1564
|
+
};
|
|
1565
|
+
async function createMap(root, configFile, file, input) {
|
|
1566
|
+
await loadValid(root, configFile);
|
|
1567
|
+
const { source, document } = await readYaml(root, file);
|
|
1568
|
+
await writeArtifactFile(root, file, applyEdits(source, [appendItem(source, document, ["maps"], {
|
|
1569
|
+
name: input.name,
|
|
1570
|
+
...input.description === "" ? {} : { description: input.description }
|
|
1571
|
+
})]));
|
|
1912
1572
|
}
|
|
1913
|
-
async function
|
|
1914
|
-
|
|
1915
|
-
const
|
|
1916
|
-
const
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
} });
|
|
1922
|
-
} catch (error) {
|
|
1923
|
-
if (error instanceof AnalysisLimitError) {
|
|
1924
|
-
consola.debug(`Planning stopped: ${inspect(error)}`);
|
|
1925
|
-
return null;
|
|
1926
|
-
}
|
|
1927
|
-
throw error;
|
|
1928
|
-
}
|
|
1929
|
-
if (captured.submission === null) return null;
|
|
1930
|
-
return {
|
|
1931
|
-
...captured.submission,
|
|
1932
|
-
seconds: Math.round((Date.now() - startedAt) / 1e3),
|
|
1933
|
-
usage: outcome
|
|
1934
|
-
};
|
|
1573
|
+
async function editMap(root, configFile, file, name, edit) {
|
|
1574
|
+
await loadValid(root, configFile);
|
|
1575
|
+
const { source, document } = await readYaml(root, file);
|
|
1576
|
+
const index = findItemIndex(document, ["maps"], name);
|
|
1577
|
+
const node = document.getIn(["maps", index], true);
|
|
1578
|
+
if (!isMap(node)) throw new Error(`maps[${name}] must be a mapping`);
|
|
1579
|
+
const next = edit(node.toJSON());
|
|
1580
|
+
await writeArtifactFile(root, file, applyEdits(source, [next === null ? removeItem(source, document, ["maps"], index) : replaceItem(source, node, next)]));
|
|
1935
1581
|
}
|
|
1936
|
-
async function
|
|
1937
|
-
const
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
...selection === null ? [] : [formatApprovedDiagrams(selection, existing)]
|
|
1943
|
-
].join("\n"), abortController, settings);
|
|
1944
|
-
let { errors } = await loadArtifacts(root);
|
|
1945
|
-
if (errors.length > 0) {
|
|
1946
|
-
console.log(pc.yellow(`\nThe artifacts have ${errors.length} problem(s). Asking ${AGENT_LABELS[settings.agent]} to repair them once:`));
|
|
1947
|
-
printErrors(errors);
|
|
1948
|
-
console.log();
|
|
1949
|
-
outcome = await runSession(root, skill, [
|
|
1950
|
-
"The UIGraph artifacts failed validation. Fix every problem below, re-reading files where needed, and run validate_artifacts until it passes. Remove findings you cannot support with evidence rather than guessing.",
|
|
1951
|
-
"",
|
|
1952
|
-
...errors.map((error) => `- ${error}`)
|
|
1953
|
-
].join("\n"), abortController, settings);
|
|
1954
|
-
errors = (await loadArtifacts(root)).errors;
|
|
1582
|
+
async function editCanvas(root, edit) {
|
|
1583
|
+
const present = await exists(path.join(root, CANVAS_FILE));
|
|
1584
|
+
let canvas = {};
|
|
1585
|
+
if (present) {
|
|
1586
|
+
const { realPath } = await resolveRepositoryFile(root, CANVAS_FILE);
|
|
1587
|
+
canvas = canvasFileSchema.parse(JSON.parse(await fs.readFile(realPath, "utf8")));
|
|
1955
1588
|
}
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1589
|
+
const before = JSON.stringify(canvas);
|
|
1590
|
+
edit(canvas);
|
|
1591
|
+
if (JSON.stringify(canvas) === before) return;
|
|
1592
|
+
if (Object.keys(canvas).length === 0) {
|
|
1593
|
+
if (present) await deleteArtifactFile(root, CANVAS_FILE);
|
|
1594
|
+
return;
|
|
1959
1595
|
}
|
|
1960
|
-
|
|
1961
|
-
console.log(pc.dim(`\n ${seconds}s · ${describeUsage(outcome)}`));
|
|
1962
|
-
}
|
|
1963
|
-
//#endregion
|
|
1964
|
-
//#region src/banner.ts
|
|
1965
|
-
const NAME = "UIGraph";
|
|
1966
|
-
const WEBSITE = "https://uigraph.app";
|
|
1967
|
-
const TAGLINE = "Map your project into services, APIs and diagrams";
|
|
1968
|
-
function blue(text) {
|
|
1969
|
-
return pc.isColorSupported ? `\x1b[38;2;59;107;255m${text}\x1b[39m` : text;
|
|
1970
|
-
}
|
|
1971
|
-
const MARK = [[
|
|
1972
|
-
0,
|
|
1973
|
-
0,
|
|
1974
|
-
pc.white("⏺")
|
|
1975
|
-
]];
|
|
1976
|
-
for (let col = 1; col < 5; col++) MARK.push([
|
|
1977
|
-
0,
|
|
1978
|
-
col,
|
|
1979
|
-
blue("━")
|
|
1980
|
-
]);
|
|
1981
|
-
MARK.push([
|
|
1982
|
-
0,
|
|
1983
|
-
5,
|
|
1984
|
-
blue("⏺")
|
|
1985
|
-
]);
|
|
1986
|
-
MARK.push([
|
|
1987
|
-
1,
|
|
1988
|
-
5,
|
|
1989
|
-
blue("┃")
|
|
1990
|
-
]);
|
|
1991
|
-
MARK.push([
|
|
1992
|
-
2,
|
|
1993
|
-
5,
|
|
1994
|
-
blue("⏺")
|
|
1995
|
-
]);
|
|
1996
|
-
for (let col = 4; col > 0; col--) MARK.push([
|
|
1997
|
-
2,
|
|
1998
|
-
col,
|
|
1999
|
-
blue("━")
|
|
2000
|
-
]);
|
|
2001
|
-
MARK.push([
|
|
2002
|
-
2,
|
|
2003
|
-
0,
|
|
2004
|
-
blue("⏺")
|
|
2005
|
-
]);
|
|
2006
|
-
MARK.push([
|
|
2007
|
-
1,
|
|
2008
|
-
0,
|
|
2009
|
-
blue("┃")
|
|
2010
|
-
]);
|
|
2011
|
-
function imageProtocol() {
|
|
2012
|
-
const program = process.env.TERM_PROGRAM;
|
|
2013
|
-
if (program === "iTerm.app" || program === "WezTerm") return "iterm";
|
|
2014
|
-
if (program === "ghostty" || process.env.TERM === "xterm-kitty" || process.env.KITTY_WINDOW_ID) return "kitty";
|
|
2015
|
-
return null;
|
|
1596
|
+
await writeArtifactFile(root, CANVAS_FILE, `${JSON.stringify(canvas, null, 2)}\n`);
|
|
2016
1597
|
}
|
|
2017
|
-
function
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
1598
|
+
async function retargetLinks(root, from, to) {
|
|
1599
|
+
await editCanvas(root, (canvas) => {
|
|
1600
|
+
for (const mapCanvas of Object.values(canvas)) for (const frameCanvas of Object.values(mapCanvas.frames ?? {})) {
|
|
1601
|
+
if (frameCanvas.links === void 0) continue;
|
|
1602
|
+
frameCanvas.links = frameCanvas.links.flatMap((link) => {
|
|
1603
|
+
if (!(link.map === from.map && (from.frame === void 0 || link.frame === from.frame))) return [link];
|
|
1604
|
+
if (to === null) return [];
|
|
1605
|
+
return [{
|
|
1606
|
+
...link,
|
|
1607
|
+
map: to.map,
|
|
1608
|
+
...from.frame === void 0 ? {} : { frame: to.frame }
|
|
1609
|
+
}];
|
|
1610
|
+
});
|
|
1611
|
+
}
|
|
1612
|
+
});
|
|
2026
1613
|
}
|
|
2027
|
-
function
|
|
2028
|
-
const
|
|
2029
|
-
|
|
2030
|
-
const
|
|
2031
|
-
const
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
];
|
|
2037
|
-
return grid.map((cells, row) => ` ${cells.join("")} ${text[row]}\x1b[K`).join("\n");
|
|
1614
|
+
async function writeFrameImage(root, mapName, frameName, upload) {
|
|
1615
|
+
const extension = IMAGE_EXTENSIONS[upload.contentType];
|
|
1616
|
+
if (extension === void 0) throw new Error("Frame images must be PNG, JPEG, WebP or GIF files");
|
|
1617
|
+
const directory = `.uigraph/maps/${slugify(mapName)}`;
|
|
1618
|
+
const base = slugify(frameName);
|
|
1619
|
+
let imagePath = `${directory}/${base}${extension}`;
|
|
1620
|
+
for (let suffix = 2; await exists(path.join(root, imagePath)); suffix++) imagePath = `${directory}/${base}-${suffix}${extension}`;
|
|
1621
|
+
await writeArtifactFile(root, imagePath, upload.data);
|
|
1622
|
+
return imagePath;
|
|
2038
1623
|
}
|
|
2039
|
-
async function
|
|
2040
|
-
|
|
2041
|
-
const
|
|
2042
|
-
const
|
|
2043
|
-
const frames = markFrames + 7 + 1;
|
|
2044
|
-
process.stdout.write("\n");
|
|
2045
|
-
for (let frame = 1; frame <= frames; frame++) {
|
|
2046
|
-
process.stdout.write(`${frame === 1 ? "" : "\x1B[2A\r"}${render(frame, markFrames)}`);
|
|
2047
|
-
if (frame === frames && protocol) process.stdout.write(`\x1b7\x1b[2A\r\x1b[2C${logoImage(protocol)}\x1b8`);
|
|
2048
|
-
await setTimeout$1(20);
|
|
2049
|
-
}
|
|
2050
|
-
process.stdout.write("\n");
|
|
1624
|
+
async function deleteUnusedImages(root, configFile, imagePaths) {
|
|
1625
|
+
const db = await loadValid(root, configFile);
|
|
1626
|
+
const used = new Set(db.maps.flatMap((map) => map.frames.flatMap(({ frame }) => frame.screenshotAssetId ?? [])));
|
|
1627
|
+
for (const imagePath of imagePaths) if (isArtifactPath(imagePath) && !used.has(imagePath)) await deleteArtifactFile(root, imagePath);
|
|
2051
1628
|
}
|
|
2052
1629
|
//#endregion
|
|
2053
|
-
//#region src/
|
|
2054
|
-
const
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
async function
|
|
2062
|
-
|
|
2063
|
-
const
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
1630
|
+
//#region src/artifacts/diagram-images.ts
|
|
1631
|
+
const DIAGRAM_IMAGE_EXTENSIONS = {
|
|
1632
|
+
...IMAGE_EXTENSIONS,
|
|
1633
|
+
"image/svg+xml": ".svg"
|
|
1634
|
+
};
|
|
1635
|
+
function imageDirectory(diagramPath) {
|
|
1636
|
+
return `${path.posix.dirname(diagramPath)}/images`;
|
|
1637
|
+
}
|
|
1638
|
+
async function listDiagramImages(root, diagramPath) {
|
|
1639
|
+
const directory = imageDirectory(diagramPath);
|
|
1640
|
+
const entries = await fs.readdir(path.join(root, directory), { withFileTypes: true }).catch((error) => {
|
|
1641
|
+
if (error.code === "ENOENT") return [];
|
|
1642
|
+
throw error;
|
|
1643
|
+
});
|
|
1644
|
+
const extensions = new Set(Object.values(DIAGRAM_IMAGE_EXTENSIONS));
|
|
1645
|
+
const images = [];
|
|
1646
|
+
for (const entry of entries) if (entry.isFile() && extensions.has(path.extname(entry.name).toLowerCase())) {
|
|
1647
|
+
const imagePath = `${directory}/${entry.name}`;
|
|
1648
|
+
const stat = await fs.stat(path.join(root, imagePath));
|
|
1649
|
+
images.push({
|
|
1650
|
+
imagePath,
|
|
1651
|
+
createdAt: stat.birthtime.toISOString()
|
|
2069
1652
|
});
|
|
2070
|
-
if (text === null) continue;
|
|
2071
|
-
files.push(file);
|
|
2072
|
-
if (text.includes(".uigraph/AGENTS.md")) continue;
|
|
2073
|
-
if (text === "" || text.endsWith("\n\n")) await fs.appendFile(target, REFERENCE);
|
|
2074
|
-
else if (text.endsWith("\n")) await fs.appendFile(target, `\n${REFERENCE}`);
|
|
2075
|
-
else await fs.appendFile(target, `\n\n${REFERENCE}`);
|
|
2076
|
-
}
|
|
2077
|
-
if (files.length === 0) {
|
|
2078
|
-
await fs.writeFile(path.join(root, "AGENTS.md"), REFERENCE, { flag: "wx" });
|
|
2079
|
-
files.push("AGENTS.md");
|
|
2080
1653
|
}
|
|
2081
|
-
return
|
|
1654
|
+
return images.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
1655
|
+
}
|
|
1656
|
+
async function writeDiagramImage(root, diagramPath, fileName, upload) {
|
|
1657
|
+
const extension = DIAGRAM_IMAGE_EXTENSIONS[upload.contentType];
|
|
1658
|
+
if (extension === void 0) throw new Error("Diagram images must be PNG, JPEG, WebP, GIF or SVG files");
|
|
1659
|
+
const directory = imageDirectory(diagramPath);
|
|
1660
|
+
const base = slugify(path.parse(fileName).name);
|
|
1661
|
+
if (base === "") throw new Error("The image file name must contain a letter or digit");
|
|
1662
|
+
let imagePath = `${directory}/${base}${extension}`;
|
|
1663
|
+
for (let suffix = 2; await exists(path.join(root, imagePath)); suffix++) imagePath = `${directory}/${base}-${suffix}${extension}`;
|
|
1664
|
+
await writeArtifactFile(root, imagePath, upload.data);
|
|
1665
|
+
return imagePath;
|
|
2082
1666
|
}
|
|
2083
1667
|
//#endregion
|
|
2084
1668
|
//#region src/workspace.ts
|
|
@@ -2087,10 +1671,8 @@ const COMPONENT_API_CONTRACT_ID = "component_api-contract";
|
|
|
2087
1671
|
const COMPONENT_FLOW_DIAGRAM_ID = "component_backend-flow-diagram";
|
|
2088
1672
|
const COMPONENT_SUPPORT_KB_ID = "component_support-kb-troubleshooting";
|
|
2089
1673
|
const REGISTRY = ".uigraph/registry.yaml";
|
|
2090
|
-
const PREFERENCES = ".uigraph/preferences.yaml";
|
|
2091
1674
|
const WORKSPACE_ID = "workspace";
|
|
2092
1675
|
const registrySchema = z.object({ projects: z.array(z.string().min(1)) });
|
|
2093
|
-
const preferencesSchema = z.object({ agentGuidance: z.object({ declined: z.array(z.string().min(1)) }) });
|
|
2094
1676
|
function homeRoot() {
|
|
2095
1677
|
return fs.realpath(os.homedir());
|
|
2096
1678
|
}
|
|
@@ -2117,19 +1699,6 @@ async function unregisterProject(root) {
|
|
|
2117
1699
|
if (!projects.includes(root)) throw new Error(`${root} is not registered`);
|
|
2118
1700
|
await writeArtifactFile(home, REGISTRY, stringify({ projects: projects.filter((project) => project !== root) }));
|
|
2119
1701
|
}
|
|
2120
|
-
async function readDeclinedGuidance(home) {
|
|
2121
|
-
const text = await readOptional(path.join(home, PREFERENCES));
|
|
2122
|
-
if (text === null) return [];
|
|
2123
|
-
return preferencesSchema.parse(parse$1(text)).agentGuidance.declined;
|
|
2124
|
-
}
|
|
2125
|
-
async function isGuidanceDeclined(root) {
|
|
2126
|
-
return (await readDeclinedGuidance(await homeRoot())).includes(root);
|
|
2127
|
-
}
|
|
2128
|
-
async function setGuidanceDeclined(root, declined) {
|
|
2129
|
-
const home = await homeRoot();
|
|
2130
|
-
const others = (await readDeclinedGuidance(home)).filter((project) => project !== root);
|
|
2131
|
-
await writeArtifactFile(home, PREFERENCES, stringify({ agentGuidance: { declined: declined ? [...others, root] : others } }));
|
|
2132
|
-
}
|
|
2133
1702
|
async function syncProject(root) {
|
|
2134
1703
|
await registerProject$1(root);
|
|
2135
1704
|
const { skipped, workspace } = await loadWorkspace();
|
|
@@ -4041,34 +3610,13 @@ async function handleLensRequest(request, response, pathname, securityHeaders) {
|
|
|
4041
3610
|
}
|
|
4042
3611
|
//#endregion
|
|
4043
3612
|
//#region src/bin.ts
|
|
4044
|
-
const
|
|
3613
|
+
const GENERATE_HINT = "Ask your coding agent to generate them with the UIGraph skill (npx skills add uigraph-oss/skills).";
|
|
4045
3614
|
if (process.env.UIGRAPH_DEBUG === "1") consola.level = LogLevels.debug;
|
|
4046
3615
|
var UsageError = class extends Error {};
|
|
4047
3616
|
function fail(message, exitCode = 1) {
|
|
4048
3617
|
console.error(pc.red(`✖ ${message}`));
|
|
4049
3618
|
process.exit(exitCode);
|
|
4050
3619
|
}
|
|
4051
|
-
async function withCancellation(message, work) {
|
|
4052
|
-
const abortController = new AbortController();
|
|
4053
|
-
let signals = 0;
|
|
4054
|
-
function onSignal() {
|
|
4055
|
-
signals++;
|
|
4056
|
-
if (signals > 1) {
|
|
4057
|
-
console.error(pc.red("\nForced exit."));
|
|
4058
|
-
process.exit(130);
|
|
4059
|
-
}
|
|
4060
|
-
console.log(pc.yellow(`\n${message} (press Ctrl+C again to force quit)`));
|
|
4061
|
-
abortController.abort();
|
|
4062
|
-
}
|
|
4063
|
-
process.on("SIGINT", onSignal);
|
|
4064
|
-
process.on("SIGTERM", onSignal);
|
|
4065
|
-
try {
|
|
4066
|
-
return await work(abortController);
|
|
4067
|
-
} finally {
|
|
4068
|
-
process.off("SIGINT", onSignal);
|
|
4069
|
-
process.off("SIGTERM", onSignal);
|
|
4070
|
-
}
|
|
4071
|
-
}
|
|
4072
3620
|
async function resolveRepository(input) {
|
|
4073
3621
|
const requested = path.resolve(input ?? process.cwd());
|
|
4074
3622
|
let realPath;
|
|
@@ -4080,11 +3628,6 @@ async function resolveRepository(input) {
|
|
|
4080
3628
|
if (!(await fs.stat(realPath)).isDirectory()) throw new UsageError(`Not a directory: ${realPath}`);
|
|
4081
3629
|
return realPath;
|
|
4082
3630
|
}
|
|
4083
|
-
function describeLimits(settings) {
|
|
4084
|
-
if (settings.maxBudgetUsd !== void 0) return ` • Each Claude session is capped at $${settings.maxBudgetUsd.toFixed(2)}.`;
|
|
4085
|
-
if (settings.agent === "claude") return " • No spending cap is set (see --max-budget); usage counts against your Claude plan or API account.";
|
|
4086
|
-
return ` • Usage counts against your ${AGENT_LABELS[settings.agent]} plan or API account.`;
|
|
4087
|
-
}
|
|
4088
3631
|
async function chooseAgent(agent, question) {
|
|
4089
3632
|
if (agent !== void 0) return agent;
|
|
4090
3633
|
if (!process.stdin.isTTY) throw new UsageError(`Choose an agent with --agent (${AGENT_NAMES.join(", ")}).`);
|
|
@@ -4096,87 +3639,6 @@ async function chooseAgent(agent, question) {
|
|
|
4096
3639
|
}))
|
|
4097
3640
|
});
|
|
4098
3641
|
}
|
|
4099
|
-
async function confirmAnalysis(root, settings) {
|
|
4100
|
-
const agent = AGENT_LABELS[settings.agent];
|
|
4101
|
-
console.log([
|
|
4102
|
-
"",
|
|
4103
|
-
`${pc.bold("uigraph")} will generate UIGraph artifacts for ${pc.cyan(root)} with ${agent}.`,
|
|
4104
|
-
"",
|
|
4105
|
-
` • ${agent} reads selected repository files (manifests, source, configs) and sends`,
|
|
4106
|
-
settings.agent === "opencode" ? " their contents to its model provider, using provider API keys from your environment." : ` their contents to its model provider, using your ${agent} login or API key.`,
|
|
4107
|
-
` • ${agent} first proposes which diagrams to generate and which existing docs to add,`,
|
|
4108
|
-
" and you choose. Nothing is written until you do.",
|
|
4109
|
-
` • ${agent} writes ${ROOT_CONFIG} and files under .uigraph/ in the repository. Nothing else`,
|
|
4110
|
-
" is changed. If generation fails, the previous artifacts are restored.",
|
|
4111
|
-
describeLimits(settings),
|
|
4112
|
-
""
|
|
4113
|
-
].join("\n"));
|
|
4114
|
-
if (await confirm({
|
|
4115
|
-
message: "Continue?",
|
|
4116
|
-
default: false
|
|
4117
|
-
})) return true;
|
|
4118
|
-
console.log(pc.yellow(`Cancelled. Nothing was sent to ${agent}.`));
|
|
4119
|
-
return false;
|
|
4120
|
-
}
|
|
4121
|
-
async function promptForDiagrams(candidates, preselected, docCandidates, agent) {
|
|
4122
|
-
const diagrams = await checkbox({
|
|
4123
|
-
message: `${AGENT_LABELS[agent]} found ${candidates.length} ${candidates.length === 1 ? "diagram" : "diagrams"} worth generating. Pick the ones to generate.`,
|
|
4124
|
-
choices: candidates.map((diagram) => ({
|
|
4125
|
-
name: `${diagram.name} ${pc.cyan(diagram.type)}`,
|
|
4126
|
-
value: diagram,
|
|
4127
|
-
checked: preselected.has(diagram.id),
|
|
4128
|
-
description: `${diagram.summary}\nevidence: ${diagram.evidence.join(", ")}`
|
|
4129
|
-
})),
|
|
4130
|
-
pageSize: candidates.length
|
|
4131
|
-
});
|
|
4132
|
-
const docs = docCandidates.length === 0 ? [] : await checkbox({
|
|
4133
|
-
message: `${AGENT_LABELS[agent]} found ${docCandidates.length} existing ${docCandidates.length === 1 ? "document" : "documents"} in the repository. Pick the ones to add to the viewer.`,
|
|
4134
|
-
choices: docCandidates.map((doc) => ({
|
|
4135
|
-
name: `${doc.name} ${pc.cyan(doc.path)}`,
|
|
4136
|
-
value: doc,
|
|
4137
|
-
checked: true,
|
|
4138
|
-
description: doc.summary
|
|
4139
|
-
})),
|
|
4140
|
-
pageSize: docCandidates.length
|
|
4141
|
-
});
|
|
4142
|
-
const focus = (await input({ message: "Anything in particular to focus on? (optional, press Enter to skip)" })).trim();
|
|
4143
|
-
return {
|
|
4144
|
-
diagrams,
|
|
4145
|
-
docs,
|
|
4146
|
-
focus: focus === "" ? null : focus
|
|
4147
|
-
};
|
|
4148
|
-
}
|
|
4149
|
-
async function chooseDiagrams(root, skill, settings) {
|
|
4150
|
-
console.log(pc.bold("\nLooking for diagrams worth generating…\n"));
|
|
4151
|
-
let plan;
|
|
4152
|
-
try {
|
|
4153
|
-
plan = await withCancellation("Cancelling…", (abortController) => planDiagrams(root, path.basename(root), skill, abortController, settings));
|
|
4154
|
-
} catch (error) {
|
|
4155
|
-
if (error instanceof AnalysisCancelledError) fail("Cancelled. Nothing was written.", 130);
|
|
4156
|
-
if (error instanceof AnalysisError) fail(`${error.message}\n Nothing was written.`);
|
|
4157
|
-
throw error;
|
|
4158
|
-
}
|
|
4159
|
-
if (plan === null) {
|
|
4160
|
-
console.log(pc.yellow("\nCould not build a diagram plan. Generating the default set instead."));
|
|
4161
|
-
return null;
|
|
4162
|
-
}
|
|
4163
|
-
console.log(pc.dim(`\n ${plan.diagrams.length} diagram and ${plan.docs.length} document candidates · ${plan.seconds}s · ${describeUsage(plan.usage)}`));
|
|
4164
|
-
const recommended = new Set(plan.diagrams.filter((diagram) => diagram.recommended).map(({ id }) => id));
|
|
4165
|
-
return promptForDiagrams(plan.diagrams, recommended, plan.docs, settings.agent);
|
|
4166
|
-
}
|
|
4167
|
-
async function downloadSkillOrFail() {
|
|
4168
|
-
try {
|
|
4169
|
-
return await loadSkill();
|
|
4170
|
-
} catch (error) {
|
|
4171
|
-
if (error instanceof AnalysisError) fail(error.message);
|
|
4172
|
-
throw error;
|
|
4173
|
-
}
|
|
4174
|
-
}
|
|
4175
|
-
async function countAnalyzableFiles(root) {
|
|
4176
|
-
const files = await listRepositoryFiles(root, "**/*");
|
|
4177
|
-
if (files.length === 0) fail("The repository has no analyzable files (generated directories and secret files are skipped). Nothing was sent to an agent.");
|
|
4178
|
-
return files.length;
|
|
4179
|
-
}
|
|
4180
3642
|
async function findGitRemote(root) {
|
|
4181
3643
|
const remote = await promisify(execFile)("git", [
|
|
4182
3644
|
"-C",
|
|
@@ -4196,84 +3658,19 @@ async function pathExists(file) {
|
|
|
4196
3658
|
throw error;
|
|
4197
3659
|
});
|
|
4198
3660
|
}
|
|
4199
|
-
async function backupArtifacts(root) {
|
|
4200
|
-
const backup = await fs.mkdtemp(path.join(os.tmpdir(), "uigraph-"));
|
|
4201
|
-
for (const entry of ARTIFACT_ENTRIES) if (await pathExists(path.join(root, entry))) await fs.cp(path.join(root, entry), path.join(backup, entry), {
|
|
4202
|
-
recursive: true,
|
|
4203
|
-
verbatimSymlinks: true
|
|
4204
|
-
});
|
|
4205
|
-
return backup;
|
|
4206
|
-
}
|
|
4207
|
-
async function restoreArtifacts(root, backup) {
|
|
4208
|
-
for (const entry of ARTIFACT_ENTRIES) {
|
|
4209
|
-
await fs.rm(path.join(root, entry), {
|
|
4210
|
-
recursive: true,
|
|
4211
|
-
force: true
|
|
4212
|
-
});
|
|
4213
|
-
if (await pathExists(path.join(backup, entry))) await fs.cp(path.join(backup, entry), path.join(root, entry), {
|
|
4214
|
-
recursive: true,
|
|
4215
|
-
verbatimSymlinks: true
|
|
4216
|
-
});
|
|
4217
|
-
}
|
|
4218
|
-
}
|
|
4219
|
-
async function runGeneration(root, fileCount, update, skill, selection, settings) {
|
|
4220
|
-
const gitRemote = await findGitRemote(root);
|
|
4221
|
-
const backup = await backupArtifacts(root);
|
|
4222
|
-
console.log(pc.bold(`\nGenerating artifacts from ${fileCount} ${fileCount === 1 ? "file" : "files"} with ${AGENT_LABELS[settings.agent]}…\n`));
|
|
4223
|
-
try {
|
|
4224
|
-
await withCancellation("Cancelling generation…", (abortController) => generateArtifacts(root, path.basename(root), gitRemote, update, abortController, skill, selection, settings));
|
|
4225
|
-
console.log(pc.green(`\n✔ Artifacts written to ${ROOT_CONFIG} and .uigraph/`));
|
|
4226
|
-
} catch (error) {
|
|
4227
|
-
await restoreArtifacts(root, backup);
|
|
4228
|
-
if (error instanceof AnalysisCancelledError) fail("Generation cancelled. The previous artifacts were restored.", 130);
|
|
4229
|
-
if (error instanceof AnalysisError) fail(`${error.message}\n The previous artifacts were restored.`);
|
|
4230
|
-
throw error;
|
|
4231
|
-
} finally {
|
|
4232
|
-
await fs.rm(backup, {
|
|
4233
|
-
recursive: true,
|
|
4234
|
-
force: true
|
|
4235
|
-
});
|
|
4236
|
-
}
|
|
4237
|
-
}
|
|
4238
3661
|
function printSummary(db) {
|
|
4239
3662
|
const endpoints = db.apiGroups.reduce((count, { endpoints }) => count + endpoints.length, 0);
|
|
4240
3663
|
console.log(pc.dim(` ${db.services.length} services · ${endpoints} endpoints · ${db.dependencies.length} dependencies · ${db.diagrams.length} diagrams`));
|
|
4241
3664
|
}
|
|
4242
|
-
async function generate(root, hasArtifacts, options) {
|
|
4243
|
-
const fileCount = await countAnalyzableFiles(root);
|
|
4244
|
-
const settings = {
|
|
4245
|
-
agent: await chooseAgent(options.agent, "Which agent should generate the artifacts?"),
|
|
4246
|
-
model: options.model,
|
|
4247
|
-
maxBudgetUsd: options.maxBudgetUsd
|
|
4248
|
-
};
|
|
4249
|
-
if (settings.agent !== "claude" && settings.maxBudgetUsd !== void 0) throw new UsageError(`--max-budget only works with Claude. ${AGENT_LABELS[settings.agent]} does not support a spending cap.`);
|
|
4250
|
-
if (!await confirmAnalysis(root, settings)) return false;
|
|
4251
|
-
const skill = await downloadSkillOrFail();
|
|
4252
|
-
await runGeneration(root, fileCount, hasArtifacts, skill, await chooseDiagrams(root, skill, settings), settings);
|
|
4253
|
-
return true;
|
|
4254
|
-
}
|
|
4255
3665
|
async function registerProject(root, port) {
|
|
4256
3666
|
const { db, errors } = await loadArtifacts(root);
|
|
4257
3667
|
if (errors.length > 0) {
|
|
4258
|
-
console.error(pc.red(`✖ The UIGraph artifacts are invalid:\n${errors.map((error) => ` • ${error}`).join("\n")}\n Fix them or
|
|
3668
|
+
console.error(pc.red(`✖ The UIGraph artifacts are invalid:\n${errors.map((error) => ` • ${error}`).join("\n")}\n Fix them, or ask your coding agent to fix them with the UIGraph skill.`));
|
|
4259
3669
|
return false;
|
|
4260
3670
|
}
|
|
4261
3671
|
printSummary(db);
|
|
4262
3672
|
return reportSync(root, await syncProject(root), port);
|
|
4263
3673
|
}
|
|
4264
|
-
async function offerGuidance(root, hasGuidance) {
|
|
4265
|
-
const accepted = await confirm({
|
|
4266
|
-
message: hasGuidance ? "Update the UIGraph instructions for coding agents?" : "Add UIGraph instructions for coding agents?",
|
|
4267
|
-
default: true
|
|
4268
|
-
});
|
|
4269
|
-
await setGuidanceDeclined(root, !accepted);
|
|
4270
|
-
if (!accepted) {
|
|
4271
|
-
console.log(pc.dim(" Run \"uigraph init\" to add them later."));
|
|
4272
|
-
return;
|
|
4273
|
-
}
|
|
4274
|
-
const files = await writeGuidance(root);
|
|
4275
|
-
console.log(pc.green(`✔ Wrote ${GUIDANCE_FILE} and linked it from ${files.join(" and ")}`));
|
|
4276
|
-
}
|
|
4277
3674
|
function reportSync(root, result, port) {
|
|
4278
3675
|
if (result.synced) {
|
|
4279
3676
|
console.log(pc.green(`✔ Registered with the viewer (${serverUrl(port)})`));
|
|
@@ -4466,82 +3863,33 @@ function parseNumber(min, max, integer) {
|
|
|
4466
3863
|
};
|
|
4467
3864
|
}
|
|
4468
3865
|
const parsePort = parseNumber(1, 65535, true);
|
|
4469
|
-
const parseBudget = parseNumber(.01, 1e4, false);
|
|
4470
3866
|
function agentOption() {
|
|
4471
3867
|
return new Option("--agent <name>", "Coding agent to use").choices(AGENT_NAMES);
|
|
4472
3868
|
}
|
|
4473
3869
|
function portOption() {
|
|
4474
3870
|
return new Option("--port <number>", "Viewer port").env("UIGRAPH_PORT").default(DEFAULT_PORT).argParser(parsePort);
|
|
4475
3871
|
}
|
|
4476
|
-
const program = new Command().name("uigraph").description("
|
|
3872
|
+
const program = new Command().name("uigraph").description("Browse the UIGraph artifacts of every registered repository in one local\nviewer, and review pull requests with Lens.").version(VERSION, "-v, --version").option("--debug", "Print stack traces, agent output and Lens progress (also UIGRAPH_DEBUG=1)").showHelpAfterError("(run uigraph --help for usage)").addHelpText("after", "\nA repository literally named \"open\", \"sync\", \"start\", \"status\", \"stop\",\n\"restart\" or \"lens\" can be passed as ./start.").hook("preAction", () => {
|
|
4477
3873
|
if (program.opts().debug === true) {
|
|
4478
3874
|
process.env.UIGRAPH_DEBUG = "1";
|
|
4479
3875
|
consola.level = LogLevels.debug;
|
|
4480
3876
|
}
|
|
4481
3877
|
});
|
|
4482
|
-
program.command("open", { isDefault: true }).description("Start the viewer,
|
|
3878
|
+
program.command("open", { isDefault: true }).description("Start the viewer, show the artifacts of a repository in it, and open it (default)").argument("[repository]", "Repository path (default: current directory)").addOption(portOption()).option("--no-open", "Do not open a browser").action(async (repository, options) => {
|
|
4483
3879
|
const root = await resolveRepository(repository);
|
|
4484
3880
|
await startViewer(options.port, "background");
|
|
4485
3881
|
const hasArtifacts = await pathExists(path.join(root, ROOT_CONFIG));
|
|
4486
|
-
let
|
|
3882
|
+
let synced = true;
|
|
4487
3883
|
if (hasArtifacts) {
|
|
4488
3884
|
console.log(`Opening the UIGraph artifacts in ${pc.cyan(root)}`);
|
|
4489
|
-
|
|
4490
|
-
} else
|
|
4491
|
-
else if (await confirm({
|
|
4492
|
-
message: `${root} has no UIGraph artifacts. Generate them with an agent?`,
|
|
4493
|
-
default: true
|
|
4494
|
-
})) generated = await generate(root, false, {
|
|
4495
|
-
agent: options.agent,
|
|
4496
|
-
model: options.model,
|
|
4497
|
-
maxBudgetUsd: options.maxBudget
|
|
4498
|
-
});
|
|
4499
|
-
const synced = hasArtifacts || generated ? await registerProject(root, options.port) : true;
|
|
4500
|
-
if (process.stdin.isTTY && (hasArtifacts || generated) && !await pathExists(path.join(root, ".uigraph/AGENTS.md")) && !await isGuidanceDeclined(root)) await offerGuidance(root, false);
|
|
3885
|
+
synced = await registerProject(root, options.port);
|
|
3886
|
+
} else console.log(`${root} has no UIGraph artifacts. ${GENERATE_HINT}`);
|
|
4501
3887
|
if (options.open) await openBrowser(serverUrl(options.port));
|
|
4502
3888
|
if (!synced) process.exitCode = 1;
|
|
4503
3889
|
});
|
|
4504
|
-
program.command("init").description("Generate artifacts if there are none, then add or update the instructions for coding agents").argument("[repository]", "Repository path (default: current directory)").addOption(agentOption()).option("--model <name>", "Model to use for generation, in the agent's own format").option("--max-budget <usd>", "Spending cap per Claude session (planning, generation and repair each get one). Claude only", parseBudget).action(async (repository, options) => {
|
|
4505
|
-
const root = await resolveRepository(repository);
|
|
4506
|
-
if (!process.stdin.isTTY) throw new UsageError("\"uigraph init\" needs a terminal.");
|
|
4507
|
-
if (!await pathExists(path.join(root, ".uigraph.yaml"))) {
|
|
4508
|
-
if (!await generate(root, false, {
|
|
4509
|
-
agent: options.agent,
|
|
4510
|
-
model: options.model,
|
|
4511
|
-
maxBudgetUsd: options.maxBudget
|
|
4512
|
-
})) {
|
|
4513
|
-
process.exitCode = 130;
|
|
4514
|
-
return;
|
|
4515
|
-
}
|
|
4516
|
-
}
|
|
4517
|
-
const synced = await registerProject(root, parsePort(process.env.UIGRAPH_PORT ?? String(7357)));
|
|
4518
|
-
await offerGuidance(root, await pathExists(path.join(root, GUIDANCE_FILE)));
|
|
4519
|
-
if (!synced) process.exitCode = 1;
|
|
4520
|
-
});
|
|
4521
|
-
program.command("generate").description("Generate artifacts, or update them after asking, then exit").argument("[repository]", "Repository path (default: current directory)").option("--update", "Regenerate existing artifacts without asking").addOption(agentOption()).option("--model <name>", "Model to use for generation, in the agent's own format").option("--max-budget <usd>", "Spending cap per Claude session (planning, generation and repair each get one). Claude only", parseBudget).action(async (repository, options) => {
|
|
4522
|
-
const root = await resolveRepository(repository);
|
|
4523
|
-
if (!process.stdin.isTTY) throw new UsageError("\"uigraph generate\" needs a terminal.");
|
|
4524
|
-
const hasArtifacts = await pathExists(path.join(root, ROOT_CONFIG));
|
|
4525
|
-
if (hasArtifacts && options.update !== true && !await confirm({
|
|
4526
|
-
message: `${root} already has UIGraph artifacts. Update them?`,
|
|
4527
|
-
default: false
|
|
4528
|
-
})) {
|
|
4529
|
-
console.log("Nothing was changed.");
|
|
4530
|
-
return;
|
|
4531
|
-
}
|
|
4532
|
-
if (!await generate(root, hasArtifacts, {
|
|
4533
|
-
agent: options.agent,
|
|
4534
|
-
model: options.model,
|
|
4535
|
-
maxBudgetUsd: options.maxBudget
|
|
4536
|
-
})) {
|
|
4537
|
-
process.exitCode = 130;
|
|
4538
|
-
return;
|
|
4539
|
-
}
|
|
4540
|
-
if (!await registerProject(root, parsePort(process.env.UIGRAPH_PORT ?? String(7357)))) process.exitCode = 1;
|
|
4541
|
-
});
|
|
4542
3890
|
program.command("sync").description("Show the artifacts of a repository in the viewer, then exit").argument("[repository]", "Repository path (default: current directory)").action(async (repository) => {
|
|
4543
3891
|
const root = await resolveRepository(repository);
|
|
4544
|
-
if (!await pathExists(path.join(root, ".uigraph.yaml"))) throw new UsageError(`${root} has no UIGraph artifacts.
|
|
3892
|
+
if (!await pathExists(path.join(root, ".uigraph.yaml"))) throw new UsageError(`${root} has no UIGraph artifacts. ${GENERATE_HINT}`);
|
|
4545
3893
|
if (!await registerProject(root, parsePort(process.env.UIGRAPH_PORT ?? String(7357)))) process.exitCode = 1;
|
|
4546
3894
|
});
|
|
4547
3895
|
program.command("start").description("Start the viewer").addOption(portOption()).action(async (options) => {
|