@takodotid/circuit 0.34.3 → 0.35.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.
- package/README.md +13 -14
- package/dist/chunks/index-20kcjxjg.js +174 -0
- package/dist/chunks/index-2r4bbhac.js +977 -0
- package/dist/chunks/index-n7w3tg5y.js +149 -0
- package/dist/chunks/new-w3zys5y4.js +172 -0
- package/dist/chunks/server-gzsf5me5.js +259 -0
- package/dist/cli/index.js +11345 -9024
- package/dist/helpers/index.js +594 -0
- package/dist/index.js +9 -28
- package/dist/types/core/{addresses.d.ts → address-space.d.ts} +23 -7
- package/dist/types/core/address.d.ts +44 -0
- package/dist/types/core/cabling.d.ts +41 -0
- package/dist/types/core/cross-checks.d.ts +28 -0
- package/dist/types/core/define.d.ts +25 -3
- package/dist/types/core/project-files.d.ts +12 -0
- package/dist/types/core/schema-docs.d.ts +13 -4
- package/dist/types/core/secrets.d.ts +62 -21
- package/dist/types/core/{graph.d.ts → topology.d.ts} +32 -12
- package/dist/types/core/utils.d.ts +8 -0
- package/dist/types/core/validator.d.ts +19 -0
- package/dist/types/helpers/bgp.d.ts +97 -0
- package/dist/types/helpers/checks.d.ts +32 -0
- package/dist/types/helpers/exchange.d.ts +49 -0
- package/dist/types/helpers/filters.d.ts +51 -0
- package/dist/types/helpers/index.d.ts +10 -0
- package/dist/types/helpers/route-server.d.ts +52 -0
- package/dist/types/index.d.ts +5 -3
- package/dist/types/integrations/librenms.d.ts +60 -0
- package/dist/types/integrations/netbox.d.ts +89 -0
- package/dist/types/integrations/peeringdb.d.ts +24 -0
- package/dist/types/integrations/prometheus.d.ts +24 -0
- package/dist/types/integrations/registries.d.ts +8 -0
- package/dist/types/platforms/bgp-wait.d.ts +18 -0
- package/dist/types/platforms/catalog.d.ts +109 -0
- package/dist/types/platforms/derive.d.ts +17 -0
- package/dist/types/platforms/fortios/firewall.d.ts +45 -0
- package/dist/types/platforms/fortios/index.d.ts +134 -0
- package/dist/types/platforms/fortios/names.d.ts +52 -0
- package/dist/types/platforms/fortios/plan.d.ts +104 -0
- package/dist/types/platforms/fortios/render.d.ts +75 -0
- package/dist/types/platforms/fortios/routing.d.ts +56 -0
- package/dist/types/platforms/fortios/tree.d.ts +37 -0
- package/dist/types/platforms/index.d.ts +13 -0
- package/dist/types/platforms/junos/bgp.d.ts +44 -0
- package/dist/types/platforms/junos/index.d.ts +114 -0
- package/dist/types/platforms/junos/render.d.ts +154 -0
- package/dist/types/platforms/line-diff.d.ts +67 -0
- package/dist/types/platforms/platform.d.ts +120 -0
- package/dist/types/platforms/raisecom-ros/index.d.ts +71 -0
- package/dist/types/platforms/raisecom-ros/render.d.ts +41 -0
- package/dist/types/platforms/routeros/export.d.ts +59 -0
- package/dist/types/platforms/routeros/index.d.ts +96 -0
- package/dist/types/{adapters/devices → platforms}/routeros/models.d.ts +4 -0
- package/dist/types/platforms/routeros/plan.d.ts +54 -0
- package/dist/types/platforms/routeros/render.d.ts +240 -0
- package/dist/types/platforms/vrp/index.d.ts +83 -0
- package/dist/types/platforms/vrp/render.d.ts +96 -0
- package/dist/types/schema/common.d.ts +12 -6
- package/dist/types/schema/index.d.ts +1 -0
- package/dist/types/schema/interfaces.d.ts +14 -6
- package/dist/types/schema/policy.d.ts +18 -10
- package/dist/types/schema/routing.d.ts +14 -1
- package/dist/types/schema/system.d.ts +16 -6
- package/dist/types/schema/time-zones.d.ts +2 -0
- package/dist/types/transport/console.d.ts +79 -23
- package/dist/types/transport/path-check.d.ts +32 -0
- package/dist/types/transport/ssh.d.ts +17 -15
- package/package.json +17 -11
- package/src/cli/graph-format.ts +91 -0
- package/src/cli/index.ts +1074 -484
- package/src/cli/new.ts +208 -175
- package/src/cli/rename.ts +97 -91
- package/src/core/address-space.ts +320 -0
- package/src/core/address.ts +202 -0
- package/src/core/cabling.ts +154 -0
- package/src/core/cross-checks.ts +364 -0
- package/src/core/define.ts +26 -3
- package/src/core/project-files.ts +35 -0
- package/src/core/schema-docs.ts +138 -130
- package/src/core/secrets.ts +170 -76
- package/src/core/topology.ts +656 -0
- package/src/core/utils.ts +37 -0
- package/src/core/validator.ts +407 -0
- package/src/helpers/bgp.ts +301 -0
- package/src/helpers/checks.ts +178 -0
- package/src/helpers/exchange.ts +117 -0
- package/src/helpers/filters.ts +107 -0
- package/src/helpers/index.ts +12 -0
- package/src/helpers/route-server.ts +212 -0
- package/src/index.ts +6 -3
- package/src/integrations/librenms.ts +291 -0
- package/src/integrations/netbox.ts +620 -0
- package/src/integrations/peeringdb.ts +173 -0
- package/src/integrations/prometheus.ts +90 -0
- package/src/integrations/registries.ts +25 -0
- package/src/platforms/bgp-wait.ts +41 -0
- package/src/platforms/catalog.ts +69 -0
- package/src/platforms/derive.ts +117 -0
- package/src/{adapters/devices → platforms}/fortios/TRAPS.md +6 -0
- package/src/platforms/fortios/firewall.ts +377 -0
- package/src/platforms/fortios/index.ts +1060 -0
- package/src/platforms/fortios/names.ts +130 -0
- package/src/platforms/fortios/plan.ts +929 -0
- package/src/platforms/fortios/render.ts +779 -0
- package/src/platforms/fortios/routing.ts +476 -0
- package/src/platforms/fortios/tree.ts +129 -0
- package/src/platforms/index.ts +36 -0
- package/src/{adapters/devices → platforms}/junos/TRAPS.md +8 -0
- package/src/platforms/junos/bgp.ts +293 -0
- package/src/platforms/junos/index.ts +1006 -0
- package/src/platforms/junos/render.ts +967 -0
- package/src/platforms/line-diff.ts +244 -0
- package/src/platforms/platform.ts +122 -0
- package/src/platforms/raisecom-ros/TRAPS.md +40 -0
- package/src/platforms/raisecom-ros/index.ts +731 -0
- package/src/platforms/raisecom-ros/render.ts +328 -0
- package/src/{adapters/devices → platforms}/routeros/TRAPS.md +16 -2
- package/src/platforms/routeros/export.ts +342 -0
- package/src/platforms/routeros/index.ts +636 -0
- package/src/{adapters/devices → platforms}/routeros/models.ts +2 -0
- package/src/platforms/routeros/plan.ts +444 -0
- package/src/platforms/routeros/render.ts +1483 -0
- package/src/platforms/vrp/TRAPS.md +45 -0
- package/src/platforms/vrp/index.ts +652 -0
- package/src/platforms/vrp/render.ts +615 -0
- package/src/schema/common.ts +15 -6
- package/src/schema/index.ts +1 -0
- package/src/schema/interfaces.ts +15 -5
- package/src/schema/policy.ts +22 -10
- package/src/schema/routing.ts +16 -1
- package/src/schema/system.ts +16 -6
- package/src/schema/time-zones.ts +482 -0
- package/src/studio/data.ts +172 -168
- package/src/studio/git.ts +95 -90
- package/src/studio/public/app.js +161 -150
- package/src/studio/public/compare.js +219 -212
- package/src/studio/public/diff.js +77 -75
- package/src/studio/public/dom.js +118 -108
- package/src/studio/public/evaluate.js +584 -545
- package/src/studio/public/export.js +120 -118
- package/src/studio/public/graph.js +1343 -1266
- package/src/studio/public/net.js +115 -109
- package/src/studio/public/tables.js +1135 -1083
- package/src/studio/public/tools.js +561 -529
- package/src/studio/server.ts +202 -196
- package/src/transport/console.ts +363 -271
- package/src/transport/path-check.ts +91 -0
- package/src/transport/ssh.ts +98 -94
- package/templates/colocation/checks.ts +11 -6
- package/templates/colocation/edge-01.ts +9 -9
- package/templates/colocation/routing.ts +2 -2
- package/templates/colocation/tenancy.ts +3 -3
- package/templates/colocation/tor-01.ts +3 -3
- package/templates/edge-router/checks.ts +4 -4
- package/templates/edge-router/edge-01.ts +5 -5
- package/templates/edge-router/routing.ts +2 -2
- package/templates/project/AGENTS.md +5 -1
- package/templates/single-site/checks.ts +2 -2
- package/dist/chunks/index-47debt16.js +0 -956
- package/dist/chunks/index-v8zgr7wm.js +0 -128
- package/dist/chunks/new-ny89s7y7.js +0 -166
- package/dist/chunks/server-tjbb3rqc.js +0 -251
- package/dist/presets/index.js +0 -394
- package/dist/types/adapters/devices/catalog.d.ts +0 -86
- package/dist/types/adapters/devices/derive.d.ts +0 -7
- package/dist/types/adapters/devices/fortios/firewall.d.ts +0 -23
- package/dist/types/adapters/devices/fortios/index.d.ts +0 -58
- package/dist/types/adapters/devices/fortios/names.d.ts +0 -48
- package/dist/types/adapters/devices/fortios/plan.d.ts +0 -28
- package/dist/types/adapters/devices/fortios/render.d.ts +0 -38
- package/dist/types/adapters/devices/fortios/routing.d.ts +0 -32
- package/dist/types/adapters/devices/fortios/tree.d.ts +0 -33
- package/dist/types/adapters/devices/index.d.ts +0 -3
- package/dist/types/adapters/devices/junos/bgp.d.ts +0 -11
- package/dist/types/adapters/devices/junos/index.d.ts +0 -42
- package/dist/types/adapters/devices/junos/render.d.ts +0 -52
- package/dist/types/adapters/devices/lines.d.ts +0 -56
- package/dist/types/adapters/devices/raisecom-ros/index.d.ts +0 -28
- package/dist/types/adapters/devices/routeros/export.d.ts +0 -39
- package/dist/types/adapters/devices/routeros/index.d.ts +0 -30
- package/dist/types/adapters/devices/routeros/plan.d.ts +0 -4
- package/dist/types/adapters/devices/routeros/render.d.ts +0 -100
- package/dist/types/adapters/devices/types.d.ts +0 -40
- package/dist/types/adapters/devices/vrp/index.d.ts +0 -27
- package/dist/types/adapters/registries/index.d.ts +0 -3
- package/dist/types/adapters/registries/librenms.d.ts +0 -15
- package/dist/types/adapters/registries/peeringdb.d.ts +0 -16
- package/dist/types/adapters/registries/prometheus.d.ts +0 -16
- package/dist/types/core/across.d.ts +0 -4
- package/dist/types/core/addr.d.ts +0 -34
- package/dist/types/core/files.d.ts +0 -6
- package/dist/types/core/merge.d.ts +0 -4
- package/dist/types/core/time.d.ts +0 -2
- package/dist/types/core/validate.d.ts +0 -2
- package/dist/types/presets/index.d.ts +0 -183
- package/src/adapters/devices/catalog.ts +0 -41
- package/src/adapters/devices/derive.ts +0 -59
- package/src/adapters/devices/fortios/firewall.ts +0 -362
- package/src/adapters/devices/fortios/index.ts +0 -923
- package/src/adapters/devices/fortios/names.ts +0 -115
- package/src/adapters/devices/fortios/plan.ts +0 -888
- package/src/adapters/devices/fortios/render.ts +0 -768
- package/src/adapters/devices/fortios/routing.ts +0 -444
- package/src/adapters/devices/fortios/tree.ts +0 -121
- package/src/adapters/devices/index.ts +0 -9
- package/src/adapters/devices/junos/bgp.ts +0 -269
- package/src/adapters/devices/junos/index.ts +0 -862
- package/src/adapters/devices/junos/render.ts +0 -872
- package/src/adapters/devices/lines.ts +0 -212
- package/src/adapters/devices/raisecom-ros/TRAPS.md +0 -26
- package/src/adapters/devices/raisecom-ros/index.ts +0 -700
- package/src/adapters/devices/routeros/export.ts +0 -321
- package/src/adapters/devices/routeros/index.ts +0 -346
- package/src/adapters/devices/routeros/plan.ts +0 -398
- package/src/adapters/devices/routeros/render.ts +0 -1490
- package/src/adapters/devices/types.ts +0 -38
- package/src/adapters/devices/vrp/TRAPS.md +0 -26
- package/src/adapters/devices/vrp/index.ts +0 -865
- package/src/adapters/registries/index.ts +0 -19
- package/src/adapters/registries/librenms.ts +0 -92
- package/src/adapters/registries/peeringdb.ts +0 -169
- package/src/adapters/registries/prometheus.ts +0 -71
- package/src/cli/graph.ts +0 -89
- package/src/core/across.ts +0 -350
- package/src/core/addr.ts +0 -174
- package/src/core/addresses.ts +0 -316
- package/src/core/files.ts +0 -32
- package/src/core/graph.ts +0 -640
- package/src/core/merge.ts +0 -19
- package/src/core/time.ts +0 -15
- package/src/core/validate.ts +0 -348
- package/src/presets/index.ts +0 -579
- /package/dist/types/{adapters/devices → platforms}/fortios/models.d.ts +0 -0
- /package/dist/types/{adapters/devices → platforms}/junos/models.d.ts +0 -0
- /package/dist/types/{adapters/devices → platforms}/raisecom-ros/models.d.ts +0 -0
- /package/dist/types/{adapters/devices → platforms}/vrp/models.d.ts +0 -0
- /package/src/{adapters/devices → platforms}/fortios/models.ts +0 -0
- /package/src/{adapters/devices → platforms}/junos/models.ts +0 -0
- /package/src/{adapters/devices → platforms}/raisecom-ros/models.ts +0 -0
- /package/src/{adapters/devices → platforms}/vrp/models.ts +0 -0
package/src/transport/console.ts
CHANGED
|
@@ -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 {
|
|
7
|
+
import { Secrets } from "../core/secrets";
|
|
8
8
|
import type { Console, Device, Jump } from "../schema";
|
|
9
|
-
import {
|
|
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
|
-
/**
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
110
|
+
export class SerialConsole {
|
|
111
|
+
private static readonly RELAY_PATH = "/tmp/circuit-console.py";
|
|
96
112
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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
|
-
|
|
111
|
-
|
|
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
|
-
|
|
114
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
|
133
|
-
|
|
166
|
+
private static async openOnJump(console: Console, jump: Jump): Promise<ConsoleSession> {
|
|
167
|
+
const client = await SerialConsole.connectJump(jump);
|
|
134
168
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
140
|
-
|
|
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
|
-
|
|
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(
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
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
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
234
|
-
|
|
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
|
-
|
|
237
|
-
}
|
|
267
|
+
return { console, user, password };
|
|
268
|
+
}
|
|
238
269
|
|
|
239
|
-
/**
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
/**
|
|
258
|
-
|
|
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
|
-
|
|
261
|
-
|
|
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
|
-
|
|
267
|
-
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
277
|
-
|
|
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
|
-
|
|
281
|
-
|
|
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
|
-
|
|
284
|
-
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
|
|
293
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
331
|
-
|
|
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
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
375
|
+
function finish() {
|
|
376
|
+
clearTimeout(timer);
|
|
377
|
+
clearTimeout(limit);
|
|
378
|
+
channel.removeListener("data", onData);
|
|
379
|
+
done();
|
|
380
|
+
}
|
|
339
381
|
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
382
|
+
function onData() {
|
|
383
|
+
clearTimeout(timer);
|
|
384
|
+
timer = setTimeout(finish, silence);
|
|
385
|
+
}
|
|
344
386
|
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
|
|
352
|
-
|
|
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
|
-
|
|
355
|
-
|
|
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
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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
|
-
|
|
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
|
+
}
|