@takodotid/circuit 0.43.0 → 1.0.0-rc.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 (257) hide show
  1. package/README.md +115 -37
  2. package/package.json +21 -58
  3. package/src/checks/across.ts +402 -0
  4. package/src/checks/built-in.ts +256 -0
  5. package/src/checks/device.ts +436 -0
  6. package/src/checks/index.ts +53 -0
  7. package/src/cli/advisories.ts +108 -0
  8. package/src/cli/app.ts +67 -0
  9. package/src/cli/editor-types.generated.ts +30 -0
  10. package/src/cli/editor-types.ts +69 -0
  11. package/src/cli/format.ts +124 -0
  12. package/src/cli/git-hooks.ts +79 -0
  13. package/src/cli/index.ts +348 -1170
  14. package/src/cli/migrate.ts +554 -0
  15. package/src/cli/project-commands.ts +656 -0
  16. package/src/cli/releases.ts +374 -0
  17. package/src/cli/remember.ts +339 -0
  18. package/src/cli/rename.ts +27 -33
  19. package/src/cli/secrets-password.ts +60 -0
  20. package/src/cli/secrets.ts +258 -0
  21. package/src/cli/setup.ts +300 -0
  22. package/src/cli/signing.ts +23 -0
  23. package/src/cli/text-editor.ts +268 -0
  24. package/src/cli/ui.ts +298 -0
  25. package/src/cli/version.ts +2 -0
  26. package/src/cli/versions.ts +158 -0
  27. package/src/config/address.ts +102 -0
  28. package/src/config/circuit.d.ts +30 -0
  29. package/src/config/common.ts +65 -0
  30. package/src/config/communities.ts +479 -0
  31. package/src/config/define.ts +684 -0
  32. package/src/config/device.ts +164 -0
  33. package/src/config/firewall.ts +150 -0
  34. package/src/config/index.ts +22 -0
  35. package/src/{schema → config}/interfaces.ts +121 -85
  36. package/src/config/keys.ts +434 -0
  37. package/src/config/management.ts +61 -0
  38. package/src/config/merge.ts +335 -0
  39. package/src/config/models.ts +70 -0
  40. package/src/config/monitoring.ts +64 -0
  41. package/src/config/network.ts +180 -0
  42. package/src/config/presets.ts +149 -0
  43. package/src/config/routing.ts +401 -0
  44. package/src/config/services.ts +51 -0
  45. package/src/config/switching.ts +84 -0
  46. package/src/config/system.ts +103 -0
  47. package/src/{schema → config}/time-zones.ts +1 -1
  48. package/src/core/address.ts +1 -1
  49. package/src/core/cabling.ts +8 -43
  50. package/src/core/graph.ts +40 -0
  51. package/src/{integrations → core}/registries.ts +5 -3
  52. package/src/core/secrets-file.ts +539 -0
  53. package/src/core/secrets.ts +83 -64
  54. package/src/core/topology.ts +103 -146
  55. package/src/core/utils.ts +12 -1
  56. package/src/model.ts +83 -0
  57. package/src/platforms/derive.ts +64 -37
  58. package/src/platforms/finding.ts +9 -0
  59. package/src/platforms/fortios/firewall.ts +76 -64
  60. package/src/platforms/fortios/index.ts +189 -125
  61. package/src/platforms/fortios/names.ts +51 -18
  62. package/src/platforms/fortios/plan.ts +132 -44
  63. package/src/platforms/fortios/render.ts +69 -62
  64. package/src/platforms/fortios/routing.ts +89 -58
  65. package/src/platforms/fortios/vdoms.ts +47 -16
  66. package/src/platforms/junos/TRAPS.md +2 -2
  67. package/src/platforms/junos/bgp.ts +63 -42
  68. package/src/platforms/junos/index.ts +122 -89
  69. package/src/platforms/junos/render.ts +139 -104
  70. package/src/platforms/platform.ts +10 -8
  71. package/src/platforms/raisecom-ros/TRAPS.md +1 -1
  72. package/src/platforms/raisecom-ros/index.ts +89 -70
  73. package/src/platforms/raisecom-ros/render.ts +49 -45
  74. package/src/platforms/routeros/TRAPS.md +3 -3
  75. package/src/platforms/routeros/index.ts +121 -60
  76. package/src/platforms/routeros/render.ts +214 -195
  77. package/src/platforms/vrp/TRAPS.md +1 -1
  78. package/src/platforms/vrp/index.ts +74 -50
  79. package/src/platforms/vrp/render.ts +67 -56
  80. package/src/project/project.ts +725 -0
  81. package/src/studio/app/env.d.ts +9 -0
  82. package/src/studio/app/index.html +18 -0
  83. package/src/studio/app/src/App.vue +95 -0
  84. package/src/studio/app/src/components/AddressesView.vue +38 -0
  85. package/src/studio/app/src/components/CablesView.vue +29 -0
  86. package/src/studio/app/src/components/CompareView.vue +160 -0
  87. package/src/studio/app/src/components/DataTable.vue +49 -0
  88. package/src/studio/app/src/components/DeviceView.vue +237 -0
  89. package/src/studio/app/src/components/DevicesView.vue +32 -0
  90. package/src/studio/app/src/components/Faceplate.vue +75 -0
  91. package/src/studio/app/src/components/GraphView.vue +282 -0
  92. package/src/studio/app/src/components/LineDiff.vue +24 -0
  93. package/src/studio/app/src/components/ProblemsView.vue +42 -0
  94. package/src/studio/app/src/components/SearchView.vue +72 -0
  95. package/src/studio/app/src/components/SecretsView.vue +112 -0
  96. package/src/studio/app/src/components/SessionsView.vue +35 -0
  97. package/src/studio/app/src/components/Settings.vue +95 -0
  98. package/src/studio/app/src/components/ToolsView.vue +118 -0
  99. package/src/studio/app/src/components/VlansView.vue +50 -0
  100. package/src/studio/app/src/lib/csv.ts +19 -0
  101. package/src/studio/{public/diff.js → app/src/lib/diff.ts} +40 -35
  102. package/src/studio/app/src/lib/format.ts +38 -0
  103. package/src/studio/app/src/lib/net.ts +68 -0
  104. package/src/studio/app/src/lib/secrets-crypto.ts +35 -0
  105. package/src/studio/app/src/lib/store.ts +71 -0
  106. package/src/studio/app/src/lib/tools.ts +217 -0
  107. package/src/studio/app/src/main.ts +9 -0
  108. package/src/studio/{public → app/src}/style.css +14 -4
  109. package/src/studio/app/tsconfig.json +17 -0
  110. package/src/studio/app/vite.config.ts +16 -0
  111. package/src/studio/assets.generated.ts +3 -0
  112. package/src/studio/data.ts +96 -188
  113. package/src/studio/git.ts +27 -43
  114. package/src/studio/server.ts +196 -107
  115. package/src/studio/types.ts +141 -0
  116. package/src/transport/console.ts +3 -3
  117. package/src/transport/ssh.ts +1 -1
  118. package/dist/chunks/index-3b343xva.js +0 -177
  119. package/dist/chunks/index-6wb41q67.js +0 -62
  120. package/dist/chunks/index-n7w3tg5y.js +0 -149
  121. package/dist/chunks/index-yzw86rc8.js +0 -921
  122. package/dist/chunks/new-w3zys5y4.js +0 -172
  123. package/dist/chunks/server-cxr9ynxc.js +0 -259
  124. package/dist/cli/index.js +0 -12872
  125. package/dist/helpers/index.js +0 -881
  126. package/dist/index.js +0 -28
  127. package/dist/types/core/address-space.d.ts +0 -62
  128. package/dist/types/core/address.d.ts +0 -44
  129. package/dist/types/core/cabling.d.ts +0 -41
  130. package/dist/types/core/cross-checks.d.ts +0 -28
  131. package/dist/types/core/define.d.ts +0 -79
  132. package/dist/types/core/project-files.d.ts +0 -12
  133. package/dist/types/core/schema-docs.d.ts +0 -25
  134. package/dist/types/core/secrets.d.ts +0 -62
  135. package/dist/types/core/topology.d.ts +0 -96
  136. package/dist/types/core/utils.d.ts +0 -10
  137. package/dist/types/core/validator.d.ts +0 -23
  138. package/dist/types/helpers/bgp.d.ts +0 -195
  139. package/dist/types/helpers/cables.d.ts +0 -69
  140. package/dist/types/helpers/checks.d.ts +0 -36
  141. package/dist/types/helpers/exchange.d.ts +0 -55
  142. package/dist/types/helpers/filters.d.ts +0 -51
  143. package/dist/types/helpers/index.d.ts +0 -14
  144. package/dist/types/helpers/route-server.d.ts +0 -52
  145. package/dist/types/helpers/upstreams.d.ts +0 -192
  146. package/dist/types/index.d.ts +0 -10
  147. package/dist/types/integrations/librenms.d.ts +0 -60
  148. package/dist/types/integrations/netbox.d.ts +0 -89
  149. package/dist/types/integrations/peeringdb.d.ts +0 -24
  150. package/dist/types/integrations/prometheus.d.ts +0 -24
  151. package/dist/types/integrations/registries.d.ts +0 -8
  152. package/dist/types/platforms/bgp-wait.d.ts +0 -24
  153. package/dist/types/platforms/catalog.d.ts +0 -109
  154. package/dist/types/platforms/derive.d.ts +0 -17
  155. package/dist/types/platforms/fortios/firewall.d.ts +0 -47
  156. package/dist/types/platforms/fortios/index.d.ts +0 -140
  157. package/dist/types/platforms/fortios/models.d.ts +0 -14
  158. package/dist/types/platforms/fortios/names.d.ts +0 -52
  159. package/dist/types/platforms/fortios/plan.d.ts +0 -104
  160. package/dist/types/platforms/fortios/render.d.ts +0 -77
  161. package/dist/types/platforms/fortios/routing.d.ts +0 -56
  162. package/dist/types/platforms/fortios/tree.d.ts +0 -37
  163. package/dist/types/platforms/fortios/vdoms.d.ts +0 -50
  164. package/dist/types/platforms/index.d.ts +0 -13
  165. package/dist/types/platforms/junos/bgp.d.ts +0 -44
  166. package/dist/types/platforms/junos/index.d.ts +0 -114
  167. package/dist/types/platforms/junos/models.d.ts +0 -14
  168. package/dist/types/platforms/junos/render.d.ts +0 -154
  169. package/dist/types/platforms/line-diff.d.ts +0 -67
  170. package/dist/types/platforms/platform.d.ts +0 -135
  171. package/dist/types/platforms/raisecom-ros/index.d.ts +0 -71
  172. package/dist/types/platforms/raisecom-ros/models.d.ts +0 -14
  173. package/dist/types/platforms/raisecom-ros/render.d.ts +0 -41
  174. package/dist/types/platforms/routeros/export.d.ts +0 -59
  175. package/dist/types/platforms/routeros/index.d.ts +0 -116
  176. package/dist/types/platforms/routeros/models.d.ts +0 -18
  177. package/dist/types/platforms/routeros/plan.d.ts +0 -58
  178. package/dist/types/platforms/routeros/render.d.ts +0 -250
  179. package/dist/types/platforms/vrp/index.d.ts +0 -83
  180. package/dist/types/platforms/vrp/models.d.ts +0 -12
  181. package/dist/types/platforms/vrp/render.d.ts +0 -96
  182. package/dist/types/schema/common.d.ts +0 -30
  183. package/dist/types/schema/device.d.ts +0 -126
  184. package/dist/types/schema/firewall.d.ts +0 -131
  185. package/dist/types/schema/index.d.ts +0 -8
  186. package/dist/types/schema/interfaces.d.ts +0 -205
  187. package/dist/types/schema/policy.d.ts +0 -107
  188. package/dist/types/schema/routing.d.ts +0 -161
  189. package/dist/types/schema/system.d.ts +0 -198
  190. package/dist/types/schema/time-zones.d.ts +0 -2
  191. package/dist/types/transport/console.d.ts +0 -115
  192. package/dist/types/transport/path-check.d.ts +0 -32
  193. package/dist/types/transport/ssh.d.ts +0 -27
  194. package/src/cli/graph-format.ts +0 -91
  195. package/src/cli/new.ts +0 -234
  196. package/src/core/address-space.ts +0 -320
  197. package/src/core/cross-checks.ts +0 -369
  198. package/src/core/define.ts +0 -124
  199. package/src/core/project-files.ts +0 -35
  200. package/src/core/schema-docs.ts +0 -174
  201. package/src/core/validator.ts +0 -508
  202. package/src/helpers/bgp.ts +0 -473
  203. package/src/helpers/cables.ts +0 -74
  204. package/src/helpers/checks.ts +0 -201
  205. package/src/helpers/exchange.ts +0 -123
  206. package/src/helpers/filters.ts +0 -107
  207. package/src/helpers/index.ts +0 -25
  208. package/src/helpers/route-server.ts +0 -212
  209. package/src/helpers/upstreams.ts +0 -369
  210. package/src/index.ts +0 -13
  211. package/src/integrations/librenms.ts +0 -291
  212. package/src/integrations/netbox.ts +0 -620
  213. package/src/integrations/peeringdb.ts +0 -173
  214. package/src/integrations/prometheus.ts +0 -90
  215. package/src/schema/common.ts +0 -41
  216. package/src/schema/device.ts +0 -145
  217. package/src/schema/firewall.ts +0 -140
  218. package/src/schema/index.ts +0 -8
  219. package/src/schema/policy.ts +0 -115
  220. package/src/schema/routing.ts +0 -173
  221. package/src/schema/system.ts +0 -203
  222. package/src/studio/public/app.js +0 -186
  223. package/src/studio/public/compare.js +0 -250
  224. package/src/studio/public/dom.js +0 -138
  225. package/src/studio/public/evaluate.js +0 -687
  226. package/src/studio/public/export.js +0 -130
  227. package/src/studio/public/graph.js +0 -1541
  228. package/src/studio/public/index.html +0 -83
  229. package/src/studio/public/net.js +0 -134
  230. package/src/studio/public/tables.js +0 -1289
  231. package/src/studio/public/tools.js +0 -610
  232. package/templates/colocation/checks.ts +0 -53
  233. package/templates/colocation/circuit.config.ts +0 -9
  234. package/templates/colocation/edge-01.ts +0 -133
  235. package/templates/colocation/routing.ts +0 -13
  236. package/templates/colocation/tenancy.ts +0 -76
  237. package/templates/colocation/tenants/acme.ts +0 -19
  238. package/templates/colocation/tenants/index.ts +0 -6
  239. package/templates/colocation/tor-01.ts +0 -33
  240. package/templates/edge-router/checks.ts +0 -20
  241. package/templates/edge-router/circuit.config.ts +0 -8
  242. package/templates/edge-router/edge-01.ts +0 -110
  243. package/templates/edge-router/routing.ts +0 -13
  244. package/templates/project/AGENTS.md +0 -51
  245. package/templates/project/CLAUDE.md +0 -1
  246. package/templates/project/README.md +0 -16
  247. package/templates/project/gitignore +0 -5
  248. package/templates/project/tsconfig.json +0 -13
  249. package/templates/single-site/checks.ts +0 -26
  250. package/templates/single-site/circuit.config.ts +0 -8
  251. package/templates/single-site/router.ts +0 -86
  252. package/templates/single-site/site.ts +0 -18
  253. package/templates/single-site/switch.ts +0 -34
  254. /package/src/studio/{public → app/public}/favicon.svg +0 -0
  255. /package/src/studio/{public → app/public}/special-gothic-latin-ext.woff2 +0 -0
  256. /package/src/studio/{public → app/public}/special-gothic-latin.woff2 +0 -0
  257. /package/src/studio/{public → app/public}/special-gothic-license.txt +0 -0
package/src/cli/index.ts CHANGED
@@ -1,1282 +1,460 @@
1
- #!/usr/bin/env node
2
- // The command line. `new` starts a project; every other command loads the network from `circuit.config.ts` in the working directory, or the file `--config` names.
1
+ #!/usr/bin/env bun
2
+ // The `circuit` command. Alone, it opens a menu; with a command, such as `circuit validate`, it runs that command. Before each command it checks that this Circuit is current (see `Releases`).
3
3
 
4
- import { Command, InvalidArgumentError, Option } from "@commander-js/extra-typings";
5
- import { createPrivateKey, createPublicKey } from "node:crypto";
6
- import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
7
- import { basename, dirname, join, relative, resolve, sep } from "node:path";
8
- import { Platforms } from "../platforms";
9
- import type { ApplyOptions, BgpLogLine, BgpSession, Platform, Plan, ReadSession } from "../platforms/platform";
4
+ import { Command, InvalidArgumentError } from "@commander-js/extra-typings";
5
+ import { spawnSync } from "node:child_process";
6
+ import { OnePassword, SecretsFile } from "../core/secrets-file";
10
7
  import { BgpWait } from "../platforms/bgp-wait";
11
- import { Registries } from "../integrations/registries";
12
- import { LibreNms } from "../integrations/librenms";
13
- import { NetBox, type NetBoxPlan } from "../integrations/netbox";
14
- import { PeeringDb } from "../integrations/peeringdb";
15
- import { Prometheus } from "../integrations/prometheus";
16
- import { Address } from "../core/address";
17
- import { Cabling, type Neighbor } from "../core/cabling";
18
- import { AddressSpace, type Segment } from "../core/address-space";
19
- import type { Finding, Network } from "../core/define";
20
- import { Topology, type Annotated, type Drift, type Layer } from "../core/topology";
21
- import { Secrets } from "../core/secrets";
22
- import { Validator } from "../core/validator";
23
- import type { Device } from "../schema";
24
- import { GraphFormat } from "./graph-format";
25
- import { Rename, type RenamePlan } from "./rename";
26
- import { ProjectFiles } from "../core/project-files";
27
- import { ConsoleScript, type ConsoleSession, SerialConsole } from "../transport/console";
28
- import { PathCheck } from "../transport/path-check";
29
- import { StudioModel } from "../studio/data";
30
- import type { ScaffoldOptions } from "./new";
8
+ import { App } from "./app";
9
+ import { SecretCommands } from "./secrets";
10
+ import { GitHooks } from "./git-hooks";
11
+ import { Migrate } from "./migrate";
12
+ import { ProjectCommands } from "./project-commands";
13
+ import { Releases } from "./releases";
14
+ import { Remember, SecretsAgent } from "./remember";
15
+ import { Format } from "./format";
16
+ import { Setup } from "./setup";
17
+ import { Versions } from "./versions";
18
+ import { type Choice, Ui } from "./ui";
19
+ import { VERSION } from "./version";
31
20
 
32
- const GENERATED_README = `# Written by Circuit
33
-
34
- Do not edit anything here: an edit changes nothing on a device, and Circuit overwrites it. Change the config instead.
35
-
36
- \`state/\` holds what each device ran when Circuit last read it, with secrets removed. \`circuit snapshot\` and \`circuit apply\` write it, and \`circuit diff\` plans against it. Commit it, so the history of this directory is the history of the network.
37
-
38
- A \`.lldp.json\` file beside a device's config is what the device saw over LLDP at the last \`circuit snapshot\`: which device and port is at the far end of each cable. \`circuit validate\` compares it with each port's \`link\`; \`circuit diff\` ignores it.
39
- `;
40
-
41
- /** One run of the command line: the network it loaded, and how it reaches devices. */
42
21
  class Cli {
43
- static readonly DEFAULT_CONFIG = "circuit.config.ts";
44
-
45
- /** Ctrl-], the key that leaves `circuit console`, as telnet uses it. */
46
- private static readonly LEAVE_KEY = 0x1d;
47
-
48
- /** `--config`, which every command but `new` takes. */
49
- private static readonly CONFIG_FLAGS = "--config <path>";
50
- private static readonly CONFIG_HELP = `the network's config file, ${Cli.DEFAULT_CONFIG} in the working directory by default`;
22
+ /** Commands the manager runs itself, whatever version a project pins. */
23
+ private static readonly APP_COMMANDS = new Set(["update", "uninstall", "new"]);
51
24
 
52
- /** The directory of the config file. `.circuit/`, `.env` and secret files are found from here. */
53
- private readonly root: string;
54
- /** What Circuit writes, beside the config. */
55
- private readonly generated: string;
56
- private readonly stateDirectory: string;
57
-
58
- private constructor(
59
- private readonly configPath: string,
60
- private readonly network: Network,
61
- /** How a device is reached and what a plan sends: `apply`'s flags, the defaults for every other command. */
62
- private readonly options: ApplyOptions
63
- ) {
64
- this.root = dirname(configPath);
65
- this.generated = join(this.root, ".circuit");
66
- this.stateDirectory = join(this.generated, "state");
67
- }
68
-
69
- /** Read the arguments and run the command they name. */
25
+ /** Read the arguments and run the command they name, or open the menu. */
70
26
  static async main(argv: string[]): Promise<void> {
71
- const program = Cli.program();
27
+ const command = argv[0];
28
+ // The process that keeps remembered secrets, started after a yes to remembering them.
29
+ if (command === SecretsAgent.COMMAND) return SecretsAgent.serve();
30
+ Releases.tidy();
31
+ OnePassword.waiting = (message) => Ui.blocked(message);
32
+
33
+ // The manager keeps itself current before every command but `uninstall`. A version it started, or Studio's own reads, leave that to it.
34
+ if (command !== "uninstall" && !Versions.started() && !process.env.CIRCUIT_LOCAL) await Releases.check(argv);
35
+
36
+ if (!Cli.APP_COMMANDS.has(command ?? "")) {
37
+ Cli.runLocal(argv);
38
+ const root = ProjectCommands.find();
39
+ if (root) {
40
+ if (Releases.binary()) Versions.remember(root);
41
+ await Versions.run(root, argv);
42
+ }
43
+ }
72
44
 
73
- // With no command, the help, as `circuit help` prints it.
45
+ const program = Cli.program();
74
46
  if (!argv.length) {
75
- program.outputHelp();
76
- return;
47
+ if (!Ui.interactive) {
48
+ program.outputHelp();
49
+ return;
50
+ }
51
+ argv = await Cli.menu();
77
52
  }
78
53
 
54
+ await Remember.load();
79
55
  await program.parseAsync(argv, { from: "user" });
56
+ await Remember.offer();
80
57
  }
81
58
 
82
- /** Load the network from the config file `--config` names, or the one in the working directory. */
83
- private static async open(configArgument: string | undefined, flags: Partial<ApplyOptions> = {}): Promise<Cli> {
84
- const configPath = resolve(process.cwd(), configArgument ?? Cli.DEFAULT_CONFIG);
85
-
86
- if (!existsSync(configPath)) {
87
- Cli.fail(
88
- configArgument
89
- ? `no config at ${configPath}`
90
- : `no ${Cli.DEFAULT_CONFIG} in ${process.cwd()}; name another with --config <path>`
91
- );
92
- }
93
-
94
- Secrets.useRoot(dirname(configPath));
95
-
96
- const network = await Cli.load(configPath);
97
- const options: ApplyOptions = {
98
- secrets: flags.secrets ?? false,
99
- rollback: flags.rollback ?? 10,
100
- ...(flags.console ? { console: true } : {}),
101
- };
102
-
103
- return new Cli(configPath, network, options);
104
- }
105
-
106
- /** The config, a TypeScript file. Bun imports it as it is; Node needs jiti, which also resolves imports written without an extension. */
107
- private static async load(path: string): Promise<Network> {
108
- if (process.versions.bun) return (await import(path)).default;
109
-
110
- const { createJiti } = await import("jiti");
111
- return createJiti(import.meta.url).import(path, { default: true });
112
- }
113
-
114
- private static fail(message: string): never {
115
- console.error(message);
116
- process.exit(1);
117
- }
118
-
119
- /** `--rollback`'s value: a whole number of minutes. */
120
- private static minutes(value: string): number {
121
- const minutes = Number(value);
122
- if (!Number.isInteger(minutes) || minutes < 1) throw new InvalidArgumentError("Give a whole number of minutes, such as 10.");
59
+ /** In a project with a full install, run the Circuit pinned in it instead of this one, so a team runs one version. */
60
+ private static runLocal(argv: string[]): void {
61
+ if (process.env.CIRCUIT_LOCAL || !Releases.binary()) return;
123
62
 
124
- return minutes;
125
- }
126
-
127
- /** `--timeout`'s value: a whole number of seconds. */
128
- private static seconds(value: string): number {
129
- const seconds = Number(value);
130
- if (!Number.isInteger(seconds) || seconds < 1) throw new InvalidArgumentError("Give a whole number of seconds, such as 90.");
63
+ const root = ProjectCommands.find();
64
+ const local = root && Setup.localCircuit(root);
65
+ if (!local) return;
131
66
 
132
- return seconds;
67
+ const result = spawnSync("bun", [local, ...argv], { stdio: "inherit", env: { ...process.env, CIRCUIT_LOCAL: "1" } });
68
+ if (result.error)
69
+ Ui.fail(
70
+ `This project runs Circuit ${Setup.pinned(root) ?? ""} with Bun, and Bun is not installed. Install it from https://bun.sh.`
71
+ );
72
+ process.exit(result.status ?? 1);
133
73
  }
134
74
 
135
- /** `--wait`'s value: a whole number of sessions. */
136
- private static count(value: string): number {
137
- const count = Number(value);
138
- if (!Number.isInteger(count) || count < 1) throw new InvalidArgumentError("Give a whole number of sessions, such as 4.");
139
-
140
- return count;
75
+ /** A whole number of minutes, seconds or sessions, as a flag takes it. */
76
+ private static whole(unit: string, example: number) {
77
+ return (value: string): number => {
78
+ const number = Number(value);
79
+ if (!Number.isInteger(number) || number < 1)
80
+ throw new InvalidArgumentError(`Give a whole number of ${unit}, such as ${example}.`);
81
+ return number;
82
+ };
141
83
  }
142
84
 
143
85
  /** Every command, with its arguments and options. `circuit --help` and `circuit <command> --help` are written from these. */
144
86
  private static program() {
145
87
  const program = new Command("circuit")
146
88
  .description("Describe each network device in TypeScript, see what would change, then make the device match.")
147
- .usage("<command> [device...] [options]")
89
+ .version(VERSION)
90
+ .usage("[command] [device] [options]")
148
91
  .showHelpAfterError("(add --help to see what it takes)");
149
92
 
150
- program
151
- .command("new")
152
- .description("start a project from a pattern, asking what it needs")
153
- .argument("[directory]", "where to create it")
154
- .option("--pattern <name>", "single-site, edge-router or colocation")
155
- .option("--asn <number>", "your AS number, for edge-router and colocation")
156
- .option("--1password", "write .env with 1Password references instead of empty values")
157
- .option("--use <manager>", "npm, pnpm or bun; the one that ran this by default")
158
- .option("--no-install", "do not install the dependencies")
159
- .action(async (directory, flags) => {
160
- const { Scaffold } = await import("./new");
161
- const options: ScaffoldOptions = {
162
- pattern: flags.pattern,
163
- asn: flags.asn,
164
- onePassword: flags["1password"],
165
- use: flags.use,
166
- install: flags.install,
167
- };
168
-
169
- await Scaffold.create(directory, options);
170
- process.exit(0);
171
- });
93
+ program.commandsGroup("Core:");
172
94
 
173
95
  program
174
96
  .command("validate")
175
- .description("check the config, then every rule in checks")
176
- .option(Cli.CONFIG_FLAGS, Cli.CONFIG_HELP)
177
- .action(async (flags) => (await Cli.open(flags.config)).validate());
178
-
179
- program
180
- .command("build")
181
- .description("print the configuration the device should run, without contacting it")
182
- .argument("<device>")
183
- .option(Cli.CONFIG_FLAGS, Cli.CONFIG_HELP)
184
- .action(async (device, flags) => (await Cli.open(flags.config)).build(device));
97
+ .description("check every file of the project, and every check in circuit.ts")
98
+ .option("--experimental", "allow what a platform renders but has not proved on a device yet, as a warning")
99
+ .action(async (flags) => (await ProjectCommands.open(flags)).validate());
185
100
 
186
101
  program
187
102
  .command("diff")
188
- .description("what apply would send, planned against the last snapshot, offline")
189
- .argument("[device...]", "every device when none is named")
103
+ .description("read the device, and show what apply would change on it")
104
+ .argument("[device]", "picked from a list when left out")
190
105
  .option("--secrets", "plan as apply --secrets would, sending every secret again")
191
- .option(Cli.CONFIG_FLAGS, Cli.CONFIG_HELP)
192
- .action(async (devices, flags) => (await Cli.open(flags.config, flags)).diff(devices));
193
-
194
- program
195
- .command("snapshot")
196
- .description("read what each device runs into the state directory")
197
- .argument("[device...]", "every device when none is named")
198
106
  .option("--console", "through the device's serial console on its jump host, instead of SSH")
199
- .option(Cli.CONFIG_FLAGS, Cli.CONFIG_HELP)
200
- .action(async (devices, flags) => (await Cli.open(flags.config, flags)).snapshot(devices));
107
+ .option("--experimental", "allow what a platform renders but has not proved on a device yet, as a warning")
108
+ .action(async (device, flags) => (await ProjectCommands.open(flags)).diff(device));
201
109
 
202
110
  program
203
111
  .command("apply")
204
- .description("read the device, plan against what it runs, and send with --confirm")
205
- .argument("<device>")
206
- .option("--confirm", "send the plan; without it, nothing is sent")
207
- .option("--secrets", "also send every secret, to rotate one")
208
- .option("--rollback <minutes>", "minutes before a device that can restore itself does", Cli.minutes, 10)
112
+ .description("read the device, show what would change, and send it once you say yes")
113
+ .argument("[device]", "picked from a list when left out")
114
+ .option("--confirm", "send without asking")
115
+ .option("--secrets", "also send every secret, to change a password")
116
+ .option("--rollback <minutes>", "minutes before a device that can restore itself does", Cli.whole("minutes", 10), 10)
209
117
  .option("--console", "through the device's serial console on its jump host, instead of SSH")
210
- .option("--force", "send even when the pings before --confirm find a path that loses too much or is too slow")
211
- .option(Cli.CONFIG_FLAGS, Cli.CONFIG_HELP)
118
+ .option("--force", "send even when the path to the device loses too much or is too slow")
119
+ .option("--experimental", "allow what a platform renders but has not proved on a device yet, as a warning")
212
120
  .action(async (device, flags) =>
213
- (await Cli.open(flags.config, flags)).apply(device, flags.confirm ?? false, flags.force ?? false)
121
+ (await ProjectCommands.open(flags)).apply(device, flags.confirm ?? false, flags.force ?? false)
214
122
  );
215
123
 
124
+ program.commandsGroup("Utilities:");
125
+
216
126
  program
217
- .command("show")
218
- .description("run commands that only read on a device and print what it answers, secrets hidden")
219
- .argument("<device>")
127
+ .command("exec")
128
+ .alias("execute")
129
+ .description("run commands that only read on a device, and print what it answers, secrets hidden")
130
+ .argument("[device]", "picked from a list when left out")
220
131
  .argument("[command...]", "each command in quotes, such as '/ip route print'")
221
132
  .option("--file <path>", "also each line of this file; blank lines and lines starting with # are skipped")
222
- .option("--timeout <seconds>", "how long each command may take", Cli.seconds, 90)
133
+ .option("--timeout <seconds>", "how long each command may take", Cli.whole("seconds", 90), 90)
223
134
  .option("--console", "through the device's serial console, instead of SSH")
224
- .option(Cli.CONFIG_FLAGS, Cli.CONFIG_HELP)
225
135
  .action(async (device, commands, flags) =>
226
- (await Cli.open(flags.config, flags)).show(device, commands, flags.file, flags.timeout)
136
+ (await ProjectCommands.open(flags)).exec(device, commands, flags.file, flags.timeout)
227
137
  );
228
138
 
229
139
  program
230
140
  .command("bgp")
231
141
  .description("each BGP neighbor in the config next to its live session: up or down and why, prefixes, uptime")
232
142
  .argument("[device]", "may be left out when only one device has BGP neighbors")
233
- // A second argument was the name filter before 0.42; it is refused below with the way to write it now.
234
- .allowExcessArguments()
235
143
  .option("--neighbors <regex>", "only the neighbors whose name matches")
236
144
  .option(
237
145
  "--wait [count]",
238
146
  `read again every ${BgpWait.COMMAND.every} seconds until every neighbor shown, or this many, is up`,
239
- Cli.count
147
+ Cli.whole("sessions", 4)
240
148
  )
241
- .option("--max <seconds>", "how long --wait waits at most", Cli.seconds, BgpWait.COMMAND.seconds)
242
- .option("--log [minutes]", "also print the device's BGP log lines from the last minutes, 5 by default", Cli.minutes)
149
+ .option("--max <seconds>", "how long --wait waits at most", Cli.whole("seconds", 90), BgpWait.COMMAND.seconds)
150
+ .option("--log [minutes]", "also print the device's BGP log lines from the last minutes, 5 by default", Cli.whole("minutes", 5))
243
151
  .option("--clear-limit", "open again the neighbors shown that closed on their prefix limit; any other is left alone")
244
- .option(Cli.CONFIG_FLAGS, Cli.CONFIG_HELP)
245
- .action(async (device, flags, command) => {
246
- const [, filter] = command.args;
247
- if (filter !== undefined)
248
- Cli.fail(
249
- `circuit bgp takes one device; filter the neighbors with --neighbors: circuit bgp ${device} --neighbors '${filter}'`
250
- );
251
- (await Cli.open(flags.config)).bgp(device, flags);
252
- });
152
+ .action(async (device, flags) => (await ProjectCommands.open()).bgp(device, flags));
253
153
 
254
154
  program
255
- .command("console")
155
+ .command("access-console")
256
156
  .description("open a device's serial console here, logged in; or send it lines and print what it answers")
257
- .argument("<device>")
157
+ .argument("[device]", "picked from a list when left out")
258
158
  .argument("[line...]", "lines to send, each in quotes; none joins the console to this terminal, Ctrl-] leaves")
259
159
  .option("--raw", "do not log in or out: only join the console, or send the lines")
260
- .option(Cli.CONFIG_FLAGS, Cli.CONFIG_HELP)
261
- .action(async (device, lines, flags) => (await Cli.open(flags.config)).console(device, lines, flags.raw ?? false));
160
+ .action(async (device, lines, flags) => (await ProjectCommands.open()).accessConsole(device, lines, flags.raw ?? false));
262
161
 
263
162
  program
264
- .command("refresh")
265
- .description("fetch prefix sets that come from a registry onto the device, with --confirm")
266
- .argument("<device>")
267
- .option("--confirm", "put them on the device; without it, nothing is sent")
268
- .option(Cli.CONFIG_FLAGS, Cli.CONFIG_HELP)
269
- .action(async (device, flags) => (await Cli.open(flags.config)).refresh(device, flags.confirm ?? false));
163
+ .command("studio")
164
+ .description("the project in your browser: the graph, every table, tools, compare and secrets")
165
+ .option("--port <number>", "the port it listens on", Cli.port, 4600)
166
+ .option(
167
+ "--host <address>",
168
+ "the address it listens on: this machine only by default; a server's address to share it",
169
+ "127.0.0.1"
170
+ )
171
+ .option(
172
+ "--allow-host <names...>",
173
+ "names besides this machine's that requests may be addressed to, such as studio.example.com behind a proxy"
174
+ )
175
+ .option("--no-open", "do not open the browser")
176
+ .action(async (flags) => Cli.studio(flags));
270
177
 
271
178
  program
272
- .command("wireguard")
273
- .description("a client config for one peer, its private key left for the peer to fill")
274
- .argument("<device>")
275
- .argument("<interface>")
276
- .argument("<peer>")
277
- .option(Cli.CONFIG_FLAGS, Cli.CONFIG_HELP)
278
- .action(async (device, iface, peer, flags) => (await Cli.open(flags.config)).wireguard(device, iface, peer));
179
+ .command("rename")
180
+ .description("give a device a new name everywhere the project writes it, and its file too")
181
+ .argument("[device]", "picked from a list when left out")
182
+ .argument("[new-name]", "asked for when left out")
183
+ .option("--file", "also rename the file that writes the device")
184
+ .option("--no-file", "leave the file's name as it is")
185
+ .option("--confirm", "write without asking")
186
+ .action(async (device, name, flags) => (await ProjectCommands.open()).rename(device, name, flags.file, flags.confirm ?? false));
279
187
 
280
188
  program
281
- .command("communities")
282
- .description("the network's BGP communities, one per line, for a looking glass or bgp.tools")
283
- .option(Cli.CONFIG_FLAGS, Cli.CONFIG_HELP)
284
- .action(async (flags) => (await Cli.open(flags.config)).communities());
189
+ .command("fmt")
190
+ .description("tidy the spacing and line breaks of the project's .ts files; what they say stays the same")
191
+ .argument("[file...]", "every TypeScript file of the project when left out")
192
+ .option("--check", "change nothing; fail when a file is not in the style")
193
+ .action(async (files, flags) =>
194
+ Format.run(
195
+ ProjectCommands.find() ?? Ui.fail("No Circuit project here: no circuit.ts in this folder or above it."),
196
+ files,
197
+ flags.check ?? false
198
+ )
199
+ );
285
200
 
286
- program
287
- .command("peeringdb")
288
- .description("bring PeeringDB's exchange records in line with the config, with --confirm")
289
- .option("--confirm", "send the changes; without it, nothing is sent")
290
- .option(Cli.CONFIG_FLAGS, Cli.CONFIG_HELP)
291
- .action(async (flags) => (await Cli.open(flags.config)).peeringdb(flags.confirm ?? false));
201
+ program.commandsGroup("Secrets:");
292
202
 
293
- program
294
- .command("librenms")
295
- .description("bring LibreNMS in line with the config, with --confirm")
296
- .option("--confirm", "send the changes; without it, nothing is sent")
297
- .option(Cli.CONFIG_FLAGS, Cli.CONFIG_HELP)
298
- .action(async (flags) => (await Cli.open(flags.config)).librenms(flags.confirm ?? false));
203
+ const FILE_FLAGS = "--file <path>";
204
+ const FILE_HELP = "the secrets file, when it isn't .circuit/secrets";
205
+ // `--file` after a command, such as `circuit secrets list --file x`, lands on `secrets` itself, so each command reads it from there too.
206
+ const secrets = program
207
+ .command("secrets")
208
+ .alias("secret")
209
+ .description("the project's secrets, each one encrypted: alone, in a terminal, opens every one in a text editor")
210
+ .option(FILE_FLAGS, FILE_HELP)
211
+ .action(async (flags) => {
212
+ const commands = await SecretCommands.open(flags.file);
213
+ if (Ui.interactive) await commands.edit();
214
+ else commands.list();
215
+ });
216
+ secrets
217
+ .command("edit")
218
+ .description("every secret, decrypted, in a text editor in the terminal; saved encrypted")
219
+ .option(FILE_FLAGS, FILE_HELP)
220
+ .action(async (flags) => (await SecretCommands.open(flags.file ?? secrets.opts().file)).edit());
221
+ secrets
222
+ .command("list")
223
+ .description("every secret, how it is kept, and the ones the config needs that the file lacks")
224
+ .option(FILE_FLAGS, FILE_HELP)
225
+ .action(async (flags) => (await SecretCommands.open(flags.file ?? secrets.opts().file)).list());
226
+ secrets
227
+ .command("get")
228
+ .description("print one secret, decrypted")
229
+ .argument("[name]")
230
+ .option(FILE_FLAGS, FILE_HELP)
231
+ .action(async (name, flags) => (await SecretCommands.open(flags.file ?? secrets.opts().file)).get(name));
232
+ secrets
233
+ .command("set")
234
+ .description("add or change a secret, encrypted; leave out the value to type it hidden")
235
+ .argument("[name]")
236
+ .argument("[value]", "an op://vault/item/field reference keeps the value in 1Password")
237
+ .option(FILE_FLAGS, FILE_HELP)
238
+ .action(async (name, value, flags) => (await SecretCommands.open(flags.file ?? secrets.opts().file)).set(name, value));
239
+ secrets
240
+ .command("delete")
241
+ .description("remove a secret")
242
+ .argument("[name]")
243
+ .option("--confirm", "delete without asking")
244
+ .option(FILE_FLAGS, FILE_HELP)
245
+ .action(async (name, flags) =>
246
+ (await SecretCommands.open(flags.file ?? secrets.opts().file)).delete(name, flags.confirm ?? false)
247
+ );
248
+ secrets
249
+ .command("encrypt")
250
+ .description("encrypt every value typed into the file in plain text")
251
+ .option(FILE_FLAGS, FILE_HELP)
252
+ .action(async (flags) => (await SecretCommands.open(flags.file ?? secrets.opts().file)).encrypt());
253
+ secrets
254
+ .command("password")
255
+ .description("change the password; every secret is encrypted again and the old password stops working")
256
+ .option("--1password <reference>", "read the password from 1Password instead of asking: op://vault/item/field")
257
+ .option("--forget-1password", "stop reading it from 1Password, and ask for it")
258
+ .option("--confirm", "change without asking; the new password comes from CIRCUIT_SECRETS_NEW_PASSWORD")
259
+ .option(FILE_FLAGS, FILE_HELP)
260
+ .action(async (flags) =>
261
+ (await SecretCommands.open(flags.file ?? secrets.opts().file)).password({
262
+ ...(flags["1password"] !== undefined ? { onePassword: flags["1password"] } : {}),
263
+ forgetOnePassword: flags.forget1password ?? false,
264
+ confirm: flags.confirm ?? false,
265
+ })
266
+ );
267
+
268
+ secrets
269
+ .command("forget")
270
+ .description("forget every secret Circuit remembers, now")
271
+ .action(async () => Remember.forget());
272
+
273
+ program.commandsGroup("Circuit:");
299
274
 
300
275
  program
301
- .command("netbox")
302
- .description("bring NetBox in line with the config, with --confirm")
303
- .option("--confirm", "send the changes; without it, nothing is sent")
304
- .option(Cli.CONFIG_FLAGS, Cli.CONFIG_HELP)
305
- .action(async (flags) => (await Cli.open(flags.config)).netbox(flags.confirm ?? false));
276
+ .command("update")
277
+ .description(
278
+ "pick a newer Circuit, the newest first: in a project, for the project and the app with it; elsewhere, for the app"
279
+ )
280
+ .option("--app", "update the installed app, even in a project")
281
+ .option("--confirm", "update to the newest without asking")
282
+ .action(async (flags) => Cli.update(flags.app ?? false, flags.confirm ?? false));
306
283
 
307
284
  program
308
- .command("prometheus")
309
- .description("every device with SNMP, as Prometheus targets for snmp_exporter")
310
- .option("--auths", "snmp_exporter's auths for them instead, with secrets as ${NAME}")
311
- .option(Cli.CONFIG_FLAGS, Cli.CONFIG_HELP)
312
- .action(async (flags) => (await Cli.open(flags.config)).prometheus(flags.auths ?? false));
285
+ .command("new")
286
+ .description("start a new project")
287
+ .argument("[folder]", "asked for when left out")
288
+ .option("--full", "Circuit as a package in the project, run with bun circuit")
289
+ .action(async (folder, flags) => Setup.run(folder, { existing: "refuse", ...(flags.full ? { kind: "full" as const } : {}) }));
313
290
 
314
291
  program
315
- .command("secrets")
316
- .description("every secret the config uses, and whether each can be read")
317
- .option(Cli.CONFIG_FLAGS, Cli.CONFIG_HELP)
318
- .action(async (flags) => (await Cli.open(flags.config)).secrets());
292
+ .command("uninstall")
293
+ .description("remove Circuit from this machine; your projects stay")
294
+ .option("--confirm", "remove without asking")
295
+ .action(async (flags) => App.uninstall(flags.confirm ?? false));
319
296
 
320
297
  program
321
- .command("rename")
322
- .description("give a device a new name in every file that names it, with --confirm")
323
- .argument("<device>")
324
- .argument("<new-name>")
325
- .option("--file", "also rename the file that declares it, to <new-name>.ts")
326
- .option("--confirm", "write the files; without it, nothing is written")
327
- .option(Cli.CONFIG_FLAGS, Cli.CONFIG_HELP)
328
- .action(async (from, to, flags) =>
329
- (await Cli.open(flags.config)).rename(from, to, flags.file ?? false, flags.confirm ?? false)
330
- );
298
+ .command("migrate")
299
+ .description("write a Circuit 0.x project again as a v1 project, and list what did not move")
300
+ .argument("<v0-folder>", "the 0.x project, with its packages installed")
301
+ .option("--out <folder>", "an empty folder for the v1 project; <v0-folder>-v1 by default")
302
+ .option("--config <file>", "0.x's config file in that folder", "circuit.config.ts")
303
+ .option(
304
+ "--yes",
305
+ "copy the 0.x project's .env into the secrets without asking; the password comes from CIRCUIT_SECRETS_PASSWORD"
306
+ )
307
+ .action(async (folder, flags) => Migrate.run(folder, { ...flags, yes: flags.yes ?? false }));
331
308
 
332
309
  program
333
- .command("addresses")
334
- .description("every address the config uses, by prefix, and what is still free")
335
- .argument("[prefix]", "only the addresses in this prefix, such as 10.0.0.0/16")
336
- .option("--length <n>", "free prefixes of this length, instead of the fewest that cover the free space")
337
- .option("--format <format>", "json, to print JSON")
338
- .option(Cli.CONFIG_FLAGS, Cli.CONFIG_HELP)
339
- .action(async (within, flags) => (await Cli.open(flags.config)).addresses(within, flags.length, flags.format));
310
+ .command("setup", { hidden: true })
311
+ .description("what the installer runs: set up the project in a folder, or offer to start one")
312
+ .argument("[folder]")
313
+ .action(async (folder) => Setup.run(folder, { existing: "ready" }));
340
314
 
341
315
  program
342
- .command("graph")
343
- .description("the network as a graph, from the config alone, without contacting a device")
344
- .argument("[device...]", "only these devices and what they connect to")
345
- .option("--layer <layers>", `${Topology.LAYERS.join(", ")}, several with commas, or all`, "l1")
346
- .addOption(new Option("--format <format>", "how to print it").choices(["mermaid", "dot", "json"] as const).default("mermaid"))
347
- .option(Cli.CONFIG_FLAGS, Cli.CONFIG_HELP)
348
- .action(async (devices, flags) => (await Cli.open(flags.config)).graph(devices, flags.layer, flags.format));
316
+ .command("studio-data", { hidden: true })
317
+ .description("what Studio shows of the project, as JSON: Studio runs it to read the project fresh")
318
+ .argument("<folder>")
319
+ .action(async (folder) => {
320
+ const { StudioModel } = await import("../studio/data");
321
+ process.stdout.write(JSON.stringify(await StudioModel.gather(folder)));
322
+ });
349
323
 
350
324
  program
351
- .command("studio")
352
- .description("Circuit Studio: the network in the browser, drawn again whenever the config changes")
353
- .option("--port <number>", "the port it listens on", "4600")
354
- .option("--no-open", "do not open the browser")
355
- .option("--host <address>", "listen on a server's address, such as 0.0.0.0, instead; needs --allow-host", "127.0.0.1")
356
- .option("--allow-host <names>", "the names it is reached by, such as studio.example.com, several with commas")
357
- .option("--json", "print what Studio shows, as JSON, instead")
358
- .option(Cli.CONFIG_FLAGS, Cli.CONFIG_HELP)
359
- .action(async (flags) => (await Cli.open(flags.config)).studio(flags));
325
+ .command("hook", { hidden: true })
326
+ .description("what the project's git hooks run")
327
+ .argument("<name>", "pre-commit")
328
+ .action(async (name) => Cli.hook(name));
360
329
 
361
330
  return program;
362
331
  }
363
332
 
364
- /** The devices with these names, or every device when none is named and `allByDefault`. */
365
- private selected(names: readonly string[], allByDefault: boolean): Device[] {
366
- if (!names.length && allByDefault) return [...this.network.devices];
367
-
368
- const known = this.network.devices.map((device) => device.name).join(", ");
369
- const byName = (name: string) => this.network.devices.find((device) => device.name === name);
370
-
371
- return names.map((name) => byName(name) ?? Cli.fail(`no device ${name}; known: ${known}`));
372
- }
373
-
374
- /** The device with this name. */
375
- private one(name: string): Device {
376
- return this.selected([name], false)[0]!;
377
- }
378
-
379
- /** How a device is reached: through its console when `--console` asks, or when it has no management address. */
380
- private optionsFor(device: Device): ApplyOptions {
381
- return this.options.console || !device.connection.host ? { ...this.options, console: true } : this.options;
382
- }
383
-
384
- /** The adapter of a device, which must be able to use its console when the device is reached through it. */
385
- private adapterOf(device: Device): Platform {
386
- const adapter = Platforms.find(device.platform) ?? Cli.fail(`no adapter for ${device.platform}`);
387
- if (!this.optionsFor(device).console) return adapter;
388
-
389
- if (!adapter.console) Cli.fail(`${device.name}: ${device.platform} cannot be reached through a console yet`);
390
- if (!device.connection.console) Cli.fail(`${device.name}: --console needs connection.console`);
391
- return adapter;
392
- }
393
-
394
- private statePath(device: Device): string {
395
- return join(this.stateDirectory, device.name + this.adapterOf(device).extension);
396
- }
397
-
398
- /** Record what a device runs, beside a note that the directory is generated. */
399
- private writeState(device: Device, text: string): void {
400
- mkdirSync(this.stateDirectory, { recursive: true });
401
- writeFileSync(join(this.generated, "README.md"), GENERATED_README);
402
- writeFileSync(this.statePath(device), text);
333
+ /** `--port`'s value: a port number. */
334
+ private static port(value: string): number {
335
+ const port = Number(value);
336
+ if (!Number.isInteger(port) || port < 1 || port > 65535)
337
+ throw new InvalidArgumentError("Give a port from 1 to 65535, such as 4600.");
338
+ return port;
403
339
  }
404
340
 
405
- /** Each device's LLDP neighbors at its last snapshot, for the devices whose neighbors were read. */
406
- private neighbors(): Map<string, Neighbor[]> {
407
- const found = new Map<string, Neighbor[]>();
408
-
409
- for (const device of this.network.devices) {
410
- const neighbors = Cabling.load(this.stateDirectory, device.name);
411
- if (neighbors) found.set(device.name, neighbors);
412
- }
413
-
414
- return found;
415
- }
416
-
417
- /** What `validate` finds: the config's own findings, then where LLDP at the last snapshot disagrees with `link`. */
418
- private findings(): Finding[] {
419
- return [...Validator.run(this.network), ...Cabling.compare(this.network.devices, this.neighbors())];
420
- }
421
-
422
- /** Validate, print what was found, and stop on an error about one of these devices. `diff` and `apply` leave LLDP out: it is what was seen, not config. */
423
- private guard(devices: Device[], all: readonly Finding[] = Validator.run(this.network)): void {
424
- const relevant = new Set(devices.map((device) => device.name));
425
- const findings = all.filter((finding) => !finding.device || relevant.has(finding.device));
426
-
427
- for (const finding of findings) {
428
- const where = finding.device ? `${finding.device}: ` : "";
429
- const level = finding.level === "error" ? "error" : "warn ";
430
- console.log(` ${level} ${where}${finding.message}`);
431
- }
432
-
433
- const errors = findings.filter((finding) => finding.level === "error").length;
434
- if (errors) Cli.fail(`\n${errors} error(s).`);
435
- }
341
+ /** Serve Studio for the project until Ctrl+C, and open it in the browser. */
342
+ private static async studio(flags: { port: number; host: string; allowHost?: string[]; open: boolean }): Promise<void> {
343
+ const root = ProjectCommands.find() ?? Ui.fail("No Circuit project here: no circuit.ts in this folder or above it.");
344
+ Setup.prepare(root);
436
345
 
437
- /** Print a plan. Returns how many commands and problems it holds. */
438
- private static printPlan(device: Device, plan: Plan): number {
439
- const commands = plan.steps.reduce((count, step) => count + step.send.length, 0);
440
- const summary = commands ? `${commands} command(s) in ${plan.steps.length} step(s)` : "matches";
441
- console.log(`${device.name}: ${summary}`);
442
-
443
- for (const step of plan.steps) {
444
- console.log(` ${step.title}`);
445
- for (const line of step.show) console.log(` ${line.slice(0, 220)}`);
446
- }
447
-
448
- for (const problem of plan.problems) console.log(` PROBLEM ${problem}`);
449
-
450
- return commands + plan.problems.length;
451
- }
452
-
453
- private validate(): void {
454
- this.guard([...this.network.devices], this.findings());
455
- console.log(`${this.network.devices.length} device(s), no errors.`);
456
- }
457
-
458
- private build(name: string): void {
459
- const device = this.one(name);
460
- process.stdout.write(this.adapterOf(device).render(device));
461
- }
462
-
463
- private diff(names: string[]): void {
464
- const devices = this.selected(names, true);
465
- this.guard(devices);
466
-
467
- let differences = 0;
468
-
469
- for (const device of devices) {
470
- const path = this.statePath(device);
471
- if (!existsSync(path)) {
472
- console.log(`${device.name}: no snapshot, run snapshot first`);
473
- continue;
474
- }
475
-
476
- const adapter = this.adapterOf(device);
477
- const plan = adapter.plan(adapter.render(device), readFileSync(path, "utf8"), this.options);
478
- differences += Cli.printPlan(device, plan);
479
- }
480
-
481
- process.exit(differences ? 1 : 0);
482
- }
483
-
484
- private async snapshot(names: string[]): Promise<void> {
485
- for (const device of this.selected(names, true)) {
486
- const adapter = this.adapterOf(device);
487
- const text = await adapter.read(device, this.network.devices, this.optionsFor(device));
488
- this.writeState(device, text);
489
- console.log(`${device.name}: ${text.split("\n").length} lines`);
490
-
491
- await this.snapshotNeighbors(device, adapter);
492
- }
493
-
494
- // With every device read, a snapshot of a device the config no longer has, such as one renamed, is removed.
495
- if (names.length || !existsSync(this.stateDirectory)) return;
496
-
497
- const current = new Set(
498
- this.network.devices.flatMap((device) => [
499
- basename(this.statePath(device)),
500
- basename(Cabling.path(this.stateDirectory, device.name)),
501
- ])
346
+ // Studio carries its page, so it is loaded only when asked for.
347
+ const { StudioServer } = await import("../studio/server");
348
+ const url = await Ui.step("Starting Studio", () =>
349
+ StudioServer.start({ root, port: flags.port, host: flags.host, allowHosts: flags.allowHost ?? [] })
502
350
  );
503
- for (const file of readdirSync(this.stateDirectory)) {
504
- if (current.has(file)) continue;
505
-
506
- rmSync(join(this.stateDirectory, file));
507
- console.log(`${file}: removed, no such device in the config`);
508
- }
509
- }
510
-
511
- /** Save a device's LLDP neighbors beside its config, where its platform can read them. A device without LLDP is left alone. */
512
- private async snapshotNeighbors(device: Device, adapter: Platform): Promise<void> {
513
- if (!Cabling.runsAnywhere(device)) return;
514
-
515
- if (!adapter.neighbors) {
516
- Cabling.forget(this.stateDirectory, device.name);
517
- console.log(`${device.name}: LLDP neighbors not read, reading them on ${device.platform} is not proved on a device yet`);
518
- return;
519
- }
520
-
521
- const neighbors = await adapter.neighbors(device, this.network.devices, this.optionsFor(device));
522
- Cabling.save(this.stateDirectory, device.name, neighbors);
523
- console.log(`${device.name}: ${neighbors.length} LLDP neighbor(s)`);
524
- }
525
-
526
- private async apply(name: string, confirmed: boolean, force: boolean): Promise<void> {
527
- const device = this.one(name);
528
- this.guard([device]);
529
- const adapter = this.adapterOf(device);
530
-
531
- // A lossy path also cuts the read short, so the path is checked before the read.
532
- if (confirmed) await this.checkPath(device, force);
533
-
534
- // The plan is made against what the device runs now, never against an old snapshot.
535
- const live = await adapter.read(device, this.network.devices, this.optionsFor(device));
536
- this.writeState(device, live);
537
-
538
- const plan = adapter.plan(adapter.render(device), live, this.options);
539
- if (!Cli.printPlan(device, plan)) return;
540
-
541
- if (plan.problems.length) Cli.fail("\nNot sent: resolve the problems first.");
542
-
543
- if (!confirmed) {
544
- console.log("\nNothing sent. Re-run with --confirm.");
545
- return;
546
- }
547
-
548
- // What the checks after the apply compare against, such as the BGP sessions up now, is read before anything is sent.
549
- const health = await adapter.health?.(device, plan, this.optionsFor(device));
550
-
551
- await adapter.apply(device, this.network.devices, plan, this.optionsFor(device));
552
-
553
- this.writeState(device, await adapter.read(device, this.network.devices, this.optionsFor(device)));
554
- console.log("snapshot updated");
555
-
556
- if (!health) return;
557
-
558
- console.log("checking the device after the apply");
559
- const problems = await health.run();
560
-
561
- if (problems.length) {
562
- Cli.fail(`\nApplied, but the device is not healthy:\n${problems.map((problem) => ` ${problem}`).join("\n")}`);
563
- }
351
+ const local = StudioServer.isLoopback(flags.host);
564
352
 
565
- console.log("no problems found");
566
- }
567
-
568
- /** Run commands that only read, each under a header with how long it took. Every command is checked before anything connects. */
569
- private async show(name: string, given: string[], file: string | undefined, timeoutSeconds: number): Promise<void> {
570
- const device = this.one(name);
571
- const adapter = this.adapterOf(device);
572
-
573
- const fromFile = file ? readFileSync(resolve(process.cwd(), file), "utf8").split(/\r?\n/) : [];
574
- const listed = fromFile.map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
575
- const commands = [...given, ...listed];
576
- if (!commands.length) Cli.fail("give a command to run, or --file");
577
-
578
- const refused = commands.filter((command) => !adapter.readOnly(command));
579
- if (refused.length) {
580
- Cli.fail(
581
- `not known to only read on ${device.platform}, so nothing was sent:\n${refused.map((command) => ` ${command}`).join("\n")}`
582
- );
583
- }
584
-
585
- const session = await adapter.session(device, this.optionsFor(device));
586
- const timeout = timeoutSeconds * 1000;
587
- let failed = false;
588
-
589
- try {
590
- for (const command of commands) {
591
- const started = Date.now();
592
- let output: string;
593
-
594
- try {
595
- output = await session.run(command, timeout);
596
- } catch (error) {
597
- output = `stopped: ${(error as Error).message}`;
598
- failed = true;
599
- }
600
-
601
- const took = Date.now() - started;
602
- const stoppedAtLimit = !failed && took >= timeout ? `\n(stopped at the ${timeoutSeconds} second limit)` : "";
603
- const block = `>>> ${command} (${(took / 1000).toFixed(1)}s)\n${output.replace(/\r/g, "").trimEnd()}${stoppedAtLimit}\n\n`;
604
- process.stdout.write(Secrets.scrub(block, { network: this.network }));
605
-
606
- // A shell or a console still runs a command that did not finish, so nothing more can be sent.
607
- if (failed) break;
608
- }
609
- } finally {
610
- session.close();
611
- }
612
-
613
- if (failed) process.exitCode = 1;
614
- }
615
-
616
- /**
617
- * Each BGP neighbor in the config next to its live session, on the device named or the only one with BGP neighbors, and only those whose name matches `neighbors` when given. With `wait`, reads again until every neighbor shown, or that many, is up, and fails when time runs out. With `clearLimit`, opens again the neighbors shown that closed on their prefix limit, and fails when none did.
618
- */
619
- private async bgp(
620
- deviceName: string | undefined,
621
- flags: { neighbors?: string; wait?: true | number; max: number; log?: true | number; clearLimit?: true }
622
- ): Promise<void> {
623
- const device = this.bgpDevice(deviceName);
624
- const pattern = flags.neighbors;
625
- const adapter = this.adapterOf(device);
626
-
627
- const supported = Platforms.all()
628
- .filter((platform) => platform.bgpSessions)
629
- .map((platform) => platform.name)
630
- .join(", ");
631
- if (!adapter.bgpSessions) Cli.fail(`circuit bgp does not support ${device.platform} yet, only ${supported}`);
632
- if (flags.log && !adapter.bgpLog) Cli.fail(`circuit bgp --log does not support ${device.platform} yet`);
633
- if (flags.clearLimit && !adapter.bgpClearLimit) Cli.fail(`circuit bgp --clear-limit does not support ${device.platform} yet`);
634
- if (flags.clearLimit && flags.wait !== undefined) Cli.fail("--clear-limit and --wait go in two runs: clear first, then wait");
635
-
636
- let matcher: RegExp;
637
- try {
638
- matcher = new RegExp(pattern ?? ".");
639
- } catch (error) {
640
- Cli.fail(`${pattern} is not a regular expression: ${(error as Error).message}`);
641
- }
642
-
643
- const names = Object.keys(device.routing?.bgp?.neighbors ?? {});
644
- if (!names.length) Cli.fail(`${device.name} has no BGP neighbors`);
645
-
646
- const shown = names.filter((name) => matcher.test(name));
647
- if (!shown.length) Cli.fail(`no BGP neighbor of ${device.name} matches ${matcher}; known: ${names.join(", ")}`);
648
-
649
- const want = flags.wait === undefined ? 0 : flags.wait === true ? shown.length : flags.wait;
650
- const session = await adapter.session(device, this.optionsFor(device));
651
-
652
- try {
653
- const read = async () => (await adapter.bgpSessions!(device, session)).filter((neighbor) => shown.includes(neighbor.neighbor));
654
-
655
- if (flags.clearLimit) {
656
- await Cli.clearLimits(adapter, session, read);
657
- return;
658
- }
659
-
660
- const limits = { seconds: flags.max, every: BgpWait.COMMAND.every };
661
- const sessions = await BgpWait.until(
662
- read,
663
- (sessions) => BgpWait.upCount(sessions) >= want,
664
- limits,
665
- (sessions) =>
666
- console.log(
667
- `${new Date().toLocaleTimeString()}: ${BgpWait.upCount(sessions)}/${want} up, checking again in ${limits.every}s`
668
- )
669
- );
670
-
671
- Cli.printBgp(sessions);
672
- if (BgpWait.upCount(sessions) < want) process.exitCode = 1;
673
-
674
- if (flags.log) await this.printBgpLog(adapter, session, flags.log === true ? 5 : flags.log);
675
- } finally {
676
- session.close();
677
- }
678
- }
679
-
680
- /** Open again the sessions closed on their limit, say which others were left alone, and show them all after. */
681
- private static async clearLimits(adapter: Platform, session: ReadSession, read: () => Promise<BgpSession[]>): Promise<void> {
682
- const sessions = await read();
683
- const closed = sessions.filter((bgp) => bgp.limit);
684
-
685
- if (!closed.length) {
686
- Cli.printBgp(sessions);
687
- console.error("\nno neighbor shown is closed on its prefix limit, so nothing was cleared");
688
- process.exitCode = 1;
689
- return;
690
- }
691
-
692
- for (const bgp of sessions) if (!bgp.limit) console.log(`${bgp.neighbor}: left alone, not closed on its limit`);
693
- await adapter.bgpClearLimit!(session, closed);
694
- console.log(
695
- `cleared ${closed.map((bgp) => bgp.neighbor).join(", ")}: each connects again, and closes again if it still sends too much\n`
353
+ Ui.done(
354
+ `Circuit Studio at ${url}${local ? "" : `, listening on ${flags.host}:${flags.port}`}. It reads the project again when a file changes. Stop it with Ctrl+C.`
696
355
  );
697
-
698
- Cli.printBgp(await read());
699
- }
700
-
701
- /** The device `circuit bgp` reads: the one named, or the only device with BGP neighbors. */
702
- private bgpDevice(name: string | undefined): Device {
703
- if (name !== undefined) return this.one(name);
704
-
705
- const withBgp = this.network.devices.filter((device) => Object.keys(device.routing?.bgp?.neighbors ?? {}).length);
706
- if (withBgp.length === 1) return withBgp[0]!;
707
- if (!withBgp.length) Cli.fail("no device has BGP neighbors");
708
-
709
- return Cli.fail(`several devices have BGP neighbors, so name one: ${withBgp.map((device) => device.name).join(", ")}`);
710
- }
711
-
712
- /** One line per neighbor: name, up or down, prefixes received, uptime, remote address. Then how many are up. */
713
- private static printBgp(sessions: readonly BgpSession[]): void {
714
- const width = Math.max(...sessions.map((session) => session.neighbor.length));
715
-
716
- for (const session of sessions) {
717
- const state = session.established ? "up " : "DOWN";
718
- const why = session.reason ? ` ${Cli.downReason(session)}` : "";
719
- console.log(
720
- `${session.neighbor.padEnd(width)} ${state} ${session.prefixes.padStart(7)} ${session.uptime.padEnd(12)} ${session.remote}${why}`
356
+ if (!local)
357
+ Ui.warn(
358
+ "Studio on a server shows the project to everyone who reaches it, and does not read devices. Put it behind a proxy that asks who people are."
721
359
  );
722
- }
723
-
724
- console.log(`${BgpWait.upCount(sessions)}/${sessions.length} up`);
725
-
726
- for (const burst of BgpWait.limitBursts(sessions)) {
727
- const names = burst.map((session) => session.neighbor);
728
- const listed = names.length > 1 ? `${names.slice(0, -1).join(", ")} and ${names.at(-1)}` : names[0];
729
- const seconds = Math.round((burst.at(-1)!.limit!.stoppedAt! - burst[0]!.limit!.stoppedAt!) / 1000);
730
-
731
- console.log(
732
- `
733
- ${listed} closed on their prefix limit within ${seconds} s of each other, from ${burst[0]!.limit!.stopped}. Several limits at once is usually a leak upstream, not growth: find the leak before raising a limit, then clear them with --clear-limit.`
734
- );
735
- }
736
- }
737
-
738
- /** Why a session is down, with its limit and when it closed when that is why. */
739
- private static downReason(session: BgpSession): string {
740
- const { limit } = session;
741
- if (!limit) return session.reason ?? "";
742
-
743
- const over = limit.max !== undefined ? `: over ${limit.max} prefixes` : "";
744
- const when = limit.stopped ? `, closed ${limit.stopped}` : "";
745
- return `${session.reason}${over}${when}`;
746
- }
747
-
748
- /** The device's BGP log lines from the last `minutes`, a message repeated folded into one line, secrets hidden. */
749
- private async printBgpLog(adapter: Platform, session: ReadSession, minutes: number): Promise<void> {
750
- const lines = Cli.foldLog(await adapter.bgpLog!(session, minutes));
751
- const text = `\n--- BGP log, last ${minutes} min\n${lines.join("\n") || "(nothing)"}\n`;
752
-
753
- process.stdout.write(Secrets.scrub(text, { network: this.network }));
754
- }
755
-
756
- /** A message repeated, such as one every 10 seconds, becomes one line with when it came first, when last, and how many times. */
757
- private static foldLog(lines: readonly BgpLogLine[]): string[] {
758
- const folded = new Map<string, { count: number; first: string; last: string }>();
759
-
760
- for (const { at, message } of lines) {
761
- const seen = folded.get(message);
762
- if (seen) {
763
- seen.count++;
764
- seen.last = at;
765
- } else {
766
- folded.set(message, { count: 1, first: at, last: at });
767
- }
768
- }
769
-
770
- return [...folded].map(([message, { count, first, last }]) => {
771
- const lastTime = last.split(" ")[1] ?? last;
772
- return count > 1 ? `${first}..${lastTime} x${count} ${message}` : `${first} ${message}`;
773
- });
774
- }
775
-
776
- /**
777
- * A device's console, for a person: logged in with the platform's own login, then joined to this terminal, or sent each line. Logs out when done, and lets the port go on Ctrl-C or when the terminal closes, so no other program finds it held.
778
- */
779
- private async console(name: string, lines: string[], raw: boolean): Promise<void> {
780
- const device = this.one(name);
781
- const dialect = Platforms.find(device.platform)?.console ?? Cli.fail(`${device.platform} cannot be reached through a console yet`);
782
- const serial = device.connection.console ?? Cli.fail(`${device.name} has no connection.console`);
783
- const login = raw ? undefined : SerialConsole.of(device);
784
-
785
- let opened: ConsoleSession;
786
- try {
787
- opened = await SerialConsole.open(serial, 1);
788
- } catch (error) {
789
- const message = (error as Error).message;
790
- if (message.startsWith("busy:")) {
791
- Cli.fail(
792
- `${device.name}: ${serial.port} is in use. Only one program can hold a console port: close the other one, then try again.`
793
- );
794
- }
795
- Cli.fail(`${device.name}: ${message}`);
796
- }
797
-
798
- let loggedIn = false;
799
- let leaving = false;
800
-
801
- const leave = async (code: number) => {
802
- if (leaving) return;
803
- leaving = true;
804
-
805
- if (process.stdin.isTTY) process.stdin.setRawMode(false);
806
- if (loggedIn) opened.channel.write(`${dialect.logout}\n`);
807
-
808
- await opened.close();
809
- process.exit(code);
810
- };
811
-
812
- for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const) process.once(signal, () => void leave(130));
813
-
814
- opened.channel.on("close", () => {
815
- if (leaving) return;
816
-
817
- console.error("\nthe console closed");
818
- process.exit(1);
819
- });
820
-
821
- if (login) {
822
- try {
823
- await SerialConsole.login(opened.channel, dialect, login.user, login.password, serial.passwordOnly);
824
- } catch (error) {
825
- console.error(`${device.name}: ${(error as Error).message}`);
826
- await leave(1);
827
- }
828
- loggedIn = true;
829
- }
830
-
831
- if (lines.length) {
832
- await ConsoleScript.run(opened.channel, lines, (answer) =>
833
- process.stdout.write(Secrets.scrub(answer, { network: this.network }))
834
- );
835
- await leave(0);
836
- return;
837
- }
838
-
839
- this.join(opened, device, loggedIn ? login?.user : undefined, leave);
840
- }
841
-
842
- /** Join the console to this terminal until Ctrl-] or the end of input. Every other key, Ctrl-C too, goes to the device. */
843
- private join(opened: ConsoleSession, device: Device, user: string | undefined, leave: (code: number) => Promise<void>): void {
844
- const stdin = process.stdin;
845
- const as = user ? `, logged in as ${user}` : "";
846
- console.error(`${device.name}'s console${as}. Ctrl-] leaves.`);
847
-
848
- opened.channel.on("data", (chunk: Buffer) => process.stdout.write(chunk));
849
-
850
- if (stdin.isTTY) stdin.setRawMode(true);
851
-
852
- stdin.on("data", (data: Buffer) => {
853
- const at = data.indexOf(Cli.LEAVE_KEY);
854
- const keys = at >= 0 ? data.subarray(0, at) : data;
855
-
856
- // A terminal sends a carriage return for Enter; text piped in ends its lines with a line feed, which the console takes as a keyboard's Enter.
857
- if (keys.length) opened.channel.write(stdin.isTTY ? keys : keys.toString("utf8"));
858
- if (at >= 0) void leave(0);
859
- });
860
-
861
- stdin.on("end", () => void leave(0));
862
- stdin.resume();
863
- }
864
-
865
- /** Ping the device and stop when the path loses too much or is too slow, unless `force`. A device reached through its console has no path to measure. */
866
- private async checkPath(device: Device, force: boolean): Promise<void> {
867
- const host = device.connection.host;
868
- if (!host || this.optionsFor(device).console) return;
869
-
870
- const measure = await PathCheck.measure(host);
871
- if (!measure) {
872
- console.log(`path: no ping program here, so the path to ${host} was not measured`);
873
- return;
874
- }
875
-
876
- const { refuse, summary } = PathCheck.verdict(host, measure);
877
- console.log(`path: ${summary}`);
878
- if (!refuse) return;
879
-
880
- const limits = `at most ${PathCheck.MAX_LOSS_PERCENT}% lost and ${PathCheck.MAX_AVERAGE_MS} ms on average`;
881
- if (force) {
882
- console.log(`path: worse than ${limits}; going ahead, as --force asks`);
883
- return;
884
- }
885
-
886
- Cli.fail(
887
- `\nNot sent: the path is worse than ${limits}. A change sent over a path that drops packets can cut the device off halfway. Fix the path, or add --force to go ahead anyway.`
888
- );
889
- }
890
-
891
- private async refresh(name: string, confirmed: boolean): Promise<void> {
892
- const device = this.one(name);
893
- const adapter = this.adapterOf(device);
894
- const fill = adapter.fill?.bind(adapter) ?? Cli.fail(`${device.platform} cannot hold a fetched prefix set`);
895
-
896
- for (const [name, set] of Object.entries(device.prefix_sets ?? {})) {
897
- if (!("source" in set)) continue;
898
-
899
- const { registry, query, family } = set.source;
900
- const prefixes = await Registries.prefixes(registry, query, family);
901
-
902
- // An empty or absurd answer is a broken feed, not a policy.
903
- if (prefixes.length < 1 || prefixes.length > 100_000) {
904
- Cli.fail(`${name}: ${prefixes.length} prefixes from ${registry}, refusing`);
905
- }
906
-
907
- console.log(`${name}: ${prefixes.length} prefixes from ${registry} ${query}`);
908
- if (confirmed) await fill(device, this.network.devices, name, family, prefixes);
909
- }
910
-
911
- if (!confirmed) console.log("\nNothing sent. Re-run with --confirm.");
912
- }
913
-
914
- /** A WireGuard public key from its private key, so the device's never has to be read off the box. */
915
- private static publicKeyOf(privateKey: string): string {
916
- // The DER prefix of a raw X25519 private key in PKCS#8.
917
- const pkcs8Prefix = Buffer.from("302e020100300506032b656e04220420", "hex");
918
- const der = Buffer.concat([pkcs8Prefix, Buffer.from(privateKey, "base64")]);
919
-
920
- const key = createPrivateKey({ key: der, format: "der", type: "pkcs8" });
921
- const publicDer = createPublicKey(key).export({ format: "der", type: "spki" });
922
-
923
- return publicDer.subarray(-32).toString("base64");
924
- }
925
-
926
- private wireguard(deviceName: string, interfaceName: string, peerName: string): void {
927
- const device = this.one(deviceName);
928
-
929
- const iface = device.interfaces?.[interfaceName];
930
- if (iface?.type !== "wireguard") Cli.fail(`${deviceName} has no WireGuard interface ${interfaceName}`);
931
-
932
- const peers = iface.peers ?? [];
933
- const peerNames = peers.map((candidate) => candidate.name).join(", ");
934
- const peer =
935
- peers.find((candidate) => candidate.name === peerName) ?? Cli.fail(`no peer ${peerName} on ${interfaceName}: ${peerNames}`);
936
-
937
- const tunnelNetworks = (iface.addresses ?? []).map((entry) => Address.prefixOf(typeof entry === "string" ? entry : entry.address));
938
-
939
- const lines = [
940
- "[Interface]",
941
- `# ${peerName}'s own private key. It never leaves their machine.`,
942
- "PrivateKey = <FILL IN>",
943
- `Address = ${peer.allowed_addresses.join(", ")}`,
944
- "",
945
- "[Peer]",
946
- `PublicKey = ${Cli.publicKeyOf(Secrets.reveal(iface.private_key))}`,
947
- `Endpoint = ${iface.endpoint ?? "<FILL IN>"}:${iface.listen_port}`,
948
- `AllowedIPs = ${(peer.client_allowed_addresses ?? tunnelNetworks).join(", ")}`,
949
- "PersistentKeepalive = 25",
950
- "",
951
- ];
952
- process.stdout.write(lines.join("\n"));
953
- }
954
-
955
- private communities(): void {
956
- const communities = this.network.communities ?? Cli.fail("the network declares no communities");
957
-
958
- for (const { community, description } of communities) {
959
- // The format splits each line at its first comma.
960
- if (community.includes(",")) Cli.fail(`community ${community} holds a comma`);
961
- console.log(`${community},${description}`);
962
- }
963
- }
964
-
965
- private async peeringdb(confirmed: boolean): Promise<void> {
966
- const plan = await PeeringDb.plan(this.network);
967
-
968
- for (const name of plan.unknown) {
969
- console.log(` PeeringDB lists ${name}, which the config does not. Remove it by hand if the network left.`);
970
- }
971
-
972
- for (const change of plan.changes) console.log(` ${change.summary}`);
973
-
974
- if (!plan.changes.length) {
975
- console.log("PeeringDB matches the config.");
976
- return;
977
- }
978
-
979
- if (!confirmed) {
980
- console.log("\nNothing sent. Re-run with --confirm.");
981
- return;
982
- }
983
-
984
- await PeeringDb.send(this.network, plan.changes);
985
- }
986
-
987
- private async librenms(confirmed: boolean): Promise<void> {
988
- const plan = await LibreNms.plan(this.network);
989
-
990
- for (const hostname of plan.unknown)
991
- console.log(` LibreNMS monitors ${hostname}, which the config does not. Remove it by hand if it is gone.`);
992
- for (const device of plan.withoutSnmp)
993
- console.log(` ${device.name} has no SNMP in its config, so LibreNMS cannot poll it. Add management.snmp first.`);
994
- for (const name of plan.dynamicGroups)
995
- console.log(` LibreNMS's group ${name} is dynamic, so Circuit cannot fill it. Make it static, or rename it.`);
996
-
997
- for (const device of plan.additions) console.log(` + ${device.name}, ${device.connection.host}`);
998
- for (const change of plan.changes) console.log(` ~ ${change.device.name}: ${change.what.join(", ")}`);
999
-
1000
- for (const group of plan.groups) {
1001
- const names = (devices: readonly Device[]) => devices.map((device) => device.name).join(", ");
1002
-
1003
- if (group.create) console.log(` + group ${group.name}: ${names(group.add)}`);
1004
- if (!group.create && group.add.length) console.log(` ~ group ${group.name}: add ${names(group.add)}`);
1005
- if (!group.create && group.remove.length) console.log(` ~ group ${group.name}: remove ${names(group.remove)}`);
1006
- }
1007
-
1008
- if (!LibreNms.count(plan)) {
1009
- console.log("LibreNMS matches the config.");
1010
- return;
1011
- }
1012
-
1013
- if (!confirmed) {
1014
- console.log("\nNothing sent. Re-run with --confirm.");
1015
- return;
1016
- }
1017
-
1018
- await LibreNms.send(this.network, plan);
360
+ if (flags.open && local) StudioServer.openBrowser(url);
1019
361
  }
1020
362
 
1021
- private async netbox(confirmed: boolean): Promise<void> {
1022
- const netbox = new NetBox(this.network);
1023
- const plan = await netbox.plan();
1024
-
1025
- Cli.showNetBox(plan);
1026
-
1027
- if (!NetBox.count(plan)) {
1028
- console.log("NetBox matches the config.");
363
+ /** In a project: move it to a version the person picks, and the app with it. Elsewhere, or with `--app`: update the app. Either way, versions no project pins any more are removed. */
364
+ private static async update(app: boolean, confirmed: boolean): Promise<void> {
365
+ const root = ProjectCommands.find();
366
+ if (root && Releases.binary()) Versions.remember(root);
367
+ if (app || !root) {
368
+ const target = await Releases.updateApp(confirmed);
369
+ Versions.prune(target ?? VERSION);
1029
370
  return;
1030
371
  }
1031
372
 
1032
- if (!confirmed) {
1033
- console.log("\nNothing sent. Re-run with --confirm.");
373
+ const packaged = Setup.pinned(root);
374
+ if (!packaged) {
375
+ await Versions.updateProject(root, confirmed);
1034
376
  return;
1035
377
  }
1036
378
 
1037
- const left = await netbox.send(plan);
1038
- if (!NetBox.count(left)) return;
1039
-
1040
- console.log("\nNetBox still differs:");
1041
- Cli.showNetBox(left);
1042
- process.exit(1);
1043
- }
1044
-
1045
- /** Each change NetBox would get, then how many of each kind. */
1046
- private static showNetBox(plan: NetBoxPlan): void {
1047
- for (const item of plan.leftAlone) console.log(` ${item} exists without the tag ${NetBox.TAG}: left alone`);
1048
- for (const change of plan.creates) console.log(` + ${change.path} ${change.key}`);
1049
-
1050
- for (const change of plan.updates) {
1051
- console.log(` ~ ${change.path} ${change.key}`);
1052
- for (const line of change.diff) console.log(` ${line}`);
1053
- }
1054
-
1055
- for (const change of plan.deletes) console.log(` - ${change.path} ${change.key}`);
379
+ // A full install pins Circuit in package.json.
380
+ const target = await Releases.pick(packaged.replace(/^[\^~]/, ""), confirmed, "This project");
381
+ if (!target) return;
1056
382
 
1057
- const paths = [...new Set([...plan.creates, ...plan.updates, ...plan.deletes].map((change) => change.path))];
1058
- const count = (changes: readonly { path: string }[], path: string) => changes.filter((change) => change.path === path).length;
1059
-
1060
- if (paths.length) console.log("");
1061
-
1062
- for (const path of paths) {
1063
- console.log(
1064
- ` ${path}: ${count(plan.creates, path)} to create, ${count(plan.updates, path)} to change, ${count(plan.deletes, path)} to delete`
1065
- );
1066
- }
1067
- }
1068
-
1069
- private prometheus(auths: boolean): void {
1070
- if (auths) {
1071
- const { yaml, notes } = Prometheus.snmpExporterAuths(this.network);
1072
- for (const note of notes) console.error(note);
1073
- process.stdout.write(yaml);
1074
- return;
1075
- }
1076
-
1077
- const { targets, withoutSnmp } = Prometheus.targets(this.network);
1078
- for (const device of withoutSnmp) console.error(`${device.name} has no SNMP in its config; left out`);
1079
- console.log(JSON.stringify(targets, null, 4));
1080
- }
1081
-
1082
- private secrets(): void {
1083
- const references = [
1084
- ...Secrets.referencesIn([this.network.devices, this.network.peeringdb, this.network.librenms, this.network.netbox]),
1085
- ].sort();
1086
- let missing = 0;
1087
-
1088
- for (const reference of references) {
1089
- const name = reference.replace(/^file:/, "");
1090
-
1091
- try {
1092
- Secrets.reveal(reference);
1093
- console.log(` ok ${name}, from ${Secrets.sourceOf(reference)}`);
1094
- } catch (error) {
1095
- missing++;
1096
- console.log(` missing ${name}: ${(error as Error).message}`);
1097
- }
1098
- }
1099
-
1100
- console.log(`\n${references.length} secret(s), ${missing} missing.`);
1101
- process.exit(missing ? 1 : 0);
1102
- }
1103
-
1104
- /** How a device compares with its last snapshot, offline, as `diff` plans it. */
1105
- private driftOf(device: Device): Drift {
1106
- const path = this.statePath(device);
1107
- if (!existsSync(path)) return { status: "no snapshot", lines: [] };
1108
-
1109
- try {
1110
- const adapter = this.adapterOf(device);
1111
- const plan = adapter.plan(adapter.render(device), readFileSync(path, "utf8"), this.options);
1112
-
1113
- const lines = [
1114
- ...plan.steps.flatMap((step) => [step.title, ...step.show.map((line) => ` ${line}`)]),
1115
- ...plan.problems.map((problem) => `PROBLEM ${problem}`),
1116
- ];
1117
-
1118
- return { status: lines.length ? "differs" : "matches", lines };
1119
- } catch (error) {
1120
- return { status: "cannot plan", lines: [(error as Error).message] };
1121
- }
383
+ await Ui.step(`Pinning Circuit ${target} in package.json`, async () => {
384
+ const result = spawnSync("bun", ["add", "--dev", "--exact", `${Setup.PACKAGE}@${target}`], {
385
+ cwd: root,
386
+ encoding: "utf8",
387
+ shell: process.platform === "win32",
388
+ });
389
+ if (result.status !== 0) throw new Error(`bun add failed: ${(result.stderr || result.stdout).trim()}`);
390
+ });
1122
391
  }
1123
392
 
1124
- /** 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. */
1125
- private annotatedGraph(layers: readonly Layer[], deviceNames: readonly string[]): Annotated {
1126
- const graph = Topology.only(Topology.of(this.network.devices), layers, deviceNames);
1127
- const shown = new Set(graph.nodes.map((node) => node.label));
393
+ /** Before a commit: every secret encrypted, and a project that validates. */
394
+ private static async hook(name: string): Promise<void> {
395
+ if (name !== "pre-commit") Ui.fail(`No hook ${name}.`);
1128
396
 
1129
- return {
1130
- ...graph,
1131
- findings: this.findings().filter((finding) => !finding.device || shown.has(finding.device)),
1132
- drift: Object.fromEntries(
1133
- this.network.devices.filter((device) => shown.has(device.name)).map((device) => [device.name, this.driftOf(device)])
1134
- ),
397
+ const stop = (files: readonly string[]) => {
398
+ const problems = GitHooks.stagedProblems(ProjectCommands.find() ?? process.cwd(), files);
399
+ if (!problems.length) return;
400
+ for (const problem of problems) Ui.error(problem);
401
+ Ui.fail("\nThe commit was stopped, so no secret reaches git.");
1135
402
  };
1136
- }
1137
-
1138
- private graph(names: string[], layer: string, format: string): void {
1139
- const requested = layer.split(",");
1140
- const layers = requested.includes("all") ? [...Topology.LAYERS] : (requested as Layer[]);
1141
403
 
1142
- const unknown = layers.filter((layer) => !Topology.LAYERS.includes(layer));
1143
- if (unknown.length) Cli.fail(`no layer ${unknown.join(", ")}; known: ${Topology.LAYERS.join(", ")}, all`);
1144
-
1145
- const devices = this.selected(names, true);
1146
- const annotated = this.annotatedGraph(layers, names.length ? devices.map((device) => device.name) : []);
1147
-
1148
- if (format === "mermaid") process.stdout.write(GraphFormat.mermaid(annotated));
1149
- else if (format === "dot") process.stdout.write(GraphFormat.dot(annotated));
1150
- else if (format === "json") console.log(JSON.stringify(annotated, null, 4));
1151
- else Cli.fail(`no format ${format}; known: mermaid, dot, json`);
1152
- }
1153
-
1154
- /** One segment and what uses each address in it. */
1155
- private static printSegment(segment: Segment, indent: string): void {
1156
- const next = segment.next ? `, next ${segment.next}` : "";
1157
- console.log(`${indent}${segment.prefix}, ${segment.free} free${next}`);
1158
-
1159
- const width = Math.max(...segment.uses.map((use) => use.address.length)) + 2;
1160
- for (const use of segment.uses) console.log(`${indent} ${use.address.padEnd(width)}${use.what}`);
1161
- }
1162
-
1163
- private addresses(within: string | undefined, lengthText: string | undefined, format: string | undefined): void {
1164
- if (within && !Address.isCidr(within)) Cli.fail(`${within} is not a prefix, such as 10.0.0.0/16`);
1165
-
1166
- const length = lengthText === undefined ? undefined : Number(lengthText);
1167
- if (length !== undefined && !(Number.isInteger(length) && length >= 0 && length <= 128))
1168
- Cli.fail("--length takes a prefix length, such as 24");
1169
-
1170
- const report = AddressSpace.report(this.network.devices, within);
1171
-
1172
- if (format === "json") {
1173
- console.log(JSON.stringify(report, null, 4));
1174
- return;
1175
- }
1176
-
1177
- for (const space of report.spaces) {
1178
- console.log(`${space.prefix}, ${space.owner}`);
1179
- for (const segment of space.segments) Cli.printSegment(segment, " ");
1180
- for (const block of space.blocks) console.log(` ${block.prefix} ${block.what}`);
1181
-
1182
- if (length === undefined) {
1183
- console.log(` free: ${space.free.join(", ") || "none"}`);
1184
- } else {
1185
- const { prefixes, total } = AddressSpace.freeOfLength(space.free, length, 16);
1186
- const more = total > BigInt(prefixes.length) ? `, and ${total - BigInt(prefixes.length)} more` : "";
1187
- console.log(` free /${length}: ${prefixes.join(", ") || "none"}${more}`);
1188
- }
1189
-
1190
- console.log("");
1191
- }
1192
-
1193
- const { segments, blocks } = report.elsewhere;
1194
- if (segments.length || blocks.length) {
1195
- console.log("Outside the space the network originates");
1196
- for (const segment of segments) Cli.printSegment(segment, " ");
1197
- for (const block of blocks) console.log(` ${block.prefix} ${block.what}`);
1198
- }
1199
-
1200
- if (!report.spaces.length && !segments.length && !blocks.length) console.log("The config holds no addresses.");
1201
- }
1202
-
1203
- private rename(from: string, to: string, file: boolean, confirmed: boolean): void {
1204
- const device = this.network.devices.find((candidate) => candidate.name === from);
1205
- if (!device) Cli.fail(`no device ${from}; known: ${this.network.devices.map((candidate) => candidate.name).join(", ")}`);
1206
- if (this.network.devices.some((candidate) => candidate.name === to)) Cli.fail(`${to} is already a device`);
1207
- if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(to)) Cli.fail(`${to} is not a hostname: letters, digits, dots, dashes and underscores`);
1208
-
1209
- const files = [this.statePath(device), Cabling.path(this.stateDirectory, device.name)];
1210
- const snapshots = files.filter((file) => existsSync(file)).map((file) => relative(this.root, file));
1211
-
1212
- let plan: RenamePlan;
1213
- try {
1214
- plan = Rename.plan(this.root, from, to, { file, snapshots });
1215
- } catch (error) {
1216
- Cli.fail((error as Error).message);
1217
- }
1218
-
1219
- for (const change of plan.changes) {
1220
- console.log(`${change.file}:${change.line}`);
1221
- console.log(` - ${change.before.trim()}`);
1222
- console.log(` + ${change.after.trim()}`);
1223
- }
1224
-
1225
- for (const move of plan.moves) console.log(`move ${move.from} to ${move.to}`);
1226
-
1227
- if (plan.mentions.length) {
1228
- console.log(`\nStill naming ${from}, left as they are: change them by hand if they mean the device.`);
1229
- for (const mention of plan.mentions) console.log(` ${mention.file}:${mention.line} ${mention.text.slice(0, 160)}`);
1230
- }
1231
-
1232
- if (!confirmed) {
1233
- console.log("\nNothing written. Re-run with --confirm.");
1234
- return;
1235
- }
1236
-
1237
- Rename.apply(this.root, plan);
1238
- console.log(`\nRenamed. Run circuit validate, then circuit apply ${to}: the device keeps its old hostname until then.`);
1239
- }
1240
-
1241
- private async studio(flags: { port: string; open: boolean; host: string; allowHost?: string; json?: true }): Promise<void> {
1242
- if (flags.json) {
1243
- const sources = ProjectFiles.sources(this.root);
1244
- const data = StudioModel.build(this.network.devices, this.annotatedGraph(Topology.LAYERS, []), {
1245
- render: (device) => this.adapterOf(device).render(device),
1246
- snapshot: (device) => (existsSync(this.statePath(device)) ? readFileSync(this.statePath(device), "utf8") : undefined),
1247
- files: (device) => ({
1248
- // Written with forward slashes on every system, as git and the page take them.
1249
- source: ProjectFiles.deviceFiles(this.root, device.name, sources)[0]?.split(sep).join("/"),
1250
- snapshot: existsSync(this.statePath(device))
1251
- ? relative(this.root, this.statePath(device)).split(sep).join("/")
1252
- : undefined,
1253
- }),
1254
- });
1255
- console.log(JSON.stringify(data));
1256
- return;
1257
- }
1258
-
1259
- const port = Number(flags.port);
1260
- if (!Number.isInteger(port) || port < 1 || port > 65535) Cli.fail("--port takes a number from 1 to 65535");
1261
-
1262
- const { StudioServer } = await import("../studio/server");
1263
- const host = flags.host;
1264
- const allowHosts = (flags.allowHost ?? "").split(",").filter(Boolean);
1265
-
1266
- // Off this machine, Studio must be told the names it is reached by: every other name is refused.
1267
- if (!StudioServer.isLoopback(host) && !allowHosts.length) {
1268
- Cli.fail(`Studio on ${host} needs --allow-host=<name>, the name people reach it by, such as studio.example.com`);
1269
- }
1270
-
1271
- await StudioServer.start({
1272
- configPath: this.configPath,
1273
- root: this.root,
1274
- port,
1275
- open: flags.open,
1276
- host,
1277
- allowHosts,
1278
- });
404
+ // `.circuit/secrets` before the project is read, so a project with errors still has it checked; then the files the networks name.
405
+ stop([SecretsFile.DEFAULT]);
406
+ if (!ProjectCommands.find()) return;
407
+
408
+ const project = await ProjectCommands.open({ experimental: true });
409
+ stop(project.secretsFiles());
410
+ // A field not proved yet is a warning here: it stops `apply`, not a commit.
411
+ project.validate();
412
+ }
413
+
414
+ /** The menu `circuit` opens alone: pick what to do, answer what it needs, and get the command it runs. */
415
+ private static async menu(): Promise<string[]> {
416
+ Ui.logo(`Your network, written down. Circuit ${VERSION}`);
417
+ Ui.intro(`Circuit ${VERSION}`);
418
+
419
+ if (!ProjectCommands.find()) {
420
+ const choice = await Ui.select("No Circuit project in this folder. What do you want to do?", [
421
+ { value: "new", label: "Start a new project" },
422
+ { value: "update", label: "Update Circuit" },
423
+ { value: "uninstall", label: "Uninstall Circuit", hint: "your projects stay" },
424
+ { value: "exit", label: "Exit" },
425
+ ]);
426
+ if (choice === "exit") process.exit(0);
427
+ return [choice];
428
+ }
429
+
430
+ const heading = (label: string): Choice<string> => ({ value: `#${label}`, label: Ui.dim(`── ${label} ──`), disabled: true });
431
+ const choice = await Ui.select("What do you want to do?", [
432
+ heading("Core"),
433
+ { value: "validate", label: "Validate", hint: "check every file of the project" },
434
+ { value: "diff", label: "Diff", hint: "what apply would change on a device" },
435
+ { value: "apply", label: "Apply", hint: "make a device match its config" },
436
+ heading("Utilities"),
437
+ { value: "exec", label: "Execute", hint: "run a command that only reads on a device" },
438
+ { value: "bgp", label: "BGP", hint: "each BGP session of a device" },
439
+ { value: "access-console", label: "Access console", hint: "a device's serial console" },
440
+ { value: "studio", label: "Studio", hint: "the project in your browser" },
441
+ { value: "rename", label: "Rename", hint: "give a device a new name everywhere" },
442
+ { value: "fmt", label: "Tidy files", hint: "spacing and line breaks only; what the files say stays the same" },
443
+ heading("Secrets"),
444
+ { value: "secrets", label: "Secrets", hint: "every secret in a text editor, saved encrypted" },
445
+ heading("Circuit"),
446
+ { value: "update", label: "Update Circuit" },
447
+ { value: "uninstall", label: "Uninstall Circuit", hint: "your projects stay" },
448
+ { value: "exit", label: "Exit" },
449
+ ]);
450
+
451
+ if (choice === "exit") process.exit(0);
452
+ return [choice];
1279
453
  }
1280
454
  }
1281
455
 
1282
- await Cli.main(process.argv.slice(2));
456
+ try {
457
+ await Cli.main(process.argv.slice(2));
458
+ } catch (error) {
459
+ Ui.fail((error as Error).message);
460
+ }