@takodotid/circuit 0.34.3 → 0.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (240) hide show
  1. package/README.md +14 -15
  2. package/dist/chunks/index-20kcjxjg.js +174 -0
  3. package/dist/chunks/index-2r4bbhac.js +977 -0
  4. package/dist/chunks/index-n7w3tg5y.js +149 -0
  5. package/dist/chunks/new-w3zys5y4.js +172 -0
  6. package/dist/chunks/server-gzsf5me5.js +259 -0
  7. package/dist/cli/index.js +11345 -9024
  8. package/dist/helpers/index.js +594 -0
  9. package/dist/index.js +9 -28
  10. package/dist/types/core/{addresses.d.ts → address-space.d.ts} +23 -7
  11. package/dist/types/core/address.d.ts +44 -0
  12. package/dist/types/core/cabling.d.ts +41 -0
  13. package/dist/types/core/cross-checks.d.ts +28 -0
  14. package/dist/types/core/define.d.ts +25 -3
  15. package/dist/types/core/project-files.d.ts +12 -0
  16. package/dist/types/core/schema-docs.d.ts +13 -4
  17. package/dist/types/core/secrets.d.ts +62 -21
  18. package/dist/types/core/{graph.d.ts → topology.d.ts} +32 -12
  19. package/dist/types/core/utils.d.ts +8 -0
  20. package/dist/types/core/validator.d.ts +19 -0
  21. package/dist/types/helpers/bgp.d.ts +97 -0
  22. package/dist/types/helpers/checks.d.ts +32 -0
  23. package/dist/types/helpers/exchange.d.ts +49 -0
  24. package/dist/types/helpers/filters.d.ts +51 -0
  25. package/dist/types/helpers/index.d.ts +10 -0
  26. package/dist/types/helpers/route-server.d.ts +52 -0
  27. package/dist/types/index.d.ts +5 -3
  28. package/dist/types/integrations/librenms.d.ts +60 -0
  29. package/dist/types/integrations/netbox.d.ts +89 -0
  30. package/dist/types/integrations/peeringdb.d.ts +24 -0
  31. package/dist/types/integrations/prometheus.d.ts +24 -0
  32. package/dist/types/integrations/registries.d.ts +8 -0
  33. package/dist/types/platforms/bgp-wait.d.ts +18 -0
  34. package/dist/types/platforms/catalog.d.ts +109 -0
  35. package/dist/types/platforms/derive.d.ts +17 -0
  36. package/dist/types/platforms/fortios/firewall.d.ts +45 -0
  37. package/dist/types/platforms/fortios/index.d.ts +134 -0
  38. package/dist/types/platforms/fortios/names.d.ts +52 -0
  39. package/dist/types/platforms/fortios/plan.d.ts +104 -0
  40. package/dist/types/platforms/fortios/render.d.ts +75 -0
  41. package/dist/types/platforms/fortios/routing.d.ts +56 -0
  42. package/dist/types/platforms/fortios/tree.d.ts +37 -0
  43. package/dist/types/platforms/index.d.ts +13 -0
  44. package/dist/types/platforms/junos/bgp.d.ts +44 -0
  45. package/dist/types/platforms/junos/index.d.ts +114 -0
  46. package/dist/types/platforms/junos/render.d.ts +154 -0
  47. package/dist/types/platforms/line-diff.d.ts +67 -0
  48. package/dist/types/platforms/platform.d.ts +120 -0
  49. package/dist/types/platforms/raisecom-ros/index.d.ts +71 -0
  50. package/dist/types/platforms/raisecom-ros/render.d.ts +41 -0
  51. package/dist/types/platforms/routeros/export.d.ts +59 -0
  52. package/dist/types/platforms/routeros/index.d.ts +96 -0
  53. package/dist/types/{adapters/devices → platforms}/routeros/models.d.ts +4 -0
  54. package/dist/types/platforms/routeros/plan.d.ts +54 -0
  55. package/dist/types/platforms/routeros/render.d.ts +240 -0
  56. package/dist/types/platforms/vrp/index.d.ts +83 -0
  57. package/dist/types/platforms/vrp/render.d.ts +96 -0
  58. package/dist/types/schema/common.d.ts +12 -6
  59. package/dist/types/schema/index.d.ts +1 -0
  60. package/dist/types/schema/interfaces.d.ts +14 -6
  61. package/dist/types/schema/policy.d.ts +18 -10
  62. package/dist/types/schema/routing.d.ts +14 -1
  63. package/dist/types/schema/system.d.ts +16 -6
  64. package/dist/types/schema/time-zones.d.ts +2 -0
  65. package/dist/types/transport/console.d.ts +79 -23
  66. package/dist/types/transport/path-check.d.ts +32 -0
  67. package/dist/types/transport/ssh.d.ts +17 -15
  68. package/package.json +18 -12
  69. package/src/cli/graph-format.ts +91 -0
  70. package/src/cli/index.ts +1074 -484
  71. package/src/cli/new.ts +208 -175
  72. package/src/cli/rename.ts +97 -91
  73. package/src/core/address-space.ts +320 -0
  74. package/src/core/address.ts +202 -0
  75. package/src/core/cabling.ts +154 -0
  76. package/src/core/cross-checks.ts +364 -0
  77. package/src/core/define.ts +26 -3
  78. package/src/core/project-files.ts +35 -0
  79. package/src/core/schema-docs.ts +138 -130
  80. package/src/core/secrets.ts +170 -76
  81. package/src/core/topology.ts +656 -0
  82. package/src/core/utils.ts +37 -0
  83. package/src/core/validator.ts +407 -0
  84. package/src/helpers/bgp.ts +301 -0
  85. package/src/helpers/checks.ts +178 -0
  86. package/src/helpers/exchange.ts +117 -0
  87. package/src/helpers/filters.ts +107 -0
  88. package/src/helpers/index.ts +12 -0
  89. package/src/helpers/route-server.ts +212 -0
  90. package/src/index.ts +6 -3
  91. package/src/integrations/librenms.ts +291 -0
  92. package/src/integrations/netbox.ts +620 -0
  93. package/src/integrations/peeringdb.ts +173 -0
  94. package/src/integrations/prometheus.ts +90 -0
  95. package/src/integrations/registries.ts +25 -0
  96. package/src/platforms/bgp-wait.ts +41 -0
  97. package/src/platforms/catalog.ts +69 -0
  98. package/src/platforms/derive.ts +117 -0
  99. package/src/{adapters/devices → platforms}/fortios/TRAPS.md +6 -0
  100. package/src/platforms/fortios/firewall.ts +377 -0
  101. package/src/platforms/fortios/index.ts +1060 -0
  102. package/src/platforms/fortios/names.ts +130 -0
  103. package/src/platforms/fortios/plan.ts +929 -0
  104. package/src/platforms/fortios/render.ts +779 -0
  105. package/src/platforms/fortios/routing.ts +476 -0
  106. package/src/platforms/fortios/tree.ts +129 -0
  107. package/src/platforms/index.ts +36 -0
  108. package/src/{adapters/devices → platforms}/junos/TRAPS.md +8 -0
  109. package/src/platforms/junos/bgp.ts +293 -0
  110. package/src/platforms/junos/index.ts +1006 -0
  111. package/src/platforms/junos/render.ts +967 -0
  112. package/src/platforms/line-diff.ts +244 -0
  113. package/src/platforms/platform.ts +122 -0
  114. package/src/platforms/raisecom-ros/TRAPS.md +40 -0
  115. package/src/platforms/raisecom-ros/index.ts +731 -0
  116. package/src/platforms/raisecom-ros/render.ts +328 -0
  117. package/src/{adapters/devices → platforms}/routeros/TRAPS.md +16 -2
  118. package/src/platforms/routeros/export.ts +342 -0
  119. package/src/platforms/routeros/index.ts +636 -0
  120. package/src/{adapters/devices → platforms}/routeros/models.ts +2 -0
  121. package/src/platforms/routeros/plan.ts +444 -0
  122. package/src/platforms/routeros/render.ts +1483 -0
  123. package/src/platforms/vrp/TRAPS.md +45 -0
  124. package/src/platforms/vrp/index.ts +652 -0
  125. package/src/platforms/vrp/render.ts +615 -0
  126. package/src/schema/common.ts +15 -6
  127. package/src/schema/index.ts +1 -0
  128. package/src/schema/interfaces.ts +15 -5
  129. package/src/schema/policy.ts +22 -10
  130. package/src/schema/routing.ts +16 -1
  131. package/src/schema/system.ts +16 -6
  132. package/src/schema/time-zones.ts +482 -0
  133. package/src/studio/data.ts +172 -168
  134. package/src/studio/git.ts +95 -90
  135. package/src/studio/public/app.js +161 -150
  136. package/src/studio/public/compare.js +219 -212
  137. package/src/studio/public/diff.js +77 -75
  138. package/src/studio/public/dom.js +118 -108
  139. package/src/studio/public/evaluate.js +584 -545
  140. package/src/studio/public/export.js +120 -118
  141. package/src/studio/public/graph.js +1343 -1266
  142. package/src/studio/public/net.js +115 -109
  143. package/src/studio/public/tables.js +1135 -1083
  144. package/src/studio/public/tools.js +561 -529
  145. package/src/studio/server.ts +202 -196
  146. package/src/transport/console.ts +363 -271
  147. package/src/transport/path-check.ts +91 -0
  148. package/src/transport/ssh.ts +98 -94
  149. package/templates/colocation/checks.ts +11 -6
  150. package/templates/colocation/edge-01.ts +9 -9
  151. package/templates/colocation/routing.ts +2 -2
  152. package/templates/colocation/tenancy.ts +3 -3
  153. package/templates/colocation/tor-01.ts +3 -3
  154. package/templates/edge-router/checks.ts +4 -4
  155. package/templates/edge-router/edge-01.ts +5 -5
  156. package/templates/edge-router/routing.ts +2 -2
  157. package/templates/project/AGENTS.md +5 -1
  158. package/templates/single-site/checks.ts +2 -2
  159. package/dist/chunks/index-47debt16.js +0 -956
  160. package/dist/chunks/index-v8zgr7wm.js +0 -128
  161. package/dist/chunks/new-ny89s7y7.js +0 -166
  162. package/dist/chunks/server-tjbb3rqc.js +0 -251
  163. package/dist/presets/index.js +0 -394
  164. package/dist/types/adapters/devices/catalog.d.ts +0 -86
  165. package/dist/types/adapters/devices/derive.d.ts +0 -7
  166. package/dist/types/adapters/devices/fortios/firewall.d.ts +0 -23
  167. package/dist/types/adapters/devices/fortios/index.d.ts +0 -58
  168. package/dist/types/adapters/devices/fortios/names.d.ts +0 -48
  169. package/dist/types/adapters/devices/fortios/plan.d.ts +0 -28
  170. package/dist/types/adapters/devices/fortios/render.d.ts +0 -38
  171. package/dist/types/adapters/devices/fortios/routing.d.ts +0 -32
  172. package/dist/types/adapters/devices/fortios/tree.d.ts +0 -33
  173. package/dist/types/adapters/devices/index.d.ts +0 -3
  174. package/dist/types/adapters/devices/junos/bgp.d.ts +0 -11
  175. package/dist/types/adapters/devices/junos/index.d.ts +0 -42
  176. package/dist/types/adapters/devices/junos/render.d.ts +0 -52
  177. package/dist/types/adapters/devices/lines.d.ts +0 -56
  178. package/dist/types/adapters/devices/raisecom-ros/index.d.ts +0 -28
  179. package/dist/types/adapters/devices/routeros/export.d.ts +0 -39
  180. package/dist/types/adapters/devices/routeros/index.d.ts +0 -30
  181. package/dist/types/adapters/devices/routeros/plan.d.ts +0 -4
  182. package/dist/types/adapters/devices/routeros/render.d.ts +0 -100
  183. package/dist/types/adapters/devices/types.d.ts +0 -40
  184. package/dist/types/adapters/devices/vrp/index.d.ts +0 -27
  185. package/dist/types/adapters/registries/index.d.ts +0 -3
  186. package/dist/types/adapters/registries/librenms.d.ts +0 -15
  187. package/dist/types/adapters/registries/peeringdb.d.ts +0 -16
  188. package/dist/types/adapters/registries/prometheus.d.ts +0 -16
  189. package/dist/types/core/across.d.ts +0 -4
  190. package/dist/types/core/addr.d.ts +0 -34
  191. package/dist/types/core/files.d.ts +0 -6
  192. package/dist/types/core/merge.d.ts +0 -4
  193. package/dist/types/core/time.d.ts +0 -2
  194. package/dist/types/core/validate.d.ts +0 -2
  195. package/dist/types/presets/index.d.ts +0 -183
  196. package/src/adapters/devices/catalog.ts +0 -41
  197. package/src/adapters/devices/derive.ts +0 -59
  198. package/src/adapters/devices/fortios/firewall.ts +0 -362
  199. package/src/adapters/devices/fortios/index.ts +0 -923
  200. package/src/adapters/devices/fortios/names.ts +0 -115
  201. package/src/adapters/devices/fortios/plan.ts +0 -888
  202. package/src/adapters/devices/fortios/render.ts +0 -768
  203. package/src/adapters/devices/fortios/routing.ts +0 -444
  204. package/src/adapters/devices/fortios/tree.ts +0 -121
  205. package/src/adapters/devices/index.ts +0 -9
  206. package/src/adapters/devices/junos/bgp.ts +0 -269
  207. package/src/adapters/devices/junos/index.ts +0 -862
  208. package/src/adapters/devices/junos/render.ts +0 -872
  209. package/src/adapters/devices/lines.ts +0 -212
  210. package/src/adapters/devices/raisecom-ros/TRAPS.md +0 -26
  211. package/src/adapters/devices/raisecom-ros/index.ts +0 -700
  212. package/src/adapters/devices/routeros/export.ts +0 -321
  213. package/src/adapters/devices/routeros/index.ts +0 -346
  214. package/src/adapters/devices/routeros/plan.ts +0 -398
  215. package/src/adapters/devices/routeros/render.ts +0 -1490
  216. package/src/adapters/devices/types.ts +0 -38
  217. package/src/adapters/devices/vrp/TRAPS.md +0 -26
  218. package/src/adapters/devices/vrp/index.ts +0 -865
  219. package/src/adapters/registries/index.ts +0 -19
  220. package/src/adapters/registries/librenms.ts +0 -92
  221. package/src/adapters/registries/peeringdb.ts +0 -169
  222. package/src/adapters/registries/prometheus.ts +0 -71
  223. package/src/cli/graph.ts +0 -89
  224. package/src/core/across.ts +0 -350
  225. package/src/core/addr.ts +0 -174
  226. package/src/core/addresses.ts +0 -316
  227. package/src/core/files.ts +0 -32
  228. package/src/core/graph.ts +0 -640
  229. package/src/core/merge.ts +0 -19
  230. package/src/core/time.ts +0 -15
  231. package/src/core/validate.ts +0 -348
  232. package/src/presets/index.ts +0 -579
  233. /package/dist/types/{adapters/devices → platforms}/fortios/models.d.ts +0 -0
  234. /package/dist/types/{adapters/devices → platforms}/junos/models.d.ts +0 -0
  235. /package/dist/types/{adapters/devices → platforms}/raisecom-ros/models.d.ts +0 -0
  236. /package/dist/types/{adapters/devices → platforms}/vrp/models.d.ts +0 -0
  237. /package/src/{adapters/devices → platforms}/fortios/models.ts +0 -0
  238. /package/src/{adapters/devices → platforms}/junos/models.ts +0 -0
  239. /package/src/{adapters/devices → platforms}/raisecom-ros/models.ts +0 -0
  240. /package/src/{adapters/devices → platforms}/vrp/models.ts +0 -0
@@ -4,9 +4,9 @@ import { spawn } from "node:child_process";
4
4
  import { EventEmitter } from "node:events";
5
5
  import { Duplex } from "node:stream";
6
6
  import { Client, type ClientChannel } from "ssh2";
7
- import { resolve } from "../core/secrets";
7
+ import { Secrets } from "../core/secrets";
8
8
  import type { Console, Device, Jump } from "../schema";
9
- import { exec } from "./ssh";
9
+ import { Ssh } from "./ssh";
10
10
 
11
11
  /** The relay, in Python's standard library only: the serial port in raw mode, locked against a second session, joined to standard input and output. */
12
12
  const RELAY = `import fcntl, os, select, sys, termios
@@ -52,12 +52,6 @@ while True:
52
52
  os.write(1, data)
53
53
  `;
54
54
 
55
- const RELAY_PATH = "/tmp/circuit-console.py";
56
-
57
- /** A serial port the relay is started with: a path under /dev, which reaches a shell, or a Windows COM port. */
58
- const PORT = /^\/dev\/[\w./:+-]+$/;
59
- const WINDOWS_PORT = /^COM\d+$/i;
60
-
61
55
  /** The relay for a console on this computer under Windows, in PowerShell, which has no termios but has .NET's serial port. */
62
56
  const WINDOWS_RELAY = `
63
57
  $port = New-Object System.IO.Ports.SerialPort $env:CIRCUIT_PORT, ([int]$env:CIRCUIT_BAUD), 'None', 8, 'One'
@@ -86,326 +80,374 @@ while ($true) {
86
80
  $port.Close()
87
81
  `;
88
82
 
89
- /** A session to a jump host, through `cloudflared access ssh` when it sits behind a Cloudflare Tunnel. */
90
- function connectJump(jump: Jump): Promise<Client> {
91
- return new Promise((done, fail) => {
92
- const client = new Client();
93
- const login = { username: jump.user, password: resolve(jump.password), readyTimeout: 60_000 };
83
+ /** An open console: the stream to the device's CLI, and how to let it go. `close` waits for the console to go quiet, then lets the port go. */
84
+ export type ConsoleSession = { channel: ClientChannel; close: () => Promise<void> };
85
+
86
+ /** How a platform's console asks for a login and shows its prompt, and what moves it on from anywhere else. */
87
+ export type ConsoleDialect = {
88
+ /** The login prompt. */
89
+ login: RegExp;
90
+ /** The password prompt. */
91
+ password: RegExp;
92
+ /** The CLI's prompt once logged in. */
93
+ prompt: RegExp;
94
+ /** What logs out from the prompt. */
95
+ logout: string;
96
+ /** Other places a console can be left, each with what moves it toward a login prompt: a pager, a config level, a question. Checked before the prompt. */
97
+ others?: readonly (readonly [RegExp, string])[];
98
+ /** The name typed at the login prompt, when the platform adds to it. */
99
+ user?: (user: string) => string;
100
+ /** What the console prints when it refuses a password, for a console that then shows no login prompt. */
101
+ refused?: RegExp;
102
+ };
103
+
104
+ /** The channel a console speaks on: an SSH stream from the relay, or anything shaped like one. */
105
+ type Channel = Pick<ClientChannel, "write"> & {
106
+ on(event: "data", listener: (chunk: Buffer) => void): unknown;
107
+ removeListener(event: "data", listener: (chunk: Buffer) => void): unknown;
108
+ };
94
109
 
95
- client.on("ready", () => done(client)).on("error", fail);
110
+ export class SerialConsole {
111
+ private static readonly RELAY_PATH = "/tmp/circuit-console.py";
96
112
 
97
- if (!jump.cloudflared) {
98
- client.connect({ ...login, host: jump.host, port: jump.port ?? 22 });
99
- return;
100
- }
113
+ /** A serial port the relay is started with: a path under /dev, which reaches a shell, or a Windows COM port. */
114
+ private static readonly PORT = /^\/dev\/[\w./:+-]+$/;
115
+ private static readonly WINDOWS_PORT = /^COM\d+$/i;
101
116
 
102
- const tunnel = spawn("cloudflared", ["access", "ssh", "--hostname", jump.host], { stdio: ["pipe", "pipe", "ignore"] });
103
- tunnel.on("error", (error) => fail(new Error(`cloudflared did not start: ${error.message}`)));
104
- client.on("close", () => tunnel.kill());
117
+ /** Terminal escape sequences: cursor moves, screen clears, character sets and modes. */
118
+ private static readonly ESCAPES = /\u001b(?:\[[0-9;?]*[A-Za-z]|[()][A-Z0-9]|[=>78c])/g;
105
119
 
106
- client.connect({ ...login, sock: Duplex.from({ readable: tunnel.stdout, writable: tunnel.stdin }) });
107
- });
108
- }
120
+ /** A session to a jump host, through `cloudflared access ssh` when it sits behind a Cloudflare Tunnel. */
121
+ private static connectJump(jump: Jump): Promise<Client> {
122
+ return new Promise((done, fail) => {
123
+ const client = new Client();
124
+ const login = { username: jump.user, password: Secrets.reveal(jump.password), readyTimeout: 60_000 };
125
+
126
+ client.on("ready", () => done(client)).on("error", fail);
127
+
128
+ if (!jump.cloudflared) {
129
+ client.connect({ ...login, host: jump.host, port: jump.port ?? 22 });
130
+ return;
131
+ }
109
132
 
110
- /** An open console: the stream to the device's CLI, and how to let it go. */
111
- export type ConsoleSession = { channel: ClientChannel; close: () => void };
133
+ const tunnel = spawn("cloudflared", ["access", "ssh", "--hostname", jump.host], { stdio: ["pipe", "pipe", "ignore"] });
134
+ tunnel.on("error", (error) => fail(new Error(`cloudflared did not start: ${error.message}`)));
135
+ client.on("close", () => tunnel.kill());
112
136
 
113
- /** Open a device's serial console, through its jump host or on this computer. Fails when another session holds the port. */
114
- export async function openConsole(console: Console): Promise<ConsoleSession> {
115
- const windows = !console.jump && process.platform === "win32";
116
- if (windows ? !WINDOWS_PORT.test(console.port) : !PORT.test(console.port)) {
117
- throw new Error(`console port ${console.port} is not ${windows ? "a COM port" : "a path under /dev"}`);
137
+ client.connect({ ...login, sock: Duplex.from({ readable: tunnel.stdout, writable: tunnel.stdin }) });
138
+ });
118
139
  }
119
- if (!Number.isInteger(console.baud) || console.baud <= 0) throw new Error(`console baud ${console.baud} is not a line speed`);
120
140
 
121
- // The relay of a session that just ended can hold the port a few seconds more, until its host sees it gone.
122
- for (let attempt = 1; ; attempt++) {
123
- try {
124
- return await (console.jump ? openOnJump(console, console.jump) : openHere(console));
125
- } catch (error) {
126
- if (attempt >= 5 || !/busy:/.test((error as Error).message)) throw error;
127
- await new Promise((done) => setTimeout(done, 5_000));
141
+ /**
142
+ * Open a device's serial console, through its jump host or on this computer.
143
+ *
144
+ * A port another program holds is tried again every five seconds, `attempts` times in all, then fails with a message that starts with `busy:`.
145
+ */
146
+ static async open(console: Console, attempts = 5): Promise<ConsoleSession> {
147
+ const windows = !console.jump && process.platform === "win32";
148
+ if (windows ? !SerialConsole.WINDOWS_PORT.test(console.port) : !SerialConsole.PORT.test(console.port)) {
149
+ throw new Error(`console port ${console.port} is not ${windows ? "a COM port" : "a path under /dev"}`);
150
+ }
151
+ if (!Number.isInteger(console.baud) || console.baud <= 0) throw new Error(`console baud ${console.baud} is not a line speed`);
152
+
153
+ // The relay of a session that just ended can hold the port a few seconds more, until its host sees it gone.
154
+ for (let attempt = 1; ; attempt++) {
155
+ try {
156
+ return await (console.jump ? SerialConsole.openOnJump(console, console.jump) : SerialConsole.openHere(console));
157
+ } catch (error) {
158
+ const busy = /busy: .*/.exec((error as Error).message);
159
+ if (busy && attempt >= attempts) throw new Error(busy[0]);
160
+ if (!busy) throw error;
161
+ await new Promise((done) => setTimeout(done, 5_000));
162
+ }
128
163
  }
129
164
  }
130
- }
131
165
 
132
- async function openOnJump(console: Console, jump: Jump): Promise<ConsoleSession> {
133
- const client = await connectJump(jump);
166
+ private static async openOnJump(console: Console, jump: Jump): Promise<ConsoleSession> {
167
+ const client = await SerialConsole.connectJump(jump);
134
168
 
135
- try {
136
- const encoded = Buffer.from(RELAY).toString("base64");
137
- await exec(client, `sh -c 'printf %s ${encoded} | base64 -d > ${RELAY_PATH}'`, 30_000);
169
+ try {
170
+ const encoded = Buffer.from(RELAY).toString("base64");
171
+ await Ssh.exec(client, `sh -c 'printf %s ${encoded} | base64 -d > ${SerialConsole.RELAY_PATH}'`, 30_000);
138
172
 
139
- const channel = await new Promise<ClientChannel>((done, fail) => {
140
- client.exec(`python3 ${RELAY_PATH} ${console.port} ${console.baud}`, (error, stream) => (error ? fail(error) : done(stream)));
173
+ const channel = await new Promise<ClientChannel>((done, fail) => {
174
+ client.exec(`python3 ${SerialConsole.RELAY_PATH} ${console.port} ${console.baud}`, (error, stream) =>
175
+ error ? fail(error) : done(stream)
176
+ );
177
+ });
178
+
179
+ await SerialConsole.relayReady(channel);
180
+
181
+ return {
182
+ channel: SerialConsole.asTerminal(channel),
183
+ // What was written last, such as a logout, is still on its way to the device; the relay ends once the console goes quiet.
184
+ close: () =>
185
+ SerialConsole.quiet(channel, 1_000).then(() => {
186
+ channel.end();
187
+ client.end();
188
+ }),
189
+ };
190
+ } catch (error) {
191
+ client.end();
192
+ throw error;
193
+ }
194
+ }
195
+
196
+ /** The relay as a child process: Python on Linux and macOS, PowerShell on Windows. */
197
+ private static async openHere(console: Console): Promise<ConsoleSession> {
198
+ const child =
199
+ process.platform === "win32"
200
+ ? spawn("powershell", ["-NoProfile", "-NonInteractive", "-Command", WINDOWS_RELAY], {
201
+ env: { ...process.env, CIRCUIT_PORT: console.port, CIRCUIT_BAUD: String(console.baud) },
202
+ })
203
+ : spawn("python3", ["-c", RELAY, console.port, String(console.baud)]);
204
+
205
+ // The child's streams, in the shape an SSH channel has.
206
+ const channel = Object.assign(new EventEmitter(), {
207
+ stderr: child.stderr,
208
+ write: (data: string | Buffer) => child.stdin.write(data),
209
+ end: () => child.stdin.end(),
141
210
  });
211
+ child.stdout.on("data", (chunk: Buffer) => channel.emit("data", chunk));
212
+ child.on("close", () => channel.emit("close"));
213
+ child.on("error", (error) => channel.emit("close", error));
214
+
215
+ const asChannel = channel as unknown as ClientChannel;
142
216
 
143
- await relayReady(channel);
217
+ try {
218
+ await SerialConsole.relayReady(asChannel);
219
+ } catch (error) {
220
+ child.kill();
221
+ throw error;
222
+ }
144
223
 
145
224
  return {
146
- channel: asTerminal(channel),
147
- // What was written last, such as a logout, is still on its way to the device; the relay ends once the console goes quiet.
148
- close: () => {
149
- void quiet(channel, 1_000).then(() => {
225
+ channel: SerialConsole.asTerminal(asChannel),
226
+ close: () =>
227
+ SerialConsole.quiet(asChannel, 1_000).then(() => {
150
228
  channel.end();
151
- client.end();
152
- });
153
- },
229
+ setTimeout(() => child.kill(), 2_000).unref();
230
+ }),
154
231
  };
155
- } catch (error) {
156
- client.end();
157
- throw error;
158
- }
159
- }
160
-
161
- /** The relay as a child process: Python on Linux and macOS, PowerShell on Windows. */
162
- async function openHere(console: Console): Promise<ConsoleSession> {
163
- const child =
164
- process.platform === "win32"
165
- ? spawn("powershell", ["-NoProfile", "-NonInteractive", "-Command", WINDOWS_RELAY], {
166
- env: { ...process.env, CIRCUIT_PORT: console.port, CIRCUIT_BAUD: String(console.baud) },
167
- })
168
- : spawn("python3", ["-c", RELAY, console.port, String(console.baud)]);
169
-
170
- // The child's streams, in the shape an SSH channel has.
171
- const channel = Object.assign(new EventEmitter(), {
172
- stderr: child.stderr,
173
- write: (data: string | Buffer) => child.stdin.write(data),
174
- end: () => child.stdin.end(),
175
- });
176
- child.stdout.on("data", (chunk: Buffer) => channel.emit("data", chunk));
177
- child.on("close", () => channel.emit("close"));
178
- child.on("error", (error) => channel.emit("close", error));
179
-
180
- const asChannel = channel as unknown as ClientChannel;
181
-
182
- try {
183
- await relayReady(asChannel);
184
- } catch (error) {
185
- child.kill();
186
- throw error;
187
232
  }
188
233
 
189
- return {
190
- channel: asTerminal(asChannel),
191
- close: () => {
192
- void quiet(asChannel, 1_000).then(() => {
193
- channel.end();
194
- setTimeout(() => child.kill(), 2_000).unref();
234
+ /** Wait until the relay says it holds the port, or why it cannot. */
235
+ private static relayReady(channel: ClientChannel): Promise<void> {
236
+ return new Promise((done, fail) => {
237
+ let said = "";
238
+ const limit = setTimeout(() => fail(new Error(`the console relay did not start: ${said.trim()}`)), 30_000);
239
+
240
+ channel.stderr.on("data", (chunk: Buffer) => {
241
+ said += chunk.toString("utf8");
242
+ if (said.includes("ready")) {
243
+ clearTimeout(limit);
244
+ done();
245
+ }
195
246
  });
196
- },
197
- };
198
- }
199
-
200
- /** Wait until the relay says it holds the port, or why it cannot. */
201
- function relayReady(channel: ClientChannel): Promise<void> {
202
- return new Promise((done, fail) => {
203
- let said = "";
204
- const limit = setTimeout(() => fail(new Error(`the console relay did not start: ${said.trim()}`)), 30_000);
205
-
206
- channel.stderr.on("data", (chunk: Buffer) => {
207
- said += chunk.toString("utf8");
208
- if (said.includes("ready")) {
247
+ channel.on("close", () => {
209
248
  clearTimeout(limit);
210
- done();
211
- }
212
- });
213
- channel.on("close", () => {
214
- clearTimeout(limit);
215
- fail(new Error(`the console relay stopped: ${said.trim() || "no reason given"}`));
249
+ fail(new Error(`the console relay stopped: ${said.trim() || "no reason given"}`));
250
+ });
216
251
  });
217
- });
218
- }
219
-
220
- /** A terminal ends a line with a carriage return. Adapters write lines as they do over SSH, and the console gets what a keyboard would send. */
221
- function asTerminal(channel: ClientChannel): ClientChannel {
222
- const write = channel.write.bind(channel);
223
- channel.write = ((data: unknown, ...rest: unknown[]) =>
224
- write(typeof data === "string" ? data.replace(/\r?\n/g, "\r") : data, ...(rest as []))) as typeof channel.write;
225
- return channel;
226
- }
252
+ }
227
253
 
228
- /** A device's console and its login, for `--console`. */
229
- export function consoleOf(device: Device) {
230
- const console = device.connection.console;
231
- if (!console) throw new Error(`${device.name}: no connection.console`);
254
+ /** A terminal ends a line with a carriage return. Adapters write lines as they do over SSH, and the console gets what a keyboard would send. */
255
+ private static asTerminal(channel: ClientChannel): ClientChannel {
256
+ const write = channel.write.bind(channel);
257
+ channel.write = ((data: unknown, ...rest: unknown[]) =>
258
+ write(typeof data === "string" ? data.replace(/\r?\n/g, "\r") : data, ...(rest as []))) as typeof channel.write;
259
+ return channel;
260
+ }
232
261
 
233
- const user = device.users?.[device.connection.user];
234
- if (!user) throw new Error(`${device.name}: connection user ${device.connection.user} is not one of its users`);
262
+ /** A device's console and its login, for `--console`. */
263
+ static of(device: Device) {
264
+ const { console, user, password } = Secrets.loginOf(device);
265
+ if (!console) throw new Error(`${device.name}: no connection.console`);
235
266
 
236
- return { console, user: device.connection.user as string, password: resolve(user.password) };
237
- }
267
+ return { console, user, password };
268
+ }
238
269
 
239
- /** How a platform's console asks for a login and shows its prompt, and what moves it on from anywhere else. */
240
- export type ConsoleDialect = {
241
- /** The login prompt. */
242
- login: RegExp;
243
- /** The password prompt. */
244
- password: RegExp;
245
- /** The CLI's prompt once logged in. */
246
- prompt: RegExp;
247
- /** What logs out from the prompt. */
248
- logout: string;
249
- /** Other places a console can be left, each with what moves it toward a login prompt: a pager, a config level, a question. Checked before the prompt. */
250
- others?: readonly (readonly [RegExp, string])[];
251
- /** The name typed at the login prompt, when the platform adds to it. */
252
- user?: (user: string) => string;
253
- /** What the console prints when it refuses a password, for a console that then shows no login prompt. */
254
- refused?: RegExp;
255
- };
270
+ /**
271
+ * Write, then wait until what the console printed since ends with one of the patterns. Returns which, and what was printed.
272
+ *
273
+ * With `settle`, a match counts only once nothing more arrives for that many milliseconds, so a line in the middle of the output that looks like a prompt does not end it.
274
+ */
275
+ static expect(channel: Channel, write: string, patterns: readonly RegExp[], timeout = 30_000, settle = 0) {
276
+ return new Promise<{ index: number; text: string }>((done, fail) => {
277
+ let text = "";
278
+ let settled: ReturnType<typeof setTimeout> | undefined;
279
+
280
+ const stop = () => {
281
+ clearTimeout(limit);
282
+ clearTimeout(settled);
283
+ channel.removeListener("data", onData);
284
+ };
285
+
286
+ const onData = (chunk: Buffer) => {
287
+ // A terminal's escape sequences, which clear the screen or move the cursor, say nothing a reader needs.
288
+ text += chunk.toString("utf8").replace(/\r/g, "").replace(SerialConsole.ESCAPES, "");
289
+ clearTimeout(settled);
290
+
291
+ const index = patterns.findIndex((pattern) => pattern.test(text));
292
+ if (index < 0) return;
293
+
294
+ const finish = () => {
295
+ stop();
296
+ done({ index, text });
297
+ };
298
+
299
+ if (settle) settled = setTimeout(finish, settle);
300
+ else finish();
301
+ };
302
+
303
+ const limit = setTimeout(() => {
304
+ stop();
305
+ fail(new Error(`the console did not answer: ${text.slice(-200)}`));
306
+ }, timeout);
307
+
308
+ channel.on("data", onData);
309
+ if (write) channel.write(write);
310
+ });
311
+ }
256
312
 
257
- /** Terminal escape sequences: cursor moves, screen clears, character sets and modes. */
258
- const ESCAPES = /\u001b(?:\[[0-9;?]*[A-Za-z]|[()][A-Z0-9]|[=>78c])/g;
313
+ /**
314
+ * Log in on a console, whatever state the last session left it in: what `others` names is moved on, a session left open is logged out, so every session starts from a fresh login. Returns once the CLI's prompt shows. A console that asks only for a password (`passwordOnly`) is logged in at its password prompt, with no name typed.
315
+ */
316
+ static async login(channel: Channel, dialect: ConsoleDialect, user: string, password: string, passwordOnly = false): Promise<void> {
317
+ const others = dialect.others ?? [];
318
+ const states = [dialect.login, dialect.password, ...others.map(([pattern]) => pattern), dialect.prompt];
319
+
320
+ // What to send in each state to get closer to a login prompt: Enter at a password prompt fails the login and asks again.
321
+ const toward = (index: number) => {
322
+ if (index === 1) return "\n";
323
+ if (index < 2 + others.length) return others[index - 2]![1];
324
+ return `${dialect.logout}\n`;
325
+ };
259
326
 
260
- /** The channel a console speaks on: an SSH stream from the relay, or anything shaped like one. */
261
- type Channel = Pick<ClientChannel, "write"> & {
262
- on(event: "data", listener: (chunk: Buffer) => void): unknown;
263
- removeListener(event: "data", listener: (chunk: Buffer) => void): unknown;
264
- };
327
+ // What the last session printed as it left, such as a login prompt after its logout, arrives late; it must not be taken for an answer.
328
+ await SerialConsole.quiet(channel, 1_000);
265
329
 
266
- /** Write, then wait until what the console printed since ends with one of the patterns. Returns which, and what was printed. */
267
- export function expect(channel: Channel, write: string, patterns: readonly RegExp[], timeout = 30_000) {
268
- return new Promise<{ index: number; text: string }>((done, fail) => {
269
- let text = "";
330
+ // The prompt a fresh login starts at: the login prompt, or the password prompt when there is no name to type.
331
+ const start = passwordOnly ? 1 : 0;
270
332
 
271
- const stop = () => {
272
- clearTimeout(limit);
273
- channel.removeListener("data", onData);
274
- };
333
+ let state = (await SerialConsole.expect(channel, "\n", states)).index;
334
+ for (let step = 0; state !== start; step++) {
335
+ if (passwordOnly && state === 0)
336
+ throw new Error("the console asks for a name, but its console is set to ask only for a password");
337
+ if (step >= 10) throw new Error(`the console never reached a ${passwordOnly ? "password" : "login"} prompt`);
338
+ state = (await SerialConsole.expect(channel, toward(state), states)).index;
339
+ }
275
340
 
276
- const onData = (chunk: Buffer) => {
277
- // A terminal's escape sequences, which clear the screen or move the cursor, say nothing a reader needs.
278
- text += chunk.toString("utf8").replace(/\r/g, "").replace(ESCAPES, "");
341
+ // A console can print the login prompt more than once; a name typed while it does is lost.
342
+ await SerialConsole.quiet(channel, 1_000);
279
343
 
280
- const index = patterns.findIndex((pattern) => pattern.test(text));
281
- if (index < 0) return;
344
+ if (!passwordOnly) {
345
+ const name = dialect.user ? dialect.user(user) : user;
346
+ if ((await SerialConsole.expect(channel, `${name}\n`, [dialect.password, dialect.login, dialect.prompt])).index !== 0) {
347
+ throw new Error("the console did not ask for a password");
348
+ }
349
+ }
282
350
 
283
- stop();
284
- done({ index, text });
285
- };
351
+ // A platform may ask something once logged in, such as whether to read a licence; `others` answers it. A refusal is checked before `others`, since the banner that follows it matches one of them.
352
+ const refusal = dialect.refused ? [dialect.refused] : [];
353
+ const first = 2 + refusal.length;
354
+ const answers = [dialect.prompt, dialect.login, ...refusal, ...others.map(([pattern]) => pattern)];
286
355
 
287
- const limit = setTimeout(() => {
288
- stop();
289
- fail(new Error(`the console did not answer: ${text.slice(-200)}`));
290
- }, timeout);
356
+ let answer = await SerialConsole.expect(channel, `${password}\n`, answers, 60_000);
357
+ for (let step = 0; answer.index >= first; step++) {
358
+ if (step >= 5) throw new Error("the console kept asking after the login");
359
+ answer = await SerialConsole.expect(channel, others[answer.index - first]![1], answers);
360
+ }
291
361
 
292
- channel.on("data", onData);
293
- if (write) channel.write(write);
294
- });
295
- }
362
+ if (answer.index === 1) throw new Error(`the console refused the login of ${user}`);
363
+ if (answer.index === 2 && dialect.refused) throw new Error("the console refused the password");
296
364
 
297
- /**
298
- * Log in on a console, whatever state the last session left it in: what `others` names is moved on, a session left open is logged out, so every session starts from a fresh login. Returns once the CLI's prompt shows. A console that asks only for a password (`passwordOnly`) is logged in at its password prompt, with no name typed.
299
- */
300
- export async function consoleLogin(
301
- channel: Channel,
302
- dialect: ConsoleDialect,
303
- user: string,
304
- password: string,
305
- passwordOnly = false
306
- ): Promise<void> {
307
- const others = dialect.others ?? [];
308
- const states = [dialect.login, dialect.password, ...others.map(([pattern]) => pattern), dialect.prompt];
309
-
310
- // What to send in each state to get closer to a login prompt: Enter at a password prompt fails the login and asks again.
311
- const toward = (index: number) => {
312
- if (index === 1) return "\n";
313
- if (index < 2 + others.length) return others[index - 2]![1];
314
- return `${dialect.logout}\n`;
315
- };
316
-
317
- // What the last session printed as it left, such as a login prompt after its logout, arrives late; it must not be taken for an answer.
318
- await quiet(channel, 1_000);
319
-
320
- // The prompt a fresh login starts at: the login prompt, or the password prompt when there is no name to type.
321
- const start = passwordOnly ? 1 : 0;
322
-
323
- let state = (await expect(channel, "\n", states)).index;
324
- for (let step = 0; state !== start; step++) {
325
- if (passwordOnly && state === 0) throw new Error("the console asks for a name, but its console is set to ask only for a password");
326
- if (step >= 10) throw new Error(`the console never reached a ${passwordOnly ? "password" : "login"} prompt`);
327
- state = (await expect(channel, toward(state), states)).index;
365
+ // Some consoles print the prompt twice after a login; what arrives late must not reach the first command.
366
+ await SerialConsole.quiet(channel, 800);
328
367
  }
329
368
 
330
- // A console can print the login prompt more than once; a name typed while it does is lost.
331
- await quiet(channel, 1_000);
369
+ /** Wait until the console has printed nothing for `silence` milliseconds, dropping what it printed, at most five seconds. */
370
+ private static quiet(channel: Channel, silence: number): Promise<void> {
371
+ return new Promise((done) => {
372
+ let timer = setTimeout(finish, silence);
373
+ const limit = setTimeout(finish, 5_000);
332
374
 
333
- if (!passwordOnly) {
334
- const name = dialect.user ? dialect.user(user) : user;
335
- if ((await expect(channel, `${name}\n`, [dialect.password, dialect.login, dialect.prompt])).index !== 0) {
336
- throw new Error("the console did not ask for a password");
337
- }
338
- }
375
+ function finish() {
376
+ clearTimeout(timer);
377
+ clearTimeout(limit);
378
+ channel.removeListener("data", onData);
379
+ done();
380
+ }
339
381
 
340
- // A platform may ask something once logged in, such as whether to read a licence; `others` answers it. A refusal is checked before `others`, since the banner that follows it matches one of them.
341
- const refusal = dialect.refused ? [dialect.refused] : [];
342
- const first = 2 + refusal.length;
343
- const answers = [dialect.prompt, dialect.login, ...refusal, ...others.map(([pattern]) => pattern)];
382
+ function onData() {
383
+ clearTimeout(timer);
384
+ timer = setTimeout(finish, silence);
385
+ }
344
386
 
345
- let answer = await expect(channel, `${password}\n`, answers, 60_000);
346
- for (let step = 0; answer.index >= first; step++) {
347
- if (step >= 5) throw new Error("the console kept asking after the login");
348
- answer = await expect(channel, others[answer.index - first]![1], answers);
387
+ channel.on("data", onData);
388
+ });
349
389
  }
350
390
 
351
- if (answer.index === 1) throw new Error(`the console refused the login of ${user}`);
352
- if (answer.index === 2 && dialect.refused) throw new Error("the console refused the password");
391
+ /** Write, then collect what the console prints until it is quiet for `silence` milliseconds, or for `limit` in all. Carriage returns and escape sequences are dropped. */
392
+ static listen(channel: Channel, write: string, silence: number, limit = 300_000): Promise<string> {
393
+ return new Promise((done) => {
394
+ let text = "";
395
+ let timer = setTimeout(finish, silence);
396
+ const cap = setTimeout(finish, limit);
397
+
398
+ function finish() {
399
+ clearTimeout(timer);
400
+ clearTimeout(cap);
401
+ channel.removeListener("data", onData);
402
+ done(text);
403
+ }
353
404
 
354
- // Some consoles print the prompt twice after a login; what arrives late must not reach the first command.
355
- await quiet(channel, 800);
356
- }
405
+ function onData(chunk: Buffer) {
406
+ text += chunk.toString("utf8").replace(/\r/g, "").replace(SerialConsole.ESCAPES, "");
407
+ clearTimeout(timer);
408
+ timer = setTimeout(finish, silence);
409
+ }
357
410
 
358
- /** Wait until the console has printed nothing for `silence` milliseconds, dropping what it printed, at most five seconds. */
359
- function quiet(channel: Channel, silence: number): Promise<void> {
360
- return new Promise((done) => {
361
- let timer = setTimeout(finish, silence);
362
- const limit = setTimeout(finish, 5_000);
363
-
364
- function finish() {
365
- clearTimeout(timer);
366
- clearTimeout(limit);
367
- channel.removeListener("data", onData);
368
- done();
369
- }
411
+ channel.on("data", onData);
412
+ if (write) channel.write(write);
413
+ });
414
+ }
370
415
 
371
- function onData() {
372
- clearTimeout(timer);
373
- timer = setTimeout(finish, silence);
416
+ /** The text after a command's echo. A terminal wraps a long echo where it likes, so the echo is matched character by character, line breaks it added skipped. */
417
+ static afterEcho(text: string, command: string): string {
418
+ // The echo can follow a prompt that arrived late, so it is looked for wherever the command's first character is.
419
+ for (let start = text.indexOf(command[0] ?? ""); start >= 0; start = text.indexOf(command[0]!, start + 1)) {
420
+ const end = SerialConsole.echoEnd(text, command, start);
421
+ if (end !== undefined) return text.slice(end).replace(/^\r?\n/, "");
374
422
  }
375
423
 
376
- channel.on("data", onData);
377
- });
378
- }
379
-
380
- /** The text after a command's echo. A terminal wraps a long echo where it likes, so the echo is matched character by character, line breaks it added skipped. */
381
- export function afterEcho(text: string, command: string): string {
382
- // The echo can follow a prompt that arrived late, so it is looked for wherever the command's first character is.
383
- for (let start = text.indexOf(command[0] ?? ""); start >= 0; start = text.indexOf(command[0]!, start + 1)) {
384
- const end = echoEnd(text, command, start);
385
- if (end !== undefined) return text.slice(end).replace(/^\r?\n/, "");
424
+ return text;
386
425
  }
387
426
 
388
- return text;
389
- }
427
+ /** Where the echo of a command that starts at `start` ends, or undefined when the text there is not its echo. */
428
+ private static echoEnd(text: string, command: string, start: number): number | undefined {
429
+ let at = start;
390
430
 
391
- /** Where the echo of a command that starts at `start` ends, or undefined when the text there is not its echo. */
392
- function echoEnd(text: string, command: string, start: number): number | undefined {
393
- let at = start;
431
+ for (const character of command) {
432
+ while (at < text.length && text[at] !== character && /[\r\n]/.test(text[at]!)) at++;
433
+ if (text[at] !== character) return undefined;
434
+ at++;
435
+ }
394
436
 
395
- for (const character of command) {
396
- while (at < text.length && text[at] !== character && /[\r\n]/.test(text[at]!)) at++;
397
- if (text[at] !== character) return undefined;
398
- at++;
437
+ return at;
399
438
  }
400
-
401
- return at;
402
439
  }
403
440
 
404
- /** A CLI on a console that runs one command at a time and returns what it printed, as an SSH exec would: no echo, no prompt. */
441
+ /** A CLI on a console or in an SSH shell that runs one command at a time and returns what it printed, as an SSH exec would: no echo, no prompt. */
405
442
  export class ConsoleShell {
443
+ /** The quiet to wait for after the prompt when output can hold a line that looks like it, as `circuit show` prints whatever the device has. */
444
+ static readonly SETTLE = 1_000;
445
+
406
446
  constructor(
407
447
  private readonly channel: Channel,
408
- private readonly dialect: ConsoleDialect
448
+ private readonly dialect: ConsoleDialect,
449
+ /** Milliseconds of quiet after the prompt shows before a command counts as done. */
450
+ private readonly settle = 0
409
451
  ) {}
410
452
 
411
453
  /** Run a command and wait for the prompt. A pager in `others` is answered as it shows. */
@@ -413,18 +455,18 @@ export class ConsoleShell {
413
455
  const pagers = (this.dialect.others ?? []).filter(([, reply]) => !reply.endsWith("\n"));
414
456
  const patterns = [this.dialect.prompt, ...pagers.map(([pattern]) => pattern)];
415
457
 
416
- let { index, text } = await expect(this.channel, `${command}\n`, patterns, timeout);
458
+ let { index, text } = await SerialConsole.expect(this.channel, `${command}\n`, patterns, timeout, this.settle);
417
459
  let output = "";
418
460
 
419
461
  // A pager stops the output; its answer goes on without a new line, and the pager's own text is dropped.
420
462
  while (index > 0) {
421
463
  const [pattern, reply] = pagers[index - 1]!;
422
464
  output += text.replace(pattern, "");
423
- ({ index, text } = await expect(this.channel, reply, patterns, timeout));
465
+ ({ index, text } = await SerialConsole.expect(this.channel, reply, patterns, timeout, this.settle));
424
466
  }
425
467
  output += text;
426
468
 
427
- const lines = afterEcho(output, command).split("\n");
469
+ const lines = SerialConsole.afterEcho(output, command).split("\n");
428
470
  return lines.slice(0, -1).join("\n");
429
471
  }
430
472
 
@@ -433,3 +475,53 @@ export class ConsoleShell {
433
475
  this.channel.write(`${this.dialect.logout}\n`);
434
476
  }
435
477
  }
478
+
479
+ /** One line for `circuit console`: what to send, the quiet that ends its answer, and the text the last answer must end with for the line to be sent. */
480
+ export type ScriptLine = { text: string; quiet: number; onlyAfter?: string };
481
+
482
+ /**
483
+ * Lines sent to a console one at a time, each answer read until the console goes quiet, as a person would type them.
484
+ *
485
+ * A line may start with `@if=TEXT=>`: it is sent only when the last answer ends with TEXT, for a prompt that may or may not come. Then, or alone, `@wait=N:` waits for N seconds of quiet instead of the usual 1.5, for a slow command.
486
+ */
487
+ export class ConsoleScript {
488
+ /** The quiet that ends an answer, in milliseconds. */
489
+ static readonly QUIET = 1_500;
490
+
491
+ /** A line as written, with `quiet` for a line that has no `@wait=`. */
492
+ static parse(line: string, quiet = ConsoleScript.QUIET): ScriptLine {
493
+ let text = line;
494
+ let onlyAfter: string | undefined;
495
+
496
+ const condition = /^@if=(.*?)=>/s.exec(text);
497
+ if (condition) {
498
+ onlyAfter = condition[1]!;
499
+ text = text.slice(condition[0].length);
500
+ }
501
+
502
+ const wait = /^@wait=(\d+(?:\.\d+)?):/.exec(text);
503
+ if (wait) {
504
+ text = text.slice(wait[0].length);
505
+ }
506
+
507
+ const silence = wait ? Number(wait[1]) * 1000 : quiet;
508
+ return { text, quiet: silence, ...(onlyAfter === undefined ? {} : { onlyAfter }) };
509
+ }
510
+
511
+ /** Send each line and hand each answer to `answered` as it comes. A line whose condition does not hold is skipped. */
512
+ static async run(
513
+ channel: Channel,
514
+ lines: readonly string[],
515
+ answered: (answer: string) => void,
516
+ quiet = ConsoleScript.QUIET
517
+ ): Promise<void> {
518
+ let last = "";
519
+
520
+ for (const line of lines.map((written) => ConsoleScript.parse(written, quiet))) {
521
+ if (line.onlyAfter !== undefined && !last.trimEnd().endsWith(line.onlyAfter.trimEnd())) continue;
522
+
523
+ last = await SerialConsole.listen(channel, `${line.text}\n`, line.quiet);
524
+ answered(last);
525
+ }
526
+ }
527
+ }