@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,539 @@
1
+ // A network's secrets file, `.circuit/secrets` unless `circuit({ secrets: { file } })` names another: `NAME="value"` lines, as in a `.env`, each value encrypted, so the file is safe to commit. Names stay readable, so git shows which secret changed. A value is AES-256-GCM, with a key made from the file's password by scrypt, and the secret's name bound to it, so a value moved to another name no longer opens.
2
+ //
3
+ // The password is never in the file. It comes from `CIRCUIT_SECRETS_PASSWORD` in the environment, from `CIRCUIT_SECRETS_PASSWORD="op://..."` in the file, a 1Password reference, or from the person, who types it. A value may also be a 1Password reference itself.
4
+ //
5
+ // After a yes, Circuit remembers the password typed and the values read from 1Password for 10 or 30 minutes, so the next commands don't ask: see src/cli/remember.ts. `hold` gives a run what is remembered.
6
+
7
+ import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto";
8
+ import { type SpawnSyncReturns, spawnSync } from "node:child_process";
9
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
10
+ import { dirname, join, resolve } from "node:path";
11
+ import { parseEnv } from "node:util";
12
+
13
+ /** What a value in a secrets file is: encrypted, a 1Password reference, empty, or plain text, which Circuit refuses. */
14
+ export type SecretKind = "encrypted" | "1password" | "empty" | "not encrypted";
15
+
16
+ /** The 1Password CLI, `op`. */
17
+ export class OnePassword {
18
+ /** Each reference read in this run, so 1Password asks once. */
19
+ private static readonly cache = new Map<string, string>();
20
+ /** The references whose values Circuit remembers from an earlier run, after a yes (src/cli/remember.ts). */
21
+ private static readonly held = new Set<string>();
22
+ /** Whether a remembered value was used in this run. */
23
+ static usedHeld = false;
24
+ /** Says what Circuit waits for while `op` runs, such as for the person to unlock 1Password; gives back what clears it. The command line sets it. */
25
+ static waiting: ((message: string) => () => void) | undefined;
26
+
27
+ static installed(): boolean {
28
+ return !spawnSync("op", ["--version"], { encoding: "utf8" }).error;
29
+ }
30
+
31
+ /** A reference read before in this run, without asking 1Password. */
32
+ static cached(reference: string): string | undefined {
33
+ return OnePassword.cache.get(reference);
34
+ }
35
+
36
+ /** Values Circuit remembers, so this run reads them without asking 1Password. */
37
+ static hold(values: Readonly<Record<string, string>>): void {
38
+ for (const [reference, value] of Object.entries(values)) {
39
+ OnePassword.cache.set(reference, value);
40
+ OnePassword.held.add(reference);
41
+ }
42
+ }
43
+
44
+ /** The values read from 1Password itself in this run, which Circuit does not remember yet. */
45
+ static unheld(): Record<string, string> {
46
+ return Object.fromEntries([...OnePassword.cache].filter(([reference]) => !OnePassword.held.has(reference)));
47
+ }
48
+
49
+ /** Forget every value of this run, as a new run starts with none. */
50
+ static forget(): void {
51
+ OnePassword.cache.clear();
52
+ OnePassword.held.clear();
53
+ OnePassword.usedHeld = false;
54
+ }
55
+
56
+ /** The value behind `op://vault/item/field`, read once per run. */
57
+ static read(reference: string, what: string): string {
58
+ const cached = OnePassword.cache.get(reference);
59
+ if (cached !== undefined) {
60
+ if (OnePassword.held.has(reference)) OnePassword.usedHeld = true;
61
+ return cached;
62
+ }
63
+
64
+ const done = OnePassword.waiting?.(`Reading ${reference} from 1Password`);
65
+ let result: SpawnSyncReturns<string>;
66
+ try {
67
+ result = spawnSync("op", ["read", "--no-newline", reference], { encoding: "utf8" });
68
+ } finally {
69
+ done?.();
70
+ }
71
+ if (result.error) throw new Error(`${what} is in 1Password, and the 1Password CLI, op, is not installed`);
72
+ if (result.status !== 0)
73
+ throw new Error(`${what}: 1Password could not read ${reference}: ${result.stderr.trim() || `op exited with ${result.status}`}`);
74
+
75
+ OnePassword.cache.set(reference, result.stdout);
76
+ return result.stdout;
77
+ }
78
+ }
79
+
80
+ export class SecretsFile {
81
+ /** Where a network's secrets are when `circuit()` names no file. */
82
+ static readonly DEFAULT = ".circuit/secrets";
83
+ /** The name that holds the password: in the environment as the password or a 1Password reference, in the file only as a 1Password reference. */
84
+ static readonly PASSWORD = "CIRCUIT_SECRETS_PASSWORD";
85
+
86
+ private static readonly ENCRYPTED = "encrypted:";
87
+ private static readonly VERSION = 1;
88
+ private static readonly SALT = 16;
89
+ private static readonly IV = 12;
90
+ private static readonly TAG = 16;
91
+ /** scrypt's cost, OWASP's minimum: N = 2^17, r = 8, p = 1. About 0.2 s, once per run. */
92
+ static readonly SCRYPT = { N: 2 ** 17, r: 8, p: 1, maxmem: 256 * 1024 * 1024 };
93
+
94
+ /** One file per path, so a password typed once opens it for the whole run. */
95
+ private static readonly opened = new Map<string, SecretsFile>();
96
+
97
+ readonly path: string;
98
+ private password: string | undefined;
99
+ private readonly keys = new Map<string, Buffer>();
100
+
101
+ private constructor(
102
+ readonly root: string,
103
+ /** The path as the project writes it, relative to its root. */
104
+ readonly name: string
105
+ ) {
106
+ this.path = join(root, name);
107
+ }
108
+
109
+ /** The secrets file at `name` in the project at `root`. */
110
+ static at(root: string, name: string = SecretsFile.DEFAULT): SecretsFile {
111
+ const path = join(root, name);
112
+ let file = SecretsFile.opened.get(path);
113
+ if (!file) SecretsFile.opened.set(path, (file = new SecretsFile(root, name)));
114
+ return file;
115
+ }
116
+
117
+ /** Forget every password of this run, as a new run starts with none. */
118
+ static forget(): void {
119
+ SecretsFile.opened.clear();
120
+ SecretsFile.held.clear();
121
+ SecretsFile.usedHeld = false;
122
+ }
123
+
124
+ /** Passwords typed in an earlier run that Circuit remembers after a yes, by the file's full path. */
125
+ private static readonly held = new Map<string, string>();
126
+ /** Whether a remembered password was used in this run. */
127
+ static usedHeld = false;
128
+
129
+ /** Passwords Circuit remembers, by the file's full path, so this run opens those files without asking. */
130
+ static hold(passwords: Readonly<Record<string, string>>): void {
131
+ for (const [path, password] of Object.entries(passwords)) SecretsFile.held.set(resolve(path), password);
132
+ }
133
+
134
+ /** Whether Circuit already remembers this password for the file at `path`. */
135
+ static holds(path: string, password: string): boolean {
136
+ return SecretsFile.held.get(resolve(path)) === password;
137
+ }
138
+
139
+ /** A `circuit secrets` command for the file `name`, with `--file` when it isn't `.circuit/secrets`. */
140
+ static command(name: string, command: string): string {
141
+ return `circuit secrets ${command}${name === SecretsFile.DEFAULT ? "" : ` --file ${name}`}`;
142
+ }
143
+
144
+ static isEncrypted(value: string): boolean {
145
+ return value.startsWith(SecretsFile.ENCRYPTED);
146
+ }
147
+
148
+ /** What a value is, by how it is written. */
149
+ static kindOf(value: string): SecretKind {
150
+ if (!value.trim()) return "empty";
151
+ if (SecretsFile.isEncrypted(value)) return "encrypted";
152
+ if (value.startsWith("op://")) return "1password";
153
+ return "not encrypted";
154
+ }
155
+
156
+ /** What stops a commit of a secrets file's text: a value in plain text, or a password written as itself. Empty when it may be committed. */
157
+ static problemsIn(text: string, name: string): string[] {
158
+ const problems: string[] = [];
159
+ const plain: string[] = [];
160
+
161
+ for (const [key, value] of Object.entries(parseEnv(text) as Record<string, string>)) {
162
+ if (key === SecretsFile.PASSWORD) {
163
+ if (!value.startsWith("op://"))
164
+ problems.push(
165
+ `${name} holds its own password. Only a 1Password reference may be written there: ${SecretsFile.PASSWORD}="op://vault/item/field". Delete the line.`
166
+ );
167
+ } else if (SecretsFile.kindOf(value) === "not encrypted") plain.push(key);
168
+ }
169
+
170
+ if (plain.length)
171
+ problems.unshift(`${name} holds ${plain.join(", ")} in plain text. Encrypt it with ${SecretsFile.command(name, "encrypt")}.`);
172
+ return problems;
173
+ }
174
+
175
+ exists(): boolean {
176
+ return existsSync(this.path);
177
+ }
178
+
179
+ private source(): string {
180
+ return this.exists() ? readFileSync(this.path, "utf8") : "";
181
+ }
182
+
183
+ /** Every name and value in the file, as written. */
184
+ values(): Record<string, string> {
185
+ return parseEnv(this.source()) as Record<string, string>;
186
+ }
187
+
188
+ /** The secrets' names: every name but the password's. */
189
+ names(): string[] {
190
+ return Object.keys(this.values()).filter((name) => name !== SecretsFile.PASSWORD);
191
+ }
192
+
193
+ /** The names whose value is plain text. */
194
+ plaintext(): string[] {
195
+ return Object.entries(this.values())
196
+ .filter(([name, value]) => name !== SecretsFile.PASSWORD && SecretsFile.kindOf(value) === "not encrypted")
197
+ .map(([name]) => name);
198
+ }
199
+
200
+ /** What stops a commit of the file as it is now. */
201
+ problems(): string[] {
202
+ return SecretsFile.problemsIn(this.source(), this.name);
203
+ }
204
+
205
+ /** The encrypted values, by name. */
206
+ private encrypted(): [string, string][] {
207
+ return Object.entries(this.values()).filter(([name, value]) => name !== SecretsFile.PASSWORD && SecretsFile.isEncrypted(value));
208
+ }
209
+
210
+ /** Whether a value in the file is encrypted, so only its password opens the file. */
211
+ hasEncrypted(): boolean {
212
+ return this.encrypted().length > 0;
213
+ }
214
+
215
+ /** Where the password comes from without anyone typing it: the environment first, then a 1Password reference in the file. */
216
+ private passwordSource(): { where: string; value: string } | undefined {
217
+ const fromEnvironment = process.env[SecretsFile.PASSWORD];
218
+ if (fromEnvironment) return { where: `the environment, ${SecretsFile.PASSWORD}`, value: fromEnvironment };
219
+
220
+ const inFile = this.values()[SecretsFile.PASSWORD];
221
+ if (inFile?.startsWith("op://")) return { where: this.name, value: inFile };
222
+ return undefined;
223
+ }
224
+
225
+ /** Where the password comes from, said without reading it. Undefined when someone has to type it. */
226
+ passwordLocation(): string | undefined {
227
+ const source = this.passwordSource();
228
+ if (!source) return undefined;
229
+ return source.value.startsWith("op://") ? `1Password, ${source.value} (named in ${source.where})` : source.where;
230
+ }
231
+
232
+ /** Whether a value of the file can be read now: the password is known, or there is nothing encrypted. */
233
+ unlocked(): boolean {
234
+ return this.password !== undefined;
235
+ }
236
+
237
+ /** Open the file with the password from the environment or 1Password, when there is one. False when someone has to type it. */
238
+ tryUnlock(): boolean {
239
+ if (this.unlocked()) return true;
240
+
241
+ const source = this.passwordSource();
242
+ if (!source) return this.unlockHeld();
243
+
244
+ const password = source.value.startsWith("op://") ? OnePassword.read(source.value, `The password of ${this.name}`) : source.value;
245
+ this.unlock(password, source.where);
246
+ return true;
247
+ }
248
+
249
+ /** Open the file with a password typed in an earlier run that Circuit remembers. One that no longer opens it is dropped. */
250
+ private unlockHeld(): boolean {
251
+ const password = SecretsFile.held.get(resolve(this.path));
252
+ if (password === undefined) return false;
253
+
254
+ try {
255
+ this.unlock(password);
256
+ } catch {
257
+ SecretsFile.held.delete(resolve(this.path));
258
+ return false;
259
+ }
260
+ SecretsFile.usedHeld = true;
261
+ return true;
262
+ }
263
+
264
+ /** Open the file with a password. It must open the values already encrypted; a file with none takes any password. */
265
+ unlock(password: string, from?: string): void {
266
+ if (!password) throw new Error(`The password of ${this.name} cannot be empty.`);
267
+
268
+ const first = this.encrypted()[0];
269
+ if (first) {
270
+ try {
271
+ this.open(first[0], first[1], password);
272
+ } catch {
273
+ throw new Error(`${from ? `The password from ${from}` : "That password"} does not open ${this.name}.`);
274
+ }
275
+ }
276
+
277
+ this.password = password;
278
+ }
279
+
280
+ /** A password made for a person who left it empty: 32 characters, 192 bits. */
281
+ static generatePassword(): string {
282
+ return randomBytes(24).toString("base64url");
283
+ }
284
+
285
+ private key(password: string, salt: Buffer): Buffer {
286
+ const id = `${password}\0${salt.toString("hex")}`;
287
+ let key = this.keys.get(id);
288
+ if (!key) this.keys.set(id, (key = scryptSync(password, salt, 32, SecretsFile.SCRYPT)));
289
+ return key;
290
+ }
291
+
292
+ /** A value, encrypted for `name`: `encrypted:` then base64 of the version, salt, IV, ciphertext and tag. */
293
+ private seal(name: string, value: string, password: string, salt: Buffer): string {
294
+ const iv = randomBytes(SecretsFile.IV);
295
+ const cipher = createCipheriv("aes-256-gcm", this.key(password, salt), iv);
296
+ cipher.setAAD(Buffer.from(name, "utf8"));
297
+ const body = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
298
+
299
+ return (
300
+ SecretsFile.ENCRYPTED +
301
+ Buffer.concat([Buffer.from([SecretsFile.VERSION]), salt, iv, body, cipher.getAuthTag()]).toString("base64")
302
+ );
303
+ }
304
+
305
+ /** The parts of an encrypted value. */
306
+ static partsOf(value: string): { salt: Buffer; iv: Buffer; body: Buffer; tag: Buffer } {
307
+ const bytes = Buffer.from(value.slice(SecretsFile.ENCRYPTED.length), "base64");
308
+ const head = 1 + SecretsFile.SALT + SecretsFile.IV;
309
+ if (bytes[0] !== SecretsFile.VERSION || bytes.length < head + SecretsFile.TAG) throw new Error("not a value Circuit encrypted");
310
+
311
+ return {
312
+ salt: bytes.subarray(1, 1 + SecretsFile.SALT),
313
+ iv: bytes.subarray(1 + SecretsFile.SALT, head),
314
+ body: bytes.subarray(head, bytes.length - SecretsFile.TAG),
315
+ tag: bytes.subarray(bytes.length - SecretsFile.TAG),
316
+ };
317
+ }
318
+
319
+ private open(name: string, value: string, password: string): string {
320
+ const { salt, iv, body, tag } = SecretsFile.partsOf(value);
321
+ const decipher = createDecipheriv("aes-256-gcm", this.key(password, salt), iv);
322
+ decipher.setAAD(Buffer.from(name, "utf8"));
323
+ decipher.setAuthTag(tag);
324
+ return Buffer.concat([decipher.update(body), decipher.final()]).toString("utf8");
325
+ }
326
+
327
+ /** The salt new values take: the one the file's values have, so one key opens them all. A file without one gets a new one. */
328
+ private salt(): Buffer {
329
+ const first = this.encrypted()[0];
330
+ return first ? Buffer.from(SecretsFile.partsOf(first[1]).salt) : randomBytes(SecretsFile.SALT);
331
+ }
332
+
333
+ private needPassword(): string {
334
+ if (this.password === undefined && !this.tryUnlock())
335
+ throw new Error(
336
+ `${this.name} is encrypted, and Circuit has no password for it. Type it when Circuit asks, or give it in ${SecretsFile.PASSWORD}.`
337
+ );
338
+ return this.password!;
339
+ }
340
+
341
+ /** The value of a name: decrypted, or read from 1Password. A value in plain text is refused: it has to be encrypted first. */
342
+ reveal(name: string): string {
343
+ const value = this.values()[name];
344
+ if (value === undefined || name === SecretsFile.PASSWORD) throw new Error(`${name} is not in ${this.name}`);
345
+ return this.decrypt(name, value);
346
+ }
347
+
348
+ /** A value as written under `name`, decrypted or read from 1Password. */
349
+ decrypt(name: string, value: string): string {
350
+ const kind = SecretsFile.kindOf(value);
351
+ if (kind === "1password") return OnePassword.read(value, name);
352
+ if (kind === "empty") return value;
353
+ if (kind === "not encrypted")
354
+ throw new Error(`${name} is in plain text in ${this.name}. Encrypt it first: ${SecretsFile.command(this.name, "encrypt")}`);
355
+
356
+ try {
357
+ return this.open(name, value, this.needPassword());
358
+ } catch (error) {
359
+ if ((error as Error).message.includes("Circuit has no password")) throw error;
360
+ throw new Error(`${name} in ${this.name} does not open with its password: it was changed by hand, or moved from another name.`);
361
+ }
362
+ }
363
+
364
+ /** A value at hand without asking anyone: only when the file is open. */
365
+ revealIfOpen(name: string, value: string): string | undefined {
366
+ if (SecretsFile.kindOf(value) === "1password") return OnePassword.cached(value);
367
+ if (!SecretsFile.isEncrypted(value)) return value;
368
+ return this.unlocked() ? this.open(name, value, this.password!) : undefined;
369
+ }
370
+
371
+ /** Set a name: encrypted, or a 1Password reference as it is. The file must be open. */
372
+ set(name: string, value: string): void {
373
+ if (name === SecretsFile.PASSWORD)
374
+ throw new Error(
375
+ `${name} holds the password's 1Password reference: write it with ${SecretsFile.command(this.name, "password")}.`
376
+ );
377
+ const stored = value.startsWith("op://") ? SecretsFile.reference(value) : this.seal(name, value, this.needPassword(), this.salt());
378
+ this.write(SecretsFile.upsert(this.source(), name, stored));
379
+ }
380
+
381
+ /** Write where the password is in 1Password into the file, or take it out with undefined. */
382
+ setPasswordReference(reference: string | undefined): void {
383
+ if (reference === undefined) {
384
+ this.write(SecretsFile.without(this.source(), SecretsFile.PASSWORD));
385
+ return;
386
+ }
387
+ this.write(SecretsFile.upsert(this.source(), SecretsFile.PASSWORD, SecretsFile.reference(reference)));
388
+ }
389
+
390
+ private static reference(value: string): string {
391
+ if (!/^op:\/\/[^"\r\n]+$/.test(value)) throw new Error(`${value} is not a 1Password reference, op://vault/item/field`);
392
+ return value;
393
+ }
394
+
395
+ /** Remove a name. False when it was not there. */
396
+ remove(name: string): boolean {
397
+ if (!(name in this.values()) || name === SecretsFile.PASSWORD) return false;
398
+ this.write(SecretsFile.without(this.source(), name));
399
+ return true;
400
+ }
401
+
402
+ /** A name at the start of a line, `NAME=` or `export NAME=`. */
403
+ private static readonly ASSIGNMENT = /^[ \t]*(?:export[ \t]+)?([A-Za-z_][A-Za-z0-9_]*)[ \t]*=(.*)$/;
404
+
405
+ /** A value as the editor shows it: as it is, in quotes only when it starts or ends with a space or a quote, which the quotes then keep. */
406
+ private static shown(value: string): string {
407
+ return /^\s|\s$|^["']/.test(value) ? `"${value}"` : value;
408
+ }
409
+
410
+ /** A value as the editor's line writes it: as typed, its spaces at either end dropped; one pair of quotes around it is taken off. */
411
+ private static typed(text: string): string {
412
+ const value = text.trim();
413
+ return /^(["']).*\1$/s.test(value) && value.length >= 2 ? value.slice(1, -1) : value;
414
+ }
415
+
416
+ /** The whole file as a person edits it: every line in its place, each value decrypted. The file must be open. Never written to disk. */
417
+ editable(): string {
418
+ const lines = (this.source() || SecretsFile.HEADER).replace(/\r\n?/g, "\n").replace(/\n$/, "").split("\n");
419
+ return `${lines
420
+ .map((line) => {
421
+ const name = SecretsFile.ASSIGNMENT.exec(line)?.[1];
422
+ if (!name) return line;
423
+ const value = (parseEnv(line) as Record<string, string>)[name] ?? "";
424
+ return `${name}=${SecretsFile.shown(name === SecretsFile.PASSWORD ? value : this.decryptForEdit(name, value))}`;
425
+ })
426
+ .join("\n")}\n`;
427
+ }
428
+
429
+ /** A value decrypted for the editor: an encrypted one opened, anything else as written. */
430
+ private decryptForEdit(name: string, value: string): string {
431
+ return SecretsFile.isEncrypted(value) ? this.open(name, value, this.needPassword()) : value;
432
+ }
433
+
434
+ /**
435
+ * Write the file from the text the editor holds: comments and blank lines as they are, each value encrypted, a value that did not
436
+ * change kept as it was encrypted, so git shows only what changed. Gives back why it can't be written, or undefined once written.
437
+ */
438
+ saveEditable(text: string): string | undefined {
439
+ const before = new Map<string, { stored: string; value: string }>();
440
+ for (const [name, stored] of Object.entries(this.values()))
441
+ before.set(name, { stored, value: name === SecretsFile.PASSWORD ? stored : this.decryptForEdit(name, stored) });
442
+
443
+ const password = this.needPassword();
444
+ const salt = this.salt();
445
+ const seen = new Set<string>();
446
+ const out: string[] = [];
447
+ const lines = text.replace(/\r\n?/g, "\n").replace(/\n$/, "").split("\n");
448
+
449
+ for (const [i, line] of lines.entries()) {
450
+ if (!line.trim() || line.trim().startsWith("#")) {
451
+ out.push(line);
452
+ continue;
453
+ }
454
+
455
+ const match = SecretsFile.ASSIGNMENT.exec(line);
456
+ if (!match) return `line ${i + 1}: write NAME=value, or a comment starting with #`;
457
+ const [, name, raw] = match as unknown as [string, string, string];
458
+ if (seen.has(name)) return `line ${i + 1}: ${name} is written twice`;
459
+ seen.add(name);
460
+
461
+ const value = SecretsFile.typed(raw);
462
+ if (name === SecretsFile.PASSWORD) {
463
+ if (!value.startsWith("op://") || value.includes('"'))
464
+ return `line ${i + 1}: ${name} may only be a 1Password reference, op://vault/item/field. The password itself never goes in the file.`;
465
+ out.push(`${name}="${value}"`);
466
+ continue;
467
+ }
468
+
469
+ if (value.includes('"') && value.startsWith("op://")) return `line ${i + 1}: a 1Password reference has no "`;
470
+ const old = before.get(name);
471
+ const stored =
472
+ old && old.value === value && SecretsFile.kindOf(old.stored) !== "not encrypted"
473
+ ? old.stored
474
+ : value.startsWith("op://") || !value
475
+ ? value
476
+ : this.seal(name, value, password, salt);
477
+ out.push(`${name}="${stored}"`);
478
+ }
479
+
480
+ this.write(`${out.join("\n")}\n`);
481
+ return undefined;
482
+ }
483
+
484
+ /** Encrypt every value written in plain text. Gives back their names. */
485
+ encryptAll(): string[] {
486
+ const names = this.plaintext();
487
+ if (!names.length) return [];
488
+
489
+ const password = this.needPassword();
490
+ const salt = this.salt();
491
+ let text = this.source();
492
+ for (const name of names) text = SecretsFile.upsert(text, name, this.seal(name, this.values()[name]!, password, salt));
493
+
494
+ this.write(text);
495
+ return names;
496
+ }
497
+
498
+ /** Encrypt every value again with a new password. The old one stops opening the file. */
499
+ changePassword(next: string): void {
500
+ if (!next) throw new Error(`The password of ${this.name} cannot be empty.`);
501
+
502
+ const old = this.needPassword();
503
+ const salt = randomBytes(SecretsFile.SALT);
504
+ let text = this.source();
505
+ for (const [name, value] of this.encrypted())
506
+ text = SecretsFile.upsert(text, name, this.seal(name, this.open(name, value, old), next, salt));
507
+
508
+ this.write(text);
509
+ this.password = next;
510
+ }
511
+
512
+ private write(text: string): void {
513
+ mkdirSync(dirname(this.path), { recursive: true });
514
+ writeFileSync(this.path, text || SecretsFile.HEADER);
515
+ }
516
+
517
+ /** What a new file starts with. */
518
+ static readonly HEADER =
519
+ "# Circuit's secrets, each value encrypted, so this file is safe to commit. Change them with: circuit secrets\n";
520
+
521
+ /** The line that sets `name`, matched however it is written. */
522
+ private static line(name: string): RegExp {
523
+ return new RegExp(`^[ \\t]*(?:export[ \\t]+)?${name}[ \\t]*=.*$`, "m");
524
+ }
525
+
526
+ /** Text with `name` set to `value`, in place when it is there, at the end when it is not. A new file starts with the header. */
527
+ static upsert(text: string, name: string, value: string): string {
528
+ const line = `${name}="${value}"`;
529
+ if (SecretsFile.line(name).test(text)) return text.replace(SecretsFile.line(name), () => line);
530
+
531
+ const base = text || SecretsFile.HEADER;
532
+ return `${base}${base.endsWith("\n") ? "" : "\n"}${line}\n`;
533
+ }
534
+
535
+ /** Text without the line that sets `name`. */
536
+ static without(text: string, name: string): string {
537
+ return text.replace(new RegExp(`${SecretsFile.line(name).source}\\r?\\n?`, "m"), "");
538
+ }
539
+ }