figram 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +39 -0
- package/dist/index.js +1274 -0
- package/dist/templates/diagram.yaml +60 -0
- package/package.json +46 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1274 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { readFileSync as readFileSync3 } from "fs";
|
|
5
|
+
import { dirname as dirname5, join as join4 } from "path";
|
|
6
|
+
import { fileURLToPath as fileURLToPath4 } from "url";
|
|
7
|
+
import { Command } from "commander";
|
|
8
|
+
|
|
9
|
+
// src/commands/build.ts
|
|
10
|
+
import { writeFile } from "fs/promises";
|
|
11
|
+
import { extname } from "path";
|
|
12
|
+
|
|
13
|
+
// src/lib/diagram.ts
|
|
14
|
+
import { existsSync } from "fs";
|
|
15
|
+
import { readFile } from "fs/promises";
|
|
16
|
+
|
|
17
|
+
// ../core/dist/index.js
|
|
18
|
+
function diff(prev, next) {
|
|
19
|
+
const ops = [];
|
|
20
|
+
const prevNodes = prev?.nodes ?? {};
|
|
21
|
+
const prevEdges = prev?.edges ?? {};
|
|
22
|
+
const nextNodes = next.nodes;
|
|
23
|
+
const nextEdges = next.edges;
|
|
24
|
+
for (const id of Object.keys(prevEdges)) {
|
|
25
|
+
if (!(id in nextEdges)) {
|
|
26
|
+
ops.push({ op: "removeEdge", id });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
const removedNodeIds = Object.keys(prevNodes).filter((id) => !(id in nextNodes));
|
|
30
|
+
const sortedRemovedNodes = sortNodesChildFirst(removedNodeIds, prevNodes);
|
|
31
|
+
for (const id of sortedRemovedNodes) {
|
|
32
|
+
ops.push({ op: "removeNode", id });
|
|
33
|
+
}
|
|
34
|
+
const upsertNodeIds = Object.keys(nextNodes).filter((id) => {
|
|
35
|
+
if (!(id in prevNodes))
|
|
36
|
+
return true;
|
|
37
|
+
return !nodesEqual(prevNodes[id], nextNodes[id]);
|
|
38
|
+
});
|
|
39
|
+
const sortedUpsertNodes = sortNodesParentFirst(upsertNodeIds, nextNodes);
|
|
40
|
+
for (const id of sortedUpsertNodes) {
|
|
41
|
+
ops.push({ op: "upsertNode", node: nextNodes[id] });
|
|
42
|
+
}
|
|
43
|
+
for (const id of Object.keys(nextEdges)) {
|
|
44
|
+
if (!(id in prevEdges) || !edgesEqual(prevEdges[id], nextEdges[id])) {
|
|
45
|
+
ops.push({ op: "upsertEdge", edge: nextEdges[id] });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return ops;
|
|
49
|
+
}
|
|
50
|
+
function sortNodesParentFirst(ids, nodes) {
|
|
51
|
+
const result = [];
|
|
52
|
+
const visited = /* @__PURE__ */ new Set();
|
|
53
|
+
const idSet = new Set(ids);
|
|
54
|
+
function visit(id) {
|
|
55
|
+
if (visited.has(id) || !idSet.has(id))
|
|
56
|
+
return;
|
|
57
|
+
const node = nodes[id];
|
|
58
|
+
if (node.parent && idSet.has(node.parent)) {
|
|
59
|
+
visit(node.parent);
|
|
60
|
+
}
|
|
61
|
+
visited.add(id);
|
|
62
|
+
result.push(id);
|
|
63
|
+
}
|
|
64
|
+
for (const id of ids) {
|
|
65
|
+
visit(id);
|
|
66
|
+
}
|
|
67
|
+
return result;
|
|
68
|
+
}
|
|
69
|
+
function sortNodesChildFirst(ids, nodes) {
|
|
70
|
+
return sortNodesParentFirst(ids, nodes).reverse();
|
|
71
|
+
}
|
|
72
|
+
function nodesEqual(a, b) {
|
|
73
|
+
return a.id === b.id && a.provider === b.provider && a.kind === b.kind && a.label === b.label && a.parent === b.parent && a.layout.x === b.layout.x && a.layout.y === b.layout.y && a.layout.w === b.layout.w && a.layout.h === b.layout.h;
|
|
74
|
+
}
|
|
75
|
+
function edgesEqual(a, b) {
|
|
76
|
+
return a.id === b.id && a.from === b.from && a.to === b.to && a.label === b.label && a.color === b.color;
|
|
77
|
+
}
|
|
78
|
+
function normalizeIcons(icons) {
|
|
79
|
+
return icons ?? {};
|
|
80
|
+
}
|
|
81
|
+
function iconsEqual(a, b) {
|
|
82
|
+
const aRegistry = a ?? {};
|
|
83
|
+
const bRegistry = b ?? {};
|
|
84
|
+
const aProviders = Object.keys(aRegistry);
|
|
85
|
+
const bProviders = Object.keys(bRegistry);
|
|
86
|
+
if (aProviders.length !== bProviders.length)
|
|
87
|
+
return false;
|
|
88
|
+
for (const provider of aProviders) {
|
|
89
|
+
const aKinds = aRegistry[provider];
|
|
90
|
+
const bKinds = bRegistry[provider];
|
|
91
|
+
if (!bKinds)
|
|
92
|
+
return false;
|
|
93
|
+
const aKindKeys = Object.keys(aKinds);
|
|
94
|
+
const bKindKeys = Object.keys(bKinds);
|
|
95
|
+
if (aKindKeys.length !== bKindKeys.length)
|
|
96
|
+
return false;
|
|
97
|
+
for (const kind of aKindKeys) {
|
|
98
|
+
if (aKinds[kind] !== bKinds[kind])
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
function countIcons(registry) {
|
|
105
|
+
if (!registry)
|
|
106
|
+
return 0;
|
|
107
|
+
let count = 0;
|
|
108
|
+
for (const mapping of Object.values(registry)) {
|
|
109
|
+
count += Object.keys(mapping).length;
|
|
110
|
+
}
|
|
111
|
+
return count;
|
|
112
|
+
}
|
|
113
|
+
function mergeIconRegistries(...registries) {
|
|
114
|
+
const result = {};
|
|
115
|
+
for (const registry of registries) {
|
|
116
|
+
if (!registry)
|
|
117
|
+
continue;
|
|
118
|
+
for (const [provider, mapping] of Object.entries(registry)) {
|
|
119
|
+
if (!result[provider]) {
|
|
120
|
+
result[provider] = {};
|
|
121
|
+
}
|
|
122
|
+
Object.assign(result[provider], mapping);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return result;
|
|
126
|
+
}
|
|
127
|
+
var DEFAULT_EDGE_COLOR = "#666666";
|
|
128
|
+
function normalizeHexColor(color) {
|
|
129
|
+
if (color.length === 4) {
|
|
130
|
+
const r = color[1];
|
|
131
|
+
const g = color[2];
|
|
132
|
+
const b = color[3];
|
|
133
|
+
return `#${r}${r}${g}${g}${b}${b}`.toUpperCase();
|
|
134
|
+
}
|
|
135
|
+
return color.toUpperCase();
|
|
136
|
+
}
|
|
137
|
+
var RESOURCE_LAYOUT = {
|
|
138
|
+
PADDING: 50,
|
|
139
|
+
ITEM_WIDTH: 160,
|
|
140
|
+
ITEM_HEIGHT: 140,
|
|
141
|
+
COLS: 2
|
|
142
|
+
};
|
|
143
|
+
var SUBNET_LAYOUT = {
|
|
144
|
+
PADDING: 40,
|
|
145
|
+
GAP: 40,
|
|
146
|
+
DEFAULT_WIDTH: 450,
|
|
147
|
+
DEFAULT_HEIGHT: 400,
|
|
148
|
+
COLS: 3
|
|
149
|
+
};
|
|
150
|
+
function isSubnet(kind) {
|
|
151
|
+
return kind === "network.subnet";
|
|
152
|
+
}
|
|
153
|
+
function normalize(dsl) {
|
|
154
|
+
const nodes = {};
|
|
155
|
+
const edges = {};
|
|
156
|
+
const subnetCounts = /* @__PURE__ */ new Map();
|
|
157
|
+
const resourceCounts = /* @__PURE__ */ new Map();
|
|
158
|
+
for (const node of dsl.nodes) {
|
|
159
|
+
const layout = node.layout ?? {};
|
|
160
|
+
let x = layout.x;
|
|
161
|
+
let y = layout.y;
|
|
162
|
+
let w = layout.w;
|
|
163
|
+
let h = layout.h;
|
|
164
|
+
if (node.parent && x === void 0 && y === void 0) {
|
|
165
|
+
if (isSubnet(node.kind)) {
|
|
166
|
+
const subnetIndex = subnetCounts.get(node.parent) ?? 0;
|
|
167
|
+
subnetCounts.set(node.parent, subnetIndex + 1);
|
|
168
|
+
const col = subnetIndex % SUBNET_LAYOUT.COLS;
|
|
169
|
+
const row = Math.floor(subnetIndex / SUBNET_LAYOUT.COLS);
|
|
170
|
+
x = SUBNET_LAYOUT.PADDING + col * (SUBNET_LAYOUT.DEFAULT_WIDTH + SUBNET_LAYOUT.GAP);
|
|
171
|
+
y = SUBNET_LAYOUT.PADDING + row * (SUBNET_LAYOUT.DEFAULT_HEIGHT + SUBNET_LAYOUT.GAP);
|
|
172
|
+
if (w === void 0)
|
|
173
|
+
w = SUBNET_LAYOUT.DEFAULT_WIDTH;
|
|
174
|
+
if (h === void 0)
|
|
175
|
+
h = SUBNET_LAYOUT.DEFAULT_HEIGHT;
|
|
176
|
+
} else {
|
|
177
|
+
const resourceIndex = resourceCounts.get(node.parent) ?? 0;
|
|
178
|
+
resourceCounts.set(node.parent, resourceIndex + 1);
|
|
179
|
+
const col = resourceIndex % RESOURCE_LAYOUT.COLS;
|
|
180
|
+
const row = Math.floor(resourceIndex / RESOURCE_LAYOUT.COLS);
|
|
181
|
+
x = RESOURCE_LAYOUT.PADDING + col * (RESOURCE_LAYOUT.ITEM_WIDTH + RESOURCE_LAYOUT.PADDING);
|
|
182
|
+
y = RESOURCE_LAYOUT.PADDING + row * (RESOURCE_LAYOUT.ITEM_HEIGHT + RESOURCE_LAYOUT.PADDING);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (x === void 0 || y === void 0) {
|
|
186
|
+
throw new Error(`Node ${node.id} requires layout.x and layout.y`);
|
|
187
|
+
}
|
|
188
|
+
nodes[node.id] = {
|
|
189
|
+
id: node.id,
|
|
190
|
+
provider: node.provider,
|
|
191
|
+
kind: node.kind,
|
|
192
|
+
label: node.label ?? node.id,
|
|
193
|
+
parent: node.parent ?? null,
|
|
194
|
+
layout: {
|
|
195
|
+
x,
|
|
196
|
+
y,
|
|
197
|
+
w: w ?? null,
|
|
198
|
+
h: h ?? null
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
if (dsl.edges) {
|
|
203
|
+
for (const edge of dsl.edges) {
|
|
204
|
+
edges[edge.id] = {
|
|
205
|
+
id: edge.id,
|
|
206
|
+
from: edge.from,
|
|
207
|
+
to: edge.to,
|
|
208
|
+
label: edge.label ?? "",
|
|
209
|
+
color: edge.color ? normalizeHexColor(edge.color) : DEFAULT_EDGE_COLOR
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return {
|
|
214
|
+
version: dsl.version,
|
|
215
|
+
docId: dsl.docId,
|
|
216
|
+
title: dsl.title ?? dsl.docId,
|
|
217
|
+
nodes,
|
|
218
|
+
edges
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
var HEX_COLOR_REGEX = /^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/;
|
|
222
|
+
function validate(doc) {
|
|
223
|
+
const errors = [];
|
|
224
|
+
if (!doc || typeof doc !== "object") {
|
|
225
|
+
return { ok: false, errors: [{ path: "", message: "Document must be an object" }] };
|
|
226
|
+
}
|
|
227
|
+
const d = doc;
|
|
228
|
+
const nodeIds = /* @__PURE__ */ new Set();
|
|
229
|
+
let nodes = null;
|
|
230
|
+
if (typeof d.version !== "number") {
|
|
231
|
+
errors.push({ path: "version", message: "version is required and must be a number" });
|
|
232
|
+
}
|
|
233
|
+
if (typeof d.docId !== "string" || d.docId.trim() === "") {
|
|
234
|
+
errors.push({ path: "docId", message: "docId is required and must be a non-empty string" });
|
|
235
|
+
}
|
|
236
|
+
if (!Array.isArray(d.nodes)) {
|
|
237
|
+
errors.push({ path: "nodes", message: "nodes must be an array" });
|
|
238
|
+
} else {
|
|
239
|
+
nodes = d.nodes;
|
|
240
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
241
|
+
const node = nodes[i];
|
|
242
|
+
const path = `nodes[${i}]`;
|
|
243
|
+
if (typeof node.id !== "string" || node.id.trim() === "") {
|
|
244
|
+
errors.push({ path: `${path}.id`, message: "node id is required" });
|
|
245
|
+
} else if (nodeIds.has(node.id)) {
|
|
246
|
+
errors.push({ path: `${path}.id`, message: `duplicate node id: ${node.id}` });
|
|
247
|
+
} else {
|
|
248
|
+
nodeIds.add(node.id);
|
|
249
|
+
}
|
|
250
|
+
if (typeof node.provider !== "string") {
|
|
251
|
+
errors.push({ path: `${path}.provider`, message: "provider is required" });
|
|
252
|
+
}
|
|
253
|
+
if (typeof node.kind !== "string") {
|
|
254
|
+
errors.push({ path: `${path}.kind`, message: "kind is required" });
|
|
255
|
+
}
|
|
256
|
+
const hasParent = !!node.parent;
|
|
257
|
+
const layout = node.layout;
|
|
258
|
+
if (!hasParent && (!layout || typeof layout !== "object")) {
|
|
259
|
+
errors.push({ path: `${path}.layout`, message: "layout is required for top-level nodes" });
|
|
260
|
+
}
|
|
261
|
+
if (layout && typeof layout === "object") {
|
|
262
|
+
const hasX = layout.x !== void 0;
|
|
263
|
+
const hasY = layout.y !== void 0;
|
|
264
|
+
if (hasX !== hasY) {
|
|
265
|
+
errors.push({
|
|
266
|
+
path: `${path}.layout`,
|
|
267
|
+
message: "layout.x and layout.y must be both specified or both omitted"
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
if (!hasParent && !hasX) {
|
|
271
|
+
errors.push({
|
|
272
|
+
path: `${path}.layout.x`,
|
|
273
|
+
message: "layout.x is required for top-level nodes"
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
if (!hasParent && !hasY) {
|
|
277
|
+
errors.push({
|
|
278
|
+
path: `${path}.layout.y`,
|
|
279
|
+
message: "layout.y is required for top-level nodes"
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
if (hasX && typeof layout.x !== "number") {
|
|
283
|
+
errors.push({ path: `${path}.layout.x`, message: "layout.x must be a number" });
|
|
284
|
+
}
|
|
285
|
+
if (hasY && typeof layout.y !== "number") {
|
|
286
|
+
errors.push({ path: `${path}.layout.y`, message: "layout.y must be a number" });
|
|
287
|
+
}
|
|
288
|
+
if (layout.w !== void 0 && typeof layout.w !== "number") {
|
|
289
|
+
errors.push({ path: `${path}.layout.w`, message: "layout.w must be a number" });
|
|
290
|
+
}
|
|
291
|
+
if (layout.h !== void 0 && typeof layout.h !== "number") {
|
|
292
|
+
errors.push({ path: `${path}.layout.h`, message: "layout.h must be a number" });
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
297
|
+
const node = nodes[i];
|
|
298
|
+
if (node.parent && !nodeIds.has(node.parent)) {
|
|
299
|
+
errors.push({
|
|
300
|
+
path: `nodes[${i}].parent`,
|
|
301
|
+
message: `parent '${node.parent}' does not exist`
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
const cycleErrors = detectCycles(nodes);
|
|
306
|
+
errors.push(...cycleErrors);
|
|
307
|
+
}
|
|
308
|
+
if (d.edges !== void 0) {
|
|
309
|
+
if (!Array.isArray(d.edges)) {
|
|
310
|
+
errors.push({ path: "edges", message: "edges must be an array" });
|
|
311
|
+
} else {
|
|
312
|
+
const edgeIds = /* @__PURE__ */ new Set();
|
|
313
|
+
const edges = d.edges;
|
|
314
|
+
for (let i = 0; i < edges.length; i++) {
|
|
315
|
+
const edge = edges[i];
|
|
316
|
+
const path = `edges[${i}]`;
|
|
317
|
+
if (typeof edge.id !== "string" || edge.id.trim() === "") {
|
|
318
|
+
errors.push({ path: `${path}.id`, message: "edge id is required" });
|
|
319
|
+
} else if (edgeIds.has(edge.id)) {
|
|
320
|
+
errors.push({ path: `${path}.id`, message: `duplicate edge id: ${edge.id}` });
|
|
321
|
+
} else {
|
|
322
|
+
edgeIds.add(edge.id);
|
|
323
|
+
}
|
|
324
|
+
if (typeof edge.from !== "string") {
|
|
325
|
+
errors.push({ path: `${path}.from`, message: "from is required" });
|
|
326
|
+
} else if (!nodeIds.has(edge.from)) {
|
|
327
|
+
errors.push({ path: `${path}.from`, message: `from '${edge.from}' does not exist` });
|
|
328
|
+
}
|
|
329
|
+
if (typeof edge.to !== "string") {
|
|
330
|
+
errors.push({ path: `${path}.to`, message: "to is required" });
|
|
331
|
+
} else if (!nodeIds.has(edge.to)) {
|
|
332
|
+
errors.push({ path: `${path}.to`, message: `to '${edge.to}' does not exist` });
|
|
333
|
+
}
|
|
334
|
+
if (edge.color !== void 0) {
|
|
335
|
+
if (typeof edge.color !== "string") {
|
|
336
|
+
errors.push({ path: `${path}.color`, message: "color must be a string" });
|
|
337
|
+
} else if (!HEX_COLOR_REGEX.test(edge.color)) {
|
|
338
|
+
errors.push({
|
|
339
|
+
path: `${path}.color`,
|
|
340
|
+
message: "color must be a valid HEX color (#RGB or #RRGGBB)"
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
if (d.icons !== void 0) {
|
|
348
|
+
if (typeof d.icons !== "object" || d.icons === null || Array.isArray(d.icons)) {
|
|
349
|
+
errors.push({ path: "icons", message: "icons must be an object" });
|
|
350
|
+
} else {
|
|
351
|
+
const icons = d.icons;
|
|
352
|
+
for (const [provider, mapping] of Object.entries(icons)) {
|
|
353
|
+
const providerPath = `icons.${provider}`;
|
|
354
|
+
if (typeof mapping !== "object" || mapping === null || Array.isArray(mapping)) {
|
|
355
|
+
errors.push({ path: providerPath, message: "icon mapping must be an object" });
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
const kindMapping = mapping;
|
|
359
|
+
for (const [kind, iconPath] of Object.entries(kindMapping)) {
|
|
360
|
+
if (typeof iconPath !== "string" || iconPath.trim() === "") {
|
|
361
|
+
errors.push({
|
|
362
|
+
path: `${providerPath}.${kind}`,
|
|
363
|
+
message: "icon path must be a non-empty string"
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
if (errors.length > 0) {
|
|
371
|
+
return { ok: false, errors };
|
|
372
|
+
}
|
|
373
|
+
return { ok: true, document: doc };
|
|
374
|
+
}
|
|
375
|
+
function detectCycles(nodes) {
|
|
376
|
+
const errors = [];
|
|
377
|
+
const nodeMap = /* @__PURE__ */ new Map();
|
|
378
|
+
for (const node of nodes) {
|
|
379
|
+
if (node.id) {
|
|
380
|
+
nodeMap.set(node.id, node);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
for (const node of nodes) {
|
|
384
|
+
if (!node.parent)
|
|
385
|
+
continue;
|
|
386
|
+
const visited = /* @__PURE__ */ new Set();
|
|
387
|
+
let current = node.id;
|
|
388
|
+
while (current) {
|
|
389
|
+
if (visited.has(current)) {
|
|
390
|
+
errors.push({
|
|
391
|
+
path: `nodes`,
|
|
392
|
+
message: `cycle detected involving node '${node.id}'`
|
|
393
|
+
});
|
|
394
|
+
break;
|
|
395
|
+
}
|
|
396
|
+
visited.add(current);
|
|
397
|
+
const currentNode = nodeMap.get(current);
|
|
398
|
+
current = currentNode?.parent;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
return errors;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// src/lib/diagram.ts
|
|
405
|
+
import { parse as parseYaml } from "yaml";
|
|
406
|
+
|
|
407
|
+
// src/errors.ts
|
|
408
|
+
var CliError = class extends Error {
|
|
409
|
+
constructor(message, exitCode) {
|
|
410
|
+
super(message);
|
|
411
|
+
this.exitCode = exitCode;
|
|
412
|
+
this.name = "CliError";
|
|
413
|
+
}
|
|
414
|
+
};
|
|
415
|
+
var FileNotFoundError = class extends CliError {
|
|
416
|
+
constructor(path) {
|
|
417
|
+
super(`File not found: ${path}`, 1);
|
|
418
|
+
this.name = "FileNotFoundError";
|
|
419
|
+
}
|
|
420
|
+
};
|
|
421
|
+
var FileExistsError = class extends CliError {
|
|
422
|
+
constructor(path) {
|
|
423
|
+
super(`${path} already exists`, 1);
|
|
424
|
+
this.name = "FileExistsError";
|
|
425
|
+
}
|
|
426
|
+
};
|
|
427
|
+
var YamlParseError = class extends CliError {
|
|
428
|
+
constructor(message) {
|
|
429
|
+
super(`Failed to parse YAML: ${message}`, 1);
|
|
430
|
+
this.name = "YamlParseError";
|
|
431
|
+
}
|
|
432
|
+
};
|
|
433
|
+
var ValidationError = class extends CliError {
|
|
434
|
+
constructor(errors) {
|
|
435
|
+
const formatted = errors.map((e) => ` - ${e.path}: ${e.message}`).join("\n");
|
|
436
|
+
super(`Validation errors:
|
|
437
|
+
${formatted}`, 1);
|
|
438
|
+
this.name = "ValidationError";
|
|
439
|
+
}
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
// src/lib/diagram.ts
|
|
443
|
+
function ensureFileExists(path) {
|
|
444
|
+
if (!existsSync(path)) {
|
|
445
|
+
throw new FileNotFoundError(path);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
async function readDiagramFile(path) {
|
|
449
|
+
return readFile(path, "utf-8");
|
|
450
|
+
}
|
|
451
|
+
function parseDiagramYaml(content) {
|
|
452
|
+
try {
|
|
453
|
+
return parseYaml(content);
|
|
454
|
+
} catch (err) {
|
|
455
|
+
throw new YamlParseError(err.message);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
function validateDiagram(parsed) {
|
|
459
|
+
const result = validate(parsed);
|
|
460
|
+
if (!result.ok) {
|
|
461
|
+
throw new ValidationError(result.errors);
|
|
462
|
+
}
|
|
463
|
+
return result.document;
|
|
464
|
+
}
|
|
465
|
+
function toIR(dsl) {
|
|
466
|
+
return normalize(dsl);
|
|
467
|
+
}
|
|
468
|
+
async function loadIRFromYamlFile(inputFile) {
|
|
469
|
+
ensureFileExists(inputFile);
|
|
470
|
+
const content = await readDiagramFile(inputFile);
|
|
471
|
+
const parsed = parseDiagramYaml(content);
|
|
472
|
+
const dsl = validateDiagram(parsed);
|
|
473
|
+
const ir = toIR(dsl);
|
|
474
|
+
return { ir };
|
|
475
|
+
}
|
|
476
|
+
async function tryLoadDiagram(inputFile) {
|
|
477
|
+
try {
|
|
478
|
+
const { ir } = await loadIRFromYamlFile(inputFile);
|
|
479
|
+
return { ir, error: null };
|
|
480
|
+
} catch (err) {
|
|
481
|
+
return { ir: null, error: err.message };
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// src/commands/build.ts
|
|
486
|
+
async function buildCommand(inputFile, outputFile) {
|
|
487
|
+
const { ir } = await loadIRFromYamlFile(inputFile);
|
|
488
|
+
const ext = extname(inputFile);
|
|
489
|
+
const output = outputFile ?? (inputFile.match(/\.ya?ml$/) ? inputFile.replace(/\.ya?ml$/, ".json") : ext ? `${inputFile.slice(0, -ext.length)}.json` : `${inputFile}.json`);
|
|
490
|
+
await writeFile(output, JSON.stringify(ir, null, 2));
|
|
491
|
+
console.log(`Built: ${inputFile} -> ${output}`);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// src/commands/init.ts
|
|
495
|
+
import { existsSync as existsSync2 } from "fs";
|
|
496
|
+
import { readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
|
|
497
|
+
import { dirname, join } from "path";
|
|
498
|
+
import { fileURLToPath } from "url";
|
|
499
|
+
var __dirname = dirname(fileURLToPath(import.meta.url));
|
|
500
|
+
async function initCommand() {
|
|
501
|
+
const filename = "diagram.yaml";
|
|
502
|
+
if (existsSync2(filename)) {
|
|
503
|
+
throw new FileExistsError(filename);
|
|
504
|
+
}
|
|
505
|
+
const templatePath = join(__dirname, "..", "templates", "diagram.yaml");
|
|
506
|
+
const template = await readFile2(templatePath, "utf-8");
|
|
507
|
+
await writeFile2(filename, template);
|
|
508
|
+
console.log(`Created ${filename}`);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// src/commands/serve.ts
|
|
512
|
+
import { readFileSync } from "fs";
|
|
513
|
+
import { createServer } from "http";
|
|
514
|
+
import { dirname as dirname3, join as join2 } from "path";
|
|
515
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
516
|
+
|
|
517
|
+
// src/lib/icons.ts
|
|
518
|
+
import { existsSync as existsSync3 } from "fs";
|
|
519
|
+
import { readFile as readFile3 } from "fs/promises";
|
|
520
|
+
import { dirname as dirname2, extname as extname2, isAbsolute, resolve } from "path";
|
|
521
|
+
import { parse } from "yaml";
|
|
522
|
+
var SUPPORTED_FORMATS = [".png", ".jpg", ".jpeg", ".gif", ".webp"];
|
|
523
|
+
async function loadIconAsBase64(iconPath, basePath) {
|
|
524
|
+
const fullPath = isAbsolute(iconPath) ? iconPath : resolve(dirname2(basePath), iconPath);
|
|
525
|
+
const ext = extname2(fullPath).toLowerCase();
|
|
526
|
+
if (ext === ".svg") {
|
|
527
|
+
throw new Error(
|
|
528
|
+
"Unsupported image format: .svg. FigJam only supports PNG, JPG/JPEG, GIF, or WebP."
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
if (!SUPPORTED_FORMATS.includes(ext)) {
|
|
532
|
+
throw new Error(`Unsupported image format: ${ext}. Supported: ${SUPPORTED_FORMATS.join(", ")}`);
|
|
533
|
+
}
|
|
534
|
+
const buffer = await readFile3(fullPath);
|
|
535
|
+
return buffer.toString("base64");
|
|
536
|
+
}
|
|
537
|
+
async function resolveIcons(dslIcons, basePath) {
|
|
538
|
+
const result = {};
|
|
539
|
+
for (const [provider, mapping] of Object.entries(dslIcons)) {
|
|
540
|
+
result[provider] = {};
|
|
541
|
+
for (const [kind, iconPath] of Object.entries(mapping)) {
|
|
542
|
+
try {
|
|
543
|
+
result[provider][kind] = await loadIconAsBase64(iconPath, basePath);
|
|
544
|
+
} catch (err) {
|
|
545
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
546
|
+
console.warn(`[icons] Failed to load ${provider}:${kind} from ${iconPath}: ${message}`);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
return result;
|
|
551
|
+
}
|
|
552
|
+
function findIconsFile(diagramPath) {
|
|
553
|
+
const dir = dirname2(diagramPath);
|
|
554
|
+
const candidates = ["figram-icons.yaml", "figram-icons.yml"];
|
|
555
|
+
for (const candidate of candidates) {
|
|
556
|
+
const fullPath = resolve(dir, candidate);
|
|
557
|
+
if (existsSync3(fullPath)) {
|
|
558
|
+
return fullPath;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
return null;
|
|
562
|
+
}
|
|
563
|
+
async function loadIconsFile(iconsFilePath) {
|
|
564
|
+
try {
|
|
565
|
+
const content = await readFile3(iconsFilePath, "utf-8");
|
|
566
|
+
const parsed = parse(content);
|
|
567
|
+
if (!parsed || typeof parsed !== "object") {
|
|
568
|
+
return { icons: null, error: "Icons file must be a YAML object" };
|
|
569
|
+
}
|
|
570
|
+
if (!parsed.icons || typeof parsed.icons !== "object") {
|
|
571
|
+
return { icons: null, error: "Icons file must have an 'icons' field" };
|
|
572
|
+
}
|
|
573
|
+
const resolved = await resolveIcons(parsed.icons, iconsFilePath);
|
|
574
|
+
return { icons: resolved, error: null };
|
|
575
|
+
} catch (err) {
|
|
576
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
577
|
+
return { icons: null, error: message };
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// src/commands/serve/messages.ts
|
|
582
|
+
var INVALID_MESSAGE_ERROR = "Invalid message format";
|
|
583
|
+
function sendMessage(ws, message) {
|
|
584
|
+
ws.send(JSON.stringify(message));
|
|
585
|
+
}
|
|
586
|
+
function sendError(ws, message) {
|
|
587
|
+
sendMessage(ws, { type: "error", message });
|
|
588
|
+
}
|
|
589
|
+
function sendErrorAndClose(ws, message) {
|
|
590
|
+
ws.send(JSON.stringify({ type: "error", message }), () => {
|
|
591
|
+
try {
|
|
592
|
+
ws.close();
|
|
593
|
+
} catch {
|
|
594
|
+
}
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
function sendWelcome(ws, cliVersion, protocolVersion) {
|
|
598
|
+
sendMessage(ws, {
|
|
599
|
+
type: "welcome",
|
|
600
|
+
cliVersion,
|
|
601
|
+
protocolVersion
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
function sendFull(ws, ir, rev) {
|
|
605
|
+
sendMessage(ws, { type: "full", rev, ir });
|
|
606
|
+
}
|
|
607
|
+
function sendIcons(ws, icons) {
|
|
608
|
+
sendMessage(ws, { type: "icons", icons });
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// src/commands/serve/patch.ts
|
|
612
|
+
function computePatchMessage(prevIR, baseRev, nextIR) {
|
|
613
|
+
const ops = diff(prevIR, nextIR);
|
|
614
|
+
if (ops.length === 0) return null;
|
|
615
|
+
return { type: "patch", baseRev, nextRev: baseRev + 1, ops };
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// src/commands/serve/watcher.ts
|
|
619
|
+
import { watch } from "fs";
|
|
620
|
+
var FileWatcher = class {
|
|
621
|
+
watcher = null;
|
|
622
|
+
debounceTimer = null;
|
|
623
|
+
debounceMs;
|
|
624
|
+
constructor(options = {}) {
|
|
625
|
+
this.debounceMs = options.debounceMs ?? 200;
|
|
626
|
+
}
|
|
627
|
+
watch(filePath, onChange) {
|
|
628
|
+
this.close();
|
|
629
|
+
try {
|
|
630
|
+
this.watcher = watch(filePath, () => {
|
|
631
|
+
this.scheduleCallback(onChange);
|
|
632
|
+
});
|
|
633
|
+
console.log(`Watching: ${filePath}`);
|
|
634
|
+
} catch (err) {
|
|
635
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
636
|
+
console.warn(`Warning: failed to watch ${filePath}: ${message}`);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
scheduleCallback(callback) {
|
|
640
|
+
if (this.debounceTimer) {
|
|
641
|
+
clearTimeout(this.debounceTimer);
|
|
642
|
+
}
|
|
643
|
+
this.debounceTimer = setTimeout(callback, this.debounceMs);
|
|
644
|
+
}
|
|
645
|
+
close() {
|
|
646
|
+
if (this.debounceTimer) {
|
|
647
|
+
clearTimeout(this.debounceTimer);
|
|
648
|
+
this.debounceTimer = null;
|
|
649
|
+
}
|
|
650
|
+
if (this.watcher) {
|
|
651
|
+
this.watcher.close();
|
|
652
|
+
this.watcher = null;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
isWatching() {
|
|
656
|
+
return this.watcher !== null;
|
|
657
|
+
}
|
|
658
|
+
};
|
|
659
|
+
var IconsWatcher = class {
|
|
660
|
+
watcher = null;
|
|
661
|
+
debounceTimer = null;
|
|
662
|
+
currentPath = null;
|
|
663
|
+
debounceMs;
|
|
664
|
+
constructor(options = {}) {
|
|
665
|
+
this.debounceMs = options.debounceMs ?? 200;
|
|
666
|
+
}
|
|
667
|
+
update(filePath, onChange) {
|
|
668
|
+
const shouldReset = this.currentPath !== filePath;
|
|
669
|
+
this.currentPath = filePath;
|
|
670
|
+
if (!shouldReset && this.watcher) return;
|
|
671
|
+
this.close();
|
|
672
|
+
if (!filePath) return;
|
|
673
|
+
try {
|
|
674
|
+
this.watcher = watch(filePath, () => {
|
|
675
|
+
this.scheduleCallback(onChange);
|
|
676
|
+
});
|
|
677
|
+
console.log(`Watching icons: ${filePath}`);
|
|
678
|
+
} catch (err) {
|
|
679
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
680
|
+
console.warn(`[icons] Warning: failed to watch ${filePath}: ${message}`);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
scheduleCallback(callback) {
|
|
684
|
+
if (this.debounceTimer) {
|
|
685
|
+
clearTimeout(this.debounceTimer);
|
|
686
|
+
}
|
|
687
|
+
this.debounceTimer = setTimeout(callback, this.debounceMs);
|
|
688
|
+
}
|
|
689
|
+
close() {
|
|
690
|
+
if (this.debounceTimer) {
|
|
691
|
+
clearTimeout(this.debounceTimer);
|
|
692
|
+
this.debounceTimer = null;
|
|
693
|
+
}
|
|
694
|
+
if (this.watcher) {
|
|
695
|
+
this.watcher.close();
|
|
696
|
+
this.watcher = null;
|
|
697
|
+
}
|
|
698
|
+
this.currentPath = null;
|
|
699
|
+
}
|
|
700
|
+
getCurrentPath() {
|
|
701
|
+
return this.currentPath;
|
|
702
|
+
}
|
|
703
|
+
};
|
|
704
|
+
|
|
705
|
+
// src/commands/serve/websocket.ts
|
|
706
|
+
import { WebSocketServer } from "ws";
|
|
707
|
+
function isValidClientMessage(msg) {
|
|
708
|
+
return typeof msg === "object" && msg !== null && typeof msg.type === "string";
|
|
709
|
+
}
|
|
710
|
+
var WebSocketManager = class {
|
|
711
|
+
wss;
|
|
712
|
+
clients = /* @__PURE__ */ new Map();
|
|
713
|
+
options;
|
|
714
|
+
state;
|
|
715
|
+
constructor(httpServer, options, state) {
|
|
716
|
+
this.wss = new WebSocketServer({ server: httpServer });
|
|
717
|
+
this.options = options;
|
|
718
|
+
this.state = state;
|
|
719
|
+
this.setupConnectionHandler();
|
|
720
|
+
}
|
|
721
|
+
setupConnectionHandler() {
|
|
722
|
+
this.wss.on("connection", (ws, _req) => {
|
|
723
|
+
this.clients.set(ws, {
|
|
724
|
+
docId: null,
|
|
725
|
+
authenticated: !this.options.secret
|
|
726
|
+
});
|
|
727
|
+
console.log("Client connected");
|
|
728
|
+
ws.on("message", (data) => this.handleMessage(ws, data));
|
|
729
|
+
ws.on("close", () => this.handleClose(ws));
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
handleMessage(ws, data) {
|
|
733
|
+
const clientState = this.clients.get(ws);
|
|
734
|
+
if (!clientState) return;
|
|
735
|
+
try {
|
|
736
|
+
const raw = JSON.parse(String(data));
|
|
737
|
+
if (!isValidClientMessage(raw)) {
|
|
738
|
+
sendError(ws, INVALID_MESSAGE_ERROR);
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
const msgType = raw.type;
|
|
742
|
+
if (msgType === "hello") {
|
|
743
|
+
this.handleHello(ws, clientState, raw);
|
|
744
|
+
} else if (msgType === "requestFull") {
|
|
745
|
+
this.handleRequestFull(ws, clientState, raw);
|
|
746
|
+
}
|
|
747
|
+
} catch {
|
|
748
|
+
sendError(ws, INVALID_MESSAGE_ERROR);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
handleHello(ws, clientState, raw) {
|
|
752
|
+
if (this.options.secret && raw.secret !== this.options.secret) {
|
|
753
|
+
sendErrorAndClose(ws, "Invalid secret");
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
clientState.authenticated = true;
|
|
757
|
+
clientState.docId = raw.docId ?? null;
|
|
758
|
+
sendWelcome(ws, this.options.getCliVersion(), this.options.protocolVersion);
|
|
759
|
+
const currentIR = this.state.getCurrentIR();
|
|
760
|
+
const currentIcons = this.state.getCurrentIcons();
|
|
761
|
+
const currentRev = this.state.getCurrentRev();
|
|
762
|
+
if (currentIR && currentIR.docId === raw.docId) {
|
|
763
|
+
if (currentIcons && Object.keys(currentIcons).length > 0) {
|
|
764
|
+
sendIcons(ws, currentIcons);
|
|
765
|
+
console.log(`Sent ${countIcons(currentIcons)} custom icons to client`);
|
|
766
|
+
}
|
|
767
|
+
sendFull(ws, currentIR, currentRev);
|
|
768
|
+
console.log(`Sent full IR to client (rev ${currentRev})`);
|
|
769
|
+
} else {
|
|
770
|
+
sendError(ws, `docId mismatch: expected ${raw.docId}, got ${currentIR?.docId}`);
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
handleRequestFull(ws, clientState, raw) {
|
|
774
|
+
if (this.options.secret && !clientState.authenticated) {
|
|
775
|
+
sendError(ws, "Authentication required");
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
const currentIR = this.state.getCurrentIR();
|
|
779
|
+
const currentIcons = this.state.getCurrentIcons();
|
|
780
|
+
const currentRev = this.state.getCurrentRev();
|
|
781
|
+
if (currentIR && currentIR.docId === raw.docId) {
|
|
782
|
+
if (currentIcons && Object.keys(currentIcons).length > 0) {
|
|
783
|
+
sendIcons(ws, currentIcons);
|
|
784
|
+
}
|
|
785
|
+
sendFull(ws, currentIR, currentRev);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
handleClose(ws) {
|
|
789
|
+
this.clients.delete(ws);
|
|
790
|
+
console.log("Client disconnected");
|
|
791
|
+
}
|
|
792
|
+
broadcast(message) {
|
|
793
|
+
const data = JSON.stringify(message);
|
|
794
|
+
const currentIR = this.state.getCurrentIR();
|
|
795
|
+
for (const [ws, clientState] of this.clients.entries()) {
|
|
796
|
+
if (clientState.authenticated && clientState.docId === currentIR?.docId) {
|
|
797
|
+
try {
|
|
798
|
+
ws.send(data);
|
|
799
|
+
} catch {
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
onError(callback) {
|
|
805
|
+
this.wss.once("error", callback);
|
|
806
|
+
}
|
|
807
|
+
async close() {
|
|
808
|
+
return new Promise((resolve3) => this.wss.close(() => resolve3()));
|
|
809
|
+
}
|
|
810
|
+
};
|
|
811
|
+
|
|
812
|
+
// src/commands/serve.ts
|
|
813
|
+
var __dirname2 = dirname3(fileURLToPath2(import.meta.url));
|
|
814
|
+
var PROTOCOL_VERSION = 1;
|
|
815
|
+
function getCliVersion() {
|
|
816
|
+
try {
|
|
817
|
+
const pkg = JSON.parse(readFileSync(join2(__dirname2, "..", "package.json"), "utf-8"));
|
|
818
|
+
return pkg.version ?? "0.0.0";
|
|
819
|
+
} catch {
|
|
820
|
+
return "0.0.0";
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
async function serveCommand(inputFile, options) {
|
|
824
|
+
ensureFileExists(inputFile);
|
|
825
|
+
let currentIR = null;
|
|
826
|
+
let currentIcons = null;
|
|
827
|
+
let currentRev = 0;
|
|
828
|
+
const diagramWatcher = new FileWatcher();
|
|
829
|
+
const iconsWatcher = new IconsWatcher();
|
|
830
|
+
function resolveIconsFilePath() {
|
|
831
|
+
return options.iconsFile ?? findIconsFile(inputFile);
|
|
832
|
+
}
|
|
833
|
+
async function loadIcons() {
|
|
834
|
+
let inlineIcons = null;
|
|
835
|
+
let fileIcons = null;
|
|
836
|
+
try {
|
|
837
|
+
const content = await readDiagramFile(inputFile);
|
|
838
|
+
const parsed = parseDiagramYaml(content);
|
|
839
|
+
const dsl = validateDiagram(parsed);
|
|
840
|
+
if (dsl.icons && Object.keys(dsl.icons).length > 0) {
|
|
841
|
+
inlineIcons = await resolveIcons(dsl.icons, inputFile);
|
|
842
|
+
}
|
|
843
|
+
} catch {
|
|
844
|
+
}
|
|
845
|
+
const resolvedIconsFilePath = resolveIconsFilePath();
|
|
846
|
+
if (options.watch) {
|
|
847
|
+
iconsWatcher.update(resolvedIconsFilePath, handleIconsChange);
|
|
848
|
+
}
|
|
849
|
+
if (resolvedIconsFilePath) {
|
|
850
|
+
const result = await loadIconsFile(resolvedIconsFilePath);
|
|
851
|
+
if (result.error) {
|
|
852
|
+
console.warn(`[icons] Warning: ${result.error}`);
|
|
853
|
+
} else {
|
|
854
|
+
fileIcons = result.icons;
|
|
855
|
+
console.log(`Loaded icons: ${resolvedIconsFilePath}`);
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
const merged = mergeIconRegistries(fileIcons, inlineIcons);
|
|
859
|
+
const iconCount = countIcons(merged);
|
|
860
|
+
if (iconCount > 0) {
|
|
861
|
+
console.log(`Custom icons: ${iconCount} icons loaded`);
|
|
862
|
+
return merged;
|
|
863
|
+
}
|
|
864
|
+
return null;
|
|
865
|
+
}
|
|
866
|
+
async function updateIcons() {
|
|
867
|
+
const nextIcons = await loadIcons();
|
|
868
|
+
if (iconsEqual(currentIcons, nextIcons)) {
|
|
869
|
+
return false;
|
|
870
|
+
}
|
|
871
|
+
currentIcons = nextIcons;
|
|
872
|
+
const payload = normalizeIcons(nextIcons);
|
|
873
|
+
wsManager.broadcast({ type: "icons", icons: payload });
|
|
874
|
+
const iconCount = countIcons(payload);
|
|
875
|
+
if (iconCount > 0) {
|
|
876
|
+
console.log(`[icons] Synced ${iconCount} custom icons`);
|
|
877
|
+
} else {
|
|
878
|
+
console.log("[icons] Cleared custom icons");
|
|
879
|
+
}
|
|
880
|
+
return true;
|
|
881
|
+
}
|
|
882
|
+
async function handleIconsChange() {
|
|
883
|
+
const path = iconsWatcher.getCurrentPath();
|
|
884
|
+
if (path) {
|
|
885
|
+
console.log(`[icons] File changed: ${path}`);
|
|
886
|
+
}
|
|
887
|
+
await updateIcons();
|
|
888
|
+
}
|
|
889
|
+
async function handleFileChange() {
|
|
890
|
+
console.log(`File changed: ${inputFile}`);
|
|
891
|
+
const { ir: ir2, error: error2 } = await tryLoadDiagram(inputFile);
|
|
892
|
+
if (error2) {
|
|
893
|
+
console.error(`Parse error: ${error2}`);
|
|
894
|
+
wsManager.broadcast({ type: "error", message: error2 });
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
if (!ir2) return;
|
|
898
|
+
const patch = computePatchMessage(currentIR, currentRev, ir2);
|
|
899
|
+
currentIR = ir2;
|
|
900
|
+
const iconsUpdated = await updateIcons();
|
|
901
|
+
if (!patch) {
|
|
902
|
+
if (!iconsUpdated) {
|
|
903
|
+
console.log(`[rev ${currentRev}] No changes`);
|
|
904
|
+
}
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
currentRev = patch.nextRev;
|
|
908
|
+
console.log(`[rev ${currentRev}] ${patch.ops.length} changes`);
|
|
909
|
+
wsManager.broadcast(patch);
|
|
910
|
+
}
|
|
911
|
+
const { ir, error } = await tryLoadDiagram(inputFile);
|
|
912
|
+
if (error) {
|
|
913
|
+
throw new Error(error);
|
|
914
|
+
}
|
|
915
|
+
currentIR = ir;
|
|
916
|
+
console.log(`Loaded: ${inputFile} (docId: ${ir?.docId})`);
|
|
917
|
+
currentIcons = await loadIcons();
|
|
918
|
+
const bindHost = options.allowRemote ? "0.0.0.0" : options.host;
|
|
919
|
+
const httpServer = createServer((_req, res) => {
|
|
920
|
+
res.writeHead(200, { "Content-Type": "text/plain" });
|
|
921
|
+
res.end("figram WebSocket server");
|
|
922
|
+
});
|
|
923
|
+
const wsManager = new WebSocketManager(
|
|
924
|
+
httpServer,
|
|
925
|
+
{
|
|
926
|
+
secret: options.secret,
|
|
927
|
+
getCliVersion,
|
|
928
|
+
protocolVersion: PROTOCOL_VERSION
|
|
929
|
+
},
|
|
930
|
+
{
|
|
931
|
+
getCurrentIR: () => currentIR,
|
|
932
|
+
getCurrentIcons: () => currentIcons,
|
|
933
|
+
getCurrentRev: () => currentRev
|
|
934
|
+
}
|
|
935
|
+
);
|
|
936
|
+
let actualPort = options.port;
|
|
937
|
+
const wssErrorPromise = new Promise((_, reject) => {
|
|
938
|
+
wsManager.onError(reject);
|
|
939
|
+
});
|
|
940
|
+
try {
|
|
941
|
+
await Promise.race([
|
|
942
|
+
new Promise((resolve3, reject) => {
|
|
943
|
+
httpServer.once("error", reject);
|
|
944
|
+
httpServer.listen(options.port, bindHost, () => {
|
|
945
|
+
const address = httpServer.address();
|
|
946
|
+
if (address && typeof address === "object") {
|
|
947
|
+
actualPort = address.port;
|
|
948
|
+
}
|
|
949
|
+
resolve3();
|
|
950
|
+
});
|
|
951
|
+
}),
|
|
952
|
+
wssErrorPromise
|
|
953
|
+
]);
|
|
954
|
+
} catch (err) {
|
|
955
|
+
const e = err;
|
|
956
|
+
const code = e?.code;
|
|
957
|
+
if (code === "EADDRINUSE") {
|
|
958
|
+
throw new CliError(
|
|
959
|
+
`Port ${options.port} is already in use (try --port ${options.port + 1})`,
|
|
960
|
+
1
|
|
961
|
+
);
|
|
962
|
+
}
|
|
963
|
+
if (code === "EACCES" || code === "EPERM") {
|
|
964
|
+
throw new CliError(
|
|
965
|
+
`Permission denied listening on ${bindHost}:${options.port} (try a different --port)`,
|
|
966
|
+
1
|
|
967
|
+
);
|
|
968
|
+
}
|
|
969
|
+
throw e;
|
|
970
|
+
}
|
|
971
|
+
const connectHost = bindHost === "0.0.0.0" ? "127.0.0.1" : bindHost;
|
|
972
|
+
console.log(`WebSocket server listening on ws://${connectHost}:${actualPort}`);
|
|
973
|
+
if (bindHost === "0.0.0.0") {
|
|
974
|
+
console.log("Tip: for LAN clients, use this machine's IP address (not 0.0.0.0).");
|
|
975
|
+
}
|
|
976
|
+
if (options.watch) {
|
|
977
|
+
diagramWatcher.watch(inputFile, handleFileChange);
|
|
978
|
+
iconsWatcher.update(resolveIconsFilePath(), handleIconsChange);
|
|
979
|
+
}
|
|
980
|
+
const sigintHandler = () => {
|
|
981
|
+
console.log("\nShutting down...");
|
|
982
|
+
wsManager.close();
|
|
983
|
+
httpServer.close();
|
|
984
|
+
process.exit(0);
|
|
985
|
+
};
|
|
986
|
+
process.on("SIGINT", sigintHandler);
|
|
987
|
+
return {
|
|
988
|
+
port: actualPort,
|
|
989
|
+
close: async () => {
|
|
990
|
+
process.off("SIGINT", sigintHandler);
|
|
991
|
+
diagramWatcher.close();
|
|
992
|
+
iconsWatcher.close();
|
|
993
|
+
await wsManager.close();
|
|
994
|
+
await new Promise((resolve3) => httpServer.close(() => resolve3()));
|
|
995
|
+
}
|
|
996
|
+
};
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
// src/commands/version.ts
|
|
1000
|
+
import { existsSync as existsSync4, readFileSync as readFileSync2, writeFileSync } from "fs";
|
|
1001
|
+
import { dirname as dirname4, join as join3, resolve as resolve2 } from "path";
|
|
1002
|
+
import { fileURLToPath as fileURLToPath3 } from "url";
|
|
1003
|
+
var __dirname3 = dirname4(fileURLToPath3(import.meta.url));
|
|
1004
|
+
function findMonorepoRoot() {
|
|
1005
|
+
let dir = resolve2(__dirname3, "..", "..");
|
|
1006
|
+
for (let i = 0; i < 5; i++) {
|
|
1007
|
+
const pkgPath = join3(dir, "package.json");
|
|
1008
|
+
if (existsSync4(pkgPath)) {
|
|
1009
|
+
try {
|
|
1010
|
+
const pkg = JSON.parse(readFileSync2(pkgPath, "utf-8"));
|
|
1011
|
+
if (pkg.workspaces) {
|
|
1012
|
+
return dir;
|
|
1013
|
+
}
|
|
1014
|
+
} catch {
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
dir = dirname4(dir);
|
|
1018
|
+
}
|
|
1019
|
+
throw new CliError("Could not find monorepo root", 1);
|
|
1020
|
+
}
|
|
1021
|
+
function getPackages() {
|
|
1022
|
+
const root = findMonorepoRoot();
|
|
1023
|
+
const packagesDir = join3(root, "packages");
|
|
1024
|
+
const packages = [];
|
|
1025
|
+
const packageNames = ["core", "cli", "plugin"];
|
|
1026
|
+
for (const name of packageNames) {
|
|
1027
|
+
const pkgPath = join3(packagesDir, name, "package.json");
|
|
1028
|
+
if (existsSync4(pkgPath)) {
|
|
1029
|
+
try {
|
|
1030
|
+
const packageJson = JSON.parse(readFileSync2(pkgPath, "utf-8"));
|
|
1031
|
+
packages.push({
|
|
1032
|
+
name: packageJson.name,
|
|
1033
|
+
path: join3(packagesDir, name),
|
|
1034
|
+
version: packageJson.version,
|
|
1035
|
+
packageJson
|
|
1036
|
+
});
|
|
1037
|
+
} catch (err) {
|
|
1038
|
+
throw new CliError(`Failed to read ${pkgPath}: ${err}`, 1);
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
return packages;
|
|
1043
|
+
}
|
|
1044
|
+
function parseVersion(version) {
|
|
1045
|
+
const match = version.match(/^(\d+)\.(\d+)\.(\d+)/);
|
|
1046
|
+
if (!match) {
|
|
1047
|
+
throw new CliError(`Invalid version format: ${version}`, 1);
|
|
1048
|
+
}
|
|
1049
|
+
return {
|
|
1050
|
+
major: parseInt(match[1], 10),
|
|
1051
|
+
minor: parseInt(match[2], 10),
|
|
1052
|
+
patch: parseInt(match[3], 10)
|
|
1053
|
+
};
|
|
1054
|
+
}
|
|
1055
|
+
function bumpVersion(version, type) {
|
|
1056
|
+
const parsed = parseVersion(version);
|
|
1057
|
+
switch (type) {
|
|
1058
|
+
case "major":
|
|
1059
|
+
return `${parsed.major + 1}.0.0`;
|
|
1060
|
+
case "minor":
|
|
1061
|
+
return `${parsed.major}.${parsed.minor + 1}.0`;
|
|
1062
|
+
case "patch":
|
|
1063
|
+
return `${parsed.major}.${parsed.minor}.${parsed.patch + 1}`;
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
async function versionShowCommand() {
|
|
1067
|
+
const packages = getPackages();
|
|
1068
|
+
console.log("\nFigram Package Versions\n");
|
|
1069
|
+
console.log("Package Version");
|
|
1070
|
+
console.log("\u2500".repeat(40));
|
|
1071
|
+
for (const pkg of packages) {
|
|
1072
|
+
const name = pkg.name.padEnd(20);
|
|
1073
|
+
console.log(`${name} ${pkg.version}`);
|
|
1074
|
+
}
|
|
1075
|
+
console.log();
|
|
1076
|
+
}
|
|
1077
|
+
async function versionCheckCommand() {
|
|
1078
|
+
const packages = getPackages();
|
|
1079
|
+
const issues = [];
|
|
1080
|
+
console.log("\nChecking version consistency...\n");
|
|
1081
|
+
const versions = new Set(packages.map((p) => p.version));
|
|
1082
|
+
if (versions.size > 1) {
|
|
1083
|
+
console.log("Package versions differ (this may be intentional):");
|
|
1084
|
+
for (const pkg of packages) {
|
|
1085
|
+
console.log(` ${pkg.name}: ${pkg.version}`);
|
|
1086
|
+
}
|
|
1087
|
+
console.log();
|
|
1088
|
+
}
|
|
1089
|
+
const corePackage = packages.find((p) => p.name === "@figram/core");
|
|
1090
|
+
if (corePackage) {
|
|
1091
|
+
for (const pkg of packages) {
|
|
1092
|
+
if (pkg.name === "@figram/core") continue;
|
|
1093
|
+
const deps = { ...pkg.packageJson.dependencies, ...pkg.packageJson.devDependencies };
|
|
1094
|
+
const coreDep = deps["@figram/core"];
|
|
1095
|
+
if (coreDep && coreDep !== "workspace:*") {
|
|
1096
|
+
issues.push(`${pkg.name} has non-workspace dependency on @figram/core: ${coreDep}`);
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
for (const pkg of packages) {
|
|
1101
|
+
try {
|
|
1102
|
+
parseVersion(pkg.version);
|
|
1103
|
+
} catch {
|
|
1104
|
+
issues.push(`${pkg.name} has invalid version: ${pkg.version}`);
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
if (issues.length > 0) {
|
|
1108
|
+
console.log("Issues found:");
|
|
1109
|
+
for (const issue of issues) {
|
|
1110
|
+
console.log(` \u26A0 ${issue}`);
|
|
1111
|
+
}
|
|
1112
|
+
console.log();
|
|
1113
|
+
throw new CliError("Version check failed", 1);
|
|
1114
|
+
}
|
|
1115
|
+
console.log("\u2713 All version checks passed\n");
|
|
1116
|
+
}
|
|
1117
|
+
async function versionBumpCommand(type, options) {
|
|
1118
|
+
const allPackages = getPackages();
|
|
1119
|
+
let packagesToUpdate;
|
|
1120
|
+
if (options.packages) {
|
|
1121
|
+
const requestedNames = options.packages.split(",").map((n) => n.trim());
|
|
1122
|
+
packagesToUpdate = allPackages.filter((p) => {
|
|
1123
|
+
const shortName = p.name.replace("@figram/", "");
|
|
1124
|
+
return requestedNames.includes(p.name) || requestedNames.includes(shortName);
|
|
1125
|
+
});
|
|
1126
|
+
if (packagesToUpdate.length === 0) {
|
|
1127
|
+
throw new CliError(`No matching packages found for: ${options.packages}`, 1);
|
|
1128
|
+
}
|
|
1129
|
+
} else {
|
|
1130
|
+
packagesToUpdate = allPackages;
|
|
1131
|
+
}
|
|
1132
|
+
console.log(`
|
|
1133
|
+
Version bump: ${type}
|
|
1134
|
+
`);
|
|
1135
|
+
const updates = [];
|
|
1136
|
+
for (const pkg of packagesToUpdate) {
|
|
1137
|
+
const oldVersion = pkg.version;
|
|
1138
|
+
const newVersion = bumpVersion(oldVersion, type);
|
|
1139
|
+
updates.push({ pkg, oldVersion, newVersion });
|
|
1140
|
+
}
|
|
1141
|
+
console.log("Planned changes:");
|
|
1142
|
+
console.log("\u2500".repeat(50));
|
|
1143
|
+
for (const { pkg, oldVersion, newVersion } of updates) {
|
|
1144
|
+
console.log(` ${pkg.name}: ${oldVersion} \u2192 ${newVersion}`);
|
|
1145
|
+
}
|
|
1146
|
+
console.log();
|
|
1147
|
+
if (options.dryRun) {
|
|
1148
|
+
console.log("Dry run - no changes made\n");
|
|
1149
|
+
return;
|
|
1150
|
+
}
|
|
1151
|
+
for (const { pkg, newVersion } of updates) {
|
|
1152
|
+
const pkgJsonPath = join3(pkg.path, "package.json");
|
|
1153
|
+
const content = readFileSync2(pkgJsonPath, "utf-8");
|
|
1154
|
+
const updatedContent = content.replace(/"version":\s*"[^"]+"/, `"version": "${newVersion}"`);
|
|
1155
|
+
writeFileSync(pkgJsonPath, updatedContent);
|
|
1156
|
+
console.log(` \u2713 Updated ${pkg.name}`);
|
|
1157
|
+
}
|
|
1158
|
+
console.log("\nVersion bump complete!\n");
|
|
1159
|
+
console.log("Next steps:");
|
|
1160
|
+
console.log(" 1. Update CHANGELOG.md for each package");
|
|
1161
|
+
console.log(" 2. Run: bun run build && bun run check && bun test");
|
|
1162
|
+
console.log(" 3. Commit: git commit -am 'chore: bump version to X.Y.Z'");
|
|
1163
|
+
console.log(` 4. Tag: git tag v${updates[0]?.newVersion ?? "X.Y.Z"}`);
|
|
1164
|
+
console.log();
|
|
1165
|
+
}
|
|
1166
|
+
async function versionSyncCommand(targetVersion, options) {
|
|
1167
|
+
try {
|
|
1168
|
+
parseVersion(targetVersion);
|
|
1169
|
+
} catch {
|
|
1170
|
+
throw new CliError(`Invalid target version: ${targetVersion}`, 1);
|
|
1171
|
+
}
|
|
1172
|
+
const packages = getPackages();
|
|
1173
|
+
console.log(`
|
|
1174
|
+
Syncing all packages to version ${targetVersion}
|
|
1175
|
+
`);
|
|
1176
|
+
const updates = [];
|
|
1177
|
+
for (const pkg of packages) {
|
|
1178
|
+
if (pkg.version !== targetVersion) {
|
|
1179
|
+
updates.push({ pkg, oldVersion: pkg.version });
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
if (updates.length === 0) {
|
|
1183
|
+
console.log("All packages already at target version\n");
|
|
1184
|
+
return;
|
|
1185
|
+
}
|
|
1186
|
+
console.log("Planned changes:");
|
|
1187
|
+
console.log("\u2500".repeat(50));
|
|
1188
|
+
for (const { pkg, oldVersion } of updates) {
|
|
1189
|
+
console.log(` ${pkg.name}: ${oldVersion} \u2192 ${targetVersion}`);
|
|
1190
|
+
}
|
|
1191
|
+
console.log();
|
|
1192
|
+
if (options.dryRun) {
|
|
1193
|
+
console.log("Dry run - no changes made\n");
|
|
1194
|
+
return;
|
|
1195
|
+
}
|
|
1196
|
+
for (const { pkg } of updates) {
|
|
1197
|
+
const pkgJsonPath = join3(pkg.path, "package.json");
|
|
1198
|
+
const content = readFileSync2(pkgJsonPath, "utf-8");
|
|
1199
|
+
const updatedContent = content.replace(/"version":\s*"[^"]+"/, `"version": "${targetVersion}"`);
|
|
1200
|
+
writeFileSync(pkgJsonPath, updatedContent);
|
|
1201
|
+
console.log(` \u2713 Updated ${pkg.name}`);
|
|
1202
|
+
}
|
|
1203
|
+
console.log("\nVersion sync complete!\n");
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
// src/index.ts
|
|
1207
|
+
var __dirname4 = dirname5(fileURLToPath4(import.meta.url));
|
|
1208
|
+
function getVersion() {
|
|
1209
|
+
try {
|
|
1210
|
+
const pkg = JSON.parse(readFileSync3(join4(__dirname4, "..", "package.json"), "utf-8"));
|
|
1211
|
+
return pkg.version ?? "0.0.0";
|
|
1212
|
+
} catch {
|
|
1213
|
+
return "0.0.0";
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
function createProgram() {
|
|
1217
|
+
const program = new Command();
|
|
1218
|
+
program.name("figram").description("YAML-driven architecture diagrams for FigJam").version(getVersion(), "-v, --version", "Show version").showHelpAfterError().showSuggestionAfterError();
|
|
1219
|
+
program.command("init").description("Create a diagram.yaml template").action(async () => {
|
|
1220
|
+
await initCommand();
|
|
1221
|
+
});
|
|
1222
|
+
program.command("build").description("Build YAML to IR JSON").argument("[file]", "Input YAML file", "diagram.yaml").option("-o, --output <file>", "Output file path (default: input file with .json extension)").action(async (file, options) => {
|
|
1223
|
+
await buildCommand(file, options.output);
|
|
1224
|
+
});
|
|
1225
|
+
program.command("serve").description("Start WebSocket server with watch").argument("[file]", "Input YAML file", "diagram.yaml").option("-p, --port <port>", "WebSocket server port", "3456").option("--host <host>", "Host to bind", "127.0.0.1").option("--no-watch", "Disable file watching").option("--allow-remote", "Allow connections from remote hosts").option("--secret <secret>", "Require secret for connection").option("--icons <path>", "Path to icons configuration file (figram-icons.yaml)").action(
|
|
1226
|
+
async (file, options) => {
|
|
1227
|
+
const port = parseInt(options.port ?? "3456", 10);
|
|
1228
|
+
if (!Number.isFinite(port) || port < 1 || port > 65535) {
|
|
1229
|
+
throw new CliError(`Invalid port: ${options.port}. Use a value between 1 and 65535.`, 1);
|
|
1230
|
+
}
|
|
1231
|
+
await serveCommand(file, {
|
|
1232
|
+
port,
|
|
1233
|
+
host: options.host ?? "127.0.0.1",
|
|
1234
|
+
watch: options.watch ?? true,
|
|
1235
|
+
allowRemote: options.allowRemote ?? false,
|
|
1236
|
+
secret: options.secret,
|
|
1237
|
+
iconsFile: options.icons
|
|
1238
|
+
});
|
|
1239
|
+
}
|
|
1240
|
+
);
|
|
1241
|
+
const versionCmd = program.command("version").description("Manage package versions across the monorepo");
|
|
1242
|
+
versionCmd.command("show", { isDefault: true }).description("Show versions of all packages").action(async () => {
|
|
1243
|
+
await versionShowCommand();
|
|
1244
|
+
});
|
|
1245
|
+
versionCmd.command("check").description("Check version consistency across packages").action(async () => {
|
|
1246
|
+
await versionCheckCommand();
|
|
1247
|
+
});
|
|
1248
|
+
versionCmd.command("bump <type>").description("Bump versions (major, minor, or patch)").option("-p, --packages <names>", "Comma-separated package names to update (default: all)").option("-n, --dry-run", "Show what would be changed without making changes").action(async (type, options) => {
|
|
1249
|
+
if (!["major", "minor", "patch"].includes(type)) {
|
|
1250
|
+
throw new CliError(`Invalid bump type: ${type}. Use major, minor, or patch.`, 1);
|
|
1251
|
+
}
|
|
1252
|
+
await versionBumpCommand(type, options);
|
|
1253
|
+
});
|
|
1254
|
+
versionCmd.command("sync <version>").description("Sync all packages to a specific version").option("-n, --dry-run", "Show what would be changed without making changes").action(async (version, options) => {
|
|
1255
|
+
await versionSyncCommand(version, options);
|
|
1256
|
+
});
|
|
1257
|
+
return program;
|
|
1258
|
+
}
|
|
1259
|
+
async function main() {
|
|
1260
|
+
const program = createProgram();
|
|
1261
|
+
if (process.argv.slice(2).length === 0) {
|
|
1262
|
+
program.outputHelp();
|
|
1263
|
+
return;
|
|
1264
|
+
}
|
|
1265
|
+
await program.parseAsync(process.argv);
|
|
1266
|
+
}
|
|
1267
|
+
main().catch((err) => {
|
|
1268
|
+
if (err instanceof CliError) {
|
|
1269
|
+
console.error(`Error: ${err.message}`);
|
|
1270
|
+
process.exit(err.exitCode);
|
|
1271
|
+
}
|
|
1272
|
+
console.error("Error:", err.message);
|
|
1273
|
+
process.exit(1);
|
|
1274
|
+
});
|