archbyte 0.3.5 → 0.4.1

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.
Files changed (45) hide show
  1. package/README.md +42 -0
  2. package/bin/archbyte.js +26 -25
  3. package/dist/agents/pipeline/merger.d.ts +2 -2
  4. package/dist/agents/pipeline/merger.js +165 -35
  5. package/dist/agents/pipeline/types.d.ts +29 -1
  6. package/dist/agents/pipeline/types.js +0 -1
  7. package/dist/agents/providers/claude-sdk.d.ts +7 -0
  8. package/dist/agents/providers/claude-sdk.js +83 -0
  9. package/dist/agents/providers/router.d.ts +5 -0
  10. package/dist/agents/providers/router.js +23 -1
  11. package/dist/agents/runtime/types.d.ts +6 -2
  12. package/dist/agents/runtime/types.js +6 -1
  13. package/dist/agents/static/component-detector.js +35 -3
  14. package/dist/agents/static/connection-mapper.d.ts +1 -1
  15. package/dist/agents/static/connection-mapper.js +74 -1
  16. package/dist/agents/static/index.js +5 -2
  17. package/dist/agents/static/types.d.ts +26 -0
  18. package/dist/cli/analyze.js +65 -18
  19. package/dist/cli/arch-diff.d.ts +38 -0
  20. package/dist/cli/arch-diff.js +61 -0
  21. package/dist/cli/auth.d.ts +8 -2
  22. package/dist/cli/auth.js +241 -31
  23. package/dist/cli/config.js +31 -5
  24. package/dist/cli/export.js +64 -2
  25. package/dist/cli/patrol.d.ts +5 -3
  26. package/dist/cli/patrol.js +417 -65
  27. package/dist/cli/setup.js +76 -8
  28. package/dist/cli/shared.d.ts +11 -0
  29. package/dist/cli/shared.js +61 -0
  30. package/dist/cli/ui.d.ts +9 -0
  31. package/dist/cli/ui.js +59 -5
  32. package/dist/cli/validate.d.ts +0 -1
  33. package/dist/cli/validate.js +0 -16
  34. package/dist/server/src/index.js +593 -19
  35. package/package.json +4 -1
  36. package/templates/archbyte.yaml +8 -0
  37. package/ui/dist/assets/index-DDCNauh7.css +1 -0
  38. package/ui/dist/assets/index-DO4t5Xu1.js +72 -0
  39. package/ui/dist/index.html +2 -2
  40. package/dist/cli/mcp-server.d.ts +0 -1
  41. package/dist/cli/mcp-server.js +0 -443
  42. package/dist/cli/mcp.d.ts +0 -1
  43. package/dist/cli/mcp.js +0 -98
  44. package/ui/dist/assets/index-0_XpUUZQ.css +0 -1
  45. package/ui/dist/assets/index-BTo0zV5E.js +0 -70
@@ -14,6 +14,9 @@ const UI_DIST = path.resolve(__dirname, "../../../ui/dist");
14
14
  let config;
15
15
  let sseClients = new Set();
16
16
  let diagramWatcher = null;
17
+ let isAnalyzing = false;
18
+ const sessionChanges = [];
19
+ const MAX_SESSION_CHANGES = 50;
17
20
  let currentArchitecture = null;
18
21
  // Process tracking for run-from-UI
19
22
  const runningWorkflows = new Map();
@@ -103,7 +106,7 @@ function createHttpServer() {
103
106
  const reqOrigin = req.headers.origin || "";
104
107
  const allowedOrigin = /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(reqOrigin) ? reqOrigin : `http://localhost:${config.port}`;
105
108
  res.setHeader("Access-Control-Allow-Origin", allowedOrigin);
106
- res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
109
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, OPTIONS");
107
110
  res.setHeader("Access-Control-Allow-Headers", "Content-Type");
108
111
  res.setHeader("X-Content-Type-Options", "nosniff");
109
112
  res.setHeader("X-Frame-Options", "SAMEORIGIN");
@@ -254,8 +257,81 @@ function createHttpServer() {
254
257
  }));
255
258
  return;
256
259
  }
257
- // API: Validate
258
- if (url === "/api/validate" && req.method === "GET") {
260
+ // API: Impact analysis — blast radius BFS from a node
261
+ if (url.startsWith("/api/impact/") && req.method === "GET") {
262
+ const arch = currentArchitecture || (await loadArchitecture());
263
+ if (!arch) {
264
+ res.writeHead(404, { "Content-Type": "application/json" });
265
+ res.end(JSON.stringify({ error: "No architecture loaded" }));
266
+ return;
267
+ }
268
+ const urlParts = url.split("?");
269
+ const nodeId = decodeURIComponent(urlParts[0].replace("/api/impact/", ""));
270
+ const params = new URL(url, `http://localhost:${config.port}`).searchParams;
271
+ const depth = Math.min(Math.max(parseInt(params.get("depth") || "2", 10) || 2, 1), 5);
272
+ const direction = params.get("direction") || "both";
273
+ // Build adjacency lists
274
+ const forward = new Map();
275
+ const reverse = new Map();
276
+ for (const nd of arch.nodes) {
277
+ forward.set(nd.id, []);
278
+ reverse.set(nd.id, []);
279
+ }
280
+ for (const edge of arch.edges) {
281
+ forward.get(edge.source)?.push(edge.target);
282
+ reverse.get(edge.target)?.push(edge.source);
283
+ }
284
+ const nodeMap = new Map();
285
+ for (const nd of arch.nodes)
286
+ nodeMap.set(nd.id, nd);
287
+ // BFS helper
288
+ function bfs(startId, adj, maxDepth) {
289
+ const visited = new Map();
290
+ visited.set(startId, 0);
291
+ const queue = [{ id: startId, d: 0 }];
292
+ const result = [];
293
+ while (queue.length > 0) {
294
+ const { id, d } = queue.shift();
295
+ if (d >= maxDepth)
296
+ continue;
297
+ for (const neighbor of adj.get(id) || []) {
298
+ if (!visited.has(neighbor)) {
299
+ const nd = d + 1;
300
+ visited.set(neighbor, nd);
301
+ result.push({ id: neighbor, label: nodeMap.get(neighbor)?.label.split("\n")[0] || neighbor, depth: nd });
302
+ queue.push({ id: neighbor, d: nd });
303
+ }
304
+ }
305
+ }
306
+ return result;
307
+ }
308
+ const upstream = (direction === "downstream") ? [] : bfs(nodeId, reverse, depth);
309
+ const downstream = (direction === "upstream") ? [] : bfs(nodeId, forward, depth);
310
+ // Collect all affected node IDs (including origin)
311
+ const affectedSet = new Set([nodeId]);
312
+ for (const n of upstream)
313
+ affectedSet.add(n.id);
314
+ for (const n of downstream)
315
+ affectedSet.add(n.id);
316
+ // Affected edges: edges between any two nodes in the affected set
317
+ const affectedEdges = [];
318
+ for (const edge of arch.edges) {
319
+ if (affectedSet.has(edge.source) && affectedSet.has(edge.target)) {
320
+ affectedEdges.push(edge.id);
321
+ }
322
+ }
323
+ res.writeHead(200, { "Content-Type": "application/json" });
324
+ res.end(JSON.stringify({
325
+ nodeId,
326
+ upstream,
327
+ downstream,
328
+ affectedEdges,
329
+ totalAffected: affectedSet.size - 1,
330
+ }));
331
+ return;
332
+ }
333
+ // API: Audit — unified validation + health endpoint
334
+ if ((url === "/api/audit" || url === "/api/validate") && req.method === "GET") {
259
335
  const arch = currentArchitecture || (await loadArchitecture());
260
336
  if (!arch) {
261
337
  res.writeHead(404, { "Content-Type": "application/json" });
@@ -282,6 +358,7 @@ function createHttpServer() {
282
358
  level: "error",
283
359
  message: `"${src.label}" (${src.layer}) -> "${tgt.label}" (${tgt.layer})`,
284
360
  nodeIds: [src.id, tgt.id],
361
+ ruleType: "builtin",
285
362
  });
286
363
  }
287
364
  }
@@ -299,6 +376,7 @@ function createHttpServer() {
299
376
  level: "warn",
300
377
  message: `"${nd.label}" has ${count} connections (threshold: 6)`,
301
378
  nodeIds: [nd.id],
379
+ ruleType: "builtin",
302
380
  });
303
381
  }
304
382
  }
@@ -315,6 +393,7 @@ function createHttpServer() {
315
393
  level: "warn",
316
394
  message: `"${nd.label}" has no connections`,
317
395
  nodeIds: [nd.id],
396
+ ruleType: "builtin",
318
397
  });
319
398
  }
320
399
  }
@@ -356,19 +435,288 @@ function createHttpServer() {
356
435
  level: "error",
357
436
  message: `Cycle: ${cycle.map((id) => nodeMap.get(id)?.label || id).join(" -> ")}`,
358
437
  nodeIds: cycle.filter((id, i, arr) => arr.indexOf(id) === i),
438
+ ruleType: "builtin",
359
439
  });
360
440
  }
441
+ // Custom rules from archbyte.yaml
442
+ try {
443
+ const yamlPath = path.join(config.workspaceRoot, ".archbyte/archbyte.yaml");
444
+ if (existsSync(yamlPath)) {
445
+ const yamlContent = readFileSync(yamlPath, "utf-8");
446
+ const rulesMatch = yamlContent.match(/^rules:\s*\n((?:\s+.*\n)*)/m);
447
+ if (rulesMatch) {
448
+ const rulesBlock = rulesMatch[1];
449
+ const ruleRegex = /^\s+-\s+name:\s*"?([^"\n]+)"?\s*\n\s+from:\s*"?([^"\n]+)"?\s*\n\s+to:\s*"?([^"\n]+)"?\s*\n\s+level:\s*"?([^"\n]+)"?/gm;
450
+ let m;
451
+ const customRules = [];
452
+ while ((m = ruleRegex.exec(rulesBlock)) !== null) {
453
+ customRules.push({ name: m[1].trim(), from: m[2].trim(), to: m[3].trim(), level: m[4].trim() });
454
+ }
455
+ // Helper: check if a node matches a custom rule matcher (type:xxx, layer:xxx, or exact id)
456
+ const matchesNode = (nd, matcher) => {
457
+ if (matcher.startsWith("type:"))
458
+ return nd.type === matcher.slice(5);
459
+ if (matcher.startsWith("layer:"))
460
+ return nd.layer === matcher.slice(6);
461
+ return nd.id === matcher;
462
+ };
463
+ for (const rule of customRules) {
464
+ for (const edge of arch.edges) {
465
+ const src = nodeMap.get(edge.source);
466
+ const tgt = nodeMap.get(edge.target);
467
+ if (!src || !tgt)
468
+ continue;
469
+ if (matchesNode(src, rule.from) && matchesNode(tgt, rule.to)) {
470
+ violations.push({
471
+ rule: rule.name,
472
+ level: rule.level === "error" ? "error" : "warn",
473
+ message: `"${src.label}" -> "${tgt.label}" violates custom rule "${rule.name}"`,
474
+ nodeIds: [src.id, tgt.id],
475
+ ruleType: "custom",
476
+ });
477
+ }
478
+ }
479
+ }
480
+ }
481
+ }
482
+ }
483
+ catch { /* ignore custom rule errors */ }
484
+ // === STRUCTURAL ISSUES ===
485
+ // Build degree maps for health metrics
486
+ const inDeg = new Map();
487
+ const outDeg = new Map();
488
+ const connCount = new Map();
489
+ const fwdAdj = new Map();
490
+ for (const nd of realNodes) {
491
+ inDeg.set(nd.id, 0);
492
+ outDeg.set(nd.id, 0);
493
+ connCount.set(nd.id, 0);
494
+ fwdAdj.set(nd.id, []);
495
+ }
496
+ for (const edge of arch.edges) {
497
+ outDeg.set(edge.source, (outDeg.get(edge.source) || 0) + 1);
498
+ inDeg.set(edge.target, (inDeg.get(edge.target) || 0) + 1);
499
+ connCount.set(edge.source, (connCount.get(edge.source) || 0) + 1);
500
+ connCount.set(edge.target, (connCount.get(edge.target) || 0) + 1);
501
+ fwdAdj.get(edge.source)?.push(edge.target);
502
+ }
503
+ // DFS cycle detection for metrics
504
+ const visitedH = new Set();
505
+ const inStackH = new Set();
506
+ const nodesInCycles = new Set();
507
+ let circularDependenciesCount = 0;
508
+ function dfsHealth(nodeId) {
509
+ visitedH.add(nodeId);
510
+ inStackH.add(nodeId);
511
+ for (const neighbor of fwdAdj.get(nodeId) || []) {
512
+ if (!visitedH.has(neighbor)) {
513
+ dfsHealth(neighbor);
514
+ }
515
+ else if (inStackH.has(neighbor)) {
516
+ nodesInCycles.add(neighbor);
517
+ nodesInCycles.add(nodeId);
518
+ circularDependenciesCount++;
519
+ }
520
+ }
521
+ inStackH.delete(nodeId);
522
+ }
523
+ for (const nd of realNodes) {
524
+ if (!visitedH.has(nd.id))
525
+ dfsHealth(nd.id);
526
+ }
527
+ // Health nodes
528
+ const totalConnections = Array.from(connCount.values()).reduce((a, b) => a + b, 0);
529
+ const avgConn = realNodes.length > 0 ? totalConnections / realNodes.length : 0;
530
+ const maxConn = Math.max(...Array.from(connCount.values()), 1);
531
+ const healthNodes = realNodes.map((nd) => {
532
+ const cc = connCount.get(nd.id) || 0;
533
+ const isOrphan = cc === 0;
534
+ const isHub = cc > 6;
535
+ const inCycle = nodesInCycles.has(nd.id);
536
+ const normalizedFanInOutRatio = maxConn > 0 ? Math.round((cc / maxConn) * 10 * 10) / 10 : 0;
537
+ let modularityIndex = 100;
538
+ if (isOrphan)
539
+ modularityIndex -= 30;
540
+ if (isHub)
541
+ modularityIndex -= 20;
542
+ if (inCycle)
543
+ modularityIndex -= 25;
544
+ if (avgConn > 0 && cc > avgConn * 1.5)
545
+ modularityIndex -= Math.min(25, Math.round(((cc - avgConn) / avgConn) * 25));
546
+ modularityIndex = Math.max(0, modularityIndex);
547
+ return {
548
+ id: nd.id,
549
+ label: nd.label.split("\n")[0],
550
+ connectionCount: cc,
551
+ inDegree: inDeg.get(nd.id) || 0,
552
+ outDegree: outDeg.get(nd.id) || 0,
553
+ isOrphan,
554
+ isHub,
555
+ inCycle,
556
+ normalizedFanInOutRatio,
557
+ modularityIndex,
558
+ couplingScore: normalizedFanInOutRatio,
559
+ healthScore: modularityIndex,
560
+ };
561
+ });
562
+ // Calculate metrics
563
+ const disconnectedComponentCount = healthNodes.filter((n) => n.isOrphan).length;
564
+ const meanCouplingRatio = avgConn > 0 ? Math.round((totalConnections / realNodes.length / maxConn) * 10 * 10) / 10 : 0;
565
+ const maxFanInOutDegree = maxConn;
566
+ const disconnectionRatio = realNodes.length > 0 ? Math.round((disconnectedComponentCount / realNodes.length) * 100) : 0;
567
+ const highDegreeHubCount = healthNodes.filter((n) => n.isHub).length;
568
+ // Convert violations to issues
569
+ const ruleViolations = violations.map((v) => ({
570
+ type: 'rule-violation',
571
+ rule: v.rule,
572
+ level: v.level,
573
+ message: v.message,
574
+ nodeIds: v.nodeIds,
575
+ ruleType: v.ruleType,
576
+ }));
577
+ // Generate structural issues from metrics
578
+ const structuralIssues = [];
579
+ if (circularDependenciesCount > 0) {
580
+ for (const nodeId of Array.from(nodesInCycles).slice(0, 5)) {
581
+ structuralIssues.push({
582
+ type: 'structural',
583
+ metric: 'circular-deps',
584
+ level: 'warn',
585
+ message: `${nodeMap.get(nodeId)?.label || nodeId} is part of a circular dependency`,
586
+ nodeIds: [nodeId],
587
+ });
588
+ }
589
+ }
590
+ for (const node of healthNodes) {
591
+ if (node.isHub) {
592
+ structuralIssues.push({
593
+ type: 'structural',
594
+ metric: 'high-hub',
595
+ level: 'warn',
596
+ message: `${node.label} is a high-degree hub with ${node.connectionCount} connections`,
597
+ nodeIds: [node.id],
598
+ });
599
+ }
600
+ if (node.isOrphan) {
601
+ structuralIssues.push({
602
+ type: 'structural',
603
+ metric: 'orphan',
604
+ level: 'warn',
605
+ message: `${node.label} has no connections (orphan component)`,
606
+ nodeIds: [node.id],
607
+ });
608
+ }
609
+ }
610
+ const allIssues = [...ruleViolations, ...structuralIssues];
611
+ const ruleViolationCount = ruleViolations.length;
612
+ const structuralIssueCount = structuralIssues.length;
361
613
  const errors = violations.filter((v) => v.level === "error").length;
362
- const warnings = violations.filter((v) => v.level === "warn").length;
363
614
  res.writeHead(200, { "Content-Type": "application/json" });
364
615
  res.end(JSON.stringify({
365
616
  passed: errors === 0,
366
- errors,
367
- warnings,
368
- violations,
617
+ issues: allIssues,
618
+ summary: {
619
+ ruleViolations: ruleViolationCount,
620
+ structuralIssues: structuralIssueCount,
621
+ totalIssues: allIssues.length,
622
+ },
623
+ metrics: {
624
+ circularDependencies: circularDependenciesCount,
625
+ highDegreeHubCount,
626
+ disconnectedComponentCount,
627
+ meanCouplingRatio,
628
+ maxFanInOutDegree,
629
+ },
630
+ nodes: healthNodes,
631
+ rules: {
632
+ builtin: [
633
+ { name: "no-layer-bypass", level: "error", description: "Prevent connections that skip layers" },
634
+ { name: "max-connections", level: "warn", description: "Flag components with too many connections" },
635
+ { name: "no-orphans", level: "warn", description: "Flag components with no connections" },
636
+ { name: "no-circular-deps", level: "error", description: "Detect circular dependency chains" },
637
+ ],
638
+ custom: [],
639
+ },
369
640
  }));
370
641
  return;
371
642
  }
643
+ // API: Health (deprecated - redirect to audit for backward compat)
644
+ if (url === "/api/health" && req.method === "GET") {
645
+ res.writeHead(307, { "Location": "/api/audit" });
646
+ res.end();
647
+ return;
648
+ }
649
+ // API: Rules — GET returns current rules config
650
+ if (url === "/api/rules" && req.method === "GET") {
651
+ const builtin = [
652
+ { name: "no-layer-bypass", level: "error", description: "Prevent connections that skip layers" },
653
+ { name: "max-connections", level: "warn", threshold: 6, description: "Flag components with too many connections" },
654
+ { name: "no-orphans", level: "warn", description: "Flag components with no connections" },
655
+ { name: "no-circular-deps", level: "error", description: "Detect circular dependency chains" },
656
+ ];
657
+ let custom = [];
658
+ try {
659
+ const yamlPath = path.join(config.workspaceRoot, ".archbyte/archbyte.yaml");
660
+ if (existsSync(yamlPath)) {
661
+ const yamlContent = readFileSync(yamlPath, "utf-8");
662
+ // Parse custom rules from YAML
663
+ const rulesMatch = yamlContent.match(/^rules:\s*\n((?:\s+.*\n)*)/m);
664
+ if (rulesMatch) {
665
+ const rulesBlock = rulesMatch[1];
666
+ const ruleRegex = /^\s+-\s+name:\s*"?([^"\n]+)"?\s*\n\s+from:\s*"?([^"\n]+)"?\s*\n\s+to:\s*"?([^"\n]+)"?\s*\n\s+level:\s*"?([^"\n]+)"?/gm;
667
+ let m;
668
+ while ((m = ruleRegex.exec(rulesBlock)) !== null) {
669
+ custom.push({ name: m[1].trim(), from: m[2].trim(), to: m[3].trim(), level: m[4].trim() });
670
+ }
671
+ }
672
+ }
673
+ }
674
+ catch { /* ignore */ }
675
+ res.writeHead(200, { "Content-Type": "application/json" });
676
+ res.end(JSON.stringify({ builtin, custom }));
677
+ return;
678
+ }
679
+ // API: Rules — PUT saves rules config
680
+ if (url === "/api/rules" && req.method === "PUT") {
681
+ let body = "";
682
+ req.on("data", (chunk) => { body += chunk.toString(); });
683
+ req.on("end", async () => {
684
+ try {
685
+ const { custom } = JSON.parse(body);
686
+ const yamlDir = path.join(config.workspaceRoot, ".archbyte");
687
+ if (!existsSync(yamlDir))
688
+ await mkdir(yamlDir, { recursive: true });
689
+ const yamlPath = path.join(yamlDir, "archbyte.yaml");
690
+ let yamlContent = "";
691
+ if (existsSync(yamlPath)) {
692
+ yamlContent = readFileSync(yamlPath, "utf-8");
693
+ }
694
+ // Remove existing rules section
695
+ yamlContent = yamlContent.replace(/^rules:\s*\n((?:\s+.*\n)*)/m, "");
696
+ // Build new rules section
697
+ if (custom && custom.length > 0) {
698
+ let rulesYaml = "rules:\n";
699
+ for (const rule of custom) {
700
+ // Validate rule fields to prevent YAML injection
701
+ const safeName = rule.name.replace(/["\n\r]/g, "");
702
+ const safeFrom = rule.from.replace(/["\n\r]/g, "");
703
+ const safeTo = rule.to.replace(/["\n\r]/g, "");
704
+ const safeLevel = (rule.level === "error" || rule.level === "warn") ? rule.level : "warn";
705
+ rulesYaml += ` - name: "${safeName}"\n from: "${safeFrom}"\n to: "${safeTo}"\n level: "${safeLevel}"\n`;
706
+ }
707
+ yamlContent = yamlContent.trimEnd() + "\n\n" + rulesYaml;
708
+ }
709
+ await writeFile(yamlPath, yamlContent.trimEnd() + "\n");
710
+ res.writeHead(200, { "Content-Type": "application/json" });
711
+ res.end(JSON.stringify({ ok: true }));
712
+ }
713
+ catch (error) {
714
+ res.writeHead(500, { "Content-Type": "application/json" });
715
+ res.end(JSON.stringify({ error: String(error) }));
716
+ }
717
+ });
718
+ return;
719
+ }
372
720
  // API: Export architecture as text format
373
721
  if (url.startsWith("/api/export") && req.method === "GET") {
374
722
  const arch = currentArchitecture || (await loadArchitecture());
@@ -379,12 +727,21 @@ function createHttpServer() {
379
727
  }
380
728
  const params = new URL(url, `http://localhost:${config.port}`).searchParams;
381
729
  const format = params.get("format") || "mermaid";
382
- const supported = ["mermaid", "markdown", "json", "plantuml", "dot"];
730
+ const supported = ["mermaid", "markdown", "json", "plantuml", "dot", "html"];
383
731
  if (!supported.includes(format)) {
384
732
  res.writeHead(400, { "Content-Type": "application/json" });
385
733
  res.end(JSON.stringify({ error: `Unknown format: ${format}. Supported: ${supported.join(", ")}` }));
386
734
  return;
387
735
  }
736
+ // HTML export requires Pro tier
737
+ if (format === "html") {
738
+ const license = loadLicenseInfo();
739
+ if (license.tier !== "premium") {
740
+ res.writeHead(403, { "Content-Type": "application/json" });
741
+ res.end(JSON.stringify({ error: "HTML export requires a Pro subscription." }));
742
+ return;
743
+ }
744
+ }
388
745
  const realNodes = arch.nodes;
389
746
  const nodeMap = new Map();
390
747
  for (const nd of realNodes)
@@ -515,6 +872,43 @@ function createHttpServer() {
515
872
  ext = "json";
516
873
  break;
517
874
  }
875
+ case "html": {
876
+ // Build self-contained interactive HTML file
877
+ const uiAssetsDir = path.join(UI_DIST, "assets");
878
+ if (!existsSync(uiAssetsDir)) {
879
+ res.writeHead(500, { "Content-Type": "application/json" });
880
+ res.end(JSON.stringify({ error: "UI build not found. Run: npm run build:ui" }));
881
+ return;
882
+ }
883
+ const { readdirSync } = await import("fs");
884
+ const uiAssetFiles = readdirSync(uiAssetsDir);
885
+ let cssInline = "";
886
+ for (const f of uiAssetFiles.filter((f) => f.endsWith(".css"))) {
887
+ cssInline += readFileSync(path.join(uiAssetsDir, f), "utf-8") + "\n";
888
+ }
889
+ let jsInline = "";
890
+ for (const f of uiAssetFiles.filter((f) => f.endsWith(".js"))) {
891
+ jsInline += readFileSync(path.join(uiAssetsDir, f), "utf-8") + "\n";
892
+ }
893
+ content = `<!DOCTYPE html>
894
+ <html lang="en" data-theme="dark">
895
+ <head>
896
+ <meta charset="UTF-8">
897
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
898
+ <title>${config.name} - ArchByte Architecture</title>
899
+ <style>${cssInline}</style>
900
+ </head>
901
+ <body>
902
+ <script>try{if(localStorage.getItem('archbyte-theme')==='light')document.documentElement.setAttribute('data-theme','light')}catch(e){}</script>
903
+ <div id="root"></div>
904
+ <script>window.__ARCHBYTE_DATA__ = ${JSON.stringify(arch)};</script>
905
+ <script type="module">${jsInline}</script>
906
+ </body>
907
+ </html>`;
908
+ contentType = "text/html";
909
+ ext = "html";
910
+ break;
911
+ }
518
912
  default: { // markdown
519
913
  const lines = [`# Architecture — ${config.name}`, "", `> Generated by ArchByte on ${new Date().toISOString().slice(0, 10)}`, ""];
520
914
  lines.push("## Summary", "");
@@ -774,6 +1168,35 @@ function createHttpServer() {
774
1168
  res.end(JSON.stringify({ running: runningWorkflows.has("__generate__") }));
775
1169
  return;
776
1170
  }
1171
+ // API: Run analyze (static or LLM) + generate
1172
+ if (url === "/api/analyze" && req.method === "POST") {
1173
+ if (isAnalyzing) {
1174
+ res.writeHead(409, { "Content-Type": "application/json" });
1175
+ res.end(JSON.stringify({ error: "Analysis already running" }));
1176
+ return;
1177
+ }
1178
+ let body = "";
1179
+ req.on("data", (chunk) => { body += chunk.toString(); });
1180
+ req.on("end", () => {
1181
+ const { mode } = JSON.parse(body || "{}");
1182
+ runAnalyzePipeline(mode === "llm" ? "llm" : "static");
1183
+ res.writeHead(200, { "Content-Type": "application/json" });
1184
+ res.end(JSON.stringify({ ok: true, mode: mode || "static" }));
1185
+ });
1186
+ return;
1187
+ }
1188
+ // API: Analyze status
1189
+ if (url === "/api/analyze/status" && req.method === "GET") {
1190
+ res.writeHead(200, { "Content-Type": "application/json" });
1191
+ res.end(JSON.stringify({ running: isAnalyzing }));
1192
+ return;
1193
+ }
1194
+ // API: Session changes feed — returns in-memory session change records (newest first)
1195
+ if (url === "/api/session-changes" && req.method === "GET") {
1196
+ res.writeHead(200, { "Content-Type": "application/json" });
1197
+ res.end(JSON.stringify(sessionChanges));
1198
+ return;
1199
+ }
777
1200
  // API: Run workflow
778
1201
  if (url.startsWith("/api/workflow/run/") && req.method === "POST") {
779
1202
  const id = url.split("/").pop();
@@ -858,13 +1281,13 @@ function createHttpServer() {
858
1281
  req.on("end", () => {
859
1282
  const { interval } = JSON.parse(body || "{}");
860
1283
  const bin = getArchbyteBin();
861
- const args = [bin, "patrol", "--watch"];
1284
+ const args = [bin, "patrol"];
862
1285
  if (interval)
863
1286
  args.push("--interval", String(interval));
864
1287
  patrolProcess = spawn(process.execPath, args, {
865
1288
  cwd: config.workspaceRoot,
866
- stdio: ["ignore", "pipe", "pipe"],
867
- env: { ...process.env, FORCE_COLOR: "0" },
1289
+ stdio: ["ignore", "inherit", "inherit"],
1290
+ env: { ...process.env, FORCE_COLOR: "1" },
868
1291
  });
869
1292
  patrolRunning = true;
870
1293
  broadcastOpsEvent({ type: "patrol:started" });
@@ -1271,12 +1694,135 @@ async function startHttpServer() {
1271
1694
  });
1272
1695
  });
1273
1696
  }
1274
- // Setup file watcher
1275
- function setupWatcher() {
1276
- if (!existsSync(config.diagramPath))
1697
+ // Diff two architecture snapshots (lightweight, inline version of cli/diff.ts)
1698
+ function diffArchitectures(prev, curr) {
1699
+ const prevNodeMap = new Map();
1700
+ for (const n of prev.nodes)
1701
+ prevNodeMap.set(n.id, n);
1702
+ const currNodeMap = new Map();
1703
+ for (const n of curr.nodes)
1704
+ currNodeMap.set(n.id, n);
1705
+ const prevNodeIds = new Set(prev.nodes.map((n) => n.id));
1706
+ const currNodeIds = new Set(curr.nodes.map((n) => n.id));
1707
+ const addedNodes = curr.nodes
1708
+ .filter((n) => !prevNodeIds.has(n.id))
1709
+ .map((n) => ({ id: n.id, label: n.label, type: n.type, layer: n.layer }));
1710
+ const removedNodes = prev.nodes
1711
+ .filter((n) => !currNodeIds.has(n.id))
1712
+ .map((n) => ({ id: n.id, label: n.label, type: n.type, layer: n.layer }));
1713
+ const modifiedNodes = [];
1714
+ for (const n of curr.nodes) {
1715
+ const old = prevNodeMap.get(n.id);
1716
+ if (!old)
1717
+ continue;
1718
+ for (const field of ["label", "type", "layer"]) {
1719
+ if (old[field] !== n[field]) {
1720
+ modifiedNodes.push({ id: n.id, field, from: old[field], to: n[field] });
1721
+ }
1722
+ }
1723
+ }
1724
+ const edgeKey = (e) => `${e.source}->${e.target}`;
1725
+ const prevEdgeKeys = new Set(prev.edges.map(edgeKey));
1726
+ const currEdgeKeys = new Set(curr.edges.map(edgeKey));
1727
+ const addedEdges = curr.edges
1728
+ .filter((e) => !prevEdgeKeys.has(edgeKey(e)))
1729
+ .map((e) => ({ source: e.source, target: e.target, label: e.label }));
1730
+ const removedEdges = prev.edges
1731
+ .filter((e) => !currEdgeKeys.has(edgeKey(e)))
1732
+ .map((e) => ({ source: e.source, target: e.target, label: e.label }));
1733
+ return { addedNodes, removedNodes, modifiedNodes, addedEdges, removedEdges };
1734
+ }
1735
+ // Run analyze → generate pipeline (used by /api/analyze and patrol)
1736
+ function runAnalyzePipeline(mode = "static", fileChanges) {
1737
+ if (isAnalyzing)
1277
1738
  return;
1278
- diagramWatcher = watch(config.diagramPath, { ignoreInitial: true });
1279
- diagramWatcher.on("change", async () => {
1739
+ isAnalyzing = true;
1740
+ const pipelineStart = Date.now();
1741
+ // Snapshot current architecture for diffing after pipeline completes
1742
+ const prevArchitecture = currentArchitecture
1743
+ ? { ...currentArchitecture, nodes: [...currentArchitecture.nodes], edges: [...currentArchitecture.edges] }
1744
+ : null;
1745
+ broadcastOpsEvent({ type: "analyzing:started", mode });
1746
+ const bin = getArchbyteBin();
1747
+ const analyzeArgs = [bin, "analyze", "--force"];
1748
+ if (mode === "static")
1749
+ analyzeArgs.push("--static");
1750
+ const analyzeChild = spawn(process.execPath, analyzeArgs, {
1751
+ cwd: config.workspaceRoot,
1752
+ stdio: ["ignore", "pipe", "pipe"],
1753
+ env: { ...process.env, FORCE_COLOR: "0" },
1754
+ });
1755
+ analyzeChild.on("close", (analyzeCode) => {
1756
+ if (analyzeCode !== 0) {
1757
+ isAnalyzing = false;
1758
+ broadcastOpsEvent({ type: "analyzing:finished", code: analyzeCode, success: false });
1759
+ return;
1760
+ }
1761
+ // Chain: generate after successful analyze
1762
+ const genChild = spawn(process.execPath, [bin, "generate"], {
1763
+ cwd: config.workspaceRoot,
1764
+ stdio: ["ignore", "pipe", "pipe"],
1765
+ env: { ...process.env, FORCE_COLOR: "0" },
1766
+ });
1767
+ genChild.on("close", async (genCode) => {
1768
+ isAnalyzing = false;
1769
+ const durationMs = Date.now() - pipelineStart;
1770
+ // Build session change record if we have a previous architecture to diff against
1771
+ if (genCode === 0) {
1772
+ try {
1773
+ const newArch = await loadArchitecture();
1774
+ if (newArch && prevArchitecture) {
1775
+ const diff = diffArchitectures(prevArchitecture, newArch);
1776
+ // Deduplicate file changes: keep latest event per path
1777
+ const dedupedFiles = new Map();
1778
+ for (const fc of (fileChanges || [])) {
1779
+ dedupedFiles.set(fc.path, fc);
1780
+ }
1781
+ const filesChanged = [...dedupedFiles.values()];
1782
+ const record = {
1783
+ id: `sc-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
1784
+ timestamp: new Date().toISOString(),
1785
+ filesChanged,
1786
+ diff,
1787
+ summary: {
1788
+ filesChanged: filesChanged.length,
1789
+ nodesAdded: diff.addedNodes.length,
1790
+ nodesRemoved: diff.removedNodes.length,
1791
+ nodesModified: diff.modifiedNodes.length,
1792
+ edgesAdded: diff.addedEdges.length,
1793
+ edgesRemoved: diff.removedEdges.length,
1794
+ },
1795
+ durationMs,
1796
+ mode,
1797
+ };
1798
+ sessionChanges.unshift(record);
1799
+ if (sessionChanges.length > MAX_SESSION_CHANGES)
1800
+ sessionChanges.pop();
1801
+ broadcastOpsEvent({ type: "session:change", change: record });
1802
+ }
1803
+ }
1804
+ catch {
1805
+ // Non-fatal — session change tracking is best-effort
1806
+ }
1807
+ }
1808
+ broadcastOpsEvent({ type: "analyzing:finished", code: genCode, success: genCode === 0 });
1809
+ });
1810
+ });
1811
+ }
1812
+ // Setup file watcher — watches the .archbyte/ directory so it works
1813
+ // even when architecture.json doesn't exist yet (first-time users)
1814
+ function setupWatcher() {
1815
+ const archbyteDir = path.dirname(config.diagramPath);
1816
+ const diagramFilename = path.basename(config.diagramPath);
1817
+ diagramWatcher = watch(archbyteDir, {
1818
+ ignoreInitial: true,
1819
+ depth: 0,
1820
+ });
1821
+ diagramWatcher.on("all", async (event, filePath) => {
1822
+ if (path.basename(filePath) !== diagramFilename)
1823
+ return;
1824
+ if (event !== "change" && event !== "add")
1825
+ return;
1280
1826
  console.error("[archbyte] Diagram changed, reloading...");
1281
1827
  currentArchitecture = await loadArchitecture();
1282
1828
  broadcastUpdate();
@@ -1323,7 +1869,8 @@ function setupShutdown() {
1323
1869
  process.on("SIGTERM", shutdown);
1324
1870
  process.on("SIGINT", shutdown);
1325
1871
  }
1326
- // License info helper — reads ~/.archbyte/credentials.json
1872
+ // License info helper — reads login state from credentials.json,
1873
+ // tier from server-verified tier-cache.json (1-hour TTL, email-stamped)
1327
1874
  function loadLicenseInfo() {
1328
1875
  const defaults = {
1329
1876
  loggedIn: false,
@@ -1343,14 +1890,41 @@ function loadLicenseInfo() {
1343
1890
  const credPath = path.join(home, ".archbyte", "credentials.json");
1344
1891
  if (!existsSync(credPath))
1345
1892
  return defaults;
1346
- const creds = JSON.parse(readFileSync(credPath, "utf-8"));
1893
+ const raw = JSON.parse(readFileSync(credPath, "utf-8"));
1894
+ // Multi-account format (version 2): extract active account
1895
+ let creds;
1896
+ if (raw?.version === 2 && raw.accounts && typeof raw.active === "string") {
1897
+ creds = raw.accounts[raw.active] ?? {};
1898
+ }
1899
+ else {
1900
+ creds = raw;
1901
+ }
1347
1902
  if (!creds.token)
1348
1903
  return defaults;
1349
1904
  // Check expiry
1350
1905
  if (creds.expiresAt && new Date(creds.expiresAt) < new Date()) {
1351
1906
  return { ...defaults, loggedIn: true, email: creds.email ?? null };
1352
1907
  }
1353
- const isPremium = creds.tier === "premium";
1908
+ // Read tier from server-verified tier-cache.json (not user-editable credentials)
1909
+ let isPremium = false;
1910
+ const tierCachePath = path.join(home, ".archbyte", "tier-cache.json");
1911
+ if (existsSync(tierCachePath)) {
1912
+ try {
1913
+ const cache = JSON.parse(readFileSync(tierCachePath, "utf-8"));
1914
+ // Only trust cache if it matches current account and is within 1-hour TTL
1915
+ const cacheAge = cache.verifiedAt ? Date.now() - new Date(cache.verifiedAt).getTime() : Infinity;
1916
+ if (cache.email === creds.email && cacheAge < 60 * 60 * 1000) {
1917
+ isPremium = cache.tier === "premium";
1918
+ }
1919
+ }
1920
+ catch {
1921
+ // Corrupt cache — fall through to credentials fallback
1922
+ }
1923
+ }
1924
+ // Fallback: if no valid tier cache, use credentials tier (less secure but functional)
1925
+ if (!isPremium && creds.tier === "premium") {
1926
+ isPremium = true;
1927
+ }
1354
1928
  return {
1355
1929
  loggedIn: true,
1356
1930
  email: creds.email ?? null,