@takodotid/circuit 0.18.5 → 0.20.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.
Files changed (47) hide show
  1. package/README.md +18 -0
  2. package/dist/chunks/index-jtzfw679.js +936 -0
  3. package/dist/chunks/{index-ex1wcst7.js → index-xm9nma2t.js} +20 -1
  4. package/dist/chunks/server-5shmsmse.js +246 -0
  5. package/dist/cli/index.js +701 -106
  6. package/dist/index.js +1 -1
  7. package/dist/presets/index.js +1 -1
  8. package/dist/types/adapters/devices/derive.d.ts +2 -0
  9. package/dist/types/core/across.d.ts +4 -0
  10. package/dist/types/core/addr.d.ts +10 -0
  11. package/dist/types/core/addresses.d.ts +46 -0
  12. package/dist/types/core/files.d.ts +6 -0
  13. package/dist/types/core/graph.d.ts +74 -0
  14. package/dist/types/core/schema-docs.d.ts +16 -0
  15. package/package.json +1 -1
  16. package/src/adapters/devices/derive.ts +32 -0
  17. package/src/adapters/devices/raisecom-ros/index.ts +23 -3
  18. package/src/adapters/devices/routeros/index.ts +10 -0
  19. package/src/adapters/devices/vrp/index.ts +10 -1
  20. package/src/cli/graph.ts +89 -0
  21. package/src/cli/index.ts +205 -2
  22. package/src/cli/rename.ts +129 -0
  23. package/src/core/across.ts +322 -0
  24. package/src/core/addr.ts +29 -0
  25. package/src/core/addresses.ts +316 -0
  26. package/src/core/files.ts +30 -0
  27. package/src/core/graph.ts +634 -0
  28. package/src/core/schema-docs.ts +166 -0
  29. package/src/core/validate.ts +2 -52
  30. package/src/studio/data.ts +255 -0
  31. package/src/studio/git.ts +119 -0
  32. package/src/studio/public/app.js +147 -0
  33. package/src/studio/public/compare.js +243 -0
  34. package/src/studio/public/diff.js +97 -0
  35. package/src/studio/public/dom.js +108 -0
  36. package/src/studio/public/evaluate.js +648 -0
  37. package/src/studio/public/export.js +122 -0
  38. package/src/studio/public/favicon.svg +6 -0
  39. package/src/studio/public/graph.js +914 -0
  40. package/src/studio/public/index.html +67 -0
  41. package/src/studio/public/net.js +128 -0
  42. package/src/studio/public/style.css +693 -0
  43. package/src/studio/public/tables.js +1223 -0
  44. package/src/studio/public/tools.js +578 -0
  45. package/src/studio/server.ts +218 -0
  46. package/templates/project/AGENTS.md +12 -9
  47. package/templates/project/README.md +1 -0
package/dist/cli/index.js CHANGED
@@ -1,4 +1,17 @@
1
1
  #!/usr/bin/env node
2
+ import {
3
+ LAYERS,
4
+ addressesOf,
5
+ deviceId,
6
+ freeOfLength,
7
+ graphOf,
8
+ holdersOf,
9
+ keyOf,
10
+ only,
11
+ portsOf,
12
+ spell,
13
+ studioData
14
+ } from "../chunks/index-jtzfw679.js";
2
15
  import {
3
16
  contains,
4
17
  familyOf,
@@ -10,7 +23,7 @@ import {
10
23
  network,
11
24
  split,
12
25
  wildcard
13
- } from "../chunks/index-ex1wcst7.js";
26
+ } from "../chunks/index-xm9nma2t.js";
14
27
  import {
15
28
  __require,
16
29
  __toESM
@@ -18,8 +31,8 @@ import {
18
31
 
19
32
  // src/cli/index.ts
20
33
  import { createPrivateKey, createPublicKey } from "node:crypto";
21
- import { existsSync as existsSync2, mkdirSync, readdirSync, readFileSync as readFileSync2, rmSync, writeFileSync } from "node:fs";
22
- import { basename, dirname, join as join2, resolve as resolve2 } from "node:path";
34
+ import { existsSync as existsSync3, mkdirSync, readdirSync as readdirSync2, readFileSync as readFileSync4, rmSync, writeFileSync as writeFileSync2 } from "node:fs";
35
+ import { basename as basename2, dirname as dirname2, join as join4, relative as relative2, resolve as resolve3, sep } from "node:path";
23
36
 
24
37
  // src/core/secrets.ts
25
38
  import { spawnSync } from "node:child_process";
@@ -207,44 +220,6 @@ function upload(client, path, content) {
207
220
  });
208
221
  }
209
222
 
210
- // src/adapters/devices/raisecom-ros/models.ts
211
- var models = {
212
- "RAX721-C-6C48": {
213
- ports: { "100g": 6, "25g": 24, "10g": 24 },
214
- spelling: { "100g": "hundredgige 1/1/{n}", "25g": "twenty-fivegige 1/2/{n}", "10g": "tengigabitethernet 1/3/{n}" }
215
- }
216
- };
217
-
218
- // src/adapters/devices/routeros/models.ts
219
- var models2 = {
220
- "CCR2216-1G-12XS-2XQ": {
221
- ports: { "100g": 2, "25g": 12, "1g": 1 },
222
- spelling: { "100g": "qsfp28-{n}-1", "25g": "sfp28-{n}", "1g": "ether{n}" }
223
- }
224
- };
225
-
226
- // src/adapters/devices/vrp/models.ts
227
- var models3 = {
228
- "CE6855-48S6Q-HI": { ports: { "10g": 48, "40g": 6 }, spelling: { "10g": "10GE1/0/{n}", "40g": "40GE1/0/{n}" } }
229
- };
230
-
231
- // src/adapters/devices/catalog.ts
232
- var catalog = { routeros: models2, vrp: models3, "raisecom-ros": models };
233
- function portsOf(platform, model) {
234
- const spec = catalog[platform]?.[model];
235
- if (!spec)
236
- throw new Error(`${platform} has no model ${model}`);
237
- return Object.entries(spec.ports).flatMap(([speed, count]) => Array.from({ length: count }, (_, i) => `${speed}-${i + 1}`));
238
- }
239
- function spell(platform, model, port) {
240
- const spec = catalog[platform]?.[model];
241
- const parts = /^(\d+[gm])-(\d+)$/.exec(port);
242
- const template = parts && spec?.spelling[parts[1]];
243
- if (!template)
244
- throw new Error(`${model} has no port ${port}`);
245
- return template.replace("{n}", parts[2]);
246
- }
247
-
248
223
  // src/adapters/devices/derive.ts
249
224
  function lagIds(device) {
250
225
  const numbers = new Map;
@@ -262,6 +237,36 @@ function ipv4Networks(device, iface) {
262
237
  const addresses = (settings?.addresses ?? []).map((entry) => typeof entry === "string" ? entry : entry.address);
263
238
  return addresses.filter((address) => familyOf(address) === "ipv4").map((address) => network(address));
264
239
  }
240
+ var SWITCH_SERVICES = ["ssh", "telnet", "snmp"];
241
+ function switchUnsupported(device) {
242
+ const found = [];
243
+ for (const service of SWITCH_SERVICES) {
244
+ const settings = device.management?.[service];
245
+ if (settings?.allow)
246
+ found.push(`management.${service}.allow, every service takes management.allow`);
247
+ if (settings?.port)
248
+ found.push(`management.${service}.port`);
249
+ }
250
+ for (const [name, user] of Object.entries(device.users ?? {})) {
251
+ if (user.description)
252
+ found.push(`users.${name}.description`);
253
+ if (user.ssh_keys?.length)
254
+ found.push(`users.${name}.ssh_keys`);
255
+ }
256
+ if (device.system?.ip)
257
+ found.push("system.ip");
258
+ for (const [name, port] of Object.entries(device.ports ?? {})) {
259
+ if (port?.hardware_offload !== undefined)
260
+ found.push(`ports.${name}.hardware_offload`);
261
+ }
262
+ for (const [name, iface] of Object.entries(device.interfaces ?? {})) {
263
+ if (iface.addresses?.some((entry) => typeof entry !== "string"))
264
+ found.push(`interfaces.${name}.addresses with a peer`);
265
+ if (iface.hardware_offload !== undefined)
266
+ found.push(`interfaces.${name}.hardware_offload`);
267
+ }
268
+ return found;
269
+ }
265
270
 
266
271
  // src/adapters/devices/lines.ts
267
272
  var indentOf = (raw) => raw.length - raw.trimStart().length;
@@ -307,12 +312,12 @@ function head(line) {
307
312
  }
308
313
  function compare(desired, current, dialect) {
309
314
  const canon = dialect.canon ?? ((line) => line);
310
- const keyOf = (entry) => [...entry.path, canon(entry.line)].join("\x00");
315
+ const keyOf2 = (entry) => [...entry.path, canon(entry.line)].join("\x00");
311
316
  const pathKey = (path) => path.join("\x00");
312
317
  const wanted = entries(desired, dialect);
313
318
  const existing = entries(current, dialect);
314
- const wantedKeys = new Set(wanted.map(keyOf));
315
- const existingKeys = new Set(existing.map(keyOf));
319
+ const wantedKeys = new Set(wanted.map(keyOf2));
320
+ const existingKeys = new Set(existing.map(keyOf2));
316
321
  const wantedBlocks = new Set(wanted.map((entry) => pathKey([...entry.path, entry.line])));
317
322
  const groups = new Map;
318
323
  const groupFor = (path) => {
@@ -322,7 +327,7 @@ function compare(desired, current, dialect) {
322
327
  return groups.get(key);
323
328
  };
324
329
  for (const entry of wanted) {
325
- if (existingKeys.has(keyOf(entry)))
330
+ if (existingKeys.has(keyOf2(entry)))
326
331
  continue;
327
332
  const ownBlock = pathKey([...entry.path, entry.line]);
328
333
  const opensBlock = wanted.some((other) => {
@@ -334,7 +339,7 @@ function compare(desired, current, dialect) {
334
339
  groupFor(entry.path).add.push(entry.line);
335
340
  }
336
341
  for (const entry of [...existing].reverse()) {
337
- if (wantedKeys.has(keyOf(entry)))
342
+ if (wantedKeys.has(keyOf2(entry)))
338
343
  continue;
339
344
  const insideRemovedBlock = entry.path.some((_, depth) => {
340
345
  const enclosing = pathKey(entry.path.slice(0, depth + 1));
@@ -344,7 +349,7 @@ function compare(desired, current, dialect) {
344
349
  continue;
345
350
  const replaced = wanted.some((other) => {
346
351
  const sameBlock = pathKey(other.path) === pathKey(entry.path);
347
- const isNew = !existingKeys.has(keyOf(other));
352
+ const isNew = !existingKeys.has(keyOf2(other));
348
353
  return sameBlock && isNew && head(other.line) === head(entry.line);
349
354
  });
350
355
  if (!replaced)
@@ -715,8 +720,15 @@ function unsupported(device) {
715
720
  found.push(`interfaces.${name}, a ${iface.type}`);
716
721
  continue;
717
722
  }
718
- if (iface.vrf)
719
- found.push(`interfaces.${name}.vrf`);
723
+ for (const field of ["vrf", "description", "mtu", "ipv6_ra"]) {
724
+ if (iface[field] !== undefined)
725
+ found.push(`interfaces.${name}.${field}`);
726
+ }
727
+ for (const entry of iface.addresses ?? []) {
728
+ const address = typeof entry === "string" ? entry : entry.address;
729
+ if (familyOf(address) === "ipv6")
730
+ found.push(`interfaces.${name}.addresses ${address}, only IPv4 here`);
731
+ }
720
732
  for (const group of iface.vrrp ?? []) {
721
733
  if (familyOf(group.address) === "ipv6")
722
734
  found.push(`interfaces.${name}.vrrp for IPv6`);
@@ -739,8 +751,15 @@ function unsupported(device) {
739
751
  }
740
752
  }
741
753
  for (const route of routing?.static ?? []) {
742
- if (!route.via || route.vrf)
754
+ if (!route.via || route.vrf || familyOf(route.prefix) === "ipv6") {
743
755
  found.push(`routing.static ${route.prefix}, only an IPv4 next hop in the default table`);
756
+ }
757
+ if (route.description || route.distance)
758
+ found.push(`routing.static ${route.prefix} description and distance`);
759
+ }
760
+ for (const [name, vlan] of Object.entries(device.vlans ?? {})) {
761
+ if (vlan.description)
762
+ found.push(`vlans.${name}.description`);
744
763
  }
745
764
  const UNSUPPORTED_SECTIONS = [
746
765
  "firewall",
@@ -767,6 +786,10 @@ function unsupported(device) {
767
786
  if (!SNMP_AUTH[user.auth])
768
787
  found.push(`management.snmp.users.${name}.auth ${user.auth}, the platform authenticates with SHA1 or MD5`);
769
788
  }
789
+ if (management.snmp?.contact || management.snmp?.location)
790
+ found.push("management.snmp.contact and location");
791
+ if (management.ssh?.weak_crypto)
792
+ found.push("management.ssh.weak_crypto");
770
793
  if (device.lldp && device.lldp !== true)
771
794
  found.push("lldp.interfaces, LLDP is per device");
772
795
  if (device.stp?.mode && device.stp.mode !== "mstp")
@@ -779,6 +802,7 @@ function unsupported(device) {
779
802
  if (system.dns || system.logging || system.release_channel || system.banner || system.ntp?.serve) {
780
803
  found.push("system.dns, logging, release_channel, banner and ntp.serve");
781
804
  }
805
+ found.push(...switchUnsupported(device));
782
806
  return found;
783
807
  }
784
808
  var REFUSED = /^(%|Error|Incomplete|Unrecognized)|can not|cannot |is in use|Wrong parameter|invalid|unsuccessful/im;
@@ -2587,6 +2611,14 @@ function unsupported2(device) {
2587
2611
  found.push(`ports.${name}.storm_control with a different percent per kind, the platform takes one`);
2588
2612
  }
2589
2613
  }
2614
+ for (const [name, iface] of Object.entries(device.interfaces ?? {})) {
2615
+ if (iface.type !== "lag")
2616
+ continue;
2617
+ for (const field of ["storm_control", "stp"]) {
2618
+ if (iface[field] !== undefined)
2619
+ found.push(`interfaces.${name}.${field}, on a LAG`);
2620
+ }
2621
+ }
2590
2622
  const bgp = device.routing?.bgp;
2591
2623
  for (const [name, neighbor] of Object.entries(bgp?.neighbors ?? {})) {
2592
2624
  const group = neighbor.group ? bgp?.groups?.[neighbor.group] : undefined;
@@ -2599,6 +2631,8 @@ function unsupported2(device) {
2599
2631
  found.push("management.console, the console uses the device's users");
2600
2632
  if (management?.privilege_password)
2601
2633
  found.push("management.privilege_password, the platform has no privileged mode");
2634
+ if (management?.snmp?.port)
2635
+ found.push("management.snmp.port");
2602
2636
  if (management?.ssh?.auth_timeout || management?.ssh?.auth_retries) {
2603
2637
  found.push("management.ssh.auth_timeout and auth_retries");
2604
2638
  }
@@ -3224,10 +3258,16 @@ function unsupported3(device) {
3224
3258
  found.push(`interfaces.${name}, a ${iface.type}`);
3225
3259
  if (iface.type === "lag" && iface.addresses?.length)
3226
3260
  found.push(`interfaces.${name}.addresses, LAGs are switched`);
3261
+ if (iface.mtu !== undefined)
3262
+ found.push(`interfaces.${name}.mtu`);
3227
3263
  for (const group of iface.vrrp ?? [])
3228
3264
  if (familyOf(group.address) === "ipv6")
3229
3265
  found.push(`interfaces.${name}.vrrp for IPv6`);
3230
3266
  }
3267
+ for (const [name, vrf] of Object.entries(device.vrfs ?? {})) {
3268
+ if (vrf.description)
3269
+ found.push(`vrfs.${name}.description`);
3270
+ }
3231
3271
  const routing = device.routing;
3232
3272
  if (routing?.bgp)
3233
3273
  found.push("routing.bgp");
@@ -3258,6 +3298,9 @@ function unsupported3(device) {
3258
3298
  if (system2.dns || system2.logging || system2.release_channel || system2.banner) {
3259
3299
  found.push("system.dns, logging, release_channel and banner");
3260
3300
  }
3301
+ if (system2.transceiver_monitoring !== undefined)
3302
+ found.push("system.transceiver_monitoring");
3303
+ found.push(...switchUnsupported(device));
3261
3304
  const flow = device.flow_export;
3262
3305
  if (flow && flow.protocol !== "sflow")
3263
3306
  found.push(`flow_export.protocol ${flow.protocol}, the platform exports sFlow`);
@@ -3437,10 +3480,10 @@ async function sendLibreNms(network2, additions) {
3437
3480
 
3438
3481
  // src/adapters/registries/peeringdb.ts
3439
3482
  var API = "https://www.peeringdb.com/api";
3440
- var addressesOf = (iface) => (iface.addresses ?? []).map((entry) => typeof entry === "string" ? entry : entry.address);
3483
+ var addressesOf2 = (iface) => (iface.addresses ?? []).map((entry) => typeof entry === "string" ? entry : entry.address);
3441
3484
  function peersWithRouteServer(device, iface) {
3442
3485
  const bgp = device.routing?.bgp;
3443
- const subnets = addressesOf(iface);
3486
+ const subnets = addressesOf2(iface);
3444
3487
  return Object.values(bgp?.neighbors ?? {}).some((neighbor) => {
3445
3488
  const group = neighbor.group ? bgp?.groups?.[neighbor.group] : undefined;
3446
3489
  const role = neighbor.role ?? group?.role;
@@ -3454,7 +3497,7 @@ function wanted(network2) {
3454
3497
  const exchange = iface.exchange;
3455
3498
  if (!exchange?.peeringdb_ixlan)
3456
3499
  continue;
3457
- const published = addressesOf(iface).map(host).filter(isGlobal);
3500
+ const published = addressesOf2(iface).map(host).filter(isGlobal);
3458
3501
  records.push({
3459
3502
  name: exchange.name,
3460
3503
  ixlan_id: exchange.peeringdb_ixlan,
@@ -3572,6 +3615,243 @@ function snmpExporterAuths(network2) {
3572
3615
 
3573
3616
  // src/core/validate.ts
3574
3617
  import { X509Certificate as X509Certificate2 } from "node:crypto";
3618
+
3619
+ // src/core/across.ts
3620
+ function checkAcross(devices) {
3621
+ const holders2 = holdersOf(devices);
3622
+ const owners = new Map;
3623
+ for (const holder of holders2) {
3624
+ const key = keyOf(host(holder.address));
3625
+ if (!owners.has(key))
3626
+ owners.set(key, holder);
3627
+ }
3628
+ return [
3629
+ ...checkLinks(devices),
3630
+ ...checkAddresses(holders2),
3631
+ ...checkSessions(devices, owners),
3632
+ ...checkVrrp(holders2),
3633
+ ...checkTunnels(devices, owners)
3634
+ ];
3635
+ }
3636
+ function carried(device, port) {
3637
+ const id = (vlan) => vlan ? device.vlans?.[vlan]?.id : undefined;
3638
+ return {
3639
+ access: id(port.access_vlan),
3640
+ native: id(port.native_vlan),
3641
+ trunk: (port.trunk_vlans ?? []).map(id).sort((a, b) => (a ?? 0) - (b ?? 0))
3642
+ };
3643
+ }
3644
+ function vlanNames(device, settings) {
3645
+ const names = [settings.access_vlan, settings.native_vlan, ...settings.trunk_vlans ?? []].filter((name) => name !== undefined);
3646
+ return new Map(names.flatMap((name) => device.vlans?.[name] ? [[device.vlans[name].id, name]] : []));
3647
+ }
3648
+ function carrierOf(device, portName, port) {
3649
+ const lag = port.lag ? device.interfaces?.[port.lag] : undefined;
3650
+ if (port.lag && lag?.type === "lag")
3651
+ return { name: port.lag, settings: lag };
3652
+ return { name: portName, settings: port };
3653
+ }
3654
+ var plainAddresses = (settings, family) => (settings.addresses ?? []).filter((entry) => typeof entry === "string" && isCidr(entry) && familyOf(entry) === family);
3655
+ function addressDisagreement(here, there, far, farName) {
3656
+ for (const family of ["ipv4", "ipv6"]) {
3657
+ const ours = plainAddresses(here, family);
3658
+ const theirs = plainAddresses(there, family);
3659
+ if (!ours.length || !theirs.length)
3660
+ continue;
3661
+ const theirPrefixes = new Set(theirs.map(network));
3662
+ if (!ours.some((address) => theirPrefixes.has(network(address)))) {
3663
+ return `has ${ours.join(", ")}, and ${far.name} ${farName} has ${theirs.join(", ")}: not one prefix of one length`;
3664
+ }
3665
+ }
3666
+ const farHosts = new Set((there.addresses ?? []).map((entry) => keyOf(host(typeof entry === "string" ? entry : entry.address))));
3667
+ for (const entry of here.addresses ?? []) {
3668
+ if (typeof entry === "string" || !keyOf(entry.peer) || farHosts.has(keyOf(entry.peer)))
3669
+ continue;
3670
+ return `${entry.address} names peer ${entry.peer}, which ${far.name} ${farName} does not hold`;
3671
+ }
3672
+ return;
3673
+ }
3674
+ function checkLinks(devices) {
3675
+ const findings = [];
3676
+ const byName = new Map(devices.map((device) => [device.name, device]));
3677
+ for (const device of devices) {
3678
+ for (const [portName, port] of Object.entries(device.ports ?? {})) {
3679
+ const link = port?.link;
3680
+ if (!port || !link)
3681
+ continue;
3682
+ const report = (message, level = "error") => findings.push({
3683
+ level,
3684
+ device: device.name,
3685
+ message: `port ${portName} link: ${message}`
3686
+ });
3687
+ const far = byName.get(link.device);
3688
+ if (!far) {
3689
+ report(`device ${link.device} is not in the network`);
3690
+ continue;
3691
+ }
3692
+ const farPort = far.ports?.[link.port];
3693
+ if (!farPort) {
3694
+ report(`${link.device} does not declare port ${link.port}`);
3695
+ continue;
3696
+ }
3697
+ const linksBack = farPort.link?.device === device.name && farPort.link.port === portName;
3698
+ if (!linksBack) {
3699
+ report(`${link.device} ${link.port} does not link back to it`);
3700
+ continue;
3701
+ }
3702
+ const here = JSON.stringify(carried(device, port));
3703
+ const there = JSON.stringify(carried(far, farPort));
3704
+ if (here !== there)
3705
+ report(`carries ${here}, and ${link.device} ${link.port} carries ${there}`);
3706
+ if (port.speed && farPort.speed && port.speed !== farPort.speed) {
3707
+ report(`speed ${port.speed}, and ${link.device} ${link.port} is ${farPort.speed}`);
3708
+ }
3709
+ const near = carrierOf(device, portName, port);
3710
+ const away = carrierOf(far, link.port, farPort);
3711
+ const mtu = near.settings.mtu ?? 1500;
3712
+ const farMtu = away.settings.mtu ?? 1500;
3713
+ if (mtu !== farMtu)
3714
+ report(`MTU ${mtu} on ${near.name}, and ${link.device} ${away.name} has ${farMtu}`);
3715
+ const disagreement = addressDisagreement(near.settings, away.settings, far, away.name);
3716
+ if (disagreement)
3717
+ report(`${near.name} ${disagreement}`);
3718
+ const farNames = vlanNames(far, away.settings);
3719
+ for (const [id, name] of vlanNames(device, near.settings)) {
3720
+ const farName = farNames.get(id);
3721
+ if (farName && farName !== name)
3722
+ report(`VLAN ${id} is ${name} here and ${farName} on ${link.device}`, "warning");
3723
+ }
3724
+ }
3725
+ }
3726
+ return findings;
3727
+ }
3728
+ function checkAddresses(holders2) {
3729
+ const findings = [];
3730
+ const first = new Map;
3731
+ const isLoopback = (holder) => holder.device.interfaces?.[holder.name]?.type === "loopback";
3732
+ for (const holder of holders2) {
3733
+ const key = keyOf(host(holder.address));
3734
+ const earlier = first.get(key);
3735
+ if (!earlier) {
3736
+ first.set(key, holder);
3737
+ continue;
3738
+ }
3739
+ if (earlier.device === holder.device)
3740
+ continue;
3741
+ const anycast = isLoopback(earlier) && isLoopback(holder);
3742
+ findings.push({
3743
+ level: anycast ? "warning" : "error",
3744
+ device: holder.device.name,
3745
+ message: `${host(holder.address)} on ${holder.name} is also on ${earlier.device.name} ${earlier.name}${anycast ? ", which is right only for anycast" : ""}`
3746
+ });
3747
+ }
3748
+ return findings;
3749
+ }
3750
+ function checkSessions(devices, owners) {
3751
+ const findings = [];
3752
+ const sessionsOf = (device) => {
3753
+ const bgp = device.routing?.bgp;
3754
+ return Object.entries(bgp?.neighbors ?? {}).map(([name, neighbor]) => {
3755
+ const group = neighbor.group ? bgp?.groups?.[neighbor.group] : undefined;
3756
+ return {
3757
+ name,
3758
+ address: neighbor.address,
3759
+ remoteAs: neighbor.remote_as ?? group?.remote_as,
3760
+ localAddress: neighbor.local_address ?? group?.local_address
3761
+ };
3762
+ });
3763
+ };
3764
+ for (const device of devices) {
3765
+ for (const session of sessionsOf(device)) {
3766
+ const far = owners.get(keyOf(session.address) ?? "")?.device;
3767
+ if (!far || far === device)
3768
+ continue;
3769
+ const report = (message) => findings.push({ level: "error", device: device.name, message: `neighbor ${session.name}: ${message}` });
3770
+ const farBgp = far.routing?.bgp;
3771
+ if (!farBgp) {
3772
+ report(`${session.address} is ${far.name}'s, which runs no BGP`);
3773
+ continue;
3774
+ }
3775
+ if (session.remoteAs !== undefined && session.remoteAs !== farBgp.asn) {
3776
+ report(`expects AS${session.remoteAs}, and ${far.name} is AS${farBgp.asn}`);
3777
+ }
3778
+ const back = sessionsOf(far).find((other) => owners.get(keyOf(other.address) ?? "")?.device === device && familyOf(other.address) === familyOf(session.address));
3779
+ if (!back) {
3780
+ report(`${far.name} has no session back to ${device.name}`);
3781
+ continue;
3782
+ }
3783
+ if (session.localAddress && keyOf(session.localAddress) !== keyOf(back.address)) {
3784
+ report(`starts from ${session.localAddress}, and ${far.name}'s session ${back.name} expects ${back.address}`);
3785
+ }
3786
+ }
3787
+ }
3788
+ return findings;
3789
+ }
3790
+ function checkVrrp(holders2) {
3791
+ const findings = [];
3792
+ const bySegment = new Map;
3793
+ for (const holder of holders2) {
3794
+ const segment = network(holder.address);
3795
+ for (const group of holder.vrrp) {
3796
+ if (!keyOf(group.address) || familyOf(group.address) !== familyOf(holder.address))
3797
+ continue;
3798
+ if (!contains(segment, group.address))
3799
+ continue;
3800
+ bySegment.set(segment, [...bySegment.get(segment) ?? [], { holder, id: group.id, address: group.address }]);
3801
+ }
3802
+ }
3803
+ for (const members of bySegment.values()) {
3804
+ for (const member of members) {
3805
+ const where = `${member.holder.device.name} ${member.holder.name}`;
3806
+ const others = members.filter((other) => other.holder.device !== member.holder.device);
3807
+ const sameId = others.find((other) => other.id === member.id && keyOf(other.address) !== keyOf(member.address));
3808
+ if (sameId) {
3809
+ findings.push({
3810
+ level: "error",
3811
+ device: member.holder.device.name,
3812
+ message: `VRRP ${member.id} on ${member.holder.name} is ${member.address}, and on ${sameId.holder.device.name} ${sameId.holder.name} it is ${sameId.address}`
3813
+ });
3814
+ }
3815
+ const sameAddress = others.find((other) => keyOf(other.address) === keyOf(member.address) && other.id !== member.id);
3816
+ if (sameAddress) {
3817
+ findings.push({
3818
+ level: "error",
3819
+ device: member.holder.device.name,
3820
+ message: `VRRP address ${member.address} is id ${member.id} on ${where}, and id ${sameAddress.id} on ${sameAddress.holder.device.name} ${sameAddress.holder.name}`
3821
+ });
3822
+ }
3823
+ }
3824
+ }
3825
+ return findings;
3826
+ }
3827
+ function checkTunnels(devices, owners) {
3828
+ const findings = [];
3829
+ const ends = devices.flatMap((device) => Object.entries(device.interfaces ?? {}).flatMap(([name, iface]) => iface.type === "gre" || iface.type === "vxlan" ? [{ device, name, iface }] : []));
3830
+ const same = (a, b) => keyOf(a) !== undefined && keyOf(a) === keyOf(b);
3831
+ for (const end of ends) {
3832
+ const far = owners.get(keyOf(end.iface.remote) ?? "")?.device;
3833
+ if (!far || far === end.device)
3834
+ continue;
3835
+ const report = (message) => findings.push({ level: "error", device: end.device.name, message: `interface ${end.name}: ${message}` });
3836
+ const kind = end.iface.type === "gre" ? "GRE" : "VXLAN";
3837
+ const mirror = ends.find((other) => other.device === far && other.iface.type === end.iface.type && same(other.iface.local, end.iface.remote) && same(other.iface.remote, end.iface.local));
3838
+ if (!mirror) {
3839
+ report(`${end.iface.remote} is ${far.name}'s, which has no ${kind} tunnel from there back to ${end.iface.local}`);
3840
+ continue;
3841
+ }
3842
+ if (end.iface.type !== "vxlan" || mirror.iface.type !== "vxlan")
3843
+ continue;
3844
+ if (end.iface.vni !== mirror.iface.vni)
3845
+ report(`VNI ${end.iface.vni}, and ${far.name} ${mirror.name} has ${mirror.iface.vni}`);
3846
+ const port = end.iface.port ?? 4789;
3847
+ const farPort = mirror.iface.port ?? 4789;
3848
+ if (port !== farPort)
3849
+ report(`UDP port ${port}, and ${far.name} ${mirror.name} has ${farPort}`);
3850
+ }
3851
+ return findings;
3852
+ }
3853
+
3854
+ // src/core/validate.ts
3575
3855
  function validate(network2) {
3576
3856
  const findings = [];
3577
3857
  const seen = new Set;
@@ -3590,7 +3870,7 @@ function validate(network2) {
3590
3870
  for (const field of adapter.unsupported(device))
3591
3871
  report(`${device.platform} cannot express ${field}`);
3592
3872
  }
3593
- findings.push(...checkLinks(network2.devices));
3873
+ findings.push(...checkAcross(network2.devices));
3594
3874
  for (const check of network2.checks ?? [])
3595
3875
  findings.push(...check(network2.devices));
3596
3876
  return findings;
@@ -3635,7 +3915,7 @@ function checkDevice(device, report) {
3635
3915
  if (!device.users?.[device.connection.user])
3636
3916
  report(`connection user '${device.connection.user}' is not one of its users`);
3637
3917
  checkVlans(device, report);
3638
- checkAddresses(device, report, cidr, ip);
3918
+ checkAddresses2(device, report, cidr, ip);
3639
3919
  checkPorts(device, names, report);
3640
3920
  checkInterfaces(device, names, report, ip);
3641
3921
  checkRouting(device, names, report, cidr, ip);
@@ -3654,7 +3934,7 @@ function checkVlans(device, report) {
3654
3934
  ids.set(vlan.id, name);
3655
3935
  }
3656
3936
  }
3657
- function checkAddresses(device, report, cidr, ip) {
3937
+ function checkAddresses2(device, report, cidr, ip) {
3658
3938
  const holderOf = new Map;
3659
3939
  const holders2 = [
3660
3940
  ...Object.entries(device.ports ?? {}).map(([name, settings]) => ({ where: `port ${name}`, settings })),
@@ -3867,47 +4147,186 @@ function checkCertificates(device, names, report) {
3867
4147
  }
3868
4148
  }
3869
4149
  }
3870
- function carried(device, port) {
3871
- const id = (vlan) => vlan ? device.vlans?.[vlan]?.id : undefined;
3872
- return {
3873
- access: id(port.access_vlan),
3874
- native: id(port.native_vlan),
3875
- trunk: (port.trunk_vlans ?? []).map(id).sort((a, b) => (a ?? 0) - (b ?? 0))
4150
+
4151
+ // src/cli/graph.ts
4152
+ function markOf(graph, node) {
4153
+ if (node.kind !== "device")
4154
+ return;
4155
+ const findings = graph.findings.filter((finding) => finding.device && deviceId(finding.device) === node.id);
4156
+ if (findings.some((finding) => finding.level === "error"))
4157
+ return "error";
4158
+ const drift = graph.drift[node.label]?.status;
4159
+ if (findings.length || drift === "differs" || drift === "cannot plan")
4160
+ return "warning";
4161
+ return;
4162
+ }
4163
+ function mermaid(graph) {
4164
+ const ids = new Map(graph.nodes.map((node, index) => [node.id, `n${index}`]));
4165
+ const text = (...lines2) => `"${lines2.map((line2) => line2.replace(/"/g, "#quot;").replace(/</g, "#lt;").replace(/>/g, "#gt;")).join("<br/>")}"`;
4166
+ const shapes = {
4167
+ device: (label) => `[${label}]`,
4168
+ segment: (label) => `([${label}])`,
4169
+ outside: (label) => `{{${label}}}`
4170
+ };
4171
+ const lines = ["graph LR"];
4172
+ for (const node of graph.nodes) {
4173
+ const label = node.kind === "device" ? text(node.label, node.detail[0] ?? "") : text(node.label);
4174
+ lines.push(` ${ids.get(node.id)}${shapes[node.kind](label)}`);
4175
+ }
4176
+ for (const edge of graph.edges) {
4177
+ const arrow = edge.oneSided ? "-.-" : "---";
4178
+ lines.push(` ${ids.get(edge.from)} ${arrow}|${text(edge.label)}| ${ids.get(edge.to)}`);
4179
+ }
4180
+ lines.push(" classDef error stroke:#d1242f,stroke-width:3px", " classDef warning stroke:#bf8700,stroke-width:3px", " classDef outside stroke-dasharray:4 3");
4181
+ for (const node of graph.nodes) {
4182
+ const mark = markOf(graph, node) ?? (node.kind === "outside" ? "outside" : undefined);
4183
+ if (mark)
4184
+ lines.push(` class ${ids.get(node.id)} ${mark}`);
4185
+ }
4186
+ return lines.join(`
4187
+ `) + `
4188
+ `;
4189
+ }
4190
+ function dot(graph) {
4191
+ const text = (value2) => `"${value2.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
4192
+ const colors = { error: "#d1242f", warning: "#bf8700" };
4193
+ const shapes = {
4194
+ device: "shape=box, style=rounded",
4195
+ segment: "shape=ellipse",
4196
+ outside: "shape=box, style=dashed"
3876
4197
  };
4198
+ const lines = [
4199
+ "graph network {",
4200
+ " rankdir=LR;",
4201
+ ' node [fontname="Helvetica"];',
4202
+ ' edge [fontname="Helvetica", fontsize=10];'
4203
+ ];
4204
+ for (const node of graph.nodes) {
4205
+ const label = node.kind === "device" ? `${node.label}
4206
+ ${node.detail[0] ?? ""}` : node.label;
4207
+ const mark = markOf(graph, node);
4208
+ const color = mark ? `, color=${text(colors[mark])}, penwidth=2` : "";
4209
+ lines.push(` ${text(node.id)} [label=${text(label)}, ${shapes[node.kind]}${color}];`);
4210
+ }
4211
+ for (const edge of graph.edges) {
4212
+ const style = edge.oneSided ? ", style=dashed" : "";
4213
+ lines.push(` ${text(edge.from)} -- ${text(edge.to)} [label=${text(edge.label)}${style}];`);
4214
+ }
4215
+ lines.push("}");
4216
+ return lines.join(`
4217
+ `) + `
4218
+ `;
3877
4219
  }
3878
- function checkLinks(devices) {
3879
- const findings = [];
3880
- const byName = new Map(devices.map((device) => [device.name, device]));
3881
- for (const device of devices) {
3882
- for (const [portName, port] of Object.entries(device.ports ?? {})) {
3883
- const link = port?.link;
3884
- if (!port || !link)
3885
- continue;
3886
- const report = (message) => findings.push({
3887
- level: "error",
3888
- device: device.name,
3889
- message: `port ${portName} link: ${message}`
4220
+
4221
+ // src/cli/rename.ts
4222
+ import { existsSync as existsSync2, readFileSync as readFileSync3, renameSync, writeFileSync } from "node:fs";
4223
+ import { basename, dirname, extname, join as join3, resolve as resolve2 } from "node:path";
4224
+
4225
+ // src/core/files.ts
4226
+ import { readdirSync, readFileSync as readFileSync2 } from "node:fs";
4227
+ import { join as join2, relative } from "node:path";
4228
+ var SKIPPED = new Set(["node_modules", ".git", ".circuit", "dist"]);
4229
+ function projectSources(root2, directory = root2) {
4230
+ const entries2 = readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
4231
+ return entries2.flatMap((entry) => {
4232
+ if (entry.isDirectory())
4233
+ return SKIPPED.has(entry.name) ? [] : projectSources(root2, join2(directory, entry.name));
4234
+ return /\.(ts|mts|cts)$/.test(entry.name) ? [relative(root2, join2(directory, entry.name))] : [];
4235
+ });
4236
+ }
4237
+ var escape = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
4238
+ function declaresDevice(text, name) {
4239
+ const declares = new RegExp(`\\bname\\s*:\\s*(["'\`])${escape(name)}\\1`);
4240
+ return text.includes("defineDevice") && declares.test(text);
4241
+ }
4242
+ function deviceFiles(root2, name, sources = projectSources(root2)) {
4243
+ return sources.filter((file) => declaresDevice(readFileSync2(join2(root2, file), "utf8"), name));
4244
+ }
4245
+
4246
+ // src/cli/rename.ts
4247
+ var escape2 = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
4248
+ function planRename(root2, from, to, options) {
4249
+ const changes2 = [];
4250
+ const moves = [];
4251
+ const exact = new RegExp(`(["'\`])${escape2(from)}\\1`, "g");
4252
+ const anywhere = new RegExp(`(^|[^A-Za-z0-9_.-])${escape2(from)}(?![A-Za-z0-9_-])`);
4253
+ const sources = projectSources(root2);
4254
+ const declaring = new Set;
4255
+ for (const file of sources) {
4256
+ const text = readFileSync3(join3(root2, file), "utf8");
4257
+ const lines = text.split(`
4258
+ `);
4259
+ if (declaresDevice(text, from))
4260
+ declaring.add(file);
4261
+ for (const [index, before] of lines.entries()) {
4262
+ const after = before.replace(exact, (_match, quote3) => `${quote3}${to}${quote3}`);
4263
+ if (after !== before)
4264
+ changes2.push({ file, line: index + 1, before, after });
4265
+ }
4266
+ }
4267
+ if (!declaring.size) {
4268
+ throw new Error(`no file declares name: "${from}"; the name may be built from parts, such as a template string, so rename it by hand`);
4269
+ }
4270
+ for (const snapshot of options.snapshots) {
4271
+ moves.push({ from: snapshot, to: join3(dirname(snapshot), `${to}${extname(snapshot)}`) });
4272
+ }
4273
+ if (options.file) {
4274
+ if (declaring.size > 1)
4275
+ throw new Error(`${[...declaring].join(" and ")} both declare name: "${from}"; rename the file by hand`);
4276
+ const [file] = [...declaring];
4277
+ const target = join3(dirname(file), `${to}${extname(file)}`);
4278
+ if (existsSync2(join3(root2, target)))
4279
+ throw new Error(`${target} already exists`);
4280
+ moves.push({ from: file, to: target });
4281
+ changes2.push(...importChanges(root2, sources, file, target, changes2));
4282
+ }
4283
+ const mentions = sources.flatMap((file) => readFileSync3(join3(root2, file), "utf8").split(`
4284
+ `).flatMap((original, index) => {
4285
+ const text = changes2.find((change) => change.file === file && change.line === index + 1)?.after ?? original;
4286
+ return anywhere.test(text) ? [{ file, line: index + 1, text: text.trim() }] : [];
4287
+ }));
4288
+ return { changes: changes2, moves, mentions };
4289
+ }
4290
+ function importChanges(root2, sources, file, target, earlier) {
4291
+ const result = [];
4292
+ const withoutExtension = (path) => path.replace(/\.(ts|mts|cts|js|mjs|cjs)$/, "");
4293
+ const moved = withoutExtension(resolve2(root2, file));
4294
+ for (const source of sources) {
4295
+ const lines = readFileSync3(join3(root2, source), "utf8").split(`
4296
+ `);
4297
+ for (const [index, original] of lines.entries()) {
4298
+ const line2 = index + 1;
4299
+ const changed = earlier.find((change) => change.file === source && change.line === line2);
4300
+ const before = changed?.after ?? original;
4301
+ const after = before.replace(/(\bfrom\s+|\bimport\s*\(\s*)(["'])(\.{1,2}\/[^"']+)\2/g, (match, lead, quote3, specifier) => {
4302
+ if (withoutExtension(resolve2(root2, dirname(source), specifier)) !== moved)
4303
+ return match;
4304
+ const extension = specifier.match(/\.(ts|mts|cts|js|mjs|cjs)$/)?.[0] ?? "";
4305
+ const renamed = `${dirname(specifier)}/${basename(withoutExtension(target))}${extension}`;
4306
+ return `${lead}${quote3}${renamed}${quote3}`;
3890
4307
  });
3891
- const far = byName.get(link.device);
3892
- if (!far) {
3893
- report(`device ${link.device} is not in the network`);
3894
- continue;
3895
- }
3896
- const farPort = far.ports?.[link.port];
3897
- if (!farPort) {
3898
- report(`${link.device} does not declare port ${link.port}`);
4308
+ if (after === before)
3899
4309
  continue;
3900
- }
3901
- const linksBack = farPort.link?.device === device.name && farPort.link.port === portName;
3902
- if (!linksBack)
3903
- report(`${link.device} ${link.port} does not link back to it`);
3904
- const here = JSON.stringify(carried(device, port));
3905
- const there = JSON.stringify(carried(far, farPort));
3906
- if (here !== there)
3907
- report(`carries ${here}, and ${link.device} ${link.port} carries ${there}`);
4310
+ if (changed)
4311
+ changed.after = after;
4312
+ else
4313
+ result.push({ file: source, line: line2, before: original, after });
3908
4314
  }
3909
4315
  }
3910
- return findings;
4316
+ return result;
4317
+ }
4318
+ function applyRename(root2, plan4) {
4319
+ const files = new Set(plan4.changes.map((change) => change.file));
4320
+ for (const file of files) {
4321
+ const lines = readFileSync3(join3(root2, file), "utf8").split(`
4322
+ `);
4323
+ for (const change of plan4.changes.filter((each) => each.file === file))
4324
+ lines[change.line - 1] = change.after;
4325
+ writeFileSync(join3(root2, file), lines.join(`
4326
+ `));
4327
+ }
4328
+ for (const move of plan4.moves)
4329
+ renameSync(join3(root2, move.from), join3(root2, move.to));
3911
4330
  }
3912
4331
 
3913
4332
  // src/cli/index.ts
@@ -3930,6 +4349,22 @@ var HELP = `usage: circuit <command> [device...] [options]
3930
4349
  prometheus every device with SNMP, as Prometheus targets for snmp_exporter
3931
4350
  --auths snmp_exporter's auths for them instead, with secrets as \${NAME}
3932
4351
  secrets every secret the config uses, and whether each can be read
4352
+ rename <device> <new-name>
4353
+ give a device a new name in every file that names it, with --confirm
4354
+ --file also rename the file that declares it, to <new-name>.ts
4355
+ addresses [prefix] every address the config uses, by prefix, and what is still free
4356
+ --length=N free prefixes of this length, instead of the fewest that cover the free space
4357
+ --format=json as JSON
4358
+ graph [device...] the network as a graph, from the config alone, without contacting a device
4359
+ --layer=l1 l1, l2, l3, bgp, ospf or tunnels, several with commas, or all
4360
+ --format=mermaid mermaid, dot or json
4361
+ studio Circuit Studio: the network in the browser, drawn again whenever the config changes
4362
+ --port=4600 the port it listens on
4363
+ --no-open do not open the browser
4364
+ --host=0.0.0.0 listen on a server's address instead, with --allow-host
4365
+ --allow-host=studio.example.com
4366
+ the names it is reached by, several with commas
4367
+ --json print what Studio shows, as JSON, instead
3933
4368
 
3934
4369
  --config <path> the network's config file, circuit.config.ts in the working directory by default`;
3935
4370
  var DEFAULT_CONFIG = "circuit.config.ts";
@@ -3946,12 +4381,12 @@ if (!command2 || command2 === "help" || command2 === "--help" || command2 === "-
3946
4381
  var configAt = args.indexOf("--config");
3947
4382
  var configArgument = configAt >= 0 ? args[configAt + 1] : args.find((arg) => arg.startsWith("--config="))?.slice("--config=".length);
3948
4383
  var rest = args.filter((arg, index) => index !== configAt && (configAt < 0 || index !== configAt + 1) && !arg.startsWith("--config="));
3949
- var configPath = resolve2(process.cwd(), configArgument ?? DEFAULT_CONFIG);
3950
- if (!existsSync2(configPath)) {
4384
+ var configPath = resolve3(process.cwd(), configArgument ?? DEFAULT_CONFIG);
4385
+ if (!existsSync3(configPath)) {
3951
4386
  console.error(configArgument ? `no config at ${configPath}` : `no ${DEFAULT_CONFIG} in ${process.cwd()}; name another with --config <path>`);
3952
4387
  process.exit(1);
3953
4388
  }
3954
- var root2 = dirname(configPath);
4389
+ var root2 = dirname2(configPath);
3955
4390
  useRoot(root2);
3956
4391
  async function load(path) {
3957
4392
  if (process.versions.bun)
@@ -3983,9 +4418,9 @@ function one() {
3983
4418
  return device ?? fail("name a device");
3984
4419
  }
3985
4420
  var adapterOf = (device) => adapters[device.platform] ?? fail(`no adapter for ${device.platform}`);
3986
- var generated = join2(root2, ".circuit");
3987
- var stateDirectory = join2(generated, "state");
3988
- var statePath = (device) => join2(stateDirectory, device.name + adapterOf(device).extension);
4421
+ var generated = join4(root2, ".circuit");
4422
+ var stateDirectory = join4(generated, "state");
4423
+ var statePath = (device) => join4(stateDirectory, device.name + adapterOf(device).extension);
3989
4424
  var GENERATED_README = `# Written by Circuit
3990
4425
 
3991
4426
  Do not edit anything here: an edit changes nothing on a device, and Circuit overwrites it. Change the config instead.
@@ -3994,8 +4429,8 @@ Do not edit anything here: an edit changes nothing on a device, and Circuit over
3994
4429
  `;
3995
4430
  function writeState(device, text) {
3996
4431
  mkdirSync(stateDirectory, { recursive: true });
3997
- writeFileSync(join2(generated, "README.md"), GENERATED_README);
3998
- writeFileSync(statePath(device), text);
4432
+ writeFileSync2(join4(generated, "README.md"), GENERATED_README);
4433
+ writeFileSync2(statePath(device), text);
3999
4434
  }
4000
4435
  function guard(devices) {
4001
4436
  const relevant = new Set(devices.map((device) => device.name));
@@ -4037,12 +4472,12 @@ function diffCommand() {
4037
4472
  let differences = 0;
4038
4473
  for (const device of devices) {
4039
4474
  const path = statePath(device);
4040
- if (!existsSync2(path)) {
4475
+ if (!existsSync3(path)) {
4041
4476
  console.log(`${device.name}: no snapshot, run snapshot first`);
4042
4477
  continue;
4043
4478
  }
4044
4479
  const adapter = adapterOf(device);
4045
- const plan4 = adapter.plan(adapter.render(device), readFileSync2(path, "utf8"), options);
4480
+ const plan4 = adapter.plan(adapter.render(device), readFileSync4(path, "utf8"), options);
4046
4481
  differences += show(device, plan4);
4047
4482
  }
4048
4483
  process.exit(differences ? 1 : 0);
@@ -4054,13 +4489,13 @@ async function snapshotCommand() {
4054
4489
  console.log(`${device.name}: ${text.split(`
4055
4490
  `).length} lines`);
4056
4491
  }
4057
- if (names.length || !existsSync2(stateDirectory))
4492
+ if (names.length || !existsSync3(stateDirectory))
4058
4493
  return;
4059
- const current = new Set(network2.devices.map((device) => basename(statePath(device))));
4060
- for (const file of readdirSync(stateDirectory)) {
4494
+ const current = new Set(network2.devices.map((device) => basename2(statePath(device))));
4495
+ for (const file of readdirSync2(stateDirectory)) {
4061
4496
  if (current.has(file))
4062
4497
  continue;
4063
- rmSync(join2(stateDirectory, file));
4498
+ rmSync(join4(stateDirectory, file));
4064
4499
  console.log(`${file}: removed, no such device in the config`);
4065
4500
  }
4066
4501
  }
@@ -4223,6 +4658,162 @@ function secretsCommand() {
4223
4658
  ${references.length} secret(s), ${missing} missing.`);
4224
4659
  process.exit(missing ? 1 : 0);
4225
4660
  }
4661
+ var flagValue = (name) => flags.find((flag) => flag.startsWith(`--${name}=`))?.slice(name.length + 3);
4662
+ function driftOf(device) {
4663
+ const path = statePath(device);
4664
+ if (!existsSync3(path))
4665
+ return { status: "no snapshot", lines: [] };
4666
+ try {
4667
+ const adapter = adapterOf(device);
4668
+ const plan4 = adapter.plan(adapter.render(device), readFileSync4(path, "utf8"), options);
4669
+ const lines = [
4670
+ ...plan4.steps.flatMap((step) => [step.title, ...step.show.map((line2) => ` ${line2}`)]),
4671
+ ...plan4.problems.map((problem) => `PROBLEM ${problem}`)
4672
+ ];
4673
+ return { status: lines.length ? "differs" : "matches", lines };
4674
+ } catch (error) {
4675
+ return { status: "cannot plan", lines: [error.message] };
4676
+ }
4677
+ }
4678
+ function annotatedGraph(layers, deviceNames) {
4679
+ const graph = only(graphOf(network2.devices), layers, deviceNames);
4680
+ const shown = new Set(graph.nodes.map((node) => node.label));
4681
+ return {
4682
+ ...graph,
4683
+ findings: validate(network2).filter((finding) => !finding.device || shown.has(finding.device)),
4684
+ drift: Object.fromEntries(network2.devices.filter((device) => shown.has(device.name)).map((device) => [device.name, driftOf(device)]))
4685
+ };
4686
+ }
4687
+ function graphCommand() {
4688
+ const requested = (flagValue("layer") ?? "l1").split(",");
4689
+ const layers = requested.includes("all") ? [...LAYERS] : requested;
4690
+ const unknown = layers.filter((layer) => !LAYERS.includes(layer));
4691
+ if (unknown.length)
4692
+ fail(`no layer ${unknown.join(", ")}; known: ${LAYERS.join(", ")}, all`);
4693
+ const format = flagValue("format") ?? "mermaid";
4694
+ const devices = selected(true);
4695
+ const annotated = annotatedGraph(layers, names.length ? devices.map((device) => device.name) : []);
4696
+ if (format === "mermaid")
4697
+ process.stdout.write(mermaid(annotated));
4698
+ else if (format === "dot")
4699
+ process.stdout.write(dot(annotated));
4700
+ else if (format === "json")
4701
+ console.log(JSON.stringify(annotated, null, 4));
4702
+ else
4703
+ fail(`no format ${format}; known: mermaid, dot, json`);
4704
+ }
4705
+ function printSegment(segment, indent) {
4706
+ const next = segment.next ? `, next ${segment.next}` : "";
4707
+ console.log(`${indent}${segment.prefix}, ${segment.free} free${next}`);
4708
+ const width = Math.max(...segment.uses.map((use) => use.address.length)) + 2;
4709
+ for (const use of segment.uses)
4710
+ console.log(`${indent} ${use.address.padEnd(width)}${use.what}`);
4711
+ }
4712
+ function addressesCommand() {
4713
+ const [within] = names;
4714
+ if (within && !isCidr(within))
4715
+ fail(`${within} is not a prefix, such as 10.0.0.0/16`);
4716
+ const lengthText = flagValue("length");
4717
+ const length = lengthText === undefined ? undefined : Number(lengthText);
4718
+ if (length !== undefined && !(Number.isInteger(length) && length >= 0 && length <= 128))
4719
+ fail("--length takes a prefix length, such as 24");
4720
+ const report = addressesOf(network2.devices, within);
4721
+ if (flagValue("format") === "json") {
4722
+ console.log(JSON.stringify(report, null, 4));
4723
+ return;
4724
+ }
4725
+ for (const space of report.spaces) {
4726
+ console.log(`${space.prefix}, ${space.owner}`);
4727
+ for (const segment of space.segments)
4728
+ printSegment(segment, " ");
4729
+ for (const block of space.blocks)
4730
+ console.log(` ${block.prefix} ${block.what}`);
4731
+ if (length === undefined) {
4732
+ console.log(` free: ${space.free.join(", ") || "none"}`);
4733
+ } else {
4734
+ const { prefixes, total } = freeOfLength(space.free, length, 16);
4735
+ const more = total > BigInt(prefixes.length) ? `, and ${total - BigInt(prefixes.length)} more` : "";
4736
+ console.log(` free /${length}: ${prefixes.join(", ") || "none"}${more}`);
4737
+ }
4738
+ console.log("");
4739
+ }
4740
+ const { segments, blocks } = report.elsewhere;
4741
+ if (segments.length || blocks.length) {
4742
+ console.log("Outside the space the network originates");
4743
+ for (const segment of segments)
4744
+ printSegment(segment, " ");
4745
+ for (const block of blocks)
4746
+ console.log(` ${block.prefix} ${block.what}`);
4747
+ }
4748
+ if (!report.spaces.length && !segments.length && !blocks.length)
4749
+ console.log("The config holds no addresses.");
4750
+ }
4751
+ function renameCommand() {
4752
+ const [from, to] = names;
4753
+ if (!from || !to)
4754
+ fail("usage: circuit rename <device> <new-name> [--file] [--confirm]");
4755
+ const device = network2.devices.find((candidate) => candidate.name === from);
4756
+ if (!device)
4757
+ fail(`no device ${from}; known: ${network2.devices.map((candidate) => candidate.name).join(", ")}`);
4758
+ if (network2.devices.some((candidate) => candidate.name === to))
4759
+ fail(`${to} is already a device`);
4760
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(to))
4761
+ fail(`${to} is not a hostname: letters, digits, dots, dashes and underscores`);
4762
+ const snapshot = statePath(device);
4763
+ const snapshots = existsSync3(snapshot) ? [relative2(root2, snapshot)] : [];
4764
+ let plan4;
4765
+ try {
4766
+ plan4 = planRename(root2, from, to, { file: flags.includes("--file"), snapshots });
4767
+ } catch (error) {
4768
+ fail(error.message);
4769
+ }
4770
+ for (const change of plan4.changes) {
4771
+ console.log(`${change.file}:${change.line}`);
4772
+ console.log(` - ${change.before.trim()}`);
4773
+ console.log(` + ${change.after.trim()}`);
4774
+ }
4775
+ for (const move of plan4.moves)
4776
+ console.log(`move ${move.from} to ${move.to}`);
4777
+ if (plan4.mentions.length) {
4778
+ console.log(`
4779
+ Still naming ${from}, left as they are: change them by hand if they mean the device.`);
4780
+ for (const mention of plan4.mentions)
4781
+ console.log(` ${mention.file}:${mention.line} ${mention.text.slice(0, 160)}`);
4782
+ }
4783
+ if (!confirmed) {
4784
+ console.log(`
4785
+ Nothing written. Re-run with --confirm.`);
4786
+ return;
4787
+ }
4788
+ applyRename(root2, plan4);
4789
+ console.log(`
4790
+ Renamed. Run circuit validate, then circuit apply ${to}: the device keeps its old hostname until then.`);
4791
+ }
4792
+ async function studioCommand() {
4793
+ if (flags.includes("--json")) {
4794
+ const sources = projectSources(root2);
4795
+ const data = studioData(network2.devices, annotatedGraph(LAYERS, []), {
4796
+ render: (device) => adapterOf(device).render(device),
4797
+ snapshot: (device) => existsSync3(statePath(device)) ? readFileSync4(statePath(device), "utf8") : undefined,
4798
+ files: (device) => ({
4799
+ source: deviceFiles(root2, device.name, sources)[0]?.split(sep).join("/"),
4800
+ snapshot: existsSync3(statePath(device)) ? relative2(root2, statePath(device)).split(sep).join("/") : undefined
4801
+ })
4802
+ });
4803
+ console.log(JSON.stringify(data));
4804
+ return;
4805
+ }
4806
+ const port = Number(flagValue("port") ?? 4600);
4807
+ if (!Number.isInteger(port) || port < 1 || port > 65535)
4808
+ fail("--port takes a number from 1 to 65535");
4809
+ const { isLoopback, studio } = await import("../chunks/server-5shmsmse.js");
4810
+ const host2 = flagValue("host") ?? "127.0.0.1";
4811
+ const allowHosts = (flagValue("allow-host") ?? "").split(",").filter(Boolean);
4812
+ if (!isLoopback(host2) && !allowHosts.length) {
4813
+ fail(`Studio on ${host2} needs --allow-host=<name>, the name people reach it by, such as studio.example.com`);
4814
+ }
4815
+ await studio({ configPath, root: root2, port, open: !flags.includes("--no-open"), host: host2, allowHosts });
4816
+ }
4226
4817
  var commands = {
4227
4818
  validate: validateCommand,
4228
4819
  build: buildCommand,
@@ -4235,7 +4826,11 @@ var commands = {
4235
4826
  peeringdb: peeringdbCommand,
4236
4827
  librenms: librenmsCommand,
4237
4828
  prometheus: prometheusCommand,
4238
- secrets: secretsCommand
4829
+ secrets: secretsCommand,
4830
+ rename: renameCommand,
4831
+ addresses: addressesCommand,
4832
+ graph: graphCommand,
4833
+ studio: studioCommand
4239
4834
  };
4240
4835
  var run = commands[command2];
4241
4836
  if (run)