@takodotid/circuit 0.31.0 → 0.32.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/dist/cli/index.js CHANGED
@@ -425,7 +425,7 @@ function expect(channel, write, patterns, timeout = 30000) {
425
425
  channel.write(write);
426
426
  });
427
427
  }
428
- async function consoleLogin(channel, dialect, user, password) {
428
+ async function consoleLogin(channel, dialect, user, password, passwordOnly = false) {
429
429
  const others = dialect.others ?? [];
430
430
  const states = [dialect.login, dialect.password, ...others.map(([pattern]) => pattern), dialect.prompt];
431
431
  const toward = (index) => {
@@ -438,32 +438,38 @@ async function consoleLogin(channel, dialect, user, password) {
438
438
  `;
439
439
  };
440
440
  await quiet(channel, 1000);
441
+ const start = passwordOnly ? 1 : 0;
441
442
  let state = (await expect(channel, `
442
443
  `, states)).index;
443
- for (let step = 0;state !== 0; step++) {
444
+ for (let step = 0;state !== start; step++) {
445
+ if (passwordOnly && state === 0)
446
+ throw new Error("the console asks for a name, but its console is set to ask only for a password");
444
447
  if (step >= 10)
445
- throw new Error("the console never reached a login prompt");
448
+ throw new Error(`the console never reached a ${passwordOnly ? "password" : "login"} prompt`);
446
449
  state = (await expect(channel, toward(state), states)).index;
447
450
  }
448
451
  await quiet(channel, 1000);
449
- const name = dialect.user ? dialect.user(user) : user;
450
- if ((await expect(channel, `${name}
452
+ if (!passwordOnly) {
453
+ const name = dialect.user ? dialect.user(user) : user;
454
+ if ((await expect(channel, `${name}
451
455
  `, [dialect.password, dialect.login, dialect.prompt])).index !== 0) {
452
- throw new Error("the console did not ask for a password");
456
+ throw new Error("the console did not ask for a password");
457
+ }
453
458
  }
459
+ const refusal = dialect.refused ? [dialect.refused] : [];
460
+ const first = 2 + refusal.length;
461
+ const answers = [dialect.prompt, dialect.login, ...refusal, ...others.map(([pattern]) => pattern)];
454
462
  let answer = await expect(channel, `${password}
455
- `, [dialect.prompt, dialect.login, ...others.map(([pattern]) => pattern)], 60000);
456
- for (let step = 0;answer.index >= 2; step++) {
463
+ `, answers, 60000);
464
+ for (let step = 0;answer.index >= first; step++) {
457
465
  if (step >= 5)
458
466
  throw new Error("the console kept asking after the login");
459
- answer = await expect(channel, others[answer.index - 2][1], [
460
- dialect.prompt,
461
- dialect.login,
462
- ...others.map(([pattern]) => pattern)
463
- ]);
467
+ answer = await expect(channel, others[answer.index - first][1], answers);
464
468
  }
465
469
  if (answer.index === 1)
466
470
  throw new Error(`the console refused the login of ${user}`);
471
+ if (answer.index === 2 && dialect.refused)
472
+ throw new Error("the console refused the password");
467
473
  await quiet(channel, 800);
468
474
  }
469
475
  function quiet(channel, silence) {
@@ -1312,7 +1318,7 @@ function ruleGaps(device, where, rule) {
1312
1318
  const set = rule.set ?? {};
1313
1319
  if ((rule.match || rule.set) && !rule.action)
1314
1320
  found.push(`${where}, a rule without action: the platform stops at the first rule that matches`);
1315
- for (const field of ["as_path_length", "rpki", "large_community"]) {
1321
+ for (const field of ["as_path_length", "through_as", "rpki", "large_community"]) {
1316
1322
  if (match[field] !== undefined)
1317
1323
  found.push(`${where}.match.${field}`);
1318
1324
  }
@@ -2761,9 +2767,9 @@ async function consoleSession(device, work) {
2761
2767
  const { console: console2, user, password } = consoleOf(device);
2762
2768
  const opened = await openConsole(console2);
2763
2769
  try {
2764
- await consoleLogin(opened.channel, FORTIOS_CONSOLE, user, password);
2770
+ await consoleLogin(opened.channel, FORTIOS_CONSOLE, user, password, console2.passwordOnly);
2765
2771
  const cli = new Cli(opened.channel);
2766
- const relogin = () => consoleLogin(opened.channel, FORTIOS_CONSOLE, user, password);
2772
+ const relogin = () => consoleLogin(opened.channel, FORTIOS_CONSOLE, user, password, console2.passwordOnly);
2767
2773
  const result = await work(cli, relogin);
2768
2774
  await consoleLogout(cli);
2769
2775
  return result;
@@ -4345,6 +4351,11 @@ function term(out, device, term, rule, family, splitFamily) {
4345
4351
  out.set(`policy-options as-path ${name}`, quote2(`.{${min},${max ?? ""}}`));
4346
4352
  from.push(["as-path", name]);
4347
4353
  }
4354
+ if (match.through_as !== undefined) {
4355
+ const name = `circuit-through-${match.through_as}`;
4356
+ out.set(`policy-options as-path ${name}`, quote2(`.* ${match.through_as} .*`));
4357
+ from.push(["as-path", name]);
4358
+ }
4348
4359
  if (match.rpki)
4349
4360
  from.push(["validation-database", VALIDATION[match.rpki]]);
4350
4361
  const wanted = [
@@ -4657,7 +4668,7 @@ async function onConsole(device, work) {
4657
4668
  const { console: console2, user, password } = consoleOf(device);
4658
4669
  const opened = await openConsole(console2);
4659
4670
  const login = async () => {
4660
- await consoleLogin(opened.channel, JUNOS_CONSOLE, user, password);
4671
+ await consoleLogin(opened.channel, JUNOS_CONSOLE, user, password, console2.passwordOnly);
4661
4672
  const ready = openCli(opened.channel);
4662
4673
  opened.channel.write(`
4663
4674
  `);
@@ -5529,7 +5540,7 @@ async function consoleShell(device) {
5529
5540
  const { console: console2, user, password } = consoleOf(device);
5530
5541
  const opened = await openConsole(console2);
5531
5542
  try {
5532
- await consoleLogin(opened.channel, RAISECOM_CONSOLE, user, password);
5543
+ await consoleLogin(opened.channel, RAISECOM_CONSOLE, user, password, console2.passwordOnly);
5533
5544
  } catch (error) {
5534
5545
  opened.close();
5535
5546
  throw error;
@@ -5542,7 +5553,7 @@ async function consoleShell(device) {
5542
5553
  opened.close();
5543
5554
  },
5544
5555
  fresh: async () => {
5545
- await consoleLogin(opened.channel, RAISECOM_CONSOLE, user, password);
5556
+ await consoleLogin(opened.channel, RAISECOM_CONSOLE, user, password, console2.passwordOnly);
5546
5557
  await send(opened.channel, "terminal page-break disable", 15000, 1000);
5547
5558
  return session;
5548
5559
  }
@@ -7080,6 +7091,8 @@ function conditions(rule) {
7080
7091
  tests.push(`bgp-path-len>${match.as_path_length.min - 1}`);
7081
7092
  if (match.as_path_length?.max !== undefined)
7082
7093
  tests.push(`bgp-path-len<${match.as_path_length.max + 1}`);
7094
+ if (match.through_as !== undefined)
7095
+ tests.push(`bgp-as-path ${match.through_as}`);
7083
7096
  if (match.rpki)
7084
7097
  tests.push(`rpki ${match.rpki}`);
7085
7098
  if (match.community)
@@ -7478,7 +7491,7 @@ async function consoleSession2(device) {
7478
7491
  const { console: console2, user, password } = consoleOf(device);
7479
7492
  const opened = await openConsole(console2);
7480
7493
  try {
7481
- await consoleLogin(opened.channel, ROUTEROS_CONSOLE, user, password);
7494
+ await consoleLogin(opened.channel, ROUTEROS_CONSOLE, user, password, console2.passwordOnly);
7482
7495
  } catch (error) {
7483
7496
  opened.close();
7484
7497
  throw error;
@@ -7487,7 +7500,7 @@ async function consoleSession2(device) {
7487
7500
  const session = {
7488
7501
  run: (command, timeout) => shell.run(command, timeout),
7489
7502
  fresh: async () => {
7490
- await consoleLogin(opened.channel, ROUTEROS_CONSOLE, user, password);
7503
+ await consoleLogin(opened.channel, ROUTEROS_CONSOLE, user, password, console2.passwordOnly);
7491
7504
  return session;
7492
7505
  },
7493
7506
  end: () => {
@@ -7726,7 +7739,7 @@ var DEVICE_FACTS3 = [
7726
7739
  "domain default",
7727
7740
  "Info:"
7728
7741
  ];
7729
- var UNDO_BY_KEYWORD2 = /^(description|name|port default vlan|port link-type|port trunk pvid vlan|set authentication password|eth-trunk|ip binding vpn-instance)\b/;
7742
+ var UNDO_BY_KEYWORD2 = /^(description|name|port default vlan|port link-type|port trunk pvid vlan|set authentication password|eth-trunk|ip binding vpn-instance|ssh server cipher|ssh server hmac|ssh server key-exchange)\b/;
7730
7743
  function undo2(line) {
7731
7744
  if (line.startsWith("undo "))
7732
7745
  return line.slice("undo ".length);
@@ -8176,7 +8189,10 @@ var VRP_CONSOLE = {
8176
8189
  password: /Password:\s*$/i,
8177
8190
  prompt: /<[^>\n]+>\s*$/,
8178
8191
  logout: "quit",
8192
+ refused: /The password is invalid/i,
8179
8193
  others: [
8194
+ [/Please Press ENTER\.?\s*$/i, `
8195
+ `],
8180
8196
  [/Change now\? ?\[Y\/N\]:?\s*$/i, `N
8181
8197
  `],
8182
8198
  [/\[~?\*?[^\]\n]+\]\s*$/, `return
@@ -8187,7 +8203,7 @@ async function onConsole2(device, work) {
8187
8203
  const { console: console2, user, password } = consoleOf(device);
8188
8204
  const opened = await openConsole(console2);
8189
8205
  const login = async () => {
8190
- await consoleLogin(opened.channel, VRP_CONSOLE, user, password);
8206
+ await consoleLogin(opened.channel, VRP_CONSOLE, user, password, console2.passwordOnly);
8191
8207
  await send(opened.channel, "screen-length 0 temporary", 1e4, 1500);
8192
8208
  };
8193
8209
  try {
@@ -18,7 +18,7 @@ export declare function login(device: Device): Promise<Client>;
18
18
  * Every login asks whether to change the password. Answering yes replaces it at once and every stored credential with it, so the answer is always no. Anything sent before the question is answered is taken as the answer.
19
19
  */
20
20
  export declare function openShell(client: Client): Promise<ClientChannel>;
21
- /** How the console asks for a login: the same question to change the password, always answered no, and a system view left with `return`. Not yet proved on a device. */
21
+ /** How the console asks for a login: the same question to change the password, always answered no, a system view left with `return`, and the line's banner after a logout or a refused password answered with Enter. Not yet proved on a device. */
22
22
  export declare const VRP_CONSOLE: ConsoleDialect;
23
23
  /** What the device runs, credentials scrubbed. */
24
24
  export declare function read(device: Device, _network?: readonly Device[], options?: ReadOptions): Promise<string>;
@@ -36,6 +36,8 @@ export type Console = {
36
36
  port: string;
37
37
  /** Line speed in bits per second, such as 9600 or 115200. */
38
38
  baud: number;
39
+ /** The console asks only for a password, with no name first. Circuit then types no name; `connection.user` still picks the password. */
40
+ passwordOnly?: true;
39
41
  };
40
42
  /** A separate routing table, with the interfaces that route in it. */
41
43
  export type Vrf = {
@@ -33,6 +33,8 @@ export type Match<S extends string> = {
33
33
  min?: number;
34
34
  max?: number;
35
35
  };
36
+ /** The AS path holds this AS anywhere: the route came from it or through it. */
37
+ through_as?: number;
36
38
  /** The route's origin validation state. */
37
39
  rpki?: "valid" | "invalid" | "not-found";
38
40
  /** Has this standard community, `asn:value`. */
@@ -27,6 +27,8 @@ export type ConsoleDialect = {
27
27
  others?: readonly (readonly [RegExp, string])[];
28
28
  /** The name typed at the login prompt, when the platform adds to it. */
29
29
  user?: (user: string) => string;
30
+ /** What the console prints when it refuses a password, for a console that then shows no login prompt. */
31
+ refused?: RegExp;
30
32
  };
31
33
  /** The channel a console speaks on: an SSH stream from the relay, or anything shaped like one. */
32
34
  type Channel = Pick<ClientChannel, "write"> & {
@@ -39,9 +41,9 @@ export declare function expect(channel: Channel, write: string, patterns: readon
39
41
  text: string;
40
42
  }>;
41
43
  /**
42
- * 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.
44
+ * 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.
43
45
  */
44
- export declare function consoleLogin(channel: Channel, dialect: ConsoleDialect, user: string, password: string): Promise<void>;
46
+ export declare function consoleLogin(channel: Channel, dialect: ConsoleDialect, user: string, password: string, passwordOnly?: boolean): Promise<void>;
45
47
  /** 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. */
46
48
  export declare function afterEcho(text: string, command: string): string;
47
49
  /** 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. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takodotid/circuit",
3
- "version": "0.31.0",
3
+ "version": "0.32.0",
4
4
  "description": "Declarative, vendor-neutral network configuration: describe each device in TypeScript, and Circuit makes the device match. Alpha.",
5
5
  "keywords": [
6
6
  "network",
@@ -173,9 +173,9 @@ async function consoleSession<T>(device: Device, work: (cli: Cli, relogin: () =>
173
173
  const opened = await openConsole(console);
174
174
 
175
175
  try {
176
- await consoleLogin(opened.channel, FORTIOS_CONSOLE, user, password);
176
+ await consoleLogin(opened.channel, FORTIOS_CONSOLE, user, password, console.passwordOnly);
177
177
  const cli = new Cli(opened.channel);
178
- const relogin = () => consoleLogin(opened.channel, FORTIOS_CONSOLE, user, password);
178
+ const relogin = () => consoleLogin(opened.channel, FORTIOS_CONSOLE, user, password, console.passwordOnly);
179
179
 
180
180
  const result = await work(cli, relogin);
181
181
  await consoleLogout(cli);
@@ -418,7 +418,7 @@ function ruleGaps(device: Device, where: string, rule: AnyRule): string[] {
418
418
  if ((rule.match || rule.set) && !rule.action)
419
419
  found.push(`${where}, a rule without action: the platform stops at the first rule that matches`);
420
420
 
421
- for (const field of ["as_path_length", "rpki", "large_community"] as const) {
421
+ for (const field of ["as_path_length", "through_as", "rpki", "large_community"] as const) {
422
422
  if (match[field] !== undefined) found.push(`${where}.match.${field}`);
423
423
  }
424
424
 
@@ -210,6 +210,12 @@ function term(out: Statements, device: Device, term: string, rule: AnyRule, fami
210
210
  from.push(["as-path", name]);
211
211
  }
212
212
 
213
+ if (match.through_as !== undefined) {
214
+ const name = `circuit-through-${match.through_as}`;
215
+ out.set(`policy-options as-path ${name}`, quote(`.* ${match.through_as} .*`));
216
+ from.push(["as-path", name]);
217
+ }
218
+
213
219
  if (match.rpki) from.push(["validation-database", VALIDATION[match.rpki]]);
214
220
 
215
221
  // Members of one named community must all be present, so a rule matching both kinds names them together.
@@ -375,7 +375,7 @@ async function onConsole<T>(device: Device, work: (channel: ClientChannel, relog
375
375
 
376
376
  // The CLI is opened on a prompt asked for after the login, since the login's own prompt is already read.
377
377
  const login = async () => {
378
- await consoleLogin(opened.channel, JUNOS_CONSOLE, user, password);
378
+ await consoleLogin(opened.channel, JUNOS_CONSOLE, user, password, console.passwordOnly);
379
379
  const ready = openCli(opened.channel);
380
380
  opened.channel.write("\n");
381
381
  await ready;
@@ -371,7 +371,7 @@ async function consoleShell(device: Device): Promise<Session> {
371
371
  const opened = await openConsole(console);
372
372
 
373
373
  try {
374
- await consoleLogin(opened.channel, RAISECOM_CONSOLE, user, password);
374
+ await consoleLogin(opened.channel, RAISECOM_CONSOLE, user, password, console.passwordOnly);
375
375
  } catch (error) {
376
376
  opened.close();
377
377
  throw error;
@@ -384,7 +384,7 @@ async function consoleShell(device: Device): Promise<Session> {
384
384
  opened.close();
385
385
  },
386
386
  fresh: async () => {
387
- await consoleLogin(opened.channel, RAISECOM_CONSOLE, user, password);
387
+ await consoleLogin(opened.channel, RAISECOM_CONSOLE, user, password, console.passwordOnly);
388
388
  await send(opened.channel, "terminal page-break disable", 15_000, 1000);
389
389
  return session;
390
390
  },
@@ -68,7 +68,7 @@ async function consoleSession(device: Device): Promise<Session> {
68
68
  const opened = await openConsole(console);
69
69
 
70
70
  try {
71
- await consoleLogin(opened.channel, ROUTEROS_CONSOLE, user, password);
71
+ await consoleLogin(opened.channel, ROUTEROS_CONSOLE, user, password, console.passwordOnly);
72
72
  } catch (error) {
73
73
  opened.close();
74
74
  throw error;
@@ -79,7 +79,7 @@ async function consoleSession(device: Device): Promise<Session> {
79
79
  const session: Session = {
80
80
  run: (command, timeout) => shell.run(command, timeout),
81
81
  fresh: async () => {
82
- await consoleLogin(opened.channel, ROUTEROS_CONSOLE, user, password);
82
+ await consoleLogin(opened.channel, ROUTEROS_CONSOLE, user, password, console.passwordOnly);
83
83
  return session;
84
84
  },
85
85
  end: () => {
@@ -1033,6 +1033,8 @@ function conditions(rule: AnyRule): string[] {
1033
1033
  if (match.prefix_length?.max !== undefined) tests.push(`dst-len<${match.prefix_length.max + 1}`);
1034
1034
  if (match.as_path_length?.min !== undefined) tests.push(`bgp-path-len>${match.as_path_length.min - 1}`);
1035
1035
  if (match.as_path_length?.max !== undefined) tests.push(`bgp-path-len<${match.as_path_length.max + 1}`);
1036
+ // The platform's AS path regexp matches each AS as a whole number, so a bare number is that AS anywhere in the path.
1037
+ if (match.through_as !== undefined) tests.push(`bgp-as-path ${match.through_as}`);
1036
1038
  if (match.rpki) tests.push(`rpki ${match.rpki}`);
1037
1039
  if (match.community) tests.push(`bgp-communities includes ${match.community}`);
1038
1040
  if (match.large_community) tests.push(`bgp-large-communities includes ${match.large_community}`);
@@ -21,3 +21,5 @@ Behaviour that looks like something else. Each one has been hit on a CE6855 runn
21
21
  - sFlow samples per port, `sflow sampling rate N`, N from 4096; the default is 8192 and an explicit 8192 is kept as written. A port can point to a collector only once the collector exists globally; until then it is refused with "collector does not exist".
22
22
  - `vlan batch` has every VLAN on one line, and undoing a list that includes a VLAN with an L3 interface is refused. A change sends only the VLANs added and `undo vlan batch` for the ones removed.
23
23
  - A VLAN name is unique across VLANs: a new VLAN cannot take a name another still has. VLANs no longer used, and their VLAN interfaces, are removed first, in the same candidate.
24
+ - `undo ssh server key-exchange <algorithms>` is refused with "Too many parameters"; `undo ssh server key-exchange` takes none. `hmac` was proved the same way. `cipher` is undone the same way but was not proved on its own.
25
+ - A console on a password only (`passwordOnly`) shows `Please Press ENTER.` after a logout or a refused password, and `Error: The password is invalid.` for a wrong one.
@@ -37,7 +37,7 @@ const DEVICE_FACTS = [
37
37
 
38
38
  /** Lines whose removal takes only their keyword, not their value. */
39
39
  const UNDO_BY_KEYWORD =
40
- /^(description|name|port default vlan|port link-type|port trunk pvid vlan|set authentication password|eth-trunk|ip binding vpn-instance)\b/;
40
+ /^(description|name|port default vlan|port link-type|port trunk pvid vlan|set authentication password|eth-trunk|ip binding vpn-instance|ssh server cipher|ssh server hmac|ssh server key-exchange)\b/;
41
41
 
42
42
  function undo(line: string): string {
43
43
  // A negated line is undone by stating it.
@@ -571,13 +571,15 @@ export async function openShell(client: Client): Promise<ClientChannel> {
571
571
  return channel;
572
572
  }
573
573
 
574
- /** How the console asks for a login: the same question to change the password, always answered no, and a system view left with `return`. Not yet proved on a device. */
574
+ /** How the console asks for a login: the same question to change the password, always answered no, a system view left with `return`, and the line's banner after a logout or a refused password answered with Enter. Not yet proved on a device. */
575
575
  export const VRP_CONSOLE: ConsoleDialect = {
576
576
  login: /Username:\s*$/i,
577
577
  password: /Password:\s*$/i,
578
578
  prompt: /<[^>\n]+>\s*$/,
579
579
  logout: "quit",
580
+ refused: /The password is invalid/i,
580
581
  others: [
582
+ [/Please Press ENTER\.?\s*$/i, "\n"],
581
583
  [/Change now\? ?\[Y\/N\]:?\s*$/i, "N\n"],
582
584
  [/\[~?\*?[^\]\n]+\]\s*$/, "return\n"],
583
585
  ],
@@ -589,7 +591,7 @@ async function onConsole<T>(device: Device, work: (channel: ClientChannel, relog
589
591
  const opened = await openConsole(console);
590
592
 
591
593
  const login = async () => {
592
- await consoleLogin(opened.channel, VRP_CONSOLE, user, password);
594
+ await consoleLogin(opened.channel, VRP_CONSOLE, user, password, console.passwordOnly);
593
595
  await send(opened.channel, "screen-length 0 temporary", 10_000, 1500);
594
596
  };
595
597
 
@@ -39,6 +39,8 @@ export type Console = {
39
39
  port: string;
40
40
  /** Line speed in bits per second, such as 9600 or 115200. */
41
41
  baud: number;
42
+ /** The console asks only for a password, with no name first. Circuit then types no name; `connection.user` still picks the password. */
43
+ passwordOnly?: true;
42
44
  };
43
45
 
44
46
  /** A separate routing table, with the interfaces that route in it. */
@@ -32,6 +32,8 @@ export type Match<S extends string> = {
32
32
  prefix_length?: { min?: number; max?: number };
33
33
  /** AS path length within this range, inclusive. Every AS counts, prepends included. */
34
34
  as_path_length?: { min?: number; max?: number };
35
+ /** The AS path holds this AS anywhere: the route came from it or through it. */
36
+ through_as?: number;
35
37
  /** The route's origin validation state. */
36
38
  rpki?: "valid" | "invalid" | "not-found";
37
39
  /** Has this standard community, `asn:value`. */
@@ -250,6 +250,8 @@ export type ConsoleDialect = {
250
250
  others?: readonly (readonly [RegExp, string])[];
251
251
  /** The name typed at the login prompt, when the platform adds to it. */
252
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;
253
255
  };
254
256
 
255
257
  /** Terminal escape sequences: cursor moves, screen clears, character sets and modes. */
@@ -293,9 +295,15 @@ export function expect(channel: Channel, write: string, patterns: readonly RegEx
293
295
  }
294
296
 
295
297
  /**
296
- * 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.
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.
297
299
  */
298
- export async function consoleLogin(channel: Channel, dialect: ConsoleDialect, user: string, password: string): Promise<void> {
300
+ export async function consoleLogin(
301
+ channel: Channel,
302
+ dialect: ConsoleDialect,
303
+ user: string,
304
+ password: string,
305
+ passwordOnly = false
306
+ ): Promise<void> {
299
307
  const others = dialect.others ?? [];
300
308
  const states = [dialect.login, dialect.password, ...others.map(([pattern]) => pattern), dialect.prompt];
301
309
 
@@ -309,32 +317,39 @@ export async function consoleLogin(channel: Channel, dialect: ConsoleDialect, us
309
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.
310
318
  await quiet(channel, 1_000);
311
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
+
312
323
  let state = (await expect(channel, "\n", states)).index;
313
- for (let step = 0; state !== 0; step++) {
314
- if (step >= 10) throw new Error("the console never reached a login prompt");
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`);
315
327
  state = (await expect(channel, toward(state), states)).index;
316
328
  }
317
329
 
318
330
  // A console can print the login prompt more than once; a name typed while it does is lost.
319
331
  await quiet(channel, 1_000);
320
332
 
321
- const name = dialect.user ? dialect.user(user) : user;
322
- if ((await expect(channel, `${name}\n`, [dialect.password, dialect.login, dialect.prompt])).index !== 0) {
323
- throw new Error("the console did not ask for a password");
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
+ }
324
338
  }
325
339
 
326
- // A platform may ask something once logged in, such as whether to read a licence; `others` answers it.
327
- let answer = await expect(channel, `${password}\n`, [dialect.prompt, dialect.login, ...others.map(([pattern]) => pattern)], 60_000);
328
- for (let step = 0; answer.index >= 2; step++) {
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)];
344
+
345
+ let answer = await expect(channel, `${password}\n`, answers, 60_000);
346
+ for (let step = 0; answer.index >= first; step++) {
329
347
  if (step >= 5) throw new Error("the console kept asking after the login");
330
- answer = await expect(channel, others[answer.index - 2]![1], [
331
- dialect.prompt,
332
- dialect.login,
333
- ...others.map(([pattern]) => pattern),
334
- ]);
348
+ answer = await expect(channel, others[answer.index - first]![1], answers);
335
349
  }
336
350
 
337
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");
338
353
 
339
354
  // Some consoles print the prompt twice after a login; what arrives late must not reach the first command.
340
355
  await quiet(channel, 800);