@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/src/cli/index.ts CHANGED
@@ -3,18 +3,24 @@
3
3
 
4
4
  import { createPrivateKey, createPublicKey } from "node:crypto";
5
5
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
6
- import { basename, dirname, join, resolve } from "node:path";
6
+ import { basename, dirname, join, relative, resolve, sep } from "node:path";
7
7
  import { adapters } from "../adapters/devices";
8
8
  import type { ApplyOptions, DeviceAdapter, Plan } from "../adapters/devices/types";
9
9
  import { registries } from "../adapters/registries";
10
10
  import { planLibreNms, sendLibreNms } from "../adapters/registries/librenms";
11
11
  import { planPeeringDb, sendPeeringDb } from "../adapters/registries/peeringdb";
12
12
  import { prometheusTargets, snmpExporterAuths } from "../adapters/registries/prometheus";
13
- import { network as prefixOf } from "../core/addr";
13
+ import { isCidr, network as prefixOf } from "../core/addr";
14
+ import { addressesOf, freeOfLength, type Segment } from "../core/addresses";
14
15
  import type { Network } from "../core/define";
16
+ import { type Annotated, type Drift, graphOf, LAYERS, only, type Layer } from "../core/graph";
15
17
  import { isSecret, referenceOf, resolve as reveal, sourceOf, useRoot } from "../core/secrets";
16
18
  import { validate } from "../core/validate";
17
19
  import type { Device } from "../schema";
20
+ import { dot, mermaid } from "./graph";
21
+ import { applyRename, planRename, type RenamePlan } from "./rename";
22
+ import { deviceFiles, projectSources } from "../core/files";
23
+ import { studioData } from "../studio/data";
18
24
 
19
25
  const HELP = `usage: circuit <command> [device...] [options]
20
26
 
@@ -35,6 +41,22 @@ const HELP = `usage: circuit <command> [device...] [options]
35
41
  prometheus every device with SNMP, as Prometheus targets for snmp_exporter
36
42
  --auths snmp_exporter's auths for them instead, with secrets as \${NAME}
37
43
  secrets every secret the config uses, and whether each can be read
44
+ rename <device> <new-name>
45
+ give a device a new name in every file that names it, with --confirm
46
+ --file also rename the file that declares it, to <new-name>.ts
47
+ addresses [prefix] every address the config uses, by prefix, and what is still free
48
+ --length=N free prefixes of this length, instead of the fewest that cover the free space
49
+ --format=json as JSON
50
+ graph [device...] the network as a graph, from the config alone, without contacting a device
51
+ --layer=l1 l1, l2, l3, bgp, ospf or tunnels, several with commas, or all
52
+ --format=mermaid mermaid, dot or json
53
+ studio Circuit Studio: the network in the browser, drawn again whenever the config changes
54
+ --port=4600 the port it listens on
55
+ --no-open do not open the browser
56
+ --host=0.0.0.0 listen on a server's address instead, with --allow-host
57
+ --allow-host=studio.example.com
58
+ the names it is reached by, several with commas
59
+ --json print what Studio shows, as JSON, instead
38
60
 
39
61
  --config <path> the network's config file, circuit.config.ts in the working directory by default`;
40
62
 
@@ -395,6 +417,183 @@ ${references.length} secret(s), ${missing} missing.`);
395
417
  process.exit(missing ? 1 : 0);
396
418
  }
397
419
 
420
+ /** The value of `--name=value`, or undefined when it is not given. */
421
+ const flagValue = (name: string) => flags.find((flag) => flag.startsWith(`--${name}=`))?.slice(name.length + 3);
422
+
423
+ /** How a device compares with its last snapshot, offline, as `diff` plans it. */
424
+ function driftOf(device: Device): Drift {
425
+ const path = statePath(device);
426
+ if (!existsSync(path)) return { status: "no snapshot", lines: [] };
427
+
428
+ try {
429
+ const adapter = adapterOf(device);
430
+ const plan = adapter.plan(adapter.render(device), readFileSync(path, "utf8"), options);
431
+
432
+ const lines = [
433
+ ...plan.steps.flatMap((step) => [step.title, ...step.show.map((line) => ` ${line}`)]),
434
+ ...plan.problems.map((problem) => `PROBLEM ${problem}`),
435
+ ];
436
+
437
+ return { status: lines.length ? "differs" : "matches", lines };
438
+ } catch (error) {
439
+ return { status: "cannot plan", lines: [(error as Error).message] };
440
+ }
441
+ }
442
+
443
+ /** The graph on these layers, around these devices when any are named, with what validate found and how each device drawn compares with its snapshot. */
444
+ function annotatedGraph(layers: readonly Layer[], deviceNames: readonly string[]): Annotated {
445
+ const graph = only(graphOf(network.devices), layers, deviceNames);
446
+ const shown = new Set(graph.nodes.map((node) => node.label));
447
+
448
+ return {
449
+ ...graph,
450
+ findings: validate(network).filter((finding) => !finding.device || shown.has(finding.device)),
451
+ drift: Object.fromEntries(
452
+ network.devices.filter((device) => shown.has(device.name)).map((device) => [device.name, driftOf(device)])
453
+ ),
454
+ };
455
+ }
456
+
457
+ function graphCommand(): void {
458
+ const requested = (flagValue("layer") ?? "l1").split(",");
459
+ const layers = requested.includes("all") ? [...LAYERS] : (requested as Layer[]);
460
+
461
+ const unknown = layers.filter((layer) => !LAYERS.includes(layer));
462
+ if (unknown.length) fail(`no layer ${unknown.join(", ")}; known: ${LAYERS.join(", ")}, all`);
463
+
464
+ const format = flagValue("format") ?? "mermaid";
465
+ const devices = selected(true);
466
+ const annotated = annotatedGraph(layers, names.length ? devices.map((device) => device.name) : []);
467
+
468
+ if (format === "mermaid") process.stdout.write(mermaid(annotated));
469
+ else if (format === "dot") process.stdout.write(dot(annotated));
470
+ else if (format === "json") console.log(JSON.stringify(annotated, null, 4));
471
+ else fail(`no format ${format}; known: mermaid, dot, json`);
472
+ }
473
+
474
+ /** One segment and what uses each address in it. */
475
+ function printSegment(segment: Segment, indent: string): void {
476
+ const next = segment.next ? `, next ${segment.next}` : "";
477
+ console.log(`${indent}${segment.prefix}, ${segment.free} free${next}`);
478
+
479
+ const width = Math.max(...segment.uses.map((use) => use.address.length)) + 2;
480
+ for (const use of segment.uses) console.log(`${indent} ${use.address.padEnd(width)}${use.what}`);
481
+ }
482
+
483
+ function addressesCommand(): void {
484
+ const [within] = names;
485
+ if (within && !isCidr(within)) fail(`${within} is not a prefix, such as 10.0.0.0/16`);
486
+
487
+ const lengthText = flagValue("length");
488
+ const length = lengthText === undefined ? undefined : Number(lengthText);
489
+ if (length !== undefined && !(Number.isInteger(length) && length >= 0 && length <= 128))
490
+ fail("--length takes a prefix length, such as 24");
491
+
492
+ const report = addressesOf(network.devices, within);
493
+
494
+ if (flagValue("format") === "json") {
495
+ console.log(JSON.stringify(report, null, 4));
496
+ return;
497
+ }
498
+
499
+ for (const space of report.spaces) {
500
+ console.log(`${space.prefix}, ${space.owner}`);
501
+ for (const segment of space.segments) printSegment(segment, " ");
502
+ for (const block of space.blocks) console.log(` ${block.prefix} ${block.what}`);
503
+
504
+ if (length === undefined) {
505
+ console.log(` free: ${space.free.join(", ") || "none"}`);
506
+ } else {
507
+ const { prefixes, total } = freeOfLength(space.free, length, 16);
508
+ const more = total > BigInt(prefixes.length) ? `, and ${total - BigInt(prefixes.length)} more` : "";
509
+ console.log(` free /${length}: ${prefixes.join(", ") || "none"}${more}`);
510
+ }
511
+
512
+ console.log("");
513
+ }
514
+
515
+ const { segments, blocks } = report.elsewhere;
516
+ if (segments.length || blocks.length) {
517
+ console.log("Outside the space the network originates");
518
+ for (const segment of segments) printSegment(segment, " ");
519
+ for (const block of blocks) console.log(` ${block.prefix} ${block.what}`);
520
+ }
521
+
522
+ if (!report.spaces.length && !segments.length && !blocks.length) console.log("The config holds no addresses.");
523
+ }
524
+
525
+ function renameCommand(): void {
526
+ const [from, to] = names;
527
+ if (!from || !to) fail("usage: circuit rename <device> <new-name> [--file] [--confirm]");
528
+
529
+ const device = network.devices.find((candidate) => candidate.name === from);
530
+ if (!device) fail(`no device ${from}; known: ${network.devices.map((candidate) => candidate.name).join(", ")}`);
531
+ if (network.devices.some((candidate) => candidate.name === to)) fail(`${to} is already a device`);
532
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(to)) fail(`${to} is not a hostname: letters, digits, dots, dashes and underscores`);
533
+
534
+ const snapshot = statePath(device);
535
+ const snapshots = existsSync(snapshot) ? [relative(root, snapshot)] : [];
536
+
537
+ let plan: RenamePlan;
538
+ try {
539
+ plan = planRename(root, from, to, { file: flags.includes("--file"), snapshots });
540
+ } catch (error) {
541
+ fail((error as Error).message);
542
+ }
543
+
544
+ for (const change of plan.changes) {
545
+ console.log(`${change.file}:${change.line}`);
546
+ console.log(` - ${change.before.trim()}`);
547
+ console.log(` + ${change.after.trim()}`);
548
+ }
549
+
550
+ for (const move of plan.moves) console.log(`move ${move.from} to ${move.to}`);
551
+
552
+ if (plan.mentions.length) {
553
+ console.log(`\nStill naming ${from}, left as they are: change them by hand if they mean the device.`);
554
+ for (const mention of plan.mentions) console.log(` ${mention.file}:${mention.line} ${mention.text.slice(0, 160)}`);
555
+ }
556
+
557
+ if (!confirmed) {
558
+ console.log("\nNothing written. Re-run with --confirm.");
559
+ return;
560
+ }
561
+
562
+ applyRename(root, plan);
563
+ console.log(`\nRenamed. Run circuit validate, then circuit apply ${to}: the device keeps its old hostname until then.`);
564
+ }
565
+
566
+ async function studioCommand(): Promise<void> {
567
+ if (flags.includes("--json")) {
568
+ const sources = projectSources(root);
569
+ const data = studioData(network.devices, annotatedGraph(LAYERS, []), {
570
+ render: (device) => adapterOf(device).render(device),
571
+ snapshot: (device) => (existsSync(statePath(device)) ? readFileSync(statePath(device), "utf8") : undefined),
572
+ files: (device) => ({
573
+ // Written with forward slashes on every system, as git and the page take them.
574
+ source: deviceFiles(root, device.name, sources)[0]?.split(sep).join("/"),
575
+ snapshot: existsSync(statePath(device)) ? relative(root, statePath(device)).split(sep).join("/") : undefined,
576
+ }),
577
+ });
578
+ console.log(JSON.stringify(data));
579
+ return;
580
+ }
581
+
582
+ const port = Number(flagValue("port") ?? 4600);
583
+ if (!Number.isInteger(port) || port < 1 || port > 65535) fail("--port takes a number from 1 to 65535");
584
+
585
+ const { isLoopback, studio } = await import("../studio/server");
586
+ const host = flagValue("host") ?? "127.0.0.1";
587
+ const allowHosts = (flagValue("allow-host") ?? "").split(",").filter(Boolean);
588
+
589
+ // Off this machine, Studio must be told the names it is reached by: every other name is refused.
590
+ if (!isLoopback(host) && !allowHosts.length) {
591
+ fail(`Studio on ${host} needs --allow-host=<name>, the name people reach it by, such as studio.example.com`);
592
+ }
593
+
594
+ await studio({ configPath, root, port, open: !flags.includes("--no-open"), host, allowHosts });
595
+ }
596
+
398
597
  const commands: Record<string, () => void | Promise<void>> = {
399
598
  validate: validateCommand,
400
599
  build: buildCommand,
@@ -408,6 +607,10 @@ const commands: Record<string, () => void | Promise<void>> = {
408
607
  librenms: librenmsCommand,
409
608
  prometheus: prometheusCommand,
410
609
  secrets: secretsCommand,
610
+ rename: renameCommand,
611
+ addresses: addressesCommand,
612
+ graph: graphCommand,
613
+ studio: studioCommand,
411
614
  };
412
615
 
413
616
  const run = commands[command];
@@ -0,0 +1,129 @@
1
+ // `circuit rename`: a device's new name everywhere the project's files name it, its snapshot renamed to match, and with `--file` its file too. It plans first and writes only with --confirm.
2
+
3
+ import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
4
+ import { basename, dirname, extname, join, resolve } from "node:path";
5
+ import { declaresDevice, projectSources } from "../core/files";
6
+
7
+ /** One line that changes. */
8
+ export type LineChange = { file: string; line: number; before: string; after: string };
9
+
10
+ export type RenamePlan = {
11
+ changes: LineChange[];
12
+ /** Files that move, from and to, relative to the project. */
13
+ moves: { from: string; to: string }[];
14
+ /** Lines that still name the device inside a longer text, such as a description or a comment, which the rename leaves alone. */
15
+ mentions: { file: string; line: number; text: string }[];
16
+ };
17
+
18
+ const escape = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
19
+
20
+ /**
21
+ * Plan renaming a device from `from` to `to` in the project at `root`.
22
+ *
23
+ * A string that is exactly the old name becomes the new name: `name: "edge-01"`, `link: { device: "edge-01", ... }`, a check's list of routers, and a key such as a tenant's ports by device, which would otherwise no longer be found. A BGP neighbor named after the device is renamed too, which renames its session on the device: `circuit diff` shows it.
24
+ */
25
+ export function planRename(root: string, from: string, to: string, options: { file: boolean; snapshots: string[] }): RenamePlan {
26
+ const changes: LineChange[] = [];
27
+ const moves: RenamePlan["moves"] = [];
28
+
29
+ const exact = new RegExp(`(["'\`])${escape(from)}\\1`, "g");
30
+ const anywhere = new RegExp(`(^|[^A-Za-z0-9_.-])${escape(from)}(?![A-Za-z0-9_-])`);
31
+
32
+ const sources = projectSources(root);
33
+ const declaring = new Set<string>();
34
+
35
+ for (const file of sources) {
36
+ const text = readFileSync(join(root, file), "utf8");
37
+ const lines = text.split("\n");
38
+
39
+ // The device's own file calls defineDevice and gives it the name.
40
+ if (declaresDevice(text, from)) declaring.add(file);
41
+
42
+ for (const [index, before] of lines.entries()) {
43
+ const after = before.replace(exact, (_match, quote: string) => `${quote}${to}${quote}`);
44
+ if (after !== before) changes.push({ file, line: index + 1, before, after });
45
+ }
46
+ }
47
+
48
+ if (!declaring.size) {
49
+ throw new Error(
50
+ `no file declares name: "${from}"; the name may be built from parts, such as a template string, so rename it by hand`
51
+ );
52
+ }
53
+
54
+ for (const snapshot of options.snapshots) {
55
+ moves.push({ from: snapshot, to: join(dirname(snapshot), `${to}${extname(snapshot)}`) });
56
+ }
57
+
58
+ if (options.file) {
59
+ if (declaring.size > 1) throw new Error(`${[...declaring].join(" and ")} both declare name: "${from}"; rename the file by hand`);
60
+
61
+ const [file] = [...declaring] as [string];
62
+ const target = join(dirname(file), `${to}${extname(file)}`);
63
+ if (existsSync(join(root, target))) throw new Error(`${target} already exists`);
64
+
65
+ moves.push({ from: file, to: target });
66
+ changes.push(...importChanges(root, sources, file, target, changes));
67
+ }
68
+
69
+ // What still names the device once every change is made.
70
+ const mentions = sources.flatMap((file) =>
71
+ readFileSync(join(root, file), "utf8")
72
+ .split("\n")
73
+ .flatMap((original, index) => {
74
+ const text = changes.find((change) => change.file === file && change.line === index + 1)?.after ?? original;
75
+ return anywhere.test(text) ? [{ file, line: index + 1, text: text.trim() }] : [];
76
+ })
77
+ );
78
+
79
+ return { changes, moves, mentions };
80
+ }
81
+
82
+ /** Imports of a file that moves, pointed at its new name. Lines already changed are changed further. */
83
+ function importChanges(root: string, sources: string[], file: string, target: string, earlier: LineChange[]): LineChange[] {
84
+ const result: LineChange[] = [];
85
+ const withoutExtension = (path: string) => path.replace(/\.(ts|mts|cts|js|mjs|cjs)$/, "");
86
+ const moved = withoutExtension(resolve(root, file));
87
+
88
+ for (const source of sources) {
89
+ const lines = readFileSync(join(root, source), "utf8").split("\n");
90
+
91
+ for (const [index, original] of lines.entries()) {
92
+ const line = index + 1;
93
+ const changed = earlier.find((change) => change.file === source && change.line === line);
94
+ const before = changed?.after ?? original;
95
+
96
+ // A relative specifier that resolves to the moved file keeps its own form, extension or not.
97
+ const after = before.replace(
98
+ /(\bfrom\s+|\bimport\s*\(\s*)(["'])(\.{1,2}\/[^"']+)\2/g,
99
+ (match, lead: string, quote: string, specifier: string) => {
100
+ if (withoutExtension(resolve(root, dirname(source), specifier)) !== moved) return match;
101
+
102
+ const extension = specifier.match(/\.(ts|mts|cts|js|mjs|cjs)$/)?.[0] ?? "";
103
+ const renamed = `${dirname(specifier)}/${basename(withoutExtension(target))}${extension}`;
104
+ return `${lead}${quote}${renamed}${quote}`;
105
+ }
106
+ );
107
+
108
+ if (after === before) continue;
109
+
110
+ if (changed) changed.after = after;
111
+ else result.push({ file: source, line, before: original, after });
112
+ }
113
+ }
114
+
115
+ return result;
116
+ }
117
+
118
+ /** Write the plan: each changed line, then each move. */
119
+ export function applyRename(root: string, plan: RenamePlan): void {
120
+ const files = new Set(plan.changes.map((change) => change.file));
121
+
122
+ for (const file of files) {
123
+ const lines = readFileSync(join(root, file), "utf8").split("\n");
124
+ for (const change of plan.changes.filter((each) => each.file === file)) lines[change.line - 1] = change.after;
125
+ writeFileSync(join(root, file), lines.join("\n"));
126
+ }
127
+
128
+ for (const move of plan.moves) renameSync(join(root, move.from), join(root, move.to));
129
+ }