airgen-cli 0.1.6 → 0.1.7
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/dist/commands/diagrams.js +255 -0
- package/package.json +1 -1
|
@@ -1,4 +1,205 @@
|
|
|
1
|
+
import { writeFileSync } from "node:fs";
|
|
1
2
|
import { output, printTable, isJsonMode } from "../output.js";
|
|
3
|
+
// ── Mermaid rendering helpers ─────────────────────────────────
|
|
4
|
+
function sanitizeId(id) {
|
|
5
|
+
return id.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
6
|
+
}
|
|
7
|
+
function mermaidNodeShape(block) {
|
|
8
|
+
const id = sanitizeId(block.id);
|
|
9
|
+
const label = block.name.replace(/"/g, "'");
|
|
10
|
+
const stereo = block.stereotype?.replace(/[«»<>]/g, "") ?? block.kind ?? "block";
|
|
11
|
+
const display = `"«${stereo}»\\n${label}"`;
|
|
12
|
+
switch (block.kind) {
|
|
13
|
+
case "system": return `${id}[${display}]`;
|
|
14
|
+
case "subsystem": return `${id}[${display}]`;
|
|
15
|
+
case "actor": return `${id}([${display}])`;
|
|
16
|
+
case "external": return `${id}>${display}]`;
|
|
17
|
+
case "interface": return `${id}{{${display}}}`;
|
|
18
|
+
default: return `${id}[${display}]`; // component
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function mermaidArrow(kind) {
|
|
22
|
+
switch (kind) {
|
|
23
|
+
case "flow": return "==>";
|
|
24
|
+
case "dependency": return "-.->";
|
|
25
|
+
case "composition": return "--*";
|
|
26
|
+
default: return "-->"; // association
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function mermaidStyle(block) {
|
|
30
|
+
const id = sanitizeId(block.id);
|
|
31
|
+
switch (block.kind) {
|
|
32
|
+
case "system": return `style ${id} fill:#ebf8ff,stroke:#1a365d,color:#1a365d`;
|
|
33
|
+
case "subsystem": return `style ${id} fill:#f0f5ff,stroke:#2c5282,color:#2c5282`;
|
|
34
|
+
case "actor": return `style ${id} fill:#f0fff4,stroke:#276749,color:#276749`;
|
|
35
|
+
case "external": return `style ${id} fill:#fffbeb,stroke:#92400e,color:#92400e`;
|
|
36
|
+
case "interface": return `style ${id} fill:#faf5ff,stroke:#6b21a8,color:#6b21a8`;
|
|
37
|
+
default: return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
// ── Terminal (layered) rendering ─────────────────────────────
|
|
41
|
+
const KIND_ICONS = {
|
|
42
|
+
system: "■", subsystem: "□", component: "◦",
|
|
43
|
+
actor: "☺", external: "◇", interface: "◈",
|
|
44
|
+
};
|
|
45
|
+
function kindArrow(kind) {
|
|
46
|
+
switch (kind) {
|
|
47
|
+
case "flow": return "══▶";
|
|
48
|
+
case "dependency": return "--▷";
|
|
49
|
+
case "composition": return "◆──";
|
|
50
|
+
default: return "──▶";
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function renderTerminal(blocks, connectors) {
|
|
54
|
+
const blockMap = new Map(blocks.map(b => [b.id, b]));
|
|
55
|
+
// Build adjacency
|
|
56
|
+
const outEdges = new Map();
|
|
57
|
+
const inEdges = new Map();
|
|
58
|
+
const inDegree = new Map();
|
|
59
|
+
for (const b of blocks)
|
|
60
|
+
inDegree.set(b.id, 0);
|
|
61
|
+
for (const c of connectors) {
|
|
62
|
+
const out = outEdges.get(c.source) ?? [];
|
|
63
|
+
out.push({ target: c.target, label: c.label ?? "", kind: c.kind });
|
|
64
|
+
outEdges.set(c.source, out);
|
|
65
|
+
const inn = inEdges.get(c.target) ?? [];
|
|
66
|
+
inn.push({ source: c.source, label: c.label ?? "", kind: c.kind });
|
|
67
|
+
inEdges.set(c.target, inn);
|
|
68
|
+
inDegree.set(c.target, (inDegree.get(c.target) ?? 0) + 1);
|
|
69
|
+
}
|
|
70
|
+
// BFS layering (Kahn's algorithm, cycles go to last layer)
|
|
71
|
+
const layer = new Map();
|
|
72
|
+
const queue = [];
|
|
73
|
+
for (const b of blocks) {
|
|
74
|
+
if ((inDegree.get(b.id) ?? 0) === 0) {
|
|
75
|
+
queue.push(b.id);
|
|
76
|
+
layer.set(b.id, 0);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
let maxLayer = 0;
|
|
80
|
+
while (queue.length > 0) {
|
|
81
|
+
const id = queue.shift();
|
|
82
|
+
const curLayer = layer.get(id);
|
|
83
|
+
for (const e of outEdges.get(id) ?? []) {
|
|
84
|
+
if (layer.has(e.target))
|
|
85
|
+
continue;
|
|
86
|
+
const newDeg = (inDegree.get(e.target) ?? 1) - 1;
|
|
87
|
+
inDegree.set(e.target, newDeg);
|
|
88
|
+
if (newDeg <= 0) {
|
|
89
|
+
const nextLayer = curLayer + 1;
|
|
90
|
+
layer.set(e.target, nextLayer);
|
|
91
|
+
maxLayer = Math.max(maxLayer, nextLayer);
|
|
92
|
+
queue.push(e.target);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
// Assign remaining (cycle members) to maxLayer + 1
|
|
97
|
+
for (const b of blocks) {
|
|
98
|
+
if (!layer.has(b.id)) {
|
|
99
|
+
layer.set(b.id, maxLayer + 1);
|
|
100
|
+
maxLayer = maxLayer + 1;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// Group blocks by layer
|
|
104
|
+
const layers = [];
|
|
105
|
+
for (let i = 0; i <= maxLayer; i++)
|
|
106
|
+
layers.push([]);
|
|
107
|
+
for (const b of blocks) {
|
|
108
|
+
layers[layer.get(b.id)].push(b);
|
|
109
|
+
}
|
|
110
|
+
// Compute column widths for the block name column
|
|
111
|
+
const nameColWidth = Math.max(...blocks.map(b => b.name.length + 4), 20);
|
|
112
|
+
const icon = (b) => KIND_ICONS[b.kind ?? ""] ?? "□";
|
|
113
|
+
const lines = [];
|
|
114
|
+
const PAD = " ";
|
|
115
|
+
for (let li = 0; li < layers.length; li++) {
|
|
116
|
+
const layerBlocks = layers[li];
|
|
117
|
+
if (layerBlocks.length === 0)
|
|
118
|
+
continue;
|
|
119
|
+
// Layer header
|
|
120
|
+
const layerLabel = li === 0 ? "Sources" : li === layers.length - 1 ? "Outputs" : `Layer ${li}`;
|
|
121
|
+
lines.push(`${PAD}┄┄ ${layerLabel} ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄`);
|
|
122
|
+
lines.push("");
|
|
123
|
+
for (const b of layerBlocks) {
|
|
124
|
+
const blockIcon = icon(b);
|
|
125
|
+
const stereo = b.stereotype?.replace(/[«»<>]/g, "") ?? b.kind ?? "block";
|
|
126
|
+
const nameStr = `${blockIcon} ${b.name}`;
|
|
127
|
+
const paddedName = nameStr + " ".repeat(Math.max(0, nameColWidth - nameStr.length));
|
|
128
|
+
// Incoming connections for this block
|
|
129
|
+
const incoming = inEdges.get(b.id) ?? [];
|
|
130
|
+
// Outgoing connections
|
|
131
|
+
const outgoing = outEdges.get(b.id) ?? [];
|
|
132
|
+
// Build the box line
|
|
133
|
+
const boxTop = `${PAD} ┌${"─".repeat(nameColWidth + 2)}┐`;
|
|
134
|
+
const boxSte = `${PAD} │ «${stereo}»${" ".repeat(Math.max(0, nameColWidth - stereo.length - 2))} │`;
|
|
135
|
+
const boxNam = `${PAD} │ ${paddedName} │`;
|
|
136
|
+
const boxBot = `${PAD} └${"─".repeat(nameColWidth + 2)}┘`;
|
|
137
|
+
lines.push(boxTop);
|
|
138
|
+
lines.push(boxSte);
|
|
139
|
+
lines.push(boxNam);
|
|
140
|
+
lines.push(boxBot);
|
|
141
|
+
// Show incoming connections (who feeds this block)
|
|
142
|
+
if (incoming.length > 0) {
|
|
143
|
+
for (const e of incoming) {
|
|
144
|
+
const srcBlock = blockMap.get(e.source);
|
|
145
|
+
const srcName = srcBlock?.name ?? "?";
|
|
146
|
+
const arrow = kindArrow(e.kind);
|
|
147
|
+
const label = e.label ? ` (${e.label})` : "";
|
|
148
|
+
lines.push(`${PAD} ◀── ${srcName}${label}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// Show outgoing connections (where this block sends data)
|
|
152
|
+
if (outgoing.length > 0) {
|
|
153
|
+
for (const e of outgoing) {
|
|
154
|
+
const tgtBlock = blockMap.get(e.target);
|
|
155
|
+
const tgtName = tgtBlock?.name ?? "?";
|
|
156
|
+
const arrow = kindArrow(e.kind);
|
|
157
|
+
const label = e.label ? ` ${e.label} ` : " ";
|
|
158
|
+
lines.push(`${PAD} ${arrow}${label}──▶ ${tgtName}`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
lines.push("");
|
|
162
|
+
}
|
|
163
|
+
// Visual separator between layers
|
|
164
|
+
if (li < layers.length - 1) {
|
|
165
|
+
lines.push(`${PAD} │`);
|
|
166
|
+
lines.push(`${PAD} ▼`);
|
|
167
|
+
lines.push("");
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
// Legend
|
|
171
|
+
lines.push(`${PAD}┄┄ Legend ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄`);
|
|
172
|
+
lines.push(`${PAD} ☺ actor ■ system □ subsystem ◦ component ◇ external ◈ interface`);
|
|
173
|
+
lines.push(`${PAD} ──▶ association ══▶ flow ◆── composition --▷ dependency`);
|
|
174
|
+
return lines.join("\n");
|
|
175
|
+
}
|
|
176
|
+
function renderMermaid(blocks, connectors, direction) {
|
|
177
|
+
const lines = [`flowchart ${direction}`];
|
|
178
|
+
// Nodes
|
|
179
|
+
for (const b of blocks) {
|
|
180
|
+
lines.push(` ${mermaidNodeShape(b)}`);
|
|
181
|
+
}
|
|
182
|
+
// Edges
|
|
183
|
+
for (const c of connectors) {
|
|
184
|
+
const src = sanitizeId(c.source);
|
|
185
|
+
const tgt = sanitizeId(c.target);
|
|
186
|
+
const arrow = mermaidArrow(c.kind);
|
|
187
|
+
if (c.label) {
|
|
188
|
+
lines.push(` ${src} ${arrow}|"${c.label.replace(/"/g, "'")}"| ${tgt}`);
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
lines.push(` ${src} ${arrow} ${tgt}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
// Styles
|
|
195
|
+
const styles = blocks.map(mermaidStyle).filter(Boolean);
|
|
196
|
+
if (styles.length > 0) {
|
|
197
|
+
lines.push("");
|
|
198
|
+
for (const s of styles)
|
|
199
|
+
lines.push(` ${s}`);
|
|
200
|
+
}
|
|
201
|
+
return lines.join("\n");
|
|
202
|
+
}
|
|
2
203
|
export function registerDiagramCommands(program, client) {
|
|
3
204
|
const cmd = program.command("diagrams").alias("diag").description("Architecture diagrams");
|
|
4
205
|
cmd
|
|
@@ -35,6 +236,60 @@ export function registerDiagramCommands(program, client) {
|
|
|
35
236
|
]);
|
|
36
237
|
output({ blocks, connectors });
|
|
37
238
|
});
|
|
239
|
+
cmd
|
|
240
|
+
.command("render")
|
|
241
|
+
.description("Render a diagram in the terminal or as Mermaid syntax")
|
|
242
|
+
.argument("<tenant>", "Tenant slug")
|
|
243
|
+
.argument("<project>", "Project slug")
|
|
244
|
+
.argument("<id>", "Diagram ID")
|
|
245
|
+
.option("--format <fmt>", "Output format: text, mermaid", "text")
|
|
246
|
+
.option("--direction <dir>", "Layout direction for mermaid: TB, LR, BT, RL", "TB")
|
|
247
|
+
.option("-o, --output <file>", "Write to file instead of stdout")
|
|
248
|
+
.option("--wrap", "Wrap mermaid in markdown fenced code block")
|
|
249
|
+
.action(async (tenant, project, id, opts) => {
|
|
250
|
+
// Fetch diagram metadata + blocks + connectors in parallel
|
|
251
|
+
const [diagramData, blocksData, connectorsData] = await Promise.all([
|
|
252
|
+
client.get(`/architecture/diagrams/${tenant}/${project}`),
|
|
253
|
+
client.get(`/architecture/blocks/${tenant}/${project}/${id}`),
|
|
254
|
+
client.get(`/architecture/connectors/${tenant}/${project}/${id}`),
|
|
255
|
+
]);
|
|
256
|
+
const diagram = (diagramData.diagrams ?? []).find(d => d.id === id);
|
|
257
|
+
const blocks = blocksData.blocks ?? [];
|
|
258
|
+
const connectors = connectorsData.connectors ?? [];
|
|
259
|
+
if (blocks.length === 0) {
|
|
260
|
+
console.log("Empty diagram — no blocks to render.");
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
let rendered;
|
|
264
|
+
if (opts.format === "mermaid") {
|
|
265
|
+
rendered = renderMermaid(blocks, connectors, opts.direction);
|
|
266
|
+
if (isJsonMode()) {
|
|
267
|
+
output({ mermaid: rendered, blocks: blocks.length, connectors: connectors.length });
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if (opts.wrap)
|
|
271
|
+
rendered = "```mermaid\n" + rendered + "\n```";
|
|
272
|
+
}
|
|
273
|
+
else {
|
|
274
|
+
// Terminal text format
|
|
275
|
+
const header = diagram?.name ?? id;
|
|
276
|
+
const headerLine = ` ${header}`;
|
|
277
|
+
const underline = ` ${"═".repeat(header.length)}`;
|
|
278
|
+
const stats = ` ${blocks.length} blocks, ${connectors.length} connectors`;
|
|
279
|
+
rendered = [headerLine, underline, stats, "", renderTerminal(blocks, connectors)].join("\n");
|
|
280
|
+
if (isJsonMode()) {
|
|
281
|
+
output({ text: rendered, blocks: blocks.length, connectors: connectors.length });
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
if (opts.output) {
|
|
286
|
+
writeFileSync(opts.output, rendered + "\n", "utf-8");
|
|
287
|
+
console.log(`Diagram written to ${opts.output}`);
|
|
288
|
+
}
|
|
289
|
+
else {
|
|
290
|
+
console.log(rendered);
|
|
291
|
+
}
|
|
292
|
+
});
|
|
38
293
|
cmd
|
|
39
294
|
.command("create")
|
|
40
295
|
.description("Create a new diagram")
|