@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
@@ -0,0 +1,339 @@
1
+ // Remembering secrets between commands, only when the person opts in: values read from 1Password and passwords typed for a secrets file, kept for 10 or 30 minutes so the next commands don't ask again. Circuit asks after a command read them. A small Circuit process keeps them in its memory, never in a file, and stops when the time is up, when the computer restarts, or with `circuit secrets forget`. It is like ssh-agent: it answers only a program that shows the token in `~/.circuit/secrets-agent.json`, a file only its owner can read.
2
+
3
+ import { spawn } from "node:child_process";
4
+ import { randomBytes, timingSafeEqual } from "node:crypto";
5
+ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
6
+ import { connect, createServer } from "node:net";
7
+ import { join, resolve } from "node:path";
8
+ import { OnePassword, SecretsFile } from "../core/secrets-file";
9
+ import { Releases } from "./releases";
10
+ import { Ui } from "./ui";
11
+
12
+ /** What the process keeps: values by their 1Password reference, and typed passwords by the secrets file's full path. */
13
+ type Held = { values: Record<string, string>; passwords: Record<string, string> };
14
+ type Request = { op: "get" | "put" | "stop"; values?: Record<string, string>; passwords?: Record<string, string>; until?: number };
15
+ type Answer = { ok: true; until: number } & Partial<Held>;
16
+ /** Where the process listens, the token it asks for, and when it stops. */
17
+ type Info = { endpoint: string; token: string; until: number };
18
+
19
+ /** The small process that keeps remembered secrets in memory, and how a command talks to it. */
20
+ export class SecretsAgent {
21
+ /** The hidden command that starts the process. */
22
+ static readonly COMMAND = "__secrets-agent";
23
+ /** The longest Circuit remembers, in minutes. */
24
+ static readonly LONGEST = 30;
25
+ /** The largest request the process reads, so a stray program can't fill its memory. */
26
+ private static readonly LARGEST = 1024 * 1024;
27
+ /** What the process keeps of the environment it starts with: enough to run, and no secret given in a variable. */
28
+ private static readonly ENVIRONMENT = [
29
+ "PATH",
30
+ "Path",
31
+ "SystemRoot",
32
+ "SYSTEMROOT",
33
+ "windir",
34
+ "TEMP",
35
+ "TMP",
36
+ "HOME",
37
+ "USERPROFILE",
38
+ "CIRCUIT_HOME",
39
+ ];
40
+
41
+ static infoPath(): string {
42
+ return join(Releases.home(), "secrets-agent.json");
43
+ }
44
+
45
+ private static info(): Info | undefined {
46
+ try {
47
+ const info = JSON.parse(readFileSync(SecretsAgent.infoPath(), "utf8")) as Info;
48
+ return info.until > Date.now() ? info : undefined;
49
+ } catch {
50
+ return undefined;
51
+ }
52
+ }
53
+
54
+ /** Ask the running process. Undefined when none is running, or it does not answer within two seconds. */
55
+ static ask(request: Request): Promise<Answer | undefined> {
56
+ const info = SecretsAgent.info();
57
+ if (!info) return Promise.resolve(undefined);
58
+
59
+ return new Promise((done) => {
60
+ let answer = "";
61
+ const socket = connect(info.endpoint);
62
+ const finish = (value?: Answer) => {
63
+ clearTimeout(timer);
64
+ socket.destroy();
65
+ done(value);
66
+ };
67
+ const timer = setTimeout(() => finish(), 2000);
68
+
69
+ socket.on("connect", () => socket.write(`${JSON.stringify({ ...request, token: info.token })}\n`));
70
+ socket.on("data", (chunk) => {
71
+ answer += chunk.toString("utf8");
72
+ const end = answer.indexOf("\n");
73
+ if (end < 0) return;
74
+ try {
75
+ const parsed = JSON.parse(answer.slice(0, end)) as Answer;
76
+ finish(parsed.ok ? parsed : undefined);
77
+ } catch {
78
+ finish();
79
+ }
80
+ });
81
+ socket.on("error", () => finish());
82
+ socket.on("close", () => finish());
83
+ });
84
+ }
85
+
86
+ /** How this Circuit runs itself: the installed binary, or Bun with these sources. */
87
+ private static self(): string[] {
88
+ return Releases.binary() ? [process.execPath] : [process.execPath, join(import.meta.dir, "index.ts")];
89
+ }
90
+
91
+ /** Start the process, to stop at `until`. False when it did not answer. */
92
+ static async start(until: number): Promise<boolean> {
93
+ const home = Releases.home();
94
+ mkdirSync(home, { recursive: true });
95
+
96
+ const id = randomBytes(8).toString("hex");
97
+ const config = {
98
+ endpoint: process.platform === "win32" ? `\\\\.\\pipe\\circuit-secrets-${id}` : join(home, `secrets-${id}.sock`),
99
+ token: randomBytes(32).toString("base64url"),
100
+ until: SecretsAgent.limit(until),
101
+ };
102
+
103
+ const environment = Object.fromEntries(
104
+ SecretsAgent.ENVIRONMENT.filter((name) => process.env[name] !== undefined).map((name) => [name, process.env[name]!])
105
+ );
106
+ const [command, ...args] = SecretsAgent.self();
107
+ const child = spawn(command!, [...args, SecretsAgent.COMMAND], {
108
+ detached: true,
109
+ windowsHide: true,
110
+ stdio: ["pipe", "ignore", "ignore"],
111
+ env: environment,
112
+ });
113
+ child.on("error", () => {});
114
+ // The token goes through stdin, never the command line or the environment, which other programs can see.
115
+ child.stdin!.end(JSON.stringify(config));
116
+ child.unref();
117
+
118
+ for (let tries = 0; tries < 50; tries++) {
119
+ await new Promise((wait) => setTimeout(wait, 100));
120
+ if (await SecretsAgent.ask({ op: "get" })) return true;
121
+ }
122
+ return false;
123
+ }
124
+
125
+ /** No later than LONGEST minutes from now. */
126
+ private static limit(until: number): number {
127
+ return Math.min(until, Date.now() + SecretsAgent.LONGEST * 60_000);
128
+ }
129
+
130
+ private static same(given: unknown, token: string): boolean {
131
+ if (typeof given !== "string") return false;
132
+ const a = Buffer.from(given);
133
+ const b = Buffer.from(token);
134
+ return a.length === b.length && timingSafeEqual(a, b);
135
+ }
136
+
137
+ /** The process itself: listen until the time is up or someone says stop, answering only those with the token. */
138
+ static async serve(): Promise<void> {
139
+ let text = "";
140
+ for await (const chunk of process.stdin) text += chunk;
141
+ const { endpoint, token, until } = JSON.parse(text) as Info;
142
+
143
+ const held: Held = { values: {}, passwords: {} };
144
+ let deadline = SecretsAgent.limit(until);
145
+ let timer: ReturnType<typeof setTimeout> | undefined;
146
+
147
+ const write = () =>
148
+ writeFileSync(SecretsAgent.infoPath(), JSON.stringify({ endpoint, token, until: deadline } satisfies Info), { mode: 0o600 });
149
+ // Remove the file only while it is still this process's, not a newer one's.
150
+ const unlist = () => {
151
+ try {
152
+ if ((JSON.parse(readFileSync(SecretsAgent.infoPath(), "utf8")) as Info).token === token) rmSync(SecretsAgent.infoPath());
153
+ } catch {}
154
+ };
155
+ const stop = () => {
156
+ server.close();
157
+ unlist();
158
+ if (process.platform !== "win32") rmSync(endpoint, { force: true });
159
+ process.exit(0);
160
+ };
161
+ const schedule = () => {
162
+ clearTimeout(timer);
163
+ timer = setTimeout(stop, Math.max(0, deadline - Date.now()));
164
+ };
165
+
166
+ const server = createServer((socket) => {
167
+ let request = "";
168
+ socket.on("error", () => socket.destroy());
169
+ socket.on("data", (chunk) => {
170
+ request += chunk.toString("utf8");
171
+ if (request.length > SecretsAgent.LARGEST) return socket.destroy();
172
+
173
+ const end = request.indexOf("\n");
174
+ if (end < 0) return;
175
+
176
+ let asked: Request & { token?: unknown };
177
+ try {
178
+ asked = JSON.parse(request.slice(0, end));
179
+ } catch {
180
+ return socket.destroy();
181
+ }
182
+ if (!SecretsAgent.same(asked.token, token)) return socket.destroy();
183
+
184
+ if (asked.op === "stop") {
185
+ unlist();
186
+ socket.end(`${JSON.stringify({ ok: true, until: Date.now() })}\n`, stop);
187
+ return;
188
+ }
189
+ if (asked.op === "put") {
190
+ Object.assign(held.values, asked.values ?? {});
191
+ Object.assign(held.passwords, asked.passwords ?? {});
192
+ if (typeof asked.until === "number") {
193
+ deadline = SecretsAgent.limit(asked.until);
194
+ write();
195
+ schedule();
196
+ }
197
+ }
198
+ socket.end(`${JSON.stringify({ ok: true, until: deadline, ...held } satisfies Answer)}\n`);
199
+ });
200
+ });
201
+
202
+ if (process.platform !== "win32") rmSync(endpoint, { force: true });
203
+ server.listen(endpoint, () => {
204
+ if (process.platform !== "win32") chmodSync(endpoint, 0o600);
205
+ write();
206
+ schedule();
207
+ });
208
+ server.on("error", () => process.exit(1));
209
+ await new Promise(() => {});
210
+ }
211
+ }
212
+
213
+ /** When a command remembers what it read, asks to, or says it used what was remembered. */
214
+ export class Remember {
215
+ /** Passwords typed in this run, by the secrets file's full path. */
216
+ private static readonly typed = new Map<string, string>();
217
+ /** When the process stops, while one runs. */
218
+ private static until: number | undefined;
219
+ private static asked = false;
220
+ private static told = false;
221
+
222
+ /** A password the person typed for a secrets file, to keep when they choose to. */
223
+ static typedPassword(file: SecretsFile, password: string): void {
224
+ Remember.typed.set(resolve(file.path), password);
225
+ }
226
+
227
+ /** At the start of a command: what Circuit remembers, when it does. */
228
+ static async load(): Promise<void> {
229
+ if (!existsSync(SecretsAgent.infoPath())) return;
230
+
231
+ const answer = await SecretsAgent.ask({ op: "get" });
232
+ if (!answer) return;
233
+
234
+ Remember.until = answer.until;
235
+ OnePassword.hold(answer.values ?? {});
236
+ SecretsFile.hold(answer.passwords ?? {});
237
+ }
238
+
239
+ private static time(until: number): string {
240
+ return new Date(until).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
241
+ }
242
+
243
+ /** What would be kept, said for the warning. */
244
+ private static what(values: number, passwords: number): string {
245
+ const parts = [];
246
+ if (values)
247
+ parts.push(
248
+ values === 1 ? "the value Circuit just read from 1Password" : `the ${values} values Circuit just read from 1Password`
249
+ );
250
+ if (passwords) parts.push(passwords === 1 ? "the password you typed" : "the passwords you typed");
251
+ return parts.join(", and ");
252
+ }
253
+
254
+ /** What remembering means, before the person says yes. */
255
+ private static warning(what: string): string {
256
+ return [
257
+ `Circuit can keep ${what}, so the next commands don't ask again.`,
258
+ "",
259
+ "While Circuit keeps them:",
260
+ "- any program running as you on this computer can get them, and 1Password does not ask you",
261
+ "- locking 1Password does not make Circuit forget them",
262
+ "",
263
+ "They stay in memory, never in a file, and are gone when the time is up, when the computer restarts, or with circuit secrets forget.",
264
+ ].join("\n");
265
+ }
266
+
267
+ /**
268
+ * After a command read secrets: keep the new ones when Circuit remembers, or, the first time in a run, ask the person whether it
269
+ * should. In a script nothing is asked.
270
+ */
271
+ static async offer(): Promise<void> {
272
+ const values = OnePassword.unheld();
273
+ const passwords = Object.fromEntries([...Remember.typed].filter(([path, password]) => !SecretsFile.holds(path, password)));
274
+ const count = { values: Object.keys(values).length, passwords: Object.keys(passwords).length };
275
+
276
+ if (count.values + count.passwords > 0) {
277
+ if (Remember.until === undefined) {
278
+ if (!Ui.interactive || Remember.asked) return;
279
+ Remember.asked = true;
280
+
281
+ const minutes = await Remember.choose(Remember.warning(Remember.what(count.values, count.passwords)));
282
+ if (!minutes || !(await Remember.turnOn(minutes))) return;
283
+ }
284
+
285
+ const answer = await SecretsAgent.ask({ op: "put", values, passwords });
286
+ if (!answer) {
287
+ Remember.until = undefined;
288
+ return;
289
+ }
290
+ OnePassword.hold(values);
291
+ SecretsFile.hold(passwords);
292
+ Remember.until = answer.until;
293
+ return;
294
+ }
295
+
296
+ if ((OnePassword.usedHeld || SecretsFile.usedHeld) && Remember.until !== undefined && !Remember.told && Ui.interactive) {
297
+ Remember.told = true;
298
+ console.error(
299
+ Ui.dim(`Used secrets Circuit remembers until ${Remember.time(Remember.until)}. Forget them now: circuit secrets forget`)
300
+ );
301
+ }
302
+ }
303
+
304
+ /** How long to remember: 10 or 30 minutes, or undefined for no. */
305
+ private static async choose(warning: string): Promise<number | undefined> {
306
+ Ui.note(warning, "Remember secrets?");
307
+ const minutes = await Ui.select(
308
+ "Remember them?",
309
+ [
310
+ { value: 0, label: "No" },
311
+ { value: 10, label: "For 10 minutes" },
312
+ { value: 30, label: "For 30 minutes" },
313
+ ],
314
+ 0
315
+ );
316
+ return minutes || undefined;
317
+ }
318
+
319
+ /** Start remembering for `minutes`. */
320
+ private static async turnOn(minutes: number): Promise<boolean> {
321
+ const until = Date.now() + minutes * 60_000;
322
+ if (!(await Ui.wait("Starting the process that remembers them", () => SecretsAgent.start(until)))) {
323
+ Ui.warn("Circuit could not start the process that remembers secrets, so nothing is remembered.");
324
+ return false;
325
+ }
326
+
327
+ Remember.until = until;
328
+ Ui.done(`Circuit remembers until ${Remember.time(until)}. Forget now: circuit secrets forget`);
329
+ return true;
330
+ }
331
+
332
+ /** `circuit secrets forget`: stop the process, and with it everything it kept. */
333
+ static async forget(): Promise<void> {
334
+ const stopped = await SecretsAgent.ask({ op: "stop" });
335
+ rmSync(SecretsAgent.infoPath(), { force: true });
336
+ Remember.until = undefined;
337
+ Ui.done(stopped ? "Circuit forgot every secret it remembered." : "Circuit remembered no secret.");
338
+ }
339
+ }
package/src/cli/rename.ts CHANGED
@@ -1,8 +1,7 @@
1
- // `circuit rename`: a device's new name everywhere the project's files name it, its snapshot renamed to match, and with `--file` its file too. It plans first and writes only with --confirm.
1
+ // `circuit rename`: a device's new name everywhere the project's files write it, and, when asked, its file too. It shows every change first and writes only once the person says yes.
2
2
 
3
- import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
4
- import { basename, dirname, extname, join, resolve } from "node:path";
5
- import { ProjectFiles } from "../core/project-files";
3
+ import { existsSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
4
+ import { basename, dirname, extname, join, relative, resolve } from "node:path";
6
5
 
7
6
  /** One line that changes. */
8
7
  export type LineChange = { file: string; line: number; before: string; after: string };
@@ -16,31 +15,41 @@ export type RenamePlan = {
16
15
  };
17
16
 
18
17
  export class Rename {
18
+ /** Folders that hold none of the project's own files. */
19
+ private static readonly SKIPPED = new Set(["node_modules", ".git", ".circuit", "dist"]);
20
+
21
+ /** A hostname: letters, digits, dots, dashes and underscores. */
22
+ static readonly HOSTNAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
23
+
19
24
  private static escape(text: string): string {
20
25
  return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
21
26
  }
22
27
 
28
+ /** Every TypeScript file of the project, relative to its folder, in name order. */
29
+ static sources(root: string, directory = root): string[] {
30
+ const entries = readdirSync(directory, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
31
+
32
+ return entries.flatMap((entry) => {
33
+ if (entry.isDirectory()) return Rename.SKIPPED.has(entry.name) ? [] : Rename.sources(root, join(directory, entry.name));
34
+ return /\.(ts|mts|cts)$/.test(entry.name) && !entry.name.endsWith(".d.ts") ? [relative(root, join(directory, entry.name))] : [];
35
+ });
36
+ }
37
+
23
38
  /**
24
- * Plan renaming a device from `from` to `to` in the project at `root`.
39
+ * Plan renaming a device from `from` to `to` in the project at `root`, whose file `declaring` writes it.
25
40
  *
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.
41
+ * A string that is exactly the old name becomes the new name: `name: "edge-01"`, `link: { device: "edge-01", ... }`, a check's list of devices, and a key such as a part'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
42
  */
28
- static plan(root: string, from: string, to: string, options: { file: boolean; snapshots: string[] }): RenamePlan {
43
+ static plan(root: string, from: string, to: string, options: { declaring: string; file: boolean }): RenamePlan {
29
44
  const changes: LineChange[] = [];
30
45
  const moves: RenamePlan["moves"] = [];
31
46
 
32
47
  const exact = new RegExp(`(["'\`])${Rename.escape(from)}\\1`, "g");
33
48
  const anywhere = new RegExp(`(^|[^A-Za-z0-9_.-])${Rename.escape(from)}(?![A-Za-z0-9_-])`);
34
-
35
- const sources = ProjectFiles.sources(root);
36
- const declaring = new Set<string>();
49
+ const sources = Rename.sources(root);
37
50
 
38
51
  for (const file of sources) {
39
- const text = readFileSync(join(root, file), "utf8");
40
- const lines = text.split("\n");
41
-
42
- // The device's own file calls defineDevice and gives it the name.
43
- if (ProjectFiles.declaresDevice(text, from)) declaring.add(file);
52
+ const lines = readFileSync(join(root, file), "utf8").split("\n");
44
53
 
45
54
  for (const [index, before] of lines.entries()) {
46
55
  const after = before.replace(exact, (_match, quote: string) => `${quote}${to}${quote}`);
@@ -48,27 +57,12 @@ export class Rename {
48
57
  }
49
58
  }
50
59
 
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
- }
56
-
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
- }
61
-
62
60
  if (options.file) {
63
- if (declaring.size > 1)
64
- throw new Error(`${[...declaring].join(" and ")} both declare name: "${from}"; rename the file by hand`);
65
-
66
- const [file] = [...declaring] as [string];
67
- const target = join(dirname(file), `${to}${extname(file)}`);
61
+ const target = join(dirname(options.declaring), `${to}${extname(options.declaring)}`);
68
62
  if (existsSync(join(root, target))) throw new Error(`${target} already exists`);
69
63
 
70
- moves.push({ from: file, to: target });
71
- changes.push(...Rename.importChanges(root, sources, file, target, changes));
64
+ moves.push({ from: options.declaring, to: target });
65
+ changes.push(...Rename.importChanges(root, sources, options.declaring, target, changes));
72
66
  }
73
67
 
74
68
  // What still names the device once every change is made.
@@ -0,0 +1,60 @@
1
+ // Getting a secrets file's password: from the environment or 1Password without asking, or typed by the person. A new password is chosen, or made by Circuit.
2
+
3
+ import { SecretsFile } from "../core/secrets-file";
4
+ import { Remember } from "./remember";
5
+ import { Ui } from "./ui";
6
+
7
+ /** Getting a secrets file's password from the person, when the environment and 1Password don't give it. */
8
+ export class SecretsPassword {
9
+ /** The shortest password a person may choose. */
10
+ private static readonly SHORTEST = 12;
11
+
12
+ /** Open a locked file: the person types its password, with three tries. Nothing to do when `file` is undefined or already open. */
13
+ static async unlock(file: SecretsFile | undefined): Promise<void> {
14
+ if (!file || file.unlocked() || file.tryUnlock()) return;
15
+ if (!Ui.interactive)
16
+ Ui.fail(
17
+ `${file.name} is encrypted, and no password was given. Give it in ${SecretsFile.PASSWORD}, or keep it in 1Password and write ${SecretsFile.PASSWORD}="op://vault/item/field" in ${file.name}.`
18
+ );
19
+
20
+ for (let tries = 3; tries > 0; tries--) {
21
+ const typed = await Ui.secret(`Password of ${file.name}`);
22
+ try {
23
+ file.unlock(typed);
24
+ Remember.typedPassword(file, typed);
25
+ return;
26
+ } catch (error) {
27
+ Ui.warn((error as Error).message);
28
+ }
29
+ }
30
+ Ui.fail(`${file.name} stays locked.`);
31
+ }
32
+
33
+ /** A new password: typed twice, or made by Circuit when left empty, and then shown once. */
34
+ static async choose(file: SecretsFile, what = `a password for ${file.name}`): Promise<string> {
35
+ if (!Ui.interactive)
36
+ Ui.fail(`${file.name} has no password yet. Run this in a terminal to choose one, or give it in ${SecretsFile.PASSWORD}.`);
37
+
38
+ const typed = await Ui.secret(`Choose ${what} (leave it empty and Circuit makes one)`, { empty: true });
39
+ if (!typed) {
40
+ const made = SecretsFile.generatePassword();
41
+ Ui.note(
42
+ `${made}\n\nSave it in your password manager now. Circuit does not keep it, and without it nobody can read ${file.name}.\nIn 1Password, Circuit can read it for you: circuit secrets password --1password op://vault/item/field`,
43
+ "The password"
44
+ );
45
+ return made;
46
+ }
47
+
48
+ if (typed.length < SecretsPassword.SHORTEST)
49
+ Ui.fail(`Use at least ${SecretsPassword.SHORTEST} characters, or leave it empty and Circuit makes one. Nothing changed.`);
50
+ if ((await Ui.secret("Type it again")) !== typed) Ui.fail("The two differ. Nothing changed.");
51
+ return typed;
52
+ }
53
+
54
+ /** Ready to write: the file open, or, when nothing in it is encrypted yet, a password chosen for it. */
55
+ static async ready(file: SecretsFile): Promise<void> {
56
+ if (file.unlocked() || file.tryUnlock()) return;
57
+ if (file.hasEncrypted()) return SecretsPassword.unlock(file);
58
+ file.unlock(await SecretsPassword.choose(file));
59
+ }
60
+ }