@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,268 @@
1
+ // A small text editor in the terminal, for `circuit secrets`: the whole text on one screen, typed into as in any editor. Ctrl+S saves; Esc leaves, or Ctrl+X, or Ctrl+Q where the terminal passes it on (VS Code's keeps Ctrl+Q for itself). It draws on the terminal's alternate screen, so what it showed is gone from the scrollback when it closes, and it never writes the text anywhere but through `save`.
2
+
3
+ /** One key, as the terminal sends it. */
4
+ export type Key =
5
+ | { kind: "text"; text: string }
6
+ | { kind: "up" | "down" | "left" | "right" | "home" | "end" | "page-up" | "page-down" | "enter" | "backspace" | "delete" }
7
+ | { kind: "save" | "quit" };
8
+
9
+ /** The text being edited, its cursor, and whether it changed since the last save. */
10
+ export class EditorBuffer {
11
+ lines: string[];
12
+ row = 0;
13
+ col = 0;
14
+ modified = false;
15
+
16
+ constructor(text: string) {
17
+ this.lines = text.replace(/\r\n?/g, "\n").replace(/\n$/, "").split("\n");
18
+ }
19
+
20
+ text(): string {
21
+ return `${this.lines.join("\n")}\n`;
22
+ }
23
+
24
+ private get line(): string {
25
+ return this.lines[this.row]!;
26
+ }
27
+
28
+ /** Type text at the cursor. A paste may hold several lines. */
29
+ insert(text: string): void {
30
+ const parts = text.replace(/\r\n?/g, "\n").replace(/\t/g, " ").split("\n");
31
+ const before = this.line.slice(0, this.col);
32
+ const after = this.line.slice(this.col);
33
+
34
+ if (parts.length === 1) {
35
+ this.lines[this.row] = before + parts[0] + after;
36
+ this.col += parts[0]!.length;
37
+ } else {
38
+ const last = parts[parts.length - 1]!;
39
+ this.lines.splice(this.row, 1, before + parts[0], ...parts.slice(1, -1), last + after);
40
+ this.row += parts.length - 1;
41
+ this.col = last.length;
42
+ }
43
+ this.modified = true;
44
+ }
45
+
46
+ newline(): void {
47
+ this.insert("\n");
48
+ }
49
+
50
+ backspace(): void {
51
+ if (this.col > 0) {
52
+ this.lines[this.row] = this.line.slice(0, this.col - 1) + this.line.slice(this.col);
53
+ this.col--;
54
+ } else if (this.row > 0) {
55
+ const previous = this.lines[this.row - 1]!;
56
+ this.lines.splice(this.row - 1, 2, previous + this.line);
57
+ this.row--;
58
+ this.col = previous.length;
59
+ } else return;
60
+ this.modified = true;
61
+ }
62
+
63
+ delete(): void {
64
+ if (this.col < this.line.length) this.lines[this.row] = this.line.slice(0, this.col) + this.line.slice(this.col + 1);
65
+ else if (this.row < this.lines.length - 1) this.lines.splice(this.row, 2, this.line + this.lines[this.row + 1]);
66
+ else return;
67
+ this.modified = true;
68
+ }
69
+
70
+ move(kind: "up" | "down" | "left" | "right" | "home" | "end", by = 1): void {
71
+ if (kind === "left") {
72
+ if (this.col > 0) this.col--;
73
+ else if (this.row > 0) this.col = this.lines[--this.row]!.length;
74
+ } else if (kind === "right") {
75
+ if (this.col < this.line.length) this.col++;
76
+ else if (this.row < this.lines.length - 1) {
77
+ this.row++;
78
+ this.col = 0;
79
+ }
80
+ } else if (kind === "up") this.row = Math.max(0, this.row - by);
81
+ else if (kind === "down") this.row = Math.min(this.lines.length - 1, this.row + by);
82
+ else if (kind === "home") this.col = 0;
83
+ else this.col = this.line.length;
84
+
85
+ this.col = Math.min(this.col, this.line.length);
86
+ }
87
+ }
88
+
89
+ /** What the editor reads keys from: a terminal in raw mode. */
90
+ export type TerminalInput = Pick<NodeJS.ReadStream, "on" | "off" | "resume" | "pause" | "setEncoding"> & {
91
+ setRawMode(mode: boolean): unknown;
92
+ };
93
+ /** What the editor draws on. */
94
+ export type TerminalOutput = Pick<NodeJS.WriteStream, "write" | "on" | "off"> & { rows?: number; columns?: number };
95
+
96
+ export class TextEditor {
97
+ private static readonly SEQUENCES: Record<string, Key["kind"]> = {
98
+ "\x1b[A": "up",
99
+ "\x1b[B": "down",
100
+ "\x1b[C": "right",
101
+ "\x1b[D": "left",
102
+ "\x1bOA": "up",
103
+ "\x1bOB": "down",
104
+ "\x1bOC": "right",
105
+ "\x1bOD": "left",
106
+ "\x1b[H": "home",
107
+ "\x1b[F": "end",
108
+ "\x1bOH": "home",
109
+ "\x1bOF": "end",
110
+ "\x1b[1~": "home",
111
+ "\x1b[4~": "end",
112
+ "\x1b[5~": "page-up",
113
+ "\x1b[6~": "page-down",
114
+ "\x1b[3~": "delete",
115
+ };
116
+
117
+ private static readonly PASTE_START = "\x1b[200~";
118
+ private static readonly PASTE_END = "\x1b[201~";
119
+
120
+ /** The keys in what the terminal sent. An escape sequence it doesn't know is dropped. */
121
+ static keys(data: string): Key[] {
122
+ const keys: Key[] = [];
123
+ let text = "";
124
+ const flush = () => {
125
+ if (text) keys.push({ kind: "text", text });
126
+ text = "";
127
+ };
128
+
129
+ for (let i = 0; i < data.length;) {
130
+ if (data.startsWith(TextEditor.PASTE_START, i)) {
131
+ flush();
132
+ const end = data.indexOf(TextEditor.PASTE_END, i);
133
+ const pasted = data.slice(i + TextEditor.PASTE_START.length, end < 0 ? data.length : end);
134
+ if (pasted) keys.push({ kind: "text", text: pasted });
135
+ i = end < 0 ? data.length : end + TextEditor.PASTE_END.length;
136
+ continue;
137
+ }
138
+
139
+ const char = data[i]!;
140
+ if (char === "\x1b") {
141
+ flush();
142
+ const sequence = /^\x1b(\[[0-9;]*[A-Za-z~]|O[A-Za-z])?/.exec(data.slice(i))![0];
143
+ const kind = TextEditor.SEQUENCES[sequence.replace(/^\x1b\[1;\d+([A-H])$/, "\x1b[$1")];
144
+ if (kind) keys.push({ kind } as Key);
145
+ // Esc alone, nothing after it, is the key itself, not the start of a sequence.
146
+ else if (sequence === "\x1b" && i + 1 === data.length) keys.push({ kind: "quit" });
147
+ i += sequence.length;
148
+ continue;
149
+ }
150
+
151
+ const control: Record<string, Key["kind"]> = {
152
+ "\r": "enter",
153
+ "\n": "enter",
154
+ "\x7f": "backspace",
155
+ "\b": "backspace",
156
+ "\x13": "save",
157
+ "\x0f": "save",
158
+ "\x11": "quit",
159
+ "\x18": "quit",
160
+ "\x03": "quit",
161
+ };
162
+ if (control[char]) {
163
+ flush();
164
+ // A terminal may send Enter as \r\n: one Enter.
165
+ if (char === "\r" && data[i + 1] === "\n") i++;
166
+ keys.push({ kind: control[char] } as Key);
167
+ } else if (char === "\t" || char >= " ") text += char;
168
+ i++;
169
+ }
170
+
171
+ flush();
172
+ return keys;
173
+ }
174
+
175
+ /**
176
+ * Edit `text` on the whole screen until the person leaves. `save` gets the text on Ctrl+S and gives back why it can't be saved, or
177
+ * undefined once saved. `note` shows at the bottom until the first key. Leaving with changes not saved asks to leave a second time.
178
+ */
179
+ static async open(options: {
180
+ title: string;
181
+ text: string;
182
+ note?: string;
183
+ save: (text: string) => string | undefined;
184
+ /** The terminal: the process's own unless a test gives another. */
185
+ terminal?: { input: TerminalInput; output: TerminalOutput };
186
+ }): Promise<void> {
187
+ const stdin = options.terminal?.input ?? (process.stdin as TerminalInput);
188
+ const out = options.terminal?.output ?? (process.stdout as TerminalOutput);
189
+ const buffer = new EditorBuffer(options.text);
190
+ const help = "Ctrl+S save Esc leave";
191
+ let message = options.note ? `${options.note} ${help}` : help;
192
+ let top = 0;
193
+ let left = 0;
194
+ let leaving = false;
195
+
196
+ const draw = () => {
197
+ const rows = Math.max(3, out.rows ?? 24);
198
+ const cols = Math.max(20, out.columns ?? 80);
199
+ const height = rows - 2;
200
+
201
+ if (buffer.row < top) top = buffer.row;
202
+ if (buffer.row >= top + height) top = buffer.row - height + 1;
203
+ if (buffer.col < left) left = buffer.col;
204
+ if (buffer.col >= left + cols) left = buffer.col - cols + 1;
205
+
206
+ const title = ` ${options.title}${buffer.modified ? " (not saved)" : ""}`;
207
+ let screen = `\x1b[?25l\x1b[H\x1b[7m${title.slice(0, cols).padEnd(cols)}\x1b[0m`;
208
+ for (let i = 0; i < height; i++) {
209
+ const line = buffer.lines[top + i];
210
+ screen += `\x1b[${i + 2};1H\x1b[K${line === undefined ? "" : line.slice(left, left + cols)}`;
211
+ }
212
+ screen += `\x1b[${rows};1H\x1b[K\x1b[7m${` ${message}`.slice(0, cols).padEnd(cols)}\x1b[0m`;
213
+ screen += `\x1b[${buffer.row - top + 2};${buffer.col - left + 1}H\x1b[?25h`;
214
+ out.write(screen);
215
+ return height;
216
+ };
217
+
218
+ out.write("\x1b[?1049h\x1b[?2004h");
219
+ stdin.setRawMode(true);
220
+ stdin.resume();
221
+ stdin.setEncoding("utf8");
222
+
223
+ try {
224
+ await new Promise<void>((done) => {
225
+ let height = draw();
226
+ const redraw = () => (height = draw());
227
+ out.on("resize", redraw);
228
+
229
+ const onData = (data: string) => {
230
+ for (const key of TextEditor.keys(data)) {
231
+ if (key.kind !== "quit") leaving = false;
232
+ message = help;
233
+
234
+ if (key.kind === "text") buffer.insert(key.text);
235
+ else if (key.kind === "enter") buffer.newline();
236
+ else if (key.kind === "backspace") buffer.backspace();
237
+ else if (key.kind === "delete") buffer.delete();
238
+ else if (key.kind === "page-up") buffer.move("up", height);
239
+ else if (key.kind === "page-down") buffer.move("down", height);
240
+ else if (key.kind === "save") {
241
+ const problem = options.save(buffer.text());
242
+ if (problem) message = `Not saved: ${problem}`;
243
+ else {
244
+ buffer.modified = false;
245
+ message = `Saved. ${help}`;
246
+ }
247
+ } else if (key.kind === "quit") {
248
+ if (!buffer.modified || leaving) {
249
+ stdin.off("data", onData);
250
+ out.off("resize", redraw);
251
+ done();
252
+ return;
253
+ }
254
+ leaving = true;
255
+ message = "Not saved. Esc again leaves without saving; Ctrl+S saves.";
256
+ } else buffer.move(key.kind);
257
+ }
258
+ redraw();
259
+ };
260
+ stdin.on("data", onData);
261
+ });
262
+ } finally {
263
+ stdin.setRawMode(false);
264
+ stdin.pause();
265
+ out.write("\x1b[?2004l\x1b[?1049l");
266
+ }
267
+ }
268
+ }
package/src/cli/ui.ts ADDED
@@ -0,0 +1,298 @@
1
+ // How the command line talks to a person: a spinner while it waits, questions with a safe default, and messages in colour. Without a terminal, such as in a script, nothing spins and nothing is asked.
2
+
3
+ import * as prompts from "@clack/prompts";
4
+ import { styleText } from "node:util";
5
+
6
+ /** One choice in a list: what it gives back, what it says, a hint beside it, and whether it can be picked. */
7
+ export type Choice<T> = { value: T; label: string; hint?: string; disabled?: boolean };
8
+
9
+ export class Ui {
10
+ /** A person is at a terminal: Circuit may spin and ask. */
11
+ static readonly interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
12
+
13
+ /** Colour only for a terminal, and never when NO_COLOR is set. */
14
+ private static paint(format: Parameters<typeof styleText>[0], text: string, stream: NodeJS.WriteStream = process.stdout): string {
15
+ return stream.isTTY && !process.env.NO_COLOR ? styleText(format, text) : text;
16
+ }
17
+
18
+ static bold(text: string): string {
19
+ return Ui.paint("bold", text);
20
+ }
21
+
22
+ static dim(text: string): string {
23
+ return Ui.paint("dim", text);
24
+ }
25
+
26
+ static red(text: string, stream?: NodeJS.WriteStream): string {
27
+ return Ui.paint("red", text, stream);
28
+ }
29
+
30
+ static green(text: string): string {
31
+ return Ui.paint("green", text);
32
+ }
33
+
34
+ static yellow(text: string, stream?: NodeJS.WriteStream): string {
35
+ return Ui.paint("yellow", text, stream);
36
+ }
37
+
38
+ private static magenta(text: string): string {
39
+ return Ui.paint("magenta", text);
40
+ }
41
+
42
+ /** Print why Circuit stops, and stop. */
43
+ static fail(message: string): never {
44
+ Ui.active?.halt();
45
+ console.error(Ui.red(message, process.stderr));
46
+ process.exit(1);
47
+ }
48
+
49
+ static warn(message: string): void {
50
+ console.error(`${Ui.yellow(" warn ", process.stderr)} ${message}`);
51
+ }
52
+
53
+ static error(message: string): void {
54
+ console.error(`${Ui.red(" error", process.stderr)} ${message}`);
55
+ }
56
+
57
+ static done(message: string): void {
58
+ console.log(`${Ui.green("✔")} ${message}`);
59
+ }
60
+
61
+ /** The spinner on screen now: drawn again after a line printed above it, and halted when Circuit stops. */
62
+ private static active: { draw: () => void; halt: () => void } | undefined;
63
+
64
+ /**
65
+ * Run `work` behind a spinner that says what Circuit waits for, with the seconds it has taken. Lines printed meanwhile, such as a
66
+ * platform's progress, go above it. When done it leaves a line, ◇ when it worked or ■ when it failed.
67
+ */
68
+ static step<T>(message: string, work: () => Promise<T>): Promise<T> {
69
+ return Ui.spin(message, work, { leave: true, delay: 0 });
70
+ }
71
+
72
+ /**
73
+ * Wait for `work` with a spinner that leaves nothing behind, for a short wait such as the check before each command. It shows only
74
+ * after a moment, so a quick answer, such as one from the cache, shows nothing.
75
+ */
76
+ static wait<T>(message: string, work: () => Promise<T>): Promise<T> {
77
+ return Ui.spin(message, work, { leave: false, delay: 150 });
78
+ }
79
+
80
+ private static async spin<T>(message: string, work: () => Promise<T>, options: { leave: boolean; delay: number }): Promise<T> {
81
+ // In a script nothing spins; inside another spinner, that one keeps going.
82
+ if (!Ui.interactive || Ui.active) return work();
83
+
84
+ // One line only: a longer one would wrap, and could not be drawn over.
85
+ const width = Math.max(20, (process.stdout.columns ?? 80) - 12);
86
+ const text = message.length > width ? `${message.slice(0, width - 1)}…` : message;
87
+ const frames = ["◒", "◐", "◓", "◑"];
88
+ const started = Date.now();
89
+ let frame = 0;
90
+ let shown = false;
91
+
92
+ const seconds = () => {
93
+ const taken = Math.floor((Date.now() - started) / 1000);
94
+ return taken >= 1 ? Ui.dim(` [${taken}s]`) : "";
95
+ };
96
+ const draw = () => {
97
+ shown = true;
98
+ process.stdout.write(`\r${Ui.magenta(frames[frame++ % frames.length]!)} ${text}${seconds()}\x1b[K`);
99
+ };
100
+ const clear = () => {
101
+ if (shown) process.stdout.write("\r\x1b[K");
102
+ };
103
+
104
+ // A line printed while the spinner is on goes above it, and the spinner is drawn again under it.
105
+ const printers = { log: console.log, info: console.info, warn: console.warn, error: console.error };
106
+ for (const name of Object.keys(printers) as (keyof typeof printers)[])
107
+ console[name] = (...args: unknown[]) => {
108
+ clear();
109
+ printers[name](...args);
110
+ if (shown) draw();
111
+ };
112
+
113
+ let ticker: ReturnType<typeof setInterval> | undefined;
114
+ const start = setTimeout(() => {
115
+ draw();
116
+ ticker = setInterval(draw, 100);
117
+ }, options.delay);
118
+
119
+ const halt = () => {
120
+ clearTimeout(start);
121
+ clearInterval(ticker);
122
+ Object.assign(console, printers);
123
+ clear();
124
+ Ui.active = undefined;
125
+ };
126
+ Ui.active = { draw: () => shown && draw(), halt };
127
+
128
+ try {
129
+ const result = await work();
130
+ const taken = seconds();
131
+ halt();
132
+ if (options.leave) console.log(`${Ui.green("◇")} ${text}${taken}`);
133
+ return result;
134
+ } catch (error) {
135
+ const taken = seconds();
136
+ halt();
137
+ if (options.leave) console.log(`${Ui.red("■")} ${text}${taken}`);
138
+ throw error;
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Say what Circuit waits for while it can't spin, such as while 1Password asks the person to unlock it. Gives back what takes the
144
+ * line away again, and brings back the spinner it covered.
145
+ */
146
+ static blocked(message: string): () => void {
147
+ if (!Ui.interactive) return () => {};
148
+
149
+ process.stdout.write(`\r${Ui.magenta("◒")} ${message}\x1b[K`);
150
+ return () => {
151
+ process.stdout.write("\r\x1b[K");
152
+ Ui.active?.draw();
153
+ };
154
+ }
155
+
156
+ /** Stop when the person pressed Ctrl-C or Esc on a question. */
157
+ private static answered<T>(value: T): Exclude<T, symbol> {
158
+ if (prompts.isCancel(value)) {
159
+ prompts.cancel("Stopped, nothing changed.");
160
+ process.exit(130);
161
+ }
162
+
163
+ return value as Exclude<T, symbol>;
164
+ }
165
+
166
+ /** A yes or no question. The default is no, so a stray Enter changes nothing. */
167
+ static async confirm(message: string, initial = false): Promise<boolean> {
168
+ return Ui.answered(await prompts.confirm({ message, initialValue: initial }));
169
+ }
170
+
171
+ static async select<T>(message: string, choices: readonly Choice<T>[], initial?: T): Promise<T> {
172
+ const options = choices.map((choice) => ({ ...choice })) as prompts.Option<T>[];
173
+ return Ui.answered(await prompts.select<T>({ message, options, ...(initial !== undefined ? { initialValue: initial } : {}) }));
174
+ }
175
+
176
+ static async text(
177
+ message: string,
178
+ options: { initial?: string; placeholder?: string; check?: (value: string) => string | undefined } = {}
179
+ ): Promise<string> {
180
+ return Ui.answered(
181
+ await prompts.text({
182
+ message,
183
+ ...(options.initial !== undefined ? { initialValue: options.initial } : {}),
184
+ ...(options.placeholder !== undefined ? { placeholder: options.placeholder } : {}),
185
+ ...(options.check ? { validate: (value) => options.check!(value ?? "") } : {}),
186
+ })
187
+ );
188
+ }
189
+
190
+ /** A value typed without showing it, such as a password. With `empty`, Enter alone gives "". */
191
+ static async secret(message: string, options: { empty?: boolean } = {}): Promise<string> {
192
+ const answer = await prompts.password({ message, validate: (value) => (value || options.empty ? undefined : "Type a value.") });
193
+ return Ui.answered(answer) ?? "";
194
+ }
195
+
196
+ /** How wide text is on screen: colours take no room. */
197
+ private static width(text: string): number {
198
+ return text.replace(/\x1b\[[0-9;]*m/g, "").length;
199
+ }
200
+
201
+ /** Words of `text` in lines of at most `width` characters; a longer word is cut. Colours are dropped, as a cut could break one. */
202
+ private static wrap(text: string, width: number): string[] {
203
+ const plain = text.replace(/\x1b\[[0-9;]*m/g, "");
204
+ if (plain.length <= width) return [text];
205
+
206
+ const lines: string[] = [];
207
+ let line = "";
208
+ for (const word of plain.split(" ")) {
209
+ const next = line ? `${line} ${word}` : word;
210
+ if (next.length <= width) {
211
+ line = next;
212
+ continue;
213
+ }
214
+ if (line) lines.push(line);
215
+ let rest = word;
216
+ while (rest.length > width) {
217
+ lines.push(rest.slice(0, width));
218
+ rest = rest.slice(width);
219
+ }
220
+ line = rest;
221
+ }
222
+ if (line) lines.push(line);
223
+ return lines;
224
+ }
225
+
226
+ /**
227
+ * Rows in a table with lines around each cell and a heading row. A column with `right` lines up its numbers on the right. In a terminal
228
+ * too narrow for it, the last column's text goes on more lines.
229
+ */
230
+ static table(columns: readonly { title: string; right?: boolean }[], rows: readonly (readonly string[])[]): string {
231
+ const widths = columns.map((column, at) => Math.max(column.title.length, ...rows.map((row) => Ui.width(row[at] ?? ""))));
232
+
233
+ // The borders and padding take 3 characters a column, and 1 more.
234
+ const room = (process.stdout.isTTY ? (process.stdout.columns ?? 0) : 0) - 1 - 3 * columns.length;
235
+ const others = widths.slice(0, -1).reduce((sum, width) => sum + width, 0);
236
+ if (room > 0 && others + widths.at(-1)! > room)
237
+ widths[widths.length - 1] = Math.max(columns.at(-1)!.title.length, room - others, 12);
238
+
239
+ const line = (left: string, middle: string, right: string) =>
240
+ Ui.dim(`${left}${widths.map((width) => "─".repeat(width + 2)).join(middle)}${right}`);
241
+ const bar = Ui.dim("│");
242
+ const cell = (text: string, at: number) => {
243
+ const pad = " ".repeat(Math.max(0, widths[at]! - Ui.width(text)));
244
+ return columns[at]!.right ? `${pad}${text}` : `${text}${pad}`;
245
+ };
246
+ const row = (cells: readonly string[]) => {
247
+ const wrapped = cells.map((text, at) => (at === columns.length - 1 ? Ui.wrap(text, widths[at]!) : [text]));
248
+ const height = Math.max(...wrapped.map((lines) => lines.length));
249
+ return Array.from(
250
+ { length: height },
251
+ (_, index) => `${bar} ${wrapped.map((lines, at) => cell(lines[index] ?? "", at)).join(` ${bar} `)} ${bar}`
252
+ );
253
+ };
254
+
255
+ return [
256
+ line("┌", "┬", "┐"),
257
+ ...row(columns.map((column) => Ui.bold(column.title))),
258
+ line("├", "┼", "┤"),
259
+ ...rows.flatMap((cells) => row(columns.map((_, at) => cells[at] ?? ""))),
260
+ line("└", "┴", "┘"),
261
+ ].join("\n");
262
+ }
263
+
264
+ /** The CIRCUIT logotype, in the font of the one on the site, drawn with blocks. */
265
+ static readonly LOGO = [
266
+ " ▄██████▄ ██ ███████▄ ▄██████▄ ██ ██ ██ ████████",
267
+ "███ ███ ██ ██ ███ ███ ███ ██ ██ ██ ██",
268
+ "██ ██ ███████▀ ██ ██ ██ ██ ██",
269
+ "███ ███ ██ ██ ██▄ ███ ███ ███ ███ ██ ██",
270
+ " ▀██████▀ ██ ██ ██▄ ▀██████▀ ▀█████▀ ██ ██",
271
+ ];
272
+
273
+ /** The logotype and a line under it, in a terminal wide enough; elsewhere, the line alone. */
274
+ static logo(line: string): void {
275
+ if (!Ui.interactive) return;
276
+ if ((process.stdout.columns ?? 0) < 64) {
277
+ console.log(Ui.bold(line));
278
+ return;
279
+ }
280
+ console.log(`\n${Ui.LOGO.map((row) => ` ${Ui.bold(row)}`).join("\n")}\n\n ${Ui.dim(line)}\n`);
281
+ }
282
+
283
+ /** A heading for a run of questions, and the line that closes it. */
284
+ static intro(title: string): void {
285
+ if (Ui.interactive) prompts.intro(Ui.bold(title));
286
+ }
287
+
288
+ static outro(message: string): void {
289
+ if (Ui.interactive) prompts.outro(message);
290
+ else console.log(message);
291
+ }
292
+
293
+ /** A boxed note, such as the next steps. */
294
+ static note(message: string, title?: string): void {
295
+ if (Ui.interactive) prompts.note(message, title);
296
+ else console.log(title ? `${title}\n${message}` : message);
297
+ }
298
+ }
@@ -0,0 +1,2 @@
1
+ /** This Circuit's version, the same as package.json's. */
2
+ export const VERSION = "1.0.0-rc.2";