@takodotid/circuit 0.34.3 → 0.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (240) hide show
  1. package/README.md +14 -15
  2. package/dist/chunks/index-20kcjxjg.js +174 -0
  3. package/dist/chunks/index-2r4bbhac.js +977 -0
  4. package/dist/chunks/index-n7w3tg5y.js +149 -0
  5. package/dist/chunks/new-w3zys5y4.js +172 -0
  6. package/dist/chunks/server-gzsf5me5.js +259 -0
  7. package/dist/cli/index.js +11345 -9024
  8. package/dist/helpers/index.js +594 -0
  9. package/dist/index.js +9 -28
  10. package/dist/types/core/{addresses.d.ts → address-space.d.ts} +23 -7
  11. package/dist/types/core/address.d.ts +44 -0
  12. package/dist/types/core/cabling.d.ts +41 -0
  13. package/dist/types/core/cross-checks.d.ts +28 -0
  14. package/dist/types/core/define.d.ts +25 -3
  15. package/dist/types/core/project-files.d.ts +12 -0
  16. package/dist/types/core/schema-docs.d.ts +13 -4
  17. package/dist/types/core/secrets.d.ts +62 -21
  18. package/dist/types/core/{graph.d.ts → topology.d.ts} +32 -12
  19. package/dist/types/core/utils.d.ts +8 -0
  20. package/dist/types/core/validator.d.ts +19 -0
  21. package/dist/types/helpers/bgp.d.ts +97 -0
  22. package/dist/types/helpers/checks.d.ts +32 -0
  23. package/dist/types/helpers/exchange.d.ts +49 -0
  24. package/dist/types/helpers/filters.d.ts +51 -0
  25. package/dist/types/helpers/index.d.ts +10 -0
  26. package/dist/types/helpers/route-server.d.ts +52 -0
  27. package/dist/types/index.d.ts +5 -3
  28. package/dist/types/integrations/librenms.d.ts +60 -0
  29. package/dist/types/integrations/netbox.d.ts +89 -0
  30. package/dist/types/integrations/peeringdb.d.ts +24 -0
  31. package/dist/types/integrations/prometheus.d.ts +24 -0
  32. package/dist/types/integrations/registries.d.ts +8 -0
  33. package/dist/types/platforms/bgp-wait.d.ts +18 -0
  34. package/dist/types/platforms/catalog.d.ts +109 -0
  35. package/dist/types/platforms/derive.d.ts +17 -0
  36. package/dist/types/platforms/fortios/firewall.d.ts +45 -0
  37. package/dist/types/platforms/fortios/index.d.ts +134 -0
  38. package/dist/types/platforms/fortios/names.d.ts +52 -0
  39. package/dist/types/platforms/fortios/plan.d.ts +104 -0
  40. package/dist/types/platforms/fortios/render.d.ts +75 -0
  41. package/dist/types/platforms/fortios/routing.d.ts +56 -0
  42. package/dist/types/platforms/fortios/tree.d.ts +37 -0
  43. package/dist/types/platforms/index.d.ts +13 -0
  44. package/dist/types/platforms/junos/bgp.d.ts +44 -0
  45. package/dist/types/platforms/junos/index.d.ts +114 -0
  46. package/dist/types/platforms/junos/render.d.ts +154 -0
  47. package/dist/types/platforms/line-diff.d.ts +67 -0
  48. package/dist/types/platforms/platform.d.ts +120 -0
  49. package/dist/types/platforms/raisecom-ros/index.d.ts +71 -0
  50. package/dist/types/platforms/raisecom-ros/render.d.ts +41 -0
  51. package/dist/types/platforms/routeros/export.d.ts +59 -0
  52. package/dist/types/platforms/routeros/index.d.ts +96 -0
  53. package/dist/types/{adapters/devices → platforms}/routeros/models.d.ts +4 -0
  54. package/dist/types/platforms/routeros/plan.d.ts +54 -0
  55. package/dist/types/platforms/routeros/render.d.ts +240 -0
  56. package/dist/types/platforms/vrp/index.d.ts +83 -0
  57. package/dist/types/platforms/vrp/render.d.ts +96 -0
  58. package/dist/types/schema/common.d.ts +12 -6
  59. package/dist/types/schema/index.d.ts +1 -0
  60. package/dist/types/schema/interfaces.d.ts +14 -6
  61. package/dist/types/schema/policy.d.ts +18 -10
  62. package/dist/types/schema/routing.d.ts +14 -1
  63. package/dist/types/schema/system.d.ts +16 -6
  64. package/dist/types/schema/time-zones.d.ts +2 -0
  65. package/dist/types/transport/console.d.ts +79 -23
  66. package/dist/types/transport/path-check.d.ts +32 -0
  67. package/dist/types/transport/ssh.d.ts +17 -15
  68. package/package.json +18 -12
  69. package/src/cli/graph-format.ts +91 -0
  70. package/src/cli/index.ts +1074 -484
  71. package/src/cli/new.ts +208 -175
  72. package/src/cli/rename.ts +97 -91
  73. package/src/core/address-space.ts +320 -0
  74. package/src/core/address.ts +202 -0
  75. package/src/core/cabling.ts +154 -0
  76. package/src/core/cross-checks.ts +364 -0
  77. package/src/core/define.ts +26 -3
  78. package/src/core/project-files.ts +35 -0
  79. package/src/core/schema-docs.ts +138 -130
  80. package/src/core/secrets.ts +170 -76
  81. package/src/core/topology.ts +656 -0
  82. package/src/core/utils.ts +37 -0
  83. package/src/core/validator.ts +407 -0
  84. package/src/helpers/bgp.ts +301 -0
  85. package/src/helpers/checks.ts +178 -0
  86. package/src/helpers/exchange.ts +117 -0
  87. package/src/helpers/filters.ts +107 -0
  88. package/src/helpers/index.ts +12 -0
  89. package/src/helpers/route-server.ts +212 -0
  90. package/src/index.ts +6 -3
  91. package/src/integrations/librenms.ts +291 -0
  92. package/src/integrations/netbox.ts +620 -0
  93. package/src/integrations/peeringdb.ts +173 -0
  94. package/src/integrations/prometheus.ts +90 -0
  95. package/src/integrations/registries.ts +25 -0
  96. package/src/platforms/bgp-wait.ts +41 -0
  97. package/src/platforms/catalog.ts +69 -0
  98. package/src/platforms/derive.ts +117 -0
  99. package/src/{adapters/devices → platforms}/fortios/TRAPS.md +6 -0
  100. package/src/platforms/fortios/firewall.ts +377 -0
  101. package/src/platforms/fortios/index.ts +1060 -0
  102. package/src/platforms/fortios/names.ts +130 -0
  103. package/src/platforms/fortios/plan.ts +929 -0
  104. package/src/platforms/fortios/render.ts +779 -0
  105. package/src/platforms/fortios/routing.ts +476 -0
  106. package/src/platforms/fortios/tree.ts +129 -0
  107. package/src/platforms/index.ts +36 -0
  108. package/src/{adapters/devices → platforms}/junos/TRAPS.md +8 -0
  109. package/src/platforms/junos/bgp.ts +293 -0
  110. package/src/platforms/junos/index.ts +1006 -0
  111. package/src/platforms/junos/render.ts +967 -0
  112. package/src/platforms/line-diff.ts +244 -0
  113. package/src/platforms/platform.ts +122 -0
  114. package/src/platforms/raisecom-ros/TRAPS.md +40 -0
  115. package/src/platforms/raisecom-ros/index.ts +731 -0
  116. package/src/platforms/raisecom-ros/render.ts +328 -0
  117. package/src/{adapters/devices → platforms}/routeros/TRAPS.md +16 -2
  118. package/src/platforms/routeros/export.ts +342 -0
  119. package/src/platforms/routeros/index.ts +636 -0
  120. package/src/{adapters/devices → platforms}/routeros/models.ts +2 -0
  121. package/src/platforms/routeros/plan.ts +444 -0
  122. package/src/platforms/routeros/render.ts +1483 -0
  123. package/src/platforms/vrp/TRAPS.md +45 -0
  124. package/src/platforms/vrp/index.ts +652 -0
  125. package/src/platforms/vrp/render.ts +615 -0
  126. package/src/schema/common.ts +15 -6
  127. package/src/schema/index.ts +1 -0
  128. package/src/schema/interfaces.ts +15 -5
  129. package/src/schema/policy.ts +22 -10
  130. package/src/schema/routing.ts +16 -1
  131. package/src/schema/system.ts +16 -6
  132. package/src/schema/time-zones.ts +482 -0
  133. package/src/studio/data.ts +172 -168
  134. package/src/studio/git.ts +95 -90
  135. package/src/studio/public/app.js +161 -150
  136. package/src/studio/public/compare.js +219 -212
  137. package/src/studio/public/diff.js +77 -75
  138. package/src/studio/public/dom.js +118 -108
  139. package/src/studio/public/evaluate.js +584 -545
  140. package/src/studio/public/export.js +120 -118
  141. package/src/studio/public/graph.js +1343 -1266
  142. package/src/studio/public/net.js +115 -109
  143. package/src/studio/public/tables.js +1135 -1083
  144. package/src/studio/public/tools.js +561 -529
  145. package/src/studio/server.ts +202 -196
  146. package/src/transport/console.ts +363 -271
  147. package/src/transport/path-check.ts +91 -0
  148. package/src/transport/ssh.ts +98 -94
  149. package/templates/colocation/checks.ts +11 -6
  150. package/templates/colocation/edge-01.ts +9 -9
  151. package/templates/colocation/routing.ts +2 -2
  152. package/templates/colocation/tenancy.ts +3 -3
  153. package/templates/colocation/tor-01.ts +3 -3
  154. package/templates/edge-router/checks.ts +4 -4
  155. package/templates/edge-router/edge-01.ts +5 -5
  156. package/templates/edge-router/routing.ts +2 -2
  157. package/templates/project/AGENTS.md +5 -1
  158. package/templates/single-site/checks.ts +2 -2
  159. package/dist/chunks/index-47debt16.js +0 -956
  160. package/dist/chunks/index-v8zgr7wm.js +0 -128
  161. package/dist/chunks/new-ny89s7y7.js +0 -166
  162. package/dist/chunks/server-tjbb3rqc.js +0 -251
  163. package/dist/presets/index.js +0 -394
  164. package/dist/types/adapters/devices/catalog.d.ts +0 -86
  165. package/dist/types/adapters/devices/derive.d.ts +0 -7
  166. package/dist/types/adapters/devices/fortios/firewall.d.ts +0 -23
  167. package/dist/types/adapters/devices/fortios/index.d.ts +0 -58
  168. package/dist/types/adapters/devices/fortios/names.d.ts +0 -48
  169. package/dist/types/adapters/devices/fortios/plan.d.ts +0 -28
  170. package/dist/types/adapters/devices/fortios/render.d.ts +0 -38
  171. package/dist/types/adapters/devices/fortios/routing.d.ts +0 -32
  172. package/dist/types/adapters/devices/fortios/tree.d.ts +0 -33
  173. package/dist/types/adapters/devices/index.d.ts +0 -3
  174. package/dist/types/adapters/devices/junos/bgp.d.ts +0 -11
  175. package/dist/types/adapters/devices/junos/index.d.ts +0 -42
  176. package/dist/types/adapters/devices/junos/render.d.ts +0 -52
  177. package/dist/types/adapters/devices/lines.d.ts +0 -56
  178. package/dist/types/adapters/devices/raisecom-ros/index.d.ts +0 -28
  179. package/dist/types/adapters/devices/routeros/export.d.ts +0 -39
  180. package/dist/types/adapters/devices/routeros/index.d.ts +0 -30
  181. package/dist/types/adapters/devices/routeros/plan.d.ts +0 -4
  182. package/dist/types/adapters/devices/routeros/render.d.ts +0 -100
  183. package/dist/types/adapters/devices/types.d.ts +0 -40
  184. package/dist/types/adapters/devices/vrp/index.d.ts +0 -27
  185. package/dist/types/adapters/registries/index.d.ts +0 -3
  186. package/dist/types/adapters/registries/librenms.d.ts +0 -15
  187. package/dist/types/adapters/registries/peeringdb.d.ts +0 -16
  188. package/dist/types/adapters/registries/prometheus.d.ts +0 -16
  189. package/dist/types/core/across.d.ts +0 -4
  190. package/dist/types/core/addr.d.ts +0 -34
  191. package/dist/types/core/files.d.ts +0 -6
  192. package/dist/types/core/merge.d.ts +0 -4
  193. package/dist/types/core/time.d.ts +0 -2
  194. package/dist/types/core/validate.d.ts +0 -2
  195. package/dist/types/presets/index.d.ts +0 -183
  196. package/src/adapters/devices/catalog.ts +0 -41
  197. package/src/adapters/devices/derive.ts +0 -59
  198. package/src/adapters/devices/fortios/firewall.ts +0 -362
  199. package/src/adapters/devices/fortios/index.ts +0 -923
  200. package/src/adapters/devices/fortios/names.ts +0 -115
  201. package/src/adapters/devices/fortios/plan.ts +0 -888
  202. package/src/adapters/devices/fortios/render.ts +0 -768
  203. package/src/adapters/devices/fortios/routing.ts +0 -444
  204. package/src/adapters/devices/fortios/tree.ts +0 -121
  205. package/src/adapters/devices/index.ts +0 -9
  206. package/src/adapters/devices/junos/bgp.ts +0 -269
  207. package/src/adapters/devices/junos/index.ts +0 -862
  208. package/src/adapters/devices/junos/render.ts +0 -872
  209. package/src/adapters/devices/lines.ts +0 -212
  210. package/src/adapters/devices/raisecom-ros/TRAPS.md +0 -26
  211. package/src/adapters/devices/raisecom-ros/index.ts +0 -700
  212. package/src/adapters/devices/routeros/export.ts +0 -321
  213. package/src/adapters/devices/routeros/index.ts +0 -346
  214. package/src/adapters/devices/routeros/plan.ts +0 -398
  215. package/src/adapters/devices/routeros/render.ts +0 -1490
  216. package/src/adapters/devices/types.ts +0 -38
  217. package/src/adapters/devices/vrp/TRAPS.md +0 -26
  218. package/src/adapters/devices/vrp/index.ts +0 -865
  219. package/src/adapters/registries/index.ts +0 -19
  220. package/src/adapters/registries/librenms.ts +0 -92
  221. package/src/adapters/registries/peeringdb.ts +0 -169
  222. package/src/adapters/registries/prometheus.ts +0 -71
  223. package/src/cli/graph.ts +0 -89
  224. package/src/core/across.ts +0 -350
  225. package/src/core/addr.ts +0 -174
  226. package/src/core/addresses.ts +0 -316
  227. package/src/core/files.ts +0 -32
  228. package/src/core/graph.ts +0 -640
  229. package/src/core/merge.ts +0 -19
  230. package/src/core/time.ts +0 -15
  231. package/src/core/validate.ts +0 -348
  232. package/src/presets/index.ts +0 -579
  233. /package/dist/types/{adapters/devices → platforms}/fortios/models.d.ts +0 -0
  234. /package/dist/types/{adapters/devices → platforms}/junos/models.d.ts +0 -0
  235. /package/dist/types/{adapters/devices → platforms}/raisecom-ros/models.d.ts +0 -0
  236. /package/dist/types/{adapters/devices → platforms}/vrp/models.d.ts +0 -0
  237. /package/src/{adapters/devices → platforms}/fortios/models.ts +0 -0
  238. /package/src/{adapters/devices → platforms}/junos/models.ts +0 -0
  239. /package/src/{adapters/devices → platforms}/raisecom-ros/models.ts +0 -0
  240. /package/src/{adapters/devices → platforms}/vrp/models.ts +0 -0
package/src/cli/new.ts CHANGED
@@ -3,199 +3,232 @@
3
3
  import { spawnSync } from "node:child_process";
4
4
  import { cpSync, existsSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
5
5
  import { basename, dirname, join, relative, resolve } from "node:path";
6
- import { createInterface } from "node:readline/promises";
7
6
  import { fileURLToPath } from "node:url";
7
+ import * as prompts from "@clack/prompts";
8
+
9
+ type Manager = keyof typeof Scaffold.MANAGERS;
10
+
11
+ type Pattern = keyof typeof Scaffold.PATTERNS;
12
+
13
+ /** The answers given as options. A question is asked only for an answer missing here. */
14
+ export type ScaffoldOptions = {
15
+ pattern?: string;
16
+ asn?: string;
17
+ onePassword?: boolean;
18
+ use?: string;
19
+ install: boolean;
20
+ };
21
+
22
+ export class Scaffold {
23
+ private static readonly ROOT = Scaffold.packageRoot();
24
+ private static readonly TEMPLATES = join(Scaffold.ROOT, "templates");
25
+ private static readonly CIRCUIT = JSON.parse(readFileSync(join(Scaffold.ROOT, "package.json"), "utf8"));
26
+
27
+ /** How each package manager installs and runs. The templates are written for npm; the others are swapped in. */
28
+ static readonly MANAGERS = {
29
+ npm: { install: "npm install", run: "npx circuit", types: "node" },
30
+ pnpm: { install: "pnpm install", run: "pnpm circuit", types: "node" },
31
+ bun: { install: "bun install", run: "bun circuit", types: "bun" },
32
+ } as const;
33
+
34
+ static readonly PATTERNS = {
35
+ "single-site": {
36
+ title: "A single site",
37
+ fits: "a home, an office or an internal network: a router and a switch, no BGP",
38
+ asn: false,
39
+ },
40
+ "edge-router": { title: "An edge router", fits: "your own AS number, with an IP transit and an internet exchange", asn: true },
41
+ colocation: {
42
+ title: "Colocation with tenants",
43
+ fits: "a provider whose customers each get their own VLANs, addresses and ports",
44
+ asn: true,
45
+ },
46
+ } as const;
47
+
48
+ /** The AS number the templates use, reserved for documentation by RFC 5398. */
49
+ private static readonly EXAMPLE_ASN = "64500";
50
+
51
+ /** Circuit's own directory: this file runs from src/cli/ in Bun, and from dist/ once built. */
52
+ private static packageRoot(): string {
53
+ let directory = dirname(fileURLToPath(import.meta.url));
54
+
55
+ while (!existsSync(join(directory, "templates"))) {
56
+ const parent = dirname(directory);
57
+ if (parent === directory) throw new Error("Circuit's templates are missing from its package");
58
+ directory = parent;
59
+ }
8
60
 
9
- /** Circuit's own directory: this file runs from src/cli/ in Bun, and from dist/ once built. */
10
- function packageRoot(): string {
11
- let directory = dirname(fileURLToPath(import.meta.url));
12
-
13
- while (!existsSync(join(directory, "templates"))) {
14
- const parent = dirname(directory);
15
- if (parent === directory) throw new Error("Circuit's templates are missing from its package");
16
- directory = parent;
61
+ return directory;
17
62
  }
18
63
 
19
- return directory;
20
- }
21
-
22
- const ROOT = packageRoot();
23
- const TEMPLATES = join(ROOT, "templates");
24
- const circuit = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8"));
25
-
26
- /** How each package manager installs and runs. The templates are written for npm; the others are swapped in. */
27
- const MANAGERS = {
28
- npm: { install: "npm install", run: "npx circuit", types: "node" },
29
- pnpm: { install: "pnpm install", run: "pnpm circuit", types: "node" },
30
- bun: { install: "bun install", run: "bun circuit", types: "bun" },
31
- } as const;
32
-
33
- type Manager = keyof typeof MANAGERS;
34
-
35
- /** The package manager that started this, from what it says about itself, such as `pnpm/9.1.0 npm/? node/v22`. */
36
- function detectManager(): Manager {
37
- const agent = process.env.npm_config_user_agent ?? "";
38
- if (agent.startsWith("pnpm/")) return "pnpm";
39
- if (agent.startsWith("bun/") || process.versions.bun) return "bun";
40
- return "npm";
41
- }
42
-
43
- const PATTERNS = {
44
- "single-site": { title: "A single site", fits: "a home, an office or an internal network: a router and a switch, no BGP", asn: false },
45
- "edge-router": { title: "An edge router", fits: "your own AS number, with an IP transit and an internet exchange", asn: true },
46
- colocation: {
47
- title: "Colocation with tenants",
48
- fits: "a provider whose customers each get their own VLANs, addresses and ports",
49
- asn: true,
50
- },
51
- } as const;
52
-
53
- type Pattern = keyof typeof PATTERNS;
54
-
55
- /** The AS number the templates use, reserved for documentation by RFC 5398. */
56
- const EXAMPLE_ASN = "64500";
64
+ /** The package manager that started this, from what it says about itself, such as `pnpm/9.1.0 npm/? node/v22`. */
65
+ private static detectManager(): Manager {
66
+ const agent = process.env.npm_config_user_agent ?? "";
67
+ if (agent.startsWith("pnpm/")) return "pnpm";
68
+ if (agent.startsWith("bun/") || process.versions.bun) return "bun";
69
+ return "npm";
70
+ }
57
71
 
58
- const USAGE = `usage: circuit new [directory] [options]
72
+ private static fail(message: string): never {
73
+ console.error(message);
74
+ process.exit(1);
75
+ }
59
76
 
60
- --pattern <name> single-site, edge-router or colocation
61
- --asn <number> your AS number, for edge-router and colocation
62
- --1password write .env with 1Password references instead of empty values
63
- --use <manager> npm, pnpm or bun; the one that ran this by default
64
- --no-install do not install the dependencies`;
77
+ /** Every file under a directory, relative to it. */
78
+ private static filesIn(directory: string): string[] {
79
+ return readdirSync(directory, { recursive: true, withFileTypes: true })
80
+ .filter((entry) => entry.isFile())
81
+ .map((entry) => relative(directory, join(entry.parentPath, entry.name)));
82
+ }
65
83
 
66
- function fail(message: string): never {
67
- console.error(message);
68
- process.exit(1);
69
- }
84
+ /** Why a project cannot be created in this directory, or undefined when it can. */
85
+ private static notEmpty(directory: string): string | undefined {
86
+ const target = resolve(process.cwd(), directory);
87
+ return existsSync(target) && readdirSync(target).length ? `${target} is not empty` : undefined;
88
+ }
70
89
 
71
- /** The value of `--name value` or `--name=value`. */
72
- function option(args: string[], name: string): string | undefined {
73
- const at = args.indexOf(`--${name}`);
74
- if (at >= 0) return args[at + 1];
75
- return args.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3);
76
- }
90
+ /** The answer to a question in a terminal, or the fallback when nobody is there to answer. Cancelling a question ends the run. */
91
+ private static async ask<T>(question: () => Promise<NoInfer<T> | symbol>, fallback: T): Promise<T> {
92
+ if (process.stdin.isTTY !== true) return fallback;
77
93
 
78
- /** Every file under a directory, relative to it. */
79
- function filesIn(directory: string): string[] {
80
- return readdirSync(directory, { recursive: true, withFileTypes: true })
81
- .filter((entry) => entry.isFile())
82
- .map((entry) => relative(directory, join(entry.parentPath, entry.name)));
83
- }
94
+ const answer = await question();
95
+ if (prompts.isCancel(answer)) {
96
+ prompts.cancel("Nothing created.");
97
+ process.exit(1);
98
+ }
84
99
 
85
- export async function create(args: string[]): Promise<void> {
86
- if (args.includes("--help") || args.includes("-h")) {
87
- console.log(USAGE);
88
- return;
100
+ return answer as T;
89
101
  }
90
102
 
91
- const valued = new Set(["--pattern", "--asn", "--use"]);
92
- const positional = args.filter((arg, index) => !arg.startsWith("--") && !valued.has(args[index - 1] ?? ""));
93
-
94
- const interactive = process.stdin.isTTY === true;
95
- const terminal = interactive ? createInterface({ input: process.stdin, output: process.stdout }) : undefined;
96
-
97
- // The answer typed, or the fallback when nothing is typed or nobody is there to type.
98
- const ask = async (question: string, fallback: string) => {
99
- if (!terminal) return fallback;
100
- const answer = (await terminal.question(`${question} (${fallback}): `)).trim();
101
- return answer || fallback;
102
- };
103
-
104
- const directory = positional[0] ?? (await ask("Directory for the project", "my-network"));
105
- const target = resolve(process.cwd(), directory);
106
- if (existsSync(target) && readdirSync(target).length) fail(`${target} is not empty`);
107
-
108
- let pattern = option(args, "pattern") as Pattern | undefined;
109
- if (!pattern) {
110
- const names = Object.keys(PATTERNS) as Pattern[];
111
- if (terminal) {
112
- console.log("\nWhich is closest to your network?");
113
- names.forEach((name, index) => console.log(` ${index + 1}. ${PATTERNS[name].title}: ${PATTERNS[name].fits}`));
103
+ static async create(directoryArgument: string | undefined, options: ScaffoldOptions): Promise<void> {
104
+ const directory =
105
+ directoryArgument ??
106
+ (await Scaffold.ask(
107
+ () =>
108
+ prompts.text({
109
+ message: "Directory for the project",
110
+ placeholder: "my-network",
111
+ defaultValue: "my-network",
112
+ validate: (value) => Scaffold.notEmpty(value || "my-network"),
113
+ }),
114
+ "my-network"
115
+ ));
116
+
117
+ const notEmpty = Scaffold.notEmpty(directory);
118
+ if (notEmpty) Scaffold.fail(notEmpty);
119
+ const target = resolve(process.cwd(), directory);
120
+
121
+ const names = Object.keys(Scaffold.PATTERNS) as Pattern[];
122
+ const pattern =
123
+ options.pattern ??
124
+ (await Scaffold.ask(
125
+ () =>
126
+ prompts.select({
127
+ message: "Which is closest to your network?",
128
+ options: names.map((name) => ({
129
+ value: name,
130
+ label: Scaffold.PATTERNS[name].title,
131
+ hint: Scaffold.PATTERNS[name].fits,
132
+ })),
133
+ }),
134
+ names[0]!
135
+ ));
136
+ if (!Object.hasOwn(Scaffold.PATTERNS, pattern)) Scaffold.fail(`no pattern ${pattern}; there are ${names.join(", ")}`);
137
+ const chosen = Scaffold.PATTERNS[pattern as Pattern];
138
+
139
+ let asn = options.asn;
140
+ if (!asn && chosen.asn) {
141
+ asn = await Scaffold.ask(
142
+ () =>
143
+ prompts.text({
144
+ message: "Your AS number, or press enter to keep the documentation example",
145
+ placeholder: Scaffold.EXAMPLE_ASN,
146
+ defaultValue: Scaffold.EXAMPLE_ASN,
147
+ validate: (value) => (!value || /^\d+$/.test(value) ? undefined : `${value} is not an AS number`),
148
+ }),
149
+ Scaffold.EXAMPLE_ASN
150
+ );
114
151
  }
115
- pattern = names[Number(await ask("Number", "1")) - 1];
116
- }
117
- if (!pattern || !(pattern in PATTERNS)) fail(`no pattern ${pattern}; there are ${Object.keys(PATTERNS).join(", ")}`);
152
+ if (asn && !/^\d+$/.test(asn)) Scaffold.fail(`${asn} is not an AS number`);
118
153
 
119
- let asn = option(args, "asn");
120
- if (!asn && PATTERNS[pattern].asn) {
121
- asn = await ask("Your AS number, or press enter to keep the documentation example", EXAMPLE_ASN);
122
- }
123
- if (asn && !/^\d+$/.test(asn)) fail(`${asn} is not an AS number`);
124
-
125
- const manager = (option(args, "use") ?? detectManager()) as Manager;
126
- if (!(manager in MANAGERS)) fail(`no package manager ${manager}; there are ${Object.keys(MANAGERS).join(", ")}`);
127
- const { install, run, types } = MANAGERS[manager];
128
-
129
- const onePassword =
130
- args.includes("--1password") || (await ask("Keep the secrets in 1Password? y or n", "n")).toLowerCase().startsWith("y");
131
- terminal?.close();
132
-
133
- // The pattern's files, then what every project has. npm leaves out a file named .gitignore, so it travels without the dot.
134
- cpSync(join(TEMPLATES, pattern), target, { recursive: true });
135
- cpSync(join(TEMPLATES, "project"), target, { recursive: true });
136
- renameSync(join(target, "gitignore"), join(target, ".gitignore"));
137
-
138
- // What the templates say about npm, said for the package manager in use.
139
- for (const file of ["AGENTS.md", "README.md", "tsconfig.json"]) {
140
- const path = join(target, file);
141
- const text = readFileSync(path, "utf8")
142
- .replaceAll("npx circuit", run)
143
- .replaceAll("npm install", install)
144
- .replaceAll('"types": ["node"]', `"types": ["${types}"]`);
145
- writeFileSync(path, text);
146
- }
154
+ const manager = options.use ?? Scaffold.detectManager();
155
+ if (!Object.hasOwn(Scaffold.MANAGERS, manager)) {
156
+ Scaffold.fail(`no package manager ${manager}; there are ${Object.keys(Scaffold.MANAGERS).join(", ")}`);
157
+ }
158
+ const { install, run, types } = Scaffold.MANAGERS[manager as Manager];
159
+
160
+ const onePassword =
161
+ options.onePassword ||
162
+ (await Scaffold.ask(() => prompts.confirm({ message: "Keep the secrets in 1Password?", initialValue: false }), false));
163
+
164
+ // The pattern's files, then what every project has. npm leaves out a file named .gitignore, so it travels without the dot.
165
+ cpSync(join(Scaffold.TEMPLATES, pattern), target, { recursive: true });
166
+ cpSync(join(Scaffold.TEMPLATES, "project"), target, { recursive: true });
167
+ renameSync(join(target, "gitignore"), join(target, ".gitignore"));
168
+
169
+ // What the templates say about npm, said for the package manager in use.
170
+ for (const file of ["AGENTS.md", "README.md", "tsconfig.json"]) {
171
+ const path = join(target, file);
172
+ const text = readFileSync(path, "utf8")
173
+ .replaceAll("npx circuit", run)
174
+ .replaceAll("npm install", install)
175
+ .replaceAll('"types": ["node"]', `"types": ["${types}"]`);
176
+ writeFileSync(path, text);
177
+ }
147
178
 
148
- const sources = filesIn(target).filter((file) => file.endsWith(".ts"));
149
- const secrets = new Set<string>();
179
+ const sources = Scaffold.filesIn(target).filter((file) => file.endsWith(".ts"));
180
+ const secrets = new Set<string>();
150
181
 
151
- for (const file of sources) {
152
- const path = join(target, file);
153
- let text = readFileSync(path, "utf8");
182
+ for (const file of sources) {
183
+ const path = join(target, file);
184
+ let text = readFileSync(path, "utf8");
154
185
 
155
- if (asn && asn !== EXAMPLE_ASN) text = text.replaceAll(new RegExp(`\\b${EXAMPLE_ASN}\\b`, "g"), asn);
156
- for (const [, name] of text.matchAll(/secret\("([A-Z0-9_]+)"\)/g)) secrets.add(name!);
186
+ if (asn && asn !== Scaffold.EXAMPLE_ASN) text = text.replaceAll(new RegExp(`\\b${Scaffold.EXAMPLE_ASN}\\b`, "g"), asn);
187
+ for (const [, name] of text.matchAll(/secret\("([A-Z0-9_]+)"\)/g)) secrets.add(name!);
157
188
 
158
- writeFileSync(path, text);
159
- }
189
+ writeFileSync(path, text);
190
+ }
160
191
 
161
- const env = [
162
- "# Secrets for Circuit. Never commit this file.",
163
- "# A value is the secret itself, or a 1Password reference, op://vault/item/field, read with the 1Password CLI.",
164
- ...[...secrets].sort().map((name) => (onePassword ? `${name}=op://Network/${name}/password` : `${name}=`)),
165
- "",
166
- ];
167
- writeFileSync(join(target, ".env"), env.join("\n"));
168
-
169
- const name = basename(target)
170
- .toLowerCase()
171
- .replace(/[^a-z0-9-]+/g, "-");
172
- const packageJson = {
173
- name,
174
- private: true,
175
- type: "module",
176
- dependencies: { "@takodotid/circuit": circuit.version },
177
- devDependencies: { [`@types/${types}`]: circuit.devDependencies[`@types/${types}`] },
178
- };
179
- writeFileSync(join(target, "package.json"), JSON.stringify(packageJson, null, 4) + "\n");
180
-
181
- if (!args.includes("--no-install")) {
182
- // npm and pnpm are scripts on Windows, which only a shell runs.
183
- const result = spawnSync(install, { cwd: target, stdio: "inherit", shell: true });
184
- if (result.status !== 0) console.log(`${install} failed; run it again in the project.`);
185
- }
192
+ const env = [
193
+ "# Secrets for Circuit. Never commit this file.",
194
+ "# A value is the secret itself, or a 1Password reference, op://vault/item/field, read with the 1Password CLI.",
195
+ "# Put quotes around a value that has a # in it: without them, # starts a comment.",
196
+ ...[...secrets].sort().map((name) => (onePassword ? `${name}=op://Network/${name}/password` : `${name}=`)),
197
+ "",
198
+ ];
199
+ writeFileSync(join(target, ".env"), env.join("\n"));
200
+
201
+ const name = basename(target)
202
+ .toLowerCase()
203
+ .replace(/[^a-z0-9-]+/g, "-");
204
+ const packageJson = {
205
+ name,
206
+ private: true,
207
+ type: "module",
208
+ dependencies: { "@takodotid/circuit": Scaffold.CIRCUIT.version },
209
+ devDependencies: { [`@types/${types}`]: Scaffold.CIRCUIT.devDependencies[`@types/${types}`] },
210
+ };
211
+ writeFileSync(join(target, "package.json"), JSON.stringify(packageJson, null, 4) + "\n");
212
+
213
+ if (options.install) {
214
+ // npm and pnpm are scripts on Windows, which only a shell runs.
215
+ const result = spawnSync(install, { cwd: target, stdio: "inherit", shell: true });
216
+ if (result.status !== 0) console.log(`${install} failed; run it again in the project.`);
217
+ }
186
218
 
187
- const steps = [
188
- `cd ${directory}`,
189
- onePassword
190
- ? `Point each line of .env at the item that holds it in 1Password, then run: ${run} secrets`
191
- : `Fill in each password in .env, then run: ${run} secrets`,
192
- "Change the names, addresses and ports in the device files to your own.",
193
- ...(PATTERNS[pattern].asn && (!asn || asn === EXAMPLE_ASN) ? ["Put your own AS number in routing.ts."] : []),
194
- `${run} validate`,
195
- `${run} snapshot, then ${run} diff, to see what would change on each device`,
196
- ];
197
-
198
- console.log(`\nCreated ${directory} from the pattern "${PATTERNS[pattern].title}".\n\nNext:`);
199
- steps.forEach((step, index) => console.log(` ${index + 1}. ${step}`));
200
- console.log(`\nWhat each file does: https://circuit.tako.id/patterns/${pattern}`);
219
+ const steps = [
220
+ `cd ${directory}`,
221
+ onePassword
222
+ ? `Point each line of .env at the item that holds it in 1Password, then run: ${run} secrets`
223
+ : `Fill in each password in .env, then run: ${run} secrets`,
224
+ "Change the names, addresses and ports in the device files to your own.",
225
+ ...(chosen.asn && (!asn || asn === Scaffold.EXAMPLE_ASN) ? ["Put your own AS number in routing.ts."] : []),
226
+ `${run} validate`,
227
+ `${run} snapshot, then ${run} diff, to see what would change on each device`,
228
+ ];
229
+
230
+ console.log(`\nCreated ${directory} from the pattern "${chosen.title}".\n\nNext:`);
231
+ steps.forEach((step, index) => console.log(` ${index + 1}. ${step}`));
232
+ console.log(`\nWhat each file does: https://circuit.tako.id/patterns/${pattern}`);
233
+ }
201
234
  }
package/src/cli/rename.ts CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
4
4
  import { basename, dirname, extname, join, resolve } from "node:path";
5
- import { declaresDevice, projectSources } from "../core/files";
5
+ import { ProjectFiles } from "../core/project-files";
6
6
 
7
7
  /** One line that changes. */
8
8
  export type LineChange = { file: string; line: number; before: string; after: string };
@@ -15,115 +15,121 @@ export type RenamePlan = {
15
15
  mentions: { file: string; line: number; text: string }[];
16
16
  };
17
17
 
18
- const escape = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
18
+ export class Rename {
19
+ private static escape(text: string): string {
20
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
21
+ }
19
22
 
20
- /**
21
- * Plan renaming a device from `from` to `to` in the project at `root`.
22
- *
23
- * A string that is exactly the old name becomes the new name: `name: "edge-01"`, `link: { device: "edge-01", ... }`, a check's list of routers, and a key such as a tenant's ports by device, which would otherwise no longer be found. A BGP neighbor named after the device is renamed too, which renames its session on the device: `circuit diff` shows it.
24
- */
25
- export function planRename(root: string, from: string, to: string, options: { file: boolean; snapshots: string[] }): RenamePlan {
26
- const changes: LineChange[] = [];
27
- const moves: RenamePlan["moves"] = [];
23
+ /**
24
+ * Plan renaming a device from `from` to `to` in the project at `root`.
25
+ *
26
+ * A string that is exactly the old name becomes the new name: `name: "edge-01"`, `link: { device: "edge-01", ... }`, a check's list of routers, and a key such as a tenant's ports by device, which would otherwise no longer be found. A BGP neighbor named after the device is renamed too, which renames its session on the device: `circuit diff` shows it.
27
+ */
28
+ static plan(root: string, from: string, to: string, options: { file: boolean; snapshots: string[] }): RenamePlan {
29
+ const changes: LineChange[] = [];
30
+ const moves: RenamePlan["moves"] = [];
28
31
 
29
- const exact = new RegExp(`(["'\`])${escape(from)}\\1`, "g");
30
- const anywhere = new RegExp(`(^|[^A-Za-z0-9_.-])${escape(from)}(?![A-Za-z0-9_-])`);
32
+ const exact = new RegExp(`(["'\`])${Rename.escape(from)}\\1`, "g");
33
+ const anywhere = new RegExp(`(^|[^A-Za-z0-9_.-])${Rename.escape(from)}(?![A-Za-z0-9_-])`);
31
34
 
32
- const sources = projectSources(root);
33
- const declaring = new Set<string>();
35
+ const sources = ProjectFiles.sources(root);
36
+ const declaring = new Set<string>();
34
37
 
35
- for (const file of sources) {
36
- const text = readFileSync(join(root, file), "utf8");
37
- const lines = text.split("\n");
38
+ for (const file of sources) {
39
+ const text = readFileSync(join(root, file), "utf8");
40
+ const lines = text.split("\n");
38
41
 
39
- // The device's own file calls defineDevice and gives it the name.
40
- if (declaresDevice(text, from)) declaring.add(file);
42
+ // The device's own file calls defineDevice and gives it the name.
43
+ if (ProjectFiles.declaresDevice(text, from)) declaring.add(file);
41
44
 
42
- for (const [index, before] of lines.entries()) {
43
- const after = before.replace(exact, (_match, quote: string) => `${quote}${to}${quote}`);
44
- if (after !== before) changes.push({ file, line: index + 1, before, after });
45
+ for (const [index, before] of lines.entries()) {
46
+ const after = before.replace(exact, (_match, quote: string) => `${quote}${to}${quote}`);
47
+ if (after !== before) changes.push({ file, line: index + 1, before, after });
48
+ }
45
49
  }
46
- }
47
-
48
- if (!declaring.size) {
49
- throw new Error(
50
- `no file declares name: "${from}"; the name may be built from parts, such as a template string, so rename it by hand`
51
- );
52
- }
53
50
 
54
- for (const snapshot of options.snapshots) {
55
- moves.push({ from: snapshot, to: join(dirname(snapshot), `${to}${extname(snapshot)}`) });
56
- }
51
+ if (!declaring.size) {
52
+ throw new Error(
53
+ `no file declares name: "${from}"; the name may be built from parts, such as a template string, so rename it by hand`
54
+ );
55
+ }
57
56
 
58
- if (options.file) {
59
- if (declaring.size > 1) throw new Error(`${[...declaring].join(" and ")} both declare name: "${from}"; rename the file by hand`);
57
+ for (const snapshot of options.snapshots) {
58
+ // A state file is named after the device, then its kind: `tor-01.cfg`, `tor-01.lldp.json`.
59
+ moves.push({ from: snapshot, to: join(dirname(snapshot), `${to}${basename(snapshot).slice(from.length)}`) });
60
+ }
60
61
 
61
- const [file] = [...declaring] as [string];
62
- const target = join(dirname(file), `${to}${extname(file)}`);
63
- if (existsSync(join(root, target))) throw new Error(`${target} already exists`);
62
+ if (options.file) {
63
+ if (declaring.size > 1)
64
+ throw new Error(`${[...declaring].join(" and ")} both declare name: "${from}"; rename the file by hand`);
64
65
 
65
- moves.push({ from: file, to: target });
66
- changes.push(...importChanges(root, sources, file, target, changes));
67
- }
66
+ const [file] = [...declaring] as [string];
67
+ const target = join(dirname(file), `${to}${extname(file)}`);
68
+ if (existsSync(join(root, target))) throw new Error(`${target} already exists`);
68
69
 
69
- // What still names the device once every change is made.
70
- const mentions = sources.flatMap((file) =>
71
- readFileSync(join(root, file), "utf8")
72
- .split("\n")
73
- .flatMap((original, index) => {
74
- const text = changes.find((change) => change.file === file && change.line === index + 1)?.after ?? original;
75
- return anywhere.test(text) ? [{ file, line: index + 1, text: text.trim() }] : [];
76
- })
77
- );
78
-
79
- return { changes, moves, mentions };
80
- }
70
+ moves.push({ from: file, to: target });
71
+ changes.push(...Rename.importChanges(root, sources, file, target, changes));
72
+ }
81
73
 
82
- /** Imports of a file that moves, pointed at its new name. Lines already changed are changed further. */
83
- function importChanges(root: string, sources: string[], file: string, target: string, earlier: LineChange[]): LineChange[] {
84
- const result: LineChange[] = [];
85
- const withoutExtension = (path: string) => path.replace(/\.(ts|mts|cts|js|mjs|cjs)$/, "");
86
- const moved = withoutExtension(resolve(root, file));
87
-
88
- for (const source of sources) {
89
- const lines = readFileSync(join(root, source), "utf8").split("\n");
90
-
91
- for (const [index, original] of lines.entries()) {
92
- const line = index + 1;
93
- const changed = earlier.find((change) => change.file === source && change.line === line);
94
- const before = changed?.after ?? original;
95
-
96
- // A relative specifier that resolves to the moved file keeps its own form, extension or not.
97
- const after = before.replace(
98
- /(\bfrom\s+|\bimport\s*\(\s*)(["'])(\.{1,2}\/[^"']+)\2/g,
99
- (match, lead: string, quote: string, specifier: string) => {
100
- if (withoutExtension(resolve(root, dirname(source), specifier)) !== moved) return match;
101
-
102
- const extension = specifier.match(/\.(ts|mts|cts|js|mjs|cjs)$/)?.[0] ?? "";
103
- const renamed = `${dirname(specifier)}/${basename(withoutExtension(target))}${extension}`;
104
- return `${lead}${quote}${renamed}${quote}`;
105
- }
106
- );
74
+ // What still names the device once every change is made.
75
+ const mentions = sources.flatMap((file) =>
76
+ readFileSync(join(root, file), "utf8")
77
+ .split("\n")
78
+ .flatMap((original, index) => {
79
+ const text = changes.find((change) => change.file === file && change.line === index + 1)?.after ?? original;
80
+ return anywhere.test(text) ? [{ file, line: index + 1, text: text.trim() }] : [];
81
+ })
82
+ );
107
83
 
108
- if (after === before) continue;
84
+ return { changes, moves, mentions };
85
+ }
109
86
 
110
- if (changed) changed.after = after;
111
- else result.push({ file: source, line, before: original, after });
87
+ /** Imports of a file that moves, pointed at its new name. Lines already changed are changed further. */
88
+ private static importChanges(root: string, sources: string[], file: string, target: string, earlier: LineChange[]): LineChange[] {
89
+ const result: LineChange[] = [];
90
+ const withoutExtension = (path: string) => path.replace(/\.(ts|mts|cts|js|mjs|cjs)$/, "");
91
+ const moved = withoutExtension(resolve(root, file));
92
+
93
+ for (const source of sources) {
94
+ const lines = readFileSync(join(root, source), "utf8").split("\n");
95
+
96
+ for (const [index, original] of lines.entries()) {
97
+ const line = index + 1;
98
+ const changed = earlier.find((change) => change.file === source && change.line === line);
99
+ const before = changed?.after ?? original;
100
+
101
+ // A relative specifier that resolves to the moved file keeps its own form, extension or not.
102
+ const after = before.replace(
103
+ /(\bfrom\s+|\bimport\s*\(\s*)(["'])(\.{1,2}\/[^"']+)\2/g,
104
+ (match, lead: string, quote: string, specifier: string) => {
105
+ if (withoutExtension(resolve(root, dirname(source), specifier)) !== moved) return match;
106
+
107
+ const extension = specifier.match(/\.(ts|mts|cts|js|mjs|cjs)$/)?.[0] ?? "";
108
+ const renamed = `${dirname(specifier)}/${basename(withoutExtension(target))}${extension}`;
109
+ return `${lead}${quote}${renamed}${quote}`;
110
+ }
111
+ );
112
+
113
+ if (after === before) continue;
114
+
115
+ if (changed) changed.after = after;
116
+ else result.push({ file: source, line, before: original, after });
117
+ }
112
118
  }
119
+
120
+ return result;
113
121
  }
114
122
 
115
- return result;
116
- }
123
+ /** Write the plan: each changed line, then each move. */
124
+ static apply(root: string, plan: RenamePlan): void {
125
+ const files = new Set(plan.changes.map((change) => change.file));
117
126
 
118
- /** Write the plan: each changed line, then each move. */
119
- export function applyRename(root: string, plan: RenamePlan): void {
120
- const files = new Set(plan.changes.map((change) => change.file));
127
+ for (const file of files) {
128
+ const lines = readFileSync(join(root, file), "utf8").split("\n");
129
+ for (const change of plan.changes.filter((each) => each.file === file)) lines[change.line - 1] = change.after;
130
+ writeFileSync(join(root, file), lines.join("\n"));
131
+ }
121
132
 
122
- for (const file of files) {
123
- const lines = readFileSync(join(root, file), "utf8").split("\n");
124
- for (const change of plan.changes.filter((each) => each.file === file)) lines[change.line - 1] = change.after;
125
- writeFileSync(join(root, file), lines.join("\n"));
133
+ for (const move of plan.moves) renameSync(join(root, move.from), join(root, move.to));
126
134
  }
127
-
128
- for (const move of plan.moves) renameSync(join(root, move.from), join(root, move.to));
129
135
  }