@takodotid/circuit 0.43.1 → 1.0.0-rc.2

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 (256) 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 +71 -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 +350 -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/render.ts +69 -62
  63. package/src/platforms/fortios/routing.ts +89 -58
  64. package/src/platforms/fortios/vdoms.ts +47 -16
  65. package/src/platforms/junos/TRAPS.md +2 -2
  66. package/src/platforms/junos/bgp.ts +63 -42
  67. package/src/platforms/junos/index.ts +122 -89
  68. package/src/platforms/junos/render.ts +139 -104
  69. package/src/platforms/platform.ts +10 -8
  70. package/src/platforms/raisecom-ros/TRAPS.md +1 -1
  71. package/src/platforms/raisecom-ros/index.ts +89 -70
  72. package/src/platforms/raisecom-ros/render.ts +49 -45
  73. package/src/platforms/routeros/TRAPS.md +3 -3
  74. package/src/platforms/routeros/index.ts +121 -60
  75. package/src/platforms/routeros/render.ts +214 -195
  76. package/src/platforms/vrp/TRAPS.md +1 -1
  77. package/src/platforms/vrp/index.ts +74 -50
  78. package/src/platforms/vrp/render.ts +67 -56
  79. package/src/project/project.ts +725 -0
  80. package/src/studio/app/env.d.ts +9 -0
  81. package/src/studio/app/index.html +18 -0
  82. package/src/studio/app/src/App.vue +95 -0
  83. package/src/studio/app/src/components/AddressesView.vue +38 -0
  84. package/src/studio/app/src/components/CablesView.vue +29 -0
  85. package/src/studio/app/src/components/CompareView.vue +160 -0
  86. package/src/studio/app/src/components/DataTable.vue +49 -0
  87. package/src/studio/app/src/components/DeviceView.vue +237 -0
  88. package/src/studio/app/src/components/DevicesView.vue +32 -0
  89. package/src/studio/app/src/components/Faceplate.vue +75 -0
  90. package/src/studio/app/src/components/GraphView.vue +282 -0
  91. package/src/studio/app/src/components/LineDiff.vue +24 -0
  92. package/src/studio/app/src/components/ProblemsView.vue +42 -0
  93. package/src/studio/app/src/components/SearchView.vue +72 -0
  94. package/src/studio/app/src/components/SecretsView.vue +112 -0
  95. package/src/studio/app/src/components/SessionsView.vue +35 -0
  96. package/src/studio/app/src/components/Settings.vue +95 -0
  97. package/src/studio/app/src/components/ToolsView.vue +118 -0
  98. package/src/studio/app/src/components/VlansView.vue +50 -0
  99. package/src/studio/app/src/lib/csv.ts +19 -0
  100. package/src/studio/{public/diff.js → app/src/lib/diff.ts} +40 -35
  101. package/src/studio/app/src/lib/format.ts +38 -0
  102. package/src/studio/app/src/lib/net.ts +68 -0
  103. package/src/studio/app/src/lib/secrets-crypto.ts +35 -0
  104. package/src/studio/app/src/lib/store.ts +71 -0
  105. package/src/studio/app/src/lib/tools.ts +217 -0
  106. package/src/studio/app/src/main.ts +9 -0
  107. package/src/studio/{public → app/src}/style.css +14 -4
  108. package/src/studio/app/tsconfig.json +17 -0
  109. package/src/studio/app/vite.config.ts +16 -0
  110. package/src/studio/assets.generated.ts +3 -0
  111. package/src/studio/data.ts +96 -188
  112. package/src/studio/git.ts +27 -43
  113. package/src/studio/server.ts +196 -107
  114. package/src/studio/types.ts +141 -0
  115. package/src/transport/console.ts +3 -3
  116. package/src/transport/ssh.ts +1 -1
  117. package/dist/chunks/index-3b343xva.js +0 -177
  118. package/dist/chunks/index-6wb41q67.js +0 -62
  119. package/dist/chunks/index-n7w3tg5y.js +0 -149
  120. package/dist/chunks/index-yzw86rc8.js +0 -921
  121. package/dist/chunks/new-w3zys5y4.js +0 -172
  122. package/dist/chunks/server-cxr9ynxc.js +0 -259
  123. package/dist/cli/index.js +0 -12922
  124. package/dist/helpers/index.js +0 -881
  125. package/dist/index.js +0 -28
  126. package/dist/types/core/address-space.d.ts +0 -62
  127. package/dist/types/core/address.d.ts +0 -44
  128. package/dist/types/core/cabling.d.ts +0 -41
  129. package/dist/types/core/cross-checks.d.ts +0 -28
  130. package/dist/types/core/define.d.ts +0 -79
  131. package/dist/types/core/project-files.d.ts +0 -12
  132. package/dist/types/core/schema-docs.d.ts +0 -25
  133. package/dist/types/core/secrets.d.ts +0 -62
  134. package/dist/types/core/topology.d.ts +0 -96
  135. package/dist/types/core/utils.d.ts +0 -10
  136. package/dist/types/core/validator.d.ts +0 -23
  137. package/dist/types/helpers/bgp.d.ts +0 -195
  138. package/dist/types/helpers/cables.d.ts +0 -69
  139. package/dist/types/helpers/checks.d.ts +0 -36
  140. package/dist/types/helpers/exchange.d.ts +0 -55
  141. package/dist/types/helpers/filters.d.ts +0 -51
  142. package/dist/types/helpers/index.d.ts +0 -14
  143. package/dist/types/helpers/route-server.d.ts +0 -52
  144. package/dist/types/helpers/upstreams.d.ts +0 -192
  145. package/dist/types/index.d.ts +0 -10
  146. package/dist/types/integrations/librenms.d.ts +0 -60
  147. package/dist/types/integrations/netbox.d.ts +0 -89
  148. package/dist/types/integrations/peeringdb.d.ts +0 -24
  149. package/dist/types/integrations/prometheus.d.ts +0 -24
  150. package/dist/types/integrations/registries.d.ts +0 -8
  151. package/dist/types/platforms/bgp-wait.d.ts +0 -24
  152. package/dist/types/platforms/catalog.d.ts +0 -109
  153. package/dist/types/platforms/derive.d.ts +0 -17
  154. package/dist/types/platforms/fortios/firewall.d.ts +0 -47
  155. package/dist/types/platforms/fortios/index.d.ts +0 -140
  156. package/dist/types/platforms/fortios/models.d.ts +0 -14
  157. package/dist/types/platforms/fortios/names.d.ts +0 -52
  158. package/dist/types/platforms/fortios/plan.d.ts +0 -114
  159. package/dist/types/platforms/fortios/render.d.ts +0 -77
  160. package/dist/types/platforms/fortios/routing.d.ts +0 -56
  161. package/dist/types/platforms/fortios/tree.d.ts +0 -37
  162. package/dist/types/platforms/fortios/vdoms.d.ts +0 -50
  163. package/dist/types/platforms/index.d.ts +0 -13
  164. package/dist/types/platforms/junos/bgp.d.ts +0 -44
  165. package/dist/types/platforms/junos/index.d.ts +0 -114
  166. package/dist/types/platforms/junos/models.d.ts +0 -14
  167. package/dist/types/platforms/junos/render.d.ts +0 -154
  168. package/dist/types/platforms/line-diff.d.ts +0 -67
  169. package/dist/types/platforms/platform.d.ts +0 -135
  170. package/dist/types/platforms/raisecom-ros/index.d.ts +0 -71
  171. package/dist/types/platforms/raisecom-ros/models.d.ts +0 -14
  172. package/dist/types/platforms/raisecom-ros/render.d.ts +0 -41
  173. package/dist/types/platforms/routeros/export.d.ts +0 -59
  174. package/dist/types/platforms/routeros/index.d.ts +0 -116
  175. package/dist/types/platforms/routeros/models.d.ts +0 -18
  176. package/dist/types/platforms/routeros/plan.d.ts +0 -58
  177. package/dist/types/platforms/routeros/render.d.ts +0 -250
  178. package/dist/types/platforms/vrp/index.d.ts +0 -83
  179. package/dist/types/platforms/vrp/models.d.ts +0 -12
  180. package/dist/types/platforms/vrp/render.d.ts +0 -96
  181. package/dist/types/schema/common.d.ts +0 -30
  182. package/dist/types/schema/device.d.ts +0 -126
  183. package/dist/types/schema/firewall.d.ts +0 -131
  184. package/dist/types/schema/index.d.ts +0 -8
  185. package/dist/types/schema/interfaces.d.ts +0 -205
  186. package/dist/types/schema/policy.d.ts +0 -107
  187. package/dist/types/schema/routing.d.ts +0 -161
  188. package/dist/types/schema/system.d.ts +0 -198
  189. package/dist/types/schema/time-zones.d.ts +0 -2
  190. package/dist/types/transport/console.d.ts +0 -115
  191. package/dist/types/transport/path-check.d.ts +0 -32
  192. package/dist/types/transport/ssh.d.ts +0 -27
  193. package/src/cli/graph-format.ts +0 -91
  194. package/src/cli/new.ts +0 -234
  195. package/src/core/address-space.ts +0 -320
  196. package/src/core/cross-checks.ts +0 -369
  197. package/src/core/define.ts +0 -124
  198. package/src/core/project-files.ts +0 -35
  199. package/src/core/schema-docs.ts +0 -174
  200. package/src/core/validator.ts +0 -508
  201. package/src/helpers/bgp.ts +0 -473
  202. package/src/helpers/cables.ts +0 -74
  203. package/src/helpers/checks.ts +0 -201
  204. package/src/helpers/exchange.ts +0 -123
  205. package/src/helpers/filters.ts +0 -107
  206. package/src/helpers/index.ts +0 -25
  207. package/src/helpers/route-server.ts +0 -212
  208. package/src/helpers/upstreams.ts +0 -369
  209. package/src/index.ts +0 -13
  210. package/src/integrations/librenms.ts +0 -291
  211. package/src/integrations/netbox.ts +0 -620
  212. package/src/integrations/peeringdb.ts +0 -173
  213. package/src/integrations/prometheus.ts +0 -90
  214. package/src/schema/common.ts +0 -41
  215. package/src/schema/device.ts +0 -145
  216. package/src/schema/firewall.ts +0 -140
  217. package/src/schema/index.ts +0 -8
  218. package/src/schema/policy.ts +0 -115
  219. package/src/schema/routing.ts +0 -173
  220. package/src/schema/system.ts +0 -203
  221. package/src/studio/public/app.js +0 -186
  222. package/src/studio/public/compare.js +0 -250
  223. package/src/studio/public/dom.js +0 -138
  224. package/src/studio/public/evaluate.js +0 -687
  225. package/src/studio/public/export.js +0 -130
  226. package/src/studio/public/graph.js +0 -1541
  227. package/src/studio/public/index.html +0 -83
  228. package/src/studio/public/net.js +0 -134
  229. package/src/studio/public/tables.js +0 -1289
  230. package/src/studio/public/tools.js +0 -610
  231. package/templates/colocation/checks.ts +0 -53
  232. package/templates/colocation/circuit.config.ts +0 -9
  233. package/templates/colocation/edge-01.ts +0 -133
  234. package/templates/colocation/routing.ts +0 -13
  235. package/templates/colocation/tenancy.ts +0 -76
  236. package/templates/colocation/tenants/acme.ts +0 -19
  237. package/templates/colocation/tenants/index.ts +0 -6
  238. package/templates/colocation/tor-01.ts +0 -33
  239. package/templates/edge-router/checks.ts +0 -20
  240. package/templates/edge-router/circuit.config.ts +0 -8
  241. package/templates/edge-router/edge-01.ts +0 -110
  242. package/templates/edge-router/routing.ts +0 -13
  243. package/templates/project/AGENTS.md +0 -51
  244. package/templates/project/CLAUDE.md +0 -1
  245. package/templates/project/README.md +0 -16
  246. package/templates/project/gitignore +0 -5
  247. package/templates/project/tsconfig.json +0 -13
  248. package/templates/single-site/checks.ts +0 -26
  249. package/templates/single-site/circuit.config.ts +0 -8
  250. package/templates/single-site/router.ts +0 -86
  251. package/templates/single-site/site.ts +0 -18
  252. package/templates/single-site/switch.ts +0 -34
  253. /package/src/studio/{public → app/public}/favicon.svg +0 -0
  254. /package/src/studio/{public → app/public}/special-gothic-latin-ext.woff2 +0 -0
  255. /package/src/studio/{public → app/public}/special-gothic-latin.woff2 +0 -0
  256. /package/src/studio/{public → app/public}/special-gothic-license.txt +0 -0
@@ -1,48 +1,41 @@
1
- // `circuit studio`: the network in the browser, on this machine or on a server for a team. The page is the files in public/, served as they are. What it shows comes from /data: gathered by `circuit studio --json` in a new process, so the config is always read fresh, and gathered again each time a file beside the config changes.
1
+ // `circuit studio`: the project in the browser, on this machine, or on a server for a team. The page is built by Vite from `app/` and carried inside Circuit (`assets.generated.ts`). What it shows comes from /api/data: gathered by Circuit in a new process, so the project is always read fresh, and gathered again each time a file of the project changes.
2
2
 
3
3
  import { execFile, spawn } from "node:child_process";
4
- import { existsSync, readFileSync, watch } from "node:fs";
5
- import { createServer, type ServerResponse } from "node:http";
6
- import { extname, join } from "node:path";
7
- import { StudioModel, type StudioData } from "./data";
4
+ import { watch } from "node:fs";
5
+ import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
6
+ import { SecretsFile } from "../core/secrets-file";
7
+ import { Secrets } from "../core/secrets";
8
+ import { Platforms } from "../platforms";
9
+ import { Releases } from "../cli/releases";
10
+ import { STUDIO_ASSETS } from "./assets.generated";
8
11
  import { Git } from "./git";
12
+ import type { LiveRead, SecretEntry, SecretsFileListing, SecretsListing, StudioData } from "./types";
9
13
 
10
14
  export type StudioOptions = {
11
- /** The config file. */
12
- configPath: string;
13
- /** The directory watched for changes: the config's own. */
15
+ /** The project's folder. */
14
16
  root: string;
15
17
  port: number;
16
- /** Open the browser once the server listens. */
17
- open: boolean;
18
18
  /** The address to listen on: 127.0.0.1 on a machine of one's own, or a server's address. */
19
19
  host: string;
20
20
  /** Names besides this machine's own that requests may be addressed to, such as `studio.example.com` behind a proxy. */
21
21
  allowHosts: string[];
22
+ /** How the project is read: by default, Circuit itself in a new process. Tests give their own. */
23
+ gather?: (folder: string) => Promise<string>;
22
24
  };
23
25
 
24
26
  export class StudioServer {
25
- /** Directories whose changes never change what Studio shows. */
26
- private static readonly IGNORED = /(^|[\\/])(node_modules|\.git|\.circuit[\\/]cache)([\\/]|$)/;
27
+ /** Folders whose changes never change what Studio shows. */
28
+ private static readonly IGNORED = /(^|[\\/])(node_modules|\.git|\.circuit)([\\/]|$)/;
27
29
 
28
- private static readonly CONTENT_TYPES: Record<string, string> = {
29
- ".html": "text/html; charset=utf-8",
30
- ".css": "text/css; charset=utf-8",
31
- ".js": "text/javascript; charset=utf-8",
32
- ".svg": "image/svg+xml",
33
- ".woff2": "font/woff2",
34
- };
30
+ /** The header the page sends with a request that reaches a device, which a page on another site cannot send without asking first. */
31
+ static readonly REQUEST_HEADER = "x-circuit-studio";
35
32
 
36
33
  /** Whether an address to listen on is this machine's own, which nothing else on the network can reach. */
37
34
  static isLoopback(host: string): boolean {
38
35
  return host === "localhost" || host === "::1" || /^127\.\d+\.\d+\.\d+$/.test(host);
39
36
  }
40
37
 
41
- /**
42
- * Whether a request's Host header names Studio, so a web page elsewhere cannot read the network through a name it points here.
43
- *
44
- * Studio answers to this machine's own names at its port, and to each name it was told to, with or without a port.
45
- */
38
+ /** Whether a request's Host header names Studio, so a web page elsewhere cannot read the project through a name it points here. */
46
39
  static hostAllowed(header: string | undefined, port: number, allowHosts: readonly string[]): boolean {
47
40
  if (!header) return false;
48
41
  if (header === `127.0.0.1:${port}` || header === `localhost:${port}` || header === `[::1]:${port}`) return true;
@@ -54,23 +47,32 @@ export class StudioServer {
54
47
  });
55
48
  }
56
49
 
57
- /** What Studio shows, as JSON, from `circuit studio --json` run as a new process, or what stopped it. */
58
- private static gather(configPath: string): Promise<string> {
59
- const cli = process.argv[1]!;
60
- const args = [...process.execArgv, cli, "studio", "--json", `--config=${configPath}`];
50
+ /** Circuit itself, to run again: the binary, or Bun with Circuit's sources. */
51
+ private static self(): { command: string; args: string[] } {
52
+ return Releases.binary()
53
+ ? { command: process.execPath, args: [] }
54
+ : { command: process.execPath, args: [...process.execArgv, process.argv[1]!] };
55
+ }
61
56
 
62
- return new Promise((done) => {
63
- execFile(process.execPath, args, { maxBuffer: 256 * 1024 * 1024 }, (error, stdout, stderr) => {
64
- if (!error) return done(stdout);
57
+ /** What Studio shows, as JSON, from `circuit studio-data` run as a new process, or what stopped it. A new process reads every file again: one process imports a file once. */
58
+ static gather(folder: string): Promise<string> {
59
+ const { command, args } = StudioServer.self();
65
60
 
66
- const message = (stderr || error.message).trim();
67
- done(JSON.stringify({ error: message }));
68
- });
61
+ return new Promise((done) => {
62
+ execFile(
63
+ command,
64
+ [...args, "studio-data", folder],
65
+ { maxBuffer: 256 * 1024 * 1024, env: { ...process.env, CIRCUIT_LOCAL: "1" } },
66
+ (error, stdout, stderr) => {
67
+ if (!error) return done(stdout);
68
+ done(JSON.stringify({ error: (stderr || error.message).trim() }));
69
+ }
70
+ );
69
71
  });
70
72
  }
71
73
 
72
74
  /** Open the address in the browser. A machine without one only misses the convenience. */
73
- private static openBrowser(url: string): void {
75
+ static openBrowser(url: string): void {
74
76
  const [command, args] =
75
77
  process.platform === "win32"
76
78
  ? ["cmd", ["/c", "start", "", url]]
@@ -83,20 +85,97 @@ export class StudioServer {
83
85
  child.unref();
84
86
  }
85
87
 
86
- /** Serve Studio until the process stops, and gather what it shows again whenever a file beside the config changes. */
87
- static async start(options: StudioOptions): Promise<void> {
88
- const { configPath, root, port } = options;
88
+ /** Each secrets file the networks use, as the page may see it: names, how each is kept, ciphertexts. Never a value in plain text, and never the password. */
89
+ static secretsListing(root: string, data: StudioData): SecretsListing {
90
+ const files = [...new Set(data.networks.map((network) => network.secretsFile))];
91
+ return {
92
+ files: files.map((file) =>
93
+ StudioServer.fileListing(
94
+ root,
95
+ file,
96
+ data.networks.filter((network) => network.secretsFile === file)
97
+ )
98
+ ),
99
+ };
100
+ }
89
101
 
90
- // The page's files ship in the package's sources, which Bun runs as they are and Node's build leaves in place.
91
- const installed = StudioModel.packageRoot();
92
- if (!installed) throw new Error("cannot find where Circuit is installed, so Studio has no page to serve");
93
- const pages = join(installed, "src", "studio", "public");
102
+ private static fileListing(root: string, name: string, networks: StudioData["networks"]): SecretsFileListing {
103
+ const file = SecretsFile.at(root, name);
104
+ const values = file.values();
105
+ const needed = [...new Set(networks.flatMap((network) => network.secrets))];
106
+ const names = [...new Set([...file.names(), ...needed])].sort();
107
+
108
+ const entries = names.map((entry): SecretEntry => {
109
+ const value = values[entry];
110
+ const isNeeded = needed.includes(entry);
111
+ if (value === undefined) return { name: entry, kind: "missing", needed: isNeeded };
112
+
113
+ const kind = SecretsFile.kindOf(value);
114
+ if (kind === "encrypted") return { name: entry, kind, ciphertext: value, needed: isNeeded };
115
+ if (kind === "1password") {
116
+ const [vault = "", item = "", ...field] = value.slice("op://".length).split("/");
117
+ return { name: entry, kind, onePassword: { vault, item, field: field.join("/") }, needed: isNeeded };
118
+ }
94
119
 
95
- let data = await StudioServer.gather(configPath);
120
+ return { name: entry, kind, needed: isNeeded };
121
+ });
96
122
 
97
- // Another commit's network is read once: a commit does not change. Each is kept as it is being read, so two asks share one read.
123
+ const passwordLocation = file.passwordLocation();
124
+ return {
125
+ file: name,
126
+ networks: networks.map((network) => network.name),
127
+ ...(passwordLocation ? { passwordLocation } : {}),
128
+ entries,
129
+ };
130
+ }
131
+
132
+ /** Read a device now and plan against what it runs, as `circuit diff` does. Nothing is kept. */
133
+ static async readDevice(root: string, data: StudioData, name: string): Promise<LiveRead> {
134
+ const device = data.devices.find((candidate) => candidate.name === name);
135
+ if (!device) throw new Error(`no device ${name}`);
136
+
137
+ const adapter = Platforms.find(device.platform);
138
+ if (!adapter) throw new Error(`no platform ${device.platform}`);
139
+
140
+ Secrets.useRoot(root);
141
+ Secrets.useFile(data.networks.find((network) => network.devices.includes(name))?.secretsFile);
142
+ const locked = Secrets.lockedFor(device);
143
+ if (locked)
144
+ throw new Error(
145
+ `${locked.name} is encrypted, and Studio has no password for it. Studio reads a device when the password comes from 1Password or ${SecretsFile.PASSWORD}; or run circuit diff ${name}, which asks for it.`
146
+ );
147
+ const options = { secrets: false, rollback: 10, ...(device.connection.host ? {} : { console: true }) };
148
+ const running = await adapter.read(device, data.devices, options);
149
+ const rendered = adapter.render(device);
150
+ const plan = adapter.plan(rendered, running, options);
151
+
152
+ return {
153
+ device: name,
154
+ running,
155
+ rendered,
156
+ steps: plan.steps.map((step) => ({ title: step.title, show: step.show })),
157
+ problems: plan.problems,
158
+ readAt: new Date().toISOString(),
159
+ };
160
+ }
161
+
162
+ /** Serve Studio until the process stops, and gather what it shows again whenever a file of the project changes. Resolves with its address once it listens. */
163
+ static async start(options: StudioOptions): Promise<string> {
164
+ const { root, port } = options;
165
+ const gather = options.gather ?? StudioServer.gather;
166
+ const local = StudioServer.isLoopback(options.host);
167
+
168
+ let data = await gather(root);
169
+
170
+ // Another commit's project is read once: a commit does not change. Each is kept as it is being read, so two asks share one read.
98
171
  const past = new Map<string, Promise<string>>();
99
172
 
173
+ const parsed = (): StudioData => {
174
+ const value = JSON.parse(data) as StudioData | { error: string };
175
+ if ("error" in value) throw new Error("the project has errors, so Studio cannot use it");
176
+ return value;
177
+ };
178
+
100
179
  /** Answer with JSON, or with an error the page shows. */
101
180
  const answer = async (response: ServerResponse, work: () => Promise<unknown>) => {
102
181
  let body: string;
@@ -108,13 +187,14 @@ export class StudioServer {
108
187
  }
109
188
  response.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" }).end(body);
110
189
  };
190
+
111
191
  const listeners = new Set<ServerResponse>();
112
192
  const tell = (message: string) => {
113
193
  for (const listener of listeners) listener.write(`data: ${message}\n\n`);
114
194
  };
115
195
 
116
- const server = createServer((request, response) => {
117
- // Studio shows one team's network: every answer asks search engines to leave it out, and robots.txt says so for the whole site.
196
+ const handle = (request: IncomingMessage, response: ServerResponse) => {
197
+ // Studio shows one team's network: every answer asks search engines to leave it out.
118
198
  response.setHeader("x-robots-tag", "noindex, nofollow, noarchive");
119
199
 
120
200
  if (!StudioServer.hostAllowed(request.headers.host, port, options.allowHosts)) {
@@ -125,109 +205,118 @@ export class StudioServer {
125
205
  const url = new URL(request.url ?? "/", "http://studio");
126
206
  const path = url.pathname;
127
207
 
128
- // The commits that changed a device: its file and its snapshot.
129
- if (path === "/history") {
208
+ if (path === "/api/data") {
209
+ response.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" }).end(data);
210
+ return;
211
+ }
212
+
213
+ // Changes are told as they happen; a proxy such as nginx is asked not to hold them back.
214
+ if (path === "/api/events") {
215
+ response.writeHead(200, {
216
+ "content-type": "text/event-stream",
217
+ "cache-control": "no-store",
218
+ connection: "keep-alive",
219
+ "x-accel-buffering": "no",
220
+ });
221
+ response.write(": connected\n\n");
222
+ listeners.add(response);
223
+ request.on("close", () => listeners.delete(response));
224
+ return;
225
+ }
226
+
227
+ if (path === "/api/secrets") {
228
+ void answer(response, async () => StudioServer.secretsListing(root, parsed()));
229
+ return;
230
+ }
231
+
232
+ // The commits that changed a device's file.
233
+ if (path === "/api/history") {
130
234
  void answer(response, async () => {
131
235
  const name = url.searchParams.get("device") ?? "";
132
- const files = (JSON.parse(data) as StudioData).files?.[name];
133
- if (!files) throw new Error(`no device ${name}`);
134
- return Git.history(
135
- root,
136
- [files.source, files.snapshot].filter((file): file is string => Boolean(file))
137
- );
236
+ const file = parsed().files[name];
237
+ if (!file) throw new Error(`no device ${name}`);
238
+ return Git.history(root, [file]);
138
239
  });
139
240
  return;
140
241
  }
141
242
 
142
243
  // What Studio can compare with: branches, tags and the latest commits.
143
- if (path === "/refs") {
244
+ if (path === "/api/refs") {
144
245
  void answer(response, () => Git.references(root));
145
246
  return;
146
247
  }
147
248
 
148
- // The network as it was at a branch, tag or commit.
149
- if (path === "/compare") {
249
+ // The project as it was at a branch, tag or commit.
250
+ if (path === "/api/compare") {
150
251
  void answer(response, async () => {
151
252
  const asked = url.searchParams.get("ref") ?? "";
152
253
  const commit = await Git.commitOf(root, asked);
153
254
  if (!commit) throw new Error(`${asked} names no commit here`);
154
255
 
155
- if (!past.has(commit.commit))
156
- past.set(commit.commit, Git.networkAt(root, configPath, commit.commit, StudioServer.gather));
157
- const network = await past.get(commit.commit)!;
158
- if (network.startsWith('{"error"')) past.delete(commit.commit);
256
+ if (!past.has(commit.commit)) past.set(commit.commit, Git.projectAt(root, commit.commit, gather));
257
+ const project = await past.get(commit.commit)!;
258
+ if (project.startsWith('{"error"')) past.delete(commit.commit);
159
259
 
160
- return `{"commit":${JSON.stringify(commit)},"network":${network}}`;
260
+ return `{"commit":${JSON.stringify(commit)},"network":${project}}`;
161
261
  });
162
262
  return;
163
263
  }
164
264
 
165
- if (path === "/robots.txt") {
166
- response.writeHead(200, { "content-type": "text/plain; charset=utf-8" }).end("User-agent: *\nDisallow: /\n");
167
- return;
168
- }
169
-
170
- if (path === "/data") {
171
- response.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" }).end(data);
265
+ // Reading a device reaches the device, so it takes a POST with Studio's own header, which a page on another site cannot send unasked.
266
+ if (path === "/api/read") {
267
+ if (request.method !== "POST" || request.headers[StudioServer.REQUEST_HEADER] !== "1") {
268
+ response.writeHead(403).end();
269
+ return;
270
+ }
271
+ void answer(response, async () => {
272
+ if (!local)
273
+ throw new Error("Studio on a server does not read devices. Run circuit diff, or Studio on your own machine.");
274
+ return StudioServer.readDevice(root, parsed(), url.searchParams.get("device") ?? "");
275
+ });
172
276
  return;
173
277
  }
174
278
 
175
- // Changes are told as they happen; a proxy such as nginx is asked not to hold them back.
176
- if (path === "/events") {
177
- response.writeHead(200, {
178
- "content-type": "text/event-stream",
179
- "cache-control": "no-store",
180
- connection: "keep-alive",
181
- "x-accel-buffering": "no",
182
- });
183
- response.write(": connected\n\n");
184
-
185
- listeners.add(response);
186
- request.on("close", () => listeners.delete(response));
279
+ if (path === "/robots.txt") {
280
+ response.writeHead(200, { "content-type": "text/plain; charset=utf-8" }).end("User-agent: *\nDisallow: /\n");
187
281
  return;
188
282
  }
189
283
 
190
- // Only a file directly in public/, by a plain name, is served: nothing above it can be reached.
191
- const name = path === "/" ? "index.html" : path.slice(1);
192
- const type = StudioServer.CONTENT_TYPES[extname(name)];
193
- const file = join(pages, name);
194
-
195
- if (!/^[a-z-]+\.[a-z0-9]+$/.test(name) || !type || !existsSync(file)) {
284
+ const asset = STUDIO_ASSETS[path === "/" ? "index.html" : path.slice(1)];
285
+ if (!asset) {
196
286
  response.writeHead(404).end();
197
287
  return;
198
288
  }
199
289
 
200
- response.writeHead(200, { "content-type": type, "cache-control": "no-store" }).end(readFileSync(file));
201
- });
290
+ const cache = path.startsWith("/assets/") ? "public, max-age=31536000, immutable" : "no-store";
291
+ response.writeHead(200, { "content-type": asset.type, "cache-control": cache }).end(Buffer.from(asset.body, "base64"));
292
+ };
202
293
 
203
- // Several files often change at once, such as on a save or a checkout: the config is read again once they settle.
204
- let timer: NodeJS.Timeout | undefined;
294
+ const server = createServer(handle);
205
295
 
206
- watch(root, { recursive: true }, (_event, filename) => {
296
+ // Several files often change at once, such as on a save or a checkout: the project is read again once they settle.
297
+ let timer: NodeJS.Timeout | undefined;
298
+ const watcher = watch(root, { recursive: true }, (_event, filename) => {
207
299
  if (filename && StudioServer.IGNORED.test(filename)) return;
208
300
 
209
301
  clearTimeout(timer);
210
302
  timer = setTimeout(async () => {
211
- data = await StudioServer.gather(configPath);
303
+ data = await gather(root);
212
304
  tell("changed");
213
- console.log(`${new Date().toLocaleTimeString()} ${filename ?? "a file"} changed, read again`);
214
305
  }, 300);
215
306
  });
307
+ server.on("close", () => watcher.close());
216
308
 
217
- // A change to the page's own files, while working on Circuit, loads the page again.
218
- watch(pages, () => tell("page"));
219
-
220
- server.on("error", (error: NodeJS.ErrnoException) => {
221
- const message = error.code === "EADDRINUSE" ? `port ${port} is in use; choose another with --port=N` : error.message;
222
- console.error(message);
223
- process.exit(1);
309
+ await new Promise<void>((done, fail) => {
310
+ server.once("error", (error: NodeJS.ErrnoException) =>
311
+ fail(new Error(error.code === "EADDRINUSE" ? `port ${port} is in use; choose another with --port` : error.message))
312
+ );
313
+ server.listen(port, options.host, () => done());
224
314
  });
225
315
 
226
- server.listen(port, options.host, () => {
227
- const local = StudioServer.isLoopback(options.host);
228
- const url = local ? `http://127.0.0.1:${port}` : `http://${options.allowHosts[0] ?? options.host}`;
229
- console.log(`Circuit Studio at ${url}, listening on ${options.host}:${port}, watching ${root}. Stop it with Ctrl+C.`);
230
- if (options.open && local) StudioServer.openBrowser(url);
231
- });
316
+ StudioServer.servers.add(server);
317
+ return local ? `http://127.0.0.1:${port}` : `http://${options.allowHosts[0] ?? options.host}:${port}`;
232
318
  }
319
+
320
+ /** Every server started, so a test can stop them. */
321
+ static readonly servers = new Set<ReturnType<typeof createServer>>();
233
322
  }
@@ -0,0 +1,141 @@
1
+ // What Studio's server sends its page. Types only, so the page shares them without taking any of Circuit's code.
2
+
3
+ import type { Graph } from "../core/graph";
4
+ import type { Device } from "../model";
5
+ import type { Finding } from "../platforms/finding";
6
+
7
+ /** One network of the project, without its devices. */
8
+ export type StudioNetwork = {
9
+ name: string;
10
+ asn?: number;
11
+ /** By name. */
12
+ sites: Record<string, { description?: string; number?: number }>;
13
+ devices: string[];
14
+ /** Where its secrets are, relative to the project. */
15
+ secretsFile: string;
16
+ /** The secrets its devices and integrations name with `secret("NAME")`. */
17
+ secrets: string[];
18
+ };
19
+
20
+ /** One address a device holds. */
21
+ export type AddressRow = {
22
+ device: string;
23
+ /** The port or interface. */
24
+ holder: string;
25
+ /** With its length. */
26
+ address: string;
27
+ /** The prefix it is in. */
28
+ prefix: string;
29
+ family: "ipv4" | "ipv6";
30
+ /** The far end of a point-to-point address. */
31
+ peer?: string;
32
+ vrf?: string;
33
+ };
34
+
35
+ /** One BGP session, with what it takes from its neighbor group, and what is at its far end. */
36
+ export type Session = {
37
+ device: string;
38
+ name: string;
39
+ address: string;
40
+ group?: string;
41
+ remoteAs?: number;
42
+ role?: string;
43
+ import?: string;
44
+ export?: string;
45
+ description?: string;
46
+ /** One of our devices, by name, or another network, as `AS64501`. */
47
+ far?: string;
48
+ /** The far device has no session back. */
49
+ oneSided?: true;
50
+ };
51
+
52
+ /** One cable, from the end that names it first. */
53
+ export type Cable = {
54
+ device: string;
55
+ port: string;
56
+ farDevice: string;
57
+ farPort: string;
58
+ /** The LAG each end's port is in. */
59
+ lag?: string;
60
+ farLag?: string;
61
+ description?: string;
62
+ /** What is between the two ends, such as a carrier's local loop. */
63
+ through?: string;
64
+ /** VLANs carried, as `name (id)`, from the port or its LAG. */
65
+ vlans: string[];
66
+ /** The far end does not name this cable back. */
67
+ oneSided?: true;
68
+ };
69
+
70
+ /** Everything Studio shows of the project, read fresh each time a file changes. */
71
+ export type StudioData = {
72
+ /** The project's folder name. */
73
+ project: string;
74
+ networks: StudioNetwork[];
75
+ devices: Device[];
76
+ /** Each device's file, from the project's folder. */
77
+ files: Record<string, string>;
78
+ /** Every port each device's model has, in order, so Studio can show the ones the project leaves shut. */
79
+ ports: Record<string, string[]>;
80
+ /** What each device would run, as `circuit apply` would send it whole, or why it cannot be rendered. */
81
+ rendered: Record<string, string>;
82
+ /** What `circuit validate` finds, and what reading the project warns of. */
83
+ findings: Finding[];
84
+ graph: Graph;
85
+ addresses: AddressRow[];
86
+ sessions: Session[];
87
+ cables: Cable[];
88
+ /** Every secret the project names with `secret("NAME")`, by name. */
89
+ secrets: string[];
90
+ };
91
+
92
+ /** What reading the project stopped on, instead of the data. */
93
+ export type StudioError = { error: string };
94
+
95
+ /** One commit, as Studio lists it. */
96
+ export type Commit = {
97
+ commit: string;
98
+ short: string;
99
+ author: string;
100
+ /** ISO 8601, with the author's time zone. */
101
+ date: string;
102
+ subject: string;
103
+ };
104
+
105
+ /** How one name in a secrets file is kept. The value is sent only when it is encrypted: a value in plain text never leaves the server. */
106
+ export type SecretEntry = {
107
+ name: string;
108
+ kind: "encrypted" | "1password" | "empty" | "not encrypted" | "missing";
109
+ /** The `encrypted:...` value, for the page to decrypt with the password a person types. */
110
+ ciphertext?: string;
111
+ /** Where 1Password keeps it. */
112
+ onePassword?: { vault: string; item: string; field: string };
113
+ /** The config names it with `secret("NAME")`. */
114
+ needed: boolean;
115
+ };
116
+
117
+ /** One secrets file as the page may see it: names, how each is kept, ciphertexts. Never a value in plain text, and never the password. */
118
+ export type SecretsFileListing = {
119
+ file: string;
120
+ /** The networks that keep their secrets here. */
121
+ networks: string[];
122
+ /** Where the password comes from, without its value. Absent when a person types it. */
123
+ passwordLocation?: string;
124
+ entries: SecretEntry[];
125
+ };
126
+
127
+ export type SecretsListing = { files: SecretsFileListing[] };
128
+
129
+ /** What `Read from the device now` found: the device's own config, and the plan from it to the project's. */
130
+ export type LiveRead = {
131
+ device: string;
132
+ /** What the device runs now, secrets removed. */
133
+ running: string;
134
+ /** What the project says it should run. */
135
+ rendered: string;
136
+ /** The plan `circuit diff` would show. */
137
+ steps: { title: string; show: string[] }[];
138
+ problems: string[];
139
+ /** When it was read, ISO 8601. */
140
+ readAt: string;
141
+ };
@@ -5,7 +5,7 @@ import { EventEmitter } from "node:events";
5
5
  import { Duplex } from "node:stream";
6
6
  import { Client, type ClientChannel } from "ssh2";
7
7
  import { Secrets } from "../core/secrets";
8
- import type { Console, Device, Jump } from "../schema";
8
+ import type { Console, Device, Jump } from "../model";
9
9
  import { Ssh } from "./ssh";
10
10
 
11
11
  /** The relay, in Python's standard library only: the serial port in raw mode, locked against a second session, joined to standard input and output. */
@@ -440,7 +440,7 @@ export class SerialConsole {
440
440
 
441
441
  /** A CLI on a console or in an SSH shell that runs one command at a time and returns what it printed, as an SSH exec would: no echo, no prompt. */
442
442
  export class ConsoleShell {
443
- /** The quiet to wait for after the prompt when output can hold a line that looks like it, as `circuit show` prints whatever the device has. */
443
+ /** The quiet to wait for after the prompt when output can hold a line that looks like it, as `circuit exec` prints whatever the device has. */
444
444
  static readonly SETTLE = 1_000;
445
445
 
446
446
  constructor(
@@ -476,7 +476,7 @@ export class ConsoleShell {
476
476
  }
477
477
  }
478
478
 
479
- /** One line for `circuit console`: what to send, the quiet that ends its answer, and the text the last answer must end with for the line to be sent. */
479
+ /** One line for `circuit access-console`: what to send, the quiet that ends its answer, and the text the last answer must end with for the line to be sent. */
480
480
  export type ScriptLine = { text: string; quiet: number; onlyAfter?: string };
481
481
 
482
482
  /**
@@ -1,7 +1,7 @@
1
1
  // SSH sessions to devices. Adapters choose how to talk to their platform; this holds what they share.
2
2
 
3
3
  import { Client, type ClientChannel } from "ssh2";
4
- import type { Device } from "../schema";
4
+ import type { Device } from "../model";
5
5
 
6
6
  export type Login = {
7
7
  host: string;